diff --git a/.changeset/mcp-tool-meta-passthrough.md b/.changeset/mcp-tool-meta-passthrough.md new file mode 100644 index 0000000000..1df7279e35 --- /dev/null +++ b/.changeset/mcp-tool-meta-passthrough.md @@ -0,0 +1,11 @@ +--- +"@executor-js/plugin-mcp": patch +--- + +**An MCP tool's reserved `_meta` map survives `tools/list` decoding and reaches the persisted catalog** + +The MCP spec reserves `_meta` on `Tool` for implementation-defined data, and servers use it for host-only routing and policy hints that do not belong in the closed `annotations` set. The plugin decoded each listed tool with a closed struct that did not declare the field, so `_meta` was discarded before the manifest entry was built. A host that embeds the plugin as its MCP client had no way to recover it: no hook exposes the raw `tools/list` result, and `connections.refresh()` answers with already-built tools. + +The listed-tool decode now declares `_meta`, and the manifest entry carries it through. Executor's own `Tool` has no `_meta` field, so `toToolDef` stamps the map into the `mcp` envelope the plugin already persists in each tool row's annotations, next to the real MCP tool name. The stamp schema declares it too, so it is not stripped a second time when a row is read back at invoke time. A host reads it from `annotations.mcp._meta`. + +The map stays opaque. Nothing in the plugin interprets its contents, and it is never merged into anything the model sees. Because it is entirely server-controlled, it is decoded permissively: a `_meta` that is not the spec's map shape is ignored for that tool rather than failing the whole-list decode, which would otherwise drop every tool the server advertises. diff --git a/packages/plugins/mcp/src/sdk/index.ts b/packages/plugins/mcp/src/sdk/index.ts index 9003a06f97..9dbb9d3d01 100644 --- a/packages/plugins/mcp/src/sdk/index.ts +++ b/packages/plugins/mcp/src/sdk/index.ts @@ -22,6 +22,7 @@ export { McpTransport, McpToolAnnotations, McpToolBinding, + McpToolMeta, parseMcpIntegrationConfig, } from "./types"; diff --git a/packages/plugins/mcp/src/sdk/manifest.ts b/packages/plugins/mcp/src/sdk/manifest.ts index f73394c771..7ced47d4cc 100644 --- a/packages/plugins/mcp/src/sdk/manifest.ts +++ b/packages/plugins/mcp/src/sdk/manifest.ts @@ -1,6 +1,6 @@ import { Option, Schema } from "effect"; -import { McpToolAnnotations } from "./types"; +import { McpToolAnnotations, McpToolMeta } from "./types"; // --------------------------------------------------------------------------- // Output types @@ -13,6 +13,8 @@ export interface McpToolManifestEntry { readonly inputSchema?: unknown; readonly outputSchema?: unknown; readonly annotations?: McpToolAnnotations; + /** The tool's reserved MCP `_meta` map, carried through verbatim. */ + readonly _meta?: McpToolMeta; } export interface McpServerMetadata { @@ -38,6 +40,11 @@ const ListedTool = Schema.Struct({ parameters: Schema.optional(Schema.Unknown), outputSchema: Schema.optional(Schema.Unknown), annotations: Schema.optional(McpToolAnnotations), + // `_meta` is opaque and entirely server-controlled, so it stays `Unknown` + // here and is narrowed to the spec's map shape per entry. Declaring the map + // inline would make one server's malformed `_meta` fail the whole-list + // decode and drop every tool it advertises. + _meta: Schema.optional(Schema.Unknown), }); const ListToolsResult = Schema.Struct({ @@ -62,6 +69,13 @@ const ServerInfo = Schema.Struct({ version: Schema.optional(Schema.String), }); +const decodeMcpToolMeta = Schema.decodeUnknownOption(McpToolMeta); + +/** Narrow a listed tool's `_meta` to the spec's map shape, dropping anything + * else. The contents stay unknown and uninterpreted. */ +const readToolMeta = (value: unknown): McpToolMeta | undefined => + value === undefined ? undefined : Option.getOrUndefined(decodeMcpToolMeta(value)); + const decodeListToolsResult = Schema.decodeUnknownOption(ListToolsResult); const decodeListToolsPageOption = Schema.decodeUnknownOption(ListToolsPage); const decodeServerInfo = Schema.decodeUnknownOption(ServerInfo); @@ -130,6 +144,7 @@ export const extractManifestFromListToolsResult = ( inputSchema: tool.inputSchema ?? tool.parameters, outputSchema: tool.outputSchema, annotations: tool.annotations, + _meta: readToolMeta(tool._meta), }, ]; }); diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 1d4a658a06..e97b24e5ef 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -239,6 +239,45 @@ describe("extractManifestFromListToolsResult", () => { expect(result.tools[2]!.annotations).toBeUndefined(); }), ); + + it.effect("carries the reserved `_meta` map through verbatim", () => + Effect.sync(() => { + const meta = { + serverName: "time", + shortDescription: "Current time", + defer_loading: false, + nested: { any: ["shape"] }, + }; + + const result = extractManifestFromListToolsResult({ + tools: [ + { + name: "time_get_current_time", + description: "Get the current time", + inputSchema: { type: "object" }, + _meta: meta, + }, + { name: "no_meta", description: "Has no _meta" }, + ], + }); + + expect(result.tools[0]!._meta).toEqual(meta); + expect(result.tools[1]!._meta).toBeUndefined(); + }), + ); + + // `_meta` is opaque and server-controlled, so a value that does not match the + // spec's map shape must not take the rest of the list down with it. + it.effect("ignores a malformed `_meta` without dropping the tool", () => + Effect.sync(() => { + const result = extractManifestFromListToolsResult({ + tools: [{ name: "odd_meta", _meta: "not-a-map" }, { name: "plain" }], + }); + + expect(result.tools.map((tool) => tool.toolName)).toEqual(["odd_meta", "plain"]); + expect(result.tools[0]!._meta).toBeUndefined(); + }), + ); }); // --------------------------------------------------------------------------- @@ -1006,6 +1045,31 @@ describe("MCP destructiveHint → requiresApproval", () => { expect(deleteTitled?.annotations?.approvalDescription).toBe("Delete dataset"); }), ); + + // Executor's `Tool` has no `_meta` field, so the reserved MCP map rides in + // the `mcp` stamp the plugin persists into the tool row's annotations. A host + // embedding the plugin reads it back from there. + it.effect("persists the tool's reserved `_meta` into the catalog stamp", () => + Effect.gen(function* () { + const server = yield* serveAnnotationsTestServer; + const executor = yield* seedAnnotationsExecutor(server.url); + + const tools = yield* executor.tools.list(); + + const stamped = tools.find((t) => String(t.name) === "meta_stamped"); + expect(stamped?.annotations).toMatchObject({ + mcp: { + toolName: "meta_stamped", + _meta: { serverName: "time", shortDescription: "Current time", defer_loading: false }, + }, + }); + + const ping = tools.find((t) => String(t.name) === "ping"); + expect( + (ping?.annotations as { readonly mcp?: { readonly _meta?: unknown } })?.mcp?._meta, + ).toBeUndefined(); + }), + ); }); describe("userFacingProbeMessage", () => { diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index f4c55383ec..a9741fe10e 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -60,6 +60,7 @@ import { McpRemoteTransport, type McpAuthMethod, type McpToolAnnotations, + McpToolMeta, expandMcpAuthMethodInputs, mcpAuthMethodFromShorthand, normalizeMcpAuthMethods, @@ -139,14 +140,17 @@ const legacyMcpClientMatches = ( // --------------------------------------------------------------------------- // Tool annotations carry an `mcp` envelope alongside the executor's policy // hints. The executor persists `ToolDef.annotations` verbatim into the tool -// row's JSON column, so the real MCP tool name + upstream annotations survive -// to `invokeTool` / `resolveAnnotations` with no plugin-side store (resolveTools -// has no ctx to write one anyway). The envelope is opaque to core. +// row's JSON column, so the real MCP tool name, the upstream annotations, and +// the tool's reserved `_meta` map survive to `invokeTool` / +// `resolveAnnotations` with no plugin-side store (resolveTools has no ctx to +// write one anyway). The envelope is opaque to core. // --------------------------------------------------------------------------- interface McpToolStamp { readonly toolName: string; readonly upstream?: McpToolAnnotations; + /** The tool's reserved MCP `_meta` map, opaque to core and to the model. */ + readonly _meta?: McpToolMeta; } type StampedAnnotations = ToolAnnotations & { readonly mcp: McpToolStamp }; @@ -162,6 +166,7 @@ const McpStampSchema = Schema.Struct({ openWorldHint: Schema.optional(Schema.Boolean), }), ), + _meta: Schema.optional(McpToolMeta), }); const AnnotationsWithStamp = Schema.Struct({ mcp: McpStampSchema }); const decodeStamp = Schema.decodeUnknownOption(AnnotationsWithStamp); @@ -427,13 +432,16 @@ const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonS }; /** Build the executor-facing ToolDef for one discovered MCP tool, stamping the - * real MCP tool name + upstream annotations into the persisted annotations so - * they survive to invokeTool with no plugin-side store. */ + * real MCP tool name, the upstream annotations, and the tool's `_meta` map + * into the persisted annotations so they survive to invokeTool with no + * plugin-side store. Executor's `ToolDef` has no `_meta` field of its own, so + * the stamp is where a host reads it back. */ const toToolDef = (entry: McpToolManifestEntry): ToolDef => { const destructive = entry.annotations?.destructiveHint === true; const stamp: McpToolStamp = { toolName: entry.toolName, ...(entry.annotations ? { upstream: entry.annotations } : {}), + ...(entry._meta ? { _meta: entry._meta } : {}), }; const annotations: StampedAnnotations = { requiresApproval: destructive, diff --git a/packages/plugins/mcp/src/sdk/types.ts b/packages/plugins/mcp/src/sdk/types.ts index 0f2f03fb9b..de0e72290e 100644 --- a/packages/plugins/mcp/src/sdk/types.ts +++ b/packages/plugins/mcp/src/sdk/types.ts @@ -287,6 +287,16 @@ export const McpToolAnnotations = Schema.Struct({ }); export type McpToolAnnotations = typeof McpToolAnnotations.Type; +// --------------------------------------------------------------------------- +// Tool `_meta` — the reserved, implementation-defined map the MCP spec puts on +// `Tool`. It is opaque to the executor and to the model: servers use it for +// host-only routing and policy hints that do not belong in the closed +// `annotations` set. It is carried through verbatim, never interpreted. +// --------------------------------------------------------------------------- + +export const McpToolMeta = Schema.Record(Schema.String, Schema.Unknown); +export type McpToolMeta = typeof McpToolMeta.Type; + // --------------------------------------------------------------------------- // Tool binding — maps a persisted (sanitized) tool name back to its real MCP // tool name and upstream annotations, persisted per-connection so invokeTool diff --git a/packages/plugins/mcp/src/testing/server.ts b/packages/plugins/mcp/src/testing/server.ts index 9f71af69b2..c1fdca98b5 100644 --- a/packages/plugins/mcp/src/testing/server.ts +++ b/packages/plugins/mcp/src/testing/server.ts @@ -634,5 +634,17 @@ export const makeAnnotationsMcpServer = () => { async () => ({ content: [] }), ); + // Host-only routing/policy hints the MCP spec reserves on `Tool._meta`. They + // are not part of the closed `annotations` set and are never shown to a model. + server.registerTool( + "meta_stamped", + { + description: "A tool carrying reserved `_meta`", + inputSchema: {}, + _meta: { serverName: "time", shortDescription: "Current time", defer_loading: false }, + }, + async () => ({ content: [] }), + ); + return server; };