MCPTypeScriptGitHub CopilotVS CodeAI

TypeScript で MCP サーバーを自作して GitHub Copilot を拡張する

Sloth255
Sloth255
·9 min read·1,945 words

はじめに

MCP(Model Context Protocol)は Anthropic が策定した、AI モデルと外部ツール・データソースを接続するための標準プロトコルです。GitHub Copilot、Claude、Cursor など主要な AI ツールが対応しています。

「社内 API を Copilot から呼び出したい」「プロジェクト固有のコマンドを 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 サーバーが提供するものは 3 種類です。

種別 説明
Tools LLM が呼び出せる関数 API 呼び出し、ファイル操作
Resources 読み取れるデータ ドキュメント、設定ファイル
Prompts 再利用可能なプロンプトテンプレート コードレビュー、障害調査

通信は stdio または Streamable HTTP を介した JSON-RPC で行われます。


MCP の設計思想と仕組み

なぜ MCP が生まれたか

MCP が登場する前は、AI ツールと外部システムの統合を各 AI クライアントが独自に実装していました。同じ社内 API を複数の AI から使うには、AI ごとに連携を実装する必要がありました。

【MCP 以前】
GitHub Copilot → 独自の連携 A → 社内 API
Claude         → 独自の連携 B → 社内 API(重複)
Cursor         → 独自の連携 C → 社内 API(重複)

【MCP 以降】
GitHub Copilot ┐
Claude         ├→ MCP サーバー → 社内 API(1 つで済む)
Cursor         ┘

MCP はこの断片化を標準プロトコルで解消します。サーバーを 1 つ実装すれば、同じ機能をクライアントごとに作り直さずに済みます。ただし、利用できる機能は各 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 を使います。接続後のメッセージは次の 3 ステップで進みます。

ステップ メソッド 内容
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 が標準で定義する transport は stdio と Streamable HTTP の 2 種類です。旧来の HTTP+SSE transport は後方互換性のために残っていますが、新規実装では Streamable HTTP を選びます。

stdio Streamable HTTP
仕組み サーバーをサブプロセスとして起動し、標準入出力でやり取り HTTP の POST / GET で JSON-RPC を送受信し、必要に応じて SSE でストリーミングする
向いている場面 ローカル開発・個人利用 チーム共有・中央管理・複数クライアント
起動コスト 低い(呼び出しのたびに起動も可) サービスとして常駐が前提

VS Code からローカルで使うなら stdio が一般的な選択肢です。transport の選び方は後述の「stdio と Streamable HTTP の選択」で説明します。


Skills ではなく MCP を自作すべきケース

VS Code の GitHub Copilot には、Markdown ファイルで振る舞いを拡張する仕組みが 2 種類あります。カスタム指示ファイル.github/copilot-instructions.md.instructions.md など)は AI に渡すガイドラインを定義し、エージェントスキルSKILL.md)は手順・スクリプト・補助リソースを含む再利用可能なワークフローを定義します。ここでは両者を「指示ベースのカスタマイズ」と呼びます。MCP サーバーを自作する前に、これらで足りるかを確認しましょう。

指示ベースのカスタマイズで解決できるケース

  • コーディング規約・プロジェクト構成など静的な情報を AI に渡したい
  • 決まった手順をプロンプトテンプレートとして再利用したい
  • 外部システムへのアクセスが不要なワークフローの定義

指示ベースのカスタマイズは専用サーバーの実装が不要です。Skill に含めたスクリプトは Agent がターミナルなどの既存ツール経由で実行できますが、実行可否・承認・出力形式はホスト環境の機能に依存します。まずここから検討するべきです。

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
API の呼び出し方を Copilot に教えたい
ホストの既存ツールで API を実行する手順を定義したい
型付きの専用ツールとして API を公開したい
認証・入力検証・結果形式をサーバー側で統一したい
複数の MCP クライアントへ同じ機能を公開したい

Skills は Agent に「何を、どの順番で行うか」を教えます。MCP はクライアントから呼び出せる「型付きの専用ツール契約」を提供します。MCP ではサーバーが呼び出しを実行し、決められた形式の結果が Copilot のコンテキストに戻ります。統制と移植性を求めるかが判断材料です。

MCP サーバーが必要なケース

指示ベースのカスタマイズだけでは統制しにくい、次の要件があれば MCP を自作します。

要件 理由
API の結果を Copilot の推論に使う 実行結果がそのままコンテキストに入り、追加の質問や加工が可能になる
認証が必要なリソースにアクセス token や credential を環境変数で安全に保持し、ユーザーに見せずに使える
書き込み・更新操作を行う Issue の作成、ラベルの更新、レコードの変更など副作用を伴う操作
リアルタイムデータが必要 現在の CI 状態、DB の最新値など、静的ファイルに書けない情報を取得する
データ量が多い・動的に変わる プロンプトに埋め込めないサイズのドキュメント群や、頻繁に変わるデータ
複雑な処理・変換が必要 ファイルのパース、集計、フォーマット変換など、コードで実行すべき処理
複数プロジェクトやチームで共有 ツールを一元管理して複数の開発者・リポジトリから呼び出す

判断フロー

