本文へスキップ

国会会議録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実測)。

SHAYOUWORLD 更新 約5分

国会会議録検索APIは手続不要で JSON を返すので、MCP サーバーの題材として手頃です。検索と発言取得の2ツールに絞り、出典(speechIDspeechURL・取得日時)を outputSchema で必須にすると、TypeScript SDK v2 で200行程度に収まります。 この記事では、その最小実装を書いて、JSON-RPC を stdin に直接流して動かすところまでを記録します。

API 自体の仕様と癖(上限100件、無効な院名の黙殺、掲載遅延)は前編の国会会議録検索APIの使い方で実測済みなので、ここでは繰り返しません。出典フィールドの設計はMCPのprovenance用outputSchema設計の形に合わせています。

先に押さえること

  1. SDK は v2 に分割された。 サーバーは @modelcontextprotocol/server 2.0.0。@modelcontextprotocol/sdk は v1 系(1.30.0)です。
  2. ツールは2つ。 search_speeches は ID と冒頭だけ、get_speech は全文を返します。
  3. 出典は契約にする。 dataprovenance を分け、outputSchema で検証します。
  4. 動作確認は生の JSON-RPC で。 server/discovertools/listtools/call の順に流し、legacy の initialize も通ることを確認しました。

SDK の現状を確認する

着手前に npm と公式ドキュメントを確認しました(2026-09-14)。

@modelcontextprotocol/sdk(v1 系)
@modelcontextprotocol/server(v2)
最新版
1.30.0(2026-07-27 公開)
2.0.0
Node 要件
>=18
>=20
zod
v3 系スキーマも可
zod ^4.2、import は zod/v4。v3 スキーマは登録時にエラー
ツール登録
server.tool() / registerTool()
registerTool(name, config, handler) のみ。tool() は削除
stdio 起動
StdioServerTransport + connect
serveStdio(factory) が推奨。transport 直接も可
実装する仕様
2025-11-25 以前中心
2026-07-28(server/discover)。legacy initialize も応答
npm view と https://ts.sdk.modelcontextprotocol.io/v2/ の migration ページ、README を 2026-09-14 に確認

公式 README には「v1 は6か月以上バグ修正を継続」「v1 から v2 へは npx @modelcontextprotocol/codemod@latest v1-to-v2 . で自動変換」とあります。新規なら v2 で書く、が今の答えです。

Terminal window
mkdir kokkai-mcp && cd kokkai-mcp
npm init -y
npm i @modelcontextprotocol/server zod
npm i -D typescript @types/node

インストール後の package.json"type": "module" にし、tsconfig.jsonmodule: NodeNextoutDir: distrootDir: src にしました。

設計:2ツールと出典オブジェクト

ツール入力返すもの呼ばないこと
search_speechesany(必須)、speakernameOfHouse(列挙)、nameOfMeetingfromuntilstartRecordmaximumRecords(最大20)件数、次ページ位置、各発言の ID・日付・会議・発言者・URL・冒頭200字全文
get_speechspeechID(正規表現で形式検証)発言全文 + メタデータ + URL検索

決めたことは4つです。

  • nameOfHouse は zod の enum にする。 API が無効値を黙殺するので、モデルの入力をそのまま渡さない
  • maximumRecords の上限を20に固定する。 API の上限100を使わない
  • リクエストを直列化し、1.5秒以上の間隔を空ける。 公式の「数秒の間隔」を守る
  • 5分の TTL キャッシュを持つ。 同じ検索をモデルが繰り返しても API を叩かない
2
ツール数
検索と全文取得を分離
20件
maximumRecords の上限
API 上限100の1/5に固定
1.5秒
リクエスト最小間隔
直列化キューで保証
200字
検索結果の本文冒頭
全文は get_speech に分ける
kokkai-mcp 0.1.0 の固定値。モデルからは変更できない

実装: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 にはサーバーを返すファクトリ関数を渡します(接続ごとに新しいインスタンスを作る設計)。inputSchemaoutputSchemaz.object() で包んだ zod v4 スキーマをそのまま渡し、SDK が JSON Schema の生成・入力検証・ハンドラ引数の型付けを1つのスキーマから行います。structuredContent はサーバーを出る前に outputSchema で検証されます。

