Cloud Functions だけでリモート MCP サーバーを作った
Claude Code に「今日のタスクを DODO に送って」と頼むと、iPhone の画面にそのまま候補が並ぶ。これを、常に動いているサーバーを1台も持たずに作った。使ったのは Firebase の Cloud Functions・Firestore・Authentication・Hosting だけ。
DODO は自分で作っている iPhone の集中アプリで、「今やること」を一言入れて Do を押すだけのもの。PC や AI エージェントで整理したやることを、そのまま iPhone に流し込みたかった。その入り口をリモート MCP にした話を書く。
全体像
Claude Code などの AI エージェント
│ MCP(Streamable HTTP)/ REST Authorization: Bearer dodo_xxx
▼
https://api.dodo.miyashi.app ← Firebase Hosting(独自ドメイン)
│ rewrites: ** → api
▼
Cloud Functions(api, asia-northeast1)
│ トークン → uid を引いて、その人の所にだけ書く
▼
Firestore users/{uid}/lists/{id}
│ リアルタイムに受け取る(読むだけ)
▼
iPhone アプリ(Sign in with Apple → Firebase Auth)
ポイントは3つ。
- サーバーレス。Functions は呼ばれたときだけ立ち上がる。サーバーの用意も OS の更新もいらず、使った分だけ課金される。
- ユーザーごとに分けるのは Sign in with Apple。利用者は Google にログインしなくていい。
- AI エージェントは、アプリで発行した API トークンだけを持つ。
なぜサーバーレスで MCP が動くのか
最初は「MCP って常時つなぎっぱなしのサーバーが要るのでは」と思っていた。でもリモート MCP の Streamable HTTP は、中身はただの HTTP POST(JSON-RPC)だ。セッションを持たない形にすれば、1 リクエストで完結する。呼ばれるたびに立ち上がって消える Functions と、相性がいい。
💡 MCP の TypeScript SDK で sessionIdGenerator: undefined にすると、状態を持たないモードになる。リクエストごとにサーバーを作って捨てればいい。
app.post("/mcp", apiAuth, async (req, res) => {
const server = mcpServer(req.uid!);
// 状態を持たないモード(リクエストごとに作って捨てる)
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => {
void transport.close();
void server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
// 状態を持たないモードでは GET(SSE)と DELETE(セッション終了)は使わない
app.get("/mcp", (_req, res) => res.status(405).set("Allow", "POST").json({ error: "method not allowed" }));
app.delete("/mcp", (_req, res) => res.status(405).set("Allow", "POST").json({ error: "method not allowed" }));
export const api = onRequest({ region: "asia-northeast1", cors: false, maxInstances: 3 }, app);
Express のアプリを onRequest に渡すだけ。同じ Functions の中に、REST の /v1/suggestions も並べている。
ツールは3つにした。
| ツール | やること |
|---|---|
add_do_suggestions | やることの一式を送る |
list_do_suggestions | 今アプリに出ている一式を見る |
clear_do_suggestions | サジェストを空にする |
AI 向けの決まりは、MCP の説明文に書く
送り方の決まりは、サーバーの instructions とツールの説明に英語で書いた。AI エージェントはこれを読んでから呼んでくれる。
- 送るたびに新しい一式になり、アプリには一番新しい一式だけが出る。だから見せたいものをまとめて送る。
- 名前は 30 文字まで(ロック画面の1行に収まる長さ)。短い動詞句にする。
- 個人情報を入れない。「田中さんに返信」ではなく「取引先に返信」と書く。
- 1 分に 10 回、1 日に 500 回まで。小分けに何度も送らない。
文字数の上限は、説明に書くだけでなく、入力の型でも弾いている。
Sign in with Apple でユーザーを分ける
流れ
- アプリで Sign in with Apple → Firebase Auth にサインインして uid を作る。
- アプリが Firebase の ID トークン付きで
POST /v1/tokensを呼び、API トークンを発行する。 - 利用者は、画面に出る「Claude Code に貼る文」をコピーして、Claude Code に渡す。
- AI エージェントは API トークンで MCP を呼ぶ。サーバーはトークンから uid を引き、その人の所にだけ書く。
Firebase Authentication の Apple プロバイダを使うので、iOS アプリのサインインだけなら Services ID も要らない。利用者のメールアドレスや名前も受け取らない(スコープを空にしている)。
API トークンはハッシュだけを保存する
トークンそのものは、発行したときに一度だけ見せる。サーバーには SHA-256 のハッシュだけを置き、tokens/{hash} から uid を引く。発行し直すと、前のトークンはトランザクションの中で消える。
export async function issueToken(uid: string): Promise<string> {
const token = newToken(); // "dodo_" + ランダムな 32 バイト
const hash = hashToken(token); // SHA-256
const userRef = db().collection("users").doc(uid);
await db().runTransaction(async (tx) => {
const old = (await tx.get(userRef)).get("tokenHash") as string | undefined;
if (old) tx.delete(db().collection("tokens").doc(old));
tx.set(db().collection("tokens").doc(hash), { uid, createdAt: FieldValue.serverTimestamp() });
tx.set(userRef, { tokenHash: hash }, { merge: true });
});
return token;
}
Firestore のルールで「自分の所を読むだけ」にする
アプリは、自分の uid の一式を読むことしかできない。書けるのは Functions(管理者権限)だけ。ほかは全部閉じる。
match /users/{uid}/lists/{id} {
allow read: if request.auth != null && request.auth.uid == uid;
}
match /{document=**} {
allow read, write: if false;
}
「使った」印も Firestore には書かず、端末の中で覚えるようにした。アプリから書けないので、ルールが単純になる。
アカウント削除
Sign in with Apple を使うアプリは、アプリの中からアカウントを削除できないと審査で断られる。削除では、サーバーのデータ(トークン・一式・回数の記録)と Firebase のユーザーを消し、Apple との連携も取り消す。
⚠️ プレミアムの機能でサインインする作りだと、プレミアムが切れたときに削除の画面へたどり着けなくなりがち。削除の欄は、プレミアムかどうかに関係なく出すようにした。
データは「一式を1ドキュメント」
最初は、やること1件を1ドキュメントにしていた。でも AI が送り続けると、たまる一方になる。そこで、送るたびに一式を1ドキュメントで作り、アプリは一番新しい1つだけを見張る形にした。
users/{uid}/lists/{id}
{ items: [{ title, targetMinutes }], createdAt, source, mode }
mode: "replace"(既定)は、送ったものだけになる。"append"は今の一式に足す。- 前の一式は履歴として残る。アカウントを削除すると全部消える。
- 1つの一式は 100 件まで。Firestore のドキュメントの 1MB の上限は、その外側の安全柵になる。
アプリ側は order(by: "createdAt", descending: true).limit(to: 1) で見張るだけだ。
課金を守る
Functions を使うには Blaze プラン(従量課金)が必要になる。無料枠は Spark と同じなので、個人の規模ならほぼ無料。ただ、トークンが漏れたり AI が送り続けたりすると、請求が膨らむかもしれない。そこで3つ入れた。
- 回数の制限:利用者ごとに 1 分 10 回・1 日 500 回。超えたら 429 と
Retry-Afterを返す。 maxInstances: 3:同時に立ち上がる数を絞る。- 古いコンテナイメージを 7 日で消す:
firebase functions:artifacts:setpolicy。これをしないと、毎月少しずつ保存料がかかる。
回数の制限は、Redis などを使わず、Firestore のドキュメント1つで作った。今の1分と今日の回数を持ち、トランザクションの中で数える。
export async function consumeRate(uid: string): Promise<number> {
const ref = db().collection("rateLimits").doc(uid);
return db().runTransaction(async (tx) => {
const snap = await tx.get(ref);
const decision = nextUsage(snap.exists ? (snap.data() as Usage) : undefined, Date.now());
if (decision.allowed) tx.set(ref, decision.usage);
return decision.allowed ? 0 : decision.retryAfterSeconds;
});
}
nextUsage は副作用のない関数にしてあるので、テストが書きやすい。数えるのは AI から送る側の呼び出しだけで、アプリが Firestore から受け取る分は数えない。
独自ドメインで受ける
Functions の素の URL は https://asia-northeast1-<project>.cloudfunctions.net/api で、長いうえにプロジェクト ID が見える。しかもこの URL は、利用者の Claude Code の MCP 設定に書き込まれる。あとで裏側を替えても変わらないよう、Firebase Hosting の独自ドメインで受けて、全部を Functions に回した。
"hosting": {
"public": "public",
"rewrites": [
{ "source": "**", "function": { "functionId": "api", "region": "asia-northeast1" } }
]
}
あとは、Hosting にカスタムドメインを足して、DNS にレコードを足すだけ。miyashi.app のサイトは Netlify で公開していて、DNS も Netlify DNS で管理しているので、そこに CNAME を1つ足した。
netlify login
netlify api createDnsRecord --data '{"zone_id":"<miyashi.app のゾーン ID>","body":{"type":"CNAME","hostname":"api.dodo.miyashi.app","value":"<プロジェクト ID>.web.app","ttl":3600}}'
📝 ドメイン自体はムームードメインで契約している。でも、ムームードメインの画面では何もしていない。ムームードメインで「ネームサーバーを Netlify にする」ようにしてあるので、DNS のレコード(どのホスト名をどこに向けるか)は全部 Netlify DNS で管理しているからだ。
| 役割 | どこ | 中身 |
|---|---|---|
| 登録(契約) | ムームードメイン | ドメインの持ち主・更新の支払い |
| DNS(行き先の表) | Netlify DNS | www やサブドメインをどこに向けるか |
自分のドメインの DNS がどこにあるかは、dig +short NS miyashi.app で分かる。dnsX.p02.nsone.net が返れば Netlify DNS だ。ネームサーバーを他に向けていれば、そこでレコードを足す。
ブログ本体(miyashi.app)は Netlify のまま、api.dodo.miyashi.app だけが Firebase に向く。足すのはこの1行だけで、ブログのほうの設定には触れていない。あとは Firebase の画面で「確認」を押して、SSL の証明書ができるのを待つ(数分〜数時間)。
ハマったところ
- Node のバージョン。手元の既定が Node 20 だったので、
npm installが Node 22 以上の Firestore のパッケージを黙って入れなかった。型エラーになり、デプロイ前の解析でも落ちた。.node-versionを 22 にし、Firebase CLI も Node 22 で動かして解決した。 - Spark プランでは Functions をデプロイできない。無料枠のままでいいつもりでも、Blaze にする必要がある。
- SSL の発行待ち。DNS を足した直後は証明書のエラーになる。ただ待つしかない。
使ってみて
実際に Claude Code から add_do_suggestions を呼ぶと、送った瞬間に iPhone の DO 画面のサジェストに並ぶ。31 文字の名前を送ると、ちゃんと「30 文字以下にして」と断られる。
claude mcp add --transport http --scope user dodo https://api.dodo.miyashi.app/mcp \
--header "Authorization: Bearer dodo_xxxxxxxx"
サーバーを1台も持たずに、AI エージェントと iPhone アプリをつなげられた。月額の固定費もゼロで、個人開発にはちょうどいい形だと思う。
📝 DODO の API と MCP の使い方は miyashi.app/dodo/api にまとめている。