はじめに
「大家One」は、小規模個人大家(1〜3棟・4〜30室程度、しかも高齢のオーナーが中心)向けの賃貸物件管理SaaSです。今回、このSaaSに「公開REST API」と「リモートMCPサーバー」を追加実装し、本番リリースしました。
■大家One - 小規模大家さん向け 賃貸管理クラウド -
https://ooyaone.com
この記事では機能紹介よりも、なぜ既存SaaSにMCPを追加したか、個人情報(氏名・電話番号・住所など)を扱うシステムでAIエージェント経由の操作を許しながらPIIを漏らさないためにどう設計したか、REST APIとMCPの二重実装をどう避けたか、という「設計判断の理由」にフォーカスします。実装は6段階のPRに分けて段階的にdevelopへマージし、本番反映しました。コード断片は実装のエッセンスを示す擬似コードであり、実ファイルそのままではありません。
MCPとは何か(簡潔に)
MCP(Model Context Protocol)は、AIエージェント(Claude Desktopなど)がツールやデータソースに接続するための標準プロトコルです。JSON-RPC形式で initialize → tools/list → tools/call という流れでやり取りし、AIエージェント側は使えるツールをリスト取得した上で必要なツールを呼び出します。「AIエージェントにとってのプラグイン規格」と捉えるとイメージしやすいです。これに対応するサーバーを立てておけば、ユーザーが自然言語で「今月の未収家賃を教えて」と話しかけるだけで、裏側でツール呼び出しが発生しSaaS側のデータを取得・操作できます。
なぜSaaSにMCPを追加するのか
ターゲットユーザーである個人大家には2つのニーズがありました。①自分のプログラムから直接データを引っ張りたい層(子ども世代がスプレッドシート連携を組みたい、など)、②AIエージェントに「今月やばい部屋ある?」と聞くだけで済ませたい層。①を段階1の公開REST API、②を段階2のリモートMCPサーバーで対応しました。
重要なのは、この2つを別実装にしなかったことです。MCPのツール呼び出しは内部的に段階1のREST APIルートハンドラをそのまま呼び出す設計にしています。理由は後述します。
アーキテクチャ全体像
既に本番稼働しているマルチテナント基盤(Supabase Postgres + RLSによるオーナー単位のデータ分離)の上に積み上げました。
段階1: 公開REST API
DB基盤 (api_tokens, move_in_drafts, 監査ログ拡張)
→ PAT認証基盤 (トークン発行・スコープ制御)
→ 外周層 + READ系11ルート (認証/認可/CORS/レート制限)
→ 非PII書込11ルート (入金記録・退去処理など)
→ 入居受付(新規契約)の引き渡し (署名付き一時ドラフトリンク)
段階2: リモートMCPサーバー
app/api/mcp/route.ts (JSON-RPCエンドポイント)
→ tools/call は段階1のルートハンドラへ内部委譲
PRをこの順序(DB基盤→認証→READ→非PII書込→PII書込の特殊経路→MCP)に分割したのは、「PIIに近づくほど慎重に、後段に回す」という方針をそのままPR構成に反映したためです。
認証: Personal Access Token (PAT)
大家自身が設定画面からPATを発行し、リクエストヘッダーに載せて呼び出します。ハッシュ化はbcrypt cost 12。トークンを保持するapi_tokensテーブルは、他の全テーブルと異なりRLS対象外です。認証前のトークン検索(pre-auth lookup)は「誰の行か」がまだ確定していない段階の操作なので、RLSをかけると検索自体が成立しません。RLSは「認証後、誰のデータか確定した状態でのテナント分離」に効くものと割り切り、認証を担うテーブルは意図的に対象外にしています。
認証後はMutationContextという判別可能なユニオン型で「操作主体が人間かAPIトークンか」を全ての書込系関数に伝播させ、監査ログにはapi_token_idを追加しました。
type MutationContext =
| { actorType: "human"; userId: string }
| { actorType: "api_token"; ownerId: string; apiTokenId: string; scopes: Scope[] };
文字列で緩く持たせると監査ログへの書き忘れがレビューをすり抜けます。判別可能なユニオンなら、分岐を書かない限りコンパイルエラーになる箇所を作れます。
非PII書込とテナント越境防止
入金記録・退去処理などのルートでは、Zodスキーマを.strict()で定義し、PIIに該当するキーが入力に含まれていれば422で拒否します。退去処理では特に、リクエストボディにtenant_idを含めさせず、resolveContractTenantIdで契約IDから内部的にテナントIDを解決します。「他人のtenant_idを指定して自分の契約に紐付ける」類の越境操作を、入力として受け付けないことで構造的に潰しています。
PIIを含む新規契約作成だけは別経路
唯一、入居者の氏名・連絡先を新規登録する「入居受付」だけは、APIから直接PIIを受け取りません。代わりにHMAC-SHA256で署名した一時ドラフトリンクを発行し、大家自身が普段のUI画面でPIIを入力する2段階方式にしています。署名比較はtimingSafeEqualで行いタイミング攻撃を防ぎ、消費はconsumed_at IS NULL条件での同一トランザクションUPDATEにより二重使用を原子的に防止しています。
理由は明快で、新規契約作成は最もPIIの流入経路として攻撃対象になりやすく、AIエージェントに「この人の名前で契約を作って」と任せると氏名の取り違えリスクが大きいためです。PIIの入力は、人間が目で確認できるUI画面に限定するという判断をしました。
PII非送出の3層防御(この記事の核)
読み取りAPI・MCPツールで入居者情報をレスポンスに絶対含めないための3層防御です。
第1層: コンパイル時ガード。 シリアライザの入力型は、PIIフィールドを構造的に持てない専用の中間型(例: PublicContractView)とし、DBの生レコード型(暗号化カラムを含む)をそのまま渡せないようにしています。渡そうとするとコンパイルエラーになるため、「消し忘れ」が入り込む余地を型システムで塞ぎます。
第2層: allowlist方式。 denylist(「これは消す」方式)ではなく、DTOを許可フィールドのリテラル列挙で構築します。denylistの怖さは、DBに新しいPIIカラムが追加されたとき除外リストへの追記を忘れると自動的に漏れることです。allowlistなら新規カラムは明示しない限り絶対に出てきません。
// allowlist方式: 明示的に列挙したものだけが出る
const dto = {
id: contractRow.id,
roomId: contractRow.roomId,
rentAmount: contractRow.rentAmount,
startDate: contractRow.startDate,
};
第3層: 実行時最終防衛。 レスポンス生成の共通関数publicJsonの中でassertNoPiiを必ず通し、キー名・値のパターンからPIIらしきものが紛れていないかを実行時に検査、検知すれば500で落とします。「返してしまってから気づく」事態だけは避ける、という発想です。
さらに、契約データの読み取りには氏名系の暗号化カラム(*_enc)を一切SELECTしない専用read関数(listContractsForPublicApiなど)を用意しました。3層防御はアプリケーション層の多重安全網ですが、これは「そもそもDBから取ってこない」という物理的な経路の遮断です。取得すらしていないデータはどんなバグがあっても漏れません。可能な箇所では「防御する」より「そもそも触れない」を優先しています。
段階1ロジックへの内部委譲によるコード重複回避
MCPサーバーの23ツールは独自にビジネスロジックを持ちません。tools/callが来ると対応する段階1のREST APIルートハンドラを内部importして呼び出します。
MCPとREST APIを完全に独立実装すると、認証・スコープ認可・レート制限・監査ログ・PII非送出ガードを両経路で二重にメンテナンスする羽目になります。二重実装は「片方だけ直して直し忘れる」事故の温床であり、PII保護のようなロジックを2箇所に書きたくありませんでした。
// 擬似コード: MCPのtools/call実装
async function handleToolCall(toolName: string, args: unknown, mcpRequest: Request) {
const route = resolveRouteForTool(toolName); // 例: "list_contracts" → GET /api/public/v1/contracts
// cookieを破棄し、Authorizationヘッダーのみ引き継いだNextRequestを内部合成
const syntheticRequest = buildSyntheticRequest(route, args, {
authorization: mcpRequest.headers.get("authorization"),
});
return route.handler(syntheticRequest);
}
ポイントは「cookieを破棄した上でNextRequestを内部合成する」点です。MCP経由のリクエストは本来ブラウザセッションを持たないため、人間のセッションCookieが誤って認可判定に混入することを明示的に排除しています。Authorizationヘッダー(PAT)だけを引き継ぐことで、MCP経由でもREST API直叩きでも認証・認可・レート制限・監査ログ・PIIガードが完全に同一のコードパスを通る状態を作りました。
MCPプロトコル自体も公式SDKを採用せず、自前で薄いJSON-RPC実装(lib/public-api/mcp/{protocol,tools,dispatch}.ts)を書いています。依存を増やさず、「プロトコル層は薄く、ビジネスロジックは1箇所に集約する」方針を徹底するためです。
品質担保: テストとレビュー体制
6本のPRはすべてレビューでmust-fix指摘ゼロで通過しました。テストは約350件で、実DB(Supabase Postgres+RLS)に対し、PIIが実際にレスポンスへ出ないこと、他オーナーのデータへ越境アクセス・書込できないこと、監査ログのactor種別が正しく記録されること、ドラフトリンクの二重消費が原子的に防止されることを検証しています。
加えてミューテーションテスト(意図的にバグを混入し、テストが検知できるか検証する手法)も実施しました。例えばassertNoPiiの呼び出しを1行消してテストが赤くなるかを確認する、といった検証です。「テストが存在すること」と「実際にバグを検知できること」は別問題であり、個人情報保護のような絶対に壊れてはいけないロジックは実効性まで確認すべきだと考えています。
まとめ
今回の設計判断は次の3点に集約されます。
- PIIに近い操作ほど経路を狭く・遠回りにする。 読み取りは3層防御+SELECT自体の遮断、書込はallowlist、PIIを新規に生む操作だけは人間のUI画面を必ず経由させる。
-
判別可能な型で「うっかり」をコンパイルエラーにする。
MutationContextのdiscriminated unionや、PIIを構造的に持てないDTO型など、レビューやテストに頼らずコンパイラに守らせる。 - プロトコルが増えてもロジックは増やさない。 MCPは新しい入り口だが、中身は段階1のREST APIへの内部委譲で一元化し、二重実装によるメンテナンスコストとセキュリティリスクを回避する。
ITに詳しくない高齢の個人大家を主対象にしたSaaSだからこそ、「AIエージェントに任せても事故らない」設計の作り込みに力を入れました。機微な情報を扱うSaaSにAPI・MCPを追加する際の参考になれば幸いです。
タグ候補: Next.js Supabase MCP PostgreSQL RLS SaaS TypeScript セキュリティ 個人情報保護 API設計