diff --git a/llmdoc/app-runtime/mcp-interop.mdx b/llmdoc/app-runtime/mcp-interop.mdx index dd6ee1e7..96a88518 100644 --- a/llmdoc/app-runtime/mcp-interop.mdx +++ b/llmdoc/app-runtime/mcp-interop.mdx @@ -1,17 +1,26 @@ --- -description: MCP 上游互操作指南:modern/legacy era 协商与缓存键、入站 /~mcp 无状态适配、互操作安全边界与四层验证。 +description: MCP 固定入口与按需发现调用、CLI 能力对等、目标 schema 与结果保真、modern/legacy 上游互操作及入站客户端验证。 kind: guide +relations: + requires: + - protocol/htbp-contract.mdx + related: + - device/durable-mailbox.mdx + - cli/argument-contract.mdx code: paths: - packages/plugins/src/feishu/feishuMcp.ts - packages/app/src/providers/mcp.ts - packages/app/src/mcpServer.ts + - packages/app/src/mcpInvoke.ts - packages/app/src/jsonSchemaValidator.ts - packages/app/src/routes/mcp.ts - packages/app/test/mcp.integration.test.ts + - packages/app/test/mcpArguments.test.ts + - scripts/verify-mcp.ts --- -# MCP 上游互操作 +# MCP 入口与上游互操作 MCP 是外部协议兼容面。项目内部历史状态可删,但现代/旧版 MCP 服务端的真实差异必须保留。 @@ -23,17 +32,47 @@ MCP 是外部协议兼容面。项目内部历史状态可删,但现代/旧版 - 不复用上游 session,不存在 `mcpsession` 或 `forceFresh` 控制面。 - 入站 `/~mcp` 是无状态适配器,不依赖 `Mcp-Session-Id`。 -### 入站 device delivery 投影 +## 入站固定入口与 CLI 对等 -入站 `/~mcp` 不为 Mailbox 暴露第二个 enqueue tool。对 metadata `delivery:'both'` 的设备工具,input schema -增加 `~delivery` enum `realtime|mailbox|fallback`;mailbox-only 工具只投影 `mailbox|fallback`;realtime-only -工具不增加该字段。MCP call 携带的 `~delivery` 原样进入同一个普通 command POST,复用 app 的授权、能力 -检查与 dispatch certainty,而不是在 MCP adapter 里再实现 fallback。 +`/~mcp` 只暴露网关固定能力,业务工具按完整 HTBP command path 发现和调用。`tools/list` 不访问上游、 +不遍历可见工具树,也不因挂载数量或路径长度增长;有 search capability 才广告 `tb_search`。固定入口 +仍逐请求认证,窄 SK 不靠缩减入口名表达权限,而在发现与调用目标时执行同一可见性和 scope 检查。 + +| MCP 入口 | 对应 CLI | 契约 | +|---|---|---| +| `tb_search` | `tb search` | 复用全局搜索选项,默认 compact,选中后按需取 schema | +| `tb_help` | `tb help` | 浏览节点或完整命令,取得参数、权限、副作用与 delivery metadata | +| `tb_list_nodes` | `tb ls` / `tb tree` | 按路径与有界深度浏览,不要求 search capability | +| `tb_call` | `tb call` | `{path,args,delivery?,ttlSeconds?,idempotencyKey?}`,args 缺省空对象 | +| `tb_device_operations` | `tb device op` | 顶层 `action: list|get|cancel`,其余参数按 action 校验 | + +发现返回的 `cmds[].path` 是调用身份,直接放进 `tb_call.path`;已知路径与契约时可直接调用。路径不是 URL +或 shell 命令。工具、Context、Skill 和可调用的 `system/*` 管理命令复用既有 handler 与权威授权,MCP 不 +新增权限旁路。对等范围是网关工具使用能力;本机文件读写、daemon、安装与 localhost 配对属于 CLI 宿主 +职责,不要求远程 MCP 模拟。 + +调用只解析目标 owner:本地先按 Registry 最长前缀解析,remote 只读取选定命令的 help;需要上游清单时 +只读取目标 provider,不能为一次调用枚举其他节点。仅编译、校验选定命令的 schema;目标 schema 畸形时 +fail closed,但无关工具的畸形 schema 不能阻塞入口发现或合法调用。设备未声明 `cmds` 时仍允许与 CLI +一致的已知 realtime 路径;显式空清单表示没有命令,不得编造 schema 或绕过 allowlist。 + +MCP 上游调用复用 provider 执行路径,保留原生内容块、`structuredContent` 与 `isError`;不能先经普通 +HTTP JSON 响应再重建而丢失图片或业务错误。其他命令继续复用普通 HTBP 执行入口与错误语义。通用调用 +和 operation 管理可包含写入或取消,入口 annotation 不能统一标为只读;目标 effect/confirm 从 help 读取, +权限由服务端判定。 + +### Device delivery 与 operation 闭环 + +`tb_call` 的 `delivery` 转成普通 command POST 的 `~delivery`,TTL 与幂等键分别进入既有 query/header; +后两者只允许显式 `mailbox|fallback`。参数控制层不修改业务 schema,也不投影第二套设备工具或 enqueue +入口。app 权威复核 command delivery 能力并执行 dispatch certainty,MCP adapter 不自行实现 fallback。 显式 delivery 的成功结果保持可判别:HTTP 200/realtime 投影为 `{delivery:'realtime',result}`,HTTP 202/mailbox 投影为 `{delivery:'mailbox',operation}`。`fallback` 只在 gateway 明确判断 `not_dispatched` 时入队;设备业务错误属于 completed,unknown 也不入队。这样一次 MCP 工具调用即可请求 fallback,同时避免 Agent 在 MCP 层 call 失败后再猜测是否 enqueue。 +返回的 operation 通过 `tb_device_operations` 查询、分页或取消,复用 owner/admin 授权。设备身份必须使用 +原始 `deviceId`;claimed cancel 只是协作式请求,result_unknown 或可能执行过的 expired 都不证明重试安全。 ## 互操作边界 @@ -59,10 +98,15 @@ gateway 明确判断 `not_dispatched` 时入队;设备业务错误属于 compl ## 验证层次 1. provider 单测:era 探测、TTL、能力约束、错误映射。 -2. app/server 集成:`~help`、单工具披露、envelope 调用、缓存命中。 -3. consumer E2E:用真实 MCP client 连接 `/~mcp`,验证初始化、发现与调用。 +2. app/server 集成:`~help`、单工具披露、完整命令路径与裸 arguments 调用、缓存命中。 +3. consumer E2E:用真实 MCP client 连接 `/~mcp`,验证 modern/legacy、固定入口,以及 help 返回路径直接 + 进入 tb_call 的闭环;窄 SK 仍看到固定入口,但发现隐藏越权目标、已知越权路径调用被拒。 4. 真实上游仅在明确提供 URL/凭据时运行 `pnpm verify:mcp`。 +排查连接先区分入站 `/~mcp` 与出站 MCP provider。CLI 经 HTBP 调用上游成功不能替代入站消费者验证; +官方 SDK 成功也不能证明尚未复现的用户客户端链路兼容。验收还须证明工具目录规模有界、入口发现不访问 +上游、无关上游故障与 schema 错误被隔离,以及设备 delivery/operation 与原生 MCP 结果语义完整。 + 注意:不要为了单个失常服务端加入无边界 fallback。先确认它是协议时代差异还是上游 bug,再把兼容限制在 provider 层并加回归测试。 JSON Schema runtime validation 使用 MCP SDK 官方 AJV adapter。不同上游 schema 即使复用同一 $id 也必须隔离 compiler,避免缓存把工具 A 的 schema 误当成工具 B;方言按 schema 声明分派,不能以删除旧校验器为由跳过校验。验证 adapter 只在服务端 app,neutral SDK 不引用 AJV/Node 实现。 diff --git a/llmdoc/cli/argument-contract.mdx b/llmdoc/cli/argument-contract.mdx index 18b6073d..d60c9455 100644 --- a/llmdoc/cli/argument-contract.mdx +++ b/llmdoc/cli/argument-contract.mdx @@ -184,6 +184,10 @@ CLI 在 workspace 内 typecheck、Vitest 与 tsup 三个解析面都必须覆盖 | 直接 API / HTBP | 权限、状态码、响应形状与错误码 | | `tb` CLI | 参数可达、帮助文本、stdout/stderr、退出码 | | Dashboard | 字段来源、校验、敏感值处理与成功后刷新 | +| MCP | 固定入口按需发现、完整路径调用、目标授权、结果与 device operation 语义 | + +CLI、Dashboard、MCP 的对等以网关工具使用能力为边界。远程 MCP 不要求模拟 CLI 的本机文件、daemon、 +安装和 localhost 配对;固定入口与验收约束见 [MCP 入口](../app-runtime/mcp-interop.mdx)。 Store 的当前对等面是 `system/store`、上述 `tb store` 命令与 Dashboard `/manage/store`。Device SDK 的 `call.uploadObject` 是设备数据面,不要求 CLI 模拟 call capability,但最终仍消费同一 create/relay/direct diff --git a/llmdoc/device/durable-mailbox.mdx b/llmdoc/device/durable-mailbox.mdx index 4562ce77..4658a2e1 100644 --- a/llmdoc/device/durable-mailbox.mdx +++ b/llmdoc/device/durable-mailbox.mdx @@ -22,6 +22,7 @@ code: - packages/app/src/routes/deviceMailbox.ts - packages/app/src/routes/invoke.ts - packages/app/src/mcpServer.ts + - packages/app/src/mcpInvoke.ts - packages/app/src/routes/mcp.ts - packages/server/src/deviceHub.ts - packages/server/src/deviceRouter.ts @@ -60,12 +61,13 @@ mailbox-capable command 仍须可发现并允许入队。 设备 command metadata 的 `delivery` 描述能力:缺省 `realtime`,`mailbox` 只支持 durable 入队,`both` 同时支持 realtime 与 Mailbox。请求 body 的保留 `~delivery` 则是每次调用的 policy:缺省 `realtime`, 可选 `realtime|mailbox|fallback`。两层不能混同;metadata 随既有 hello command metadata 进入 Help -JSON/DSL/Markdown、structured profile 与 MCP 投影,不增加 frame 类型。 +JSON/DSL/Markdown 与 structured profile;MCP 通过 `tb_help` 按需披露,不增加 frame 类型或逐工具 schema 投影。 | 面 | 入口 | 责任 | |---|---|---| | caller invoke | `POST /<完整 command path>` | arguments 顶层可带 `~delivery`;TTL 在 query,幂等键在 header | | caller 管理 | `POST /~device/operations/get|list|cancel` | owner/admin 查询、分页与取消 | +| MCP caller | `tb_call` + `tb_device_operations` | 同一 delivery、TTL、幂等键与 operation 管理语义,见 [MCP 入口](../app-runtime/mcp-interop.mdx) | | device 数据 | `POST /~device/mailbox/claim|renew|complete` | Bearer 鉴权、lease 与四种设备终态 | | SDK device | `createDeviceMailboxProcessor` | 宿主显式调用 `pullOnce`/`drain`;不自启 timer | @@ -81,7 +83,7 @@ fallback 的判断必须消费 `DeviceCallDisposition` 这类 dispatch certainty 每条 operation 的 `targetPath` 是含命令叶子的完整 command identity;设备内部执行 `path` 则保持相对 mountPath 且含命令叶子。Registry 的系统写入 `deviceId` 是数据面原始身份,不能从规范化 mount path -反推。Dashboard/CLI 的便利校验都不替代服务端对 device-backed command 与 `delivery` 的权威复核。 +反推。Dashboard/CLI/MCP 的便利校验都不替代服务端对 device-backed command 与 `delivery` 的权威复核。 ## Authority、存储与加密 diff --git a/llmdoc/meta.json b/llmdoc/meta.json index 7bb6c8ae..37ae5854 100644 --- a/llmdoc/meta.json +++ b/llmdoc/meta.json @@ -9,7 +9,7 @@ "validatedRevision": "fca74657f159c6a41b0a58abceb44243f025af5d" }, "app-runtime/mcp-interop.mdx": { - "validatedRevision": "fca74657f159c6a41b0a58abceb44243f025af5d" + "validatedRevision": "5663a7eb6df73c18085dec8f01053523f73beeb6" }, "architecture.mdx": { "validatedRevision": "fca74657f159c6a41b0a58abceb44243f025af5d" @@ -18,7 +18,7 @@ "validatedRevision": "fca74657f159c6a41b0a58abceb44243f025af5d" }, "cli/argument-contract.mdx": { - "validatedRevision": "fca74657f159c6a41b0a58abceb44243f025af5d" + "validatedRevision": "5663a7eb6df73c18085dec8f01053523f73beeb6" }, "dashboard/canvas-architecture.mdx": { "validatedRevision": "fca74657f159c6a41b0a58abceb44243f025af5d" @@ -42,13 +42,13 @@ "validatedRevision": "fca74657f159c6a41b0a58abceb44243f025af5d" }, "protocol/htbp-contract.mdx": { - "validatedRevision": "fca74657f159c6a41b0a58abceb44243f025af5d" + "validatedRevision": "5663a7eb6df73c18085dec8f01053523f73beeb6" }, "protocol/security-boundaries.mdx": { - "validatedRevision": "fca74657f159c6a41b0a58abceb44243f025af5d" + "validatedRevision": "5663a7eb6df73c18085dec8f01053523f73beeb6" }, "release/npm-publishing.mdx": { - "validatedRevision": "29c1feacd57348d449f93e9b3596e1d4fdd29e8f" + "validatedRevision": "5663a7eb6df73c18085dec8f01053523f73beeb6" }, "search/search-index.mdx": { "validatedRevision": "fca74657f159c6a41b0a58abceb44243f025af5d" @@ -57,7 +57,7 @@ "validatedRevision": "fca74657f159c6a41b0a58abceb44243f025af5d" }, "device/durable-mailbox.mdx": { - "validatedRevision": "fca74657f159c6a41b0a58abceb44243f025af5d" + "validatedRevision": "5663a7eb6df73c18085dec8f01053523f73beeb6" }, "hosts-deploy/managed-configuration.mdx": { "validatedRevision": "fca74657f159c6a41b0a58abceb44243f025af5d" diff --git a/llmdoc/protocol/htbp-contract.mdx b/llmdoc/protocol/htbp-contract.mdx index 32303463..c8e614ba 100644 --- a/llmdoc/protocol/htbp-contract.mdx +++ b/llmdoc/protocol/htbp-contract.mdx @@ -97,7 +97,7 @@ builtin、Context 与 Skillhub 的命令使用 `HtbpCommandRegistry` 注册一 mailbox 或 certainty-gated fallback,没有独立 `~enqueue` 路由。 - `POST /~device/operations/get|list|cancel`:调用方的 operation 管理面;`POST /~device/mailbox/claim|renew|complete` 是设备 Bearer 数据面。 - `PUT /~store/uploads/` 与 `GET /~store/refs|shares/` 是 Store 的 bearer 数据面,不是 Registry 节点;精确授权见[Store](#store)。 -- `ALL /~mcp`:把当前身份可见工具投影为 MCP server。`GET /healthz`:公开健康信息,不证明认证数据面可用。 +- `ALL /~mcp`:提供固定发现、帮助、按路径调用与 device operation 入口,不全量投影业务工具;目标仍按当前身份授权,详见 [MCP 入口](../app-runtime/mcp-interop.mdx)。`GET /healthz`:公开健康信息,不证明认证数据面可用。 - 错误统一 `{code,message,retryable}`;主要 code:not_found、permission_denied、invalid_argument、conflict、unavailable、rate_limited、internal。 - 未知 Help DSL 行与未知可选 capability 应忽略(协议演进能力);安全字段和未知写入参数不得静默忽略。 diff --git a/packages/app/package.json b/packages/app/package.json index 2a3d7a83..05985b9f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@tool-bridge/app", - "version": "0.21.0", + "version": "0.22.0", "description": "Host-neutral HTBP application: assemble the tool-bridge tree on any runtime by injecting state, object, secret, device-channel and search adapters", "type": "module", "license": "MIT", @@ -38,7 +38,8 @@ "@modelcontextprotocol/client": "catalog:", "@modelcontextprotocol/core": "catalog:", "@modelcontextprotocol/server": "catalog:", - "hono": "catalog:" + "hono": "catalog:", + "zod": "catalog:" }, "devDependencies": { "@modelcontextprotocol/sdk": "catalog:", @@ -46,8 +47,7 @@ "@tool-bridge/plugins": "workspace:*", "tsup": "catalog:", "typescript": "catalog:", - "vitest": "catalog:", - "zod": "catalog:" + "vitest": "catalog:" }, "repository": { "type": "git", diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index c86c24ce..acd714dd 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -50,8 +50,6 @@ export { handleMcpRequest, type McpBridgeTool, type McpToolBridge, - mcpToolIdentity, - mcpToolName, } from './mcpServer' // --- mcp 上游的托管 OAuth 授权码流程 --- export { diff --git a/packages/app/src/mcpInvoke.ts b/packages/app/src/mcpInvoke.ts new file mode 100644 index 00000000..5633e02c --- /dev/null +++ b/packages/app/src/mcpInvoke.ts @@ -0,0 +1,168 @@ +/** Targeted MCP invocation, sharing the ordinary HTBP execution and authorization paths. */ +import { + canonicalizePath, + check, + NodeRegistryStore, + resolveUpstreamTool, + TBError, + type ToolResult, + validatePath, + virtualizeTools, +} from '@tool-bridge/core' +import { helpJsonSchema } from '@tool-bridge/core/protocol' +import type { AppContext, TbHono } from './deps' +import type { RouteEnv } from './routes/env' +import { providerFor, upstreamTools } from './toolNodes' +import { validateMcpArguments } from './mcpServer' +import { deviceToolMarker } from './deviceNodes' +import { helpModelFor } from './helpModel' + +/** Logical HTBP paths are encoded segment by segment, never accepted as URLs. */ +export function mcpPath(path: string, allowRoot = false): string { + const error = validatePath(path, { allowRoot }) + if (error !== null) throw error + return canonicalizePath(path).split('/').map(encodeURIComponent).join('/') +} + +export function mcpRequest( + c: AppContext, + app: TbHono, + path: string, + body?: unknown, + extraHeaders?: Record, +): Promise { + return Promise.resolve(app.request(new Request(new URL(path, c.req.url), { + method: body === undefined ? 'GET' : 'POST', + headers: { + 'authorization': c.req.header('authorization') ?? '', + 'accept': 'application/json', + 'content-type': 'application/json', + ...extraHeaders, + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: c.req.raw.signal, + }))) +} + +export async function mcpResponse(response: Response): Promise { + const text = await response.text() + let content: unknown = text + try { + content = JSON.parse(text) as unknown + } catch { + // Markdown/DSL and plain command results retain their text representation. + } + return { content, ...(response.ok ? {} : { isError: true }) } +} + +interface TargetInput { + args?: Record + delivery?: 'fallback' | 'mailbox' | 'realtime' + idempotencyKey?: string + path: string + ttlSeconds?: number +} + +export async function invokeMcpTarget( + c: AppContext, + env: RouteEnv, + app: TbHono, + input: TargetInput, +): Promise { + const encoded = mcpPath(input.path) + const path = canonicalizePath(input.path) + const args = { ...input.args } + if (input.delivery !== undefined && Object.hasOwn(args, '~delivery')) { + throw new TBError('invalid_argument', 'delivery and args.~delivery cannot both be provided') + } + const delivery = input.delivery ?? args['~delivery'] + delete args['~delivery'] + if (Object.keys(args).some(key => key.startsWith('~'))) { + throw new TBError('invalid_argument', 'unknown invocation control in args') + } + if (delivery !== undefined && delivery !== 'realtime' && delivery !== 'mailbox' && delivery !== 'fallback') { + throw new TBError('invalid_argument', 'delivery must be realtime, mailbox or fallback') + } + if ((input.ttlSeconds !== undefined || input.idempotencyKey !== undefined) + && input.delivery !== 'mailbox' && input.delivery !== 'fallback') { + throw new TBError('invalid_argument', 'ttlSeconds and idempotencyKey require mailbox or fallback delivery') + } + + const { deps, searchSync } = env + const ctx = c.get('ctx') + const registry = new NodeRegistryStore(c.get('store')) + const resolved = await registry.resolve(path).catch(() => null) + if (resolved === null) throw TBError.notFound('not found') + const { node, rest: command } = resolved + + // A remote owns the full remaining path. Request only that command's help, not its tree. + if (node.kind === 'remote') { + if (!check(ctx, path, 'read').allow) throw TBError.notFound('not found') + if (!check(ctx, path, 'call').allow) { + throw new TBError('permission_denied', `no scope grants 'call' on '${path}'`) + } + const response = await mcpRequest(c, app, `/${encoded}/~help`) + if (!response.ok) return mcpResponse(response) + const model = helpJsonSchema.safeParse(await response.json().catch(() => null)) + if (!model.success) throw new TBError('unavailable', 'remote command help returned invalid JSON') + const selected = model.data.cmds.find(cmd => canonicalizePath(cmd.path) === path) + if (selected === undefined) throw TBError.notFound('not found') + validateMcpArguments(selected.inputSchema, args, path) + } else { + if (command === '' || command.includes('/') || !check(ctx, node.path, 'read').allow) { + throw TBError.notFound('not found') + } + const marker = deviceToolMarker(node) + if (marker === null && (node.kind === 'mcp' || node.kind === 'http' || node.kind === 'tool')) { + if (!check(ctx, node.path, 'call').allow) { + throw new TBError('permission_denied', `no scope grants 'call' on '${node.path}'`) + } + if (delivery !== undefined) { + throw new TBError('invalid_argument', 'delivery is only supported by device-backed tool commands') + } + const provider = await providerFor(node, ctx, deps) + const raw = await upstreamTools(node, provider, deps, false, new Date().toISOString(), searchSync) + const selected = virtualizeTools(node.virtualize, raw).exposed.find(tool => tool.name === command) + if (selected === undefined) throw TBError.notFound('not found') + validateMcpArguments(selected.inputSchema, args, path) + // Keep native MCP content blocks, structured output and business errors intact. + return provider.call(resolveUpstreamTool(node.virtualize, raw, command), args) + } + if (marker !== null && marker.cmds === undefined) { + // An omitted device command catalog permits known realtime paths in HTBP. + // An explicitly empty catalog still means no commands; never invent a schema. + if (!check(ctx, node.path, 'call').allow) { + throw new TBError('permission_denied', `no scope grants 'call' on '${node.path}'`) + } + } else { + const model = await helpModelFor(node, registry, ctx, env.builtinsOf(c.get('store')), deps, { + includeDirectUpload: check(ctx, node.path, 'write').allow, + now: new Date().toISOString(), + refresh: false, + schemas: true, + searchSync, + }) + const selected = model.cmds.find(cmd => canonicalizePath(cmd.path) === path) + if (selected === undefined) throw TBError.notFound('not found') + if (!check(ctx, node.path, selected.scope).allow) { + throw new TBError('permission_denied', `no scope grants '${selected.scope}' on '${node.path}'`) + } + validateMcpArguments(selected.inputSchema, args, path) + } + } + + const query = input.ttlSeconds === undefined ? '' : `?ttlSeconds=${input.ttlSeconds}` + const response = await mcpRequest(c, app, `/${encoded}${query}`, { + ...args, + ...(delivery === undefined ? {} : { '~delivery': delivery }), + }, input.idempotencyKey === undefined ? undefined : { 'x-tb-idempotency-key': input.idempotencyKey }) + const result = await mcpResponse(response) + if (delivery !== undefined && response.ok) { + return { + content: response.status === 202 + ? { delivery: 'mailbox', operation: result.content } + : { delivery: 'realtime', result: result.content }, + } + } + return result +} diff --git a/packages/app/src/mcpServer.ts b/packages/app/src/mcpServer.ts index d0d48c9a..1a78727b 100644 --- a/packages/app/src/mcpServer.ts +++ b/packages/app/src/mcpServer.ts @@ -9,8 +9,8 @@ * * **缓存提示(SEP-2549)**:`tools/list` 结果携带 `ttlMs`/`cacheScope`。SDK 只在 modern * 编解码器上填充这两个字段,2025 响应的线上形状完全不变,故无需按 era 分叉。 - * `cacheScope` 恒为 `private`:本网关的工具清单经调用方 scope 过滤(见 tbApp 的 - * Authorizer.Check),对共享中间层可缓存等同于跨身份泄露目录。 + * `cacheScope` 恒为 `private`:入口按部署能力裁剪,每次请求仍独立鉴权。 + * 业务工具目录只通过 tb_search / tb_help 按需读取,不进入 tools/list。 */ import { @@ -25,58 +25,17 @@ import { WebStandardStreamableHTTPServerTransport, } from '@modelcontextprotocol/server' import { CallToolResultSchema, ToolSchema } from '@modelcontextprotocol/core' +import { isTBError, type ToolResult, type ToolSpec } from '@tool-bridge/core' import { ToolJsonSchemaValidator } from './jsonSchemaValidator' -export interface McpBridgeTool { - confirm?: boolean - delivery?: 'both' | 'mailbox' | 'realtime' - description?: string - effect?: string - identity: string - inputSchema?: unknown - invokePath: string - mcpName?: string - operation?: 'help' | 'listNodes' | 'search' - providerBacked?: boolean - sourcePath: string - toolName: string -} +/** Only the fixed gateway entry points are MCP tools; command paths stay in arguments. */ +export type McpBridgeTool = ToolSpec export interface McpToolBridge { - call(tool: McpBridgeTool, args: Record): Promise<{ - content: unknown - isError?: boolean - structuredContent?: Record - }> + call(tool: McpBridgeTool, args: Record): Promise list(): Promise } -function hex(bytes: Uint8Array): string { - return [...bytes].map(byte => byte.toString(16).padStart(2, '0')).join('') -} - -/** Length-unambiguous identity for an HTBP invocation tuple (direct command path + tool name). */ -export function mcpToolIdentity(invokePath: string, toolName: string): string { - return JSON.stringify([invokePath, toolName]) -} - -/** MCP-safe encoding; long identities retain a readable prefix plus collision-resistant SHA-256. */ -export async function mcpToolName(identity: string): Promise { - const encoded = [...new TextEncoder().encode(identity)] - .map((byte) => { - const char = String.fromCharCode(byte) - if (/[A-Za-z0-9.-]/.test(char)) return char - if (char === '_') return '__' - return `_${byte.toString(16).padStart(2, '0')}` - }) - .join('') - const prefixed = `tb_${encoded}` - if (prefixed.length <= 128) return prefixed - - const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(identity))) - return `tb_${encoded.slice(0, 60)}_${hex(digest)}` -} - /** JSON Schema 2020-12 里取「单个子 schema」的关键字。 */ const SUBSCHEMA_KEYS = [ 'additionalProperties', 'contains', 'else', 'if', 'items', 'not', @@ -97,8 +56,8 @@ const MAX_SCHEMA_DEPTH = 32 * * 存在的理由:v2 的 `ToolSchema` 把 `inputSchema.properties` 放宽成 * `Record`,`{ properties: { value: 'not-a-schema' } }` 能过 parse; - * cfworker 编译时也不报错,反而把那条属性当作无约束——即 `validateInput` 对它静默放行。 - * 两道防线同时失效会让畸形上游 schema 变成调用路径上的 fail-open,故在投影处显式关死。 + * SDK 的 AJV adapter 默认关闭 meta-schema 校验。这里保留结构与深度边界, + * 具体方言和关键字语义仍由官方 adapter 校验,不把畸形 schema 当成无约束输入。 */ function assertSchemaShape(value: unknown, path: string, depth = 0): void { if (typeof value === 'boolean') return @@ -108,7 +67,12 @@ function assertSchemaShape(value: unknown, path: string, depth = 0): void { if (depth >= MAX_SCHEMA_DEPTH) throw new Error(`JSON Schema nested too deeply at '${path}'`) const schema = value as Record for (const key of SUBSCHEMA_KEYS) { - if (schema[key] !== undefined) assertSchemaShape(schema[key], `${path}/${key}`, depth + 1) + const sub = schema[key] + // Older JSON Schema dialects use an array-valued items for tuples. The SDK + // chooses the dialect and rejects this form for 2020-12 itself. + if (key === 'items' && Array.isArray(sub)) { + sub.forEach((item, i) => assertSchemaShape(item, `${path}/items/${i}`, depth + 1)) + } else if (sub !== undefined) assertSchemaShape(sub, `${path}/${key}`, depth + 1) } for (const key of SUBSCHEMA_MAP_KEYS) { const map = schema[key] @@ -130,10 +94,9 @@ function assertSchemaShape(value: unknown, path: string, depth = 0): void { function inputSchemaOf( raw: unknown, - delivery?: McpBridgeTool['delivery'], ): Tool['inputSchema'] { - if (raw === undefined && delivery === undefined) return { type: 'object', properties: {} } - const source = raw ?? { type: 'object', properties: {} } + if (raw === undefined) return { type: 'object', properties: {} } + const source = raw if (source === null || typeof source !== 'object' || Array.isArray(source)) { throw new Error('MCP tool inputSchema must be a JSON Schema object') } @@ -142,29 +105,26 @@ function inputSchemaOf( throw new Error('MCP tool inputSchema root type must be object') } assertSchemaShape(schema, 'inputSchema') - if (delivery !== 'mailbox' && delivery !== 'both') { - return { ...schema, type: 'object' } as Tool['inputSchema'] + return { ...schema, type: 'object' } as Tool['inputSchema'] +} + +function inputValidator(raw: unknown, path: string) { + try { + // The MCP SDK's Tool schema permits JSONValue properties; the shape check above + // narrows them to JSON Schemas before passing them to its official AJV adapter. + return new ToolJsonSchemaValidator().getValidator( + inputSchemaOf(raw) as Parameters[0], + ) + } catch { + throw new ProtocolError(ProtocolErrorCode.InternalError, `invalid input schema for '${path}'`) } - const properties = schema.properties - if (properties !== undefined && ( - properties === null - || typeof properties !== 'object' - || Array.isArray(properties) - )) throw new Error('MCP tool inputSchema properties must be an object') - return { - ...schema, - type: 'object', - properties: { - ...((properties ?? {}) as Record), - '~delivery': { - type: 'string', - enum: delivery === 'both' - ? ['realtime', 'mailbox', 'fallback'] - : ['mailbox', 'fallback'], - description: 'Tool Bridge delivery policy; fallback prefers realtime and queues only when dispatch definitely did not occur.', - }, - }, - } as Tool['inputSchema'] +} + +/** Use the existing SDK AJV adapter, compiling only the selected command's schema. */ +export function validateMcpArguments(raw: unknown, args: Record, path: string): void { + const validate = inputValidator(raw, path) + const result = validate(args) + if (!result.valid) throw new ProtocolError(ProtocolErrorCode.InvalidParams, result.errorMessage) } function annotationsOf(tool: McpBridgeTool): ToolAnnotations | undefined { @@ -181,72 +141,26 @@ interface ProjectedTool { validateInput: ReturnType } -async function projectTools( - source: McpBridgeTool[], - validator: ToolJsonSchemaValidator, -): Promise { - const unique = new Map() - for (const tool of source) { - if (unique.has(tool.identity)) { - throw new ProtocolError(ProtocolErrorCode.InternalError, 'duplicate HTBP tool identity') - } - unique.set(tool.identity, tool) - } - const projected = await Promise.all( - [...unique.values()].map(async (bridge) => { - const name = bridge.mcpName ?? await mcpToolName(bridge.identity) - const annotations = annotationsOf(bridge) - const description = bridge.description === undefined - ? `HTBP ${bridge.invokePath}` - : `${bridge.description}\n\nHTBP ${bridge.invokePath}` - let inputSchema: Tool['inputSchema'] - try { - inputSchema = inputSchemaOf(bridge.inputSchema, bridge.delivery) - } catch { - throw new ProtocolError(ProtocolErrorCode.InternalError, - `invalid input schema for '${bridge.sourcePath}/${bridge.toolName}'`, - ) - } - const candidate = { - name, - description, - inputSchema, - _meta: { - 'io.tool-bridge/path': bridge.sourcePath, - 'io.tool-bridge/command': bridge.toolName, - }, - ...(annotations !== undefined ? { annotations } : {}), - } - const parsed = ToolSchema.safeParse(candidate) - if (!parsed.success) { - throw new ProtocolError(ProtocolErrorCode.InternalError, - `invalid tool metadata for '${bridge.sourcePath}/${bridge.toolName}'`, - ) - } - let validateInput: ProjectedTool['validateInput'] - try { - // ToolSchema 把 inputSchema.properties 定为 Record(含 null), - // 而 getValidator 要 Record——SDK 自身两侧类型不咬合。运行时 - // 无碍(非法 schema 由 getValidator 抛出,下面 catch 兜住),故在此单点收窄。 - validateInput = validator.getValidator( - parsed.data.inputSchema as Parameters[0], - ) - } catch { - throw new ProtocolError(ProtocolErrorCode.InternalError, - `invalid input schema for '${bridge.sourcePath}/${bridge.toolName}'`, - ) - } - return { bridge, tool: parsed.data, validateInput } - }), - ) +function projectTools(source: McpBridgeTool[]): ProjectedTool[] { const names = new Set() - for (const item of projected) { - if (names.has(item.tool.name)) { + return source.map((bridge) => { + if (names.has(bridge.name)) { throw new ProtocolError(ProtocolErrorCode.InternalError, 'MCP tool name collision') } - names.add(item.tool.name) - } - return projected.sort((a, b) => a.tool.name.localeCompare(b.tool.name)) + names.add(bridge.name) + const annotations = annotationsOf(bridge) + const validateInput = inputValidator(bridge.inputSchema, bridge.name) + const parsed = ToolSchema.safeParse({ + name: bridge.name, + description: bridge.description, + inputSchema: inputSchemaOf(bridge.inputSchema), + ...(annotations === undefined ? {} : { annotations }), + }) + if (!parsed.success) { + throw new ProtocolError(ProtocolErrorCode.InternalError, `invalid tool metadata for '${bridge.name}'`) + } + return { bridge, tool: parsed.data, validateInput } + }).sort((a, b) => a.tool.name.localeCompare(b.tool.name)) } function contentOf(value: unknown): CallToolResult['content'] { @@ -258,14 +172,7 @@ function contentOf(value: unknown): CallToolResult['content'] { return [{ type: 'text', text }] } -/** - * 造一个只服务本次请求的 Server。工厂形态是 `createMcpHandler` 的要求:modern 与 - * legacy 两条腿各自取一个新实例,互不共享状态。 - * - * `Server`(低阶 API)在 v2 标了 deprecated,推荐 `McpServer`——但那套是围绕 - * `registerTool` 的静态注册设计的,而本网关的工具集是每请求从 HTBP 树按调用方权限 - * 现算的(projectTools),属文档所称的 advanced use case,故继续用低阶 API。 - */ +/** Fixed gateway tools are assembled per authenticated request, for both MCP eras. */ function buildMcpServer(version: string, bridge: McpToolBridge, listTtlMs: number): Server { const validator = new ToolJsonSchemaValidator() const server = new Server( @@ -277,12 +184,13 @@ function buildMcpServer(version: string, bridge: McpToolBridge, listTtlMs: numbe cacheHints: { 'tools/list': { ttlMs: listTtlMs, cacheScope: 'private' } }, }, ) + let projected: Promise | undefined + const tools = () => projected ??= bridge.list().then(projectTools) server.setRequestHandler('tools/list', async () => ({ - tools: (await projectTools(await bridge.list(), validator)).map(item => item.tool), + tools: (await tools()).map(item => item.tool), })) server.setRequestHandler('tools/call', async (rpc) => { - const projected = await projectTools(await bridge.list(), validator) - const selected = projected.find(item => item.tool.name === rpc.params.name) + const selected = (await tools()).find(item => item.tool.name === rpc.params.name) if (selected === undefined) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'tool not found') } @@ -291,9 +199,15 @@ function buildMcpServer(version: string, bridge: McpToolBridge, listTtlMs: numbe if (!validation.valid) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, validation.errorMessage) } - const result = await bridge.call(selected.bridge, args) + let result: ToolResult + try { + result = await bridge.call(selected.bridge, args) + } catch (error) { + if (!isTBError(error)) throw error + result = { content: error.toJSON(), isError: true } + } const candidate = { - content: contentOf(result.content), + content: contentOf(result.contentBlocks ?? result.content), ...(result.isError === true ? { isError: true } : {}), ...(result.structuredContent !== undefined ? { structuredContent: result.structuredContent } diff --git a/packages/app/src/routes/mcp.ts b/packages/app/src/routes/mcp.ts index 984c50a0..f82d459e 100644 --- a/packages/app/src/routes/mcp.ts +++ b/packages/app/src/routes/mcp.ts @@ -1,475 +1,111 @@ -/** - * `/~mcp`:把 HTBP 树无状态投影成一个 MCP server。 - * - * 每次请求现算工具清单(控制面三工具 + 树上可见节点的命令/工具),因此不存在 - * isolate 本地会话状态——鉴权始终是当次请求的 Bearer 身份。调用一律回灌到本 app - * 自己的 HTTP 面(或直连 Provider),协议行为与 Agent 直接 fetch 完全一致。 - */ import { - type Action, - check, - DEFAULT_MAX_NODES, - type HelpModel, - MAX_TREE_DEPTH, - NodeRegistryStore, - resolveUpstreamTool, - TBError, - type ToolResult, - type ToolSpec, - type TreeNode, - type TreePath, - validatePath, - virtualizeTools, -} from '@tool-bridge/core' -import { helpJsonSchema } from '@tool-bridge/core/protocol' + deviceOperationStateSchema, + toolSearchOptionsSchema, +} from '@tool-bridge/core/protocol' +/** Fixed MCP entry points. Business tools are discovered and invoked by HTBP path. */ +import { MAX_TREE_DEPTH, OperationRegistry, TBError, type ToolResult } from '@tool-bridge/core' +import { z } from 'zod/v4' import type { RouteEnv } from './env' -import { - remotePassthroughIfMatch, - remotePathProjectorIfMatch, - remoteProtocolError, - remoteTreeChildren, -} from '../federation' -import { - handleMcpRequest, - type McpBridgeTool, - type McpToolBridge, - mcpToolIdentity, -} from '../mcpServer' +import { invokeMcpTarget, mcpPath, mcpRequest, mcpResponse } from '../mcpInvoke' import { type AppContext, type TbHono, TOOL_CACHE_TTL_DEFAULT } from '../deps' -import { providerFor, upstreamTools } from '../toolNodes' -import { pruneExpiredContext } from '../contextNodes' -import { deviceToolMarker } from '../deviceNodes' -import { helpModelFor } from '../helpModel' +import { handleMcpRequest, type McpToolBridge } from '../mcpServer' import { runHandler } from '../responses' -/** 单次 `/~mcp` 请求内允许的远端发现往返上限(联邦子树可能很深)。 */ -const MCP_REMOTE_MAX_REQUESTS = 32 - -const mcpCommand = ( - nodePath: TreePath, - nodeDescription: string, - command: { - confirm?: boolean - delivery?: 'both' | 'mailbox' | 'realtime' - effect?: string - h?: string - inputSchema?: unknown - name: string - path: string - scope: Action - }, -): McpBridgeTool => { - const modelPath = nodePath.replace(/^\/+|\/+$/g, '') - const commandPath = command.path.replace(/^\/+|\/+$/g, '') - // command.path 现在恒为完整命令路径(含叶子段);它必须落在节点子树内。 - if (!commandPath.startsWith(`${modelPath}/`)) { - throw new TBError('internal', `command path '${command.path}' escapes node '${nodePath}'`) - } - const invokePath = `/${commandPath}` - return { - identity: mcpToolIdentity(invokePath, command.name), - sourcePath: nodePath, - toolName: command.name, - invokePath, - description: command.h ?? nodeDescription, - ...(command.inputSchema !== undefined ? { inputSchema: command.inputSchema } : {}), - ...(command.delivery !== undefined ? { delivery: command.delivery } : {}), - ...(command.effect !== undefined ? { effect: command.effect } : {}), - ...(command.confirm === true ? { confirm: true } : {}), - } -} - -const toolSpecCommand = ( - node: TreeNode, - tool: ToolSpec, - providerBacked = false, -): McpBridgeTool => { - const invokePath = `/${node.path}/${tool.name}` - return { - identity: mcpToolIdentity(invokePath, tool.name), - sourcePath: node.path, - toolName: tool.name, - invokePath, - ...(providerBacked ? { providerBacked: true } : {}), - description: tool.description ?? node.description, - ...(tool.inputSchema !== undefined ? { inputSchema: tool.inputSchema } : {}), - ...(tool.delivery !== undefined ? { delivery: tool.delivery } : {}), - ...(tool.effect !== undefined ? { effect: tool.effect } : {}), - ...(tool.confirm === true ? { confirm: true } : {}), - } +const callInput = z.strictObject({ + path: z.string().min(1).describe('Complete command path returned by tb_help or tb_search; the same path as tb call.'), + args: z.record(z.string(), z.unknown()).optional().describe('The command arguments object. Defaults to {}.'), + delivery: z.enum(['realtime', 'mailbox', 'fallback']).optional(), + ttlSeconds: z.number().int().positive().optional(), + idempotencyKey: z.string().min(1).optional(), +}) + +const operationIdentity = { + deviceId: z.string().min(1), + operationId: z.string().min(1), } +const deviceOperationInput = z.discriminatedUnion('action', [ + z.strictObject({ + action: z.literal('list'), + deviceId: z.string().min(1), + cursor: z.string().min(1).optional(), + limit: z.number().int().positive().optional(), + states: z.array(deviceOperationStateSchema).min(1).optional(), + }), + z.strictObject({ action: z.literal('get'), ...operationIdentity }), + z.strictObject({ action: z.literal('cancel'), ...operationIdentity }), +]) -/** - * 当次请求的 MCP 投影桥:list 现算可见工具集,call 回灌本 app 的 HTTP 面(或直连 Provider)。 - * - * `app` 必须是装配中的同一实例——回灌走 `app.request`,才能复用鉴权中间件与全部路由语义。 - */ function mcpBridgeFor(c: AppContext, env: RouteEnv, app: TbHono): McpToolBridge { - const { builtinsOf, deps, globalSearchCapabilities, searchSync } = env - const ctx = c.get('ctx') - const registry = new NodeRegistryStore(c.get('store')) - let remoteRequests = 0 + const operations = new OperationRegistry() + const request = async (path: string, body?: unknown): Promise => + mcpResponse(await mcpRequest(c, app, path, body)) - const controlTools = (): McpBridgeTool[] => [ - ...(globalSearchCapabilities().includes('search') - ? [{ - identity: JSON.stringify(['control', 'search']), - sourcePath: '', - toolName: 'Search', - invokePath: '/~search', - mcpName: 'tb_search', - operation: 'search' as const, - description: 'Search visible tools across the Tool Bridge tree (compact by default).', - effect: 'read', - inputSchema: { - type: 'object', - additionalProperties: false, - properties: { - query: { type: 'string', minLength: 1 }, - mode: { type: 'string', enum: ['keyword'] }, - limit: { type: 'integer', minimum: 1, maximum: 200 }, - cursor: { type: 'string', minLength: 1 }, - detail: { type: 'string', enum: ['compact', 'full'], default: 'compact' }, - effects: { - type: 'array', - minItems: 1, - items: { - type: 'string', - enum: ['read', 'write', 'destructive', 'unknown'], - }, - }, - federation: { type: 'string', enum: ['local', 'recursive'] }, - matching: { type: 'string', enum: ['best', 'all'] }, - minCoverage: { type: 'number', exclusiveMinimum: 0, maximum: 1 }, - pathPrefix: { type: 'string', minLength: 1 }, - }, - required: ['query'], - }, - }] - : []), - { - identity: JSON.stringify(['control', 'help']), - sourcePath: '', - toolName: 'Help', - invokePath: '/~help', - mcpName: 'tb_help', - operation: 'help', - description: 'Describe a visible Tool Bridge node or one of its tools.', + if (env.globalSearchCapabilities().includes('search')) { + operations.register('tb_search', { + description: 'Search visible Tool Bridge tools, compact by default. Use tb_help on a selected command path for its full schema, then tb_call. Use detail="full" to include schemas in this response.', effect: 'read', - inputSchema: { - type: 'object', - additionalProperties: false, - properties: { - path: { type: 'string' }, - tool: { type: 'string', minLength: 1, pattern: '^[^/]+$' }, - format: { type: 'string', enum: ['json', 'markdown', 'dsl'] }, - }, - }, - }, - { - identity: JSON.stringify(['control', 'list-nodes']), - sourcePath: '', - toolName: 'List', - invokePath: '/~tree', - mcpName: 'tb_list_nodes', - operation: 'listNodes', - description: 'List the visible Tool Bridge node tree from a path.', - effect: 'read', - inputSchema: { - type: 'object', - additionalProperties: false, - properties: { - path: { type: 'string' }, - depth: { type: 'integer', minimum: 0, maximum: MAX_TREE_DEPTH }, - }, - }, - }, - ] - - const takeRemoteRequest = (): void => { - remoteRequests += 1 - if (remoteRequests > MCP_REMOTE_MAX_REQUESTS) { - throw new TBError('unavailable', 'remote MCP discovery request budget exceeded', { - retryable: false, - }) - } + inputSchema: toolSearchOptionsSchema.extend({ query: z.string().trim().min(1) }), + }, async ({ query, ...opts }) => request('/~search', { query, opts })) } - - // remote 成功响应固定取 JSON,经权威 RemotePathProjector.projectHelp 严格收敛并 rebase - // 到本地挂载(与 ~help 路由同一实现):node path 匹配、命令 containment、children 直接 - // 后代校验与可见性裁剪都在投影里,返回的 HelpModel 已是本地完整路径。 - const remoteHelp = async (path: TreePath): Promise => { - takeRemoteRequest() - const projector = await remotePathProjectorIfMatch(registry, path) - if (projector === null) { - throw remoteProtocolError(`remote ~help path '${path}' lost its mount owner`) - } - const headers = new Headers(c.req.raw.headers) - headers.set('accept', 'application/json') - const response = await remotePassthroughIfMatch( - c, - ctx, - registry, - path, - '~help', - deps, - headers, - ) - if (response === null || !response.ok) { - throw new TBError('unavailable', `remote ~help failed for '${path}'`, { retryable: true }) - } - const parsed = helpJsonSchema.safeParse(await response.json().catch(() => null)) - if (!parsed.success) { - throw new TBError('unavailable', `remote ~help returned invalid JSON for '${path}'`) + operations.register('tb_help', { + description: 'Describe a visible node or complete command path, including input schema, permissions, effects and device delivery. Root help browses available nodes. Equivalent to tb help.', + effect: 'read', + inputSchema: z.strictObject({ + path: z.string().optional(), + tool: z.string().min(1).regex(/^[^/]+$/).optional(), + format: z.enum(['json', 'markdown', 'dsl']).optional(), + schemas: z.boolean().optional(), + }), + }, async ({ path = '', tool, format = 'json', schemas }) => { + const prefix = mcpPath(path, true) + if (tool !== undefined && prefix === '') { + throw new TBError('invalid_argument', 'tool detail requires a node path') } - return projector.projectHelp(parsed.data, path, ctx) - } - - const remotePaths = async (root: TreePath): Promise => { - const found: TreePath[] = [] - const seen = new Set() - const pending: Array<{ depth: number, path: TreePath }> = [{ depth: 0, path: root }] - while (pending.length > 0) { - const current = pending.shift() - if (current === undefined || seen.has(current.path)) continue - const { depth, path } = current - seen.add(path) - if (!check(ctx, path, 'read').allow || !check(ctx, path, 'call').allow) continue - const owner = await registry.resolve(path).catch(() => null) - if (owner?.node.path !== root || owner.node.kind !== 'remote') continue - if (found.length >= DEFAULT_MAX_NODES) { - throw new TBError('unavailable', 'remote MCP discovery node budget exceeded', { - retryable: false, - }) - } - found.push(path) - takeRemoteRequest() - const children = await remoteTreeChildren(c, ctx, registry, path, deps) - if (children.length > 0 && depth >= MAX_TREE_DEPTH) { - throw new TBError('unavailable', 'remote MCP discovery depth exceeded', { - retryable: false, - }) - } - for (const child of children) { - if (child.path.startsWith(`${root}/`) && !seen.has(child.path)) { - pending.push({ path: child.path, depth: depth + 1 }) - } - } - } - return found - } - - const list = async (): Promise => { - const now = new Date().toISOString() - const nodes = await pruneExpiredContext(await registry.subtree(''), registry) - const result: McpBridgeTool[] = controlTools() - - for (const node of nodes) { - if (node.kind === 'directory') continue - if (!check(ctx, node.path, 'read').allow) continue - - if (node.kind === 'remote') { - for (const path of await remotePaths(node.path)) { - const model = await remoteHelp(path) - for (const command of model.cmds) { - if (check(ctx, path, command.scope).allow) { - let detailed = command - // 投影后 command.path 已是 `/<本地完整命令路径>`;缺 schema 且非节点自身时 - // 直接对该路径再取一次工具级 ~help(两级披露的细节级)。 - if (command.inputSchema === undefined && command.path !== `/${model.node.path}`) { - const detail = await remoteHelp(command.path.replace(/^\/+/, '')) - detailed = detail.cmds.find(item => item.name === command.name) ?? command - } - result.push(mcpCommand(path, model.node.description, detailed)) - } - } - } - continue - } - - const marker = deviceToolMarker(node) - if (marker !== null) { - if (check(ctx, node.path, 'call').allow) { - result.push(...(marker.cmds ?? []).map(tool => toolSpecCommand(node, tool))) - } - continue - } - - if ( - (node.kind === 'mcp' || node.kind === 'http' || node.kind === 'tool') - && node.config !== undefined - ) { - if (!check(ctx, node.path, 'call').allow) continue - const provider = await providerFor(node, ctx, deps) - const raw = await upstreamTools(node, provider, deps, false, now, searchSync) - const { exposed } = virtualizeTools(node.virtualize, raw) - result.push(...exposed.map(tool => toolSpecCommand(node, tool, true))) - continue - } - - const model = await helpModelFor(node, registry, ctx, builtinsOf(c.get('store')), deps, { - includeDirectUpload: check(ctx, node.path, 'write').allow, - refresh: false, - now, - searchSync, - }) - for (const command of model.cmds) { - if (check(ctx, node.path, command.scope).allow) { - result.push(mcpCommand(node.path, node.description, command)) - } - } - } - return result - } + const command = tool === undefined ? prefix : `${prefix}/${mcpPath(tool)}` + const response = await mcpRequest(c, app, `/${command ? `${command}/` : ''}~help${schemas ? '?schemas=1' : ''}`, undefined, { + accept: format === 'json' ? 'application/json' : format === 'dsl' ? 'text/plain' : 'text/markdown', + }) + return mcpResponse(response) + }) + operations.register('tb_list_nodes', { + description: 'Browse visible Tool Bridge nodes at a bounded depth. Use tb_help to inspect one node or command. Equivalent to tb ls / tb tree; works without search.', + effect: 'read', + inputSchema: z.strictObject({ + path: z.string().optional(), + depth: z.number().int().min(0).max(MAX_TREE_DEPTH).optional(), + }), + }, async ({ path = '', depth = 1 }) => { + const prefix = mcpPath(path, true) + return request(`/${prefix ? `${prefix}/` : ''}~tree?depth=${depth}`) + }) + operations.register('tb_call', { + description: 'Invoke one Tool Bridge command by its complete path and arguments, equivalent to tb call. Discover unknown commands with tb_search / tb_help; check the target effect and confirm before writes. Supports tools, Context, Skill and system commands. Device delivery may return a mailbox operation; inspect it with tb_device_operations. Never retry a write merely because the result is unknown.', + effect: 'destructive', + confirm: true, + inputSchema: callInput, + }, async input => invokeMcpTarget(c, env, app, input)) + operations.register('tb_device_operations', { + description: 'List, inspect or cancel device mailbox operations, equivalent to tb device op. Use the original deviceId and operationId. Cancellation of a claimed operation is cooperative; result_unknown and expired with executionMayHaveOccurred do not establish that a retry is safe.', + effect: 'destructive', + confirm: true, + inputSchema: deviceOperationInput, + }, async (input) => { + const { action, deviceId } = input + const body = action === 'list' + ? { deviceId, opts: { cursor: input.cursor, limit: input.limit, states: input.states } } + : { deviceId, operationId: input.operationId } + return request(`/~device/operations/${action}`, body) + }) return { - list, - call: async (tool, args) => { - const resultFromResponse = async (response: Response): Promise<{ - content: unknown - isError?: boolean - }> => { - const text = await response.text() - let value: unknown = text - try { - value = JSON.parse(text) as unknown - } catch { - // Text help/DSL results remain MCP text content. - } - return { content: value, ...(response.ok ? {} : { isError: true }) } - } - if (tool.operation !== undefined) { - const rawPath = args.path ?? '' - if (typeof rawPath !== 'string') { - throw new TBError('invalid_argument', 'path must be a string') - } - const path = rawPath.replace(/^\/+|\/+$/g, '') - const pathError = validatePath(path, { allowRoot: true }) - if (pathError !== null) throw pathError - const segments = path === '' ? [] : path.split('/') - if (segments.some(segment => segment === '.' || segment === '..')) { - throw new TBError('invalid_argument', 'path contains a dot segment') - } - const encoded = segments.map(segment => encodeURIComponent(segment)) - const headers = new Headers({ - authorization: c.req.header('authorization') ?? '', - }) - - if (tool.operation === 'search') { - headers.set('accept', 'application/json') - headers.set('content-type', 'application/json') - const opts = { - detail: args.detail ?? 'compact', - ...Object.fromEntries( - [ - 'mode', - 'limit', - 'cursor', - 'effects', - 'federation', - 'matching', - 'minCoverage', - 'pathPrefix', - ] - .filter(key => args[key] !== undefined) - .map(key => [key, args[key]]), - ), - } - const response = await app.request(new Request(new URL('/~search', c.req.url), { - method: 'POST', - headers, - body: JSON.stringify({ - query: args.query, - opts, - }), - })) - return await resultFromResponse(response) - } - - if (tool.operation === 'help') { - const detail = args.tool - if (detail !== undefined) { - if (path === '' || typeof detail !== 'string' || detail.includes('/')) { - throw new TBError('invalid_argument', 'tool detail requires a node path and one segment') - } - encoded.push(encodeURIComponent(detail)) - } - const format = args.format ?? 'json' - headers.set('accept', format === 'json' - ? 'application/json' - : format === 'dsl' ? 'text/plain' : 'text/markdown') - const prefix = encoded.length === 0 ? '' : `/${encoded.join('/')}` - return await resultFromResponse(await app.request(new Request( - new URL(`${prefix}/~help`, c.req.url), - { headers }, - ))) - } - - headers.set('accept', 'application/json') - const prefix = encoded.length === 0 ? '' : `/${encoded.join('/')}` - const url = new URL(`${prefix}/~tree`, c.req.url) - if (args.depth !== undefined) url.searchParams.set('depth', String(args.depth)) - return await resultFromResponse(await app.request(new Request(url, { headers }))) - } - if (tool.providerBacked === true) { - let node: TreeNode - try { - node = await registry.get(tool.sourcePath) - } catch { - throw TBError.notFound('not found') - } - if (!check(ctx, node.path, 'read').allow) throw TBError.notFound('not found') - if (!check(ctx, node.path, 'call').allow) { - throw new TBError('permission_denied', `no scope grants 'call' on '${node.path}'`) - } - if ( - (node.kind !== 'mcp' && node.kind !== 'http' && node.kind !== 'tool') - || node.config === undefined - ) { - throw TBError.notFound('not found') - } - const provider = await providerFor(node, ctx, deps) - const raw = await upstreamTools(node, provider, deps, false, new Date().toISOString(), searchSync) - const upstreamName = resolveUpstreamTool(node.virtualize, raw, tool.toolName) - const result: ToolResult = await provider.call(upstreamName, args) - return { - content: result.contentBlocks ?? result.content, - ...(result.isError === true ? { isError: true } : {}), - ...(result.structuredContent !== undefined - ? { structuredContent: result.structuredContent } - : {}), - } - } - const url = new URL(tool.invokePath, c.req.url) - const headers = new Headers({ - 'accept': 'application/json', - 'authorization': c.req.header('authorization') ?? '', - 'content-type': 'application/json', - }) - // 唯一调用形态:直连 `POST `(含命令/工具叶子段),body 即 arguments 本体。 - const response = await app.request( - new Request(url, { method: 'POST', headers, body: JSON.stringify(args) }), - ) - const result = await resultFromResponse(response) - if (args['~delivery'] !== undefined && response.ok) { - return { - content: response.status === 202 - ? { delivery: 'mailbox', operation: result.content } - : { delivery: 'realtime', result: result.content }, - } - } - return result - }, + list: async () => operations.list(), + call: async (tool, args) => operations.call(tool.name, args, undefined), } } -// MCP is an HTBP reserved control segment. Stateless serving keeps every request behind -// the gateway's current Bearer identity instead of trusting isolate-local session state. -// tools/list advertises the same freshness window the gateway's own upstream tool cache -// already serves from, so client-side caching adds no staleness class we don't already have. export function registerMcpRoute(app: TbHono, env: RouteEnv): void { app.all('/~mcp', c => - runHandler(async () => await handleMcpRequest( + runHandler(async () => handleMcpRequest( c.req.raw, env.deps.version, mcpBridgeFor(c, env, app), diff --git a/packages/app/test/mcp.integration.test.ts b/packages/app/test/mcp.integration.test.ts index 038a19ba..72b98e4b 100644 --- a/packages/app/test/mcp.integration.test.ts +++ b/packages/app/test/mcp.integration.test.ts @@ -1,13 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { mcpToolIdentity, mcpToolName, processDeviceHello } from '../src/index' import { connectModernMcpClient, connectTestMcpClient } from './mcpClient' import { createTestApp, TEST_VERSION } from './harness' import { MemorySearchIndex } from './memorySearchIndex' +import { processDeviceHello } from '../src/index' import { TEST_ADMIN_SK } from './fixtures' // 文件级单实例(对齐原 SELF.fetch 语义:一个文件共享一份持久状态)。 // 注入索引:/~mcp 只在宿主提供 SearchIndex 时投影 tb_search(gateway 侧对应 -// 可选的 TB_SEARCH binding),缺省不注入就只有 tb_help / tb_list_nodes。 +// 可选的 TB_SEARCH binding),其余固定入口不依赖动态工具目录。 const search = new MemorySearchIndex() const tb = await createTestApp({ search }) @@ -39,11 +39,12 @@ async function issueSk(input: unknown): Promise { return ((await response.json()) as { secret: string }).secret } -async function mountHttpTools(path: string, tools: unknown[]): Promise { +async function mountHttpTools(path: string, tools: unknown[], virtualize?: Record): Promise { const response = await postJson('system/registry/write', { path, kind: 'http', description: `${path} tools`, + ...(virtualize === undefined ? {} : { virtualize }), config: { kind: 'http', endpoint: 'https://mcp-exit-upstream.test', @@ -233,34 +234,20 @@ describe('MCP consumer endpoint', () => { try { const listed = await client.listTools() - expect(listed.tools.map(tool => tool.name)).toEqual(expect.arrayContaining([ - 'tb_search', - 'tb_help', - 'tb_list_nodes', - ])) + expect(listed.tools.map(tool => tool.name).sort()).toEqual([ + 'tb_call', 'tb_device_operations', 'tb_help', 'tb_list_nodes', 'tb_search', + ]) + expect(listed.tools.find(tool => tool.name === 'tb_call')?.annotations).toMatchObject({ + readOnlyHint: false, destructiveHint: true, + }) expect(listed.tools.find(tool => tool.name === 'tb_search')).toMatchObject({ description: expect.stringContaining('compact by default'), inputSchema: { - type: 'object', - additionalProperties: false, + type: 'object', additionalProperties: false, properties: { query: { type: 'string', minLength: 1 }, mode: { type: 'string', enum: ['keyword'] }, - limit: { type: 'integer', minimum: 1, maximum: 200 }, - cursor: { type: 'string', minLength: 1 }, - detail: { type: 'string', enum: ['compact', 'full'], default: 'compact' }, - effects: { - type: 'array', - minItems: 1, - items: { - type: 'string', - enum: ['read', 'write', 'destructive', 'unknown'], - }, - }, - federation: { type: 'string', enum: ['local', 'recursive'] }, - matching: { type: 'string', enum: ['best', 'all'] }, - minCoverage: { type: 'number', exclusiveMinimum: 0, maximum: 1 }, - pathPrefix: { type: 'string', minLength: 1 }, + detail: { type: 'string', enum: ['compact', 'full'] }, }, required: ['query'], }, @@ -353,6 +340,9 @@ describe('MCP consumer endpoint', () => { it('serves the 2026-07-28 era without a handshake and caches tools/list privately', async () => { await mountHttp('mcp-modern/basic') + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ hello: 'modern' }), { + headers: { 'content-type': 'application/json' }, + }))) const client = await connectModernMcpClient( 'https://tb.test/~mcp', TEST_ADMIN_SK, @@ -367,6 +357,9 @@ describe('MCP consumer endpoint', () => { const cacheable = listed as unknown as { cacheScope?: string, ttlMs?: number } expect(cacheable.cacheScope).toBe('private') expect(cacheable.ttlMs).toBe(300_000) + await expect(client.callTool({ + name: 'tb_call', arguments: { path: '/mcp-modern/basic/greet', args: { name: 'modern' } }, + })).resolves.toMatchObject({ structuredContent: { hello: 'modern' } }) } finally { await client.close() } @@ -389,7 +382,7 @@ describe('MCP consumer endpoint', () => { } }) - it('projects ~delivery and performs one MCP fallback call without a second enqueue tool', async () => { + it('preserves delivery, TTL, idempotency and operation lifecycle through fixed tools', async () => { const deviceSk = await issueSk({ owner: 'device:mcp-mailbox', scopes: [{ pattern: 'device/**', actions: ['read', 'call', 'register'] }], @@ -426,30 +419,171 @@ describe('MCP consumer endpoint', () => { ) try { const listed = await client.listTools() - const command = listed.tools.find( - tool => tool._meta?.['io.tool-bridge/path'] === 'device/mcp-mailbox/tools/mail', - ) - expect(command?.inputSchema).toMatchObject({ - additionalProperties: false, - properties: { - 'text': { type: 'string' }, - '~delivery': { enum: ['realtime', 'mailbox', 'fallback'] }, - }, - }) - const result = await client.callTool({ - name: command?.name ?? '', - arguments: { '~delivery': 'fallback', 'text': 'hello' }, - }) + expect(listed.tools.map(tool => tool.name)).toContain('tb_device_operations') + const input = { + path: '/device/mcp-mailbox/tools/mail/send', + args: { text: 'hello' }, + delivery: 'fallback', + ttlSeconds: 300, + idempotencyKey: 'mcp-send-1', + } + for (const invalid of [ + { path: input.path, args: { 'text': 'hello', '~delivery': 'mailbox' }, delivery: 'mailbox' }, + { path: input.path, args: { text: 'hello' }, ttlSeconds: 30 }, + { path: input.path, args: { text: 'hello' }, delivery: 'realtime', idempotencyKey: 'bad-policy' }, + { path: input.path, args: { 'text': 'hello', '~delivery': 'mailbox' }, ttlSeconds: 30 }, + ]) { + await expect(client.callTool({ name: 'tb_call', arguments: invalid })).resolves.toMatchObject({ + isError: true, structuredContent: { code: 'invalid_argument' }, + }) + } + await expect(client.callTool({ + name: 'tb_device_operations', arguments: { action: 'list', deviceId: 'mcp-mailbox' }, + })).resolves.toMatchObject({ structuredContent: { items: [] } }) + const result = await client.callTool({ name: 'tb_call', arguments: input }) expect(result.structuredContent).toMatchObject({ delivery: 'mailbox', operation: { state: 'queued', targetPath: 'device/mcp-mailbox/tools/mail/send' }, }) + const operation = (result.structuredContent as { + operation: { createdAt: string, expiresAt: string, operationId: string } + }).operation + expect(Date.parse(operation.expiresAt) - Date.parse(operation.createdAt)).toBe(300_000) + await expect(client.callTool({ name: 'tb_call', arguments: input })).resolves.toMatchObject({ + structuredContent: { operation: { operationId: operation.operationId } }, + }) + await expect(client.callTool({ + name: 'tb_device_operations', + arguments: { action: 'list', deviceId: 'mcp-mailbox', states: ['queued'], limit: 1 }, + })).resolves.toMatchObject({ + structuredContent: { items: [expect.objectContaining({ operationId: operation.operationId })] }, + }) + await expect(client.callTool({ + name: 'tb_device_operations', + arguments: { action: 'get', deviceId: 'mcp-mailbox', operationId: operation.operationId }, + })).resolves.toMatchObject({ structuredContent: { state: 'queued' } }) + await expect(client.callTool({ + name: 'tb_device_operations', + arguments: { action: 'cancel', deviceId: 'mcp-mailbox', operationId: operation.operationId }, + })).resolves.toMatchObject({ structuredContent: { state: 'cancelled' } }) + await expect(client.callTool({ + name: 'tb_device_operations', + arguments: { action: 'get', deviceId: 'mcp-mailbox', operationId: operation.operationId }, + })).resolves.toMatchObject({ structuredContent: { state: 'cancelled' } }) + await expect(client.callTool({ + name: 'tb_call', arguments: { ...input, args: { text: 'changed' } }, + })).resolves.toMatchObject({ isError: true }) + await expect(client.callTool({ + name: 'tb_call', arguments: { path: input.path, args: { 'text': 'raw-control', '~delivery': 'mailbox' } }, + })).resolves.toMatchObject({ + structuredContent: { delivery: 'mailbox', operation: { state: 'queued' } }, + }) } finally { await client.close() } }) - it('tools/list clips by command scope and tools/call reuses the HTBP provider path', async () => { + it.each(['completed', 'unknown'] as const)( + 'does not enqueue or repeat a fallback call after %s dispatch', async (disposition) => { + const invoke = vi.fn(async () => disposition === 'completed' + ? { disposition, result: { ok: true as const, value: { delivered: true } } } + : { + disposition, + result: { + ok: false as const, + error: { code: 'unavailable' as const, message: 'connection lost', retryable: true }, + }, + }) + const isolated = await createTestApp({ device: { + ws: async () => new Response(null, { status: 501 }), invoke, + } }) + await processDeviceHello({ + authorization: `Bearer ${TEST_ADMIN_SK}`, + deviceIdHint: 'mcp-dispatch', + hello: { deviceId: 'mcp-dispatch', expose: { nodes: [{ + path: 'tools/mail', kind: 'tool', description: 'mail', + cmds: [{ name: 'send', delivery: 'both', inputSchema: { type: 'object', properties: {} } }], + }] } }, + store: isolated.state, + }) + const client = await connectTestMcpClient('https://tb.test/~mcp', TEST_ADMIN_SK, + (input, init) => isolated.request(input, init)) + try { + const result = await client.callTool({ + name: 'tb_call', arguments: { + path: '/device/mcp-dispatch/tools/mail/send', args: {}, delivery: 'fallback', + }, + }) + expect(invoke).toHaveBeenCalledTimes(1) + if (disposition === 'completed') { + expect(result).toMatchObject({ structuredContent: { delivery: 'realtime', result: { delivered: true } } }) + } else { + expect(result).toMatchObject({ + isError: true, + structuredContent: { code: 'unavailable', retryable: false, message: expect.stringContaining('was not enqueued') }, + }) + } + await expect(client.callTool({ + name: 'tb_device_operations', arguments: { action: 'list', deviceId: 'mcp-dispatch' }, + })).resolves.toMatchObject({ structuredContent: { items: [] } }) + } finally { + await client.close() + } + }, + ) + + it('calls a known realtime device path without descriptors while preserving explicit allowlists and scope', async () => { + const invoke = vi.fn(async () => ({ + disposition: 'completed' as const, result: { ok: true as const, value: { invoked: true } }, + })) + const isolated = await createTestApp({ device: { + ws: async () => new Response(null, { status: 501 }), invoke, + } }) + await processDeviceHello({ + authorization: `Bearer ${TEST_ADMIN_SK}`, + deviceIdHint: 'mcp-dynamic', + hello: { deviceId: 'mcp-dynamic', expose: { nodes: [ + { path: 'tools/dynamic', kind: 'tool', description: 'runtime command without metadata' }, + { path: 'tools/empty', kind: 'tool', description: 'explicit empty allowlist', cmds: [] }, + { path: 'tools/declared', kind: 'tool', description: 'explicit allowlist', cmds: [{ name: 'other' }] }, + ] } }, + store: isolated.state, + }) + const issued = await isolated.request('https://tb.test/system/sk/write', { + method: 'POST', + headers: { ...admin().headers, 'content-type': 'application/json', 'accept': 'application/json' }, + body: JSON.stringify({ owner: 'agent:mcp-dynamic-reader', scopes: [{ pattern: 'device/**', actions: ['read'] }] }), + }) + expect(issued.status).toBe(200) + const { secret } = await issued.json() as { secret: string } + const reader = await connectTestMcpClient('https://tb.test/~mcp', secret, + (input, init) => isolated.request(input, init)) + const caller = await connectTestMcpClient('https://tb.test/~mcp', TEST_ADMIN_SK, + (input, init) => isolated.request(input, init)) + try { + const path = '/device/mcp-dynamic/tools/dynamic/known' + await expect(reader.callTool({ name: 'tb_call', arguments: { path, args: {} } })).resolves.toMatchObject({ isError: true }) + expect(invoke).not.toHaveBeenCalled() + await expect(caller.callTool({ name: 'tb_call', arguments: { path, args: { payload: 'known' } } })).resolves.toMatchObject({ + structuredContent: { invoked: true }, + }) + expect(invoke).toHaveBeenCalledTimes(1) + await expect(caller.callTool({ + name: 'tb_call', arguments: { path, args: {}, delivery: 'mailbox' }, + })).resolves.toMatchObject({ isError: true, structuredContent: { code: 'invalid_argument' } }) + for (const target of ['empty', 'declared']) { + await expect(caller.callTool({ + name: 'tb_call', arguments: { path: `/device/mcp-dynamic/tools/${target}/known`, args: {} }, + })).resolves.toMatchObject({ isError: true, structuredContent: { code: 'not_found' } }) + } + expect(invoke).toHaveBeenCalledTimes(1) + } finally { + await reader.close() + await caller.close() + } + }) + + it('discovers a selected command and enforces its schema and scope before calling', async () => { await mountHttp('mcp-round15/allowed') await mountHttp('mcp-round15/read-only') await mountContext('mcp-round15/context') @@ -475,43 +609,34 @@ describe('MCP consumer endpoint', () => { (input, init) => tb.request(input, init), ) try { - const listed = await client.listTools() - const allowed = listed.tools.find( - tool => tool._meta?.['io.tool-bridge/path'] === 'mcp-round15/allowed', - ) - expect(allowed).toMatchObject({ - description: expect.stringContaining('greet through mcp-round15/allowed'), + const help = await client.callTool({ + name: 'tb_help', arguments: { path: '/mcp-round15/allowed', schemas: true }, + }) + const command = (help.structuredContent as { + cmds: Array<{ inputSchema: unknown, path: string }> + }).cmds[0] + expect(command).toMatchObject({ + path: '/mcp-round15/allowed/greet', inputSchema: { - type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'], + type: 'object', properties: { name: { type: 'string' } }, required: ['name'], }, - _meta: { 'io.tool-bridge/command': 'greet' }, }) - expect( - listed.tools.some( - tool => tool._meta?.['io.tool-bridge/path'] === 'mcp-round15/read-only', - ), - ).toBe(false) - const contextCommands = listed.tools - .filter(tool => tool._meta?.['io.tool-bridge/path'] === 'mcp-round15/context') - .map(tool => tool._meta?.['io.tool-bridge/command']) - expect(contextCommands).toContain('list') - expect(contextCommands).not.toContain('write') - await expect(client.callTool({ - name: allowed?.name ?? '', - arguments: {}, + name: 'tb_call', arguments: { path: command?.path, args: {} }, })).rejects.toThrow(/name|required/i) await expect(client.callTool({ - name: allowed?.name ?? '', - arguments: { name: 42 }, + name: 'tb_call', arguments: { path: command?.path, args: { name: 42 } }, })).rejects.toThrow(/name|string/i) + await expect(client.callTool({ + name: 'tb_call', arguments: { path: '/mcp-round15/read-only/greet', args: { name: 'blocked' } }, + })).resolves.toMatchObject({ isError: true }) + await expect(client.callTool({ + name: 'tb_call', arguments: { path: '/mcp-round15/context/write', args: { path: 'x', entry: { content: 'blocked', contentType: 'text/plain' } } }, + })).resolves.toMatchObject({ isError: true }) expect(upstream).not.toHaveBeenCalled() const called = await client.callTool({ - name: allowed?.name ?? '', - arguments: { name: 'Ada' }, + name: 'tb_call', arguments: { path: command?.path, args: { name: 'Ada' } }, }) expect(called).toMatchObject({ content: [{ type: 'text', text: expect.stringContaining('hello Ada') }], @@ -523,6 +648,76 @@ describe('MCP consumer endpoint', () => { } }) + it('preserves Context, Skill and builtin authorization including read-only mounts', async () => { + const skillFiles = [{ path: 'SKILL.md', content: '---\nname: mcp-skill\ndescription: Test skill.\n---\n# Skill\n' }] + const cases = [ + { kind: 'context', write: 'write', args: { path: 'entry', entry: { content: 'stored', contentType: 'text/plain' } } }, + { kind: 'skillhub', write: 'publish', args: { id: 'mcp-skill', files: skillFiles } }, + ] as const + for (const item of cases) { + for (const readOnly of [false, true]) { + const path = `mcp-access/${item.kind}${readOnly ? '-ro' : ''}` + const mounted = await postJson('system/registry/write', { + path, kind: item.kind, description: 'permission contract', + config: { kind: item.kind, provider: 'storage', readOnly }, + }, admin()) + expect(mounted.status).toBe(200) + mountedPaths.push(path) + } + } + const readerSk = await issueSk({ + owner: 'agent:mcp-access-reader', + scopes: [ + { pattern: 'mcp-access/**', actions: ['read'] }, + { pattern: 'system/annotation', actions: ['read', 'write', 'call'] }, + ], + }) + const reader = await connectTestMcpClient('https://tb.test/~mcp', readerSk, + (input, init) => tb.request(input, init)) + const writer = await connectTestMcpClient('https://tb.test/~mcp', TEST_ADMIN_SK, + (input, init) => tb.request(input, init)) + try { + for (const item of cases) { + const path = `/mcp-access/${item.kind}` + await expect(reader.callTool({ + name: 'tb_call', arguments: { path: `${path}/list`, args: {} }, + })).resolves.toMatchObject({ structuredContent: { items: [] } }) + await expect(reader.callTool({ + name: 'tb_call', arguments: { path: `${path}/${item.write}`, args: item.args }, + })).resolves.toMatchObject({ isError: true }) + await expect(writer.callTool({ + name: 'tb_call', arguments: { path: `${path}/list`, args: {} }, + })).resolves.toMatchObject({ structuredContent: { items: [] } }) + const written = await writer.callTool({ + name: 'tb_call', arguments: { path: `${path}/${item.write}`, args: item.args }, + }) + expect(written.isError).not.toBe(true) + const listed = await reader.callTool({ name: 'tb_call', arguments: { path: `${path}/list`, args: {} } }) + expect((listed.structuredContent as { items: unknown[] }).items).toHaveLength(1) + await expect(writer.callTool({ + name: 'tb_call', arguments: { path: `${path}-ro/${item.write}`, args: item.args }, + })).resolves.toMatchObject({ isError: true }) + await expect(writer.callTool({ + name: 'tb_call', arguments: { path: `${path}-ro/list`, args: {} }, + })).resolves.toMatchObject({ structuredContent: { items: [] } }) + } + const annotation = { path: 'mcp-access/context', text: 'requires admin' } + await expect(reader.callTool({ + name: 'tb_call', arguments: { path: '/system/annotation/set', args: annotation }, + })).resolves.toMatchObject({ isError: true }) + await expect(writer.callTool({ + name: 'tb_call', arguments: { path: '/system/annotation/set', args: annotation }, + })).resolves.toMatchObject({ structuredContent: { text: 'requires admin' } }) + await expect(reader.callTool({ + name: 'tb_call', arguments: { path: '/system/annotation/get', args: { path: annotation.path } }, + })).resolves.toMatchObject({ structuredContent: { text: 'requires admin' } }) + } finally { + await postJson('system/annotation/remove', { path: 'mcp-access/context' }, admin()) + await reader.close() + await writer.close() + } + }) + it('没有 write scope 的 MCP tools/list 不探测直传 signer', async () => { const objectsFactory = vi.fn(async () => { throw new Error('must not resolve object signer') @@ -565,18 +760,16 @@ describe('MCP consumer endpoint', () => { ) try { const listed = await client.listTools() - const commands = listed.tools - .filter(tool => tool._meta?.['io.tool-bridge/path'] === 'mcp-read/context') - .map(tool => tool._meta?.['io.tool-bridge/command']) - expect(commands).toContain('list') - expect(commands).not.toContain('create_upload') + expect(listed.tools.map(tool => tool.name).sort()).toEqual([ + 'tb_call', 'tb_device_operations', 'tb_help', 'tb_list_nodes', + ]) expect(objectsFactory).not.toHaveBeenCalled() } finally { await client.close() } }) - it('reconnects with a narrow SK to shrink the exact tool set and reject stale names', async () => { + it('keeps fixed tool names while a narrower identity rejects previously visible command paths', async () => { await mountHttp('mcp-round16/allowed') await mountHttp('mcp-round16/admin-only') const upstream = vi.fn(async () => @@ -590,23 +783,12 @@ describe('MCP consumer endpoint', () => { TEST_ADMIN_SK, (input, init) => tb.request(input, init), ) - let adminOnlyName = '' - let allowedName = '' + let adminNames: string[] = [] try { - const listed = await adminClient.listTools() - const phasePaths = listed.tools - .filter(tool => String(tool._meta?.['io.tool-bridge/path']).startsWith('mcp-round16/')) - .map(tool => String(tool._meta?.['io.tool-bridge/path'])) - .sort() - expect(phasePaths).toEqual(['mcp-round16/admin-only', 'mcp-round16/allowed']) - adminOnlyName = listed.tools.find( - tool => tool._meta?.['io.tool-bridge/path'] === 'mcp-round16/admin-only', - )?.name ?? '' - allowedName = listed.tools.find( - tool => tool._meta?.['io.tool-bridge/path'] === 'mcp-round16/allowed', - )?.name ?? '' - expect(adminOnlyName).not.toBe('') - expect(allowedName).not.toBe('') + adminNames = (await adminClient.listTools()).tools.map(tool => tool.name) + await expect(adminClient.callTool({ + name: 'tb_help', arguments: { path: '/mcp-round16/admin-only/greet' }, + })).resolves.toMatchObject({ structuredContent: { cmds: [{ path: '/mcp-round16/admin-only/greet' }] } }) } finally { await adminClient.close() } @@ -621,24 +803,15 @@ describe('MCP consumer endpoint', () => { (input, init) => tb.request(input, init), ) try { - const listed = await narrowClient.listTools() - const phaseTools = listed.tools.filter( - tool => String(tool._meta?.['io.tool-bridge/path']).startsWith('mcp-round16/'), - ) - expect(phaseTools.map(tool => tool._meta?.['io.tool-bridge/path'])).toEqual([ - 'mcp-round16/allowed', - ]) - expect(phaseTools[0]?.name).toBe(allowedName) - + expect((await narrowClient.listTools()).tools.map(tool => tool.name)).toEqual(adminNames) await expect(narrowClient.callTool({ - name: adminOnlyName, - arguments: { name: 'forbidden' }, - })).rejects.toThrow(/tool not found/i) + name: 'tb_call', + arguments: { path: '/mcp-round16/admin-only/greet', args: { name: 'forbidden' } }, + })).resolves.toMatchObject({ isError: true }) expect(upstream).not.toHaveBeenCalled() - await expect(narrowClient.callTool({ - name: allowedName, - arguments: { name: 'permitted' }, + name: 'tb_call', + arguments: { path: '/mcp-round16/allowed/greet', args: { name: 'permitted' } }, })).resolves.toMatchObject({ structuredContent: { ok: true } }) expect(upstream).toHaveBeenCalledTimes(1) } finally { @@ -646,7 +819,7 @@ describe('MCP consumer endpoint', () => { } }) - it('invalid provider schemas fail closed before they reach an MCP client', async () => { + it('invalid provider schemas fail closed only when that command is selected', async () => { await mountHttpTools('mcp-round15/invalid-schema', [ { name: 'broken', @@ -654,14 +827,69 @@ describe('MCP consumer endpoint', () => { pathTemplate: '/broken', inputSchema: { type: 'object', properties: { value: 'not-a-schema' } }, }, + { + name: 'healthy', method: 'POST', pathTemplate: '/healthy', + inputSchema: { type: 'object', properties: {} }, + }, ]) + const upstream = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { + headers: { 'content-type': 'application/json' }, + })) + vi.stubGlobal('fetch', upstream) const client = await connectTestMcpClient( 'https://tb.test/~mcp', TEST_ADMIN_SK, (input, init) => tb.request(input, init), ) try { - await expect(client.listTools()).rejects.toThrow(/invalid tool metadata|invalid input schema/i) + await expect(client.listTools()).resolves.toMatchObject({ tools: expect.any(Array) }) + await expect(client.callTool({ + name: 'tb_help', arguments: { path: '/mcp-round15/invalid-schema/healthy' }, + })).resolves.toMatchObject({ structuredContent: { cmds: [{ path: '/mcp-round15/invalid-schema/healthy' }] } }) + await expect(client.callTool({ + name: 'tb_call', arguments: { path: '/mcp-round15/invalid-schema/healthy', args: {} }, + })).resolves.toMatchObject({ structuredContent: { ok: true } }) + expect(upstream).toHaveBeenCalledTimes(1) + await expect(client.callTool({ + name: 'tb_call', arguments: { path: '/mcp-round15/invalid-schema/broken', args: {} }, + })).rejects.toThrow(/invalid tool metadata|invalid input schema/i) + expect(upstream).toHaveBeenCalledTimes(1) + } finally { + await client.close() + } + }) + + it('honors virtualized names and hidden tools when resolving a selected command', async () => { + await mountHttpTools('mcp-virtualized', [{ + name: 'original', method: 'POST', pathTemplate: '/original', + inputSchema: { type: 'object', properties: {} }, + }, { + name: 'secret', method: 'POST', pathTemplate: '/secret', + inputSchema: { type: 'object', properties: {} }, + }], { rename: { original: 'public' }, hide: ['secret'] }) + const upstream = vi.fn(async (input: RequestInfo | URL) => { + expect(String(input)).toContain('/original') + return new Response(JSON.stringify({ ok: true }), { + headers: { 'content-type': 'application/json' }, + }) + }) + vi.stubGlobal('fetch', upstream) + const client = await connectTestMcpClient('https://tb.test/~mcp', TEST_ADMIN_SK, + (input, init) => tb.request(input, init)) + try { + await expect(client.callTool({ + name: 'tb_help', arguments: { path: '/mcp-virtualized' }, + })).resolves.toMatchObject({ structuredContent: { cmds: [{ path: '/mcp-virtualized/public' }] } }) + for (const name of ['original', 'secret']) { + await expect(client.callTool({ + name: 'tb_call', arguments: { path: `/mcp-virtualized/${name}`, args: {} }, + })).resolves.toMatchObject({ isError: true }) + } + expect(upstream).not.toHaveBeenCalled() + await expect(client.callTool({ + name: 'tb_call', arguments: { path: '/mcp-virtualized/public', args: {} }, + })).resolves.toMatchObject({ structuredContent: { ok: true } }) + expect(upstream).toHaveBeenCalledTimes(1) } finally { await client.close() } @@ -717,11 +945,9 @@ describe('MCP consumer endpoint', () => { (input, init) => tb.request(input, init), ) try { - const listed = await client.listTools() - const tool = listed.tools.find( - item => item._meta?.['io.tool-bridge/path'] === 'mcp-round15/native-result', - ) - const called = await client.callTool({ name: tool?.name ?? '', arguments: {} }) + const called = await client.callTool({ + name: 'tb_call', arguments: { path: '/mcp-round15/native-result/fail-richly', args: {} }, + }) expect(called).toMatchObject({ content: [ { type: 'text', text: 'upstream rejected the operation' }, @@ -746,42 +972,29 @@ describe('MCP consumer endpoint', () => { }, }]) - const helpResponse = await tb.request( - 'https://tb.test/mcp-round15/camel-case/~help?schemas=1', - admin({ headers: { accept: 'application/json' } }), - ) - expect(helpResponse.status).toBe(200) - const help = (await helpResponse.json()) as { - cmds: Array<{ name: string, path: string }> + const client = await connectTestMcpClient('https://tb.test/~mcp', TEST_ADMIN_SK, + (input, init) => tb.request(input, init)) + try { + const help = await client.callTool({ + name: 'tb_help', arguments: { path: '/mcp-round15/camel-case', format: 'json' }, + }) + const commands = (help.structuredContent as { cmds: Array<{ name: string, path: string }> }).cmds + expect(commands).toEqual([ + expect.objectContaining({ name: 'getlivecontext', path: '/mcp-round15/camel-case/getlivecontext' }), + ]) + const advertisedPath = commands[0]?.path + await expect(client.callTool({ + name: 'tb_help', arguments: { path: advertisedPath }, + })).resolves.toMatchObject({ + structuredContent: { cmds: [{ name: 'getlivecontext', path: advertisedPath }] }, + }) + await client.callTool({ + name: 'tb_call', arguments: { path: advertisedPath, args: { domain: 'sensor' } }, + }) + expect(toolCalls).toEqual([{ name: 'GetLiveContext', arguments: { domain: 'sensor' } }]) + } finally { + await client.close() } - expect(help.cmds).toEqual([ - expect.objectContaining({ - name: 'getlivecontext', - path: '/mcp-round15/camel-case/getlivecontext', - }), - ]) - const advertisedPath = help.cmds[0]?.path - expect(advertisedPath).toBeDefined() - - const detailResponse = await tb.request( - `https://tb.test${advertisedPath}/~help`, - admin({ headers: { accept: 'application/json' } }), - ) - expect(detailResponse.status).toBe(200) - await expect(detailResponse.json()).resolves.toMatchObject({ - node: { path: 'mcp-round15/camel-case/getlivecontext' }, - cmds: [{ name: 'getlivecontext', path: advertisedPath }], - }) - - const invokeResponse = await postJson( - advertisedPath?.replace(/^\//, '') ?? '', - { domain: 'sensor' }, - admin(), - ) - expect(invokeResponse.status).toBe(200) - expect(toolCalls).toEqual([ - { name: 'GetLiveContext', arguments: { domain: 'sensor' } }, - ]) }) it('fails discovery when upstream tool names collide after canonicalization', async () => { @@ -880,21 +1093,15 @@ describe('MCP consumer endpoint', () => { (input, init) => tb.request(input, init), ) try { - const listed = await client.listTools() - const remote = listed.tools.find( - tool => tool._meta?.['io.tool-bridge/path'] === 'mcp-round15/peer/alpha', - ) - expect(remote).toMatchObject({ - inputSchema: { - type: 'object', - properties: { text: { type: 'string' } }, - required: ['text'], - }, - _meta: { 'io.tool-bridge/command': 'echo' }, + await client.listTools() + expect(remoteFetch).not.toHaveBeenCalled() + const help = await client.callTool({ + name: 'tb_help', arguments: { path: '/mcp-round15/peer/alpha/echo' }, }) + const command = (help.structuredContent as { cmds: Array<{ path: string }> }).cmds[0] + expect(command?.path).toBe('/mcp-round15/peer/alpha/echo') const called = await client.callTool({ - name: remote?.name ?? '', - arguments: { text: 'remote' }, + name: 'tb_call', arguments: { path: command?.path, args: { text: 'remote' } }, }) expect(called).toMatchObject({ structuredContent: { echoed: 'remote' } }) expect( @@ -916,19 +1123,17 @@ describe('MCP consumer endpoint', () => { (input, init) => tb.request(input, init), ) try { - const listed = await noCallClient.listTools() - expect( - listed.tools.some( - tool => tool._meta?.['io.tool-bridge/path'] === 'mcp-round15/peer/alpha', - ), - ).toBe(false) + await noCallClient.listTools() + await expect(noCallClient.callTool({ + name: 'tb_call', arguments: { path: '/mcp-round15/peer/alpha/echo', args: { text: 'blocked' } }, + })).resolves.toMatchObject({ isError: true }) expect(remoteFetch).toHaveBeenCalledTimes(before) } finally { await noCallClient.close() } }) - it('local longest-prefix nodes override remote descendants in the projected tree', async () => { + it('resolves the local longest-prefix command without exploring its remote parent', async () => { await mountRemote('mcp-round15/override') await mountHttp('mcp-round15/override/alpha') const remoteFetch = vi.fn(async (input: RequestInfo | URL) => { @@ -962,19 +1167,19 @@ describe('MCP consumer endpoint', () => { (input, init) => tb.request(input, init), ) try { - const listed = await client.listTools() - const overridden = listed.tools.filter( - tool => tool._meta?.['io.tool-bridge/path'] === 'mcp-round15/override/alpha', - ) - expect(overridden).toHaveLength(1) - expect(overridden[0]?._meta?.['io.tool-bridge/command']).toBe('greet') - expect(remoteFetch).toHaveBeenCalledTimes(2) + await client.listTools() + await expect(client.callTool({ + name: 'tb_help', arguments: { path: '/mcp-round15/override/alpha/greet' }, + })).resolves.toMatchObject({ + structuredContent: { cmds: [{ path: '/mcp-round15/override/alpha/greet', name: 'greet' }] }, + }) + expect(remoteFetch).not.toHaveBeenCalled() } finally { await client.close() } }) - it('fails closed within a fixed remote discovery request budget', async () => { + it('does not walk a wide remote tree when listing fixed tools', async () => { await mountRemote('mcp-round15/budget') const remoteFetch = vi.fn(async (input: RequestInfo | URL) => { const path = new URL(String(input)).pathname @@ -997,8 +1202,8 @@ describe('MCP consumer endpoint', () => { (input, init) => tb.request(input, init), ) try { - await expect(client.listTools()).rejects.toThrow() - expect(remoteFetch.mock.calls.length).toBeLessThanOrEqual(32) + await expect(client.listTools()).resolves.toMatchObject({ tools: expect.any(Array) }) + expect(remoteFetch).not.toHaveBeenCalled() } finally { await client.close() } @@ -1023,7 +1228,11 @@ describe('MCP consumer endpoint', () => { (input, init) => tb.request(input, init), ) try { - await expect(client.listTools()).rejects.toThrow() + await client.listTools() + expect(remoteFetch).not.toHaveBeenCalled() + await expect(client.callTool({ + name: 'tb_list_nodes', arguments: { path: 'mcp-round15/path-escape' }, + })).resolves.toMatchObject({ isError: true }) expect(remoteFetch).toHaveBeenCalledTimes(1) } finally { await client.close() @@ -1031,14 +1240,76 @@ describe('MCP consumer endpoint', () => { }, ) - it('flat names are collision-safe, client-compatible, and length bounded', async () => { - const slash = await mcpToolName(mcpToolIdentity('/a', 'b\0c')) - const shiftedNul = await mcpToolName(mcpToolIdentity('/a\0b', 'c')) - const escapedLiteral = await mcpToolName(mcpToolIdentity('/a_2Fb', 'c')) - const long = await mcpToolName(mcpToolIdentity(`/${'很长'.repeat(100)}`, '工具')) - expect(slash).not.toBe(shiftedNul) - expect(slash).not.toBe(escapedLiteral) - expect(long).toMatch(/^[A-Za-z0-9._-]{1,128}$/) - expect(long).toHaveLength(128) + it('keeps a bounded tool catalog despite long paths and an unavailable unrelated upstream', async () => { + const path = `mcp-on-demand/${'long'.repeat(30)}` + await mountHttp(path) + await mountMcp('mcp-on-demand/offline') + const upstream = vi.fn(async (input: RequestInfo | URL) => { + if (String(input).includes('round15-mcp-upstream')) throw new Error('unrelated upstream offline') + return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } }) + }) + vi.stubGlobal('fetch', upstream) + const client = await connectTestMcpClient('https://tb.test/~mcp', TEST_ADMIN_SK, + (input, init) => tb.request(input, init)) + try { + const listed = await client.listTools() + expect(listed.tools.map(tool => tool.name).sort()).toEqual([ + 'tb_call', 'tb_device_operations', 'tb_help', 'tb_list_nodes', 'tb_search', + ]) + expect(JSON.stringify(listed)).not.toContain(path) + expect(upstream).not.toHaveBeenCalled() + await expect(client.callTool({ + name: 'tb_help', arguments: { path: `/${path}/greet` }, + })).resolves.toMatchObject({ structuredContent: { cmds: [{ path: `/${path}/greet` }] } }) + expect(upstream).not.toHaveBeenCalled() + await expect(client.callTool({ + name: 'tb_call', arguments: { path: `/${path}/greet`, args: { name: 'healthy' } }, + })).resolves.toMatchObject({ structuredContent: { ok: true } }) + expect(upstream).toHaveBeenCalledTimes(1) + } finally { + await client.close() + } }) + + it.each(['a\\b', 'a%2fb'])( + 'preserves logical path segment %s without redirecting writes to another node', async (segment) => { + const path = `mcp-path/${segment}` + await mountContext(path) + await mountContext('mcp-path/a/b') + const client = await connectTestMcpClient('https://tb.test/~mcp', TEST_ADMIN_SK, + (input, init) => tb.request(input, init)) + try { + await expect(client.callTool({ name: 'tb_help', arguments: { path } })).resolves.toMatchObject({ + structuredContent: { node: { path } }, + }) + const result = await client.callTool({ + name: 'tb_call', arguments: { path: `/${path}/write`, args: { path: 'entry', entry: { content: 'exact target', contentType: 'text/plain' } } }, + }) + expect(result.isError).not.toBe(true) + await expect(client.callTool({ + name: 'tb_call', arguments: { path: `/${path}/get`, args: { path: 'entry' } }, + })).resolves.toMatchObject({ structuredContent: { content: 'exact target' } }) + await expect(client.callTool({ + name: 'tb_call', arguments: { path: '/mcp-path/a/b/list', args: {} }, + })).resolves.toMatchObject({ structuredContent: { items: [] } }) + } finally { + await client.close() + } + }, + ) + + it.each(['/~mcp', '/~device/mailbox/claim', 'https://example.com/steal', '/a/../system/sk/write'])( + 'rejects a non-command tb_call target %s', async (path) => { + const upstream = vi.fn() + vi.stubGlobal('fetch', upstream) + const client = await connectTestMcpClient('https://tb.test/~mcp', TEST_ADMIN_SK, + (input, init) => tb.request(input, init)) + try { + await expect(client.callTool({ name: 'tb_call', arguments: { path, args: {} } })).resolves.toMatchObject({ isError: true }) + expect(upstream).not.toHaveBeenCalled() + } finally { + await client.close() + } + }, + ) }) diff --git a/packages/app/test/mcpArguments.test.ts b/packages/app/test/mcpArguments.test.ts new file mode 100644 index 00000000..361cb352 --- /dev/null +++ b/packages/app/test/mcpArguments.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { validateMcpArguments } from '../src/mcpServer' + +describe('targeted MCP argument validation', () => { + it('distinguishes an omitted schema from an invalid declared schema', () => { + expect(() => validateMcpArguments(undefined, { value: 1 }, 'tools/opaque')).not.toThrow() + for (const schema of [null, false, [], { type: 'string' }, { properties: { value: 'not-a-schema' } }]) { + expect(() => validateMcpArguments(schema, {}, 'tools/invalid')).toThrow(/invalid input schema/) + } + }) + + it('preserves draft-07 tuple validation through the MCP boundary', () => { + const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + tuple: { type: 'array', items: [{ type: 'string' }, { type: 'integer' }], additionalItems: false }, + }, + } + expect(() => validateMcpArguments(schema, { tuple: ['value', 2] }, 'tools/tuple')).not.toThrow() + expect(() => validateMcpArguments(schema, { tuple: [2, 'value'] }, 'tools/tuple')).toThrow() + expect(() => validateMcpArguments(schema, { tuple: ['value', 2, true] }, 'tools/tuple')).toThrow() + }) + + it('does not allow one selected schema to poison another with the same $id', () => { + const first = { $id: 'https://schema.test/shared', type: 'object', required: ['first'] } + const second = { $id: 'https://schema.test/shared', type: 'object', required: ['second'] } + expect(() => validateMcpArguments(first, { first: true }, 'tools/first')).not.toThrow() + expect(() => validateMcpArguments(second, { first: true }, 'tools/second')).toThrow() + expect(() => validateMcpArguments(second, { second: true }, 'tools/second')).not.toThrow() + }) + + it('rejects deeply nested schemas before compiling them', () => { + let nested: unknown = { type: 'string' } + for (let i = 0; i < 40; i += 1) nested = { type: 'object', properties: { nested } } + expect(() => validateMcpArguments(nested, {}, 'tools/deep')).toThrow(/invalid input schema/) + }) +}) diff --git a/packages/core/src/protocol/wire.ts b/packages/core/src/protocol/wire.ts index 0c2d9c6e..da8b5269 100644 --- a/packages/core/src/protocol/wire.ts +++ b/packages/core/src/protocol/wire.ts @@ -216,18 +216,20 @@ export const toolSearchPageSchema: z.ZodType = z.strictObjec sources: z.array(toolSearchSourceResultSchema).optional(), }) +export const toolSearchOptionsSchema = z.strictObject({ + cursor: z.string().optional(), + detail: z.enum(['compact', 'full']).optional(), + effects: z.array(z.enum(['read', 'write', 'destructive', 'unknown'])).min(1).optional(), + federation: z.enum(['local', 'recursive']).optional(), + limit: z.number().int().optional(), + matching: z.enum(['best', 'all']).optional(), + minCoverage: z.number().gt(0).max(1).optional(), + mode: z.enum(['keyword']).optional(), + pathPrefix: z.string().optional(), +}) + export const toolSearchRequestSchema: z.ZodType = z.strictObject({ - opts: z.strictObject({ - cursor: z.string().optional(), - detail: z.enum(['compact', 'full']).optional(), - effects: z.array(z.enum(['read', 'write', 'destructive', 'unknown'])).min(1).optional(), - federation: z.enum(['local', 'recursive']).optional(), - limit: z.number().int().optional(), - matching: z.enum(['best', 'all']).optional(), - minCoverage: z.number().gt(0).max(1).optional(), - mode: z.enum(['keyword']).optional(), - pathPrefix: z.string().optional(), - }).optional(), + opts: toolSearchOptionsSchema.optional(), query: z.string().trim().min(1), }) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 88f4fc03..bfd514f4 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@tool-bridge/sdk", - "version": "0.23.0", + "version": "0.24.0", "description": "tool-bridge SDK: embed a TB instance (createToolBridge), register local providers, and connect to a remote gateway", "type": "module", "license": "MIT", diff --git a/packages/server/package.json b/packages/server/package.json index a697d29f..66e1a325 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@tool-bridge/server", - "version": "0.22.1", + "version": "0.23.0", "description": "Self-hosted Tool Bridge Node server with PostgreSQL, S3 object storage and WebSocket device channels", "type": "module", "license": "MIT", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85c9f209..f3f82bdc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,9 @@ importers: hono: specifier: 'catalog:' version: 4.12.28 + zod: + specifier: 'catalog:' + version: 4.4.3 devDependencies: '@modelcontextprotocol/sdk': specifier: 'catalog:' @@ -162,9 +165,6 @@ importers: vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.1.0)(jsdom@30.0.1)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) - zod: - specifier: 'catalog:' - version: 4.4.3 packages/cli: dependencies: diff --git a/scripts/verify-mcp.ts b/scripts/verify-mcp.ts index 28fd936c..df730042 100644 --- a/scripts/verify-mcp.ts +++ b/scripts/verify-mcp.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' /** * MCP 生产出口验收:官方 SDK initialize 后完成 tools/list + tools/call,再以窄 SK - * 重连并验证工具集收窄、旧的 admin-only 工具名不可调用。 + * 重连并验证固定入口一致、help/search 隐藏越权目标、已知 admin 路径不可调用。 * * 用法: * `TB_BASE_URL=https://... TB_SK=tbk_... TB_MCP_NARROW_SK=tbk_... pnpm verify:mcp` @@ -13,7 +13,9 @@ import assert from 'node:assert/strict' * 钉住 2026-07-28——拿不到就响亮失败,用于确认新协议确实已上线。 * * 默认调用只读的 system/registry:list。可用 TB_MCP_PATH、TB_MCP_COMMAND 与 - * TB_MCP_ARGS(JSON object)选择另一项无副作用工具。窄 SK 默认须允许 + * TB_MCP_ARGS(JSON object)选择另一项无副作用工具;该目标必须对窄 SK 不可见。 + * 实际调用路径只取实时 help 的 cmds[].path,不从 PATH/COMMAND 重建。 + * 窄 SK 默认须允许 * system/status:get,也可用 TB_MCP_NARROW_PATH / _COMMAND / _ARGS 改写。 * 本脚本不创建或修改生产资源。 */ @@ -49,8 +51,49 @@ async function connect(endpoint: URL, sk: string): Promise { return client } -function toolMeta(tool: { _meta?: Record }, key: string): unknown { - return tool._meta?.[key] +const ENTRY_POINTS = ['tb_call', 'tb_device_operations', 'tb_help', 'tb_list_nodes', 'tb_search'].sort() + +type McpResult = Awaited> + +function resultObject(result: McpResult, label: string): Record { + const text = Array.isArray(result.content) + ? result.content.find(block => block.type === 'text')?.text + : undefined + const value: unknown = result.structuredContent ?? (typeof text === 'string' ? JSON.parse(text) : undefined) + assert.ok(value !== null && typeof value === 'object' && !Array.isArray(value), `${label} must return a JSON object`) + return value as Record +} + +function assertSucceeded(result: McpResult, label: string): void { + assert.notEqual(result.isError, true, `${label} returned isError=true`) + assert.ok(Array.isArray(result.content) && result.content.length > 0, `${label} returned empty content`) +} + +async function commandPath(client: Client, path: string, command: string): Promise { + const result = await client.callTool({ name: 'tb_help', arguments: { path, tool: command } }) + assertSucceeded(result, `tb_help ${path}:${command}`) + const help = resultObject(result, 'tb_help') + assert.ok(Array.isArray(help.cmds), 'tb_help.cmds must be an array') + const selected = help.cmds.find((item: unknown) => + item !== null && typeof item === 'object' && (item as { name?: unknown }).name === command, + ) as { path?: unknown } | undefined + assert.ok(selected, `tb_help did not describe ${path}:${command}`) + assert.ok(typeof selected.path === 'string' && selected.path.length > 0, 'tb_help command must have a complete path') + return selected.path +} + +async function assertDenied(client: Client, name: string, args: Record): Promise { + let result: McpResult + try { + result = await client.callTool({ name, arguments: args }) + } catch (error) { + // SDK eras can represent a rejected invocation as an RPC error or isError content. + assert.match(error instanceof Error ? error.message : String(error), /not[_ ]found|permission[_ ]denied|no scope/i) + return + } + assert.equal(result.isError, true, `${name} must reject the narrow identity`) + const body = resultObject(result, `${name} denial`) + assert.ok(body.code === 'not_found' || body.code === 'permission_denied', `${name} must fail because of visibility or scope`) } async function main(): Promise { @@ -77,27 +120,21 @@ async function main(): Promise { ) const endpoint = new URL(`${baseUrl}/~mcp`) const adminClient = await connect(endpoint, adminSk) - let adminNames = new Set() + let adminCommandPath = '' try { const listed = await adminClient.listTools() - assert.ok(listed.tools.length > 0, 'admin tools/list must return at least one tool') - adminNames = new Set(listed.tools.map(tool => tool.name)) - const selected = listed.tools.find(tool => - toolMeta(tool, 'io.tool-bridge/path') === callPath - && toolMeta(tool, 'io.tool-bridge/command') === callCommand, - ) - assert.ok(selected, `tools/list did not expose ${callPath}:${callCommand}`) - - const called = await adminClient.callTool({ name: selected.name, arguments: callArguments }) - assert.notEqual(called.isError, true, `${callPath}:${callCommand} returned isError=true`) - assert.ok( - Array.isArray(called.content) && called.content.length > 0, - `${callPath}:${callCommand} returned empty content`, - ) + assert.deepEqual(listed.tools.map(tool => tool.name).sort(), ENTRY_POINTS, 'admin must expose only the fixed MCP entry points') + assertSucceeded(await adminClient.callTool({ name: 'tb_list_nodes', arguments: { depth: 1 } }), 'admin tb_list_nodes') + adminCommandPath = await commandPath(adminClient, callPath, callCommand) + const called = await adminClient.callTool({ name: 'tb_call', arguments: { path: adminCommandPath, args: callArguments } }) + assertSucceeded(called, `admin tb_call ${adminCommandPath}`) + const searched = await adminClient.callTool({ name: 'tb_search', arguments: { query: callCommand, pathPrefix: callPath } }) + assertSucceeded(searched, 'admin tb_search') + assert.ok(Array.isArray(resultObject(searched, 'admin tb_search').items), 'admin search must return items') console.log( `ok admin ${adminClient.getProtocolEra() ?? 'unknown'} era` + ` (${adminClient.getNegotiatedProtocolVersion() ?? 'unknown'})` - + ` → tools/list (${listed.tools.length}) → tools/call ${callPath}:${callCommand}`, + + ` → fixed tools/list (${listed.tools.length}) → help/search → tb_call ${adminCommandPath}`, ) } finally { await adminClient.close() @@ -106,45 +143,21 @@ async function main(): Promise { const narrowClient = await connect(endpoint, narrowSk) try { const listed = await narrowClient.listTools() - const narrowNames = new Set(listed.tools.map(tool => tool.name)) - assert.ok(narrowNames.size > 0, 'narrow tools/list must expose at least one allowed tool') - assert.ok( - narrowNames.size < adminNames.size, - `narrow tools/list must shrink: admin=${adminNames.size}, narrow=${narrowNames.size}`, - ) - for (const name of narrowNames) { - assert.ok(adminNames.has(name), `narrow tools/list exposed non-admin tool '${name}'`) - } - const narrowSelected = listed.tools.find(tool => - toolMeta(tool, 'io.tool-bridge/path') === narrowCallPath - && toolMeta(tool, 'io.tool-bridge/command') === narrowCallCommand, - ) - assert.ok( - narrowSelected, - `narrow tools/list did not expose ${narrowCallPath}:${narrowCallCommand}`, - ) + assert.deepEqual(listed.tools.map(tool => tool.name).sort(), ENTRY_POINTS, 'narrow identity keeps the same fixed MCP entry points') + const narrowCommandPath = await commandPath(narrowClient, narrowCallPath, narrowCallCommand) const narrowCalled = await narrowClient.callTool({ - name: narrowSelected.name, - arguments: narrowCallArguments, + name: 'tb_call', + arguments: { path: narrowCommandPath, args: narrowCallArguments }, }) - assert.notEqual( - narrowCalled.isError, - true, - `${narrowCallPath}:${narrowCallCommand} returned isError=true`, - ) - assert.ok( - Array.isArray(narrowCalled.content) && narrowCalled.content.length > 0, - `${narrowCallPath}:${narrowCallCommand} returned empty content`, - ) - const adminOnlyCallCandidate = [...adminNames].find(name => !narrowNames.has(name)) ?? '' - assert.notEqual(adminOnlyCallCandidate, '', 'expected at least one admin-only tool') - await assert.rejects( - narrowClient.callTool({ name: adminOnlyCallCandidate, arguments: {} }), - /tool not found/i, - ) + assertSucceeded(narrowCalled, `narrow tb_call ${narrowCommandPath}`) + await assertDenied(narrowClient, 'tb_help', { path: adminCommandPath }) + const searched = await narrowClient.callTool({ name: 'tb_search', arguments: { query: callCommand, pathPrefix: callPath } }) + assertSucceeded(searched, 'narrow tb_search') + assert.deepEqual(resultObject(searched, 'narrow tb_search').items, [], 'search must hide the forbidden target subtree') + await assertDenied(narrowClient, 'tb_call', { path: adminCommandPath, args: callArguments }) console.log( - `ok narrow reconnect → tools/list shrank ${adminNames.size} → ${narrowNames.size}` - + ` → tools/call ${narrowCallPath}:${narrowCallCommand}; stale call rejected`, + `ok narrow reconnect → fixed tools/list (${listed.tools.length})` + + ` → tb_call ${narrowCommandPath}; help/search hidden and known forbidden path rejected`, ) } finally { await narrowClient.close()