MCP 工具注册 深度解析
逐段拆解 sv-number-mcp 如何把国家、服务、号码和验证码变成 Agent 可调用工具。
导入链与契约
MCP 工具注册页从 server 初始化开始:src/index.ts:11 负责其中一个检查点;src/index.ts:12 负责其中一个检查点;src/index.ts:13 负责其中一个检查点;src/index.ts:15 负责其中一个检查点;src/index.ts:16 负责其中一个检查点;src/index.ts:20 负责其中一个检查点;src/index.ts:24 把 SDK、stdio transport、zod、API helper 和 TOTP helper 接起来,说明所有工具都跑在同一个 Node 进程里。src/index.ts:26 负责其中一个检查点;src/index.ts:29 负责其中一个检查点;src/index.ts:31 的 text/failure 封装让每个工具返回相同 MCP content 结构。
9import { readFileSync } from "node:fs"; 10 11import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 12import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 13import { z } from "zod"; 14 15import { ApiError, call, expect, json, pollSeconds } from "./api.js"; 16import { totp } from "./totp.js"; 17 18// Версия берётся из пакета, а не вписывается сюда: вписанная разошлась с package.json 19// и сервер представлялся клиентам чужим номером версии. 20const { version } = JSON.parse( 21 readFileSync(new URL("../package.json", import.meta.url), "utf8"), 22) as { version: string }; 23 24const server = new McpServer({ name: "sv-number", version }); 25 26const text = (value: unknown) => ({ 27 content: [{ type: "text" as const, text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }], 28});
核心实现,逐段拆
▸目录查询工具
list_countries 与 list_services 的区别在于前者只过滤国家名,后者要帮 Agent 从域名或站点名找到任意服务 code。src/index.ts:51 负责其中一个检查点;src/index.ts:57 负责其中一个检查点;src/index.ts:59 负责其中一个检查点;src/index.ts:62 负责其中一个检查点;src/index.ts:107 负责其中一个检查点;src/index.ts:122 负责其中一个检查点;src/index.ts:123 负责其中一个检查点;src/index.ts:130 负责其中一个检查点;src/index.ts:133 负责其中一个检查点;src/index.ts:136 显示服务查询会保留 notInList fallback,并按 rank 与库存排序。
51server.tool( 52 "list_countries", 53 "Every country the numbers can come from, with its id and operators. Use the id in order_number.", 54 { search: z.string().optional().describe("Filter by country name, case-insensitive") }, 55 async ({ search }) => { 56 try { 57 const countries = await json<Array<{ id: number; name: string; operators: Record<string, string> }>>( 58 "getCountryAndOperators"); 59 const found = search 60 ? countries.filter((c) => c.name.toLowerCase().includes(search.toLowerCase())) 61 : countries; 62 return text(found.map((c) => ({ id: c.id, name: c.name, operators: Object.keys(c.operators) }))); 63 } catch (error) { 64 return failure(error); 65 } 66 }, 67);
▸统一 text/failure 返回
src/index.ts:26 统一成功响应,src/index.ts:29 统一失败响应,src/index.ts:31 用 isError 告诉 MCP client 这是工具错误;这让每个工具 handler 可以专注业务状态。
▸服务匹配排序
短查询是 Agent 常见问题:用户可能输入 x、discord.com 或 LinkedIn。src/index.ts:71 负责其中一个检查点;src/index.ts:74 负责其中一个检查点;src/index.ts:79 负责其中一个检查点;src/index.ts:81 负责其中一个检查点;src/index.ts:82 负责其中一个检查点;src/index.ts:87 负责其中一个检查点;src/index.ts:90 负责其中一个检查点;src/index.ts:92 负责其中一个检查点;src/index.ts:94 负责其中一个检查点;src/index.ts:96 负责其中一个检查点;src/index.ts:100 负责其中一个检查点;src/index.ts:104 用 bareName、wholeWord 和 rank 分层排序,避免 x 匹配到 Maxim 这类误选。
71// The catalogue is written in site names, an agent usually holds a URL: "discord.com" has to 72// find "Discord". Strip what the URL adds around the name and keep the raw query as well, 73// because a few entries spell the domain out ("X.com (Twitter)"). 74const bareName = (query: string) => 75 query.toLowerCase().trim() 76 .replace(/^[a-z]+:\/\//, "").replace(/^www\./, "") 77 .replace(/\/.*$/, "").replace(/\.[a-z]{2,}$/, ""); 78 79// A short query lands inside unrelated names: "x" sits in the middle of "Maxim". Matching the 80// whole word first keeps those below the site the agent actually asked for. 81const escapeRe = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 82const wholeWord = (name: string, needle: string) => 83 needle.length > 0 && new RegExp(`(^|[^a-z0-9])${escapeRe(needle)}([^a-z0-9]|$)`).test(name); 84 85// Lower is better. Ranking matters because a short query matches dozens of names and the 86// agent picks the first thing it reads. 87const rank = (service: Service, query: string, bare: string) => { 88 const id = service.id.toLowerCase(); 89 const name = service.name.toLowerCase(); 90 if (id === query || id === bare) return 0; 91 if (name === query || name === bare) return 1; 92 if (wholeWord(name, query)) return 2; 93 if (wholeWord(name, bare)) return 3; 94 if (name.includes(query)) return 4; 95 if (name.startsWith(bare)) return 5; 96 if (name.includes(bare)) return 6; 97 return -1; 98}; 99 100const view = (service: Service) => { 101 // the API sends 0 where it has no statistics, which is not the same as "nothing arrives" 102 const measured = Number(service.deliverability ?? 0); 103 return { service: service.id, name: service.name, price: service.price, 104 online: service.quantity, deliveredPercent: measured > 0 ? measured : null }; 105};
关键流程
目录到下单的桥接点在 src/index.ts:117 负责其中一个检查点;src/index.ts:118 负责其中一个检查点;src/index.ts:124 负责其中一个检查点;src/index.ts:125 负责其中一个检查点;src/index.ts:129 负责其中一个检查点;src/index.ts:130 负责其中一个检查点;src/index.ts:137 负责其中一个检查点;src/index.ts:138,没有 search 时返回完整列表,有 search 时返回 matches 与 notInList。这个返回形状对 Agent 友好,因为它能先读最佳匹配,也能看到兜底服务。
边界 / 失败模式
src/index.ts:127到src/index.ts:129处理空 bare name,防止空字符串匹配整个目录。src/index.ts:140到src/index.ts:142在无匹配时提示换国家或用 notInList。src/index.ts:101到src/index.ts:104把 deliverability=0 改成 null,避免误读成 0% 到达率。