10/3

2026

Cloud Functions だけでリモート MCP サーバーを作った — Sign in with Apple でユーザーを分ける、サーバーレスな個人開発

#MCP#Cloud Functions#Firebase#Sign in with Apple#サーバーレス#Claude Code#iOSCloud Functions だけでリモート MCP サーバーを作った — Sign in with Apple でユーザーを分ける、サーバーレスな個人開発

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 でユーザーを分ける

流れ

  1. アプリで Sign in with Apple → Firebase Auth にサインインして uid を作る。
  2. アプリが Firebase の ID トークン付きで POST /v1/tokens を呼び、API トークンを発行する。
  3. 利用者は、画面に出る「Claude Code に貼る文」をコピーして、Claude Code に渡す。
  4. 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. 回数の制限:利用者ごとに 1 分 10 回・1 日 500 回。超えたら 429 と Retry-After を返す。
  2. maxInstances: 3:同時に立ち上がる数を絞る。
  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 DNSwww やサブドメインをどこに向けるか

自分のドメインの 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 にまとめている。