深度教程展示如何设计并实现 Model Context Protocol 服务器,打造标准化 agent 工具接口。覆盖 HTTP API 包装、测试、LLM 集成完整链路。
从零开始构建并测试 MCP 服务器 | AI 智能体实验室日志
AI 智能体实验室日志
指南
术语表
中级教程
Схема составлена по утверждённому брифу и source_url будущей статьи
工具集成不应该针对每一种智能体框架重新编写。在本指南中,你将在一个普通 HTTP API 前设置稳定的协议边界,公开一个实用工具,在不涉及模型的情况下对其进行测试,然后将它连接到一个智能体。
阅读时长 45 分钟
中级
更新于 2026 年 8 月 2 日
架构与协议边界
架构与协议边界
构建 HTTP API 客户端
构建 HTTP API 客户端
实现 MCP 服务器
实现 MCP 服务器
使用客户端测试服务器
使用客户端测试服务器
将其连接到 LLM 智能体
将其连接到 LLM 智能体
验证完整链路
验证完整链路
故障场景与加固
故障场景与加固
局限性与后续步骤
局限性与后续步骤
模型上下文协议通常简称为 MCP,它为应用程序向 AI 系统提供工具和上下文提供了一种标准方式。你无需为每一种智能体框架编写单独的适配器,而是可以将集成实现一次并作为服务器运行,再由兼容的客户端使用它。
这里的具体案例是一个小型问题跟踪器集成。智能体接收一个项目键,并需要查找尚未解决的问题。上游服务已经提供了 HTTP API:
GET /v1/issues?project=OPS&status=open&limit=10
你将把该端点封装为一个名为 search_open_issues 的工具。该工具会验证输入、调用 API、规范化响应,并返回可供模型使用的紧凑结果。随后,同一台服务器可以连接到不同的 MCP 兼容宿主,而无需修改其业务逻辑。
完成本教程后,你将拥有一台通过真实 HTTP 边界工作的服务器、一项确定性的客户端测试,以及一个能够发现并调用该工具的智能体循环示例。
本教程使用 TypeScript 和 Node.js。API URL 和令牌通过环境变量提供,因此源代码中不会嵌入任何凭据。示例有意不依赖任何特定的问题跟踪器供应商。
架构与协议边界
LLM 不会直接调用问题跟踪器。宿主应用程序运行模型和 MCP 客户端。该客户端连接到你的服务器,发现其工具,并转发经过批准的调用。
用户
|
v
智能体宿主 ---- 模型
|
| MCP 客户端会话
v
问题 MCP 服务器
|
| HTTPS + bearer token
v
问题跟踪器 API
这种分离非常重要:
API 客户端负责身份验证、超时、HTTP 状态处理和响应规范化。
API 客户端负责身份验证、超时、HTTP 状态处理和响应规范化。
MCP 服务器负责工具发现、输入验证、工具描述和符合协议格式的结果。
MCP 服务器负责工具发现、输入验证、工具描述和符合协议格式的结果。
智能体宿主负责模型提示词、工具调用审批、对话状态和停止条件。
智能体宿主负责模型提示词、工具调用审批、对话状态和停止条件。
这正是模型上下文协议服务器的实际价值:协议兼容性与供应商特定的 API 行为保持分离。如果以后切换智能体框架,只要新宿主支持 MCP,问题跟踪器适配器就无须更改。
你将首先使用本地标准输入/输出传输。虽然业务集成会调用 HTTP 服务,但宿主到服务器的连接仍保持在本地,便于调试。以后可以引入远程传输,而无须修改工具实现。
保持协议输出干净
当服务器通过标准输出通信时,绝不能把应用程序日志写入其中。任何意外的日志行都可能破坏协议消息。请将诊断信息发送到标准错误。
使用受支持的 Node.js 版本和较新的 MCP TypeScript SDK 版本。软件包 API 可能会演进,因此请在锁文件中固定依赖版本,并以编译器对已安装 SDK 的判断为最终依据。
mkdir issue-mcp-server
cd issue-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install --save-dev typescript tsx @types/node
npx tsc --init
更新 package.json,使 Node 将编译后的 JavaScript 视为 ES 模块:
{
"name": "issue-mcp-server",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx src/server.ts",
"check": "tsc --noEmit",
"build": "tsc",
"test:client": "tsx test/client.ts",
"agent": "tsx examples/agent.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "YOUR_INSTALLED_VERSION",
"zod": "YOUR_INSTALLED_VERSION"
},
"devDependencies": {
"@types/node": "YOUR_INSTALLED_VERSION",
"tsx": "YOUR_INSTALLED_VERSION",
"typescript": "YOUR_INSTALLED_VERSION"
}
}
不要真的用任意数字替换这些版本。安装完成后,请保留 npm 记录的确切版本,并提交生成的锁文件。
一个精简的 tsconfig.json 就足够了:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": ".",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true
},
"include": ["src/**/*.ts", "test/**/*.ts", "examples/**/*.ts"]
}
创建目录:
mkdir -p src test examples
该项目将包含:
issue-mcp-server/
├── examples/
│ └── agent.ts
├── src/
│ ├── api.ts
│ └── server.ts
├── test/
│ └── client.ts
├── package.json
└── tsconfig.json
构建 HTTP API 客户端
从协议层下方开始。这样可以独立测试 API 行为,并防止传输细节泄漏到工具处理程序中。
export type Issue = {
id: string;
title: "string;"
status: string;
url: string;
};
type ApiIssue = {
id?: unknown;
title?: unknown;
status?: unknown;
url?: unknown;
};
type ApiResponse = {
issues?: unknown;
};
export class ApiError extends Error {
constructor(
message: string,
public readonly status?: number
) {
super(message);
this.name = "ApiError";
}
}
function requireConfig() {
const baseUrl = process.env.ISSUES_API_URL;
const token = process.env.ISSUES_API_TOKEN;
if (!baseUrl) {
throw new ApiError("ISSUES_API_URL is not configured");
}
if (!token) {
throw new ApiError("ISSUES_API_TOKEN is not configured");
}
return {
baseUrl: baseUrl.replace(/\/$/, ""),
token
};
}
function normalizeIssue(value: ApiIssue): Issue | null {
if (
typeof value.id !== "string" ||
typeof value.title !== "string" ||
typeof value.status !== "string" ||
typeof value.url !== "string"
) {
return null;
}
return {
id: value.id,
title: "value.title,"
status: value.status,
url: value.url
};
}
export async function searchOpenIssues(
project: string,
limit: number,
signal?: AbortSignal
): Promise<Issue[]> {
const { baseUrl, token } = requireConfig();
const url = new URL("/v1/issues", baseUrl);
url.searchParams.set("project", project);
url.searchParams.set("status", "open");
url.searchParams.set("limit", String(limit));
const timeoutSignal = AbortSignal.timeout(10_000);
const combinedSignal = signal
? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal;
let response: Response;
try {
response = await fetch(url, {
method: "GET",
headers: {
"accept": "application/json",
"authorization": `Bearer ${token}`,
"user-agent": "issue-mcp-server/1.0"
},
signal: combinedSignal
});
} catch (error) {
if (error instanceof Error && error.name === "TimeoutError") {
throw new ApiError("Issue API timed out");
}
throw new ApiError("Issue API could not be reached");
}
if (!response.ok) {
const retryAfter = response.headers.get("retry-after");
const suffix = retryAfter
? `; retry after ${retryAfter} seconds`
: "";
throw new ApiError(
`Issue API returned HTTP ${response.status}${suffix}`,
response.status
);
}
let body: ApiResponse;
try {
body = await response.json() as ApiResponse;
} catch {
throw new ApiError("Issue API returned invalid JSON");
}
if (!Array.isArray(body.issues)) {
throw new ApiError("Issue API response has no issues array");
}
return body.issues
.map((item) => normalizeIssue(item as ApiIssue))
.filter((item): item is Issue => item !== null)
.slice(0, limit);
}
为什么要规范化响应?
HTTP 状态码成功并不能证明响应体具有预期的形状。返回未验证的上游数据会造成两个问题:AI 智能体接收到不稳定的字段,恶意或意外内容可能会消耗过多的 context。规范化操作会建立一个严格的数据契约并丢弃格式错误的记录。
token 仅用于授权头中。它永远不会被返回给模型、包含在错误中或打印在日志中。在添加诊断时要保持这个特性。
在启动服务器的 shell 中设置这些变量:
export ISSUES_API_URL="https://issues.example.internal"
export ISSUES_API_TOKEN="replace-with-a-real-token"
使用你实际的 API 源。不要提交 token 或将其放在可能被共享的 AI 智能体配置中。在生产环境中,使用进程监督程序或部署平台的密钥管理设施。
MCP 工具由名称、面向模型的描述、输入 schema 和执行处理器组成。好的描述应该是可操作的:它们解释何时调用工具、工具返回什么以及工具不做什么。
创建 src/server.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from
"@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { ApiError, searchOpenIssues } from "./api.js";
const server = new McpServer({
name: "issue-search",
version: "1.0.0"
});
server.registerTool(
"search_open_issues",
{
title: "\"Search open issues\","
description: ""
"Find unresolved issues in one project. Use this when the user " +
"asks what is open, blocked, or awaiting work. This tool is " +
"read-only and returns issue IDs, titles, statuses, and URLs.",
inputSchema: {
project: z
.string()
.trim()
.min(1)
.max(40)
.regex(
/^[A-Za-z][A-Za-z0-9_-]*$/,
"Use a project key such as OPS or web-platform"
),
limit: z
.number()
.int()
.min(1)
.max(25)
.default(10)
}
},
async ({ project, limit }) => {
try {
const issues = await searchOpenIssues(project, limit);
const summary = issues.length === 0
? `No open issues found for ${project}.`
: issues
.map(
(issue) =>
`${issue.id}: ${issue.title} ` +
`[${issue.status}] ${issue.url}`
)
.join("\n");
return {
content: [
{
type: "text",
text: summary
}
],
structuredContent: {
project,
count: issues.length,
issues
}
};
} catch (error) {
const message = error instanceof ApiError
? error.message
: "Unexpected server error";
console.error("search_open_issues failed:", message);
return {
isError: true,
content: [
{
type: "text",
text:
`Could not search issues for ${project}: ${message}. ` +
"Do not claim that no issues exist."
}
]
};
}
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("issue-search MCP server is ready");
文本结果对于只将文本工具输出传递给模型的宿主仍然很有用。结构化结果为功能完整的宿主提供可预测的对象用于渲染或下游处理。
注意错误的措辞。不可用的 API 与空的问题列表不同。明确告知 AI 智能体不要将失败解释为"无问题"可以防止微妙但有害的错误结论。
项目密钥受限于 40 个字符和一个保守的字符集。限制不能超过 25。这些不仅仅是表面检查:它们限制响应大小、提前拒绝歧义输入并减少上游服务的工作负载。
对项目进行类型检查:
npm run check
如果你安装的 SDK 版本使用略有不同的注册签名,请遵循其编译器诊断和已安装的类型定义。保持架构边界完整:schema 和协议逻辑在 server.ts 中,HTTP 行为在 api.ts 中。
暂时不要引入模型。直接 MCP 客户端提供确定性证据,证明初始化、发现、验证、执行和关闭都能正常工作。
创建 test/client.ts:
import { Client } from
"@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from
"@modelcontextprotocol/sdk/client/stdio.js";
const transport = new StdioClientTransport({
command: process.execPath,
args: [
"--import",
"tsx",
"src/server.ts"
],
env: {
...process.env,
ISSUES_API_URL:
process.env.ISSUES_API_URL ?? "",
ISSUES_API_TOKEN:
process.env.ISSUES_API_TOKEN ?? ""
}
});
const client = new Client({
name: "issue-search-test",
version: "1.0.0"
});
try {
await client.connect(transport);
const listed = await client.listTools();
const tool = listed.tools.find(
(candidate) => candidate.name === "search_open_issues"
);
if (!tool) {
throw new Error("search_open_issues was not discovered");
}
console.log("Discovered:", tool.name);
const result = await client.callTool({
name: "search_open_issues",
arguments: {
project: "OPS",
limit: 3
}
});
console.log(JSON.stringify(result, null, 2));
if (result.isError) {
process.exitCode = 1;
}
} finally {
await client.close();
}
仅在配置了你有权访问的端点后才运行它:
npm run test:client
这是一个集成检查,不是对特定输出的承诺。成功的运行应该演示以下所有内容:
客户端完成协议握手。
listTools 包含 search_open_issues。
调用到达配置的 API。
结果包含文本,在支持的情况下,还包含结构化内容。
进程关闭而不挂起。
对于可重复的自动化测试,将 ISSUES_API_URL 指向实现相同端点的本地测试服务器。其 fixture 可以返回刻意较小的响应:
{
"issues": [
{
"id": "OPS-12",
"title": "Retry delayed synchronization jobs",
"status": "open",
"url": "https://tracker.invalid/issues/OPS-12"
}
]
}
以 .invalid 结尾的域防止 fixture 暗示真实的客户或活动服务。你的测试应该验证形状和转换,而不仅仅是打印结果。有用的断言包括:
上游请求包含 status=open。
Bearer 头存在,但其值永远不会被记录。
limit 为 3 产生不超过三个规范化的问题。
格式错误的记录被省略。
HTTP 401、429 和 500 响应产生 isError: true。
无效输入在发出 HTTP 请求前被拒绝。
临时将客户端参数更改为每个无效情况:
{ "project": "", "limit": 3 }
{ "project": "../admin", "limit": 3 }
{ "project": "OPS", "limit": 0 }
{ "project": "OPS", "limit": 1000 }
每个调用都应该在验证时失败,而不是到达 API。确切的错误格式取决于 SDK 和客户端,因此应该断言失败类别而不是脆弱的文本。
有两种实用的连接模式。桌面或 IDE 宿主可以从配置启动服务器。自定义应用程序可以自己打开客户端会话并将发现的工具暴露给其模型 SDK。
典型的本地宿主配置具有以下形状:
{
"mcpServers": {
"issue-search": {
"command": "node",
"args": [
"/absolute/path/to/issue-mcp-server/dist/src/server.js"
],
"env": {
"ISSUES_API_URL": "https://issues.example.internal",
"ISSUES_API_TOKEN": "supply-through-your-secret-manager"
}
}
}
}
在将宿主指向 dist 前进行构建:
npm run build
不同宿主的配置文件名和嵌套结构各不相同。请根据你的宿主调整外层配置,但应保留可执行文件、参数和环境变量的语义。重启或重新加载宿主,检查其工具列表,并在发送自然语言请求之前确认其中已出现 search_open_issues。
在搜索配置方法时,经常会看到短语 claude model context protocol,因为兼容 Claude 的宿主推动了 MCP 的普及。这里构建的服务器并未与某个模型供应商绑定:兼容性取决于宿主对协议的支持以及传输配置。
自定义智能体循环包含四项基本操作:
连接 MCP 客户端并列出工具。
连接 MCP 客户端并列出工具。
将工具的 schema 转换为模型供应商使用的工具格式。
将工具的 schema 转换为模型供应商使用的工具格式。
将用户请求和工具定义发送给模型。
将用户请求和工具定义发送给模型。
通过 MCP 执行请求的调用,并将结果返回给模型。
通过 MCP 执行请求的调用,并将结果返回给模型。
下面这个与供应商无关的框架展示了控制流程。请将占位的模型适配器替换为你已经在使用的 SDK:
import { Client } from
"@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from
"@modelcontextprotocol/sdk/client/stdio.js";
type ModelToolCall = {
id: string;
name: string;
arguments: Record<string, unknown>;
};
type ModelResponse = {
text?: string;
toolCalls: ModelToolCall[];
};
async function callModel(input: {
messages: unknown[];
tools: unknown[];
}): Promise<ModelResponse> {
throw new Error(
"Implement this adapter with your chosen model SDK"
);
}
const transport = new StdioClientTransport({
command: "node",
args: ["dist/src/server.js"],
env: {
...process.env,
ISSUES_API_URL:
process.env.ISSUES_API_URL ?? "",
ISSUES_API_TOKEN:
process.env.ISSUES_API_TOKEN ?? ""
}
});
const mcp = new Client({
name: "issue-agent",
version: "1.0.0"
});
await mcp.connect(transport);
try {
const { tools } = await mcp.listTools();
const modelTools = tools.map((tool) => ({
type: "function",
name: tool.name,
description: "tool.description,"
parameters: tool.inputSchema
}));
const messages: unknown[] = [
{
role: "system",
content:
"Use tools for current issue data. Never treat a tool " +
"error as an empty result. Do not invent issue details."
},
{
role: "user",
content:
"List up to three open issues in OPS and summarize them."
}
];
for (let turn = 0; turn < 6; turn += 1) {
const response = await callModel({
messages,
tools: modelTools
});
if (response.toolCalls.length === 0) {
console.log(response.text ?? "");
break;
}
messages.push({
role: "assistant",
content: response
});
for (const call of response.toolCalls) {
const result = await mcp.callTool({
name: call.name,
arguments: call.arguments
});
messages.push({
role: "tool",
toolCallId: call.id,
content: JSON.stringify(result)
});
}
}
} finally {
await mcp.close();
}
这个框架有意在一个边界处保持不完整:不同模型供应商使用的请求、响应和工具结果类型各不相同。实现 callModel 只需要编写一个小型适配器;HTTP 集成和 MCP 服务器仍然可以复用。
绝不要执行模型生成的任意工具名称。应根据 MCP 的发现响应或显式策略构建允许列表,在服务器端再次验证参数,限制循环轮数,并要求对修改数据的操作进行审批。
const allowed = new Set(
tools.map((tool) => tool.name)
);
if (!allowed.has(call.name)) {
throw new Error(`Tool is not allowed: ${call.name}`);
}
我们的工具是只读的,但读取权限仍可能暴露敏感的议题标题。必须由上游 API 令牌实施授权;对于多用户应用程序,还必须通过宿主的用户到凭据映射来实施授权。
验证应分层进行。如果最终答案有误,以下顺序可以帮助你确定是哪一层边界出现了故障。
npm run check
npm run build
确认编译成功,并且配置的服务器入口点存在于 dist 目录下。
npm run test:client
在引入模型之前,先确认工具发现和直接工具调用均正常。还要测试一个无效参数和一个模拟的上游错误。
启动选定的宿主,并检查其中注册的工具。如果工具不存在,请调试进程启动和传输配置。修改提示词无法修复连接失败问题。
使用一个明确需要获取当前工具数据的请求:
查找 OPS 项目中最多三个处于打开状态的议题。请包含每个议题的 ID 和 URL。如果工具失败,请报告失败,而不是猜测。
检查运行轨迹或应用程序日志,并回答以下问题:
模型是否选择了 search_open_issues?
模型是否选择了 search_open_issues?
project 和 limit 是否有效?
project 和 limit 是否有效?
服务器是否准确发出了预期的 API 请求?
服务器是否准确发出了预期的 API 请求?
最终答案是否只使用了工具返回的记录?
最终答案是否只使用了工具返回的记录?
发生 API 错误时,智能体是否避免声称列表为空?
发生 API 错误时,智能体是否避免声称列表为空?
除非你确实在自己的环境中观察到了成功结果,否则不要记录测试成功。这里的命令定义了一套可重复的验证流程,并不代表测试已经通过。
层级
证据
构建
类型检查和编译均已完成,且没有错误。
发现
客户端列出了 search_open_issues。
验证
无效的项目键和数量限制绝不会传递至 API。
HTTP
在受控测试中观察到了预期的路径、查询参数和授权请求头。
错误
超时和非成功状态会产生明确的工具错误。
智能体
智能体调用工具,并以返回的记录作为响应依据。
构建
类型检查和编译均已完成,且没