From 8baeb4bd6e2523c5b43e278aede6cad7f1233d05 Mon Sep 17 00:00:00 2001 From: Abhishek B R Date: Thu, 24 Sep 2026 03:39:46 +0530 Subject: [PATCH] Use the MCP tool title as approval copy without destructiveHint Closes #2074 --- .changeset/mcp-approval-title.md | 5 ++ packages/plugins/mcp/src/sdk/plugin.test.ts | 52 +++++++++++++++++++++ packages/plugins/mcp/src/sdk/plugin.ts | 11 ++++- packages/plugins/mcp/src/testing/server.ts | 10 ++++ 4 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 .changeset/mcp-approval-title.md diff --git a/.changeset/mcp-approval-title.md b/.changeset/mcp-approval-title.md new file mode 100644 index 000000000..6d917e314 --- /dev/null +++ b/.changeset/mcp-approval-title.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Use an MCP tool's `title` annotation as its approval prompt whether or not the tool declares `destructiveHint`. A non-destructive tool gated by a `require_approval` policy used to show its raw tool address instead of the title. Whether a tool requires approval on its own is still decided by `destructiveHint` alone. diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 487874677..907404bd8 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -11,6 +11,8 @@ import { import { AuthTemplateSlug, ConnectionName, + ElicitationResponse, + FormElicitation, IntegrationSlug, OAuthClientSlug, ToolAddress, @@ -1397,6 +1399,7 @@ describe("mcpPlugin", () => { // --------------------------------------------------------------------------- const serveAnnotationsTestServer = serveMcpServer(makeAnnotationsMcpServer); +const isFormElicitation = Schema.is(FormElicitation); const seedAnnotationsExecutor = (serverUrl: string) => createExecutor( @@ -1451,6 +1454,55 @@ describe("MCP destructiveHint → requiresApproval", () => { }), ); + it.effect("uses annotations.title as approvalDescription without destructiveHint", () => + Effect.gen(function* () { + const server = yield* serveAnnotationsTestServer; + const executor = yield* seedAnnotationsExecutor(server.url); + + const tools = yield* executor.tools.list(); + const createTitled = tools.find((t) => String(t.name) === "create_titled"); + expect(createTitled?.annotations?.requiresApproval).toBe(false); + expect(createTitled?.annotations?.approvalDescription).toBe("Create dataset"); + + const deleteUntitled = tools.find((t) => String(t.name) === "delete"); + expect(deleteUntitled?.annotations?.approvalDescription).toBe("delete"); + + const ping = tools.find((t) => String(t.name) === "ping"); + expect(ping?.annotations?.approvalDescription).toBeUndefined(); + }), + ); + + it.effect("a policy-gated additive tool prompts with its title", () => + Effect.gen(function* () { + const server = yield* serveAnnotationsTestServer; + const executor = yield* seedAnnotationsExecutor(server.url); + yield* executor.policies.create({ + owner: "org", + pattern: "annotations_test.*", + action: "require_approval", + }); + + const tools = yield* executor.tools.list(); + const createTitled = tools.find((t) => String(t.name) === "create_titled"); + expect(createTitled).toBeDefined(); + + const messages: string[] = []; + yield* executor.execute( + createTitled!.address, + { name: "reports" }, + { + onElicitation: (ctx) => { + if (isFormElicitation(ctx.request)) messages.push(ctx.request.message); + return Effect.succeed(ElicitationResponse.make({ action: "accept" })); + }, + }, + ); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatch(/^Create dataset\n/); + }), + ); + // 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. diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 079b89dce..6b053ecfd 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -538,6 +538,10 @@ const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonS * the stamp is where a host reads it back. */ const toToolDef = (entry: McpToolManifestEntry): ToolDef => { const destructive = entry.annotations?.destructiveHint === true; + // The title describes the action whether or not it destroys state, so it is + // the approval copy for any call that pauses, including one a policy forces. + const approvalDescription = + entry.annotations?.title ?? (destructive ? entry.toolName : undefined); const stamp: McpToolStamp = { toolName: entry.toolName, ...(entry.annotations ? { upstream: entry.annotations } : {}), @@ -545,7 +549,7 @@ const toToolDef = (entry: McpToolManifestEntry): ToolDef => { }; const annotations: StampedAnnotations = { requiresApproval: destructive, - ...(destructive ? { approvalDescription: entry.annotations?.title ?? entry.toolName } : {}), + ...(approvalDescription ? { approvalDescription } : {}), mcp: stamp, }; return { @@ -1925,7 +1929,10 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { approvalDescription: ann.title ?? stamp?.toolName ?? String(row.name), }; } else { - out[String(row.name)] = { requiresApproval: false }; + out[String(row.name)] = { + requiresApproval: false, + ...(ann?.title ? { approvalDescription: ann.title } : {}), + }; } } return out; diff --git a/packages/plugins/mcp/src/testing/server.ts b/packages/plugins/mcp/src/testing/server.ts index 2c0ca2f20..8d7204ba4 100644 --- a/packages/plugins/mcp/src/testing/server.ts +++ b/packages/plugins/mcp/src/testing/server.ts @@ -698,6 +698,16 @@ export const makeAnnotationsMcpServer = () => { async () => ({ content: [] }), ); + server.registerTool( + "create_titled", + { + description: "An additive tool with a title annotation", + inputSchema: { name: z.string() }, + annotations: { readOnlyHint: false, destructiveHint: false, title: "Create dataset" }, + }, + async () => ({ content: [] }), + ); + server.registerTool( "list", {