MCP 服务器实现指南(TypeScript SDK)

使用官方 @modelcontextprotocol/sdk 从零构建 MCP 服务器。本文覆盖服务器初始化、工具注册、请求处理、错误传播、资源暴露和 Prompt 模板定义,附带完整可运行的代码示例。

1. 环境准备

MCP 官方提供了 TypeScript SDK(@modelcontextprotocol/sdk),这是实现 MCP Server 的首选方式。SDK 封装了协议细节,让开发者可以专注于业务逻辑。

1.1 项目初始化

mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init

1.2 基础项目结构

my-mcp-server/
├── src/
│   ├── index.ts          # 入口与服务器初始化
│   ├── tools.ts          # 工具定义与处理函数
│   ├── resources.ts      # 资源定义
│   └── prompts.ts        # 提示词模板
├── package.json
└── tsconfig.json

2. 创建基础服务器

2.1 入口文件

// src/index.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";

// 定义服务器能力
const server = new Server(
  {
    name: "weather-server",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
      resources: {},
    },
  }
);

// 注册工具列表处理器
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "get_weather",
        description: "获取指定城市的当前天气",
        inputSchema: {
          type: "object",
          properties: {
            city: {
              type: "string",
              description: "城市名称,如 Beijing",
            },
            units: {
              type: "string",
              enum: ["celsius", "fahrenheit"],
              description: "温度单位",
            },
          },
          required: ["city"],
        },
      },
    ],
  };
});

// 注册工具调用处理器
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  if (name === "get_weather") {
    const schema = z.object({
      city: z.string(),
      units: z.enum(["celsius", "fahrenheit"]).optional().default("celsius"),
    });
    const params = schema.parse(args);

    const weather = await fetchWeather(params.city, params.units);
    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(weather, null, 2),
        },
      ],
    };
  }

  throw new Error(`未知工具: ${name}`);
});

// 启动 stdio 传输
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Weather MCP Server running on stdio");

2.2 运行与测试

# 构建并运行
npx tsx src/index.ts

# 在 Claude Desktop 中配置
# ~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "weather": {
      "command": "npx",
      "args": ["-y", "tsx", "/path/to/my-mcp-server/src/index.ts"]
    }
  }
}

3. 工具调用的完整实现

3.1 输入验证

使用 Zod 进行强类型验证,确保参数符合预期:

// src/tools.ts
import { z } from "zod";

export const ToolSchemas = {
  get_weather: z.object({
    city: z.string().min(1).max(100),
    units: z.enum(["celsius", "fahrenheit"]).default("celsius"),
  }),

  search_location: z.object({
    query: z.string().min(1),
    limit: z.number().int().min(1).max(20).default(5),
  }),
};

export type ToolArgs = {
  [K in keyof typeof ToolSchemas]: z.infer<(typeof ToolSchemas)[K]>;
};

3.2 业务逻辑分离

// src/weather.ts
interface WeatherData {
  city: string;
  temperature: number;
  condition: string;
  humidity: number;
  windSpeed: number;
  units: string;
}

export async function fetchWeather(
  city: string,
  units: string
): Promise<WeatherData> {
  // 实际项目中调用天气 API
  // 这里使用模拟数据
  return {
    city,
    temperature: units === "celsius" ? 22 : 72,
    condition: "晴朗",
    humidity: 45,
    windSpeed: 10,
    units,
  };
}

export async function searchLocations(
  query: string,
  limit: number
): Promise<string[]> {
  const mockLocations = [
    "Beijing",
    "Shanghai",
    "Guangzhou",
    "Shenzhen",
    "Chengdu",
    "Hangzhou",
    "Wuhan",
    "Xi'an",
  ];

  return mockLocations
    .filter((loc) => loc.toLowerCase().includes(query.toLowerCase()))
    .slice(0, limit);
}

4. 错误处理

MCP 使用 JSON-RPC 的错误机制。服务器应返回标准化的错误响应:

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  try {
    const { name, arguments: args } = request.params;

    if (name === "get_weather") {
      const params = ToolSchemas.get_weather.parse(args);
      const weather = await fetchWeather(params.city, params.units);
      return {
        content: [{ type: "text", text: JSON.stringify(weather) }],
      };
    }

    return {
      content: [
        {
          type: "text",
          text: `错误: 未知工具 '${name}'`,
        },
      ],
      isError: true,
    };
  } catch (error) {
    if (error instanceof z.ZodError) {
      return {
        content: [
          {
            type: "text",
            text: `参数验证失败: ${error.errors.map((e) => e.message).join(", ")}`,
          },
        ],
        isError: true,
      };
    }

    return {
      content: [
        {
          type: "text",
          text: `执行错误: ${error instanceof Error ? error.message : String(error)}`,
        },
      ],
      isError: true,
    };
  }
});

