diff --git a/CLAUDE.md b/CLAUDE.md index e2a2783..a733b11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,9 +110,11 @@ Terminal B ──HTTP──▶ (same bridge) ### Streaming (Opera AI tools) -`opera_do`, `opera_chat`, `opera_make`, `opera_research` are streamed: the bridge -keeps the HTTP response open and flushes `{"log": "..."}` lines as MCP -`notifications/message` arrive, then ends the response with `{"result": "..."}`. +`opera_do`, `opera_chat`, `opera_make`, `opera_research`, `opera_call_mcp_tool` +are streamed: the bridge keeps the HTTP response open and flushes `{"log": "..."}` +lines as MCP `notifications/message` arrive, then ends the response with +`{"result": "..."}`. `opera_call_mcp_tool` streams when the underlying MCP tool +emits progress events. The client (`client.ts → httpPost`) reads those lines and calls `onLog(msg)` which writes to stderr so the user sees progress in real time. diff --git a/README.md b/README.md index 0f43139..ddeb71f 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,14 @@ opera-browser-cli eval "(() => { const rows = [...document.querySelectorAll('tr' | `perf-insight ` | Analyze a performance insight | | `heap ` | Capture a heap snapshot | +### MCP Hub + +| Command | Description | Requires | +|-----------------------------------------------|-------------------------------------------------------|------------------| +| `mcp-servers` | List MCP servers registered in the browser | Opera Neon | +| `mcp-tools --server ` | List tools exposed by a specific MCP server | Opera Neon | +| `mcp-call --server --tool ` | Execute a tool on an MCP server | Opera Neon | + ### Opera AI | Command | Description | Requires | diff --git a/SKILL.md b/SKILL.md index b7aa104..925e314 100644 --- a/SKILL.md +++ b/SKILL.md @@ -12,6 +12,9 @@ metadata: {"openclaw": {"requires": {"bins": ["opera-browser-cli"]}}} - **`chat`** — available on any Opera browser. Use `--model ` to select an AI model. - **`models`** — list available AI models for chat (shows IDs and which is the default). - **`invoke-do`, `make`, `research`** — require **Opera Neon** with an active sign-in. +- **`mcp-servers`** — list MCP servers registered in the browser (requires Opera Neon). +- **`mcp-tools --server `** — list tools exposed by a specific MCP server (requires Opera Neon). +- **`mcp-call --server --tool [--params '{...}']`** — execute a tool on an MCP server (requires Opera Neon). Run `opera-browser-cli --help` for the full command list, or `opera-browser-cli --help` for per-command flags and examples. diff --git a/src/bridge.ts b/src/bridge.ts index 7c55689..a76401b 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -50,6 +50,7 @@ const OPERA_AI_TOOLS = new Set([ "opera_do", "opera_research", "opera_make", + "opera_call_mcp_tool", ]); export interface LastSnapshotCache { diff --git a/src/cli.ts b/src/cli.ts index 66f0020..b363bfe 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -98,7 +98,7 @@ export type MainOptions = { }; export const TOP_HELP = `usage: opera-browser-cli [command] [args] [flags] -commands[46]: +commands[49]: open , snapshot, screenshot , click @, fill @ , type , press , scroll , back, wait , eval , run, @@ -110,6 +110,7 @@ commands[46]: attach, launch-args, login, chat [--model ] , invoke-do , make , research , models, + mcp-servers, mcp-tools --server , mcp-call --server --tool , setup, logs, doctor exit codes: @@ -141,7 +142,7 @@ environment: opera ai: chat is available on any Opera browser. Use --model to select a model. Run "models" to list available models. - invoke-do, make, and research require Opera Neon with an active sign-in. + invoke-do, make, research, mcp-servers, mcp-tools, and mcp-call require Opera Neon with an active sign-in. Run \`opera-browser-cli setup\` to configure the executable path, or set OPERA_CLI_EXECUTABLE_PATH="/Applications/Opera Neon.app/Contents/MacOS/Opera". @@ -724,6 +725,37 @@ examples: opera-browser-cli logs opera-browser-cli logs --lines 200`, + // MCP Hub + "mcp-servers": `usage: opera-browser-cli mcp-servers +List MCP servers registered in the browser. +Shows connection status and transport info for each server. +Requires Opera Neon. + +examples: + opera-browser-cli mcp-servers`, + + "mcp-tools": `usage: opera-browser-cli mcp-tools --server +List tools exposed by a specific MCP server. +Requires Opera Neon. + +flags: + --server MCP server name (from mcp-servers) + +examples: + opera-browser-cli mcp-tools --server my-server`, + + "mcp-call": `usage: opera-browser-cli mcp-call --server --tool [--params '{...}'] +Execute a tool on a specific MCP server. +Requires Opera Neon. + +flags: + --server MCP server name (from mcp-servers) + --tool Tool name to execute + --params '{...}' JSON parameters to pass to the tool + +examples: + opera-browser-cli mcp-call --server my-server --tool echo --params '{"text":"hello"}'`, + doctor: `usage: opera-browser-cli doctor [--fix] Diagnose opera-browser-cli configuration: bridge status, config file, Opera Neon executable, MCP server, browser profile, session hooks, and log file. Each check @@ -1045,6 +1077,9 @@ export const EXIT_CODES: Record = { TIMEOUT: 5, REF_NOT_FOUND: 6, PAGE_CLOSED: 6, + EXTENSION_NOT_FOUND: 3, + NOT_FOUND: 2, + SERVER_DISCONNECTED: 3, UNKNOWN: 1, }; @@ -3141,6 +3176,182 @@ async function handleModels(): Promise { return lines.join("\n"); } +// --- MCP Hub handlers --- + +function parseServerArg(args: string[]): { server?: string } { + const serverIdx = args.indexOf("--server"); + if (serverIdx === -1 || serverIdx + 1 >= args.length) return {}; + return { server: args[serverIdx + 1] }; +} + +function parseMcpCallArgs(args: string[]): { + server?: string; + tool?: string; + params?: string; +} { + let server: string | undefined; + let tool: string | undefined; + let params: string | undefined; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--server" && i + 1 < args.length) server = args[++i]; + else if (args[i] === "--tool" && i + 1 < args.length) tool = args[++i]; + else if (args[i] === "--params" && i + 1 < args.length) params = args[++i]; + } + return { server, tool, params }; +} + +/** + * Check if the raw result from a tool call is an error message from the tool handler + * (e.g., "Opera.dispatchAction(...) failed with error: ...") and throw a meaningful + * CdpError if so. Returns the parsed data if the result is valid JSON. + */ +function parseMcpResultOrThrow(result: string, label: string): T { + if (!result || !result.trim()) { + throw new CdpError( + `${label} is not available on this browser version.`, + "UNSUPPORTED_OPERATION", + [ + 'Run `opera-browser-cli doctor` to check the connection', + 'Ensure you are using Opera Neon with MCP Hub support', + ], + ); + } + // CDP error codes (consent, subscription, sign-in, etc.) mean the + // feature is not available on this browser — treat as unsupported. + if (/\[OPERA_CDP_ERR:/.test(result)) { + throw new CdpError( + `${label} is not available on this browser version.`, + "UNSUPPORTED_OPERATION", + [ + 'Run `opera-browser-cli doctor` to check the connection', + 'Ensure you are using Opera Neon with MCP Hub support', + ], + ); + } + try { + return JSON.parse(result) as T; + } catch { + throw new CdpError( + result || `${label} returned an invalid response`, + "UNKNOWN", + ['Run `opera-browser-cli doctor` to check the connection'], + ); + } +} + +function formatMcpToolResult(resultJson: string): string { + if (!resultJson || !resultJson.trim()) { + return "(empty result \u2014 the MCP tool may not be available on this browser version)"; + } + let result: { content?: Array<{ type: string; text?: string }>; isError?: boolean }; + try { + result = JSON.parse(resultJson); + } catch { + return resultJson; + } + const lines: string[] = []; + if (result.content && result.content.length > 0) { + for (const block of result.content) { + if (block.type === "text") { + lines.push(block.text ?? ""); + } else { + lines.push(JSON.stringify(block)); + } + } + } + if (result.isError) { + if (lines.length === 0) { + lines.push("Error: (no details provided)"); + } else { + lines.unshift("Error:"); + } + } + return lines.length > 0 ? lines.join("\n") : "(empty result)"; +} + +async function handleMcpServers(args: string[]): Promise { + const result = await callTool("opera_list_mcp_servers", {}); + const data = parseMcpResultOrThrow<{ servers?: Array<{ name: string; connection: { type: string }; transportInfo: { type: string; url?: string; extensionId?: string } }> }>(result, "MCP servers"); + + const servers = data.servers ?? []; + if (servers.length === 0) { + return "No MCP servers registered. Load the MCP extension in opera://extensions and register a server."; + } + + const lines: string[] = ["MCP Servers:"]; + for (const server of servers) { + const status = server.connection?.type ?? "unknown"; + const transport = server.transportInfo; + const location = transport?.url ?? transport?.extensionId ?? "\u2014"; + lines.push(` ${server.name} (${status})`); + lines.push(` transport: ${transport?.type ?? "?"} \u2014 ${location}`); + } + lines.push(""); + lines.push("Run 'opera-browser-cli mcp-tools --server ' to list tools for one server."); + return lines.join("\n"); +} + +async function handleMcpTools(args: string[]): Promise { + const parsed = parseServerArg(args); + if (!parsed.server) { + throw new CdpError("Missing --server", "VALIDATION_ERROR", [ + "Run 'opera-browser-cli mcp-servers' to see available servers.", + ]); + } + const result = await callTool("opera_list_mcp_tools", { server: parsed.server }); + const data = parseMcpResultOrThrow<{ tools?: Array<{ tool: { name: string; description?: string } }> }>(result, "MCP tools"); + const tools = data.tools ?? []; + if (tools.length === 0) { + return `No tools reported for '${parsed.server}'. The server may be disconnected.`; + } + const lines: string[] = [`Tools from ${parsed.server}:`]; + for (const t of tools) { + const name = t.tool?.name ?? "(unnamed)"; + lines.push(` ${name.padEnd(20)} \u2014 ${t.tool?.description ?? ""}`); + } + return lines.join("\n"); +} + +async function handleMcpCall(args: string[]): Promise { + const parsed = parseMcpCallArgs(args); + if (!parsed.server || !parsed.tool) { + throw new CdpError("Missing --server or --tool", "VALIDATION_ERROR", [ + "Usage: opera-browser-cli mcp-call --server --tool --params '{...}'", + ]); + } + let params: Record = {}; + if (parsed.params) { + try { + params = JSON.parse(parsed.params); + } catch { + throw new CdpError( + `--params must be valid JSON. Got: ${parsed.params}`, + "VALIDATION_ERROR", + ["Example: --params '{\"key\":\"value\"}'"], + ); + } + } + const callArgs: Record = { + server: parsed.server, + tool: parsed.tool, + }; + if (parsed.params) { + callArgs['parameters'] = params; + } + const result = await callTool("opera_call_mcp_tool", callArgs); + if (!result || !result.trim() || /\[OPERA_CDP_ERR:/.test(result)) { + throw new CdpError( + "MCP tool execution is not available on this browser version.", + "UNSUPPORTED_OPERATION", + [ + 'Run `opera-browser-cli doctor` to check the connection', + 'Ensure you are using Opera Neon with MCP Hub support', + ], + ); + } + return formatMcpToolResult(result); +} + async function handleRun(): Promise { if (process.stdin.isTTY) { throw new CdpError("No script provided on stdin", "VALIDATION_ERROR", [ @@ -3293,6 +3504,9 @@ const COMMANDS: Record = { make: withoutFullFlag(handleMake), research: withoutFullFlag(handleResearch), models: withoutFullFlag(handleModels), + "mcp-servers": withoutFullFlag(handleMcpServers), + "mcp-tools": withoutFullFlag(handleMcpTools), + "mcp-call": withoutFullFlag(handleMcpCall), setup: withoutFullFlag(handleSetup), logs: withoutFullFlag(handleLogs), doctor: withoutFullFlag(handleDoctor), diff --git a/src/client.ts b/src/client.ts index 178a73f..d420cc5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -121,6 +121,9 @@ export type ErrorCode = | "AUTH_REQUIRED" | "VALIDATION_ERROR" | "UNSUPPORTED_OPERATION" + | "EXTENSION_NOT_FOUND" + | "NOT_FOUND" + | "SERVER_DISCONNECTED" | "UNKNOWN"; export class CdpError extends AxiError { @@ -884,6 +887,7 @@ const OPERA_AI_TOOLS = new Set([ "opera_do", "opera_research", "opera_make", + "opera_call_mcp_tool", ]); /** @@ -1102,6 +1106,34 @@ export function mapErrorMessage(message: string): CdpError { "Run `opera-browser-cli doctor` to inspect the current configuration", ]); } + if (message.includes("MCP Hub extension not available")) { + return new CdpError( + "MCP Hub extension not loaded. Load it in opera://extensions.", + "EXTENSION_NOT_FOUND", + ["Visit opera://extensions", "Load the MCP Hub extension (unpacked)"], + ); + } + + // MCP-specific errors: guarded by MCP context to avoid false matches on generic server/not found. + // The extension surfaces these as raw strings from thrown hub errors. + if ( + (message.includes("MCP") || message.includes("opera_list_mcp")) && + message.includes("not found") + ) { + return new CdpError(message, "NOT_FOUND", [ + "Run 'opera-browser-cli mcp-servers' to see available servers.", + ]); + } + + if ( + (message.includes("MCP") || message.includes("opera_list_mcp")) && + message.includes("not connected") + ) { + return new CdpError(message, "SERVER_DISCONNECTED", [ + "Connect the server in the MCP Hub sidepanel.", + ]); + } + // Try to parse JSON error try { const parsed = JSON.parse(message); diff --git a/test/exit-codes.test.ts b/test/exit-codes.test.ts index 519e857..35d39b5 100644 --- a/test/exit-codes.test.ts +++ b/test/exit-codes.test.ts @@ -29,6 +29,9 @@ describe("exit codes", () => { TIMEOUT: 5, REF_NOT_FOUND: 6, PAGE_CLOSED: 6, + EXTENSION_NOT_FOUND: 3, + NOT_FOUND: 2, + SERVER_DISCONNECTED: 3, UNKNOWN: 1, };