diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index c0f2fc94cc7..f0051c31833 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -26,6 +26,7 @@ import { } from "@modelcontextprotocol/sdk/types.js" import { Cause, Effect, Exit, Schema } from "effect" import { ConfigMCP } from "@opencode-ai/schema/config/mcp" +import type { Session } from "@opencode-ai/schema/session" import { McpStdio } from "./stdio.js" const DEFAULT_STARTUP_TIMEOUT = 30_000 @@ -156,6 +157,7 @@ export interface Connection { readonly callTool: (input: { readonly name: string readonly args?: Record + readonly sessionID?: Session.ID }) => Effect.Effect readonly onClose: (callback: () => void) => void /** Registers a callback fired when the server emits an MCP logging notification. */ @@ -396,7 +398,11 @@ export const connect = Effect.fnUntraced(function* ( Effect.tryPromise({ try: (signal) => client.callTool( - { name: input.name, arguments: input.args ?? {} }, + { + name: input.name, + arguments: input.args ?? {}, + ...(input.sessionID === undefined ? {} : { _meta: { sessionID: input.sessionID } }), + }, CallToolResultSchema, // Keep progress tokens available while enforcing a hard wall-clock execution timeout. { signal, timeout: executionTimeout, onprogress: () => {} }, diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index f17831ce448..c168ddad991 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -3,6 +3,7 @@ export * as Mcp from "./index.js" import { Mcp } from "@opencode-ai/schema/mcp" import { McpEvent } from "@opencode-ai/schema/mcp-event" import { ephemeral } from "@opencode-ai/schema/event" +import type { Session } from "@opencode-ai/schema/session" import { createHash } from "node:crypto" import { isDeepStrictEqual } from "node:util" import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect" @@ -153,6 +154,7 @@ export interface Interface extends State.Transformable { readonly server: ServerName | string readonly name: string readonly args?: Record + readonly sessionID?: Session.ID }) => Effect.Effect readonly instructions: () => Effect.Effect readonly prompts: () => Effect.Effect @@ -762,7 +764,7 @@ export const layer = (options?: Options) => message: "MCP server is not connected", }) const result = yield* target.entry.client - .callTool({ name: input.name, args: input.args }) + .callTool({ name: input.name, args: input.args, sessionID: input.sessionID }) .pipe( Effect.mapError( (error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }), diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index 9aeab1bca8a..755c482bafb 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -72,6 +72,7 @@ export const layer = Layer.effect( server: tool.server, name: tool.name, args: (input ?? {}) as Record, + sessionID: context.sessionID, }) .pipe( Effect.catchTags({ diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 79bcb36d0c0..d5ab3f69328 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -49,6 +49,7 @@ import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/t let assertion: Deferred.Deferred | undefined let decision: Effect.Effect = Effect.void let calls = 0 +let invocations: Array[0]> = [] type ResourcePage = { items: Array<{ name: string; uri: string; description?: string; mimeType?: string }> @@ -83,6 +84,12 @@ function resourceServer( resourceLists: 0, templateLists: 0, toolLists: 0, + toolCalls: [] as Array<{ + name: string + arguments: Record | undefined + sessionID: unknown + progressToken: unknown + }>, initializations: 0, urls: [] as string[], } @@ -132,6 +139,17 @@ function resourceServer( } }) } + if (!input.emptyElicitation && !input.urlElicitation) { + protocol.setRequestHandler(CallToolRequestSchema, (request) => { + state.toolCalls.push({ + name: request.params.name, + arguments: request.params.arguments, + sessionID: request.params._meta?.sessionID, + progressToken: request.params._meta?.progressToken, + }) + return Promise.resolve({ content: [] }) + }) + } if (input.resources !== false) { protocol.setRequestHandler(ListResourcesRequestSchema, (request) => { state.resourceLists += 1 @@ -313,6 +331,7 @@ const mcp = Layer.mock(Mcp.Service, { callTool: (input) => Effect.sync(() => { calls += 1 + invocations.push(input) if (input.name === "fail") return new Mcp.ToolResult({ server: Mcp.ServerName.make(input.server), @@ -380,6 +399,43 @@ test("MCP tool names match V1 sanitization", () => { expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id") }) +test("passes session IDs as MCP request metadata", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const server = yield* resourceServer() + const connection = yield* connect( + "session-metadata", + new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }), + import.meta.dir, + ) + yield* connection.callTool({ + name: "echo", + args: { text: "hello" }, + sessionID: Session.ID.make("ses_mcp_metadata"), + }) + yield* connection.callTool({ name: "echo" }) + + expect(server.state.toolCalls).toEqual([ + { + name: "echo", + arguments: { text: "hello" }, + sessionID: "ses_mcp_metadata", + progressToken: expect.any(Number), + }, + { + name: "echo", + arguments: {}, + sessionID: undefined, + progressToken: expect.any(Number), + }, + ]) + expect(server.state.toolCalls[0]?.progressToken).not.toBe(server.state.toolCalls[1]?.progressToken) + }), + ), + ) +}) + test("preserves output schema validation across paginated tool discovery", async () => { const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } }) server.setRequestHandler(ListToolsRequestSchema, ({ params }) => @@ -1610,6 +1666,54 @@ it.effect("advertises MCP output schemas to Code Mode", () => }), ) +it.effect("forwards the invoking session through direct and Code Mode MCP tools", () => + Effect.gen(function* () { + assertion = yield* Deferred.make() + decision = Effect.void + invocations = [] + const registry = yield* Tool.Service + const registration = yield* McpTool.Service + yield* registration.flush + const toolSet = yield* registry.snapshot() + + expect(toolSet.definitions.find((tool) => tool.name === "direct_lookup")?.inputSchema).not.toHaveProperty( + "properties.sessionID", + ) + expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).not.toContain("sessionID") + + const directSessionID = Session.ID.make("ses_mcp_direct") + yield* toolSet.execute({ + sessionID: directSessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call_mcp_direct", name: "direct_lookup", input: {} }, + }) + expect(invocations[0]).toEqual({ + server: "direct", + name: "lookup", + args: {}, + sessionID: directSessionID, + }) + + const codeModeSessionID = Session.ID.make("ses_mcp_codemode") + yield* toolSet.execute({ + sessionID: codeModeSessionID, + ...toolIdentity, + call: { + type: "tool-call", + id: "call_mcp_codemode", + name: "execute", + input: { code: "return await tools.demo.search({})" }, + }, + }) + expect(invocations[1]).toEqual({ + server: "demo", + name: "search", + args: {}, + sessionID: codeModeSessionID, + }) + }), +) + it.effect("returns content-only MCP results through Code Mode", () => Effect.gen(function* () { assertion = yield* Deferred.make() diff --git a/packages/www/src/docs/content/mcp-servers.mdx b/packages/www/src/docs/content/mcp-servers.mdx index 111aa3090d2..a61286ca836 100644 --- a/packages/www/src/docs/content/mcp-servers.mdx +++ b/packages/www/src/docs/content/mcp-servers.mdx @@ -231,6 +231,18 @@ Use permission actions to hide or deny a server's tools without stopping its con } ``` +## Session context + +When OpenCode invokes an MCP tool on behalf of a session, it includes the invoking +session's ID in `CallToolRequest.params._meta.sessionID`. This applies to direct tool +calls and Code Mode over both stdio and Streamable HTTP. + +The ID is request metadata, not a tool argument, so it does not appear in the +model-visible tool schema. Treat it as an opaque correlation value: it identifies the +invoking OpenCode session rather than the MCP transport session, can be absent for +calls without session context, and must not be used by itself for authentication or +authorization. Remote MCP servers receive the raw ID and may log or retain it. + ## Manage servers OpenCode interfaces can add servers to project or global configuration, list