はじめに
普段Rustに触れる機会がなかった自分が、ふとしたきっかけで「MCPサーバー」というものを作ってみようと思いました。
実は実際に作るまでMCP自体が何なのかもよく分かっていませんでした。
そんな状態からClaudeCodeと対話しながら手を動かし、PostgreSQLのテーブルスキーマ情報を返すMCPサーバーを実際に作ってみました。
この記事では、その過程で学習した「MCPサーバーを実装する上でのポイント」を、実装の勘所や工夫した点を中心にまとめています。
こんな方に読んでいただけることを想定しています。
- MCPという言葉は聞いたことがあるが、実際どういうものかよく分かっていない
- AIエージェント向けのツールをどう設計・実装すればよいか知りたい
- MCPサーバーを実際に作ってみたい
この記事はMCPサーバーの実装(設計・工夫・つまずいたポイント)に焦点を当てており、Rust自体の文法解説は扱いません。
使用言語がRustである必要はなく、他の言語でMCPサーバーを実装する際にも参考にしていただける内容を目指しています。
今回作成したソースコードは以下で公開しています。
まずMCPサーバーとは
MCP(Model Context Protocol)とは、AI(LLM)が外部のツールやデータソースとやり取りするための共通の「会話の作法」を定めたプロトコルです。
これまでAIアシスタントに外部の機能(ファイル操作/API連携/DB参照など)を使わせようとすると、AIツールごと・連携先ごとに個別の繋ぎ込みが必要でした。
MCPはこの部分を標準化し、「MCPという共通の話し方さえ守れば、どんなAIツールからでも同じように呼び出せる」状態を作る仕組みです。
具体的には、MCPサーバーが「AIから呼び出せる機能(ツール)」を公開し、MCPクライアント(ClaudeCodeなど)がそのツールを呼び出す、という役割分担になっています。
通信はJSON-RPCというシンプルな形式のメッセージのやり取りで行われ、「こういう名前の機能を、こういう引数で呼び出したい」「結果はこうでした」という会話がAIとツールの間で交わされます。
「ツール」という言葉は、MCPの仕様で定義された正式な用語です。MCPには「Tools(ツール)」の他に、AIが参照できる読み取り専用のデータを表す「Resources」や、再利用可能なプロンプトのひな形を表す「Prompts」といった概念も用意されています。今回作成したのは、この中の「Tools」にあたる機能です。
MCPサーバーが提供する機能の例としては、以下のようなものがあります。
- ファイルの読み書き
- 外部APIの呼び出し
- データベースの参照/操作
今回作成したのも、この中の一つで、PostgreSQLのテーブルスキーマ情報をAIに提供するMCPサーバーです。
今回作ったMCPサーバーの設計
解決したい課題
AIエージェントにデータベースが絡むタスクを依頼する際、テーブルの構造(カラム名・型・主キー・外部キーなど)を毎回人間が説明する必要がある、という課題がありました。そのつどコンテキストとして貼り付けるのは面倒ですし、DBの構造が変われば情報が古くなってしまいます。
また、AIエージェントに直接データベースへの接続情報を持たせるのではなく、接続情報自体はMCPサーバー側に集約し、AIエージェントには「スキーマ情報を取得する」という限定された操作だけを許可したい、という狙いもありました。
そこで、AIエージェント自身がDBのスキーマ情報を取得できるようにする、というのが今回のモチベーションです。
ツールの入出力設計
機能は get_table_schema という1つのツールに集約しました。
- 引数: テーブル名(必須)、スキーマ名(省略可、省略時は
public) - 戻り値: カラム定義一覧・主キー・外部キー・インデックス情報をまとめたJSON
複数の細かいツール(カラム取得用、主キー取得用、など)に分割する設計も考えられましたが、AIエージェント側から見て「テーブル名を渡せば、そのテーブルに関する情報が一通り揃う」形の方がシンプルで使いやすいと考えて、1ツールにまとめる設計にしました。
get_table_schema の戻り値サンプル(JSON)
{
"schema": "public",
"table": "users",
"columns": [
{
"name": "id",
"data_type": "integer",
"is_nullable": false,
"default": "nextval('users_id_seq'::regclass)",
"character_maximum_length": null,
"numeric_precision": 32,
"numeric_scale": 0,
"is_primary_key": true
},
{
"name": "email",
"data_type": "character varying",
"is_nullable": false,
"default": null,
"character_maximum_length": 255,
"numeric_precision": null,
"numeric_scale": null,
"is_primary_key": false
},
{
"name": "organization_id",
"data_type": "integer",
"is_nullable": true,
"default": null,
"character_maximum_length": null,
"numeric_precision": 32,
"numeric_scale": 0,
"is_primary_key": false
}
],
"primary_key": ["id"],
"foreign_keys": [
{
"constraint_name": "users_organization_id_fkey",
"column": "organization_id",
"foreign_schema": "public",
"foreign_table": "organizations",
"foreign_column": "id"
}
],
"indexes": [
{
"name": "users_pkey",
"definition": "CREATE UNIQUE INDEX users_pkey ON public.users USING btree (id)"
}
]
}
character_maximum_length や numeric_precision などが null になっている列があります。
「値が存在しない」ことをどう表現したかは→ 実装時に直面した課題とその解決で触れます。
全体アーキテクチャ
通信方式にはMCPの"Streamable HTTP"を採用しています。MCPサーバー自体は専用のデータベースを持たず、別のPostgreSQLコンテナに接続してスキーマ情報を取得する構成です。
MCPサーバー自身もDockerコンテナとして動かし、ホスト環境に依存しない形にしました。
実装のポイント
ツールの入力スキーマを自動生成する
MCPクライアントは tools/list でツール一覧と「各ツールがどんな引数を受け取るか」を取得し、それをもとにAIへツールの使い方を伝えます。その情報がつまったJSON Schemaを手書きするかコードから自動生成する必要があります。
今回は引数の定義(型・必須かどうか・説明文)をコード上の1箇所にまとめて書くと、そこからJSON Schemaが自動で生成される仕組みを利用しました。これにより「スキーマ定義とツール本体のロジックがズレる」という事態を避けられます。スキーマ定義に説明文(description)を添えられる仕組みも合わせて活用し、AIが引数の意味を正しく解釈できるようにしました。
実際のコード抜粋(引数の定義)
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct GetTableSchemaRequest {
#[schemars(description = "テーブルが属するスキーマ名(省略時は public)")]
pub schema: Option<String>,
#[schemars(description = "スキーマ情報を取得するテーブル名")]
pub table: String,
}
各フィールドに添えた description がそのままJSON Schemaの説明文になり、AIに渡されます。
エラー設計の考え方
ツールの失敗には性質が異なる2種類があると考え、意図的に返し方を分けました。
| 失敗の種類 | 返し方 | 意味 |
|---|---|---|
| DB接続不可など、基盤自体の問題 | プロトコルレベルのエラー | 引数を変えても解決しない、深刻な失敗 |
| 指定されたテーブルが存在しない、など想定内の失敗 | ツールの実行結果としての失敗(成功応答の中でエラーを示す) | AI側が引数を変えて自分でリトライできる失敗 |
この区別をすることで、AIエージェント側が「リトライすれば解決するかもしれない失敗」と「リトライしても無駄な失敗」を判断しやすくなります。
実際のコード抜粋(2種類の失敗の書き分け)
async fn get_table_schema(
&self,
Parameters(GetTableSchemaRequest { schema, table }): Parameters<GetTableSchemaRequest>,
) -> Result<CallToolResult, McpError> {
let schema = schema.unwrap_or_else(|| "public".to_string());
let client = self.connect().await?;
if !table_exists(&client, &schema, &table).await? {
return Ok(CallToolResult::error(vec![ContentBlock::text(format!(
"テーブル '{schema}.{table}' が見つかりませんでした"
))]));
}
// ...スキーマ情報を取得して CallToolResult::success を返す
}
self.connect().await? はDB接続に失敗した時点で関数を抜け、プロトコルレベルのエラー(Err(McpError))としてそのまま呼び出し元に伝わります。
一方、テーブルが存在しない場合は Ok(CallToolResult::error(...)) として、成功応答の中で失敗を表現しています。
デバッグ出力の注意点
MCPサーバーは、採用するトランスポート(stdio、HTTPなど)によって「プロトコル通信に使うチャンネル」が変わります。
デバッグ用のログをうっかりそのチャンネルに混ぜて出力してしまうと、通信内容が壊れてクライアント側でパースエラーになる、という事態が起こり得ます。
ログは標準エラー出力(stderr)など、プロトコル通信の経路とは別のところに出す習慣をつけておくと安全です。
tokio::spawn(async move {
if let Err(e) = connection.await {
// ログはプロトコル通信の経路と混ざらないよう、標準エラー出力(stderr)へ
eprintln!("DB接続エラー: {e}");
}
});
実装時に直面した課題とその解決
実装中には、MCPサーバーの名前・バージョンが使用ライブラリ側のデフォルト値のまま返ってしまう、という小さなハマりどころもありました。initialize のレスポンス内容は実際に確認し、意図した値になっているかチェックしておくとよさそうです。
実際のコード抜粋(サーバー情報の設定)
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")))
.with_protocol_version(ProtocolVersion::V_2024_11_05)
.with_instructions(
"PostgreSQL データベースのテーブルスキーマ情報を取得するツールを提供します。\
get_table_schema にテーブル名(と任意でスキーマ名)を指定してください。".to_string(),
)
}
env!("CARGO_PKG_NAME") / env!("CARGO_PKG_VERSION") で、ライブラリ自身ではなく自分のプロジェクトの情報を明示的に渡すよう修正しています。
「値が存在しない」状態をツールの出力でどう表現するか
データベースの列には、値が設定されていない(NULL)ケースがあります。例えば「文字数上限のないTEXT型の列」と「上限255文字のVARCHAR型の列」を同じ形式で表現しようとすると、前者は「上限なし」を意味する情報が本来存在しません。
これを、空文字列や 0 のような「それらしい代用値」で埋めてしまうと、AIが結果を解釈する際に「本当にその値なのか」「そもそも値が存在しないのか」を区別できなくなり、誤った判断につながりかねません。
そこで各フィールドを「値がある/ない」を明示的に表現できる型(Option型)として定義しました。
struct ColumnSchema {
// ...
/// 文字列型の最大長。該当しない型なら`None`。
character_maximum_length: Option<i32>,
/// 数値型の精度(桁数)。該当しない型なら`None`。
numeric_precision: Option<i32>,
// ...
}
DBの列がNULLの場合は自動的に「値なし」として扱われるようにしました。
これにより実装時にも「値がない場合にどう扱うか」を必ず考慮せざるを得ない設計になり、AIに返すJSON上でも値が存在しない項目は明確に null として表現され、空文字列や 0 とはっきり区別できるようになっています。
テストとデバッグ
JSON-RPCリクエストを手動で流し込んで確認する
MCPサーバーは、特別なクライアントがなくても、標準入出力(またはHTTP)に対してJSON-RPC形式のリクエストを直接送ることで動作確認ができます。開発中は、以下の流れのリクエストをまとめて用意しておき、それをサーバーに流し込んで確認する、という方法で動作確認していました。
-
initialize— 接続を開始する -
notifications/initialized— 初期化完了を通知する -
tools/call(またはtools/list) — 実際にツールを呼び出す/一覧を取得する
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"manual-test","version":"0.0.1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_table_schema","arguments":{"table":"users"}}}
複数のリクエストをまとめて流し込むと、レスポンスが返ってくる順番は送信順と一致するとは限りません。
各レスポンスは id で対応するリクエストと紐付いているため、順番ではなく id をもとに結果を照合する必要があります。
curl コマンドの例:
curl -i -X POST http://localhost:8081/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"manual-test","version":"0.0.1"}}}'
Streamable HTTPでは、レスポンスがJSONで返る場合とSSE(Server-Sent Events)ストリームで返る場合の両方があり得るため、Accept ヘッダーに両方の形式を指定しておく必要があります。
また initialize のレスポンスヘッダーには Mcp-Session-Id が含まれており、notifications/initialized や tools/call など以降のリクエストでは、このセッションIDを Mcp-Session-Id ヘッダーに付けて送る必要があります。
この方法の良いところは、MCPクライアントやAIエージェントを介さずに、プロトコルレベルでツールの入出力を直接確認できる点です。
ツールを変更するたびに毎回AIエージェント経由で確認するよりも、変更の影響範囲を素早く・正確に切り分けられます。
実際のMCPクライアントからの動作確認
プロトコルレベルでの確認がひと通り済んだ後は、実際にClaudeCodeなどのMCPクライアントにサーバーを登録し、AIエージェント経由でツールを呼び出して最終確認をしました。「AIがツールの説明文や引数からどう使い方を理解し、実際にどう呼び出すか」という部分は、手動でのJSON-RPC確認だけでは分からず、実際のクライアントを通した確認でしか見えてきません。
まとめ
MCPが何かも分からない状態から始まり、ClaudeCodeと対話しながら実際に手を動かすことで、PostgreSQLのテーブルスキーマ情報を返すMCPサーバーを組み立てることができました。
理解が深まったポイントとしては,,,
- MCPは「AIと外部ツールが共通の作法でやり取りするためのプロトコル」であり、ツール開発者はその作法(ツールの入出力スキーマ、エラーの表現方法など)に沿って実装する必要がある
- ツールの入力スキーマを自動生成する仕組みを活用すると、スキーマ定義とロジックのズレを防げる
- 失敗の性質(基盤自体の問題か、想定内の失敗か)に応じてエラーの返し方を使い分けると、AIエージェントが自律的に判断しやすくなる
MCPというプロトコル自体はシンプルですが、実際に手を動かして初めて分かる設計上の勘所がいくつもありました。
