はじめに
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))
)
テストは次の層で行います。
- 入力バリデーションの単体テスト
- ツールハンドラ単体テスト
- transport を含む統合テスト
- MCP Inspector での手動確認
stdio と Streamable HTTP の選択
| 観点 | stdio | Streamable HTTP |
|---|---|---|
| ローカル導入 | 非常に簡単 | やや重い |
| 中央監査 | 弱い | 強い |
| 認証統制 | 個人依存 | しやすい |
| デバッグ | 簡単 | ネットワーク要因が増える |
| 配布方法 | npm / バイナリ | サービス URL |
まず stdio で使い勝手を確かめ、チーム共有・中央管理が必要になった時点で Streamable HTTP を検討します。HTTP で公開する場合は、Origin 検証・localhost へのバインド・認証を実装します。
MCP サーバーの分割
1 つのサーバーに何でも入れると、責務が膨らみます。次の単位で分けると運用できます。
- GitHub 系ツール
- 社内 API 系ツール
- DB 参照ツール
- ドキュメント Resource 専用サーバー
分割すれば、権限や環境変数も分けて管理できます。
失敗しやすいパターン
1. 何でもできる汎用ツールを作る
run_anything や execute_sql(任意 SQL)のような設計は、安全性と再現性を損ないやすくなります。
2. inputSchema を雑にする
モデルが引数を安定して組み立てられず、呼び出しの失敗が増えます。
3. 秘密情報を引数で受ける
ログや会話経路から漏れやすくなります。
4. 読み取りと更新を同じ権限で混ぜる
運用事故につながります。
5. ツール実装を handler にべったり書く
テストも保守も難しくなります。
導入ステップの現実解
初めて MCP サーバーを作るなら、次の順に始めます。
- stdio でローカル専用サーバーを作る
- read-only tool を 1〜2 個だけ作る
- Resources / Prompts を追加する
- ログとエラーハンドリングを整える
- 必要なら更新系ツールを慎重に増やす
この順なら、安全性を確保しながら実用性を確かめられます。
最初に作る候補:
- プロジェクト設定参照
- 社内ドキュメント検索
- 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 個から始め、運用を確かめてから拡張します
