mcpserver
§ 00 · Source-level Observatory

mcp-server 给 Agent 一支临时手机号

SV Number MCP server 把商业短信验证码 API 包成 9 个 MCP stdio 工具:Agent 可以查国家、查服务、下单号码、等待验证码、完成或取消 activation,还能本地计算 TOTP。这个项目值得拆,因为它把“不稳定、文本 marker 风格的外部 API”翻译成 Agent 可理解、可恢复的工具契约。

★ 618
GitHub Stars
528
LOC
14
Files
32h
Last commit
Agent ↓ MCP stdio sv-number-mcp ├─ list_services / order_number ├─ wait_for_code / finish_activation └─ totp_code 本地计算 ↓ handler_api SV Number API
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

这个仓库不是做一个通用短信平台 SDK,而是非常明确地服务 Agent:当自动化流程碰到手机号验证墙,MCP server 提供一个可被 Claude Code、Codex、Cursor 或 Claude Desktop 调用的工具边界。入口在 src/index.ts:24 负责其中一个检查点;src/index.ts:34 负责其中一个检查点;src/index.ts:51 负责其中一个检查点;src/index.ts:107 负责其中一个检查点;src/index.ts:150 负责其中一个检查点;src/index.ts:179,这些位置说明它先创建 MCP server,再逐个注册 get_balance、list_countries、list_services、order_number、wait_for_code 等工具,而不是把 API 方法裸暴露给模型。

我更看重它的工程取舍:API key 只从环境读取,返回值统一包成 MCP text content,错误统一标记 isError,外部 API 的裸文本 marker 通过 api.ts 变成可行动句子。证据集中在 src/index.ts:26 负责其中一个检查点;src/index.ts:29 负责其中一个检查点;src/index.ts:39 负责其中一个检查点;src/index.ts:45 负责其中一个检查点;src/index.ts:63 负责其中一个检查点;src/index.ts:144 配合另一个证据点 src/api.ts:43 负责其中一个检查点;src/api.ts:45 负责其中一个检查点;src/api.ts:47 负责其中一个检查点;src/api.ts:50 负责其中一个检查点;src/api.ts:56 负责其中一个检查点;src/api.ts:59;这让 Agent 看到的是“换国家或 operator=any”,不是无法决策的 NO_NUMBERS 字符串。

▸核心数据

架构模式
Node.js MCP stdio server + thin HTTP client + 本地 TOTP helper。
核心抽象
activationId 是号码生命周期主键,service/country/operator 是采购参数,marker 是外部 API 状态协议。
性能/精度数据
项目本身不做模型推理;关键体验指标是 4 秒轮询间隔、20 分钟号码生命周期、deliveredPercent 和 online pool。
下游依赖
@modelcontextprotocol/sdk、zod、Node fetch、SV Number handler_api。
本文导航 — 主页面拆整体架构,子页面分别展开 MCP 工具注册、API marker 客户端和 TOTP / manifest 安全配置。
§ 01 · ARCHITECTURE

九个工具的边界

MCP server 的第一层边界来自工具粒度:账户余额、国家、服务目录、号码下单、验证码轮询、完成、取消、再次短信和 TOTP 被拆成独立工具。src/index.ts:34 负责其中一个检查点;src/index.ts:51 负责其中一个检查点;src/index.ts:107 负责其中一个检查点;src/index.ts:150 负责其中一个检查点;src/index.ts:179 负责其中一个检查点;src/index.ts:208 负责其中一个检查点;src/index.ts:223 负责其中一个检查点;src/index.ts:239 负责其中一个检查点;src/index.ts:254 这组注册点覆盖了完整生命周期,模型不需要记 API action 名,也不会把“取消”和“完成”混成一个状态字符串。

▸系统拓扑

MCP Client -- tool call JSON --> src/index.ts -- call/expect/json --> src/api.ts -- handler_api?action=... --> SV Number -- ACCESS_NUMBER / STATUS_OK / NO_NUMBERS --> marker translator -- MCP text + isError --> Agent

▸模块清单

