mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 06:22:12 +00:00
feat(core): pass session context to MCP tools (#46008)
This commit is contained in:
parent
0593a6b8eb
commit
3625942952
5 changed files with 127 additions and 2 deletions
|
|
@ -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<string, unknown>
|
||||
readonly sessionID?: Session.ID
|
||||
}) => Effect.Effect<CallToolResult, Error>
|
||||
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: () => {} },
|
||||
|
|
|
|||
|
|
@ -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<Draft> {
|
|||
readonly server: ServerName | string
|
||||
readonly name: string
|
||||
readonly args?: Record<string, unknown>
|
||||
readonly sessionID?: Session.ID
|
||||
}) => Effect.Effect<ToolResult, NotFoundError | ToolCallError>
|
||||
readonly instructions: () => Effect.Effect<ServerInstructions[]>
|
||||
readonly prompts: () => Effect.Effect<Prompt[]>
|
||||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/t
|
|||
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
|
||||
let decision: Effect.Effect<void, Permission.Error> = Effect.void
|
||||
let calls = 0
|
||||
let invocations: Array<Parameters<Mcp.Interface["callTool"]>[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<string, unknown> | 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<Permission.AssertInput>()
|
||||
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<Permission.AssertInput>()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue