Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .changeset/mcp-tool-meta-passthrough.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/plugins/mcp/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export {
McpTransport,
McpToolAnnotations,
McpToolBinding,
McpToolMeta,
parseMcpIntegrationConfig,
} from "./types";

Expand Down
17 changes: 16 additions & 1 deletion packages/plugins/mcp/src/sdk/manifest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Option, Schema } from "effect";

import { McpToolAnnotations } from "./types";
import { McpToolAnnotations, McpToolMeta } from "./types";

// ---------------------------------------------------------------------------
// Output types
Expand All @@ -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 {
Expand All @@ -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({
Expand All @@ -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);
Expand Down Expand Up @@ -130,6 +144,7 @@ export const extractManifestFromListToolsResult = (
inputSchema: tool.inputSchema ?? tool.parameters,
outputSchema: tool.outputSchema,
annotations: tool.annotations,
_meta: readToolMeta(tool._meta),
},
];
});
Expand Down
64 changes: 64 additions & 0 deletions packages/plugins/mcp/src/sdk/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}),
);
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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", () => {
Expand Down
18 changes: 13 additions & 5 deletions packages/plugins/mcp/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
McpRemoteTransport,
type McpAuthMethod,
type McpToolAnnotations,
McpToolMeta,
expandMcpAuthMethodInputs,
mcpAuthMethodFromShorthand,
normalizeMcpAuthMethods,
Expand Down Expand Up @@ -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 };
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions packages/plugins/mcp/src/sdk/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions packages/plugins/mcp/src/testing/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Loading