模块很少,但责任拆得干净:src/index.ts:15 负责其中一个检查点;src/index.ts:16 负责其中一个检查点;src/index.ts:18 负责其中一个检查点;src/index.ts:24 负责把版本、API client 和 TOTP helper 接到 MCP server;src/api.ts:6 负责其中一个检查点;src/api.ts:16 负责其中一个检查点;src/api.ts:43 负责其中一个检查点;src/api.ts:63 负责环境变量、错误表、HTTP 调用和 JSON 解析;src/totp.ts:4 负责其中一个检查点;src/totp.ts:6 负责其中一个检查点;src/totp.ts:25 负责其中一个检查点;src/totp.ts:42 负责本地二次验证码计算。

src/index.ts
MCP 工具注册、参数 schema、生命周期状态和人类可读 note。
src/api.ts
SV Number handler_api 的最薄封装,把 marker 风格回复翻译成 Error。
src/totp.ts
RFC 6238 TOTP 计算,secret 不离开本地进程。

▸关键数据结构:Service 排序视图

服务目录不是直接原样返回。src/index.ts:69 负责其中一个检查点;src/index.ts:74 负责其中一个检查点;src/index.ts:81 负责其中一个检查点;src/index.ts:87 负责其中一个检查点;src/index.ts:100 负责其中一个检查点;src/index.ts:103 先定义 Service,再把 URL 清成 bareName、用 wholeWord 保护短查询,最后输出 price、online 和 deliveredPercent;这个设计让 Agent 可以按成功率和库存决策。

src/index.ts · L69-105TypeScript
69type Service = { id: string; name: string; price: number; quantity: number; deliverability?: string };
70
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};
深度阅读 · MCP 工具注册
§ 02 · CORE MODULES

API marker 翻译层

▸环境变量与密钥边界

API client 的关键点是没有全局配置文件:src/api.ts:6 负责其中一个检查点;src/api.ts:8 负责其中一个检查点;src/api.ts:43 负责其中一个检查点;src/api.ts:44 负责其中一个检查点;src/api.ts:45 负责其中一个检查点;src/api.ts:47 从环境取 BASE、LANG 和 SVN_API_KEY,缺 key 直接抛错,构造 URLSearchParams 时才把 key 加进请求。README 说 key 不会作为工具返回,源码也确实没有把 query 或 key 写入 MCP response。

src/api.ts · L6-14TypeScript
6const BASE = process.env.SVN_API_BASE
7  ?? "https://sms-verification-number.com/stubs/handler_api";
8const LANG = process.env.SVN_LANG ?? "en";
9
10export class ApiError extends Error {
11  constructor(public marker: string, message: string) {
12    super(message);
13  }
14}

▸marker 到可行动错误

SV Number API 用 HTTP 200 返回 NO_BALANCE、NO_NUMBERS、BAD_STATUS 等文本 marker。src/api.ts:16 负责其中一个检查点;src/api.ts:18 负责其中一个检查点;src/api.ts:21 负责其中一个检查点;src/api.ts:29 负责其中一个检查点;src/api.ts:30 负责其中一个检查点;src/api.ts:32 负责其中一个检查点;src/api.ts:38 负责其中一个检查点;src/api.ts:40 把这些 marker 翻译成 Agent 能下一步执行的句子;src/api.ts:56 负责其中一个检查点;src/api.ts:58 负责其中一个检查点;src/api.ts:59 负责其中一个检查点;src/api.ts:63 负责其中一个检查点;src/api.ts:66 负责其中一个检查点;src/api.ts:68 则把 expect 和 json 两条路径都接入同一个解释器。