5. 资源暴露

Resources 提供只读数据访问,与 Tools 不同,它们不执行操作而是返回数据:

// 注册资源列表
server.setRequestHandler(ListResourcesRequestSchema, async () => {
  return {
    resources: [
      {
        uri: "weather://beijing/current",
        name: "北京当前天气",
        mimeType: "application/json",
      },
      {
        uri: "weather://shanghai/current",
        name: "上海当前天气",
        mimeType: "application/json",
      },
    ],
  };
});

// 处理资源读取请求
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const uri = request.params.uri;

  if (uri === "weather://beijing/current") {
    const weather = await fetchWeather("Beijing", "celsius");
    return {
      contents: [
        {
          uri,
          mimeType: "application/json",
          text: JSON.stringify(weather),
        },
      ],
    };
  }

  if (uri === "weather://shanghai/current") {
    const weather = await fetchWeather("Shanghai", "celsius");
    return {
      contents: [
        {
          uri,
          mimeType: "application/json",
          text: JSON.stringify(weather),
        },
      ],
    };
  }

  throw new Error(`未知资源: ${uri}`);
});

6. Prompts 提示词模板

Prompts 让服务器可以贡献预定义的对话模板:

import {
  ListPromptsRequestSchema,
  GetPromptRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

// 列出可用提示词模板
server.setRequestHandler(ListPromptsRequestSchema, async () => {
  return {
    prompts: [
      {
        name: "weather_report",
        description: "生成指定城市的详细天气报告",
        arguments: [
          {
            name: "city",
            description: "目标城市",
            required: true,
          },
        ],
      },
      {
        name: "travel_advice",
        description: "根据天气提供旅行建议",
        arguments: [
          {
            name: "destination",
            description: "目的地",
            required: true,
          },
          {
            name: "activity",
            description: "活动类型",
            required: false,
          },
        ],
      },
    ],
  };
});

// 获取完整提示词内容
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  if (name === "weather_report") {
    const city = args?.city ?? "未知城市";
    return {
      description: `${city} 天气报告`,
      messages: [
        {
          role: "user",
          content: {
            type: "text",
            text: `请获取 ${city} 的当前天气信息,并提供以下分析:\n` +
                  `1. 当前温度和体感温度\n` +
                  `2. 天气状况和建议穿着\n` +
                  `3. 未来几小时的天气趋势`
            ,
          },
        },
      ],
    };
  }

  throw new Error(`未知提示词: ${name}`);
});

7. 中间件与横切关注点

在实际项目中,MCP 服务器需要处理大量横切关注点:日志记录、指标采集、访问控制、请求限速等。使用中间件模式可以优雅地解决这些问题。

7.1 日志中间件

function loggingMiddleware(handler: RequestHandler): RequestHandler {
  return async (request) => {
    const start = Date.now();
    const method = request.method;
    
    console.log(`[${new Date().toISOString()}] ${method} 请求开始`);
    
    try {
      const result = await handler(request);
      const duration = Date.now() - start;
      console.log(`[${new Date().toISOString()}] ${method} 请求成功 (${duration}ms)`);
      return result;
    } catch (error) {
      const duration = Date.now() - start;
      console.error(`[${new Date().toISOString()}] ${method} 请求失败 (${duration}ms):`, error);
      throw error;
    }
  };
}

// 使用中间件包装处理器
server.setRequestHandler(
  CallToolRequestSchema,
  loggingMiddleware(async (request) => {
    // 实际的处理逻辑
    return handleToolCall(request);
  })
);

7.2 请求限流中间件

class RateLimitMiddleware {
  private calls: Map<string, number[]> = new Map();
  private maxCalls: number;
  private windowMs: number;

  constructor(maxCalls: number = 60, windowMs: number = 60000) {
    this.maxCalls = maxCalls;
    this.windowMs = windowMs;
  }

  wrap(handler: RequestHandler): RequestHandler {
    return async (request) => {
      const toolName = (request.params as any).name ?? "unknown";
      const now = Date.now();
      
      if (!this.calls.has(toolName)) {
        this.calls.set(toolName, []);
      }
      
      const timestamps = this.calls.get(toolName)!;
      // 清理过期记录
      const validTimestamps = timestamps.filter((t) => now - t < this.windowMs);
      this.calls.set(toolName, validTimestamps);
      
      if (validTimestamps.length >= this.maxCalls) {
        throw new Error(`工具 ${toolName} 调用频率超限,请稍后重试`);
      }
      
      validTimestamps.push(now);
      return handler(request);
    };
  }
}

