はじめに:REST APIがあるのに、なぜMCPが必要なのか
REST APIは、プログラムがシステムを操作するための優れたインターフェースです。それでは、既存APIがある場合、AIにもそのAPIを直接呼ばせれば十分なのでしょうか。
技術的には、OpenAPI定義などをAIへ渡してHTTPリクエストを生成させる方法もあります。しかしAIクライアントが「利用可能な操作」「必要な引数」「戻り値の形」を共通の方法で発見し、実行するには、そのためのインターフェースが必要です。
MCPのToolは、名前、説明、入力Schemaをクライアントへ公開し、tools/callによって実行されます。MCPのTools仕様では、Tool定義に説明とJSON Schemaベースの入力定義が含まれます。
この記事で伝えたい中心メッセージは、次の一文です。
MCPはREST APIを置き換えるものではなく、AIクライアントが既存APIを利用するためのインターフェースを追加するものである。
MCPサーバーへ保存処理や業務ルールを複製するのではなく、既存REST APIのAI向けアダプターとして薄く実装します。
この記事と対応する実行可能な完成コードは、GitHubのtask-api-mcpリポジトリで公開しています。以降のファイル名、コマンド、テストはこのリポジトリの実装に合わせています。
1. 今回作るシステムの全体像
ユーザーは自然言語でタスク作成を依頼します。AIクライアントは依頼を構造化し、stdioでMCPサーバーのcreate_task Toolを呼びます。MCPサーバーはHTTPリクエストへ変換し、既存REST APIへ送信します。
flowchart LR
accTitle: タスク作成システムの全体構成
accDescr: ユーザーの自然言語による依頼をAIクライアントがMCP Tool呼び出しへ変換し、MCPサーバーがREST APIへHTTPリクエストを送り、APIがJSONファイルへ保存する。
User[ユーザー] -->|自然言語| AI[AIクライアント]
AI -->|MCP / stdio| MCP[MCPサーバー]
MCP -->|HTTP POST /tasks| API[タスク管理REST API]
API --> JSON[(data/tasks.json)]システム全体のデータフロー
各コンポーネントの責務は次のとおりです。
コンポーネント | 責務 |
|---|---|
AIクライアント | 自然言語をToolの引数へ変換する |
MCPサーバー | Toolを公開し、MCPとHTTPの間を変換する |
REST API | 業務ルールを検証し、タスクを作成する |
JSONファイル | タスクを永続化する |
実際の呼び出しでは、2種類の通信が連続して発生します。AIクライアントとMCPサーバーの間はMCP、MCPサーバーと既存システムの間はHTTPです。
sequenceDiagram
accTitle: create_task Toolの実行順序
accDescr: ユーザーの依頼を受けたAIクライアントがcreate_taskを呼び、MCPサーバーが入力を検証してREST APIへ送信し、APIがtasks.jsonへ保存した結果を同じ経路で返す。
actor User as ユーザー
participant AI as AIクライアント
participant MCP as MCPサーバー
participant API as REST API
participant File as tasks.json
User->>AI: 明日までのタスクを作成して
AI->>MCP: tools/call create_task
MCP->>MCP: 入力Schemaを検証
MCP->>API: POST /tasks
API->>File: タスクを保存
File-->>API: 保存完了
API-->>MCP: 201 Created
MCP-->>AI: 構造化された作成結果
AI-->>User: タスクを作成しましたcreate_taskの実行シーケンス
ここで重要なのは、MCPサーバーを新しい業務システムにしないことです。タスク作成の正規の入り口は、引き続きREST APIです。
2. 既存タスク管理REST APIを確認する
既存APIには次のエンドポイントがあるものとします。
POST /tasks
GET /tasks
GET /tasks/{task_id}この記事でMCP Toolとして公開するのはPOST /tasksに対応するcreate_taskだけです。
リクエスト例は次のとおりです。
{
"title": "MCPの記事を公開する",
"description": "図とコードを最終確認する",
"priority": "high",
"due_date": "2026-07-16"
}成功時には201 Createdと作成済みタスクを返します。
{
"id": "b48b8da3-f80b-40e3-a78d-8d70b11e58c4",
"title": "MCPの記事を公開する",
"description": "図とコードを最終確認する",
"priority": "high",
"due_date": "2026-07-16",
"status": "todo",
"created_at": "2026-07-15T21:30:00+09:00"
}REST API側では、タイトルの必須・文字数、説明文の最大長、優先度、日付形式、過去日付の禁止などを検証します。この検証はMCPサーバーを追加した後も残します。CLI、Web UI、バッチ処理など、MCP以外の呼び出し元にも同じルールを適用するためです。
MCP実装を始める前に、API単体で確認しておきます。
curl -X POST http://localhost:8000/tasks \
-H "Content-Type: application/json" \
-d '{
"title": "MCPの記事を公開する",
"priority": "high",
"due_date": "2026-07-16"
}'ここで失敗する場合は、先にREST APIを修正します。MCPを介したデバッグと既存APIのデバッグを同時に始めると、障害箇所を切り分けにくくなります。
3. REST APIをMCP化する設計
create_taskの内部から直接data/tasks.jsonを書き換える実装は、一見すると簡単です。しかし、次の問題が生じます。
- RESTとMCPで保存処理が重複する
- バリデーションや業務ルールが二重管理になる
- 認証、監査ログ、レート制限を迂回する可能性がある
- JSONからDBへ移行したときに両方を修正する必要がある
- 呼び出し経路によって結果が変わり得る
そこで、MCP ToolはTaskApiClient.create_task()を呼び、そのクライアントがPOST /tasksを実行する三層構造にします。
Toolが担当するのは、MCPの入力と出力、および意味のあるエラーへの変換だけです。タスクを作成できるかという最終判断はREST APIへ残します。
REST APIとMCPサーバーが同じコードベースにあり、同一プロセスで動かすなら、ControllerとToolから共通のService層を呼ぶ設計も可能です。ただし今回は「既存システムを外部からMCP化する」という条件を明確にするため、HTTP境界を維持します。
4. 公開リポジトリからプロジェクトを準備する
完成版はtask-api-mcpリポジトリから取得できます。Python 3.10以上とuvを用意し、次の手順で依存関係を再現します。Inspectorを使う場合だけNode.js 18以上、Codexで試す場合だけCodex CLIも必要です。
git clone https://github.com/yunosuke-github/task-api-mcp.git
cd task-api-mcp
uv sync --devuv.lockが含まれているため、記事と同じ検証済みの依存関係をインストールできます。2026年7月16日時点では、MCP Python SDKの安定版は1.28.1で、v2はプレリリースです。本サンプルはv1系の挙動を固定するためmcp[cli]==1.28.1を指定しています。PyPIと公式v1.xリポジトリも併せて確認してください。
パッケージ設定にはREST APIとMCPサーバーの実行コマンドも定義しています。
[project]
name = "task-api-mcp"
version = "0.1.0"
description = "A local task REST API with a thin MCP adapter"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
dependencies = [
"fastapi>=0.115,<1",
"httpx>=0.27,<1",
"mcp[cli]==1.28.1",
"pydantic>=2.10,<3",
"uvicorn>=0.30,<1",
]
[project.scripts]
task-api = "task_api.app:main"
task-mcp = "task_mcp.server:main"
[dependency-groups]
dev = [
"pytest>=8.3,<9",
"pytest-asyncio>=0.24,<2",
]リポジトリは、REST APIとMCPアダプターを別パッケージに分けています。
task-api-mcp/
├── data/tasks.json
├── src/
│ ├── task_api/
│ │ ├── app.py
│ │ ├── models.py
│ │ └── repository.py
│ └── task_mcp/
│ ├── api_client.py
│ ├── errors.py
│ ├── models.py
│ └── server.py
├── tests/
├── .env.example
├── pyproject.toml
└── uv.locktask_apiは入力検証とJSON永続化を担当し、task_mcpはToolとHTTPの変換だけを担当します。MCPサーバーはdata/tasks.jsonを直接操作しません。
接続先、タイムアウト、保存先は環境変数へ分離しています。
TASK_API_BASE_URL=http://localhost:8000
TASK_API_TIMEOUT_SECONDS=10
TASK_API_DATA_FILE=data/tasks.json.envを使う場合は、uvへ明示的に渡します。
cp .env.example .env
uv run --env-file .env task-api
# 別ターミナルでMCPサーバーを直接起動する場合
uv run --env-file .env task-mcp5. REST APIクライアントを実装する
HTTP固有の処理をToolから分離し、REST APIの結果を3種類のアプリケーションエラーへ変換します。
"""Application-level errors raised by the task REST API client."""
class TaskApiValidationError(Exception):
"""The API rejected a request that the caller can correct."""
class TaskApiUnavailableError(Exception):
"""The API could not be reached or returned a temporary failure."""
class TaskApiUnexpectedError(Exception):
"""The API returned an unexpected status or response body."""httpxの例外や生のレスポンス本文をToolへ漏らさず、モデルが次の行動を判断できる意味へ変換します。
"""Async HTTP client used by the MCP adapter."""
from typing import Any
import httpx
from .errors import (
TaskApiUnexpectedError,
TaskApiUnavailableError,
TaskApiValidationError,
)
from .models import CreateTaskRequest, TaskResult
class TaskApiClient:
"""Translate HTTP outcomes from the task API into domain-level errors."""
def __init__(
self,
base_url: str,
timeout_seconds: float,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
self._base_url = base_url.rstrip("/")
self._timeout = timeout_seconds
self._transport = transport
async def create_task(self, request: CreateTaskRequest) -> TaskResult:
"""Create a task through the REST API and validate its response."""
try:
async with httpx.AsyncClient(
base_url=self._base_url,
timeout=self._timeout,
transport=self._transport,
) as client:
response = await client.post(
"/tasks",
json=request.model_dump(mode="json"),
)
except (httpx.TimeoutException, httpx.RequestError) as exc:
raise TaskApiUnavailableError from exc
if response.status_code == 201:
try:
return TaskResult.model_validate(response.json())
except (ValueError, TypeError) as exc:
raise TaskApiUnexpectedError from exc
if response.status_code in {400, 409, 422}:
raise TaskApiValidationError(self._safe_detail(response))
if response.status_code >= 500:
raise TaskApiUnavailableError
raise TaskApiUnexpectedError
@staticmethod
def _safe_detail(response: httpx.Response) -> str:
"""Extract only a bounded, user-correctable API error message."""
try:
body: Any = response.json()
except ValueError:
return "The task request was rejected."
detail = body.get("detail") if isinstance(body, dict) else None
if isinstance(detail, str):
return detail[:300]
return "The task request was rejected."明示したタイムアウト、201レスポンスの出力検証、修正可能な4xx、一時障害の5xx、想定外レスポンスを分けています。APIのdetailは文字列の場合だけ最大300文字まで採用し、配列や内部構造はそのまま返しません。
6. MCPサーバーを初期化する
v1.28.1ではFastMCPを次の場所からインポートします。
from mcp.server.fastmcp import FastMCP
mcp = FastMCP(
name="task-manager",
json_response=True,
)FastMCPは、関数の型ヒントとdocstringからTool定義を生成します。Pydanticモデルなど互換性のある戻り値型を指定すれば、構造化出力のSchemaも生成できます。v1.xの公式READMEには、FastMCPの初期化、@mcp.tool()、Inspectorの利用例が掲載されています。
サーバー名、Tool名、docstringには異なる役割があります。
- サーバー名は、クライアント上で接続先を識別する
- Tool名は、モデルが実行する操作を識別する
- docstringは、いつ何のために使う操作かを説明する
- 引数descriptionは、各値の意味や制約を説明する
AIによる選択精度を高めるには、createやexecuteではなく、対象を含むcreate_taskのような名前を使います。
7. create_task Toolを実装する
MCP境界で使う入出力モデルを定義します。REST API側にも独立したモデルがあり、最終的な業務ルールはそちらで検証します。
"""Models at the boundary between MCP and the task REST API."""
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel
Priority = Literal["low", "medium", "high"]
class CreateTaskRequest(BaseModel):
"""JSON body sent to ``POST /tasks``."""
title: str
description: str | None = None
priority: Priority = "medium"
due_date: date | None = None
class TaskResult(BaseModel):
"""Structured task returned to the MCP client."""
id: str
title: str
description: str | None = None
priority: Priority
due_date: date | None = None
status: str
created_at: datetimeToolの引数はモデルが組み立てやすいフラットな形にし、各引数の制約と説明をSchemaへ載せます。次は公開リポジトリのserver.py全体です。
"""stdio MCP server exposing the task API as a model-friendly tool."""
import logging
import os
import sys
from datetime import date
from typing import Annotated
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.exceptions import ToolError
from pydantic import Field
from .api_client import TaskApiClient
from .errors import (
TaskApiUnexpectedError,
TaskApiUnavailableError,
TaskApiValidationError,
)
from .models import CreateTaskRequest, Priority, TaskResult
logger = logging.getLogger(__name__)
def _timeout_seconds() -> float:
raw_timeout = os.getenv("TASK_API_TIMEOUT_SECONDS", "10")
try:
timeout = float(raw_timeout)
except ValueError as exc:
raise RuntimeError("TASK_API_TIMEOUT_SECONDS must be a number.") from exc
if timeout <= 0:
raise RuntimeError("TASK_API_TIMEOUT_SECONDS must be greater than zero.")
return timeout
mcp = FastMCP(name="task-manager", json_response=True)
client = TaskApiClient(
base_url=os.getenv("TASK_API_BASE_URL", "http://localhost:8000"),
timeout_seconds=_timeout_seconds(),
)
@mcp.tool()
async def create_task(
title: Annotated[
str,
Field(
min_length=1,
max_length=100,
description="Short task title.",
),
],
description: Annotated[
str | None,
Field(
max_length=1000,
description="Optional details about the task.",
),
] = None,
priority: Annotated[
Priority,
Field(description="Task priority: low, medium, or high."),
] = "medium",
due_date: Annotated[
date | None,
Field(description="Optional due date in YYYY-MM-DD format."),
] = None,
) -> TaskResult:
"""Create one task in the task management service."""
request = CreateTaskRequest(
title=title,
description=description,
priority=priority,
due_date=due_date,
)
try:
return await client.create_task(request)
except TaskApiValidationError as exc:
raise ToolError(f"The task could not be created: {exc}") from exc
except TaskApiUnavailableError as exc:
raise ToolError(
"The task service is temporarily unavailable. Try again later."
) from exc
except TaskApiUnexpectedError as exc:
logger.exception("Unexpected task API response")
raise ToolError("The task service returned an unexpected response.") from exc
def main() -> None:
"""Run the MCP server over stdio without writing logs to stdout."""
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
mcp.run(transport="stdio")
if __name__ == "__main__":
main()_timeout_seconds()は環境変数を起動時に検証し、0以下や数値でない値を早期に拒否します。main()はログをstderrへ向け、stdioのstdoutを汚しません。Tool本体は入力の変換、APIクライアント呼び出し、構造化結果または安全なToolErrorの返却だけを担当します。
8. 入力Schemaとバリデーションを設計する
型ヒントとFieldから生成される入力Schemaは、概ね次の形になります。実際の表現はSDKとPydanticのバージョンで細部が変わるため、Inspectorで最終確認してください。
{
"type": "object",
"properties": {
"title": {
"type": "string",
"minLength": 1,
"maxLength": 100
},
"description": {
"anyOf": [{"type": "string", "maxLength": 1000}, {"type": "null"}],
"default": null
},
"priority": {
"type": "string",
"enum": ["low", "medium", "high"],
"default": "medium"
},
"due_date": {
"anyOf": [{"type": "string", "format": "date"}, {"type": "null"}],
"default": null
}
},
"required": ["title"]
}バリデーションは二種類に分けて考えます。
種類 | 例 | 主な検証場所 |
|---|---|---|
形式的な検証 | 必須、型、日付形式、enum、文字数 | MCP SchemaとREST API |
業務ルール | 過去日付、重複、作成数の上限 | REST API |
MCP側の検証は、不要なHTTP呼び出しを減らし、モデルへ早くフィードバックするために役立ちます。ただしREST API側の検証を削除してはいけません。MCPは唯一の呼び出し元ではないからです。
エラーは、モデルが次の操作を判断できる具体性を持たせます。
The task could not be created: due_date must be today or later.Validation failedだけでは、どの値を直すべきか分かりません。
9. 成功結果とエラー結果を返す
成功時には文章だけでなくTaskResultを返します。これにより、AIは作成結果をユーザーへ説明できるだけでなく、後続処理でid、status、due_dateなどを利用できます。
MCP仕様は、Toolの失敗を二種類に分けています。
- 存在しないToolや不正なリクエスト構造はプロトコルエラー
- API失敗、入力値、業務ルールの失敗は
isError: trueのTool Execution Error
後者にはモデルが自己修正できる情報を含めることが推奨されています。Tools仕様のエラー処理を参照してください。v1 SDKにはToolErrorが定義されており、Tool内の想定内エラーを表現できます。v1.xの例外定義も確認できます。
REST APIの状態 | MCP側の扱い |
|---|---|
400 / 422 | 修正可能な入力エラー |
409 | 重複などの業務エラー |
タイムアウト・接続失敗 | 一時的な利用不能 |
500系 | 一時的なサーバー障害 |
不正JSON・未知の状態 | 汎用エラーを返し、詳細はログへ記録 |
モデルへ返すメッセージには、スタックトレース、ローカルパス、APIキー、内部ホスト名、生のレスポンス、ライブラリ例外全文を含めません。原因調査に必要な詳細はstderr側のログへ残します。
10. stdioで起動してInspectorから確認する
cloneしたリポジトリのルートでREST APIを起動します。
uv run task-apiAPIはhttp://localhost:8000で待ち受けます。ブラウザでhttp://localhost:8000/docsを開くとSwagger UIも確認できます。コード変更時の自動リロードが必要なら、代わりに次を使います。
uv run uvicorn task_api.app:app --reload --port 8000APIを起動したまま、別ターミナルからMCP Inspectorを起動します。
TASK_API_BASE_URL=http://localhost:8000 \
npx -y @modelcontextprotocol/inspector \
uv run python -m task_mcp.serverInspectorでは次を確認します。
create_taskがTool一覧に表示されるtitleだけが必須で、1〜100文字になっているpriorityがlow、medium、highのenumになっているdue_dateが日付として表現される- 正常なタスクを作成でき、
data/tasks.jsonへ保存される - 過去日付とAPI停止時に、安全で修正可能なエラーが返る
stdioではクライアントとサーバーがstdin/stdoutでJSON-RPCを交換するため、通常の出力をstdoutへ書けません。公開実装はlogging.basicConfig(..., stream=sys.stderr)を使い、診断ログをプロトコルから分離しています。Transport仕様も参照してください。
11. AIクライアントから実際に呼び出す
リポジトリのルートで、stdioサーバーをCodex CLIへ登録します。
codex mcp add task-manager \
--env TASK_API_BASE_URL=http://localhost:8000 \
--env TASK_API_TIMEOUT_SECONDS=10 \
-- uv run python -m task_mcp.server登録結果はcodex mcp list、Codex TUIでは/mcpから確認できます。CodexのMCP公式ドキュメントも参照してください。
次のように依頼してみます。
明日までに「MCPの記事を公開する」というタスクを作成してください。
優先度はhighにしてください。モデルは自然言語を次のようなTool引数へ変換します。
{
"title": "MCPの記事を公開する",
"description": null,
"priority": "high",
"due_date": "2026-07-17"
}due_dateは日付だけを受け取ります。時刻まで必要なら、REST APIとToolの両方にdatetimeまたは時刻フィールドを設計してください。MCPアダプターだけでAPIが表現できない情報を暗黙に保存してはいけません。
最後にdata/tasks.jsonを確認し、自然言語の依頼がREST APIを経由して保存されたことを確かめます。
cat data/tasks.json12. 自動テストを実行する
公開リポジトリにはREST API、HTTPクライアント、MCP Toolの3層を確認するテストが含まれています。
uv run pytestコミット2b343d2では全26テストが成功します。テストは一時ディレクトリとhttpx.MockTransportを使うため、起動中のAPIを必要とせず、手元のdata/tasks.jsonも変更しません。
たとえばMCP層では、実際に公開される入力Schemaを取得して制約を検証します。
import pytest
from task_mcp import server
@pytest.mark.asyncio
async def test_create_task_schema_is_model_friendly() -> None:
tools = await server.mcp.list_tools()
create_task_tool = next(tool for tool in tools if tool.name == "create_task")
schema = create_task_tool.inputSchema
assert schema["required"] == ["title"]
assert schema["properties"]["title"]["minLength"] == 1
assert schema["properties"]["title"]["maxLength"] == 100
assert schema["properties"]["priority"]["enum"] == [
"low",
"medium",
"high",
]テスト対象は次の3段階です。
- REST API:正常作成、空白除去、未知フィールド、過去日付、404、保存障害
- APIクライアント:201、422、タイムアウト、接続失敗、5xx、不正JSON、未知のHTTP状態
- MCP Tool:構造化結果、Schema、検証エラー、一時障害、想定外レスポンスの
ToolError変換
すべてのテストと最新の実装はGitHubリポジトリで確認できます。
13. 実運用に向けて考えること
認証情報はTool引数にしない
実運用ではAPIキー、Bearer Token、ユーザーID、テナントID、トレースIDなどをREST APIへ渡す場合があります。認証情報をAIが生成するTool引数に含めず、MCPサーバーの環境変数、シークレットストア、または認証済みセッションから取得してください。
作成系Toolは二重実行へ備える
AIクライアントや通信層で再試行が起きる可能性があります。REST API側でIdempotency-Key、リクエストID、重複検出、監査ログなどを検討します。MCP側だけで重複を防ぐと、別のAPIクライアントには効果がありません。
stdioとStreamable HTTPを使い分ける
stdioは、ローカルのAIクライアントがローカルプロセスを起動する構成に適しています。複数ユーザーがネットワーク越しに利用する場合は、Streamable HTTP、認証、セッション管理、Origin検証などが必要です。本記事ではそのリモート構成を扱いません。
RESTエンドポイントを機械的にTool化しない
REST APIが次の操作を持っていても、すべてを同じ粒度でToolにする必要はありません。
POST /tasks
GET /tasks
GET /tasks/{id}
PATCH /tasks/{id}
DELETE /tasks/{id}AIの利用目的に合わせて、次のような操作単位に再設計できます。
create_task
search_tasks
complete_taskREST APIはシステムのリソースと業務境界を表現します。一方、MCP Toolはモデルが理解しやすく、安全に実行できる操作単位を表現します。特に削除や外部送信などの高リスク操作では、Toolを分離し、説明、承認、監査を設計する価値があります。
まとめ
既存REST APIをMCP対応にするために、APIを削除したり、AI専用の保存処理を作り直したりする必要はありません。
MCPサーバーはAIクライアント向けの薄いアダプターとして追加できます。Toolでは入力をREST API用のリクエストへ変換し、構造化された結果を返し、APIの失敗をモデルが理解できる安全なエラーへ変換します。業務ルールと永続化は既存REST APIへ残します。
この責務分担なら、REST APIを利用する既存クライアントを維持しながら、自然言語という新しい操作経路を追加できます。ローカルのstdio構成で確認した後は、認証、冪等性、監査ログ、Streamable HTTPへ段階的に拡張できます。
まずは公開サンプルをcloneし、REST API、Inspector、Codex、pytestの順に動かして、境界ごとの責務を確認してみてください。