TypeScriptでMCPサーバーを自作する:2026年完全ガイド
2026年、使い物になるAI IDE(Claude Desktop、Cursor、Windsurf、VS Code + Copilot)はすべてMCPに対応しています。Model Context Protocolは、AIアシスタントを外部ツール・データベース・APIに接続するための事実上の標準になりました。ただ、ここに問題があります。ほとんどの開発者は、まだMCPサーバーを使う側にいるだけで、自分で作っていません。
これは大きな機会損失です。MCPサーバーを自作すれば、AIコーディングアシスタントに何でも接続できます。社内API、デプロイパイプライン、データベース、監視ダッシュボード、CMS。最初のMCPサーバーを作った瞬間、AIアシスタントは汎用チャットボットから、開発環境の真のエクステンションに変わります。
このガイドでは、TypeScriptでプロダクション品質のMCPサーバーを構築するために必要なすべてをカバーします。ゼロからプロダクションレディまで。プロトコルの内部構造、公式SDK、実践パターン、セキュリティ、デプロイ戦略を網羅的に解説します。
MCPとは何か(30秒版)
MCPサーバーを使ったことはあるけど作ったことはない方へ、メンタルモデルを説明します。
MCPはJSON-RPC 2.0ベースのプロトコルです。AIクライアント(Claude、Cursorなど)と外部のケイパビリティプロバイダー(あなたのサーバー)間の通信を標準化しています。LSP(Language Server Protocol)のAIアシスタント版と考えてください。
MCPサーバーが公開できるケイパビリティは3種類です:
- Tools : AIが呼び出せる関数(例:「DBにクエリを実行」「ステージングにデプロイ」「Jiraチケット作成」)
- Resources : AIが読めるデータ(例:「現在のスキーマ」「今日のエラーログ」「デプロイ状況」)
- Prompts : AIが使える事前定義テンプレート(例:「このPRを分析して」「マイグレーション計画を生成して」)
クライアントがサーバーのケイパビリティを検出し、AIモデルが会話コンテキストに基づいていつ・どう使うかを判断する仕組みです。
┌─────────────────┐ JSON-RPC 2.0 ┌─────────────────┐
│ AIクライアント │ ◄──────────────────────────► │ MCPサーバー │
│ (Claude等) │ stdio / SSE / HTTP │ (あなたのコード) │
│ │ │ │
│ - ケイパビリティ│ │ - Tools │
│ を検出 │ │ - Resources │
│ - Toolsを呼出 │ │ - Prompts │
│ - データを読む │ │ - Auth/Security│
└─────────────────┘ └─────────────────┘
プロジェクトのセットアップ
実際にMCPサーバーを作っていきましょう。PostgreSQLデータベースへのアクセスをAIアシスタントに提供するサーバーです。コアパターンをすべて学べる実用的なユースケースです。
前提条件
node --version # v20+推奨 npm --version # v10+
プロジェクト初期化
mkdir mcp-database-server cd mcp-database-server npm init -y npm install @modelcontextprotocol/sdk@latest zod # 2026年3月時点でv1.27+ npm install -D typescript @types/node tsx
TypeScript設定:
// tsconfig.json { "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "declaration": true }, "include": ["src/**/*"] }
package.jsonを更新:
{ "type": "module", "bin": { "mcp-database-server": "./dist/index.js" }, "scripts": { "build": "tsc", "dev": "tsx src/index.ts", "start": "node dist/index.js" } }
最初のMCPサーバー
基本のスケルトンです。すべてのMCPサーバーの出発点は同じです:
// src/index.ts import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; const server = new McpServer({ name: 'mcp-database-server', version: '1.0.0', capabilities: { tools: {}, resources: {}, prompts: {}, }, }); // ここにTools、Resources、Promptsを追加していきます const transport = new StdioServerTransport(); await server.connect(transport); console.error('MCP Database Server running on stdio');
stderrにログ出力している点に注目してください。stdoutチャネルはクライアントとのJSON-RPC通信専用です。JSON-RPC以外のものをstdoutに書くとプロトコルが壊れます。MCPサーバーを初めて作る人が必ずハマるポイントです。
Toolsの追加
ToolsはMCPで最も強力なプリミティブです。AIがあなたに代わって実際のアクションを実行できるようになります。
基本的なTool定義
import { z } from 'zod'; server.tool( 'query', 'Execute a read-only SQL query against the database', { sql: z.string().describe('The SQL query to execute (SELECT only)'), params: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])) .optional() .describe('Parameterized query values'), }, async ({ sql, params }) => { const normalized = sql.trim().toUpperCase(); if (!normalized.startsWith('SELECT') && !normalized.startsWith('WITH') && !normalized.startsWith('EXPLAIN')) { return { content: [{ type: 'text', text: 'Error: Only SELECT, WITH, and EXPLAIN queries are allowed.', }], isError: true, }; } try { const result = await pool.query(sql, params); return { content: [{ type: 'text', text: JSON.stringify(result.rows, null, 2) }], }; } catch (error) { return { content: [{ type: 'text', text: `Query error: ${error instanceof Error ? error.message : 'Unknown error'}`, }], isError: true, }; } } );
構造を分解すると:
- 名前 (
'query') : Toolの一意識別子 - 説明 : これが一番重要なんです。AIがこれを読んでいつこのToolを使うか判断するので、優秀なジュニアエンジニアに説明する感覚で書いてください。
- スキーマ : 入力パラメータを定義するZodスキーマ。AIが見て正しい呼び出しを組み立てます。
- ハンドラー : Toolが呼ばれたときに実行されるasync関数。
Tool説明のベストプラクティス
Toolの説明は事実上プロンプトエンジニアリングです。AIモデルがこれを読んで、どのToolをどう使うかを決定します。説明が不適切だと、AIが間違ったToolを選んだり、パラメータを誤って渡したりします。
// ❌ 悪い例:曖昧すぎる server.tool('query', 'Run a query', { sql: z.string() }, handler); // ❌ 悪い例:短すぎて制約なし server.tool('query', 'SQL query', { sql: z.string() }, handler); // ✅ 良い例:具体的で、制約とサンプル付き server.tool( 'query', 'Execute a read-only SQL query against the PostgreSQL database. ' + 'Only SELECT, WITH (CTE), and EXPLAIN queries are allowed. ' + 'Use parameterized queries ($1, $2, ...) for user-provided values. ' + 'Returns results as JSON array of objects. ' + 'Example: SELECT id, name FROM users WHERE active = $1', { sql: z.string().describe('PostgreSQL query (SELECT/WITH/EXPLAIN only)'), params: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])) .optional() .describe('Values for parameterized query placeholders ($1, $2, ...)'), }, handler );
マルチToolパターン
実際のサーバーは、適切なセーフガード付きで複数の関連Toolを公開します:
// 読み取り専用:確認不要 server.tool( 'list_tables', 'List all tables in the database with their column count and row count estimates', {}, async () => { const result = await pool.query(` SELECT schemaname, tablename, (SELECT count(*) FROM information_schema.columns c WHERE c.table_schema = t.schemaname AND c.table_name = t.tablename) as column_count, n_live_tup as estimated_rows FROM pg_stat_user_tables t ORDER BY schemaname, tablename `); return { content: [{ type: 'text', text: JSON.stringify(result.rows, null, 2) }] }; } ); // パラメータ付き読み取り専用 server.tool( 'describe_table', 'Get detailed schema information for a specific table including columns, types, constraints, and indexes', { table: z.string().describe('Table name (can include schema prefix like "public.users")'), }, async ({ table }) => { const [schema, tableName] = table.includes('.') ? table.split('.') : ['public', table]; const columns = await pool.query(` SELECT column_name, data_type, is_nullable, column_default, character_maximum_length FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2 ORDER BY ordinal_position `, [schema, tableName]); const indexes = await pool.query(` SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = $1 AND tablename = $2 `, [schema, tableName]); return { content: [{ type: 'text', text: JSON.stringify({ columns: columns.rows, indexes: indexes.rows }, null, 2), }], }; } ); // 書き込み操作:AIがユーザーに確認できるよう明確な結果を返す server.tool( 'execute_migration', 'Execute a SQL migration statement (CREATE, ALTER, DROP). ' + 'WARNING: This modifies the database schema. ' + 'The AI should always confirm with the user before calling this tool.', { sql: z.string().describe('The DDL statement to execute'), description: z.string().describe('Human-readable description of what this migration does'), }, async ({ sql, description }) => { try { await pool.query('BEGIN'); await pool.query(sql); await pool.query('COMMIT'); return { content: [{ type: 'text', text: `Migration successful: ${description}\n\nExecuted:\n${sql}` }], }; } catch (error) { await pool.query('ROLLBACK'); return { content: [{ type: 'text', text: `Migration failed: ${error instanceof Error ? error.message : 'Unknown'}\n\nRolled back.` }], isError: true, }; } } );
Resourcesの追加
Resourcesは、アクションを実行せずにAIがデータを読めるようにします。コンテキスト提供に最適です。AIがより良い判断を下すための背景情報を渡せます。
静的リソース
server.resource( 'schema', 'db://schema', { description: 'Current database schema including all tables, columns, and relationships', mimeType: 'application/json', }, async () => { const result = await pool.query(` SELECT t.table_schema, t.table_name, json_agg(json_build_object( 'column', c.column_name, 'type', c.data_type, 'nullable', c.is_nullable = 'YES', 'default', c.column_default ) ORDER BY c.ordinal_position) as columns FROM information_schema.tables t JOIN information_schema.columns c ON c.table_schema = t.table_schema AND c.table_name = t.table_name WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE' GROUP BY t.table_schema, t.table_name ORDER BY t.table_name `); return { contents: [{ uri: 'db://schema', mimeType: 'application/json', text: JSON.stringify(result.rows, null, 2), }], }; } );
リソーステンプレート(動的リソース)
パラメータ化されたデータを公開できます:
server.resource( 'table-data', 'db://tables/{tableName}/sample', { description: 'Sample data (first 10 rows) from a specific table', mimeType: 'application/json' }, async (uri) => { const tableName = uri.pathname.split('/')[2]; // テーブル名の妥当性検証(SQLインジェクション防止) const validTable = await pool.query( `SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename = $1`, [tableName] ); if (validTable.rows.length === 0) throw new Error(`Table '${tableName}' not found`); const result = await pool.query(`SELECT * FROM "${tableName}" LIMIT 10`); return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result.rows, null, 2) }], }; } );
Promptsの追加
Promptsは、特定のタスクに対してAIが使える事前定義テンプレートです。あまり活用されていませんが、ドメイン知識をエンコードする上で非常に強力です。
server.prompt( 'analyze-query-performance', 'Analyze a slow SQL query and suggest optimizations', { query: z.string().describe('The SQL query to analyze') }, async ({ query }) => { // 実行計画を取得 const explainResult = await pool.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query}`); const plan = JSON.stringify(explainResult.rows[0], null, 2); // 関連テーブルの統計情報を取得 const tables = extractTableNames(query); const stats = await Promise.all( tables.map(async (table) => { const result = await pool.query(` SELECT relname, reltuples::bigint as estimated_rows, pg_size_pretty(pg_total_relation_size(oid)) as total_size FROM pg_class WHERE relname = $1 `, [table]); return result.rows[0]; }) ); return { messages: [{ role: 'user', content: { type: 'text', text: [ `以下のPostgreSQLクエリのパフォーマンスを分析してください:`, '', '```sql', query, '```', '', '実行計画 (EXPLAIN ANALYZE):', '```json', plan, '```', '', 'テーブル統計:', '```json', JSON.stringify(stats, null, 2), '```', '', '以下を示してください:', '1. ボトルネック(シーケンシャルスキャン、高コストノード)', '2. CREATE INDEX文付きのインデックス推奨', '3. クエリ書き換え案', '4. 最適化後の予想改善効果', ].join('\n'), }, }], }; } );
トランスポート層:stdio、SSE、Streamable HTTP
MCPは複数のトランスポートメカニズムをサポートしています。デプロイモデルに合わせて選ぶことが重要です。
stdio(標準I/O)
ローカルMCPサーバーのデフォルト。クライアントがサーバーを子プロセスとして起動し、stdin/stdoutで通信します。
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; const transport = new StdioServerTransport(); await server.connect(transport);
使用場面: ローカル開発ツール、CLI連携、AIクライアントと同じマシンで動作するサーバー。
利点: シンプル、ネットワーク設定不要、ライフサイクル自動管理。
欠点: ローカルのみ、サーバーインスタンスあたりクライアント1つ。
SSE(Server-Sent Events) : 非推奨
⚠️ 注意: SSEトランスポートはMCP仕様2025-11-25で非推奨(deprecated)になりました。新規プロジェクトではStreamable HTTPを使用してください。既存のサーバーでまだ使われているため参考用に掲載しています。
HTTP経由でアクセス可能なリモートサーバー用:
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import express from 'express'; const app = express(); const transports = new Map<string, SSEServerTransport>(); app.get('/sse', (req, res) => { const transport = new SSEServerTransport('/messages', res); transports.set(transport.sessionId, transport); res.on('close', () => transports.delete(transport.sessionId)); server.connect(transport); }); app.post('/messages', (req, res) => { const sessionId = req.query.sessionId as string; const transport = transports.get(sessionId); if (transport) transport.handlePostMessage(req, res); else res.status(404).send('Session not found'); }); app.listen(3001, () => console.error('MCP SSE server on port 3001'));
使用場面: レガシーサーバー、古いクライアントとの後方互換。新規プロジェクトではStreamable HTTPを使用してください。
Streamable HTTP(推奨)
2026年現在、すべての新規リモートMCPサーバーに推奨されるトランスポートです。SSEに代わるウェブ標準トランスポートとなり、単一エンドポイント、バッチ/ストリーミングレスポンス対応、セッション管理、水平スケーリングを提供します:
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import express from 'express'; const app = express(); app.use(express.json()); app.post('/mcp', async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, // ステートレスモード }); res.on('close', () => transport.close()); await server.connect(transport); await transport.handleRequest(req, res); }); app.listen(3001);
使用場面: サーバーレス(AWS Lambda、Vercel Edge)、ステートレスAPI、長時間接続が問題になる環境。
セキュリティ:RCEサーバーを作ってはいけない
ほとんどのMCPチュートリアルはここで終わりますが、本当に重要な作業はここからです。セキュリティが甘いMCPサーバーは、AIモデルがトリガーできるリモートコード実行(RCE)エンドポイントと同じです。
入力バリデーション
AIモデルの入力を絶対に信用してはいけません。すべてバリデーションしてください:
server.tool( 'query', 'Execute a read-only SQL query', { sql: z.string() .max(10000) // クエリ長を制限 .refine( (sql) => { const upper = sql.trim().toUpperCase(); return upper.startsWith('SELECT') || upper.startsWith('WITH') || upper.startsWith('EXPLAIN'); }, { message: 'Only SELECT, WITH, and EXPLAIN queries are allowed' } ) .refine( (sql) => { const upper = sql.toUpperCase(); const dangerous = ['DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'TRUNCATE', 'GRANT', 'REVOKE', 'CREATE']; return !dangerous.some(kw => new RegExp(`\\b${kw}\\b`).test(upper)); }, { message: 'Query contains disallowed DDL/DML keywords' } ), }, handler );
DB接続のセキュリティ
クエリ用Toolには読み取り専用接続を使いましょう:
import pg from 'pg'; // 読み取り専用コネクションプール const readPool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 5, options: '-c default_transaction_read_only=on', }); // 書き込み操作用の別プール(必要な場合のみ) const writePool = new pg.Pool({ connectionString: process.env.DATABASE_WRITE_URL, max: 2, });
レート制限
AIの暴走ループがサーバーを叩き続けるのを防ぎましょう:
class RateLimiter { private calls: Map<string, number[]> = new Map(); check(toolName: string, maxCalls: number, windowMs: number): boolean { const now = Date.now(); const timestamps = this.calls.get(toolName) || []; const recent = timestamps.filter(t => now - t < windowMs); if (recent.length >= maxCalls) return false; recent.push(now); this.calls.set(toolName, recent); return true; } }
タイムアウト保護
function withTimeout<T>(promise: Promise<T>, ms: number, op: string): Promise<T> { return Promise.race([ promise, new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`${op} timed out after ${ms}ms`)), ms) ), ]); } // Toolハンドラー内で: const result = await withTimeout(pool.query(sql, params), 30_000, 'Database query');
エラーハンドリング
トイサーバーとプロダクションサーバーを分けるのは、エラーハンドリングの品質です。
function handleToolError(error: unknown, context: string) { console.error(`[${context}]`, error); if (error instanceof z.ZodError) { return { content: [{ type: 'text' as const, text: `Validation error: ${error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`, }], isError: true, }; } if (error instanceof pg.DatabaseError) { return { content: [{ type: 'text' as const, text: `Database error (${error.code}): ${error.message}` + (error.detail ? `\nDetail: ${error.detail}` : '') + (error.hint ? `\nHint: ${error.hint}` : ''), }], isError: true, }; } if (error instanceof Error && error.message.includes('timed out')) { return { content: [{ type: 'text' as const, text: `Operation timed out. The query may be too complex or the database may be under heavy load.`, }], isError: true, }; } return { content: [{ type: 'text' as const, text: `Error in ${context}: ${error instanceof Error ? error.message : 'Unknown error'}`, }], isError: true, }; }
核心は:エラーメッセージはプロンプトであるということ。AIがエラーメッセージを読んで次のアクションを決めます。修正可能な情報を含めましょう:
- ❌
"Error: 42P01": AIにとって役に立たない - ✅
"Table 'userz' not found. Did you mean 'users'? Available tables: users, posts, comments": AIが自己修正できる
テスト
MCP Inspectorでの手動テスト
npx @modelcontextprotocol/inspector tsx src/index.ts
ブラウザUIが開き、以下が可能です:
- 登録済みのTools/Resources/Promptsの確認
- テスト入力でのTool実行
- JSON-RPCメッセージの検査
- トランスポート問題のデバッグ
Claude Desktopとの連携
サーバーを~/Library/Application Support/Claude/claude_desktop_config.json(macOS)に追加します:
{ "mcpServers": { "database": { "command": "tsx", "args": ["/absolute/path/to/src/index.ts"], "env": { "DATABASE_URL": "postgresql://user:pass@localhost:5432/mydb" } } } }
Claude Desktopを再起動すると、ツールボックスアイコンにToolsが表示されます。
自動テスト
// test/server.test.ts import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; describe('MCP Database Server', () => { let server: McpServer; let client: Client; beforeEach(async () => { server = createServer(); client = new Client({ name: 'test-client', version: '1.0.0' }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); await client.connect(clientTransport); }); test('list_tables returns table information', async () => { const result = await client.callTool({ name: 'list_tables', arguments: {} }); expect(result.isError).toBeFalsy(); const tables = JSON.parse(result.content[0].text); expect(tables).toBeInstanceOf(Array); expect(tables.length).toBeGreaterThan(0); }); test('query rejects INSERT statements', async () => { const result = await client.callTool({ name: 'query', arguments: { sql: "INSERT INTO users (name) VALUES ('hack')" }, }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Only SELECT'); }); test('query handles parameterized queries', async () => { const result = await client.callTool({ name: 'query', arguments: { sql: 'SELECT * FROM users WHERE id = $1', params: [1] }, }); expect(result.isError).toBeFalsy(); }); });
完成版サーバー
すべてをまとめて、プロダクションレディなサーバーに仕上げましょう:
// src/index.ts import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; import pg from 'pg'; // ── 設定 ─────────────────────────────────────────────────── const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { console.error('DATABASE_URL environment variable is required'); process.exit(1); } const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 5, options: '-c default_transaction_read_only=on -c statement_timeout=30000', }); pool.query('SELECT 1').catch((err) => { console.error('Failed to connect to database:', err.message); process.exit(1); }); // ── サーバーセットアップ ─────────────────────────────────── const server = new McpServer({ name: 'mcp-database-server', version: '1.0.0', capabilities: { tools: {}, resources: {}, prompts: {} }, }); // ── Tools ────────────────────────────────────────────────── server.tool( 'list_tables', 'List all tables in the public schema with column count and estimated row count', {}, async () => { const result = await pool.query(` SELECT tablename, (SELECT count(*) FROM information_schema.columns c WHERE c.table_schema = 'public' AND c.table_name = t.tablename) as columns, n_live_tup as estimated_rows FROM pg_stat_user_tables t WHERE schemaname = 'public' ORDER BY tablename `); return { content: [{ type: 'text', text: JSON.stringify(result.rows, null, 2) }] }; } ); server.tool( 'describe_table', 'Get detailed column info, types, constraints, and indexes for a table', { table: z.string().describe('Table name in the public schema') }, async ({ table }) => { const columns = await pool.query(` SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position `, [table]); if (columns.rows.length === 0) { return { content: [{ type: 'text', text: `Table '${table}' not found.` }], isError: true }; } const indexes = await pool.query(` SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1 `, [table]); const fks = await pool.query(` SELECT kcu.column_name, ccu.table_name AS foreign_table, ccu.column_name AS foreign_column FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = $1 `, [table]); return { content: [{ type: 'text', text: JSON.stringify({ table, columns: columns.rows, indexes: indexes.rows, foreignKeys: fks.rows }, null, 2), }], }; } ); server.tool( 'query', 'Execute a read-only SQL query. Only SELECT, WITH (CTE), and EXPLAIN allowed. ' + 'Use $1, $2, ... for parameterized values. Returns JSON array.', { sql: z.string().max(10000).describe('PostgreSQL SELECT query'), params: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])) .optional().describe('Parameter values for $1, $2, ... placeholders'), }, async ({ sql, params }) => { const upper = sql.trim().toUpperCase(); const disallowed = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER', 'TRUNCATE', 'CREATE', 'GRANT', 'REVOKE']; for (const kw of disallowed) { if (new RegExp(`\\b${kw}\\b`).test(upper)) { return { content: [{ type: 'text', text: `Blocked: '${kw}' statements are not allowed.` }], isError: true }; } } try { const result = await pool.query(sql, params || []); const truncated = result.rows.length > 100 ? { rows: result.rows.slice(0, 100), note: `Showing 100 of ${result.rows.length} rows` } : { rows: result.rows, total: result.rows.length }; return { content: [{ type: 'text', text: JSON.stringify(truncated, null, 2) }] }; } catch (err) { const pgErr = err as pg.DatabaseError; return { content: [{ type: 'text', text: `Query failed: ${pgErr.message}${pgErr.hint ? `\nHint: ${pgErr.hint}` : ''}` }], isError: true, }; } } ); // ── Resources ────────────────────────────────────────────── server.resource( 'schema-overview', 'db://schema', { description: 'Complete database schema overview', mimeType: 'application/json' }, async () => { const result = await pool.query(` SELECT t.tablename, json_agg(json_build_object('column', c.column_name, 'type', c.data_type, 'nullable', c.is_nullable = 'YES') ORDER BY c.ordinal_position) as columns FROM pg_stat_user_tables t JOIN information_schema.columns c ON c.table_schema = 'public' AND c.table_name = t.tablename WHERE t.schemaname = 'public' GROUP BY t.tablename ORDER BY t.tablename `); return { contents: [{ uri: 'db://schema', mimeType: 'application/json', text: JSON.stringify(result.rows, null, 2) }] }; } ); // ── Prompts ──────────────────────────────────────────────── server.prompt( 'optimize-query', 'Analyze a SQL query and suggest performance optimizations', { query: z.string().describe('The SQL query to optimize') }, async ({ query }) => ({ messages: [{ role: 'user', content: { type: 'text', text: `Analyze this PostgreSQL query for performance:\n\n\`\`\`sql\n${query}\n\`\`\`\n\nSuggest index optimizations, query rewrites, and explain the reasoning.`, }, }], }) ); // ── ライフサイクル ───────────────────────────────────────── process.on('SIGINT', async () => { await pool.end(); process.exit(0); }); // ── 起動 ─────────────────────────────────────────────────── const transport = new StdioServerTransport(); await server.connect(transport); console.error('MCP Database Server running');
デプロイパターン
パターン1:npmパッケージ(ローカル配布)
npm run build npm pack # .tgzを配布またはnpmにパブリッシュ # ユーザーがインストール・設定: npm install -g mcp-database-server
Claude Desktop設定:
{ "mcpServers": { "database": { "command": "mcp-database-server", "env": { "DATABASE_URL": "postgresql://..." } } } }
パターン2:Docker(チーム/リモート)
FROM node:20-slim WORKDIR /app COPY package*.json ./ RUN npm ci --production COPY dist/ ./dist/ EXPOSE 3001 CMD ["node", "dist/sse-server.js"]
パターン3:サーバーレス(Streamable HTTP)
Vercel、AWS Lambda、Cloudflare Workers向け:
// api/mcp.ts(Vercelサーバーレス関数) import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; export default async function handler(req: Request): Promise<Response> { const server = createServer(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); await server.connect(transport); return transport.handleRequest(req); }
よくあるミスと回避方法
1. stdoutにログを出す
// ❌ JSON-RPCプロトコルが壊れる console.log('Server started'); // ✅ stderrを使う console.error('Server started');
2. コネクションのライフサイクルを管理しない
// ❌ 切断時にリソースがリークする const server = new McpServer(config); // ✅ 適切にクリーンアップ server.onclose = async () => { await pool.end(); console.error('Connection closed, resources cleaned up'); };
3. データを返しすぎる
// ❌ テーブル全体をコンテキストウィンドウに注ぎ込む return { content: [{ type: 'text', text: JSON.stringify(allRows) }] }; // ✅ ページネーションと要約 const rows = result.rows.slice(0, 50); return { content: [{ type: 'text', text: JSON.stringify({ rows, total: result.rowCount, note: result.rowCount > 50 ? `Showing 50 of ${result.rowCount} rows. Use LIMIT/OFFSET for pagination.` : undefined, }, null, 2), }], };
4. エラーコンテキストの欠如
// ❌ AIが自己修正できない return { content: [{ type: 'text', text: 'Error' }], isError: true }; // ✅ AIがアプローチを調整できる return { content: [{ type: 'text', text: `Column "user_name" not found in table "users". Available columns: id, email, full_name, created_at. Did you mean "full_name"?`, }], isError: true, };
5. DBクエリにタイムアウトを設定しない
// ❌ 複雑なクエリがコネクションを永久にロック await pool.query(complexSql); // ✅ プールレベルでstatement_timeoutを設定 const pool = new pg.Pool({ connectionString: DATABASE_URL, options: '-c statement_timeout=30000', // 30秒 });
MCPの今後
プロトコルは急速に進化しています。すでにリリースされたものと今後の展望を整理します。
仕様に反映済み(2025-11-25):
- OAuth 2.1認証 : MCPサーバーがOAuth Resource Serverとして分類され、Protected Resource Metadataの発見が標準化されました。アドホックな認証パターンはもう不要です。
- Structured Tool Output : Toolがテキストだけじゃなく構造化データも返せるようになりました。クライアント側でプログラマティックに処理しやすくなったのが大きいですね。
- Elicitation : サーバーがインタラクション中にユーザーへ追加入力を要求できるようになりました。「APIキーを入力してください」「OAuthフローを承認してください」といったフローです。
- アイコンメタデータ : Tools・Resources・PromptsにアイコンURLを含められ、
listレスポンスで返されます。クライアントUIがリッチになりますね。 titleフィールド : 人間が読みやすい表示名用のtitleフィールドが追加され、nameはプログラマティックな識別子として残ります。
今後の展望(SDK v2以降):
- エージェント間MCP : MCPサーバーが他のMCPサーバーのクライアントになれるようになります。コンポーザブルなAIツールチェーンが実現できるようになるんです。
- Tasks(実験的) : 永続的な状態追跡と結果の遅延取得のための新プリミティブです。長時間実行の操作で活きてきます。
- サンドボックス実行 : マニフェストベースの権限システムを備えたコンテナアイソレーションの標準化が進んでいます。
コミュニティのMCPサーバーレジストリには、GitHub、Slack、Jira、Kubernetes、AWS、Terraformなど数百のサーバーが登録されています。自作はプロトコルを理解する最善の方法であり、既製サーバーでは実現できない超能力をAIアシスタントに与える道でもあります。
まとめ
MCPサーバーの構築は難しくありません。SDKがプロトコルの複雑さを処理してくれるので、開発者はユースケースに合ったTools、Resources、Promptsの定義に集中できます。
重要なポイント:
- Toolsから始めましょう。 最もインパクトのあるプリミティブです。Claude DesktopでToolを1つ動かしてから改善を重ねましょう。
- 説明文はプロンプトエンジニアリングです。 そのまま受け取ってください。AIがあなたの説明を読んでToolの使用判断をしています。
- セキュリティは必須です。 入力バリデーション、読み取り専用接続、レート制限、タイムアウトを必ず実装してください。
- エラーメッセージはプロンプトです。 AIが自己修正できるよう、アクショナブルなコンテキストを返しましょう。
- トランスポートは慎重に選びましょう。 ローカルはstdio、リモートはStreamable HTTP(SSEは非推奨)。
「コードについて話せるAI」と「インフラを実際に操作できるAI」の差は、MCPサーバー1つ分の距離です。その橋を架けましょう。
関連ツールを見る
Pockitの無料開発者ツールを試してみましょう