AI人工智能

MCP v2 生产级服务实战:Tools、Resources、OAuth、幂等与安全治理

基于 MCP 2026-07-28 与 TypeScript SDK v2,从工具、资源和提示模板开始,实现身份、租户隔离、幂等、人工审批、审计、契约测试和生产观测。

TY
Tycho
技术博主
• 2026-09-26 • 34 分钟阅读 • 2 次浏览
MCP v2 生产级服务实战:Tools、Resources、OAuth、幂等与安全治理

一、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 API

MCP 描述的是协议,不会替你完成授权、业务幂等、审计和人工审批。模型传来的参数永远是不可信输入,服务端必须重新校验身份、租户、资源范围和业务状态。

二、初始化 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');
  });
});
  1. 用 Inspector 或测试客户端验证 initialize/discover、工具列表、资源读取和 Prompt 渲染。
  2. 对每个 Tool 做 schema 边界、缺少 scope、跨租户、重复请求、超时和后端 500 测试。
  3. 把审计日志与请求 trace 关联,但对描述、令牌和个人信息做脱敏。
  4. 在预发布使用固定任务集评测工具选择率、参数正确率、未授权调用率和人工否决率。

九、部署、观测与运行手册

  • 容器以非 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,最后用幂等、审批、测试和可观测性封闭风险,才能把演示服务升级为可信基础设施。

十一、官方资料

TY

Tycho

热爱分享技术知识,帮助开发者成长。

评论 (0)

评论功能当前已关闭
暂无评论,快来抢沙发吧!