From 3dce08812a49368e7d06c9348bee38b0f7fbedc9 Mon Sep 17 00:00:00 2001 From: zonemeen <994718917@qq.com> Date: Tue, 15 Sep 2026 15:56:23 +0800 Subject: [PATCH] feat(mcp): add tools command --- packages/opencode/src/cli/cmd/mcp.ts | 69 +++++- packages/opencode/src/mcp/index.ts | 7 + .../__snapshots__/help-snapshots.test.ts.snap | 17 ++ .../test/cli/help/help-snapshots.test.ts | 1 + packages/opencode/test/cli/mcp-tools.test.ts | 218 ++++++++++++++++++ packages/opencode/test/session/prompt.test.ts | 1 + .../test/session/snapshot-tool-race.test.ts | 1 + packages/web/src/content/docs/cli.mdx | 20 ++ 8 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/cli/mcp-tools.test.ts diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c2d2ee2f3b73..e83f533b6e20 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -1,6 +1,6 @@ import { cmd } from "./cmd" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" -import { effectCmd } from "../effect-cmd" +import { effectCmd, fail } from "../effect-cmd" import { Cause } from "effect" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" @@ -99,6 +99,7 @@ export const McpCommand = cmd({ yargs .command(McpAddCommand) .command(McpListCommand) + .command(McpToolsCommand) .command(McpAuthCommand) .command(McpLogoutCommand) .command(McpDebugCommand) @@ -167,6 +168,72 @@ export const McpListCommand = effectCmd({ }), }) +export const McpToolsCommand = effectCmd({ + command: "tools [name]", + describe: "list tools exposed by MCP servers", + builder: (yargs) => + yargs.positional("name", { + describe: "name of the MCP server (shows tool descriptions when specified)", + type: "string", + }), + handler: Effect.fn("Cli.mcp.tools")(function* (args) { + const cfg = yield* Config.Service + const config = yield* cfg.get() + const servers = configuredServers(config) + .filter(([name]) => args.name === undefined || name === args.name) + .toSorted(([a], [b]) => a.localeCompare(b)) + + if (args.name !== undefined && servers.length === 0) + return yield* fail(`MCP server "${args.name}" not found. Run "opencode mcp list" to list configured servers.`) + + UI.empty() + prompts.intro("MCP Tools") + if (servers.length === 0) { + prompts.log.warn("No MCP servers configured") + prompts.outro("Add servers with: opencode mcp add") + return + } + + const mcp = yield* MCP.Service + const statuses = yield* mcp.status() + const results = yield* Effect.forEach(servers, ([name]) => + Effect.gen(function* () { + const status = statuses[name] + if (status?.status === "connected") { + const tools = (yield* mcp.toolDefinitions(name)).toSorted((a, b) => a.name.localeCompare(b.name)) + prompts.log.info( + `${name} · ${tools.length} tool(s)` + + (tools.length === 0 ? "\n No tools exposed" : "") + + tools + .map((tool) => { + const description = args.name === undefined ? "" : tool.description?.trim().replace(/\s+/g, " ") + return `\n ${tool.name}${description ? `\n ${UI.Style.TEXT_DIM}${description}${UI.Style.TEXT_NORMAL}` : ""}` + }) + .join(""), + ) + return true + } + if (status?.status === "disabled") { + prompts.log.warn(`${name} · disabled`) + return args.name === undefined + } + if (status?.status === "needs_auth") { + prompts.log.warn(`${name} · needs authentication\n Run: opencode mcp auth ${name}`) + return false + } + if (status?.status === "needs_client_registration") { + prompts.log.error(`${name} · needs client registration\n ${status.error}`) + return false + } + prompts.log.error(`${name} · ${status?.status === "failed" ? `failed\n ${status.error}` : "not initialized"}`) + return false + }), + ) + prompts.outro(`${servers.length} server(s)`) + if (results.includes(false)) return yield* fail("Could not list tools from every requested MCP server.") + }), +}) + export const McpAuthCommand = effectCmd({ command: "auth [name]", describe: "authenticate with an OAuth-enabled MCP server", diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 05f12fa2ee45..573b85c15cb6 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -166,6 +166,7 @@ export interface Interface { readonly clients: () => Effect.Effect> readonly instructions: () => Effect.Effect readonly tools: () => Effect.Effect> + readonly toolDefinitions: (name: string) => Effect.Effect> readonly prompts: () => Effect.Effect> readonly resources: (clientName?: string) => Effect.Effect> readonly resourceTemplates: ( @@ -687,6 +688,11 @@ const layer = Layer.effect( return result }) + const toolDefinitions = Effect.fn("MCP.toolDefinitions")(function* (name: string) { + const s = yield* InstanceState.get(state) + return s.status[name]?.status === "connected" ? (s.defs[name] ?? []) : [] + }) + function collectFromConnected( s: State, listFn: (c: Client, timeout?: number) => Promise, @@ -974,6 +980,7 @@ const layer = Layer.effect( clients, instructions, tools, + toolDefinitions, prompts, resources, resourceTemplates, diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index e9d3ad233807..02b4eb46bd42 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -29,6 +29,7 @@ manage MCP (Model Context Protocol) servers Commands: opencode mcp add [name] add an MCP server opencode mcp list list MCP servers and their status [aliases: ls] + opencode mcp tools [name] list tools exposed by MCP servers opencode mcp auth [name] authenticate with an OAuth-enabled MCP server opencode mcp logout [name] remove OAuth credentials for an MCP server opencode mcp debug debug OAuth connection for an MCP server @@ -416,6 +417,22 @@ Options: --pure run without external plugins [boolean]" `; +exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp tools --help 1`] = ` +"opencode mcp tools [name] + +list tools exposed by MCP servers + +Positionals: + name name of the MCP server (shows tool descriptions when specified) [string] + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean]" +`; + exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp add --help 1`] = ` "opencode mcp add [name] diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index 3a14d0d7ecc7..d7aaf584dadd 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -70,6 +70,7 @@ const TOP_LEVEL = [ // gains user-visible flags that we want to lock in. const SUBCOMMANDS = [ ["mcp", "list"], + ["mcp", "tools"], ["mcp", "add"], ["mcp", "auth"], ["mcp", "logout"], diff --git a/packages/opencode/test/cli/mcp-tools.test.ts b/packages/opencode/test/cli/mcp-tools.test.ts new file mode 100644 index 000000000000..6984253bd01b --- /dev/null +++ b/packages/opencode/test/cli/mcp-tools.test.ts @@ -0,0 +1,218 @@ +import { describe, expect } from "bun:test" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { CallToolRequestSchema, ListToolsRequestSchema, type Tool } from "@modelcontextprotocol/sdk/types.js" +import { Effect } from "effect" +import path from "node:path" +import { cliIt } from "../lib/cli-process" + +const tools: Tool[] = [ + { name: "search_docs", description: "Search local docs", inputSchema: { type: "object" } }, + { name: "search.docs", description: "Search remote docs", inputSchema: { type: "object" } }, +] + +function serve(items: Tool[]) { + return Effect.acquireRelease( + Effect.promise(async () => { + const requests: string[] = [] + const protocol = new Server({ name: "tools-test", version: "1.0.0" }, { capabilities: { tools: {} } }) + protocol.setRequestHandler(ListToolsRequestSchema, async (request) => { + requests.push("tools/list") + return request.params?.cursor + ? { tools: items.slice(1) } + : { tools: items.slice(0, 1), nextCursor: items.length > 1 ? "next" : undefined } + }) + protocol.setRequestHandler(CallToolRequestSchema, async () => { + requests.push("tools/call") + return { content: [] } + }) + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + enableJsonResponse: true, + }) + await protocol.connect(transport) + const http = Bun.serve({ port: 0, fetch: (request) => transport.handleRequest(request) }) + return { + url: http.url.toString(), + requests, + close: async () => { + await http.stop(true) + await protocol.close() + }, + } + }), + (server) => Effect.promise(server.close), + ) +} + +describe("opencode mcp tools", () => { + cliIt.live("groups local and remote tools, preserving native names and pagination", ({ opencode }) => + Effect.gen(function* () { + const server = yield* serve(tools) + const result = yield* opencode.spawn(["mcp", "tools"], { + env: { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + mcp: { + remote: { type: "remote", url: server.url, oauth: false }, + local: { + type: "local", + command: [process.execPath, path.join(import.meta.dir, "../fixture/mcp-lifecycle-stdio.ts")], + }, + disabled: { type: "local", command: ["missing-mcp-server"], enabled: false }, + }, + }), + }, + }) + opencode.expectExit(result, 0) + expect(result.stdout).toContain("remote · 2 tool(s)") + expect(result.stdout).toContain("search.docs") + expect(result.stdout).toContain("search_docs") + expect(result.stdout).not.toContain("Search local docs") + expect(result.stdout).toContain("local · 1 tool(s)") + expect(result.stdout).toContain("current_directory") + expect(result.stdout).toContain("disabled · disabled") + expect(server.requests).toEqual(["tools/list", "tools/list"]) + }), + ) + + cliIt.live("shows only the requested server even when another server fails", ({ opencode }) => + Effect.gen(function* () { + const server = yield* serve([ + tools[0], + { ...tools[1], description: " Search\n\nremote\tdocs " }, + { name: "no_description", inputSchema: { type: "object" } }, + ]) + const result = yield* opencode.spawn(["mcp", "tools", "docs"], { + env: { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + mcp: { + docs: { type: "remote", url: server.url, oauth: false }, + ignored: { type: "local", command: ["missing-mcp-server"] }, + }, + }), + }, + }) + opencode.expectExit(result, 0) + expect(result.stdout).toContain("docs · 3 tool(s)") + expect(result.stdout).toContain("Search local docs") + expect(result.stdout).toContain("Search remote docs") + expect(result.stdout).toContain("no_description") + expect(result.stdout).not.toContain("undefined") + expect(result.stdout).not.toContain("ignored") + expect(server.requests).toEqual(["tools/list", "tools/list"]) + }), + ) + + cliIt.live("distinguishes an empty configuration from an unknown server", ({ opencode }) => + Effect.gen(function* () { + const empty = yield* opencode.spawn(["mcp", "tools"]) + opencode.expectExit(empty, 0) + expect(empty.stdout).toContain("No MCP servers configured") + + const missing = yield* opencode.spawn(["mcp", "tools", "missing"]) + opencode.expectExit(missing, 1) + expect(missing.stderr).toContain('MCP server "missing" not found') + expect(missing.stderr).toContain("opencode mcp list") + }), + ) + + cliIt.live("reports a connected server with no tools", ({ opencode }) => + Effect.gen(function* () { + const server = yield* serve([]) + const result = yield* opencode.spawn(["mcp", "tools", "empty"], { + env: { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ mcp: { empty: { type: "remote", url: server.url } } }), + }, + }) + opencode.expectExit(result, 0) + expect(result.stdout).toContain("empty · 0 tool(s)") + expect(result.stdout).toContain("No tools exposed") + expect(server.requests).toEqual(["tools/list"]) + }), + ) + + cliIt.live("fails when the requested server is disabled", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["mcp", "tools", "disabled"], { + env: { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + mcp: { disabled: { type: "local", command: ["missing-mcp-server"], enabled: false } }, + }), + }, + }) + opencode.expectExit(result, 1) + expect(result.stdout).toContain("disabled · disabled") + expect(result.stdout).not.toContain("0 tool(s)") + }), + ) + + cliIt.live("keeps successful results while reporting a failed connection", ({ opencode }) => + Effect.gen(function* () { + const server = yield* serve(tools) + const result = yield* opencode.spawn(["mcp", "tools"], { + env: { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + mcp: { + docs: { type: "remote", url: server.url, oauth: false }, + broken: { type: "local", command: ["missing-mcp-server"] }, + }, + }), + }, + }) + opencode.expectExit(result, 1) + expect(result.stdout).toContain("docs · 2 tool(s)") + expect(result.stdout).toContain("broken · failed") + expect(result.stdout).not.toContain("0 tool(s)") + }), + ) + + cliIt.live("reports authentication requirements instead of an empty tool list", ({ opencode }) => + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url) + if (url.pathname === "/.well-known/oauth-protected-resource") + return Response.json({ resource: `${url.origin}/mcp`, authorization_servers: [url.origin] }) + if (url.pathname === "/.well-known/oauth-authorization-server") + return Response.json({ + issuer: url.origin, + authorization_endpoint: `${url.origin}/authorize`, + token_endpoint: `${url.origin}/token`, + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }) + if (url.pathname !== "/mcp") return new Response("Not found", { status: 404 }) + return new Response("Unauthorized", { + status: 401, + headers: { + "WWW-Authenticate": `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource"`, + }, + }) + }, + }), + ), + (server) => Effect.promise(async () => server.stop(true)), + ) + const result = yield* opencode.spawn(["mcp", "tools", "private"], { + env: { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + mcp: { + private: { + type: "remote", + url: new URL("/mcp", server.url).toString(), + oauth: { clientId: "test-client" }, + }, + }, + }), + }, + }) + opencode.expectExit(result, 1) + expect(result.stdout).toContain("private · needs authentication") + expect(result.stdout).toContain("opencode mcp auth private") + expect(result.stdout).not.toContain("0 tool(s)") + }), + ) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index da6e0f8d036f..92060d1347d5 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -117,6 +117,7 @@ function makeMcp(instructions: MCP.ServerInstructions[] = []) { clients: () => Effect.succeed({}), instructions: () => Effect.succeed(instructions), tools: () => Effect.succeed({}), + toolDefinitions: () => Effect.succeed([]), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), resourceTemplates: () => Effect.succeed({}), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 1265237840f3..d005b6ba01ff 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -39,6 +39,7 @@ const mcp = Layer.succeed( clients: () => Effect.succeed({}), instructions: () => Effect.succeed([]), tools: () => Effect.succeed({}), + toolDefinitions: () => Effect.succeed([]), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), resourceTemplates: () => Effect.succeed({}), diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 4e4fea2b46ac..41df2d24d61a 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -259,6 +259,26 @@ opencode mcp ls --- +#### tools + +List the tools exposed by configured MCP servers, grouped by server. + +```bash +opencode mcp tools +``` + +Specify a server name to include tool descriptions. + +```bash +opencode mcp tools github +``` + +Disabled servers, authentication requirements, and connection failures are reported separately from empty tool lists. The command exits with a non-zero status if a requested server cannot be inspected. Disabled servers are reported without failing the command when listing all servers. + +This lists the tools exposed by each MCP server. An agent's permissions and tool configuration can further restrict which tools it can use. + +--- + #### auth Authenticate with an OAuth-enabled MCP server.