flowchart TD
  A[やりたいことがある] --> B{外部システムへの<br/>アクセスが必要?}
  B -->|No| C[指示ベースのカスタマイズで解決できる]
  B -->|Yes| D{Copilot が結果を<br/>受け取って推論する必要がある?}
  D -->|No — コード生成だけでよい| C
  D -->|Yes| E{認証・副作用・<br/>動的データが絡む?}
  E -->|No| F[既存の MCP サーバー<br/>で代替できないか確認]
  E -->|Yes| 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 error: ${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

毎回チャットに貼らずに参照できるようになります。


Prompts の活用

MCP では Tools だけでなく Prompts も提供できます。同じ手順やレビュー観点を繰り返し使う場面に向いています。

  • セキュリティレビュー用 Prompt
  • PR 説明文生成 Prompt
  • 障害調査の初動 Prompt

ツールは「実行手段」、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 は便利ですが、1 つのツールに多くの処理を詰め込むと危険です。

悪い例 良い例
manage_project list_open_issues, update_issue_labels
run_anything get_pipeline_status
execute_sql search_customers

副作用を名前から判断でき、入力パラメータが少なく、失敗時の挙動を説明できるツールは扱いやすくなります。

inputSchema を丁寧に作る

ツールの入力スキーマは、単なるバリデーションではなく LLM へのインターフェースでもあります。説明が曖昧だと、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)
  // ...
}

エラーハンドリングの考え方

エラーは少なくとも次の 3 種に分けて扱います。

  • 入力エラー(バリデーション失敗)
  • 認証 / 権限エラー
  • 外部サービス障害
function toToolErrorMessage(error: unknown): string {
  if (error instanceof z.ZodError) {
    return '入力が不正です。必須項目や値の形式を確認してください。'
  }

  if (error instanceof Error) {
    return `ツール実行に失敗しました: ${error.message}`
  }

  return 'ツール実行に失敗しました。'
}

返すメッセージでは内部詳細を出しすぎず、次に取る行動を示します。


認証情報の扱い

秘密情報はプロンプトや引数で渡さず、環境変数や安全な実行環境から読むのが基本です。

やること やらないこと
API token を env から読む tool 引数に secret を含める
最小権限の token を使う ログに token を出す
本番と開発で環境を分ける エラーメッセージに secret を含める

権限は最小限に

  • GitHub Issue 検索だけなら read-only 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)
    }),
  ])
}

最低限必要な要素:

  • タイムアウト
  • 再試行回数の上限
  • レート制限
  • エラーメッセージの正規化

観測性(ログ設計)

デバッグ時は「何が呼ばれたか」だけでは足りません。ログには次の項目を残します。

  • ツール名
  • 呼び出し時刻・実行時間
  • 成否・失敗理由の分類

ただし、残しすぎない:個人情報、アクセストークン、完全な SQL 文、外部 API 応答全体はログに出しません。

テスト戦略

ツール実装を関数に分ければ、MCP プロトコルの外でもロジック単体を検証できます。

// ビジネスロジックを分離してテスト可能にする
export async function searchGithubIssues(input: SearchIssuesInput) {
  // 実処理
}

server.registerTool(
  'search_github_issues',
  { inputSchema: SearchIssuesInputSchema },
  async (input) => formatResult(await searchGithubIssues(input))
)

テストは次の層で行います。

  1. 入力バリデーションの単体テスト
  2. ツールハンドラ単体テスト
  3. transport を含む統合テスト
  4. MCP Inspector での手動確認

stdio と Streamable HTTP の選択

観点 stdio Streamable HTTP
ローカル導入 非常に簡単 やや重い
中央監査 弱い 強い
認証統制 個人依存 しやすい
デバッグ 簡単 ネットワーク要因が増える
配布方法 npm / バイナリ サービス URL

まず stdio で使い勝手を確かめ、チーム共有・中央管理が必要になった時点で Streamable HTTP を検討します。HTTP で公開する場合は、Origin 検証・localhost へのバインド・認証を実装します。


MCP サーバーの分割

1 つのサーバーに何でも入れると、責務が膨らみます。次の単位で分けると運用できます。

  • GitHub 系ツール
  • 社内 API 系ツール
  • DB 参照ツール
  • ドキュメント Resource 専用サーバー

分割すれば、権限や環境変数も分けて管理できます。


失敗しやすいパターン

1. 何でもできる汎用ツールを作る

run_anythingexecute_sql(任意 SQL)のような設計は、安全性と再現性を損ないやすくなります。

2. inputSchema を雑にする

モデルが引数を安定して組み立てられず、呼び出しの失敗が増えます。

3. 秘密情報を引数で受ける

ログや会話経路から漏れやすくなります。

4. 読み取りと更新を同じ権限で混ぜる

運用事故につながります。

5. ツール実装を handler にべったり書く

テストも保守も難しくなります。


導入ステップの現実解

初めて MCP サーバーを作るなら、次の順に始めます。

  1. stdio でローカル専用サーバーを作る
  2. read-only tool を 1〜2 個だけ作る
  3. Resources / Prompts を追加する
  4. ログとエラーハンドリングを整える
  5. 必要なら更新系ツールを慎重に増やす

この順なら、安全性を確保しながら実用性を確かめられます。

最初に作る候補:

  • プロジェクト設定参照
  • 社内ドキュメント検索
  • CI 状態確認
  • PR / Issue 検索

更新系のツールは、利用者との信頼関係と監査設計が整ってから追加します。


まとめ

  • MCP サーバーは JSON-RPC over stdio / Streamable HTTP で通信し、現行の @modelcontextprotocol/server で TypeScript 実装できます
  • Tools(実行手段)・Resources(参照データ)・Prompts(思考の型)の 3 種を組み合わせて Copilot を拡張できます
  • .vscode/mcp.json に登録するだけで VS Code の GitHub Copilot から呼び出せます
  • ツール設計は「何をさせないか」から始め、inputSchema を丁寧に作って LLM が引数を組み立てやすくします
  • 秘密情報は環境変数で管理し、権限は最小限に絞る
  • まずは stdio + read-only ツール 1〜2 個から始め、運用を確かめてから拡張します

参考リンク