From 7cc498f40c01d1f6eb2f4abfbc0094b541bfcb22 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 16 Sep 2026 23:37:24 +0000 Subject: [PATCH 1/2] feat: filter generated MCP tools by negotiated client name --- .changeset/mcp-client-tool-visibility.md | 5 ++++ .../agent-bundle/src/mcp-server-runtime.ts | 19 +++++++++++++ packages/agent-bundle/src/routes/public.ts | 2 ++ .../tests/generated-route-server.test.ts | 10 ++++++- .../tests/mcp-server-runtime.test.ts | 27 +++++++++++++++++++ website/docs/en/guide/authoring/mcp.mdx | 14 ++++++++++ website/docs/zh/guide/authoring/mcp.mdx | 12 +++++++++ 7 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 .changeset/mcp-client-tool-visibility.md diff --git a/.changeset/mcp-client-tool-visibility.md b/.changeset/mcp-client-tool-visibility.md new file mode 100644 index 000000000..e9e8e8fd4 --- /dev/null +++ b/.changeset/mcp-client-tool-visibility.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Add `ToolConfig.excludeClients` to hide generated MCP tools from matching negotiated client-name prefixes and reject direct calls while preserving other clients and CLI/browser projections. diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 425db9f30..33c69157c 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -399,9 +399,15 @@ export const registerGeneratedRoutes = ( artifactEpoch: string, options: RegisterGeneratedRoutesOptions = {}, ): void => { + const clientTools: { readonly prefixes: readonly string[]; readonly disable: () => void }[] = []; for (const route of Object.values(routes)) { switch (route.kind) { case 'tool': { + const excluded = route.config['excludeClients']; + if (excluded !== undefined && (!Array.isArray(excluded) || excluded.some(prefix => + typeof prefix !== 'string' || prefix.trim() === '' || prefix.length > 128))) { + throw new TypeError(`Tool ${JSON.stringify(route.name)} excludeClients must contain non-empty client-name prefixes up to 128 characters.`); + } const outputSchema = advertisedOutputSchema(route.module.resultSchema); const registered = server.registerTool(route.name, { ...selectedConfig(route.config, ['_meta', 'annotations', 'description', 'icons', 'title']), @@ -425,6 +431,9 @@ export const registerGeneratedRoutes = ( return attachMcpStructuredContent(rendered.toolResult, rendered.result); }, options.afterRender)) as never); options.tasks?.declareTool(registered, route.name, routeTaskSupport(route.config)); + if (Array.isArray(excluded) && excluded.length > 0) { + clientTools.push({ prefixes: excluded.map(prefix => String(prefix).toLowerCase()), disable: () => registered.disable() }); + } break; } case 'resource': { @@ -473,6 +482,16 @@ export const registerGeneratedRoutes = ( } } } + if (clientTools.length > 0) { + const initialized = server.server.oninitialized; + server.server.oninitialized = () => { + const name = server.server.getClientVersion()?.name.toLowerCase(); + if (name !== undefined) for (const tool of clientTools) { + if (tool.prefixes.some(prefix => name.startsWith(prefix))) tool.disable(); + } + initialized?.(); + }; + } }; /** Registers compiled MCP App surfaces as inline HTML resources. */ diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index 76bbc5138..4b9968b56 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -457,6 +457,8 @@ export interface ToolExecutionConfig { } export interface ToolConfig { + /** Case-insensitive negotiated MCP client-name prefixes where this tool is unavailable. Unknown clients retain tools; this is presentation, not authorization. */ + readonly excludeClients?: readonly string[]; /** Execution-free input metadata for forms and CLI flags; the original schema owns validation. */ readonly inputJsonSchema?: RouteInputSchema; readonly _meta?: RouteMeta; diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index d89704ff4..77790be4e 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -115,7 +115,7 @@ it('lists and calls a generated filesystem tool through final-only Flight', { re writeProjectFile(root, 'src/mcp/curator/tools/inspect.tsx', [ "import { Agent, agent } from '@agent-bundle/runtime';", "import { z } from 'zod';", - "export const config = { annotations: { readOnlyHint: true }, description: 'Inspect one source.' };", + "export const config = { annotations: { readOnlyHint: true }, description: 'Inspect one source.', excludeClients: ['codex'] };", "export const inputSchema = z.object({ source: z.string() }).strict();", "export const resultSchema = z.object({ actor: z.unknown(), host: z.unknown(), invocationKind: z.literal('tool'), lineage: z.unknown(), session: z.unknown(), source: z.string(), workspace: z.unknown() }).strict();", 'export default async function Inspect({ input, signal }) {', @@ -210,6 +210,14 @@ it('lists and calls a generated filesystem tool through final-only Flight', { re } finally { await client.close(); } + const excludedClient = new Client({ name: 'Codex_cli_rs', version: '0.0.0' }); + try { + await excludedClient.connect(new StdioClientTransport({ args: [entry], command: process.execPath, stderr: 'pipe' })); + expect((await excludedClient.listTools()).tools.map(tool => tool.name)).not.toContain('inspect'); + await expect(excludedClient.callTool({ arguments: { source: 'library' }, name: 'inspect' })).rejects.toThrow(/disabled|not found/i); + } finally { + await excludedClient.close(); + } }); const writeGeneratedProject = async ( diff --git a/packages/agent-bundle/tests/mcp-server-runtime.test.ts b/packages/agent-bundle/tests/mcp-server-runtime.test.ts index b54b5aadd..582cc4c36 100644 --- a/packages/agent-bundle/tests/mcp-server-runtime.test.ts +++ b/packages/agent-bundle/tests/mcp-server-runtime.test.ts @@ -109,6 +109,33 @@ const stubs = (options: { }; describe('generated server lineage correlation', () => { + it('excludes same-client tools from listing and direct calls without leaking across sessions', async () => { + const routes = Object.fromEntries([ + ['codex_send', ['codex']], ['gbot_send', ['grok bot', 'grokbot', 'grok-bot']], ['common', []], + ].map(([name, excludeClients]) => [String(name), { + config: { excludeClients }, id: String(name), kind: 'tool' as const, name: String(name), + module: { default: () => undefined, inputSchema: z.object({}).strict(), resultSchema: z.object({ ok: z.boolean() }) }, + }])); + await Promise.all([ + ['codex_cli_rs', 'codex_send'], ['Grok Bot', 'gbot_send'], ['Cursor', undefined], ['unknown', undefined], + ].map(async ([name, hidden]) => { + const { host } = stubs(); + const server = await createGeneratedRouteMcpServer({ artifactEpoch: 'epoch', host, + plugin: { name: 'client-tools', version: '0.0.0' }, routes }); + const client = new Client({ name: name!, version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + try { + const listed = (await client.listTools()).tools.map(tool => tool.name).sort(); + expect(listed).toEqual(['codex_send', 'gbot_send', 'common'].filter(tool => tool !== hidden).sort()); + if (hidden) await expect(client.callTool({ name: hidden, arguments: {} })).rejects.toThrow(/disabled|not found/i); + } finally { + await client.close(); + await server.close(); + } + })); + }); + it('hands the registry the raw tools/call arguments, not the schema-parsed input with defaults applied', async () => { // Cursor's hook records the arguments as sent (`tool_input`); a schema default // would make `{}` and `{ label: 'probe' }` parse alike and misattribute the diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index ea76b20af..fc33f9696 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -44,6 +44,20 @@ The tool routes under `examples/*/src/mcp/**/tools/` and the `mcp-server` scaffo shape directly. The examples keep named schema exports beside the helper so colocated CLI projections and route tests can import their types. +## Client-specific tool inventory + +Set a tool's static `excludeClients` to negotiated MCP client-name prefixes where the tool +should be unavailable, for example `excludeClients: ['codex']` for a tool that messages +Codex. Matching is case-insensitive and happens after MCP initialization, separately for +each server session. An excluded tool disappears from `tools/list` and rejects direct +`tools/call` requests. Tools without this option and clients with unmatched names retain +the normal inventory. Prefixes must be non-empty strings of at most 128 characters. + +Use the client's actual identity. A client identifying itself as `Cursor` cannot be +distinguished from another Cursor client; do not assume it is Grok Bot. Client names are +self-reported, so this feature selects the interface and does not replace authorization. +CLI and browser projections are unaffected. + ## Generated route servers Put one module per route under `src/mcp//`: diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index eeb7323d0..1fe70e059 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -39,6 +39,18 @@ export default defineTool({ `examples/*/src/mcp/**/tools/` 下的工具路由与 `mcp-server` 脚手架都直接采用这种形态。 示例会在辅助函数旁保留命名 schema 导出,供同目录 CLI 投影与路由测试导入类型。 +## 按客户端选择工具列表 + +在工具的静态配置中设置 `excludeClients`,填写不应提供该工具的 MCP 客户端名称前缀。 +例如,向 Codex 发送消息的工具可以使用 `excludeClients: ['codex']`。匹配不区分大小写, +在 MCP 初始化后按服务器会话分别进行。排除的工具不会出现在 `tools/list` 中,直接调用 +`tools/call` 也会被拒绝。未设置此选项的工具以及名称不匹配的客户端保留正常工具列表。 +前缀必须是非空字符串,长度最多为 128 个字符。 + +请使用客户端实际提供的身份。自称 `Cursor` 的客户端无法与其他 Cursor 客户端区分, +不能据此认定它是 Grok Bot。客户端名称由客户端自行报告,因此该功能只选择接口, +不能代替授权检查。CLI 和浏览器投影不受影响。 + ## 生成式路由服务器 在 `src/mcp//` 下每个路由放一个模块: From 57873e3a11aa83ad53904253e7fc324abf07339d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 16 Sep 2026 23:38:04 +0000 Subject: [PATCH 2/2] docs: link host tool visibility changeset to PR --- .changeset/mcp-client-tool-visibility.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/mcp-client-tool-visibility.md b/.changeset/mcp-client-tool-visibility.md index e9e8e8fd4..26cb0f8ee 100644 --- a/.changeset/mcp-client-tool-visibility.md +++ b/.changeset/mcp-client-tool-visibility.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Add `ToolConfig.excludeClients` to hide generated MCP tools from matching negotiated client-name prefixes and reject direct calls while preserving other clients and CLI/browser projections. +Add `ToolConfig.excludeClients` to hide generated MCP tools from matching negotiated client-name prefixes and reject direct calls while preserving other clients and CLI/browser projections. (#820)