国会会議録APIをMCPサーバー化する:TypeScript最小実装とClaude Code登録まで
国会会議録検索APIをMCP TypeScript SDK v2(@modelcontextprotocol/server 2.0.0)でstdioサーバーにする手順。検索・発言取得の2ツール、出典を返すoutputSchema、JSON-RPCを直接流した動作確認ログ、claude mcp addの構文まで(2026-09-14実測)。
国会会議録検索APIは手続不要で JSON を返すので、MCP サーバーの題材として手頃です。検索と発言取得の2ツールに絞り、出典(speechID・speechURL・取得日時)を outputSchema で必須にすると、TypeScript SDK v2 で200行程度に収まります。 この記事では、その最小実装を書いて、JSON-RPC を stdin に直接流して動かすところまでを記録します。
API 自体の仕様と癖(上限100件、無効な院名の黙殺、掲載遅延)は前編の国会会議録検索APIの使い方で実測済みなので、ここでは繰り返しません。出典フィールドの設計はMCPのprovenance用outputSchema設計の形に合わせています。
先に押さえること
- SDK は v2 に分割された。 サーバーは
@modelcontextprotocol/server2.0.0。@modelcontextprotocol/sdkは v1 系(1.30.0)です。 - ツールは2つ。
search_speechesは ID と冒頭だけ、get_speechは全文を返します。 - 出典は契約にする。
dataとprovenanceを分け、outputSchemaで検証します。 - 動作確認は生の JSON-RPC で。
server/discover→tools/list→tools/callの順に流し、legacy のinitializeも通ることを確認しました。
SDK の現状を確認する
着手前に npm と公式ドキュメントを確認しました(2026-09-14)。
公式 README には「v1 は6か月以上バグ修正を継続」「v1 から v2 へは npx @modelcontextprotocol/codemod@latest v1-to-v2 . で自動変換」とあります。新規なら v2 で書く、が今の答えです。
mkdir kokkai-mcp && cd kokkai-mcpnpm init -ynpm i @modelcontextprotocol/server zodnpm i -D typescript @types/nodeインストール後の package.json は "type": "module" にし、tsconfig.json は module: NodeNext、outDir: dist、rootDir: src にしました。
設計:2ツールと出典オブジェクト
| ツール | 入力 | 返すもの | 呼ばないこと |
|---|---|---|---|
search_speeches | any(必須)、speaker、nameOfHouse(列挙)、nameOfMeeting、from、until、startRecord、maximumRecords(最大20) | 件数、次ページ位置、各発言の ID・日付・会議・発言者・URL・冒頭200字 | 全文 |
get_speech | speechID(正規表現で形式検証) | 発言全文 + メタデータ + URL | 検索 |
決めたことは4つです。
nameOfHouseは zod の enum にする。 API が無効値を黙殺するので、モデルの入力をそのまま渡さないmaximumRecordsの上限を20に固定する。 API の上限100を使わない- リクエストを直列化し、1.5秒以上の間隔を空ける。 公式の「数秒の間隔」を守る
- 5分の TTL キャッシュを持つ。 同じ検索をモデルが繰り返しても API を叩かない
実装:src/server.ts
以下が実際に動かしたコードです(型定義の一部を省略)。stdout には JSON-RPC 以外を書かないため、起動メッセージは console.error です。
import { McpServer } from '@modelcontextprotocol/server';import { serveStdio } from '@modelcontextprotocol/server/stdio';import * as z from 'zod/v4';
const API_BASE = 'https://kokkai.ndl.go.jp/api';const PUBLISHER = '国立国会図書館 国会会議録検索システム';const MIN_INTERVAL_MS = 1500;const CACHE_TTL_MS = 5 * 60 * 1000;const SNIPPET_LENGTH = 200;
type SpeechRecord = { speechID: string; issueID: string; session: number; nameOfHouse: string; nameOfMeeting: string; issue: string; date: string; speechOrder: number; speaker: string; speakerGroup: string | null; speakerPosition: string | null; speakerRole: string | null; speech: string; speechURL: string; meetingURL: string;};type SpeechResponse = { numberOfRecords: number; numberOfReturn: number; startRecord: number; nextRecordPosition?: number; speechRecord?: SpeechRecord[];};
// ---- 直列化 + 最小間隔 + TTL キャッシュ ----let lastRequestAt = 0;let chain: Promise<unknown> = Promise.resolve();const cache = new Map<string, { at: number; body: SpeechResponse }>();const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function fetchSpeech(params: Record<string, string>) { const qs = new URLSearchParams({ ...params, recordPacking: 'json' }); const url = `${API_BASE}/speech?${qs.toString()}`; const hit = cache.get(url); if (hit && Date.now() - hit.at < CACHE_TTL_MS) return { body: hit.body, url };
const run = async () => { const wait = lastRequestAt + MIN_INTERVAL_MS - Date.now(); if (wait > 0) await sleep(wait); lastRequestAt = Date.now(); const res = await fetch(url, { headers: { 'User-Agent': 'kokkai-mcp/0.1 (codeagent.jp example)' } }); const text = await res.text(); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const e = JSON.parse(text) as { message: string; details?: string[] }; msg = [e.message, ...(e.details ?? [])].join(' / '); } catch { /* keep msg */ } throw new Error(`国会会議録API エラー: ${msg}`); } const body = JSON.parse(text) as SpeechResponse; cache.set(url, { at: Date.now(), body }); return { body, url }; }; const p = chain.then(run, run); chain = p.catch(() => undefined); return p;}
// ---- 出典スキーマ ----const SourceSchema = z.object({ id: z.string(), uri: z.string(), title: z.string(), publisher: z.string(), retrievedAt: z.string(), sourceType: z.literal('official-api'), locator: z.string().optional(),});const SpeechItemSchema = z.object({ speechID: z.string(), issueID: z.string(), date: z.string(), session: z.number(), nameOfHouse: z.string(), nameOfMeeting: z.string(), issue: z.string(), speechOrder: z.number(), speaker: z.string(), speakerGroup: z.string().nullable(), speakerPosition: z.string().nullable(), speakerRole: z.string().nullable(), speechURL: z.string(), meetingURL: z.string(), sourceRefs: z.array(z.string()),});const ProvenanceSchema = z.object({ generatedAt: z.string(), sources: z.array(SourceSchema), warnings: z.array(z.string()),});
function toItem(r: SpeechRecord, sourceId: string) { const { speech, ...rest } = r; return { ...rest, sourceRefs: [sourceId] };}function result(structured: unknown) { return { content: [{ type: 'text' as const, text: JSON.stringify(structured, null, 2) }], structuredContent: structured as Record<string, unknown>, };}
serveStdio(() => { const server = new McpServer({ name: 'kokkai-ndl', version: '0.1.0' });
server.registerTool( 'search_speeches', { title: '国会会議録 発言検索', description: '国会会議録検索システムAPI(発言単位出力)で発言を検索し、発言ID・会議録ID・発言者・URLと本文冒頭を返す。' + '全文が必要なら get_speech を speechID で呼ぶ。検索語は半角スペース区切りで AND、部分一致。', inputSchema: z.object({ any: z.string().min(1).describe('検索語。半角スペース区切りで AND 検索'), speaker: z.string().optional().describe('発言者名(部分一致)'), nameOfHouse: z.enum(['衆議院', '参議院', '両院', '両院協議会']).optional(), nameOfMeeting: z.string().optional().describe('会議名(部分一致)'), from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), until: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), startRecord: z.number().int().min(1).default(1), maximumRecords: z.number().int().min(1).max(20).default(10), }), outputSchema: z.object({ data: z.object({ numberOfRecords: z.number(), numberOfReturn: z.number(), startRecord: z.number(), nextRecordPosition: z.number().nullable(), items: z.array(SpeechItemSchema.extend({ snippet: z.string() })), }), provenance: ProvenanceSchema, }), annotations: { readOnlyHint: true, openWorldHint: true }, }, async (input) => { const params: Record<string, string> = { any: input.any, startRecord: String(input.startRecord), maximumRecords: String(input.maximumRecords), }; for (const k of ['speaker', 'nameOfHouse', 'nameOfMeeting', 'from', 'until'] as const) { if (input[k]) params[k] = input[k]!; } const { body, url } = await fetchSpeech(params); const retrievedAt = new Date().toISOString(); const sourceId = 'ndl-speech-search'; const warnings: string[] = []; if (body.nextRecordPosition) { warnings.push(`全 ${body.numberOfRecords} 件中 ${body.startRecord}〜${body.startRecord + body.numberOfReturn - 1} 件のみ。続きは startRecord=${body.nextRecordPosition}`); } warnings.push('会議録は開催から掲載まで時間差がある。直近の会議は未収録の可能性がある'); return result({ data: { numberOfRecords: body.numberOfRecords, numberOfReturn: body.numberOfReturn, startRecord: body.startRecord, nextRecordPosition: body.nextRecordPosition ?? null, items: (body.speechRecord ?? []).map((r) => ({ ...toItem(r, sourceId), snippet: r.speech.replace(/\r?\n/g, ' ').slice(0, SNIPPET_LENGTH), })), }, provenance: { generatedAt: retrievedAt, sources: [{ id: sourceId, uri: url, title: '国会会議録検索システム 検索用API(発言単位出力)', publisher: PUBLISHER, retrievedAt, sourceType: 'official-api' as const }], warnings, }, }); }, );
server.registerTool( 'get_speech', { title: '国会会議録 発言取得', description: '発言ID(例: 122104889X00820260424_001)を指定して発言全文とメタデータ、会議録URLを返す。', inputSchema: z.object({ speechID: z.string().regex(/^[0-9A-Za-z]{21}_\d{3}$/).describe('会議録ID_発言番号(3桁)'), }), outputSchema: z.object({ data: SpeechItemSchema.extend({ speech: z.string() }).nullable(), provenance: ProvenanceSchema, }), annotations: { readOnlyHint: true, openWorldHint: true }, }, async ({ speechID }) => { const { body, url } = await fetchSpeech({ speechID, maximumRecords: '1' }); const retrievedAt = new Date().toISOString(); const r = body.speechRecord?.[0]; const sourceId = 'ndl-speech'; return result({ data: r ? { ...toItem(r, sourceId), speech: r.speech } : null, provenance: { generatedAt: retrievedAt, sources: [{ id: sourceId, uri: r ? r.speechURL : url, title: r ? `${r.nameOfHouse} ${r.nameOfMeeting} ${r.issue}(${r.date}) 発言${r.speechOrder}` : '国会会議録検索システム 検索用API', publisher: PUBLISHER, retrievedAt, sourceType: 'official-api' as const, ...(r ? { locator: `speechOrder=${r.speechOrder}` } : {}), }], warnings: r ? [] : [`speechID ${speechID} に該当する発言はなかった`], }, }); }, );
console.error('kokkai-ndl MCP server listening on stdio'); return server;});v2 で変わった点を3つだけ補足します。serveStdio にはサーバーを返すファクトリ関数を渡します(接続ごとに新しいインスタンスを作る設計)。inputSchema と outputSchema は z.object() で包んだ zod v4 スキーマをそのまま渡し、SDK が JSON Schema の生成・入力検証・ハンドラ引数の型付けを1つのスキーマから行います。structuredContent はサーバーを出る前に outputSchema で検証されます。
動かす:JSON-RPC を stdin に流す
npx tsc で dist/server.js を作り、子プロセスとして起動して JSON-RPC を書き込むプローブを用意しました。フレーミングは1行1メッセージ、各リクエストには 2026-07-28 の必須 _meta を付けます。プローブ本体はMCPサーバーをJSON-RPCでテストするのハーネスを短くしたものです。
// probe.mjs(抜粋)const META = { 'io.modelcontextprotocol/protocolVersion': '2026-07-28', 'io.modelcontextprotocol/clientInfo': { name: 'probe', version: '0.0.1' }, 'io.modelcontextprotocol/clientCapabilities': {},};function send(method, params = {}) { const id = ++n; child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params: { ...params, _meta: META } }) + '\n'); return new Promise((resolve) => pending.set(id, resolve));}const disc = await send('server/discover');const tools = await send('tools/list');const search = await send('tools/call', { name: 'search_speeches', arguments: { any: '生成AI 著作権', nameOfHouse: '衆議院', from: '2026-01-01', maximumRecords: 2 } });実行結果です(2026-09-14、長い部分は省略)。
== server/discover{ "supportedVersions": ["2026-07-28"], "capabilities": { "tools": { "listChanged": true } }, "resultType": "complete", "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "kokkai-ndl", "version": "0.1.0" } } }
== tools/list[ 'search_speeches (outputSchema: true)', 'get_speech (outputSchema: true)' ]
== tools/call search_speechesnumberOfRecords: 6, numberOfReturn: 2, nextRecordPosition: 3items[0]: 122104080X01620260715_102 2026-07-15 衆議院 経済産業委員会 第16号 阿部司(日本維新の会) https://kokkai.ndl.go.jp/txt/122104080X01620260715/102items[1]: 122104575X00820260625_094 2026-06-25 衆議院 政治改革に関する特別委員会 第8号 臼木秀剛(国民民主党・無所属クラブ)
== tools/call get_speech (speechID=122104080X01620260715_102)"provenance": { "sources": [ { "id": "ndl-speech", "uri": "https://kokkai.ndl.go.jp/txt/122104080X01620260715/102", "title": "衆議院 経済産業委員会 第16号(2026-07-15) 発言102", "publisher": "国立国会図書館 国会会議録検索システム", "retrievedAt": "2026-09-14T04:30:40.825Z", "sourceType": "official-api", "locator": "speechOrder=102" } ], "warnings": [] }
== tools/call get_speech (speechID="not-an-id"){ "content": [ { "type": "text", "text": "Input validation error: Invalid arguments for tool get_speech: speechID: Invalid string: must match pattern /^[0-9A-Za-z]{21}_\\d{3}$/" } ], "isError": true, "resultType": "complete" }確認できたことは3つです。server/discover が 2026-07-28 を返し、tools/list の両ツールに outputSchema が付き、tools/call の結果に structuredContent(data + provenance)が入ること。不正な speechID はハンドラに到達せず、SDK が isError: true で返すこと。そして検索結果の1件目を get_speech に渡すと、locator 付きの出典が返ることです。
legacy の initialize も通る
古いクライアント向けに、_meta なしの initialize(protocolVersion: "2025-11-25")も送ってみました。
== initialize (legacy 2025-11-25){ "protocolVersion": "2025-11-25", "capabilities": { "tools": { "listChanged": true } }, "serverInfo": { "name": "kokkai-ndl", "version": "0.1.0" } }== tools/list (legacy): [ 'search_speeches', 'get_speech' ]v2 サーバーが両世代に応答するので、クライアント側の世代を気にせず配れます。
Claude Code に登録する
Claude Code の公式ドキュメントでは、stdio サーバーは -- の後にコマンドと引数を書きます。-- を省くと、サーバー側の引数が Claude Code のオプションとして解釈されます。
# ローカルスコープ(既定。このプロジェクトのみ)claude mcp add --transport stdio kokkai-ndl -- node /absolute/path/kokkai-mcp/dist/server.js
# 登録内容と接続状態の確認claude mcp get kokkai-ndlclaude mcp listチームで共有するなら --scope project でプロジェクト直下の .mcp.json に書かれます。同じ内容を手で書くならこうです。
{ "mcpServers": { "kokkai-ndl": { "command": "node", "args": ["/absolute/path/kokkai-mcp/dist/server.js"] } }}本記事で実際に実行したのは stdin への JSON-RPC までで、claude mcp add の実行と Claude Code からのツール呼び出しは行っていません。登録後に claude mcp list で Connected にならない場合は、MCPサーバー接続トラブルシューティングの順で切り分けてください。stdout に文字を出しているだけで壊れる、という典型例は console.log の混入です。
既存の houan-mcp との違い
すでに公開している @codeagentjp/houan-mcp は、国会会議録に加えて衆参の議案情報を扱う4ツール構成で、依存ゼロの単一 .mjs です(7つのユースケース)。今回の kokkai-mcp は「SDK v2 の書き方」と「outputSchema で出典を契約にする」の2点を見せるための最小実装で、機能は重なります。実務で使うなら houan-mcp、v2 SDK での自作の雛形が欲しいならこの記事、という位置づけです。
設計の考え方そのものはe-Gov法令MCPの設計と作り方で書いた「回答文ではなく根拠データを返す」と同じです。
まとめ
- MCP TypeScript SDK は v2 に分割された。サーバーは
@modelcontextprotocol/server2.0.0、zod はzod/v4、Node 20 以上 - ツールは
search_speeches(ID + 冒頭200字)とget_speech(全文)の2つ。上限20件・1.5秒間隔・5分キャッシュはサーバー側で固定 outputSchemaでdataとprovenanceを必須化し、structuredContentとcontentを同じオブジェクトから作る- 生の JSON-RPC で
server/discover(2026-07-28)、tools/list、tools/call、legacyinitializeの応答を確認した - Claude Code への登録は
claude mcp add --transport stdio <name> -- node <path>。--を忘れない
MCP サーバーの価値は API を呼べることではなく、モデルが引いた発言に必ず speechURL が付いて返ってくることにあります。その契約をスキーマで書けるのが、v2 SDK の outputSchema です。
関連して読む
mcp・claude-codeを続けて読む
· 参考リンク 5件国会の情報を AI エージェントから引く——houan-mcp の7つの実用ユースケース
Claude Desktop / Claude Code / Cursor から国会会議録と議案情報を出典付きで引ける @codeagentjp/houan-mcp の使いどころを、記者・法務・研究者・市民目線で7つのユースケースに整理します。答弁検索、法案ウォッチ、大臣発言の時系列追跡まで。
mcp・claude-codeを続けて読む
· 参考リンク 9件houan-mcpで関連法案を検索する: 議事録公開前の調査フロー
議事録公開前に、衆参の公式議案情報から関連法案を探す方法を @codeagentjp/houan-mcp の実演として整理します。
この記事の情報・検証メモ
- mcp
- diet
- ai-agent
- claude-code
- stdio
- json
- 公開日
- 情報確認
- 参考リンク
- 7件
- 更新性
- 定期更新
仕様・料金・提供範囲が変わりやすいテーマは、公開日・更新日・情報確認日を分けて管理します。 導入前には必ず記事末尾の一次情報と公式ドキュメントで最新状況を確認してください。
一次情報・参考リンク
- 国会会議録検索システム 検索用API(国立国会図書館) https://kokkai.ndl.go.jp/api.html
- MCP TypeScript SDK v2 ドキュメント https://ts.sdk.modelcontextprotocol.io/v2/
- MCP TypeScript SDK v2: Tools https://ts.sdk.modelcontextprotocol.io/v2/servers/tools.html
- MCP TypeScript SDK v2: Serving over stdio https://ts.sdk.modelcontextprotocol.io/v2/serving/stdio.html
- MCP TypeScript SDK v2: Upgrade to v2 https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.html
- modelcontextprotocol/typescript-sdk README https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/README.md
- Claude Code Docs: Connect Claude Code to tools via MCP https://code.claude.com/docs/en/mcp