Compare commits

...
Author SHA1 Message Date
kitlangton 570cb13c19 feat(core): pass session IDs in MCP tool metadata 2026-08-28 18:49:13 +00:00
6 changed files with 136 additions and 2 deletions
+6 -1
View File
@@ -156,6 +156,7 @@ export interface Connection {
readonly callTool: (input: {
readonly name: string
readonly args?: Record<string, unknown>
readonly sessionID?: string
}) => Effect.Effect<CallToolResult, Error>
readonly onClose: (callback: () => void) => void
/** Registers a callback fired when the server emits an MCP logging notification. */
@@ -396,7 +397,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: () => {} },
+2 -1
View File
@@ -153,6 +153,7 @@ export interface Interface extends State.Transformable<Draft> {
readonly server: ServerName | string
readonly name: string
readonly args?: Record<string, unknown>
readonly sessionID?: string
}) => Effect.Effect<ToolResult, NotFoundError | ToolCallError>
readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly prompts: () => Effect.Effect<Prompt[]>
@@ -762,7 +763,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 }),
+1
View File
@@ -72,6 +72,7 @@ export const layer = Layer.effect(
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
sessionID: context.sessionID,
})
.pipe(
Effect.catchTags({
+18
View File
@@ -0,0 +1,18 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
export function sessionServer() {
const server = new Server({ name: "session", version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, () =>
Promise.resolve({
tools: [{ name: "echo", inputSchema: { type: "object", properties: { text: { type: "string" } } } }],
}),
)
server.setRequestHandler(CallToolRequestSchema, (request) =>
Promise.resolve({ content: [], structuredContent: request.params }),
)
return server
}
if (import.meta.main) await sessionServer().connect(new StdioServerTransport())
+98
View File
@@ -43,6 +43,7 @@ import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
import { sessionServer } from "./fixture/mcp-session"
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
@@ -380,6 +381,103 @@ test("MCP tool names match V1 sanitization", () => {
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
})
for (const transport of ["stdio", "http"]) {
for (const codemode of [false, true]) {
test(`passes session metadata over ${transport} with codemode=${codemode}`, async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const config = yield* Effect.gen(function* () {
if (transport === "stdio")
return new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-session.ts")],
codemode,
})
const server = yield* Effect.acquireRelease(
Effect.promise(async () => {
const protocol = sessionServer()
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
})
await protocol.connect(transport)
const http = Bun.serve({
port: 0,
// This request/response fixture does not need a standalone SSE stream.
fetch: (request) =>
request.method === "GET" ? new Response(null, { status: 405 }) : transport.handleRequest(request),
})
return {
url: http.url.toString(),
close: async () => {
await protocol.close()
await http.stop(true)
},
}
}),
(server) => Effect.promise(server.close),
)
return new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, codemode })
})
yield* Effect.gen(function* () {
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
const mcp = yield* Mcp.Service
yield* registration.flush
const snapshot = yield* registry.snapshot()
const catalog = yield* mcp.tools()
expect(catalog[0]?.inputSchema).toEqual({ type: "object", properties: { text: { type: "string" } } })
yield* Effect.forEach(
["ses_mcp_first", "ses_mcp_second"],
(id) =>
Effect.gen(function* () {
const result = yield* snapshot.execute({
sessionID: Session.ID.make(id),
...toolIdentity,
call: {
type: "tool-call",
id: `call_${id}`,
name: codemode ? "execute" : "resources_echo",
input: codemode
? { code: 'return await tools.resources.echo({ text: "hello" })' }
: { text: "hello" },
},
})
const expected = {
name: "echo",
arguments: { text: "hello" },
_meta: { sessionID: id, progressToken: expect.any(Number) },
}
expect(codemode ? JSON.parse(result.output.output) : result.output).toEqual(expected)
}),
{ concurrency: "unbounded" },
)
const result = yield* mcp.callTool({ server: "resources", name: "echo" })
expect(result.structured).toEqual({
name: "echo",
arguments: {},
_meta: { progressToken: expect.any(Number) },
})
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Mcp.node]), [
[Mcp.node, resourceMcpLayer(config)],
[Permission.node, Layer.mock(Permission.Service, { assert: () => Effect.void })],
[Bus.node, events],
[Image.node, imagePassthrough],
]),
),
)
}),
),
)
})
}
}
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 }) =>
@@ -231,6 +231,17 @@ Use permission actions to hide or deny a server's tools without stopping its con
}
```
## Session context
When an agent calls an MCP tool, OpenCode includes the active session ID in
`CallToolRequest.params._meta.sessionID`. This applies to both direct tool calls
and Code Mode, over stdio and Streamable HTTP.
MCP servers can use this ID to route actions to the calling OpenCode session.
It is request metadata, not a tool argument, and is not added to the model-visible
tool schema. It identifies the OpenCode session, not the MCP transport session,
and should not be treated as an authentication credential.
## Manage servers
OpenCode interfaces can add servers to project or global configuration, list