動かす:JSON-RPC を stdin に流す

npx tscdist/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_speeches
numberOfRecords: 6, numberOfReturn: 2, nextRecordPosition: 3
items[0]: 122104080X01620260715_102 2026-07-15 衆議院 経済産業委員会 第16号 阿部司(日本維新の会)
https://kokkai.ndl.go.jp/txt/122104080X01620260715/102
items[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/discover2026-07-28 を返し、tools/list の両ツールに outputSchema が付き、tools/call の結果に structuredContentdata + provenance)が入ること。不正な speechID はハンドラに到達せず、SDK が isError: true で返すこと。そして検索結果の1件目を get_speech に渡すと、locator 付きの出典が返ることです。

legacy の initialize も通る

古いクライアント向けに、_meta なしの initializeprotocolVersion: "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 のオプションとして解釈されます。

Terminal window
# ローカルスコープ(既定。このプロジェクトのみ)
claude mcp add --transport stdio kokkai-ndl -- node /absolute/path/kokkai-mcp/dist/server.js
# 登録内容と接続状態の確認
claude mcp get kokkai-ndl
claude 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 listConnected にならない場合は、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/server 2.0.0、zod は zod/v4、Node 20 以上
  • ツールは search_speeches(ID + 冒頭200字)と get_speech(全文)の2つ。上限20件・1.5秒間隔・5分キャッシュはサーバー側で固定
  • outputSchemadataprovenance を必須化し、structuredContentcontent を同じオブジェクトから作る
  • 生の JSON-RPC で server/discover(2026-07-28)、tools/listtools/call、legacy initialize の応答を確認した
  • Claude Code への登録は claude mcp add --transport stdio <name> -- node <path>-- を忘れない

MCP サーバーの価値は API を呼べることではなく、モデルが引いた発言に必ず speechURL が付いて返ってくることにあります。その契約をスキーマで書けるのが、v2 SDK の outputSchema です。

関連して読む

この記事の情報・検証メモ
Tags
公開日
情報確認
参考リンク
7件
更新性
定期更新
更新管理

仕様・料金・提供範囲が変わりやすいテーマは、公開日・更新日・情報確認日を分けて管理します。 導入前には必ず記事末尾の一次情報と公式ドキュメントで最新状況を確認してください。

検証メモ
Node.js 24.8.0 @modelcontextprotocol/server 2.0.0 / zod 4.6.5 / TypeScript 国会会議録検索システム API 実行日 2026-09-14 JSON-RPC stdio プローブ(server/discover, tools/list, tools/call)実行日 2026-09-14
図解を保存・共有

記事の要点を1枚にまとめました。画像は新しいタブで開いて保存できます。

国会会議録APIをMCPサーバー化する:TypeScript最小実装とClaude Code登録まで 検索と発言取得の2ツールで、出典をoutputSchemaで必須化したstdioサーバーがv2 SDKで200行に収まる SDKは v2 に分割された:サーバーは @modelcontextprotocol/server 2.0.0。zod/v4 必須、Node 20 以上、serveStdio で起動。@modelcontextprotocol/sdk 1.30.0 は v1 系。 2ツールで足りる:search_speeches は本文冒頭200字と ID だけ。get_speech は speechID で全文を返す。両方 data と provenance を structuredContent で返す。 実測で確認したこと:server/discover が 2026-07-28 を返す。legacy の initialize(2025-11-25)も通る。不正な speechID は isError で止まる。
国会会議録APIをMCPサーバー化する:TypeScript最小実装とClaude Code登録まで 記事の要約 2026.09.14 実装・公開事例
Primary sources

一次情報・参考リンク

About the author
SHAYOUWORLD

日本の公共データAPIを使うMCPサーバーを作って公開し、ローカルLLMを自分のGPUで測った記録を、一次情報・検証ログ・失敗例とあわせて整理します。