8. HTTP SSE 传输方式

除了 stdio,MCP Server 还可以通过 HTTP + SSE 方式对外提供服务:

import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";

const app = express();

const transports: Map<string, SSEServerTransport> = new Map();

app.get("/sse", async (req, res) => {
  const transport = new SSEServerTransport("/messages", res);
  transports.set(transport.sessionId, transport);

  res.on("close", () => {
    transports.delete(transport.sessionId);
  });

  await server.connect(transport);
});

app.post("/messages", async (req, res) => {
  const sessionId = req.query.sessionId as string;
  const transport = transports.get(sessionId);

  if (!transport) {
    res.status(400).send("无效的 sessionId");
    return;
  }

  await transport.handlePostMessage(req, res);
});

app.listen(3000, () => {
  console.log("MCP Server listening on http://localhost:3000");
});

8. 测试策略

8.1 单元测试

// tests/weather.test.ts
import { describe, it, expect } from "vitest";
import { fetchWeather, searchLocations } from "../src/weather";

describe("Weather API", () => {
  it("should fetch weather for Beijing", async () => {
    const weather = await fetchWeather("Beijing", "celsius");
    expect(weather.city).toBe("Beijing");
    expect(weather.units).toBe("celsius");
  });

  it("should search locations", async () => {
    const results = await searchLocations("Shang", 5);
    expect(results).toContain("Shanghai");
    expect(results.length).toBeLessThanOrEqual(5);
  });
});

8.2 集成测试

使用 MCP Client SDK 对服务器进行端到端测试:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";

async function createTestClient() {
  const client = new Client({ name: "test-client", version: "1.0" });
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

  await Promise.all([
    client.connect(clientTransport),
    server.connect(serverTransport),
  ]);

  return client;
}

it("should list tools", async () => {
  const client = await createTestClient();
  const tools = await client.listTools();
  expect(tools.tools.length).toBeGreaterThan(0);
});

9. 打包与发布

9.1 编译为可执行文件

使用 pkg 或 esbuild 将 TypeScript 编译为可直接运行的 Node.js 程序:

# 使用 esbuild 打包
npx esbuild src/index.ts --bundle --platform=node --outfile=dist/server.js

# 或者直接使用 tsx 运行(推荐开发时)

9.2 npm 发布

10. 打包与发布

10.1 编译为独立二进制

使用 pkg 可以将 Node.js 应用打包为无需 Node.js 环境的独立可执行文件,便于分发和部署:

# 安装打包工具
npm install -D pkg

# 打包为各平台可执行文件
npx pkg dist/server.js --targets node18-linux-x64,node18-macos-x64,node18-win-x64 --output bin/mcp-server

10.2 npm 全局安装

将 MCP 服务器发布到 npm,用户可以直接通过 npx 运行:

{
  "name": "@my-org/mcp-weather-server",
  "version": "1.0.0",
  "bin": {
    "mcp-weather-server": "./dist/server.js"
  },
  "scripts": {
    "build": "esbuild src/index.ts --bundle --platform=node --outfile=dist/server.js",
    "start": "node dist/server.js"
  }
}
# 用户安装方式
npm install -g @my-org/mcp-weather-server
mcp-weather-server

# 或直接使用 npx
npx @my-org/mcp-weather-server

10.3 Docker 容器化

FROM node:20-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

COPY dist/ ./dist/

USER node
EXPOSE 3000

CMD ["node", "dist/server.js"]
docker build -t mcp-weather-server .
docker run -p 3000:3000 mcp-weather-server

11. 总结

实现一个健壮的 MCP Server 需要注意以下要点:

  1. 输入验证:始终使用 Zod 或其他 schema 库验证工具参数,防止注入攻击
  2. 错误传播:使用标准的 isError 标志而非抛异常,让 AI 助手知道调用失败
  3. 工具描述:工具描述要足够详细,因为这是 AI 决定何时调用该工具的唯一依据
  4. 资源粒度:资源应该以合理的粒度组织,太大的资源会超出模型的上下文限制
  5. 测试覆盖:使用 InMemoryTransport 进行快速集成测试,无需启动真实进程

在掌握基础实现后,接下来的文章将深入讲解 MCP Client 的集成方式、安全实践、高级工具设计模式以及多服务器协同工作。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「ai」更多文章

  1. 排序学习与搜索召回排序系统
  2. 数据版本控制与血缘:DVC 与 LakeFS
  3. 模型可解释性:SHAP、LIME 与注意力归因