用 TypeScript 构建类型安全的 MCP 服务器,连接企业数据
两年前,想在德国找出 50 到 200 人规模的 SaaS 公司,我们得打开门户网站、设置筛选条件,再把结果导出到 Excel。现在,同样的需求可以变成一个 MCP 工具调用:大语言模型(LLM)客户端调用 search_companies,传入关键词、国家代码和结果数量限制,GraphQL API 则在对话中直接返回结构化数据。第一版只花了一个下午就写完,但真正让它能够处理生产数据,却用了好几个月。这期间的大部分工作都集中在类型安全、权限边界和测试上,而不是协议本身。
技术栈
我们的 MCP 服务器把 LLM 客户端连接到一家 B2B 企业情报平台,上面有超过一百万家公司的档案。现有的 GraphQL API 跑在 AWS AppSync 上,已经做好了访问控制,所以 MCP 层只是一个 TypeScript 进程,用的是官方 @modelcontextprotocol/sdk。它的职责是校验输入、把工具调用转成 GraphQL 请求,再把结果返回给模型。本地开发通过 aws-vault 运行,这样可以避免把长期有效的 AWS 凭证写进配置文件和 .mcp.json 文件里。
围绕工作流设计工具
最初,我们的工具清单完全照搬 GraphQL API,每个后端操作对应一个 MCP 工具。结果模型经常要把好几个底层调用串起来,才能完成一个用户眼中的单一操作,而每多一次调用,就多一次参数出错的机会。于是我们改用基于工作流的工具,覆盖公司搜索、档案查询和收藏集访问。计划中的工具一共九个,但并没有 run_graphql_query,因为自由格式的查询接口很难校验,还可能把提示注入变成数据泄露。
用同一套 Schema 做契约和校验
每个工具都用 Zod schema 作为参数的唯一事实来源。MCP SDK 会把它转成展示给客户端的 JSON Schema,在运行时执行校验,并给处理函数提供带类型的参数。这样一来,对外声明的输入契约就不会和服务器实际实现脱节。一个典型的搜索工具长这样:
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const server = new McpServer({ name: "company-data", version: "1.0.0" });
server.registerTool(
"search_companies",
{
description: "Search companies by keyword, with an optional country filter.",
inputSchema: {
query: z.string().min(1),
country: z.string().length(2).optional(),
limit: z.number().int().min(1).max(100).default(10),
},
},
async ({ query, country, limit }) => {
const result = await gqlClient.searchCompanies({ query, country, limit });
return { content: [{ type: "text", text: JSON.stringify(result) }] };
}
);
这里用 MCP SDK 创建了一个名为 company-data 的服务器,然后通过 registerTool 注册了一个名为 search_companies 的工具。工具的描述说明了它的用途:按关键字搜索公司,并支持可选的国家筛选。inputSchema 定义了输入参数的校验规则:query 是必填字符串且长度至少为 1;country 是可选的两字符国家代码;limit 是可选数字,默认值为 10,取值范围在 1 到 100 之间。
当模型调用这个工具时,实际执行的函数会拿着这些参数去调用 gqlClient.searchCompanies 查询底层数据,然后把结果以 JSON 文本的形式包装成 MCP 标准的内容格式返回。
默认最小权限
服务器默认以只读模式启动,写操作需要显式指定启动参数。计划中的 9 个工具里,6 个只读,每个变更处理器在访问后端之前都会检查 mutationsAllowed 标志。写请求被拒绝时,错误信息会提示需要 --allow-mutations 参数;启动日志则会显示当前模式和已注册的工具数量。
我们把读写能力拆到不同工具中,因为调用方是概率模型,结构上的分离比依赖一个模式参数更安全。每个意图都有独立的速率限制。ai_search 工具每分钟最多 5 次请求,既能覆盖对话中常见的两到四次追问,也能打断失控的循环调用。这并不能阻止提示注入,但能限制注入指令能访问的范围,以及调用后端的频率。一个只读、schema 严格的服务器,相比带变更权限的通用查询工具,给攻击者留下的空间要小得多。
把 MCP 层当作后端服务来测试
通过 LLM 客户端调试时,很难区分是服务器缺陷还是模型行为导致的。我们用模拟的 GraphQL 客户端对每个处理器做了测试,覆盖无效查询、超限请求和被拦截的变更操作,同时 mock 会捕获发送到下游的变量。这些测试在发布前发现了一个国家代码归一化的 bug。之后,一个较小的集成测试套件使用 aws-vault 凭据,针对真实的 AppSync 端点运行。MCP Inspector 仍然作为发布前的手动检查环节保留,因为它可以直接进行 stdio 调用,不会把模型行为掺杂进测试。
集成测试的必要性,在 create_collection 这个工具上体现得很明显:所有模拟测试都通过了,但一接真实后端,就在 Lambda 解析器里抛出了空指针异常。我们只好先把该工具从注册列表中移除,等解析器修复后再放回去,因此最终发布的服务端只暴露了 8 个工具,而不是原计划的 9 个。单元测试能验证包装逻辑,却无法证明后端真的接受了协议生成的操作。
这次事故还揭出一条传输层规则:stdout 属于 JSON-RPC 协议的一部分,任何一行多余的 console.log 都可能污染 stdio 消息流。
生产环境经验
生产环境中的 MCP 服务端,本质上是连接「概率性调用方」与生产数据的集成服务,而不是周末随手写写就能上线的 API 包装器。团队应当从只读工作流起步,设置严格的 schema 限制,并且在开放写权限之前,必须有明确可见的审批决策。日志要包含足够的上下文,方便事后还原:哪个工具被执行了、哪个用户授权了、后端返回了什么结果。此外,职责归属也要落实到具体团队,否则工具的审核很容易在平台工程团队和 AI 功能开发团队之间互相推诿。