独立开发者写 MCP server 的第一版不要上来就做 OAuth、多租户和远程部署,把一个只读 SaaS demo 跑通:本地 stdio、一个 tool、一个 resource、Inspector 验证、Claude Desktop 配置。本文访问时间是 2026-05-22,@modelcontextprotocol/sdk 在 npm 上的 latest 为 1.29.0;如果你读到时版本变了,跑下面的版本检查命令,不要照抄旧文章里的底层 handler。
确认版本和 API 有无变化
MCP TypeScript SDK 迭代很快,旧文章常见问题是还在用 Server + setRequestHandler(ListToolsRequestSchema, ...) 当入门代码。新项目优先看官方 build-server 教程里的高层 McpServer,再用 npm 元数据确认版本。
npm view @modelcontextprotocol/sdk version
npm view @modelcontextprotocol/sdk peerDependencies --json
本文比对到的结果是:@modelcontextprotocol/sdk latest 为 1.29.0,peer dependency 支持 zod 的 ^3.25 || ^4.0。官方 build-server TypeScript 示例仍安装 zod@3,所以保守教程用 zod@3;如果你的项目已经全面迁到 Zod 4,用 tsc 验证 SDK 当前版本的类型。
| 场景 | 2026 年建议 | 验证方式 |
|---|---|---|
| 本地 Claude Desktop / Cursor 集成 | stdio transport | Inspector 能连接,客户端完整重启后能列出 tool |
| 公开远程 MCP 服务 | Streamable HTTP | 加鉴权、会话、限流,再用 HTTP 客户端测初始化 |
| 旧客户端兼容 | HTTP + SSE | 只在目标客户端明确要求时保留 |
| 入门代码 | McpServer.registerTool / registerResource | npm run build 无类型错误 |
项目初始化
下面命令在 macOS、Linux 和大多数 CI 环境都能跑。Windows 用户把路径写法换成 PowerShell 风格即可,MCP 本身不要求特定包管理器。
mkdir indie-mcp-server
cd indie-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod@3
npm install -D typescript tsx @types/node
npm pkg set type=module
npm pkg set scripts.build=tsc
npm pkg set scripts.dev="tsx src/index.ts"
npm pkg set scripts.inspect="npm run build && npx @modelcontextprotocol/inspector node build/index.js"
mkdir src
tsconfig.json 用 Node 16 模块解析,这是官方教程给 TypeScript server 的基线配置。Node 版本不用写死到文章里,在自己机器上跑 node --version;官方教程要求 Node.js 16 或更高,生产项目建议跟你的部署平台统一到当前 LTS。
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
最小 Server 写法
这个 demo 模拟一个独立 SaaS 的后台:tool 查询客户状态,resource 暴露定价规则。真实项目里,resource 更适合放只读文档、配置、schema、账单规则;tool 才适合触发查询、写入或外部 API 调用。
把下面内容保存为 src/index.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "indie-mcp-server",
version: "0.1.0",
});
const customers = [
{ id: "demo_001", plan: "pro", mrr: 29, status: "active" },
{ id: "demo_002", plan: "free", mrr: 0, status: "trial" },
] as const;
server.registerResource(
"pricing-policy",
"app://pricing-policy",
{
title: "Pricing policy",
description: "Read-only pricing notes for the SaaS support agent",
mimeType: "text/markdown",
},
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: "text/markdown",
text: "# Pricing\n\nFree plan: $0. Pro plan: $29/month. Refund window: 7 days.",
},
],
}),
);
server.registerTool(
"lookup_customer",
{
title: "Lookup customer",
description: "Return a demo customer's plan, MRR and account status by customer id.",
inputSchema: {
customerId: z.string().min(1).describe("Customer id, for example demo_001"),
},
},
async ({ customerId }) => {
const customer = customers.find((item) => item.id === customerId);
if (!customer) {
return {
isError: true,
content: [{ type: "text", text: `No customer found for ${customerId}` }],
};
}
return {
content: [
{
type: "text",
text: JSON.stringify(customer, null, 2),
},
],
};
},
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("indie-mcp-server running on stdio");
}
main().catch((error) => {
console.error("Fatal error in MCP server", error);
process.exit(1);
});
注意最后两处都用 console.error。stdio transport 把 stdout 留给 JSON-RPC 协议,console.log 会污染协议流,表现成客户端连不上、初始化失败或莫名其妙的 JSON parse error。
本地验证 Tools 和 Resources
先不要急着接 Claude Desktop。用 Inspector 单独验证协议,能少排一层客户端配置问题。
npm run build
npx @modelcontextprotocol/inspector node build/index.js
Inspector 打开后做四件事:
- 确认连接成功,server 名称显示为
indie-mcp-server。 - 在 Tools 里调用
lookup_customer,参数填{"customerId":"demo_001"}。 - 再填
{"customerId":"missing"},确认错误路径返回isError: true。 - 在 Resources 里读取
app://pricing-policy,确认 MIME type 和文本内容都正常。
这一步比直接问 Claude 更可靠,因为它会暴露 schema、返回值和 notification stream。等 Inspector 通过后,再接具体客户端。
接入 Claude Desktop
Claude Desktop 的本地 server 配置要写绝对路径。官方调试文档也提醒,客户端启动 stdio server 时工作目录可能不是你的项目目录,配置相对路径很容易在重启后失效。
macOS 配置文件路径通常是:
~/Library/Application Support/Claude/claude_desktop_config.json
示例配置:
{
"mcpServers": {
"indie-demo": {
"command": "node",
"args": ["/ABSOLUTE/PATH/indie-mcp-server/build/index.js"],
"env": {
"SAAS_API_BASE": "https://api.example.com"
}
}
}
}
保存后要完整退出并重新打开 Claude Desktop,只关窗口不一定会重启 MCP 进程。连接失败时先看 Claude 日志:macOS 在 ~/Library/Logs/Claude,Windows 在 %APPDATA%\\Claude\\logs。
密钥与权限存放
MCP server 的权限等于启动它的客户端进程权限。独立开发者最容易犯的错,是把 Stripe live secret、数据库 URL、内部 admin token 直接塞进本地配置,然后让 tool 按模型请求自由调用。
更稳的限制是三层:
| 限制 | 做法 | 不做什么 |
|---|---|---|
| 代码仓库 | .env.example 只放变量名 | 不提交真实 .env、Claude 配置和客户数据 |
| MCP tool | 默认只读、限字段、限返回条数 | 不让模型直接拿全量用户表或 live secret |
| 后端代理 | 用短期 token、scope、审计日志 | 不把支付平台主密钥交给本地客户端 |
如果必须做写操作,例如退款、改套餐、发邮件,把 tool 名字写得非常明确:create_refund_draft 比 manage_billing 安全。返回值先生成草稿,让真人在你的后台确认;不要把「客户端会弹确认」当成唯一权限模型。
日志与调试
本地 stdio server 用 stderr。最简单的结构化日志是:
console.error(JSON.stringify({
level: "info",
event: "tool_called",
tool: "lookup_customer",
customerId,
ts: new Date().toISOString(),
}));
不要记录 API key、Authorization header、完整用户邮箱、支付卡信息和原始 prompt。需要排查时记录 request id、tool 名、耗时、状态码和被脱敏的实体 id 就够了。
远程 Streamable HTTP server 不受 stdout 协议限制,但要把日志接进平台侧:Cloudflare Workers Logs、Railway Logs、Fly.io Logs 或你自己的 OpenTelemetry。远程服务还要额外测 Mcp-Session-Id、并发请求、超时和鉴权失败路径。
什么时候做远程部署
本地 stdio 适合个人工作流、内部客服助手、连接本机文件或内网服务。想把 MCP server 给用户用,才考虑远程托管。
远程部署前至少补齐这些东西:
- Streamable HTTP transport,而不是把 stdio 进程硬挂到 Web server 后面。
- HTTPS、认证、限流、审计日志和版本化 endpoint。
- 多租户隔离:tenant id 不能只靠模型传参决定。
- 工具超时:外部 API 10 秒没返回就失败,不要让客户端一直等。
- 向后兼容策略:SDK 升级后先跑 Inspector 和一组真实客户端回归。
如果你只是给自己或 3 人小团队用,先保持本地 stdio。远程 MCP 的维护成本不在代码量,而在权限、密钥轮换、日志留存和协议升级。