971 lines
27 KiB
Markdown
971 lines
27 KiB
Markdown
# Node/TypeScript MCP 服务端实现指南
|
||
|
||
## 概述
|
||
|
||
本文档提供了使用 MCP TypeScript SDK 实现 MCP 服务端时的 Node/TypeScript 特定最佳实践与示例。内容涵盖项目结构、服务端初始化、工具注册模式、基于 Zod 的输入验证、错误处理以及完整的可运行示例。
|
||
|
||
---
|
||
|
||
## 快速参考
|
||
|
||
### 关键导入
|
||
```typescript
|
||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||
import express from "express";
|
||
import { z } from "zod";
|
||
```
|
||
|
||
### 服务端初始化
|
||
```typescript
|
||
const server = new McpServer({
|
||
name: "service-mcp-server",
|
||
version: "1.0.0"
|
||
});
|
||
```
|
||
|
||
### 工具注册模式
|
||
```typescript
|
||
server.registerTool(
|
||
"tool_name",
|
||
{
|
||
title: "Tool Display Name",
|
||
description: "What the tool does",
|
||
inputSchema: { param: z.string() },
|
||
outputSchema: { result: z.string() }
|
||
},
|
||
async ({ param }) => {
|
||
const output = { result: `Processed: ${param}` };
|
||
return {
|
||
content: [{ type: "text", text: JSON.stringify(output) }],
|
||
structuredContent: output // 结构化数据的现代模式
|
||
};
|
||
}
|
||
);
|
||
```
|
||
|
||
---
|
||
|
||
## MCP TypeScript SDK
|
||
|
||
官方 MCP TypeScript SDK 提供:
|
||
- `McpServer` 类用于服务端初始化
|
||
- `registerTool` 方法用于工具注册
|
||
- Zod schema 集成实现运行时输入验证
|
||
- 类型安全的工具处理函数实现
|
||
|
||
**重要——仅使用现代 API:**
|
||
- **应该使用**:`server.registerTool()`、`server.registerResource()`、`server.registerPrompt()`
|
||
- **不要使用**:旧的已弃用 API,如 `server.tool()`、`server.setRequestHandler(ListToolsRequestSchema, ...)` 或手动注册处理函数
|
||
- `register*` 方法提供更好的类型安全性、自动 schema 处理,是推荐做法
|
||
|
||
详情请参阅参考资料中的 MCP SDK 文档。
|
||
|
||
## 服务端命名规范
|
||
|
||
Node/TypeScript MCP 服务端必须遵循以下命名模式:
|
||
- **格式**:`{service}-mcp-server`(小写,用连字符连接)
|
||
- **示例**:`github-mcp-server`、`jira-mcp-server`、`stripe-mcp-server`
|
||
|
||
名称应:
|
||
- 通用(不与特定功能绑定)
|
||
- 描述所集成的服务/API
|
||
- 易于从任务描述中推断
|
||
- 不包含版本号或日期
|
||
|
||
## 项目结构
|
||
|
||
为 Node/TypeScript MCP 服务端创建以下结构:
|
||
|
||
```
|
||
{service}-mcp-server/
|
||
├── package.json
|
||
├── tsconfig.json
|
||
├── README.md
|
||
├── src/
|
||
│ ├── index.ts # 主入口文件,包含 McpServer 初始化
|
||
│ ├── types.ts # TypeScript 类型定义与接口
|
||
│ ├── tools/ # 工具实现(每个领域一个文件)
|
||
│ ├── services/ # API 客户端与共享工具函数
|
||
│ ├── schemas/ # Zod 验证 schema
|
||
│ └── constants.ts # 共享常量(API_URL、CHARACTER_LIMIT 等)
|
||
└── dist/ # 构建后的 JavaScript 文件(入口:dist/index.js)
|
||
```
|
||
|
||
## 工具实现
|
||
|
||
### 工具命名
|
||
|
||
工具名称使用蛇形命名法(snake_case),例如 "search_users"、"create_project"、"get_channel_info",名称应清晰且面向操作。
|
||
|
||
**避免命名冲突**:包含服务上下文以防止重叠:
|
||
- 使用 "slack_send_message" 而非 "send_message"
|
||
- 使用 "github_create_issue" 而非 "create_issue"
|
||
- 使用 "asana_list_tasks" 而非 "list_tasks"
|
||
|
||
### 工具结构
|
||
|
||
工具通过 `registerTool` 方法注册,需满足以下要求:
|
||
- 使用 Zod schema 进行运行时输入验证和类型安全
|
||
- `description` 字段必须显式提供——JSDoc 注释不会被自动提取
|
||
- 显式提供 `title`、`description`、`inputSchema` 和 `annotations`
|
||
- `inputSchema` 必须是 Zod schema 对象(而非 JSON schema)
|
||
- 所有参数和返回值类型需显式声明
|
||
|
||
```typescript
|
||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||
import { z } from "zod";
|
||
|
||
const server = new McpServer({
|
||
name: "example-mcp",
|
||
version: "1.0.0"
|
||
});
|
||
|
||
// 用于输入验证的 Zod schema
|
||
const UserSearchInputSchema = z.object({
|
||
query: z.string()
|
||
.min(2, "查询字符串至少需要 2 个字符")
|
||
.max(200, "查询字符串不能超过 200 个字符")
|
||
.describe("用于匹配姓名/邮箱的搜索字符串"),
|
||
limit: z.number()
|
||
.int()
|
||
.min(1)
|
||
.max(100)
|
||
.default(20)
|
||
.describe("返回的最大结果数"),
|
||
offset: z.number()
|
||
.int()
|
||
.min(0)
|
||
.default(0)
|
||
.describe("分页时跳过的结果数"),
|
||
response_format: z.nativeEnum(ResponseFormat)
|
||
.default(ResponseFormat.MARKDOWN)
|
||
.describe("输出格式:'markdown' 用于人类可读,'json' 用于机器可读")
|
||
}).strict();
|
||
|
||
// 从 Zod schema 推导的类型定义
|
||
type UserSearchInput = z.infer<typeof UserSearchInputSchema>;
|
||
|
||
server.registerTool(
|
||
"example_search_users",
|
||
{
|
||
title: "Search Example Users",
|
||
description: `在 Example 系统中按姓名、邮箱或团队搜索用户。
|
||
|
||
该工具搜索 Example 平台中的所有用户资料,支持部分匹配和多种搜索筛选条件。不会创建或修改用户,仅搜索已有用户。
|
||
|
||
参数:
|
||
- query (string):用于匹配姓名/邮箱的搜索字符串
|
||
- limit (number):返回的最大结果数,范围 1-100(默认值:20)
|
||
- offset (number):分页时跳过的结果数(默认值:0)
|
||
- response_format ('markdown' | 'json'):输出格式(默认值:'markdown')
|
||
|
||
返回:
|
||
JSON 格式:结构化数据,schema 如下:
|
||
{
|
||
"total": number, // 找到的匹配总数
|
||
"count": number, // 本次响应中的结果数
|
||
"offset": number, // 当前分页偏移量
|
||
"users": [
|
||
{
|
||
"id": string, // 用户 ID(例如 "U123456789")
|
||
"name": string, // 全名(例如 "张三")
|
||
"email": string, // 邮箱地址
|
||
"team": string, // 团队名称(可选)
|
||
"active": boolean // 用户是否活跃
|
||
}
|
||
],
|
||
"has_more": boolean, // 是否还有更多结果
|
||
"next_offset": number // 下一页的偏移量(若 has_more 为 true)
|
||
}
|
||
|
||
示例:
|
||
- 何时使用:"查找所有营销团队成员" -> 参数为 query="team:marketing"
|
||
- 何时使用:"搜索张三的账号" -> 参数为 query="zhang"
|
||
- 不要使用:需要创建用户时(应使用 example_create_user)
|
||
|
||
错误处理:
|
||
- 请求过多时返回 "Error: Rate limit exceeded"(429 状态码)
|
||
- 搜索结果为空时返回 "未找到与 '<query>' 匹配的用户"`,
|
||
inputSchema: UserSearchInputSchema,
|
||
annotations: {
|
||
readOnlyHint: true,
|
||
destructiveHint: false,
|
||
idempotentHint: true,
|
||
openWorldHint: true
|
||
}
|
||
},
|
||
async (params: UserSearchInput) => {
|
||
try {
|
||
// 输入验证由 Zod schema 处理
|
||
// 使用验证后的参数发起 API 请求
|
||
const data = await makeApiRequest<any>(
|
||
"users/search",
|
||
"GET",
|
||
undefined,
|
||
{
|
||
q: params.query,
|
||
limit: params.limit,
|
||
offset: params.offset
|
||
}
|
||
);
|
||
|
||
const users = data.users || [];
|
||
const total = data.total || 0;
|
||
|
||
if (!users.length) {
|
||
return {
|
||
content: [{
|
||
type: "text",
|
||
text: `未找到与 '${params.query}' 匹配的用户`
|
||
}]
|
||
};
|
||
}
|
||
|
||
// 准备结构化输出
|
||
const output = {
|
||
total,
|
||
count: users.length,
|
||
offset: params.offset,
|
||
users: users.map((user: any) => ({
|
||
id: user.id,
|
||
name: user.name,
|
||
email: user.email,
|
||
...(user.team ? { team: user.team } : {}),
|
||
active: user.active ?? true
|
||
})),
|
||
has_more: total > params.offset + users.length,
|
||
...(total > params.offset + users.length ? {
|
||
next_offset: params.offset + users.length
|
||
} : {})
|
||
};
|
||
|
||
// 根据请求格式生成文本表示
|
||
let textContent: string;
|
||
if (params.response_format === ResponseFormat.MARKDOWN) {
|
||
const lines = [`# 用户搜索结果:'${params.query}'`, "",
|
||
`共找到 ${total} 个用户(显示 ${users.length} 个)`, ""];
|
||
for (const user of users) {
|
||
lines.push(`## ${user.name}(${user.id})`);
|
||
lines.push(`- **邮箱**:${user.email}`);
|
||
if (user.team) lines.push(`- **团队**:${user.team}`);
|
||
lines.push("");
|
||
}
|
||
textContent = lines.join("\n");
|
||
} else {
|
||
textContent = JSON.stringify(output, null, 2);
|
||
}
|
||
|
||
return {
|
||
content: [{ type: "text", text: textContent }],
|
||
structuredContent: output // 结构化数据的现代模式
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
content: [{
|
||
type: "text",
|
||
text: handleApiError(error)
|
||
}]
|
||
};
|
||
}
|
||
}
|
||
);
|
||
```
|
||
|
||
## Zod Schema 用于输入验证
|
||
|
||
Zod 提供运行时类型验证:
|
||
|
||
```typescript
|
||
import { z } from "zod";
|
||
|
||
// 带验证的基本 schema
|
||
const CreateUserSchema = z.object({
|
||
name: z.string()
|
||
.min(1, "姓名为必填项")
|
||
.max(100, "姓名不能超过 100 个字符"),
|
||
email: z.string()
|
||
.email("邮箱格式无效"),
|
||
age: z.number()
|
||
.int("年龄必须为整数")
|
||
.min(0, "年龄不能为负数")
|
||
.max(150, "年龄不能超过 150")
|
||
}).strict(); // 使用 .strict() 禁止额外字段
|
||
|
||
// 枚举
|
||
enum ResponseFormat {
|
||
MARKDOWN = "markdown",
|
||
JSON = "json"
|
||
}
|
||
|
||
const SearchSchema = z.object({
|
||
response_format: z.nativeEnum(ResponseFormat)
|
||
.default(ResponseFormat.MARKDOWN)
|
||
.describe("输出格式")
|
||
});
|
||
|
||
// 带默认值的可选字段
|
||
const PaginationSchema = z.object({
|
||
limit: z.number()
|
||
.int()
|
||
.min(1)
|
||
.max(100)
|
||
.default(20)
|
||
.describe("返回的最大结果数"),
|
||
offset: z.number()
|
||
.int()
|
||
.min(0)
|
||
.default(0)
|
||
.describe("跳过的结果数")
|
||
});
|
||
```
|
||
|
||
## 响应格式选项
|
||
|
||
支持多种输出格式以提高灵活性:
|
||
|
||
```typescript
|
||
enum ResponseFormat {
|
||
MARKDOWN = "markdown",
|
||
JSON = "json"
|
||
}
|
||
|
||
const inputSchema = z.object({
|
||
query: z.string(),
|
||
response_format: z.nativeEnum(ResponseFormat)
|
||
.default(ResponseFormat.MARKDOWN)
|
||
.describe("输出格式:'markdown' 用于人类可读,'json' 用于机器可读")
|
||
});
|
||
```
|
||
|
||
**Markdown 格式**:
|
||
- 使用标题、列表和格式化以提高可读性
|
||
- 将时间戳转换为人类可读格式
|
||
- 显示名称及括号中的 ID
|
||
- 省略冗长的元数据
|
||
- 按逻辑对相关信息分组
|
||
|
||
**JSON 格式**:
|
||
- 返回适合程序化处理的完整结构化数据
|
||
- 包含所有可用字段和元数据
|
||
- 使用一致的字段名和类型
|
||
|
||
## 分页实现
|
||
|
||
适用于列出资源的工具:
|
||
|
||
```typescript
|
||
const ListSchema = z.object({
|
||
limit: z.number().int().min(1).max(100).default(20),
|
||
offset: z.number().int().min(0).default(0)
|
||
});
|
||
|
||
async function listItems(params: z.infer<typeof ListSchema>) {
|
||
const data = await apiRequest(params.limit, params.offset);
|
||
|
||
const response = {
|
||
total: data.total,
|
||
count: data.items.length,
|
||
offset: params.offset,
|
||
items: data.items,
|
||
has_more: data.total > params.offset + data.items.length,
|
||
next_offset: data.total > params.offset + data.items.length
|
||
? params.offset + data.items.length
|
||
: undefined
|
||
};
|
||
|
||
return JSON.stringify(response, null, 2);
|
||
}
|
||
```
|
||
|
||
## 字符限制与截断
|
||
|
||
添加 CHARACTER_LIMIT 常量以防止响应过长:
|
||
|
||
```typescript
|
||
// 在 constants.ts 模块级别
|
||
export const CHARACTER_LIMIT = 25000; // 最大响应大小(字符数)
|
||
|
||
async function searchTool(params: SearchInput) {
|
||
let result = generateResponse(data);
|
||
|
||
// 检查字符限制,必要时截断
|
||
if (result.length > CHARACTER_LIMIT) {
|
||
const truncatedData = data.slice(0, Math.max(1, data.length / 2));
|
||
response.data = truncatedData;
|
||
response.truncated = true;
|
||
response.truncation_message =
|
||
`响应已从 ${data.length} 项截断至 ${truncatedData.length} 项。` +
|
||
`请使用 'offset' 参数或添加筛选条件以查看更多结果。`;
|
||
result = JSON.stringify(response, null, 2);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
```
|
||
|
||
## 错误处理
|
||
|
||
提供清晰、可操作的错误信息:
|
||
|
||
```typescript
|
||
import axios, { AxiosError } from "axios";
|
||
|
||
function handleApiError(error: unknown): string {
|
||
if (error instanceof AxiosError) {
|
||
if (error.response) {
|
||
switch (error.response.status) {
|
||
case 404:
|
||
return "错误:未找到资源。请检查 ID 是否正确。";
|
||
case 403:
|
||
return "错误:权限不足。您没有访问此资源的权限。";
|
||
case 429:
|
||
return "错误:请求频率超限。请稍后再发起更多请求。";
|
||
default:
|
||
return `错误:API 请求失败,状态码 ${error.response.status}`;
|
||
}
|
||
} else if (error.code === "ECONNABORTED") {
|
||
return "错误:请求超时。请重试。";
|
||
}
|
||
}
|
||
return `错误:发生意外错误:${error instanceof Error ? error.message : String(error)}`;
|
||
}
|
||
```
|
||
|
||
## 共享工具函数
|
||
|
||
将通用功能提取为可复用的函数:
|
||
|
||
```typescript
|
||
// 共享 API 请求函数
|
||
async function makeApiRequest<T>(
|
||
endpoint: string,
|
||
method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
|
||
data?: any,
|
||
params?: any
|
||
): Promise<T> {
|
||
try {
|
||
const response = await axios({
|
||
method,
|
||
url: `${API_BASE_URL}/${endpoint}`,
|
||
data,
|
||
params,
|
||
timeout: 30000,
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"Accept": "application/json"
|
||
}
|
||
});
|
||
return response.data;
|
||
} catch (error) {
|
||
throw error;
|
||
}
|
||
}
|
||
```
|
||
|
||
## Async/Await 最佳实践
|
||
|
||
网络请求和 I/O 操作始终使用 async/await:
|
||
|
||
```typescript
|
||
// 好的做法:异步网络请求
|
||
async function fetchData(resourceId: string): Promise<ResourceData> {
|
||
const response = await axios.get(`${API_URL}/resource/${resourceId}`);
|
||
return response.data;
|
||
}
|
||
|
||
// 不好的做法:Promise 链
|
||
function fetchData(resourceId: string): Promise<ResourceData> {
|
||
return axios.get(`${API_URL}/resource/${resourceId}`)
|
||
.then(response => response.data); // 可读性和可维护性较差
|
||
}
|
||
```
|
||
|
||
## TypeScript 最佳实践
|
||
|
||
1. **使用严格 TypeScript**:在 tsconfig.json 中启用严格模式
|
||
2. **定义接口**:为所有数据结构创建清晰的接口定义
|
||
3. **避免使用 `any`**:使用正确类型或 `unknown` 替代 `any`
|
||
4. **使用 Zod 进行运行时验证**:使用 Zod schema 验证外部数据
|
||
5. **类型守卫**:为复杂类型检查创建类型守卫函数
|
||
6. **错误处理**:始终使用 try-catch 并配合正确的错误类型检查
|
||
7. **空值安全**:使用可选链(`?.`)和空值合并(`??`)
|
||
|
||
```typescript
|
||
// 好的做法:通过 Zod 和接口实现类型安全
|
||
interface UserResponse {
|
||
id: string;
|
||
name: string;
|
||
email: string;
|
||
team?: string;
|
||
active: boolean;
|
||
}
|
||
|
||
const UserSchema = z.object({
|
||
id: z.string(),
|
||
name: z.string(),
|
||
email: z.string().email(),
|
||
team: z.string().optional(),
|
||
active: z.boolean()
|
||
});
|
||
|
||
type User = z.infer<typeof UserSchema>;
|
||
|
||
async function getUser(id: string): Promise<User> {
|
||
const data = await apiCall(`/users/${id}`);
|
||
return UserSchema.parse(data); // 运行时验证
|
||
}
|
||
|
||
// 不好的做法:使用 any
|
||
async function getUser(id: string): Promise<any> {
|
||
return await apiCall(`/users/${id}`); // 没有类型安全
|
||
}
|
||
```
|
||
|
||
## 包配置
|
||
|
||
### package.json
|
||
|
||
```json
|
||
{
|
||
"name": "{service}-mcp-server",
|
||
"version": "1.0.0",
|
||
"description": "用于 {Service} API 集成的 MCP 服务端",
|
||
"type": "module",
|
||
"main": "dist/index.js",
|
||
"scripts": {
|
||
"start": "node dist/index.js",
|
||
"dev": "tsx watch src/index.ts",
|
||
"build": "tsc",
|
||
"clean": "rm -rf dist"
|
||
},
|
||
"engines": {
|
||
"node": ">=18"
|
||
},
|
||
"dependencies": {
|
||
"@modelcontextprotocol/sdk": "^1.6.1",
|
||
"axios": "^1.7.9",
|
||
"zod": "^3.23.8"
|
||
},
|
||
"devDependencies": {
|
||
"@types/node": "^22.10.0",
|
||
"tsx": "^4.19.2",
|
||
"typescript": "^5.7.2"
|
||
}
|
||
}
|
||
```
|
||
|
||
### tsconfig.json
|
||
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2022",
|
||
"module": "Node16",
|
||
"moduleResolution": "Node16",
|
||
"lib": ["ES2022"],
|
||
"outDir": "./dist",
|
||
"rootDir": "./src",
|
||
"strict": true,
|
||
"esModuleInterop": true,
|
||
"skipLibCheck": true,
|
||
"forceConsistentCasingInFileNames": true,
|
||
"declaration": true,
|
||
"declarationMap": true,
|
||
"sourceMap": true,
|
||
"allowSyntheticDefaultImports": true
|
||
},
|
||
"include": ["src/**/*"],
|
||
"exclude": ["node_modules", "dist"]
|
||
}
|
||
```
|
||
|
||
## 完整示例
|
||
|
||
```typescript
|
||
#!/usr/bin/env node
|
||
/**
|
||
* Example 服务的 MCP 服务端。
|
||
*
|
||
* 本服务端提供与 Example API 交互的工具,包括用户搜索、
|
||
* 项目管理和数据导出功能。
|
||
*/
|
||
|
||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||
import { z } from "zod";
|
||
import axios, { AxiosError } from "axios";
|
||
|
||
// 常量
|
||
const API_BASE_URL = "https://api.example.com/v1";
|
||
const CHARACTER_LIMIT = 25000;
|
||
|
||
// 枚举
|
||
enum ResponseFormat {
|
||
MARKDOWN = "markdown",
|
||
JSON = "json"
|
||
}
|
||
|
||
// Zod schema
|
||
const UserSearchInputSchema = z.object({
|
||
query: z.string()
|
||
.min(2, "查询字符串至少需要 2 个字符")
|
||
.max(200, "查询字符串不能超过 200 个字符")
|
||
.describe("用于匹配姓名/邮箱的搜索字符串"),
|
||
limit: z.number()
|
||
.int()
|
||
.min(1)
|
||
.max(100)
|
||
.default(20)
|
||
.describe("返回的最大结果数"),
|
||
offset: z.number()
|
||
.int()
|
||
.min(0)
|
||
.default(0)
|
||
.describe("分页时跳过的结果数"),
|
||
response_format: z.nativeEnum(ResponseFormat)
|
||
.default(ResponseFormat.MARKDOWN)
|
||
.describe("输出格式:'markdown' 用于人类可读,'json' 用于机器可读")
|
||
}).strict();
|
||
|
||
type UserSearchInput = z.infer<typeof UserSearchInputSchema>;
|
||
|
||
// 共享工具函数
|
||
async function makeApiRequest<T>(
|
||
endpoint: string,
|
||
method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
|
||
data?: any,
|
||
params?: any
|
||
): Promise<T> {
|
||
try {
|
||
const response = await axios({
|
||
method,
|
||
url: `${API_BASE_URL}/${endpoint}`,
|
||
data,
|
||
params,
|
||
timeout: 30000,
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"Accept": "application/json"
|
||
}
|
||
});
|
||
return response.data;
|
||
} catch (error) {
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function handleApiError(error: unknown): string {
|
||
if (error instanceof AxiosError) {
|
||
if (error.response) {
|
||
switch (error.response.status) {
|
||
case 404:
|
||
return "错误:未找到资源。请检查 ID 是否正确。";
|
||
case 403:
|
||
return "错误:权限不足。您没有访问此资源的权限。";
|
||
case 429:
|
||
return "错误:请求频率超限。请稍后再发起更多请求。";
|
||
default:
|
||
return `错误:API 请求失败,状态码 ${error.response.status}`;
|
||
}
|
||
} else if (error.code === "ECONNABORTED") {
|
||
return "错误:请求超时。请重试。";
|
||
}
|
||
}
|
||
return `错误:发生意外错误:${error instanceof Error ? error.message : String(error)}`;
|
||
}
|
||
|
||
// 创建 MCP 服务端实例
|
||
const server = new McpServer({
|
||
name: "example-mcp",
|
||
version: "1.0.0"
|
||
});
|
||
|
||
// 注册工具
|
||
server.registerTool(
|
||
"example_search_users",
|
||
{
|
||
title: "Search Example Users",
|
||
description: `[完整描述如上所示]`,
|
||
inputSchema: UserSearchInputSchema,
|
||
annotations: {
|
||
readOnlyHint: true,
|
||
destructiveHint: false,
|
||
idempotentHint: true,
|
||
openWorldHint: true
|
||
}
|
||
},
|
||
async (params: UserSearchInput) => {
|
||
// 实现如上所示
|
||
}
|
||
);
|
||
|
||
// 主函数
|
||
// 用于 stdio(本地):
|
||
async function runStdio() {
|
||
if (!process.env.EXAMPLE_API_KEY) {
|
||
console.error("错误:需要设置 EXAMPLE_API_KEY 环境变量");
|
||
process.exit(1);
|
||
}
|
||
|
||
const transport = new StdioServerTransport();
|
||
await server.connect(transport);
|
||
console.error("MCP 服务端通过 stdio 运行");
|
||
}
|
||
|
||
// 用于流式 HTTP(远程):
|
||
async function runHTTP() {
|
||
if (!process.env.EXAMPLE_API_KEY) {
|
||
console.error("错误:需要设置 EXAMPLE_API_KEY 环境变量");
|
||
process.exit(1);
|
||
}
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
|
||
app.post('/mcp', async (req, res) => {
|
||
const transport = new StreamableHTTPServerTransport({
|
||
sessionIdGenerator: undefined,
|
||
enableJsonResponse: true
|
||
});
|
||
res.on('close', () => transport.close());
|
||
await server.connect(transport);
|
||
await transport.handleRequest(req, res, req.body);
|
||
});
|
||
|
||
const port = parseInt(process.env.PORT || '3000');
|
||
app.listen(port, () => {
|
||
console.error(`MCP 服务端运行在 http://localhost:${port}/mcp`);
|
||
});
|
||
}
|
||
|
||
// 根据环境选择传输方式
|
||
const transport = process.env.TRANSPORT || 'stdio';
|
||
if (transport === 'http') {
|
||
runHTTP().catch(error => {
|
||
console.error("服务端错误:", error);
|
||
process.exit(1);
|
||
});
|
||
} else {
|
||
runStdio().catch(error => {
|
||
console.error("服务端错误:", error);
|
||
process.exit(1);
|
||
});
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 高级 MCP 功能
|
||
|
||
### 资源注册
|
||
|
||
将数据暴露为资源,实现基于 URI 的高效访问:
|
||
|
||
```typescript
|
||
import { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js";
|
||
|
||
// 使用 URI 模板注册资源
|
||
server.registerResource(
|
||
{
|
||
uri: "file://documents/{name}",
|
||
name: "Document Resource",
|
||
description: "按名称访问文档",
|
||
mimeType: "text/plain"
|
||
},
|
||
async (uri: string) => {
|
||
// 从 URI 中提取参数
|
||
const match = uri.match(/^file:\/\/documents\/(.+)$/);
|
||
if (!match) {
|
||
throw new Error("URI 格式无效");
|
||
}
|
||
|
||
const documentName = match[1];
|
||
const content = await loadDocument(documentName);
|
||
|
||
return {
|
||
contents: [{
|
||
uri,
|
||
mimeType: "text/plain",
|
||
text: content
|
||
}]
|
||
};
|
||
}
|
||
);
|
||
|
||
// 动态列出可用资源
|
||
server.registerResourceList(async () => {
|
||
const documents = await getAvailableDocuments();
|
||
return {
|
||
resources: documents.map(doc => ({
|
||
uri: `file://documents/${doc.name}`,
|
||
name: doc.name,
|
||
mimeType: "text/plain",
|
||
description: doc.description
|
||
}))
|
||
};
|
||
});
|
||
```
|
||
|
||
**何时使用资源 vs 工具:**
|
||
- **资源**:适用于基于简单 URI 参数的数据访问
|
||
- **工具**:适用于需要验证和业务逻辑的复杂操作
|
||
- **资源**:数据相对静态或基于模板时
|
||
- **工具**:操作有副作用或涉及复杂工作流时
|
||
|
||
### 传输选项
|
||
|
||
TypeScript SDK 支持两种主要传输机制:
|
||
|
||
#### 流式 HTTP(推荐用于远程服务端)
|
||
|
||
```typescript
|
||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||
import express from "express";
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
|
||
app.post('/mcp', async (req, res) => {
|
||
// 为每个请求创建新的传输(无状态,防止请求 ID 冲突)
|
||
const transport = new StreamableHTTPServerTransport({
|
||
sessionIdGenerator: undefined,
|
||
enableJsonResponse: true
|
||
});
|
||
|
||
res.on('close', () => transport.close());
|
||
|
||
await server.connect(transport);
|
||
await transport.handleRequest(req, res, req.body);
|
||
});
|
||
|
||
app.listen(3000);
|
||
```
|
||
|
||
#### stdio(用于本地集成)
|
||
|
||
```typescript
|
||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||
|
||
const transport = new StdioServerTransport();
|
||
await server.connect(transport);
|
||
```
|
||
|
||
**传输方式选择:**
|
||
- **流式 HTTP**:Web 服务、远程访问、多客户端
|
||
- **stdio**:命令行工具、本地开发、子进程集成
|
||
|
||
### 通知支持
|
||
|
||
在服务端状态变化时通知客户端:
|
||
|
||
```typescript
|
||
// 工具列表变更时通知
|
||
server.notification({
|
||
method: "notifications/tools/list_changed"
|
||
});
|
||
|
||
// 资源变更时通知
|
||
server.notification({
|
||
method: "notifications/resources/list_changed"
|
||
});
|
||
```
|
||
|
||
适度使用通知——仅当服务端能力确实发生变化时使用。
|
||
|
||
---
|
||
|
||
## 代码最佳实践
|
||
|
||
### 代码组合性与可复用性
|
||
|
||
你的实现必须优先考虑组合性和代码复用:
|
||
|
||
1. **提取通用功能**:
|
||
- 为多个工具中使用的操作创建可复用的辅助函数
|
||
- 构建共享的 API 客户端来处理 HTTP 请求,而非重复代码
|
||
- 将错误处理逻辑集中到工具函数中
|
||
- 将业务逻辑提取到可组合的专用函数中
|
||
- 提取共享的 Markdown 或 JSON 字段选择与格式化功能
|
||
|
||
2. **避免重复**:
|
||
- 切勿在不同工具之间复制粘贴相似代码
|
||
- 如果发现自己两次编写相似逻辑,将其提取为函数
|
||
- 分页、过滤、字段选择和格式化等常见操作应共享
|
||
- 认证/授权逻辑应集中管理
|
||
|
||
## 构建与运行
|
||
|
||
始终先构建 TypeScript 代码再运行:
|
||
|
||
```bash
|
||
# 构建项目
|
||
npm run build
|
||
|
||
# 运行服务端
|
||
npm start
|
||
|
||
# 开发模式(自动重载)
|
||
npm run dev
|
||
```
|
||
|
||
始终确保 `npm run build` 成功完成后再认为实现完成。
|
||
|
||
## 质量检查清单
|
||
|
||
在最终确定 Node/TypeScript MCP 服务端实现之前,请确保:
|
||
|
||
### 战略设计
|
||
- [ ] 工具支持完整工作流,而不仅仅是 API 端点封装
|
||
- [ ] 工具名称反映自然的任务划分
|
||
- [ ] 响应格式针对智能体上下文效率进行优化
|
||
- [ ] 在适当的地方使用人类可读的标识符
|
||
- [ ] 错误信息引导智能体正确使用
|
||
|
||
### 实现质量
|
||
- [ ] 聚焦实现:实现最重要和最有价值的工具
|
||
- [ ] 所有工具使用 `registerTool` 注册,并带有完整配置
|
||
- [ ] 所有工具包含 `title`、`description`、`inputSchema` 和 `annotations`
|
||
- [ ] 注解正确设置(readOnlyHint、destructiveHint、idempotentHint、openWorldHint)
|
||
- [ ] 所有工具使用 Zod schema 进行运行时输入验证,并启用 `.strict()` 约束
|
||
- [ ] 所有 Zod schema 具有适当的约束条件和描述性错误信息
|
||
- [ ] 所有工具具有全面的描述,包含显式的输入/输出类型
|
||
- [ ] 描述包含返回值示例和完整的 schema 文档
|
||
- [ ] 错误信息清晰、可操作且具有教育意义
|
||
|
||
### TypeScript 质量
|
||
- [ ] 为所有数据结构定义 TypeScript 接口
|
||
- [ ] 在 tsconfig.json 中启用严格 TypeScript 模式
|
||
- [ ] 不使用 `any` 类型——使用 `unknown` 或正确的类型替代
|
||
- [ ] 所有异步函数具有显式的 `Promise<T>` 返回类型
|
||
- [ ] 错误处理使用正确的类型守卫(例如 `axios.isAxiosError`、`z.ZodError`)
|
||
|
||
### 高级功能(如适用)
|
||
- [ ] 为适当的数据端点注册资源
|
||
- [ ] 配置了合适的传输方式(stdio 或流式 HTTP)
|
||
- [ ] 为动态服务端能力实现通知
|
||
- [ ] 通过 SDK 接口实现类型安全
|
||
|
||
### 项目配置
|
||
- [ ] Package.json 包含所有必要的依赖
|
||
- [ ] 构建脚本在 dist/ 目录中生成可运行的 JavaScript
|
||
- [ ] 主入口正确配置为 dist/index.js
|
||
- [ ] 服务端名称遵循格式:`{service}-mcp-server`
|
||
- [ ] tsconfig.json 正确配置,启用严格模式
|
||
|
||
### 代码质量
|
||
- [ ] 分页已正确实现(如适用)
|
||
- [ ] 大型响应检查 CHARACTER_LIMIT 常量并附带清晰信息截断
|
||
- [ ] 为可能较大的结果集提供过滤选项
|
||
- [ ] 所有网络操作优雅处理超时和连接错误
|
||
- [ ] 通用功能被提取为可复用的函数
|
||
- [ ] 相似操作的返回类型保持一致
|
||
|
||
### 测试与构建
|
||
- [ ] `npm run build` 成功完成且无错误
|
||
- [ ] dist/index.js 已创建且可执行
|
||
- [ ] 服务端可运行:`node dist/index.js --help`
|
||
- [ ] 所有导入正确解析
|
||
- [ ] 示例工具调用按预期工作
|