使用 TypeScript SDK 的registerTool构建 MCP Tools:从 Schema 推导到结构化输出实战
【免费下载链接】typescript-sdkThe official TypeScript SDK for Model Context Protocol servers and clients项目地址: https://gitcode.com/GitHub_Trending/ty/typescript-sdk
在 Model Context Protocol(MCP)中,tool(工具)是客户端及其驱动的模型可以在服务器上执行的动作,也是将服务器能力暴露给模型的核心原语。本文以官方 TypeScript SDK 仓库中的 examples/tools 入门示例为主线,完整演示如何用McpServer.registerTool注册工具、由单一 Zod Schema 自动推导 JSON Schema 与参数校验、通过outputSchema+structuredContent产出机器可读的结构化结果,以及客户端如何listTools检视 schema 与annotations、callTool调用工具并断言结构化输出。读完本文,你将掌握一套可复制的「注册—检视—调用—校验」全流程写法,并理解其底层的实现机制。
从一个完整可运行的示例开始
官方仓库的 examples/tools 被标注为"Start here"(从这里开始),它是一对配套的 server/client 程序:server.ts注册了两个工具(calc与echo),client.ts则负责列出工具、检视 schema 与注解、发起调用、断言结构化输出,并验证「调用不存在的工具必然失败」。示例同时支持 stdio 与 Streamable HTTP 两种传输,还支持现代(2026-07-28)与 legacy 两种协议时代。
运行方式
示例根目录的 README.md 给出的运行命令非常简单:
pnpm tsx examples/tools/client.ts默认情况下 client 会以stdio方式在本地拉起server.ts并与之通信。示例还通过@mcp-examples/shared的 args.ts 提供了几个可选的命令行开关(这些开关同样被仓库的示例运行器 run-examples.ts 使用):
| 参数 | 作用 | 默认值 |
|---|---|---|
--http | 改用 Streamable HTTP 传输(server 通过@hono/node-server监听) | 不传则走 stdio |
--port <N> | HTTP 监听端口 | $PORT或3000 |
--http <url> | 客户端连接的 HTTP 端点 | http://127.0.0.1:<port>/mcp |
--legacy | 以 legacy(2025-11-25)协议时代运行 | 默认 modern(自动协商 2026-07-28) |
例如以 HTTP + modern 时代运行:
# 终端 1:启动服务器 pnpm tsx examples/tools/server.ts --http --port 3000 # 终端 2:驱动客户端 pnpm tsx examples/tools/client.ts --http http://127.0.0.1:3000/mcpexamples/tools/package.json中已配置好server/client两个脚本,也可以直接使用pnpm --filter @mcp-examples/tools server等方式运行。示例依赖@modelcontextprotocol/client、@modelcontextprotocol/server、@modelcontextprotocol/hono(HTTP 承载)与zod,全部来自仓库内的 workspace 包。
示例中 server/client 的分工
- server.ts:创建
McpServer,注册calc(带outputSchema、annotations、icons的"进阶"工具)与echo(仅返回文本的"基础"工具),并通过 stdio 或 Hono HTTP 暴露; - client.ts:
listTools断言两个工具都在列表中,检视calc的annotations.readOnlyHint、必填参数、outputSchema与icons,callTool调用calc与echo并断言结果,最后验证调用不存在的工具会失败。
用registerTool注册工具:一个 Schema 的三种用途
基本形态
在 server.ts 中,注册工具只需三步:给工具起名、给出配置对象、编写处理函数。以示例中的calc为例:
server.registerTool( 'calc', { title: 'Calculator', description: 'Apply an arithmetic operation to two numbers', inputSchema: z.object({ op: z.enum(['add', 'sub', 'mul']).describe('the operation to apply'), a: z.number().describe('left operand'), b: z.number().describe('right operand') }), outputSchema: z.object({ op: z.string(), result: z.number() }), annotations: { readOnlyHint: true, idempotentHint: true }, icons: [{ src: 'https://example.test/calc.svg', mimeType: 'image/svg+xml', sizes: ['any'] }] }, async ({ op, a, b }) => { const result = op === 'add' ? a + b : op === 'sub' ? a - b : a * b; const structuredContent = { op, result }; return { content: [{ type: 'text', text: `${a} ${op} ${b} = ${result}` }], structuredContent }; } );registerTool的签名(见 mcp.ts)为registerTool(name, config, cb),其中config支持以下字段:
| 字段 | 类型 | 说明 |
|---|---|---|
title | string | 展示名,供客户端界面显示 |
description | string | 工具用途说明,是模型理解该工具的主要文案 |
inputSchema | Zod / 任意 Standard-Schema 兼容 schema | 输入参数 schema |
outputSchema | Zod / 任意 Standard-Schema 兼容 schema | 输出结果 schema(可选) |
annotations | ToolAnnotations | 行为提示,如readOnlyHint、destructiveHint、idempotentHint |
icons | Icon[] | 客户端可在 UI 中渲染的图标(src必填,mimeType、sizes、theme可选) |
_meta | Record<string, unknown> | 透传的附加元数据 |
一个 Schema 的三种用途
inputSchema是你唯一需要手写的 schema。官方文档 docs/servers/tools.md 明确说明,从这一个 schema 出发,SDK 会替你完成三件事:
- 推导出模型看到的 JSON Schema:
tools/list向客户端广播的输入 schema 由它转换而来; - 在 handler 运行之前校验参数:参数不合法时直接拒绝,handler 不会执行;
- 推断 handler 的参数类型:
async ({ query, limit }) => ...中query、limit的类型由 schema 静态推导,全程类型安全。
注意.describe()会原样保留:query字段在广告出去的 JSON Schema 里带着"Substring to match against product names"作为description——这往往是模型能看到的关于该参数的唯一文档,务必写清楚。
从 v1 迁移的兼容说明
在 v2 中registerTool取代了 v1 的tool()。从源码看,registerTool同时保留了一个被标记为@deprecated的旧形态:inputSchema/outputSchema可以传裸的 Zod shape 记录(如{ field: z.string() }),SDK 会自动用z.object()包一层(mcp.ts)。新代码应直接传z.object({...})。官方迁移路径是先跑 codemod 再看 upgrade-to-v2 指南。
客户端调用工具:list、inspect、call
建立连接
client.ts 中,客户端先创建Client实例并连接:
const client = new Client( { name: 'tools-example-client', version: '1.0.0' }, { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } ); await (transport === 'stdio' ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) : client.connect(new StreamableHTTPClientTransport(new URL(url))));同一个客户端代码只需换传输层,stdio 与 Streamable HTTP 的调用逻辑完全一致。siblingPath来自 args.ts,负责把相对路径解析成绝对路径,用于拉起同目录下的server.ts。
listTools:检视 schema 与 annotations
client.listTools()返回服务器注册的全部工具,随后示例对返回结果做了一系列断言:
const list = await client.listTools(); const names = new Set(list.tools.map(t => t.name)); check.ok(names.has('calc') && names.has('echo'), 'tools/list should contain calc and echo'); const calc = list.tools.find(t => t.name === 'calc')!; check.equal(calc.annotations?.readOnlyHint, true); const required = (calc.inputSchema as { required?: string[] }).required ?? []; check.ok(required.includes('op') && required.includes('a') && required.includes('b')); check.ok(calc.outputSchema, 'calc should publish an outputSchema'); check.equal(calc.icons?.[0]?.src, 'https://example.test/calc.svg', 'calc should advertise its icons over the wire');从中可以看到几个关键点:
annotations.readOnlyHint会通过tools/list原样广播到客户端;z.object({...})转换出的 JSON Schema 带有required数组,必填字段(op、a、b)都在其中;- 注册了
outputSchema的工具,其派生 JSON Schema 会出现在列表条目上,供客户端自行校验; - 注册的
icons也会在线上传输(示例用https://example.test/calc.svg演示)。
在客户端实现层面,listTools会先检查服务器是否声明了tools能力,未声明时直接返回空列表;对于流式 HTTP 的现代协议,它还会做分页聚合,并排除不符合x-mcp-header约束的工具定义(见 client.ts)。
callTool:调用并断言结构化输出
const result = await client.callTool({ name: 'calc', arguments: { op: 'add', a: 2, b: 3 } }); check.equal((result.structuredContent as { result?: number } | undefined)?.result, 5); check.equal((result.structuredContent as { op?: string } | undefined)?.op, 'add'); const echo = await client.callTool({ name: 'echo', arguments: { text: 'hi' } }); check.equal(echo.content?.[0]?.type === 'text' ? echo.content[0].text : '', 'hi'); check.equal(echo.structuredContent, undefined);calc的返回同时携带两层表达:
content:人类可读的文本"2 add 3 = 5";structuredContent:机器可读的结构化数据{ op: 'add', result: 5 }。
而echo只返回content,因此structuredContent为undefined——这就是"纯文本工具"与"结构化输出工具"在结果形态上的差别。
调用不存在的工具
示例最后验证了错误路径:调用一个未注册的工具(nope),要么得到一个isError: true的工具结果,要么直接抛线上错误——两种情况都被视为"调用失败":
let unknownFailed = false; try { const r = await client.callTool({ name: 'nope', arguments: {} }); unknownFailed = !!r.isError; } catch { unknownFailed = true; } check.ok(unknownFailed, 'calling an unknown tool should fail');outputSchema 与 structuredContent:机器可读的结构化输出
注册侧:声明与返回配对
官方文档 docs/servers/tools.md 中的product-details展示了标准写法:outputSchema声明输出形状,handler 返回值中把结构化数据放在structuredContent字段,与content并列返回:
server.registerTool( 'product-details', { description: 'Look up one product by its exact name', inputSchema: z.object({ name: z.string() }), outputSchema: z.object({ name: z.string(), price: z.number() }) }, async ({ name }) => { const product = catalog.find(candidate => candidate.name === name); if (!product) throw new Error(`No product named ${name}`); const output = { name: product.name, price: product.price }; return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output }; } );SDK 会在结果离开服务器之前对structuredContent按outputSchema做一次校验,并把派生的 JSON Schema 通过tools/list广播出去,让客户端也能独立校验。调用product-details传入{ name: 'Travel mug' }会同时拿到两种渲染:
{ content: [ { type: 'text', text: '{"name":"Travel mug","price":24}' } ], structuredContent: { name: 'Travel mug', price: 24 } }需要留意:结构化结果在线上的编码方式随协议时代而不同,详见 protocol-versions.md。
混合内容块:一次返回多种类型
一个工具的结果可以同时混排多种content块:image与audio携带 base64 的data和mimeType;resource把资源内容内联嵌入(无需额外resources/read往返);resource_link只按uri引用资源而不带字节。product-card示例同时返回图片、语音和资源记录三块内容:
server.registerTool( 'product-card', { description: 'Render one product as an image, a spoken name, and its catalog record', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => { const product = catalog.find(candidate => candidate.name === name); if (!product) throw new Error(`No product named ${name}`); return { content: [ { type: 'image', data: cardPng, mimeType: 'image/png' }, { type: 'audio', data: spokenNameWav, mimeType: 'audio/wav' }, { type: 'resource', resource: { uri: `catalog://products/${encodeURIComponent(product.name)}`, mimeType: 'application/json', text: JSON.stringify(product) } } ] }; } );这些内容块会原样到达客户端,内嵌的resource不会触发额外的resources/read往返——一个结果即是一个自包含的渲染包。
参数校验失败:handler 不会执行
当客户端传入 schema 拒绝的参数时,SDK 会在 handler 运行前拦截。把limit传成 999(schema 上限是 50):
const rejected = await client.callTool({ name: 'search', arguments: { query: 'mug', limit: 999 } }); console.log(rejected);得到的不是异常,而是一个isError: true的普通工具结果:
{ content: [ { type: 'text', text: 'Input validation error: Invalid arguments for tool search: limit: Too big: expected number to be <=50' } ], isError: true }这种设计对模型非常友好:拒绝信息是一个可读的普通结果,模型读到提示后可以修正参数重试。抛出的异常与协议级失败属于另一主题,参见 errors.md。
用 annotations 描述工具行为
annotations是给客户端的行为提示,title是展示名。clear-catalog示例声明自己"具有破坏性且幂等":
server.registerTool( 'clear-catalog', { title: 'Clear the catalog', description: 'Remove every product from the catalog', annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true } }, async () => { catalog.length = 0; return { content: [{ type: 'text', text: 'Catalog cleared' }] }; } );两个要点:
- 无参数的工具可以省略
inputSchema; annotations永远不会改变 SDK 执行工具的方式,它只影响客户端的决策:例如宿主可以对只读工具自动放行(auto-approve),对破坏性工具强制要求用户确认。仓库中 examples/tools/client.ts 正是通过断言calc.annotations?.readOnlyHint === true来验证注解确实"上了线"。
深入源码:registerTool 内部发生了什么
从 mcp.ts 的实现可以看到registerTool的完整落地路径:
- 重名保护:
if (this._registeredTools[name]) throw new Error(\Tool ${name} is already registered`)`,同一服务器内工具名不可重复; - Schema 归一化:
normalizeRawShapeSchema统一处理 Standard-Schema 对象与裸 Zod shape 两种输入; - 执行器构建:内部通过
createToolExecutor(inputSchema, handler)生成执行器——参数校验与 handler 的类型绑定都发生在这条链路上;后续若通过updateTool更新 handler 或 schema,还会重建执行器; - 能力广播:注册完成后
setToolRequestHandlers()挂载tools/list/tools/call请求处理,并sendToolListChanged()通知客户端工具列表发生变化(mcp.ts)。
此外,registerTool返回的RegisteredTool支持后续通过updateTool动态更新callback、outputSchema、annotations、icons、enabled等,并会再次触发sendToolListChanged()。
实战对照:官方指南示例
文档页 docs/servers/tools.md 中的每段代码都是从可运行示例 tools.examples.ts 的//#region区块同步而来(由pnpm sync:snippets --check校验同步)。该文件底部的 harness 用内存传输InMemoryTransport.createLinkedPair()把客户端与服务器直接对接,逐段产生了文档引用的输出——这意味着文档中的全部代码示例都是真实运行过、输出可复现的。你可以直接运行:
npx tsx examples/guides/servers/tools.examples.ts # 从 examples/ 目录来亲眼看到每次调用的实际输出。这种"文档代码即运行示例"的做法也意味着:把 tools.examples.ts 当作最贴近文档的完整参考实现,把 examples/tools 当作最小可运行的两文件示例,两者互为印证。
小结
registerTool(name, config, handler)注册工具,inputSchema是唯一需要手写的 Zod 对象 schema;- 同一个 schema 同时产出广告给模型的 JSON Schema、参数校验规则和 handler 参数类型;
- 不通过校验的参数以
isError: true的工具结果返回,handler 不会执行; outputSchema+structuredContent提供机器可读结果,并在离开服务器前被校验;content块支持text、image、audio、resource_link与内嵌resource,一次返回可混排多种;title与annotations描述工具行为,仅供客户端决策,从不影响执行;- 从
tools/list检视、tools/call调用到校验错误路径,客户端侧的全流程都可以在 examples/tools/client.ts 中直接验证。
【免费下载链接】typescript-sdkThe official TypeScript SDK for Model Context Protocol servers and clients项目地址: https://gitcode.com/GitHub_Trending/ty/typescript-sdk
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考