Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion packages/opencode/src/cli/cmd/mcp.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -99,6 +99,7 @@ export const McpCommand = cmd({
yargs
.command(McpAddCommand)
.command(McpListCommand)
.command(McpToolsCommand)
.command(McpAuthCommand)
.command(McpLogoutCommand)
.command(McpDebugCommand)
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export interface Interface {
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly tools: () => Effect.Effect<Record<string, McpTool>>
readonly toolDefinitions: (name: string) => Effect.Effect<ReadonlyArray<MCPToolDef>>
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
readonly resourceTemplates: (
Expand Down Expand Up @@ -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<T extends { name: string }>(
s: State,
listFn: (c: Client, timeout?: number) => Promise<T[]>,
Expand Down Expand Up @@ -974,6 +980,7 @@ const layer = Layer.effect(
clients,
instructions,
tools,
toolDefinitions,
prompts,
resources,
resourceTemplates,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> debug OAuth connection for an MCP server
Expand Down Expand Up @@ -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]

Expand Down
1 change: 1 addition & 0 deletions packages/opencode/test/cli/help/help-snapshots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
218 changes: 218 additions & 0 deletions packages/opencode/test/cli/mcp-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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)")
}),
)
})
1 change: 1 addition & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({}),
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/test/session/snapshot-tool-race.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({}),
Expand Down
Loading
Loading