mcp-server 给 Agent 一支临时手机号
SV Number MCP server 把商业短信验证码 API 包成 9 个 MCP stdio 工具:Agent 可以查国家、查服务、下单号码、等待验证码、完成或取消 activation,还能本地计算 TOTP。这个项目值得拆,因为它把“不稳定、文本 marker 风格的外部 API”翻译成 Agent 可理解、可恢复的工具契约。
它解决什么,凭什么值得拆
这个仓库不是做一个通用短信平台 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 字符串。
▸核心数据
九个工具的边界
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 名,也不会把“取消”和“完成”混成一个状态字符串。
▸系统拓扑
▸模块清单
模块很少,但责任拆得干净: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 负责本地二次验证码计算。
▸关键数据结构: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 可以按成功率和库存决策。
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};
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。
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 两条路径都接入同一个解释器。
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};
号码生命周期
号码生命周期由 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 把成功、取消和再次短信拆成明确状态转移。
▸下单路径
▸等待验证码路径
源码里的自校验,而不是测试目录
仓库没有单独 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 就是第一层验收
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);
▸轮询边界是第二层验收
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 是第三层验收
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
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 字符立即失败,避免返回伪验证码。
技术亮点
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 分开,便于恢复。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,而是告诉模型该充值、换国家、放慢轮询还是检查参数。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 的辅助工具。技术亮点之外,别忽略这些
这个项目边界清晰,但风险也清晰:它把 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 没有政策、审计和人工确认,就可能把“注册账号”变成过度自动化的灰区动作。