前言
MCP(Model Context Protocol)是 Anthropic 制定的标准协议,用于连接 AI 模型与外部工具、数据源。GitHub Copilot、Claude、Cursor 等主流 AI 工具都已支持该协议。
无论是想从 Copilot 调用公司内部 API,还是让 AI 执行项目专用命令,都可以通过自建 MCP 服务器实现。
本文以实战方式介绍如何使用 TypeScript 实现 MCP 服务器,并将其连接到 VS Code 的 GitHub Copilot,涵盖配置、设计和运维中的关键实践。
MCP 基本概念
flowchart TD
A[VS Code Copilot Agent] -->|MCP| B[MCP 服务器]
B --> C[外部工具]
B --> D[API]
B --> E[数据库]MCP 服务器可向客户端提供三类能力。
| 类别 | 说明 | 示例 |
|---|---|---|
| Tools | LLM 可调用的函数 | API 调用、文件操作 |
| Resources | 可读取的数据 | 文档、配置文件 |
| Prompts | 可复用的提示词模板 | 代码审查、故障排查 |
通信通过 stdio 或 Streamable HTTP 承载的 JSON-RPC 进行。
MCP 的设计思想与机制
MCP 为什么会出现
MCP 出现前,AI 工具与外部系统的集成通常由各个 AI 客户端分别实现。若要让多个 AI 使用同一个内部 API,就得为每个 AI 重复开发一套集成。
【MCP 之前】
GitHub Copilot → 自定义集成 A → 内部 API
Claude → 自定义集成 B → 内部 API(重复)
Cursor → 自定义集成 C → 内部 API(重复)
【MCP 之后】
GitHub Copilot ┐
Claude ├→ MCP 服务器 → 内部 API(只需一个)
Cursor ┘
MCP 借助标准化协议解决了这种碎片化问题。实现一个服务器后,同一能力不必再为每个客户端重复开发。不过,实际可用的功能仍取决于各 AI 客户端支持的 MCP 版本、transport 和认证方式。
设计核心:LLM 只负责推理,服务器负责执行
MCP 的核心设计原则是关注点分离。
- LLM(如 Copilot):理解上下文,决定调用哪个工具以及传递哪些参数
- MCP 服务器:实际执行工具并返回结果
LLM 不会直接访问外部系统。请求始终经过显式的工具调用接口,因此认证、验证和日志记录都可以集中在服务器端处理。
sequenceDiagram
participant U as 用户
participant L as LLM(Copilot)
participant S as MCP 服务器
participant A as 外部 API
U->>L: “查看 open Issue”
L->>S: tools/call search_github_issues
S->>A: GET /search/issues
A-->>S: 结果 JSON
S-->>L: 工具执行结果
L-->>U: 格式化后的回答使用 JSON-RPC 2.0 通信
通信使用 JSON-RPC 2.0。连接建立后,交互通常按以下三个步骤进行。
| 步骤 | 方法 | 内容 |
|---|---|---|
| 1 | initialize |
客户端与服务器交换版本和支持的能力 |
| 2 | tools/list |
客户端获取工具列表(名称和 schema) |
| 3 | tools/call |
客户端请求执行工具 |
下面以 search_github_issues 为例,展示请求和响应:
// 请求
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "search_github_issues",
"arguments": {
"query": "authentication",
"repo": "owner/my-repo",
"state": "open"
}
}
}
// 响应
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "[{\"number\": 42, \"title\": \"Fix OAuth token refresh\", ...}]"
}
]
}
}
LLM 接收 content 后,会将其整理成自然语言回答并返回给用户。
transport 的作用
MCP 标准定义了 stdio 与 Streamable HTTP 两种 transport。旧版 HTTP+SSE transport 为保持向后兼容仍被保留,但新项目建议选择 Streamable HTTP。
| stdio | Streamable HTTP | |
|---|---|---|
| 机制 | 将服务器作为子进程启动,通过标准输入输出通信 | 通过 HTTP POST / GET 收发 JSON-RPC,按需使用 SSE 流式传输 |
| 适用场景 | 本地开发、个人使用 | 团队共享、集中管理、多个客户端 |
| 启动成本 | 低(可按调用启动) | 通常需要作为常驻服务运行 |
在 VS Code 本地使用时,stdio 是常见选择。后文“选择 stdio 与 Streamable HTTP”会进一步说明如何取舍。
应该自建 MCP,而不是使用 Skills 的场景
VS Code 的 GitHub Copilot 提供两种通过 Markdown 文件扩展行为的机制。自定义指令文件(.github/copilot-instructions.md、.instructions.md 等)用于定义传递给 AI 的准则,Agent Skills(SKILL.md)则用于定义包含步骤、脚本和辅助资源的可复用工作流。下文将二者统称为“基于指令的定制”。在考虑自建 MCP 服务器前,应先判断这些机制是否已足够。
可用基于指令的定制解决的场景
- 希望向 AI 提供编码规范、项目结构等静态信息
- 希望将固定流程作为提示词模板复用
- 定义不需要访问外部系统的工作流
基于指令的定制不需要实现专用服务器。Agent 可以通过终端等既有工具执行 Skill 中的脚本,但能否执行、如何审批以及输出格式,都取决于宿主环境的能力。应先评估这类方式能否满足需求。
也可以通过 Skills 教会如何调用外部 API
如果需求只是“使用外部 API”,也可以在 Skills 中写明调用方式,让 Copilot 生成代码或 curl 命令。
<!-- SKILL.md 示例 -->
内部状态 API 的端点是 https://api.internal/status。
身份验证时,请在 Authorization 标头中指定 Bearer 令牌。
curl 调用示例:curl -H "Authorization: Bearer $TOKEN" https://api.internal/status
当目标是让 Copilot 生成代码时,这种方式很有效。在 Agent 可通过既有终端或扩展调用 API 的环境中,执行步骤、审批和结果处理同样依赖宿主环境。
需要 MCP 的边界
出现以下任一情况时,单靠 Skills 就难以满足需求。
| 情况 | 基于指令的定制 | MCP |
|---|---|---|
| 教会 Copilot 如何调用 API | 适合 | — |
| 定义使用宿主既有工具执行 API 的步骤 | 适合 | — |
| 将 API 作为类型化专用工具公开 | 不适用 | 适合 |
| 在服务器端统一认证、输入验证和结果格式 | 不适用 | 适合 |
| 向多个 MCP 客户端公开相同能力 | 不适用 | 适合 |
Skills 用于教 Agent“做什么、按什么顺序做”,MCP 则提供客户端可调用的“类型化专用工具契约”。在 MCP 中,服务器执行调用,并将约定格式的结果返回到 Copilot 上下文。能否集中管控以及是否需要跨客户端复用,是判断的关键。
需要 MCP 服务器的场景
当出现以下仅靠基于指令的定制难以管控的需求时,就应自建 MCP。
| 需求 | 原因 |
|---|---|
| 将 API 结果用于 Copilot 推理 | 执行结果会直接进入上下文,后续还可继续追问或加工 |
| 访问需要认证的资源 | 可将 token 和 credential 安全保存在环境变量中,无需展示给用户 |
| 执行写入、更新操作 | 创建 Issue、更新标签、修改记录等具有副作用的操作 |
| 需要实时数据 | 获取当前 CI 状态、DB 最新值等无法写入静态文件的信息 |
| 数据量大或动态变化 | 无法嵌入提示词的大量文档,或频繁变化的数据 |
| 需要复杂处理或转换 | 文件解析、聚合、格式转换等应由代码执行的处理 |
| 跨多个项目或团队共享 | 集中管理供多个开发者和仓库调用的工具 |
判断流程
flowchart TD
A[需要完成一项任务] --> B{需要访问<br/>外部系统吗?}
B -->|否| C[可采用基于指令的定制]
B -->|是| D{Copilot 是否需要接收结果<br/>并据此推理?}
D -->|否,仅生成代码即可| C
D -->|是| E{是否涉及认证、副作用或<br/>动态数据?}
E -->|否| F[评估能否使用现有 MCP 服务器]
E -->|是| G[自建 MCP 服务器]
F -->|不能| G是否需要在服务器端管控专用工具契约、认证和输入验证,是区分基于指令的定制与 MCP 的关键。
配置
mkdir my-mcp-server
cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D typescript @types/node tsx
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"strict": true
},
"include": ["src/**/*"]
}
// package.json(节选)
{
"type": "module",
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
实现基础 MCP 服务器
// src/index.ts
import { McpServer } from '@modelcontextprotocol/server'
import { serveStdio } from '@modelcontextprotocol/server/stdio'
import * as z from 'zod/v4'
import { fetchWeather } from './tools/weather.js'
interface GithubIssueSearchResponse {
items: Array<{
number: number
title: string
state: string
html_url: string
}>
}
function createServer(): McpServer {
const server = new McpServer({
name: 'my-custom-tools',
version: '1.0.0',
})
server.registerTool(
'get_weather',
{
description: '获取指定城市的当前天气',
inputSchema: z.object({
city: z.string().describe('要查询天气的城市名称,例如 Tokyo 或 Osaka'),
}),
},
async ({ city }) => {
const weather = await fetchWeather(city)
return {
content: [{
type: 'text',
text: `${city} 当前天气:${weather.description},气温:${weather.temp}°C`,
}],
}
}
)
server.registerTool(
'search_github_issues',
{
description: '搜索 GitHub 仓库中的 Issue',
inputSchema: z.object({
query: z.string().describe('搜索关键词'),
repo: z.string().regex(/^[^/]+\/[^/]+$/).describe('仓库名称(owner/repo 格式)'),
state: z.enum(['open', 'closed', 'all']).default('open').describe('Issue 状态'),
}),
},
async ({ query, repo, state }) => {
const token = process.env.GITHUB_TOKEN
if (!token) {
return {
content: [{ type: 'text', text: '尚未设置 GITHUB_TOKEN' }],
isError: true,
}
}
const stateQualifier = state === 'all' ? '' : ` state:${state}`
const url = new URL('https://api.github.com/search/issues')
url.searchParams.set(
'q',
`${query} is:issue repo:${repo}${stateQualifier}`
)
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json',
},
})
if (!response.ok) {
return {
content: [{ type: 'text', text: `GitHub API 请求失败:${response.status}` }],
isError: true,
}
}
const data = await response.json() as GithubIssueSearchResponse
const issues = data.items.slice(0, 5).map((issue) => ({
number: issue.number,
title: issue.title,
state: issue.state,
url: issue.html_url,
}))
return {
content: [{ type: 'text', text: JSON.stringify(issues, null, 2) }],
}
}
)
return server
}
void serveStdio(createServer)
console.error('MCP 服务器已启动')
实际工具实现示例
天气 API(外部 API 调用)
// src/tools/weather.ts
interface WeatherData {
description: string
temp: number
humidity: number
}
export async function fetchWeather(city: string): Promise<WeatherData> {
const apiKey = process.env.OPENWEATHER_API_KEY
if (!apiKey) throw new Error('尚未设置 OPENWEATHER_API_KEY')
const url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=${apiKey}&units=metric&lang=ja`
const response = await fetch(url)
if (!response.ok) {
throw new Error(`无法获取天气数据:${response.statusText}`)
}
const data = await response.json()
return {
description: data.weather[0].description,
temp: Math.round(data.main.temp),
humidity: data.main.humidity,
}
}
内部文档搜索(本地文件)
// src/tools/docs-search.ts
import * as fs from 'fs/promises'
import * as path from 'path'
import { pathToFileURL } from 'url'
interface DocResult {
title: string
excerpt: string
url: string
}
export async function searchDocs(
query: string,
limit: number
): Promise<DocResult[]> {
const docsDir = process.env.DOCS_DIR ?? './docs'
const files = await fs.readdir(docsDir)
const results: DocResult[] = []
for (const file of files.filter((f) => f.endsWith('.md'))) {
const content = await fs.readFile(path.join(docsDir, file), 'utf-8')
if (content.toLowerCase().includes(query.toLowerCase())) {
const lines = content.split('\n')
// 跳过 frontmatter(开头的 --- 块),并将第一个 H1 用作标题
let bodyStart = 0
if (lines[0]?.trim() === '---') {
const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---')
bodyStart = end >= 0 ? end + 1 : 0
}
const h1 = lines.slice(bodyStart).find((l) => /^#\s/.test(l))
const title = h1 ? h1.replace(/^#\s*/, '') : path.basename(file, '.md')
const matchIndex = content.toLowerCase().indexOf(query.toLowerCase())
const excerpt = content.slice(
Math.max(0, matchIndex - 50),
matchIndex + 150
)
results.push({
title,
excerpt,
url: pathToFileURL(path.resolve(docsDir, file)).toString(),
})
if (results.length >= limit) break
}
}
return results
}
实现 Resources
Resources 用于向 AI 提供可读取的参考资料。内部规范、运维流程和 API 文档等需要稳定引用的内容很适合放在这里。
// 添加到 src/index.ts 顶部
import { readFile } from 'node:fs/promises'
import path from 'node:path'
// 添加到 createServer 中 return server 之前
server.registerResource(
'project-guide',
'docs://project-guide',
{
title: '项目指南',
description: '开发时参考的项目指南',
mimeType: 'text/markdown',
},
async (uri) => {
const docsDir = process.env.DOCS_DIR ?? path.join(process.cwd(), 'docs')
const content = await readFile(
path.join(docsDir, 'project-guide.md'),
'utf-8'
)
return {
contents: [
{
uri: uri.href,
mimeType: 'text/markdown',
text: content,
},
],
}
}
)
适合放入 Resources 的内容包括:
- 设计指南与编码规范
- 运维 Runbook
- API 规范摘要
- 内部 FAQ
这样,AI 无需在每次对话中重新粘贴资料即可引用这些内容。
使用 Prompts
MCP 不仅能提供 Tools,也能提供 Prompts。需要重复使用固定步骤或审查框架时,Prompts 很有用。
- 安全审查 Prompt
- PR 描述生成 Prompt
- 故障调查初始 Prompt
可以把 Tool 看作执行入口,把 Prompt 看作思考框架。
连接到 VS Code
// .vscode/mcp.json
{
"servers": {
"my-custom-tools": {
"type": "stdio",
"command": "node",
"args": ["${workspaceFolder}/dist/index.js"],
"env": {
"GITHUB_TOKEN": "${env:GITHUB_TOKEN}",
"OPENWEATHER_API_KEY": "${env:OPENWEATHER_API_KEY}"
}
}
}
}
开发阶段可直接用 tsx 执行,无需构建即可启动。
{
"servers": {
"my-custom-tools-dev": {
"type": "stdio",
"command": "npx",
"args": ["tsx", "${workspaceFolder}/src/index.ts"]
}
}
}
在 Copilot Chat 中使用
重启 VS Code 后,即可在 Copilot Chat 中使用 MCP 工具。
“请告诉我 Tokyo 的天气”
→ Copilot 自动调用 get_weather 工具
“查找与 \"memory leak\" 有关的 open Issue”
→ Copilot 自动调用 search_github_issues 工具
还可以按服务器名称限定工具范围。
#my-custom-tools 列出 owner/my-repo 中有关 "authentication" 的 open Issue
→ 通过该服务器调用 search_github_issues 工具
调试提示
# 使用 MCP Inspector 交互式测试工具
npx @modelcontextprotocol/inspector npx tsx src/index.ts
在浏览器中打开 Inspector 在终端显示的 URL,即可直观查看工具调用和返回值。
工具设计最佳实践
从“不要让它做什么”开始设计
MCP Tools 很方便,但把过多能力塞进一个工具会带来风险。
| 不佳设计 | 更佳设计 |
|---|---|
manage_project |
list_open_issues, update_issue_labels |
run_anything |
get_pipeline_status |
execute_sql |
search_customers |
工具名称能反映副作用、输入参数较少且失败行为易于说明时,调用会更稳定。
认真设计 inputSchema
工具的输入 schema 不只是验证机制,也是 LLM 的接口。说明含糊会降低模型推断参数的准确度。
const updateIssueInput = {
type: 'object',
properties: {
repo: { type: 'string', pattern: '^[^/]+/[^/]+$' },
issueNumber: { type: 'integer', minimum: 1 },
labels: {
type: 'array',
items: { type: 'string' },
maxItems: 10,
},
},
required: ['repo', 'issueNumber', 'labels'],
additionalProperties: false,
}
设计要点:
- 明确编写
description - 能使用
enum时尽量使用 - 显式声明
required - 使用
additionalProperties: false防止意外参数
分离 Zod 与 JSON Schema 的职责
inputSchema 是对外契约,Zod 则负责在运行时再次验证。对于调用外部 API 或 DB 的工具,服务器端的再次验证必不可少。
case 'update_issue': {
// 用 JSON Schema 定义展示给模型的结构,
// 再用 Zod 验证实际接收的值
const input = z.object({
repo: z.string().regex(/^[^/]+\/[^/]+$/),
issueNumber: z.number().int().positive(),
labels: z.array(z.string()).max(10),
}).parse(args)
// ...
}
错误处理的思路
至少将错误分为以下三类处理,后续会更容易维护。
- 输入错误(验证失败)
- 认证 / 权限错误
- 外部服务故障
function toToolErrorMessage(error: unknown): string {
if (error instanceof z.ZodError) {
return '输入无效。请检查必填字段和数值格式。'
}
if (error instanceof Error) {
return `工具执行失败:${error.message}`
}
return '工具执行失败。'
}
返回的信息不应泄露过多内部细节,同时应让用户知道下一步如何处理。
凭证管理
不要通过提示词或参数传递敏感信息,应从环境变量或安全的执行环境中读取。
| 应做 | 不应做 |
|---|---|
| 从环境变量读取 API token | 在 tool 参数中包含 secret |
| 使用最小权限的 token | 在日志中输出 token |
| 隔离生产和开发环境 | 在错误信息中包含 secret |
权限应保持最小化
- 仅搜索 GitHub Issue 时使用只读 token
- DB 读取工具使用仅有 SELECT 权限的用户
- 部署确认工具只授予 workflow read 权限
在团队环境中,将只读工具、更新类工具和面向生产环境的工具分开管理会更安全。
运维设计
超时、重试与部分失败
本地实验能成功,生产环境却可能并不稳定。不要以“总会成功”为前提编写代码。
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)
}),
])
}
至少应考虑以下措施:
- 超时
- 重试次数上限
- 速率限制
- 错误信息规范化
可观测性(日志设计)
调试时需要的不只是“调用了什么”。日志中建议记录:
- 工具名称
- 调用时间与执行时长
- 成功/失败及失败原因分类
同时避免记录不必要的内容:个人信息、访问 token、完整 SQL 语句和完整外部 API 响应都不应出现在日志中。
测试策略
将工具实现拆分为函数后,可以脱离 MCP 协议测试业务逻辑。
// 分离业务逻辑以便测试
export async function searchGithubIssues(input: SearchIssuesInput) {
// 实际处理逻辑
}
server.registerTool(
'search_github_issues',
{ inputSchema: SearchIssuesInputSchema },
async (input) => formatResult(await searchGithubIssues(input))
)
建议按以下层级测试:
- 输入验证单元测试
- 工具 handler 单元测试
- 包含 transport 的集成测试
- 使用 MCP Inspector 手动确认
选择 stdio 与 Streamable HTTP
| 维度 | stdio | Streamable HTTP |
|---|---|---|
| 本地接入 | 非常简单 | 稍重 |
| 集中审计 | 弱 | 强 |
| 认证管控 | 依赖个人 | 更容易 |
| 调试 | 简单 | 网络因素更多 |
| 分发方式 | npm / 二进制文件 | 服务 URL |
建议先用 stdio 验证易用性;需要团队共享或集中管理时,再考虑 Streamable HTTP。通过 HTTP 公开时,应实现 Origin 验证、将服务绑定到 localhost 并进行认证。
MCP 服务器的拆分
把所有功能都放进一个服务器,职责很容易失控。按以下单元拆分会更易于运维:
- GitHub 相关工具
- 内部 API 相关工具
- DB 查询工具
- 文档 Resource 专用服务器
拆分后,权限和环境变量也更容易梳理。
常见失败模式
1. 制作无所不能的通用工具
run_anything 或 execute_sql(任意 SQL)这类设计容易降低安全性和可复现性。
2. inputSchema 过于随意
模型难以稳定组装参数,调用失败会增加。
3. 通过参数接收秘密信息
很容易通过日志或对话路径泄露。
4. 用同一权限混合读取和更新
更容易发生运维事故。
5. 将工具实现完全写在 handler 中
难以测试和维护。
实用的导入步骤
第一次创建 MCP 服务器时,以下顺序更稳妥:
- 用 stdio 创建仅本地使用的服务器
- 只创建 1 到 2 个只读 tool
- 添加 Resources / Prompts
- 完善日志与错误处理
- 必要时谨慎增加更新类工具
按这个顺序推进,更容易兼顾安全性与实用性。
适合作为第一个工具的候选包括:
- 项目配置查询
- 内部文档搜索
- CI 状态确认
- PR / Issue 搜索
更新类工具应在使用者信任建立、审计设计完善后再添加。
总结
- MCP 服务器通过 stdio / Streamable HTTP 上的 JSON-RPC 通信,基于当前的
@modelcontextprotocol/server可用 TypeScript 实现 - 结合 Tools(执行入口)、Resources(参考数据)和 Prompts(思考框架)可以扩展 Copilot 的能力
- 只需在
.vscode/mcp.json中注册,即可从 VS Code 的 GitHub Copilot 调用 - 工具设计应从“不要让它做什么”开始;仔细设计
inputSchema可提高 LLM 对参数的推断准确度 - 敏感信息通过环境变量管理,并坚持最小权限原则
- 先从 stdio + 1 到 2 个只读工具开始,积累经验后再扩展
