はじめに
前回まで作ってきた Todo MCP サーバー、実は致命的な問題があります。
誰でも繋げて、誰でも TODO を読み書きできる状態です。
app = mcp.streamable_http_app()
このまま uvicorn で起動すると、ポートにアクセスできる人なら誰でも addTodoItem や todos://list を叩けてしまいます。ローカルで動かして試す分にはいいですが、実運用やチーム共有を考えるなら認証は避けて通れません。
今回は前回までの Tools / Resources / Prompts 入りの MCP サーバーに、Bearer Token 認証を追加します。
対象者
- 前回までの記事で Todo MCP サーバーを作った人
- MCP サーバーを人に共有したい・実運用したい人
- MCP の認証まわりがどう実装されるのか知りたい人
今回やること・やらないこと
MCP の認証は仕様上 OAuth 2.1 がベースとして定義されていますが、いきなりフルの OAuth を実装するとそれだけで1記事終わってしまいます。
今回は最小構成として、固定の API キーを Bearer Token として検証するシンプルな認証を実装します。「とりあえず野ざらし状態を脱する」が目的です。
| やること | やらないこと |
|---|---|
| Bearer Token によるリクエスト検証 | OAuth 2.1 のフルフロー |
| 未認証リクエストの 401 拒否 | トークンの発行・リフレッシュ |
| クライアント側でのトークン送信 | ユーザーごとの権限管理 |
1. MCP サーバー側に認証を追加する
トークンを検証するミドルウェアを書く
mcp-server/main.py の streamable_http_app() を呼んでいる箇所の前に、認証チェックを挟みます。
import os
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
API_TOKEN = os.environ.get("MCP_API_TOKEN", "dev-secret-token")
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return JSONResponse(
{"error": "Unauthorized"}, status_code=401
)
token = auth_header.removeprefix("Bearer ").strip()
if token != API_TOKEN:
return JSONResponse(
{"error": "Invalid token"}, status_code=401
)
return await call_next(request)
FastMCP は内部的に Starlette アプリとして動いているので、Starlette のミドルウェアがそのまま使えます。
ミドルウェアを組み込む
app = mcp.streamable_http_app()
app.add_middleware(AuthMiddleware)
トークンは環境変数から読む形にして、ハードコードを避けます。
export MCP_API_TOKEN="your-secret-token-here"
uv run uvicorn main:app --port 3001 --reload
2. テストクライアント側でトークンを送る
クライアント側は streamablehttp_client の呼び出しにヘッダーを足すだけです。
import os
MCP_URL = "http://localhost:3001/mcp"
API_TOKEN = os.environ.get("MCP_API_TOKEN", "dev-secret-token")
headers = {"Authorization": f"Bearer {API_TOKEN}"}
async with streamablehttp_client(MCP_URL, headers=headers) as (read, write, _):
...
クライアント側も同じ環境変数を見るようにしておけば、トークンを2箇所に書かずに済みます。
export MCP_API_TOKEN="your-secret-token-here"
uv run main.py
3. 動作確認
トークンなしでアクセスする
ヘッダーを外した状態、もしくは間違ったトークンで叩くと弾かれます。
curl -X POST http://localhost:3001/mcp \
-H "Content-Type: application/json" \
-d '{}'
{"error": "Unauthorized"}
ステータスコードも 401 が返ってきているのを確認してください。
正しいトークンでアクセスする
テストクライアントを環境変数つきで起動すると、前回までと同じように Tools / Resources / Prompts が使えます。
=== MCP Todo クライアント ===
操作を選んでください:
1. ツール一覧を見る
...
トークンが一致していれば、ミドルウェアを意識することなく今まで通り操作できます。
ハマりがちなポイント
Claude Desktop など外部クライアントから繋ぐ場合
外部クライアントの設定ファイルでも、ヘッダーを渡せる設定項目があるか確認が必要です。設定で渡せない場合は、リバースプロキシ側(nginx など)でヘッダーを付与する構成にするのが現実的です。
トークンをコードに直接書かない
API_TOKEN = "dev-secret-token" のようにデフォルト値を残しておくと、環境変数の設定を忘れたまま本番にデプロイしてしまうリスクがあります。本番運用するなら、環境変数が未設定の場合は起動自体を失敗させる方が安全です。
API_TOKEN = os.environ["MCP_API_TOKEN"] # 未設定なら起動時に例外で落ちる
4. Todo API(FastAPI)側にも認証をつける
ここまでで MCP サーバー(3001番)は守れましたが、実はまだ穴が残っています。
Todo API(8080番)に直接アクセスすれば、認証なしで読み書きできてしまいます。
# MCP サーバーを経由せず直接叩ける
curl http://localhost:8080/todos
MCP サーバーがいくら認証で守られていても、その先の API が無防備なら意味がありません。「クライアント → MCP サーバー → Todo API」の2段階、両方を守る必要があります。
クライアント
↓ Bearer Token①(MCP サーバー宛)
MCP サーバー(port 3001)★前回ここを保護
↓ Bearer Token②(Todo API 宛)
Todo API(port 8080)★今回ここを保護
↓
SQLite
FastAPI 側にトークン検証を追加する
api/main.py に検証用の依存関数を追加します。
import os
from fastapi import Depends, Header, HTTPException
API_TOKEN = os.environ["TODO_API_TOKEN"]
def verify_token(authorization: str = Header(None)):
if authorization is None or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Unauthorized")
token = authorization.removeprefix("Bearer ").strip()
if token != API_TOKEN:
raise HTTPException(status_code=401, detail="Invalid token")
各エンドポイントに Depends(verify_token) を足すだけで保護できます。
@app.get("/todos")
def get_todos(_: None = Depends(verify_token)):
...
@app.post("/todos")
def create_todo(body: TodoCreate, _: None = Depends(verify_token)):
...
@app.put("/todos/{todo_id}")
def update_todo(todo_id: int, body: TodoUpdate, _: None = Depends(verify_token)):
...
@app.delete("/todos/{todo_id}")
def delete_todo(todo_id: int, _: None = Depends(verify_token)):
...
起動時にトークンを渡します。
export TODO_API_TOKEN="todo-api-secret"
uv run uvicorn main:app --port 8080 --reload
MCP サーバー側からトークンつきでリクエストする
MCP サーバーは Todo API に対しては「クライアント」の立場になるので、httpx でリクエストする際にヘッダーをつけます。
import os
TODO_API_TOKEN = os.environ["TODO_API_TOKEN"]
TODO_API_HEADERS = {"Authorization": f"Bearer {TODO_API_TOKEN}"}
既存の addTodoItem などのリクエストすべてにヘッダーを足します。
@mcp.tool(description="新しい TODO を追加する")
async def addTodoItem(title: str) -> str:
async with httpx.AsyncClient() as client:
await client.post(
f"{API_BASE}/todos",
json={"title": title},
headers=TODO_API_HEADERS,
)
return f"{title} を追加しました"
deleteTodoItem / updateTodoItem / Resource の get_todos_resource も同様に headers=TODO_API_HEADERS を足してください。
動作確認
Todo API に直接、トークンなしでアクセスすると弾かれます。
curl http://localhost:8080/todos
{"detail": "Unauthorized"}
MCP サーバー経由(クライアント → MCP サーバー → Todo API、トークン①②ともに正しい)であれば、今まで通り操作できます。
まとめ
| 層 | Before | After |
|---|---|---|
| クライアント → MCP サーバー | 誰でも接続可能 | Bearer Token① で保護 |
| MCP サーバー → Todo API | 誰でも直接叩ける | Bearer Token② で保護 |
「MCP サーバーだけ守って満足」になりがちですが、その先の実 API が素通しだと意味がありません。MCP はあくまで AI との橋渡し役で、データを最終的に守っているのは API 側だという意識が大事です。
今回はシンプルな固定トークン方式でしたが、複数人で使う・ユーザーごとに権限を変えるとなると OAuth 2.1 のフローが必要になってきます。そのあたりはまた別記事でまとめる予定です。
