一、MCP 服务解决什么问题
Model Context Protocol 把 AI 应用与外部能力之间的交互标准化。生产实现不能只做到“工具能调用”,还要明确三种原语的控制权:Prompt 由用户主动选择,Resource 由应用决定如何提供上下文,Tool 由模型选择调用但必须受服务端策略约束。本文以 2026-07-28 规范和 TypeScript SDK v2 为基线,构建一个可验证的工单服务。
AI host/client -> MCP transport -> authentication -> policy gate
-> tools: create_ticket / get_ticket
-> resources: policy://support/priority
-> prompts: triage-ticket
-> audit log + idempotency store + backend APIMCP 描述的是协议,不会替你完成授权、业务幂等、审计和人工审批。模型传来的参数永远是不可信输入,服务端必须重新校验身份、租户、资源范围和业务状态。
二、初始化 TypeScript v2 项目
mkdir mcp-ticket-server && cd mcp-ticket-server
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D typescript tsx vitest @types/node
npx tsc --init --rootDir src --outDir dist \
--module nodenext --moduleResolution nodenext --target es2022 --strict
mkdir -p src test将 package.json 的 type 设为 module,并锁定依赖版本。CI 使用 npm ci,不要每次构建漂移到新版本。服务配置从环境变量读取,启动时验证缺失项并立即失败。
三、先定义后端边界和调用上下文
import { z } from 'zod';
export const CreateTicketInput = z.object({
title: z.string().trim().min(5).max(120),
description: z.string().trim().min(10).max(4000),
priority: z.enum(['low', 'normal', 'high']),
idempotencyKey: z.string().uuid(),
});
export type Caller = {
subject: string;
tenantId: string;
scopes: Set<string>;
};
export function requireScope(caller: Caller, scope: string) {
if (!caller.scopes.has(scope)) throw new Error('FORBIDDEN');
}调用上下文必须由已验证的访问令牌生成,不能让模型在参数中传 tenantId 或 userId。idempotencyKey 由客户端为一次业务意图生成;服务端以 tenantId + key 建唯一索引,网络重试时返回第一次结果。
四、实现 Tools:结构化输入、最小输出与幂等
import { McpServer } from '@modelcontextprotocol/server';
import { z } from 'zod';
import { CreateTicketInput, requireScope } from './domain.js';
import { ticketRepo, audit } from './infra.js';
export function registerTools(server: McpServer, callerForRequest: () => Caller) {
server.registerTool('create_ticket', {
title: 'Create support ticket',
description: 'Creates one support ticket after explicit user confirmation.',
inputSchema: CreateTicketInput,
outputSchema: z.object({ticketId: z.string(), status: z.literal('created')}),
}, async (input) => {
const caller = callerForRequest();
requireScope(caller, 'tickets:create');
const parsed = CreateTicketInput.parse(input);
const existing = await ticketRepo.findByIdempotency(caller.tenantId, parsed.idempotencyKey);
if (existing) return {structuredContent: {ticketId: existing.id, status: 'created'}};
const ticket = await ticketRepo.createInTransaction(caller, parsed);
await audit.write({action: 'ticket.create', subject: caller.subject,
tenantId: caller.tenantId, resourceId: ticket.id, outcome: 'success'});
return {structuredContent: {ticketId: ticket.id, status: 'created'}};
});
}- 工具描述写清副作用、前置条件和输出,不用营销语诱导模型调用。
- 响应只返回完成下一步所需字段,不回传内部堆栈、数据库主键关系或访问令牌。
- 删除、付款、发信等高风险工具拆成 preview 与 execute;execute 需要短时确认令牌。
- 对超时、限流、冲突和后端不可用返回稳定错误码,客户端才能决定重试还是询问用户。
五、实现 Resources 与 Prompts
server.registerResource('support-priority-policy', 'policy://support/priority', {
title: 'Support priority policy',
mimeType: 'text/markdown',
}, async (uri) => ({contents: [{
uri: uri.href,
mimeType: 'text/markdown',
text: '# Priority policy\n- high: production outage or confirmed security incident\n- normal: degraded function with workaround\n- low: question or cosmetic issue',
}]}));
server.registerPrompt('triage-ticket', {
title: 'Triage a support request',
argsSchema: {symptom: z.string().max(2000)},
}, ({symptom}) => ({messages: [{role: 'user', content: {type: 'text',
text: `Use policy://support/priority. Classify this symptom, explain the evidence, and ask for confirmation before creating a ticket:\n${symptom}`}}]}));Resource URI 应稳定、可缓存、可授权;敏感资源按调用者动态过滤。Prompt 只是模板,不能绕过工具层权限。不要在 Prompt 中嵌入秘密或把内部系统提示当安全边界。
六、Transport、会话与鉴权
本地桌面集成可用 stdio;远程生产服务使用规范支持的 HTTP transport。2026-07-28 规范增加了无状态模式和 server/discover,但是否使用会话取决于业务需求。无论哪种模式,都要校验 OAuth issuer、audience、过期时间、scope,并限制 Host/Origin,防止 DNS rebinding。
import { createServer } from 'node:http';
createServer(async (req, res) => {
try {
assertAllowedHost(req.headers.host);
assertAllowedOrigin(req.headers.origin);
const token = readBearer(req.headers.authorization);
const claims = await verifyJwt(token, {
issuer: process.env.OIDC_ISSUER!, audience: 'mcp-ticket-server'
});
req.caller = {subject: claims.sub, tenantId: claims.tenant_id,
scopes: new Set(String(claims.scope).split(' '))};
await mcpHttpHandler(req, res);
} catch (error) {
writeSafeError(res, error);
}
}).listen(3000, '0.0.0.0');示例省略了框架胶水函数,但安全顺序不能省:先网络来源校验,再令牌校验,再创建调用上下文,最后交给 MCP handler。不要接受客户端自行声明的 issuer。
七、人工审批与防止混淆代理
create_ticket 虽然风险较低,仍应让用户看到标题、优先级和描述摘要。高风险操作采用两阶段协议:preview 返回规范化参数、影响范围和一次性 approval_id;用户确认后 execute 只接受 approval_id,服务端从数据库恢复原参数并校验未过期、未使用、同一用户和同一租户。这样模型无法在确认后偷偷替换收款人或资源 ID。
CREATE TABLE approvals (
id uuid PRIMARY KEY,
tenant_id text NOT NULL,
subject text NOT NULL,
action text NOT NULL,
canonical_payload jsonb NOT NULL,
payload_sha256 text NOT NULL,
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
UNIQUE (tenant_id, id)
);八、测试协议契约与失败路径
import { describe, expect, it } from 'vitest';
describe('create_ticket', () => {
it('deduplicates retries with the same key', async () => {
const input = {title: 'Checkout returns 502', description: 'Occurs for all users',
priority: 'high', idempotencyKey: crypto.randomUUID()};
const first = await callTool('create_ticket', input, callerWith('tickets:create'));
const second = await callTool('create_ticket', input, callerWith('tickets:create'));
expect(second.structuredContent.ticketId).toBe(first.structuredContent.ticketId);
expect(await countTickets()).toBe(1);
});
it('rejects cross-tenant access', async () => {
await expect(callTool('get_ticket', {ticketId: tenantBTicket}, tenantACaller))
.rejects.toThrow('NOT_FOUND');
});
});- 用 Inspector 或测试客户端验证 initialize/discover、工具列表、资源读取和 Prompt 渲染。
- 对每个 Tool 做 schema 边界、缺少 scope、跨租户、重复请求、超时和后端 500 测试。
- 把审计日志与请求 trace 关联,但对描述、令牌和个人信息做脱敏。
- 在预发布使用固定任务集评测工具选择率、参数正确率、未授权调用率和人工否决率。
九、部署、观测与运行手册
- 容器以非 root、只读文件系统运行;出站网络只允许后端 API 和身份提供者。
- 指标至少包括 tool_calls_total、tool_errors_total、tool_duration、approval_denied、idempotency_hits。
- 日志记录 tool、subject 哈希、tenant、resource、outcome、latency、trace_id;不记录完整输入。
- 发布采用 canary;错误率、P95 或拒绝率越过阈值立即回滚。
- 工具版本做向后兼容;破坏性 schema 变化新增工具名或明确版本,不原地改变语义。
十、总结
生产级 MCP 的价值不是让模型“拥有更多权限”,而是把能力暴露成有契约、可授权、可审计、可回滚的接口。先固定身份与租户边界,再实现结构化工具、资源和 Prompt,最后用幂等、审批、测试和可观测性封闭风险,才能把演示服务升级为可信基础设施。