src/api.ts · L16-36TypeScript
16// what the markers mean, in words an agent can act on
17const ERRORS: Record<string, string> = {
18  BAD_KEY: "The API key is not accepted. Check SVN_API_KEY.",
19  // checked against the live API: an unknown service, an unknown country and an unknown
20  // activation id all come back as ERROR_API, so this is not only a server-side failure
21  ERROR_API: "Either the parameters are wrong (unknown service, country or activation id) "
22    + "or the provider had a hiccup. Check the values against list_services and "
23    + "list_countries before retrying.",
24  ERROR_SQL: "Server-side error on the provider side. Retry with a backoff.",
25  BAD_ACTION: "Malformed parameters, for example an activation id that is not a number.",
26  NO_METHOD: "Unknown action.",
27  BAD_STATUS: "The activation is not in a state where that call makes sense: it may be "
28    + "cancelled, already finished, or still waiting for its first code.",
29  NO_BALANCE: "The account balance is empty. It has to be topped up before ordering.",
30  NO_NUMBERS: "No numbers for this country and service right now. Change country, or set operator to any.",
31  NO_ACTIVATION: "Unknown activation id, or the 20 minutes already ran out.",
32  CANNOT_BEFORE_2_MIN: "This cancel came too early for the API. Wait a couple of minutes and repeat.",
33  REQUEST_LIMIT: "Over the rate limit. Slow the polling down.",
34  WRONG_MAX_PRICE: "The max price is below the current minimum for this service.",
35  BANNED: "The account is blocked.",
36};
深度阅读 · API marker 客户端
§ 03 · KEY FLOW

号码生命周期

号码生命周期由 order、wait、finish/cancel 三段组成。src/index.ts:150 负责其中一个检查点;src/index.ts:163 负责其中一个检查点;src/index.ts:165 负责其中一个检查点;src/index.ts:167 负责其中一个检查点;src/index.ts:168 负责其中一个检查点;src/index.ts:169 下单后返回 activationId 和 phone;src/index.ts:179 负责其中一个检查点;src/index.ts:188 负责其中一个检查点;src/index.ts:190 负责其中一个检查点;src/index.ts:191 负责其中一个检查点;src/index.ts:192 负责其中一个检查点;src/index.ts:198 负责其中一个检查点;src/index.ts:200 在 20 分钟窗口内轮询;src/index.ts:208 负责其中一个检查点;src/index.ts:215 负责其中一个检查点;src/index.ts:223 负责其中一个检查点;src/index.ts:231 负责其中一个检查点;src/index.ts:239 负责其中一个检查点;src/index.ts:246 把成功、取消和再次短信拆成明确状态转移。

▸下单路径

order_number → params(service,country,operator,maxPrice) → expect ACCESS_NUMBER → split id/phone → Agent note

▸等待验证码路径

wait_for_code → getStatus loop → STATUS_OK(code) / STATUS_CANCEL / timeout → finish or cancel
深度阅读 · TOTP 与 manifest 配置
§ 04 · TESTS & VALIDATION

源码里的自校验,而不是测试目录

仓库没有单独 tests 目录,所以质量来自运行时保护:src/index.ts:157 负责其中一个检查点;src/index.ts:159 负责其中一个检查点;src/index.ts:160 负责其中一个检查点;src/index.ts:161 负责其中一个检查点;src/index.ts:184 负责其中一个检查点;src/index.ts:185 负责其中一个检查点;src/index.ts:259 负责其中一个检查点;src/index.ts:260 负责其中一个检查点;src/index.ts:262 用 zod 限制工具参数;src/api.ts:50 负责其中一个检查点;src/api.ts:51 负责其中一个检查点;src/api.ts:52 负责其中一个检查点;src/api.ts:63 负责其中一个检查点;src/api.ts:65 负责其中一个检查点;src/api.ts:67 负责其中一个检查点;src/api.ts:68 用 response.ok 和 JSON parse fallback 保护 API 边界;src/totp.ts:12 负责其中一个检查点;src/totp.ts:14 负责其中一个检查点;src/totp.ts:33 负责其中一个检查点;src/totp.ts:35 负责其中一个检查点;src/totp.ts:37 负责其中一个检查点;src/totp.ts:40 对 base32 字符、HMAC digest 和动态截断做本地检查。

▸参数 schema 就是第一层验收

