自作MCPサーバーで社内DBとAPIを安全公開:最小権限とテストの具体策
社内APIやローカルDBをLLMや開発者ツールにつなぎたいとき、独自スクリプトを書き足していくと、ツールごとに接続コードが増え、権限管理も属人化します。この記事では、Model Context Protocol (MCP) の公式SDKでMCPサーバーを自作し、ローカルDBと社内APIを安全に公開する実装パターンを整理します。Pythonの最小コード、入力バリデーションと認可の設計、CLI / IDEでの接続テストまで、手元で試せる形で扱います。
標準化が進むModel Context Protocol (MCP) の基本構造
MCPは、LLMアプリケーションと外部のデータ・機能をつなぐためのオープンなプロトコルです。仕様はJSON-RPC 2.0を土台にしており、公式のTypeScript SDKやPython SDKを使うと、メッセージ処理を自前で書かずに済みます。主要な開発者ツールがMCPクライアント機能を備えているため、一度MCPサーバーを書けば複数のツールから再利用できます。「デファクト」と呼べるかは意見が分かれますが、少なくとも接続方式が標準化されている点が実務上の利点です。
構成要素は3つです。ツール側の ホスト (CLIやIDE)、ホスト内でサーバーと1対1で接続する クライアント、機能を公開する MCPサーバー です。トランスポートは主に2種類あります。
- stdio: ホストがサーバーをサブプロセスとして起動し、標準入出力でやり取りします。ローカル開発環境の用途では第一候補です。
- HTTP (SSE): Server-Sent Eventsを組み合わせたHTTP経由で接続します。チームで共有するサーバーやリモート運用で使います。
stdioでは 標準出力にログを書いてはいけません。プロトコルのメッセージと混ざって通信が壊れるためです。ログは標準エラー出力へ流します。自作の初期に最も踏みやすい落とし穴です。
Resources・Tools・Promptsの使い分けとAPI設計のベストプラクティス
MCPサーバーが公開する機能は、Resources、Tools、Promptsの3種類です。主な違いは「誰が呼び出しを制御するか」です。仕様上、Resourcesはアプリケーション側が文脈として取り込むデータ、Toolsはモデルが必要に応じて呼ぶ関数、Promptsはユーザーが明示的に選ぶテンプレートという位置づけです。
実務での振り分けは次のとおりです。
- Resources: DBスキーマ、設定の説明、APIのエンドポイント一覧など、読むだけで副作用のない参照情報
- Tools: 注文の検索、チケットの取得など、引数を受けて処理を実行する操作
- Prompts: 「この顧客の直近の注文を調査する」のような定型の依頼文
Tool設計では、DBに対する汎用の run_sql を作らず、用途を絞った find_orders のような粒度にします。汎用SQLを渡すと、モデルの誤判断やプロンプトインジェクションの影響範囲がDB全体に広がるためです。引数には型と範囲を明示し、説明文(docstring)にはモデルが使いどころを判断できる情報を書きます。戻り値は必要な列だけに絞ります。コンテキストを節約でき、不要な情報の露出も減ります。
公式SDKで作る最小限のローカルDB連携MCPサーバー実装
Python SDK (パッケージ名 mcp) の FastMCP を使い、SQLiteの注文テーブルを読み取り専用で公開する例です。ポイントは、DB接続を読み取り専用で開くこと、SQLをパラメータバインドで組み立てること、ログを標準エラーに出すことの3点です。
# server.py
import logging
import os
import sqlite3
import sys
from typing import Literal
from mcp.server.fastmcp import FastMCP
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
log = logging.getLogger("orders-db")
DB_PATH = os.environ["APP_DB_PATH"]
mcp = FastMCP("orders-db")
def connect() -> sqlite3.Connection:
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA query_only = ON")
return conn
@mcp.resource("schema://orders")
def orders_schema() -> str:
"""ordersテーブルのスキーマ定義"""
with connect() as conn:
row = conn.execute(
"SELECT sql FROM sqlite_master WHERE name = 'orders'"
).fetchone()
return row["sql"] if row else "not found"
@mcp.tool()
def find_orders(
customer_id: int,
status: Literal["pending", "paid", "shipped"] = "paid",
limit: int = 20,
) -> list[dict]:
"""顧客IDとステータスで注文を検索する。最大50件。"""
limit = max(1, min(limit, 50))
log.info("find_orders customer=%s status=%s", customer_id, status)
with connect() as conn:
rows = conn.execute(
"SELECT id, status, total_amount, created_at "
"FROM orders WHERE customer_id = ? AND status = ? "
"ORDER BY created_at DESC LIMIT ?",
(customer_id, status, limit),
).fetchall()
return [dict(r) for r in rows]
@mcp.prompt()
def investigate_customer(customer_id: int) -> str:
return f"顧客ID {customer_id} の直近の注文を find_orders で確認し、異常がないか要約してください。"
if __name__ == "__main__":
mcp.run(transport="stdio")
mode=ro と PRAGMA query_only を重ねているのは、片方が誤設定されても書き込みを防ぐためです。PostgreSQLやMySQLを使う場合も、アプリ用とは別の SELECT権限だけを持つDBロール を用意して接続します。
最小権限の原則で防ぐ:MCPサーバーにおける入力バリデーションと認可制御
MCPサーバーの入力は、モデルが生成した値です。モデルは、ユーザーの指示だけでなく取得した文書の内容にも影響されます。そのため、サーバー側では呼び出し元を信用せず、通常のWeb APIと同じ水準で検証します。前節の例では、Literal による列挙、limit の上限クランプ、パラメータバインドがその役割を担っています。
社内API連携では、接続先とパスをサーバー側で固定します。モデルにURLを渡させると、SSRFの入口になるためです。
import httpx
BASE_URL = "https://internal.example.com/api/v1"
TOKEN = os.environ["TICKET_API_TOKEN"] # 読み取りスコープのみのトークン
@mcp.tool()
def get_ticket(ticket_id: int) -> dict:
"""チケットIDから件名・状態・担当者を取得する。"""
if ticket_id <= 0:
raise ValueError("ticket_id must be positive")
r = httpx.get(
f"{BASE_URL}/tickets/{ticket_id}",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=5.0,
)
r.raise_for_status()
data = r.json()
return {k: data.get(k) for k in ("id", "title", "status", "assignee")}
認可制御は次の方針で整理します。
- 認証情報は環境変数や秘密情報管理から渡し、コードや設定ファイルのリポジトリに含めない
- トークンは読み取り専用スコープで発行し、MCPサーバー専用にする
- 書き込み系のToolは、読み取り系とは別のサーバーに分けるか、少なくとも別のクライアント設定にする
- 返却フィールドを絞り、個人情報や内部IDは必要なものだけにする
Toolのアノテーション(readOnlyHint など)は仕様上、あくまでクライアントへのヒントです。仕様でも信頼できないサーバーのアノテーションは信用しないよう注意されており、実際の防御はサーバーとバックエンドの権限で担保します。
HTTP (SSE) で公開する場合は、仕様のセキュリティ上の注意に従い、ローカル用途ならlocalhostにバインドし、Originヘッダーを検証します。チーム共有なら認証を必須にします。仕様はOAuthベースの認可を定めているため、独自トークン方式を作る前に最新の仕様を確認してください。
開発者ツール(CLI / IDE)との接続テストと運用のポイント
最初の動作確認には、公式の MCP Inspector が便利です。クライアントツールを介さずに、Toolの一覧や呼び出し結果、エラーを直接確認できます。
APP_DB_PATH=./dev.db npx @modelcontextprotocol/inspector python server.py
Inspectorで問題がなければ、実際のツールに登録します。Claude Codeでは claude mcp add で、VS Codeでは .vscode/mcp.json で、Claude Desktopでは設定JSONでstdioサーバーを登録できます。設定ファイルの項目名はツールとバージョンで異なるため、各ツールの公式ドキュメントで確認してください。共通するのは、起動コマンド、引数、環境変数の3点です。DBパスやトークンは環境変数で渡し、設定ファイルをリポジトリにコミットする場合は秘密情報を含めないようにします。
運用では次の点を押さえてください。
- Toolの数を絞る: 数が増えるとモデルの選択精度が下がり、説明文がコンテキストを圧迫します。
- 監査ログを残す: Tool名、引数、呼び出し時刻を標準エラーかファイルに記録し、不審な呼び出しを追跡できるようにします。
- 契約テストを書く: SDKのクライアントで
list_toolsと代表的なcall_toolを自動テストにし、スキーマの破壊的変更をCIで検出します。 - 接続先のサーバーを選別する: 第三者製のMCPサーバーは、Tool説明文に悪意ある指示が仕込まれる可能性があります。ソースを確認し、バージョンを固定してから導入します。
まずは読み取り専用の1サーバー・2〜3Toolから始め、実際の利用ログを見てから公開範囲を広げると、権限設計の見直しも小さく済みます。


