mcpserver
§ · Nested detail page

MCP 工具注册 深度解析

逐段拆解 sv-number-mcp 如何把国家、服务、号码和验证码变成 Agent 可调用工具。

§ 00 · IMPORT CHAIN

导入链与契约

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 结构。

src/index.ts · L9-28TypeScript
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});
§ 01 · CORE IMPL

核心实现,逐段拆

▸目录查询工具

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 与库存排序。

src/index.ts · L51-67TypeScript
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 这类误选。

src/index.ts · L71-105TypeScript
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};
§ 02 · KEY FLOW

关键流程

list_services(search) → bareName → rank → view(price, online, deliveredPercent) → order_number(service,country)

目录到下单的桥接点在 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 友好,因为它能先读最佳匹配,也能看到兜底服务。

§ 03 · EDGE CASES

边界 / 失败模式

  • 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% 到达率。
Sub-page · 2026-08-12Source · sv-number/mcp-server↑ mcp-server 主页面