src/index.ts · L150-177TypeScript
150server.tool(
151  "order_number",
152  "Order a private number for one service in one country. The number is held for 20 minutes "
153  + "and belongs to this activation alone. Money is held now and charged when the code arrives. "
154  + "The service code has to be the one for the site you are registering with, otherwise its SMS "
155  + "goes to a different pool and never reaches you.",
156  {
157    service: z.string().describe("Service code from list_services, for example tg. It must belong "
158      + "to the site you are registering with, or be ot when that site is not in the catalogue"),
159    country: z.number().int().describe("Country id from list_countries"),
160    operator: z.string().default("any").describe("Operator, or any to keep the pool wide"),
161    maxPrice: z.number().optional().describe("Ceiling per activation; prices float with demand"),
162  },
163  async ({ service, country, operator, maxPrice }) => {
164    try {
165      const params: Record<string, string | number> = { service, country, operator };
166      if (maxPrice !== undefined) params.maxPrice = maxPrice;
167      const reply = await expect("ACCESS_NUMBER", "getNumber", params);
168      const [, id, phone] = reply.split(":");
169      return text({
170        activationId: id, phone,
171        note: "The phone has no leading +. Add it when the signup form wants E.164.",
172      });
173    } catch (error) {
174      return failure(error);
175    }
176  },
177);

▸轮询边界是第二层验收

src/index.ts · L179-206TypeScript
179server.tool(
180  "wait_for_code",
181  "Wait for the verification code on an ordered number. Polls on its own and returns the code "
182  + "itself, already parsed out of the SMS. Trigger the SMS on the target service first.",
183  {
184    activationId: z.string().describe("Id returned by order_number"),
185    timeoutSeconds: z.number().int().default(300).describe("How long to wait, up to the 20 minute life"),
186  },
187  async ({ activationId, timeoutSeconds }) => {
188    const deadline = Date.now() + Math.min(timeoutSeconds, 20 * 60) * 1000;
189    try {
190      while (Date.now() < deadline) {
191        const reply = await call("getStatus", { id: activationId });
192        if (reply.startsWith("STATUS_OK")) return text({ code: reply.split(":")[1] });
193        // the same answer covers a cancellation you made and the 20 minute expiry
194        if (reply === "STATUS_CANCEL") return text({ code: null, state: "cancelled",
195          next: "The activation is over and the money is back on the balance. Order a fresh "
196              + "number instead of calling this id again." });
197        if (!reply.startsWith("STATUS_")) throw new ApiError(reply.split(":")[0], reply);
198        await new Promise((resolve) => setTimeout(resolve, pollSeconds * 1000));
199      }
200      return text({ code: null, state: "timeout",
201                    next: "Call cancel_activation to get the money back, or wait again." });
202    } catch (error) {
203      return failure(error);
204    }
205  },
206);

▸API parse fallback 是第三层验收

src/api.ts · L43-72TypeScript
43export async function call(action: string, params: Record<string, string | number> = {}): Promise<string> {
44  const key = process.env.SVN_API_KEY;
45  if (!key) throw new Error("SVN_API_KEY is not set in the environment.");
46
47  const query = new URLSearchParams({ api_key: key, action, lang: LANG });
48  for (const [name, value] of Object.entries(params)) query.set(name, String(value));
49
50  const response = await fetch(`${BASE}?${query}`, { headers: { Accept: "*/*" } });
51  if (!response.ok) throw new Error(`HTTP ${response.status} from the API.`);
52  return (await response.text()).trim();
53}
54
55/** Calls an action that must answer with `marker`, and returns the reply. */
56export async function expect(marker: string, action: string,
57                             params: Record<string, string | number> = {}): Promise<string> {
58  const reply = await call(action, params);
59  if (!reply.startsWith(marker)) throw new ApiError(reply.split(":")[0], explain(reply));
60  return reply;
61}
62
63export async function json<T>(action: string, params: Record<string, string | number> = {}): Promise<T> {
64  const reply = await call(action, params);
65  try {
66    return JSON.parse(reply) as T;
67  } catch {
68    throw new ApiError(reply.split(":")[0], explain(reply));
69  }
70}
71
72export const pollSeconds = Number(process.env.SVN_POLL_SECONDS ?? 4);

▸本地 TOTP 避免泄露 second factor

