使用 TypeScript SDK 的 `registerTool` 构建 MCP Tools:从 Schema 推导到结构化输出实战
2026/9/15 17:23:11 网站建设 项目流程

使用 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 与annotationscallTool调用工具并断言结构化输出。读完本文,你将掌握一套可复制的「注册—检视—调用—校验」全流程写法,并理解其底层的实现机制。

从一个完整可运行的示例开始

官方仓库的 examples/tools 被标注为"Start here"(从这里开始),它是一对配套的 server/client 程序:server.ts注册了两个工具(calcecho),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 监听端口$PORT3000
--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/mcp

examples/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(带outputSchemaannotationsicons的"进阶"工具)与echo(仅返回文本的"基础"工具),并通过 stdio 或 Hono HTTP 暴露;
  • client.tslistTools断言两个工具都在列表中,检视calcannotations.readOnlyHint、必填参数、outputSchemaiconscallTool调用calcecho并断言结果,最后验证调用不存在的工具会失败。

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支持以下字段:

字段类型说明
titlestring展示名,供客户端界面显示
descriptionstring工具用途说明,是模型理解该工具的主要文案
inputSchemaZod / 任意 Standard-Schema 兼容 schema输入参数 schema
outputSchemaZod / 任意 Standard-Schema 兼容 schema输出结果 schema(可选)
annotationsToolAnnotations行为提示,如readOnlyHintdestructiveHintidempotentHint
iconsIcon[]客户端可在 UI 中渲染的图标(src必填,mimeTypesizestheme可选)
_metaRecord<string, unknown>透传的附加元数据

一个 Schema 的三种用途

inputSchema你唯一需要手写的 schema。官方文档 docs/servers/tools.md 明确说明,从这一个 schema 出发,SDK 会替你完成三件事:

  1. 推导出模型看到的 JSON Schematools/list向客户端广播的输入 schema 由它转换而来;
  2. 在 handler 运行之前校验参数:参数不合法时直接拒绝,handler 不会执行;
  3. 推断 handler 的参数类型async ({ query, limit }) => ...querylimit的类型由 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数组,必填字段(opab)都在其中;
  • 注册了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,因此structuredContentundefined——这就是"纯文本工具"与"结构化输出工具"在结果形态上的差别。

调用不存在的工具

示例最后验证了错误路径:调用一个未注册的工具(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 会在结果离开服务器之前structuredContentoutputSchema做一次校验,并把派生的 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块:imageaudio携带 base64 的datamimeTyperesource把资源内容内联嵌入(无需额外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' }] }; } );

两个要点:

  1. 无参数的工具可以省略inputSchema
  2. 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动态更新callbackoutputSchemaannotationsiconsenabled等,并会再次触发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块支持textimageaudioresource_link与内嵌resource,一次返回可混排多种;
  • titleannotations描述工具行为,仅供客户端决策,从不影响执行;
  • 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询