MCPTypeScriptGitHub CopilotVS CodeAI

使用 TypeScript 自建 MCP 服务器,扩展 GitHub Copilot

Sloth255
Sloth255
·7 min read·1,453 words

前言

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 SkillsSKILL.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))
)

建议按以下层级测试:

  1. 输入验证单元测试
  2. 工具 handler 单元测试
  3. 包含 transport 的集成测试
  4. 使用 MCP Inspector 手动确认

选择 stdio 与 Streamable HTTP

维度 stdio Streamable HTTP
本地接入 非常简单 稍重
集中审计
认证管控 依赖个人 更容易
调试 简单 网络因素更多
分发方式 npm / 二进制文件 服务 URL

建议先用 stdio 验证易用性;需要团队共享或集中管理时,再考虑 Streamable HTTP。通过 HTTP 公开时,应实现 Origin 验证、将服务绑定到 localhost 并进行认证。


MCP 服务器的拆分

把所有功能都放进一个服务器,职责很容易失控。按以下单元拆分会更易于运维:

  • GitHub 相关工具
  • 内部 API 相关工具
  • DB 查询工具
  • 文档 Resource 专用服务器

拆分后,权限和环境变量也更容易梳理。


常见失败模式

1. 制作无所不能的通用工具

run_anythingexecute_sql(任意 SQL)这类设计容易降低安全性和可复现性。

2. inputSchema 过于随意

模型难以稳定组装参数,调用失败会增加。

3. 通过参数接收秘密信息

很容易通过日志或对话路径泄露。

4. 用同一权限混合读取和更新

更容易发生运维事故。

5. 将工具实现完全写在 handler 中

难以测试和维护。


实用的导入步骤

第一次创建 MCP 服务器时,以下顺序更稳妥:

  1. 用 stdio 创建仅本地使用的服务器
  2. 只创建 1 到 2 个只读 tool
  3. 添加 Resources / Prompts
  4. 完善日志与错误处理
  5. 必要时谨慎增加更新类工具

按这个顺序推进,更容易兼顾安全性与实用性。

适合作为第一个工具的候选包括:

  • 项目配置查询
  • 内部文档搜索
  • 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 个只读工具开始,积累经验后再扩展

参考链接