SaaS API の MCP ラッパーを書いて学んだこと
はじめに
Productboard の REST API を MCP(Model Context Protocol)サーバーとしてラップするプロジェクトを、TDD でゼロから書いていました。54 ツール、62 テスト、本体 1,001 行。技術的にはひととおり形になったと思います。
ただ、結論としては実用には至りませんでした。中核機能である Notes の作成 API が私の環境では一貫して動かず、CRUD の "C" が欠けた状態ではエージェントのワークフローが成立しません。
原因は自分のコードではなく、ラップ対象の API 側にありました。この記事を通じて伝えたいことは 2 つです。
- MCP 化は実装よりも「API の検品」が支配的 — 薄いラッパーだからこそ、API の品質がそのまま上限になる
- 宣言的データ設計には「消費側テスト」が必須 — 定義にフィールドを足しても、使う側が見ていなければ存在しないのと同じ
同じようなことを考えている方の参考になれば幸いです。
背景:25,000 行を 1,000 行で書き直す
出発点は cfdude/productboard-mcp という既存の MCP サーバーでした。AI で生成されたコードで「一応動くけど設計されていない」という状態です。
| 指標 | 旧コード | 書き直し後 |
|---|---|---|
| ソースコード行数 | 25,000+ | 1,001 |
any 型 |
283 箇所 | 0 |
| runtime deps | 2 (mcp-sdk + axios) | 1 (mcp-sdk) |
| エラーハンドリングパターン | 17 通り | 1 通り |
| ルーティング分岐 | 18 if/else | Map lookup |
| API に存在しないフェイクツール | 21 個 | 0 |
数字以上に深刻だったのは構造的な問題です。
-
name.includes()ルーティング →get_feature_statusesが features ハンドラーに誤ルーティング。ツール追加のたびに衝突リスクが増える -
McpError非継承のカスタムエラー階層 → 17 ハンドラー中 16 個でエラーコードが消失。クライアント側で診断不能 - Productboard API に存在しないフェイクツール 21 個 → API ラッパーの責務を超えた自作機能が混入
AI 生成コードでありがちな、部分的に動くが全体としては壊れているパターンでした。25,000 行を直すより、1,000 行で書き直したほうが早いと判断しました。
設計:ツールはデータであってコードではない
書き直しにあたって一番大事にした設計判断は、ツールを宣言的データとして定義するということです。
const tools: ToolDef[] = [
{
name: 'get_features',
method: 'GET',
path: '/features',
description: 'List all features',
inputSchema: { type: 'object', properties: { ... } },
},
{
name: 'create_feature',
method: 'POST',
path: '/features',
wrap: 'data',
description: 'Create a new feature',
inputSchema: { type: 'object', properties: { ... }, required: ['name'] },
},
// ... 54 ツール全てが同じ形式
];
ルーティングは Map<string, ToolDef> による O(1) lookup。汎用ハンドラーが method と path から HTTP リクエストを組み立て、パスパラメータを args から置換し、残りを query params(GET)または body(POST/PATCH)に振り分けます。
SaaS API のラッパーではエンドポイントの追加が頻繁に発生するので、「オブジェクトを 1 つ足すだけ」で済む構造は合理的だったと思います。ただし、この設計にも落とし穴がありました。それは後述します。
API の壁
実装自体は順調でした。問題は API 側にありました。ここが今回の記事の主題です。
仕様不一致:ドキュメントが嘘をつく
Feature の parent 指定。ドキュメントには { id, type } 形式と書いてあります。しかし実際にはネストしたオブジェクトが必要でした。
// ドキュメント通り → 動かない
{ "parent": { "id": "xxx", "type": "component" } }
// 実際に動く形式
{ "parent": { "component": { "id": "xxx" } } }
Release の state enum。ドキュメントに載っている値と、API が実際に受け付ける値が違います。
ドキュメント: "in_progress", "future"
実際の API: "in-progress", "upcoming", "completed", null
ページネーションのパラメータ名。ドキュメントでは limit / offset を示唆していますが、実際はカーソルベースで pageLimit / pageCursor を使います。さらに厄介なことに、/companies や /users では limit が偶然通ってしまいます。/features では 400 エラー。テストが通っているように見えて本番で壊れるパターンで、気づくのに少し時間がかかりました。
動かないエンドポイント:Notes の作成
create_note は私の環境(Pro プラン)では一貫して失敗しました。MCP 側ではエラーコード -32600、メッセージ Validation error として観測されています(SDK 層で変換された結果で、HTTP ステータスやエラーボディは未確認です)。
MCP error -32600: Validation error
HTML 形式にしたり、タグを付けたり、ASCII 限定にしたり、オプションフィールド(tags, ownerEmail, sourceOrigin)を足したり。何を試しても同じ結果です。いずれも他リソースでは「失敗→形式調整→成功」に収束したのに対し、Notes だけは同じ粒度で試しても失敗が固定でした。トークンは Settings > Integrations > Public API から発行したものですが、scope の詳細は管理画面上で確認できなかったため、トークン権限が原因である可能性も否定できません。
本来は HTTP ステータスとエラーボディをログに吐くガードを入れて切り分けるべきでしたが、記事時点では MCP 経由のエラー文字列までしか観測できていません。
一方、他のリソースは正常に動きます。たとえば create_feature の実レスポンスはこういう形式です。
// create_feature の成功レスポンス(ID等はマスク)
{
"data": {
"id": "27fad6e2-...",
"name": "提案UI/UX",
"description": "<p>...</p>\n",
"type": "feature",
"status": { "id": "0c36a91a-...", "name": "In progress" },
"parent": { "component": { "id": "e7584126-..." } },
"archived": false,
"createdAt": "2026-02-07T16:07:49.134Z"
}
}
Feature, Objective, Release, Component, Product — いずれも { data: { ... } } ラッパーで統一されたレスポンスが返ってきます。
54 ツールのうち、動作確認できたものとできなかったものの内訳です。
| 状態 | ツール数 | 例 | 備考 |
|---|---|---|---|
| CRUD 成立(確認できた範囲) | 20 | Features, Companies, Users, Releases 等 | ドキュメント乖離を回避すれば動く |
| 一貫して失敗 | 1 | create_note |
Validation error。原因切り分け不能 |
| プラン制限 | 数件 | 一部 Enterprise 機能 | 403 で明示的に拒否 |
| 未検証 | 残り | Webhooks, Plugin Integrations 等 | 動作環境を用意できず |
Notes は Productboard でユーザーフィードバックを管理する中核機能です。ここの Create が塞がれると、エージェントが「ユーザーの声を拾って Note に起こす」というワークフローが組めません。CRUD が揃うリソースは複数あるものの、一番使いたい操作が動かない時点で MCP サーバーとしての価値は大きく下がりました。
暗黙の仕様:エスケープ必須のフィールド
少なくとも description フィールドでは、プレーンテキストを受け付けず HTML でのラップが必要でした。加えて、< > を含む文字列を送ると 400 が返りやすく、内部的に HTML として解釈されているようです。エスケープまたはタグでのラップが前提になっていました。
// 400 エラー
"description": "response < 3s"
// OK
"description": "<p>response under 3s</p>"
有料プラン制限の不透明さ
Enterprise 限定の API は 403 で明示的に拒否されるのでまだ対処のしようがあります。問題は create_note のように、プラン制限なのかバグなのかトークン権限なのか、外から判断できないケースです。
MCP ラッパーを作る側としては、どのツールが「動く」のかを事前に確定できないのはなかなかつらいものがありました。
宣言的データ設計の落とし穴
今回のプロジェクトで設計上いちばん考えさせられたバグについて書きます。
ToolDef に wrap?: 'data' | 'none' フィールドを追加して、30 以上のツールに設定しました。Productboard API は POST/PATCH で { data: { ...body } } のラップを要求しますが、一部のエンドポイント(note tags 等)はラップ不要です。その制御のためのフィールドでした。
ところが server.ts のルーティング層が、このフィールドを一切参照していませんでした。
-
create_feature(POST +wrap:'data')は、client.tsが常に{ data: body }でラップしていたので偶然動く -
add_note_tag(POST +wrap:'none')では不要なラップが発生
修正自体は 1 行で済みました。
// 当時の運用: wrap:'none' は「data ラップしない」= body も送らない、で十分だった
const body = def.wrap === 'none' ? undefined : remaining;
補足しておくと、ここでの wrap:'none' は「data ラップしない」という意味で使っていますが、実装上は「ボディなし」として扱っています。正確には bodyStyle: 'raw' | 'dataWrapped' | 'omit' のような命名のほうが意図が明確だったと思います。
なぜ気づけなかったか
型チェックでは検出できません。 wrap は optional なので、参照しなくても型エラーにならないんですね。Biome の noExplicitAny も、optional フィールドの未参照までは拾ってくれません。
正常系のテストは通っていました。create_feature(wrap:'data')は偶然正しく動き、wrap:'none' のほとんどは DELETE(ボディなし)に集中していたため、POST で wrap:'none' という例外ケースのテストがなかったのが直接の原因です。
教訓
宣言的データ設計では、フィールドの定義と消費が別ファイルに分かれます。定義側にフィールドを足しても、消費側がそれを見ていなければ存在しないのと同じです。型チェックはこのギャップを検出してくれません。
対策としては、フィールドを追加したら必ず消費側のテストを書くこと。特に例外ケース(ここでは wrap:'none' の POST)は意図的に書かないとカバーできません。
これは MCP に限らず、宣言的な設定ファイルやデータ駆動の設計全般に当てはまる話だと思います。
MCP サーバー実装で踏んだ地雷
MCP SDK 特有の罠をいくつか挙げておきます。
ServerResult 型の互換性
setRequestHandler の戻り値は ServerResult 型(Zod $loose 由来)を要求します。名前付きインターフェースを返すと index signature の不足で型エラーになるため、分割代入でリテラルを返す必要があります。
// NG: 名前付き型を直接返す → 型エラー
server.setRequestHandler(CallToolRequestSchema, async (req) =>
callTool(req.params.name, req.params.arguments ?? {}),
);
// OK: 分割代入してリテラルで返す
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const { content } = await callTool(...);
return { content };
});
空レスポンスが沈黙する
ツールハンドラーが { content: [...] } 形式でない値を返すと、エラーにならず空レスポンスになります。SDK 側でバリデーションされないため、ハンドラーの返却値を検証するガードを自前で入れておくのが安全です。
// エラーにならない。ただ空が返る
return { status: 'ok', data: result };
// 正しい形式
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
process.cwd() はホスト側のディレクトリ
MCP サーバーはホストアプリの子プロセスとして起動されるため、process.cwd() はサーバー自身のディレクトリではありません。設定は環境変数で渡すのが無難です。
セキュリティ:送信先ドメインの固定
MCP サーバーは外部 API へのプロキシとして動作します。baseUrl が設定ミスや誤設定で書き換わると、Bearer トークンが意図しないサーバーに送信される可能性があります。誤設定によるトークン漏洩を防ぐために、送信先のホスト名を完全一致で検証しています。
const url = new URL(baseUrl);
const allowed = url.hostname === 'api.productboard.com';
if (!allowed) {
throw new Error(`Invalid baseUrl: ${url.hostname}`);
}
hostname.endsWith('productboard.com') のような末尾一致は evilproductboard.com を許してしまうため不十分です。サブドメインまで許可する必要があるケースでも、明示的なリスト(['api.productboard.com'])で管理するほうが安全だと思います。
次やるならこうする
同じことをもう一度やるなら、実装を始める前にこの手順を踏むと思います。
- 中核 3 操作を先に叩く — そのサービスで一番使いたい Create / Read / Update を curl で実行して、API が期待通り動くか確認する。今回でいえば notes create / feature create / release update の 3 つを最初に試すべきだった
- プラン差分を確かめる — 403 と 400(や 422)の判別がつくまで進めない。エラーメッセージからプラン制限かどうか分からない場合は、サポートに問い合わせてから実装に入る
- ドキュメントを信用しない — 少なくとも各リソースの CRUD を 1 回ずつ実際に叩いて、リクエスト/レスポンスの形式を確認する。特に enum 値とページネーション形式(cursor か offset か、パラメータ名は何か)
具体的には、こういうチェックを最初にやっておけばよかったと思います。
# 1. 中核操作の疎通確認
curl -X POST .../notes -d '{"data":{"title":"test","content":"hello"}}'
# → 成功するか? 422 か? 403 か?
# 2. ページネーション形式の確認
curl ".../features?pageLimit=1"
# → レスポンスに links.next があるか? pageCursor が含まれるか?
# 3. enum 値の確認
curl -X PATCH .../releases/{id} -d '{"data":{"state":"in-progress"}}'
# → docs の "in_progress" ではなく "in-progress" が正しいか?
MCP ラッパーは薄い層です。実装コストは低い。だからこそ、時間の大半は「API の検品」に使うのが正しい配分だったと今は思います。
おわりに
MCP サーバーの実装そのものは、宣言的ツール定義と汎用ハンドラーの組み合わせでシンプルに書けます。問題になるのはラップ対象の API 側です。
SaaS API を MCP でラップする前に確認しておくとよいと思う項目をまとめておきます。
- ドキュメントの信頼性 — リクエスト/レスポンスの形式が実装と一致しているか
- プラン制限の明示性 — どの機能がどのプランで使えるか、エラーメッセージから判別できるか
- エンドポイントの完全性 — 主要な CRUD が全て動作するか
- レスポンス形式の一貫性 — ページネーションやエラー形式がエンドポイント間で統一されているか
25,000 行を 1,001 行に書き直す過程で、API ラッパーというレイヤーの脆さを実感しました。それでも、宣言的データ設計の考え方やその落とし穴など、得られた知見はそれなりにあったので、こうして記事にまとめてみた次第です。
どなたかの参考になれば幸いです。
リポジトリ
git clone https://github.com/ebiyy/pb-mcp.git
cd pb-mcp
npm install
npm test # 62 テスト実行
npm run quality # typecheck + lint + test
knowledge/ ディレクトリに、この記事で触れた各トピックの詳細な技術メモを置いてあります。
付録:TypeScript / Node 実装メモ
本題からは外れますが、実装中に引っかかった点を簡潔に残しておきます。
Node 22+ の 204 レスポンス
Node 22 は fetch 仕様を厳密に検証するため、204 (No Content) にボディを付けると例外になります。テスト用モックでは status === 204 ? null : JSON.stringify(body) の分岐が必要です。
Vitest のモック変数シャドウイング
const fetch = vi.fn<typeof fetch>() は TDZ で自己参照になり any に推論されます。const fetchFn = vi.fn<typeof fetch>() のように変数名を変えれば回避できます。
Discussion