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
5 changes: 5 additions & 0 deletions .changeset/mcp-client-tool-visibility.md
Original file line number Diff line number Diff line change
@@ -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. (#820)
19 changes: 19 additions & 0 deletions packages/agent-bundle/src/mcp-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']),
Expand All @@ -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': {
Expand Down Expand Up @@ -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();
Comment on lines +489 to +490

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject excluded tools before task augmentation

For a client negotiating MCP 2025-11-25, an excluded tool with execution.taskSupport: 'optional' or 'required' can still receive a task-augmented tools/call: TaskAugmentedServer._wrapHandler creates and returns the task before its background invocation reaches the SDK handler affected by registered.disable(). The task later fails, but the original call succeeds with a task handle, contradicting the documented promise that excluded tools reject direct calls; the exclusion must also remove or block task support before task creation.

AGENTS.md reference: AGENTS.md:L96-L99

Useful? React with 👍 / 👎.

}
initialized?.();
};
}
};

/** Registers compiled MCP App surfaces as inline HTML resources. */
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/routes/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 9 additions & 1 deletion packages/agent-bundle/tests/generated-route-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {',
Expand Down Expand Up @@ -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 (
Expand Down
27 changes: 27 additions & 0 deletions packages/agent-bundle/tests/mcp-server-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions website/docs/en/guide/authoring/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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/<server>/`:
Expand Down
12 changes: 12 additions & 0 deletions website/docs/zh/guide/authoring/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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/<server>/` 下每个路由放一个模块:
Expand Down
Loading