src/totp.ts · L25-43TypeScript
25export function totp(secret: string, options: { digits?: number; period?: number;
26                                                algorithm?: string; at?: number } = {}) {
27  const digits = options.digits ?? 6;
28  const period = options.period ?? 30;
29  const algorithm = (options.algorithm ?? "sha1").toLowerCase();
30  const seconds = options.at ?? Math.floor(Date.now() / 1000);
31  const counter = Math.floor(seconds / period);
32
33  const message = Buffer.alloc(8);
34  message.writeBigUInt64BE(BigInt(counter));
35  const digest = createHmac(algorithm, decodeBase32(secret)).update(message).digest();
36
37  const offset = digest[digest.length - 1] & 0x0f;
38  const binary = ((digest[offset] & 0x7f) << 24) | (digest[offset + 1] << 16)
39               | (digest[offset + 2] << 8) | digest[offset + 3];
40  const code = (binary % 10 ** digits).toString().padStart(digits, "0");
41
42  return { code, secondsLeft: period - (seconds % period) };
43}
  • src/index.ts:188 将 timeout 限制在 20 分钟生命期内,避免 Agent 无限等待过期号码。
  • src/api.ts:30 把 NO_NUMBERS 翻译成换国家或 operator=any 的可行动建议。
  • src/totp.ts:14 遇到非法 base32 字符立即失败,避免返回伪验证码。
§ 05 · HIGHLIGHTS

技术亮点

短查询排序很实用
src/index.ts:74 负责其中一个检查点;src/index.ts:81 负责其中一个检查点;src/index.ts:87 负责其中一个检查点;src/index.ts:90 负责其中一个检查点;src/index.ts:92 负责其中一个检查点;src/index.ts:94 负责其中一个检查点;src/index.ts:96 显示作者专门处理 x、domain 和 substring 排序,减少 Agent 选错服务码。
生命周期语义清楚
src/index.ts:167 负责其中一个检查点;src/index.ts:192 负责其中一个检查点;src/index.ts:194 负责其中一个检查点;src/index.ts:200 负责其中一个检查点;src/index.ts:215 负责其中一个检查点;src/index.ts:231 负责其中一个检查点;src/index.ts:246 把 ACCESS_NUMBER、STATUS_OK、STATUS_CANCEL 和 status code 6/8/3 分开,便于恢复。
错误面向 Agent
src/api.ts:16 负责其中一个检查点;src/api.ts:21 负责其中一个检查点;src/api.ts:29 负责其中一个检查点;src/api.ts:30 负责其中一个检查点;src/api.ts:33 负责其中一个检查点;src/api.ts:34 不是暴露裸 marker,而是告诉模型该充值、换国家、放慢轮询还是检查参数。
TOTP 在本地
src/totp.ts:25 负责其中一个检查点;src/totp.ts:33 负责其中一个检查点;src/totp.ts:35 负责其中一个检查点;src/totp.ts:37 负责其中一个检查点;src/totp.ts:40 负责其中一个检查点;src/totp.ts:42 secret 不进网络请求,返回 code 和 secondsLeft,适合作为同一 MCP server 的辅助工具。
§ 06 · RISKS & GAPS

技术亮点之外,别忽略这些

这个项目边界清晰,但风险也清晰:它把 Agent 带进真实手机号验证流程。src/index.ts:150 负责其中一个检查点;src/index.ts:179 负责其中一个检查点;src/index.ts:208 负责其中一个检查点;src/index.ts:223 负责其中一个检查点;src/index.ts:239 负责其中一个检查点;src/index.ts:254 说明工具已经覆盖完整 activation 生命周期;如果上层 Agent 没有政策、审计和人工确认,就可能把“注册账号”变成过度自动化的灰区动作。

没有本地测试目录
源码有参数 schema 和 marker 防护,但仓库未提供单元测试或 mock API;未来最该补的是 api.ts marker matrix。
商业 API 强依赖
SVN_API_BASE 可配置,但默认指向单一 provider;provider 行为变更会直接影响工具语义。
使用场景需要治理
README 明确不适合银行、支付、政府账号;真正接入 Agent 前需要组织级 allowlist。
轮询仍需速率保护
默认 4 秒轮询合理,但大规模并发 Agent 仍需上层限流和预算控制。
诚实记录 — 页面基于 2026-08-12 克隆源码,未实际调用商业 API,也未验证短信到达率。
Generated · 2026-08-12Source · sv-number/mcp-server↑ index