Skip to content

MCP サーバー 技術設計(v3 — Laravel 内蔵版)

概要

項目内容
ステータス🟡 設計中
親ドキュメント案件提案書
対象コードベースrepos/mappy/laravel(Laravel 5.7.29 / PHP ^7.1.3、4 製品共通・env 切替)
Phase 1 対象製品GMAC(gmac-g.com)のみ有効化

v2 設計からの経緯

旧 v2 設計(Lambda + API Gateway + OAuth 2.1 + DynamoDB。案件提案書 v2 ほか)は設計レビューの結果、要件に対して構成が大きいと判断し、既存 Laravel に内蔵する軽量構成へ方針転換した。v2 一式はアーカイブ扱いとするが、v2 で確定した DB 実スキーマ調査・SQL 記述(ツール一覧 v2 の §8)は本設計に流用している。

基本方針(技術レビュー指摘・厳守): 管理画面で取れる情報を MCP でエージェント向けにインターフェース公開するだけ。必要機能は次の 4 つのみ。

  1. ログインユーザー毎のトークン払い出し(管理画面)
  2. Laravel で MCP 用 API 作成
  3. MCP 用エンドポイント用意
  4. Laravel でトークン認証

OAuth 認可サーバー / Lambda / API Gateway / DynamoDB 等の新規 AWS リソースは一切追加せず、既存 Laravel EC2 に相乗りする。MCP サーバーはデータ提供のみで PPTX 生成はしない(#9 レポート AI 案件は並行・独立)。

1. 全体構成

1.1 コンポーネント責務

コンポーネント種別責務
database/migrations/create_mappy_mcp_tokens_table新規トークンテーブル追加(4 製品共通コードのため全クラスターに同スキーマ適用)
app/Models/Mappy/McpToken.php新規トークンモデル(外部キー制約なし、既存 mappy_* テーブルの慣行に従う)
app/Http/Middleware/Mappy/AuthenticateMcpToken.php新規Bearer 検証・ユーザー解決(Kernel に mappy.mcp.auth で登録)
app/Http/Controllers/Mappy/McpController.php新規JSON-RPC 2.0 ハンドラ(initialize / tools/list / tools/call)
app/Services/Mappy/Mcp/新規tool 定義層(名前 / inputSchema / ハンドラ)。将来 PHP 8.1+ 化時に php-mcp/server へ移行しやすいようコントローラから分離
app/Http/Controllers/Mappy/Admin/McpTokenController.php新規トークン管理 API 3 本(同ディレクトリ UserController.php と同型)
resources/js/mappy/views/admin/McpTokenSettings.vue新規トークン発行・失効 UI
resources/js/mappy/views/admin/UserDetail.vue改修第 3 タブ「MCPトークン」追加
routes/mappy/api.php改修MCP エンドポイント + 管理 API ルート追加
env テンプレート ×4 / mappy 系 config改修MCP_ENABLED フラグ追加
config/logging.php改修専用チャネル mcp 追加
既存 Service 群(InsightService / ReviewService / RankingService / StatisticService / KeywordService)流用集計ロジック。変更しない(MCP 用の再実装をしない)

外部 Composer パッケージは追加しない(php-mcp/server・logiscape/mcp-sdk-php・laravel/mcp はいずれも PHP 8.1+ / Laravel 10+ 必須で導入不能)。JSON-RPC ハンドラは Route 1 本 + コントローラ + Service 層の 200〜400 行想定で自前実装する。実装は PHP 7.1 互換構文のみ(arrow function / typed property / null-safe 演算子は使用禁止)。

2. 認証設計

Laravel Sanctum は Laravel 6.0+ 必須のため 5.7 では composer 依存解決が通らない。Passport の personal access token も、本番 Aurora に oauth 系テーブルが存在せず(DeleteGmacTables.php で意図的に破棄済み)4 クラスター全部への再作成が必要になるため不採用。既存 Passport は CreateFreshApiToken による SPA 認証専用として現状維持し、一切触らない。Sanctum 方式を模倣した独自テーブル + 専用ミドルウェアで実装する。

2.1 トークンテーブル mappy_mcp_tokens(DDL 確定)

sql
CREATE TABLE `mappy_mcp_tokens` (
  `id`           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `user_id`      INT UNSIGNED    NOT NULL,               -- mappy_users.id (int unsigned) に型を合わせる。bigint にしない
  `name`         VARCHAR(100)    NOT NULL,               -- 用途メモ (例: "伊藤さん Claude Code 用")
  `token_hash`   CHAR(64)        NOT NULL,               -- hash('sha256', 平文)
  `last_used_at` TIMESTAMP       NULL DEFAULT NULL,
  `expires_at`   TIMESTAMP       NULL DEFAULT NULL,      -- NULL = 無期限 (Phase 1 UI では設定項目を出さない)
  `revoked_at`   TIMESTAMP       NULL DEFAULT NULL,      -- NULL = 有効。失効は物理削除でなくこの列をセット
  `created_at`   TIMESTAMP       NULL DEFAULT NULL,
  `updated_at`   TIMESTAMP       NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `mappy_mcp_tokens_token_hash_unique` (`token_hash`),
  KEY `mappy_mcp_tokens_user_id_index` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2.2 発行・検証・失効の仕様

項目決定
トークン形式'mcp_' . Str::random(40)(平文 44 文字)
保存hash('sha256', $plain) のみ DB 保存。平文は発行 API レスポンスで一度だけ返し、再表示 API は作らない
発行者Phase 1 は管理者のみ(管理画面 UserDetail から。§7 参照)。ユーザー自身によるセルフ発行は Phase 2 検討
検証ミドルウェア AuthenticateMcpToken: $request->bearerToken() 無し → 401 / hash('sha256', $token)token_hash 一致 + revoked_at IS NULL + (expires_at IS NULL or 未来) を検索 / 紐づく MappyUser の is_enabled=1 確認 / OK なら Auth::shouldUse('mappy') を呼んでから Auth::guard('mappy')->setUser($user)。shouldUse が既定ガードとリクエストの userResolver を差し替えるため、既存 Service の auth()->user() と後続 throttle のユーザー単位キーイングの両方が意図通り動く(setUser 単独では $request->user() が既定 'web' ガードを解決して null のままとなり、throttle が IP キーイングにフォールバックする)
last_used_at検証成功時、前回更新から 5 分以上経過している場合のみ UPDATE(書込み負荷抑制)
失効revoked_at に now() をセット(管理画面の失効ボタン)。物理削除しない
有効期限Phase 1 既定は無期限(expires_at = NULL)。列は用意し将来の運用変更に備える

2.3 トークン管理 API(管理者用 3 本)

routes/mappy/api.php の既存 prefix('admin')->middleware(['auth:admin_api']) グループ内に追加する。

メソッド / パス用途
GET /api/mappy/admin/users/{user}/mcp-tokens一覧(name / created_at / last_used_at / 状態。ハッシュ・平文は返さない)
POST /api/mappy/admin/users/{user}/mcp-tokens発行(このレスポンスでのみ平文返却)
DELETE /api/mappy/admin/mcp-tokens/{token}失効(revoked_at セット)

3. エンドポイント仕様

3.1 ルート定義

POST /api/mappy/mcp の 1 ルートのみ。routes/mappy/api.php の大グループ(Route::name('mappy.')->middleware(['mappy.config.override'])->prefix('mappy'))配下に追加する。

php
Route::middleware(['mappy.mcp.auth', 'throttle:60,1'])
    ->post('/mcp', 'Mappy\\McpController@handle');
// GET 等は 405 (MCP 仕様 MUST)。POST を含む Route::any は登録順にのみ依存して動く危うい書き方のため使わない。
// abort(405) は HTML エラーページを返すため、エラー表 §3.4 の「body なし」に合わせて空 body を返す
Route::match(['get', 'put', 'patch', 'delete', 'head', 'options'], '/mcp', function () {
    return response('', 405);
});
  • 実 URL: /api/mappy/mcp(RouteServiceProvider が prefix api + api ミドルウェアグループを付与)
  • VerifyCsrfToken は api グループに含まれないため CSRF の except 追加は不要
  • ミドルウェアスタック(実効順): throttle:120,1(api 既定)→ bindingsEncryptCookiesAddQueuedCookiesToResponsemappy.config.overridemappy.mcp.auththrottle:60,1
  • 既存 Service がロール / 権限を参照するため mappy.config.override を必ずスタックに含める(大グループで適用済み)

3.2 JSON-RPC 2.0 メソッド

トランスポートは MCP Streamable HTTP の単発 request/response モード(SSE 不使用)。レスポンス Content-Type は常に application/json。Accept ヘッダ・MCP-Protocol-Version ヘッダの検証は省略(単発 JSON で全クライアント互換)。Origin ヘッダが付与され、かつ自ドメイン以外 / 想定外の場合は 403(DNS rebinding 対策。CLI エージェントは通常 Origin を送らないため実運用影響なし)。

initialize — リクエスト / レスポンス:

json
{ "jsonrpc": "2.0", "id": 1, "method": "initialize",
  "params": { "protocolVersion": "2025-03-26", "capabilities": {},
              "clientInfo": { "name": "claude-code", "version": "2.x" } } }
json
{ "jsonrpc": "2.0", "id": 1,
  "result": { "protocolVersion": "2025-03-26",
              "capabilities": { "tools": {} },
              "serverInfo": { "name": "mappy-mcp", "version": "1.0.0" } } }

notifications/initialized(id なし notification 全般)— HTTP 202 Accepted・body なしで応答する(200 + JSON は不可。MCP 仕様 MUST):

json
{ "jsonrpc": "2.0", "method": "notifications/initialized" }

tools/list — 7 tool を固定列挙(inputSchema は JSON Schema を PHP 連想配列で静的定義):

json
{ "jsonrpc": "2.0", "id": 2,
  "result": { "tools": [
    { "name": "insight_summary",
      "description": "GBP インサイト(表示回数・行動数)の期間集計を返す",
      "inputSchema": { "type": "object",
        "required": ["date_from", "date_to"],
        "properties": { "gbp_location_id": { "type": "integer" },
                        "date_from": { "type": "string", "format": "date" },
                        "date_to":   { "type": "string", "format": "date" } } } }
  ] } }

※実際は 7 tool 分の定義を返す(§4 参照)。

tools/call — リクエスト / レスポンス(結果 JSON は content[0].text に文字列で格納):

json
{ "jsonrpc": "2.0", "id": 3, "method": "tools/call",
  "params": { "name": "insight_summary",
              "arguments": { "date_from": "2026-06-01", "date_to": "2026-06-30" } } }
json
{ "jsonrpc": "2.0", "id": 3,
  "result": { "content": [ { "type": "text", "text": "{\"all_views_count\":15234,...}" } ],
              "isError": false } }

3.3 stateless 運用

  • Mcp-Session-Id ヘッダは一切返さない(返さなければクライアントはセッションなしで動作する)
  • initialize 済みかどうかの状態管理もしない(毎 POST を独立処理)

3.4 エラーレスポンス形式(確定表)

事象HTTPJSON-RPC
Authorization ヘッダ無し / トークン不一致・失効・期限切れ / is_enabled=0401{"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"Unauthorized"}}
MCP_ENABLED=false の製品404(Laravel 標準 404)
GET / PUT 等405body なし
レート超過429(Laravel ThrottleRequests 標準)
JSON パース不能200error -32700
不正リクエスト形式200error -32600
未知メソッド200error -32601
tools/call パラメータ不正(未知 tool 名含む)200error -32602
tool 実行中の業務エラー(スコープ外店舗 / search_ranking_enabled=0 / GBP API 失敗等)200result.content[0].text にエラー説明 + isError: true
サーバー内部エラー200error -32000(message は汎用文言。詳細はログのみ)

3.5 配置と通信経路(Phase 1)

Phase 1 の MCP エンドポイントは、既存アプリと同一ドメイン・同一ポート (443) に相乗りする。専用のゲートウェイ・専用ポートは設けない。

AI クライアント
  → https://gmac-g.com:443 (既存 gmac-production ALB / TLS 終端)
  → EC2 gmac-app-production(既存 nginx → PHP-FPM → Laravel)
  → /api/mappy/mcp ルート(本設計で追加)
論点Phase 1 の判断理由
API Gateway(AWS マネージド)の要否不要入口制御(TLS 終端・ルーティング)は既存 ALB が担っており、認証・レート制限は Laravel ミドルウェア(mappy.mcp.auth + throttle)が担う。MCP 専用に新設すると本設計の軽量方針(新規 AWS リソースなし)に反する
ポート443 を既存アプリと共用MCP プロトコル自体はポートを規定しない(HTTP 上の JSON-RPC)。443 はファイアウォール・プロキシを追加設定なしで通過でき、クライアント設定も標準 HTTPS で済むため実運用上ほぼ一択
同一オリジン相乗りの安全性Phase 1 の利用規模では十分① Bearer トークン認証(全 MCP リクエストで sha256 ハッシュ照合)② 読取専用(書込 tool なし=侵害されてもデータ改変は発生しない)③ throttle レート制限 ④ テナンシ境界(トークン→ユーザー→可視店舗のみ)⑤ Origin 検証(DNS rebinding 対策)の 5 層で防御

3.6 将来のエンドポイント分離(Phase 2+ セキュリティ強化オプション)

利用が本格化した段階で、MCP の入口を既存アプリから分離する選択肢を残す。分離の粒度は 3 段階あり、コストと分離度のバランスから中段階(専用サブドメイン)を推奨する。

段階実現方法分離できるもの追加コスト
現状(Phase 1)同一ドメイン + パス(/api/mappy/mcpパスのみゼロ
中(推奨)専用サブドメイン(例 mcp.gmac-g.com)+ 既存 ALB のホストベースルーティング + 既存 EC2ドメイン単位の WAF ルール / アクセスログ / レート制限を MCP 専用に設定可能小(ALB リスナールール追加 + ACM 証明書 + Route 53 レコードのみ。EC2 / アプリコードは変更不要)
専用 EC2 / コンテナ + 専用ターゲットグループプロセス・障害の完全隔離(MCP の高負荷が本体アプリに波及しない)中(インスタンス費用 + デプロイ経路の複線化)
  • 分離時もポートは 443 のままとする(「専用ポート」方式は FW / プロキシ通過性とクライアント設定の複雑化というデメリットのみで、サブドメイン分離に対する利点がない)
  • 移行はクライアント側の接続 URL 変更のみで完結する(トークン・tool 定義は影響なし)。Phase 1 の設計はこの移行を妨げない
  • 着手判断の目安: 社外ユーザーへの開放時、または MCP 起因の負荷・セキュリティイベントが観測された時点

4. 7 tool 詳細

4.1 確定一覧(変更禁止)

#tool 名データ源主要出力備考
1location_listmappy_gbp_locations (境界: mappy_integrated_gbp_locations、GROUP は mappy_group_location 経由)店舗一覧: id / title(店舗名) / address / primary_category_display_name / store_code / statusモデル GbpLocation。status は 0=normal〜6=disabled。境界は integratedLocations 準拠 (available は不採用、§5 参照)
2review_listmappy_gbp_reviews口コミ一覧 (star_rating / comment / create_time / reviewer_display_name / reply_comment) + 集計 (件数 / 平均評価 / 評価分布 / 返信率)type=1 (REVIEW_TYPE) で必ず絞る。期間フィルタは create_time。ReviewService::getReviewCountOfLocations / getAverageRatingOfLocations / getReviewCountGroupByRating + calculateReplyRateForLocations 流用。star_rating は varchar だが数値保存済みで AVG 可
3ranking_listmappy_search_rankings JOIN mappy_keywordsキーワード別の平均検索順位・日次順位推移 (keyword 文字列 / ranking / ranking_at)ranking NULL/0/>=61 は圏外 (OUT_OF_RANGE_VALUE=61)。JOIN 時 mappy_keywords.deleted_at IS NULL (レポート互換は withTrashed)。期間列は ranking_at (IDX あり)。RankingService::getAverageRankingInPeriod / getRankingChartData 流用。search_ranking_enabled=0 のユーザーは業務エラー
4insight_summarymappy_gbp_insights期間 SUM: 表示回数 (all_views / search / map 別) / 行動数 action_count (website+route+call 内訳含む) / action_rate期間列は insight_at (unique[gbp_location_id, insight_at]、日次 1 行/店舗)。InsightService::getInsightCountOfLocations (月間レポートの正解クエリ) ほぼそのまま流用
5post_listmappy_gbp_posts投稿一覧 (title / summary / topic_type / gbp_state / create_time / gbp_search_url) + 期間投稿記事数期間列は create_time。件数集計は StatisticService::getPostOperationRanking と同形 (state フィルタなし)。gbp_state='LIVE' フィルタはオプションパラメータとして提供。現行 DownloadableReport に投稿数セクションは無く、分析(統計)機能が集計の正
6media_listmappy_gbp_media_updated_locations写真一覧 (gbp_media_url / gbp_media_thumbnail_url / category / gbp_state / created_at) + 期間投稿写真数期間列は created_at。StatisticService::getPictureOperationRanking と同形。gbp_state='LIVE' フィルタはオプション。mappy_gbp_media_schedules は予約メタであり主データ源にしない。originates_from_gbp 等の削除済み列は使用不可
7keyword_listGBP Performance API 直呼び (GET {locationName}/searchkeywords/impressions/monthly)。恒久テーブルなし月次流入キーワードと表示回数 / 上位 10 / unique 流入キーワード数KeywordService::retrieveMonthlyKeywordsImpressions + BusinessProfileApiService::getMonthlyKeywordsImpressions 流用。Google トークンは getApiUser() (root MAIN) で解決。insightsValue.value 空は threshold 値代替。mappy_report_keywords は鮮度保証のない使い捨てキャッシュのため使わない。mappy_keywords (順位計測用登録キーワード) は流入キーワードではない (ranking_list 側専用)

代表ユースケース(AI による月間レポート生成)との対応: 表示回数 / 行動数 = #4、口コミ評価・評価分布・返信率 = #2、平均検索順位 = #3、キーワード上位 10・流入キーワード数 = #7、投稿記事数 = #5、投稿写真数 = #6、店舗基本情報 = #1。マッピーログイン回数は対象外(決定済)。

tool 名はアンダースコア区切りで統一する(v2 までのドット区切り location.list 形式から変更)。Anthropic Messages API のツール名は ^[a-zA-Z0-9_-]{1,64}$ パターンに制限されており、Claude Code は MCP tool を mcp__mappy__<tool名> 形式で API へ渡すため、ドット入り名は API 側バリデーションエラー(400)になる既知事例がある。Phase 2 で検討する ChatGPT(OpenAI function 名 ^[a-zA-Z0-9_-]+$)でも同じ制約があるため、全クライアント互換の命名とする。

4.2 共通仕様

パラメータ適用 tool説明
gbp_location_idinteger 任意#1〜#6省略時はスコープ内全店舗を対象(v2 慣行踏襲)。スコープ外 ID 指定は業務エラー(isError: true
limit / offsetinteger 任意一覧系(#1, #2, #3, #5, #6)一覧応答は既定 50 件 / limit 上限 200
date_from / date_todate集計系(#3, #4 は必須。#2, #5, #6 は任意)指定時は最大 13 ヶ月。省略時: #5, #6 は過去 30 日、#2 は全期間(口コミ画面の累計評価と一致させるため。§4.4 参照)

一覧系の応答(content[0].text 内 JSON)は {"items":[...], "total": n, "limit": 50, "offset": 0} 形式。読取専用 — 書込み系 tool は Phase 1 に存在しない。

datetime / timestamp 列(create_time / created_at)への期間適用は、date_from を startOfDay(00:00:00)、date_to を endOfDay(23:59:59)へ変換してから BETWEEN する(流用元 StatisticService::getPostOperationRanking / getPictureOperationRanking と同じ境界補正)。date 値のまま渡すと :date_to が当日 00:00:00 と解釈され、最終日のデータがほぼ全て漏れる。

大規模テーブルの期間絞り込みは必須

mappy_search_rankings は約 1,090 万行、mappy_gbp_insights は約 430 万行。ranking_at(IDX あり)/ insight_at の期間絞り込みなしではフルスキャンになるため、#3 / #4 は date_from / date_to必須とし、最大 13 ヶ月に制限する。

以降の SQL 概略で :scope_location_ids は §5 のテナンシ解決で得た店舗 ID 集合を指す。

4.3 location_list

必要フラグ: is_enabled=1 のみ。固有パラメータ: q(店舗名部分一致)/ store_code(完全一致)。

sql
SELECT gl.id, gl.name, gl.title, gl.address, gl.store_code,
       gl.primary_category, gl.primary_category_display_name, gl.status, gl.data_source
FROM mappy_gbp_locations gl
INNER JOIN mappy_integrated_gbp_locations igl ON igl.gbp_location_id = gl.id
WHERE igl.user_id = :user_id
  AND (:q IS NULL OR gl.title LIKE CONCAT('%', :q, '%'))
  AND (:store_code IS NULL OR gl.store_code = :store_code)
ORDER BY gl.id LIMIT :limit OFFSET :offset

GROUP ユーザー(user_type=2)は igl JOIN を mappy_group_location 経由の許可店舗 ID 集合に置換する。出力例: {"items":[{"id":8802,"title":"渋谷店","address":"東京都渋谷区...","primary_category_display_name":"美容室","store_code":"SHIBUYA001","status":0}],"total":3,"limit":50,"offset":0}

4.4 review_list

必要フラグ: is_enabled=1。固有パラメータ: min_rating / max_rating(1〜5)/ has_reply(true=返信済のみ / false=未返信のみ)。type=1(REVIEW_TYPE)で必ず絞る

star_rating は varchar だが、ReviewService::serialize()(app/Services/Mappy/ReviewService.php:251)が GBP の文字列(FIVE〜ONE)を保存前に数値(0〜5)へ変換して INSERT しているため、DB には '5''1' の数値文字列が入っている('FIVE' 形式ではない)。UNSPECIFIED は 0 で保存され、既存クエリは whereNotNull による除外のみを行う。既存 getAverageRatingOfLocations()(同 :308)も AVG(star_rating) を直接実行している。v2 設計書 §8 にあった CASE ... WHEN 'FIVE' 変換と min_rating の文字列変換は実データと矛盾するため本設計では廃止する(値域の最終確認は §12 参照)。

sql
SELECT r.id, r.gbp_location_id, gl.title AS location_title,
       r.reviewer_display_name, r.star_rating,
       r.comment, r.reply_comment, r.create_time
FROM mappy_gbp_reviews r
INNER JOIN mappy_gbp_locations gl ON gl.id = r.gbp_location_id
WHERE r.type = 1
  AND r.gbp_location_id IN (:scope_location_ids)
  AND (:min_rating IS NULL OR r.star_rating >= :min_rating)  -- 数値文字列をそのまま比較。0 = UNSPECIFIED
  AND (:max_rating IS NULL OR r.star_rating <= :max_rating)
  AND r.create_time BETWEEN :date_from AND :date_to  -- startOfDay〜endOfDay 補正 (§4.2)。期間省略時はこの行を外し全期間
ORDER BY r.create_time DESC LIMIT :limit OFFSET :offset

集計(件数 / 平均評価 / 評価分布 / 返信率)は ReviewService::getReviewCountOfLocations / getAverageRatingOfLocations / getReviewCountGroupByRating + calculateReplyRateForLocations を流用し、summary として同梱する。summary の集計期間は items と同一の期間フィルタに従う: date_from / date_to 省略時は全期間累計(管理画面の口コミ画面と同じ値。月間レポートの「店舗の総合評価」はこちらを使う)、期間指定時は当該期間の集計(月間レポートの期間集計と一致)となる。出力例: {"items":[...],"summary":{"review_count":34,"average_rating":4.4,"rating_distribution":{"5":20,"4":8,"3":4,"2":1,"1":1},"reply_rate":88.2},"total":34,"limit":50,"offset":0}

4.5 ranking_list

必要フラグ: is_enabled=1 かつ search_ranking_enabled=1(0 の場合は tools/call のみ業務エラー。tools/list からは隠さない)。date_from / date_to 必須。固有パラメータ: keyword(部分一致)。

mappy_search_rankings.user_id には MAIN ユーザーの ID が格納されている。:main_user_ids はトークンユーザーの ID 直指定ではなく、既存 RankingController と同じく MappyUser::getIntegratedMainUserIds()(app/Models/Mappy/MappyUser.php:494。GROUP ユーザーはその親 MAIN の ID を返す)で解決した MAIN ユーザー ID 集合(IN 句)とする。トークンユーザー ID 直指定では GROUP トークンで順位データが 0 件になるため不可。流用元 RankingService::getAverageRankingInPeriod(array $userIds, ...) のシグネチャも userIds 配列前提である。

sql
SELECT sr.gbp_location_id, sr.keyword_id, kw.keyword, sr.ranking_at, sr.ranking
FROM mappy_search_rankings sr
INNER JOIN mappy_keywords kw ON kw.id = sr.keyword_id AND kw.deleted_at IS NULL
WHERE sr.user_id IN (:main_user_ids)  -- getIntegratedMainUserIds() で解決した MAIN ユーザー ID
  AND sr.gbp_location_id IN (:scope_location_ids)
  AND sr.ranking_at BETWEEN :date_from AND :date_to   -- ranking_at に IDX あり、必須絞り込み
  AND (:keyword IS NULL OR kw.keyword LIKE CONCAT('%', :keyword, '%'))
ORDER BY sr.ranking_at, sr.keyword_id LIMIT :limit OFFSET :offset

ranking が NULL / 0 / 61 以上(OUT_OF_RANGE_VALUE=61)は圏外とし、出力では ranking: null に正規化する。キーワード別平均は RankingService::getAverageRankingInPeriod、日次推移は getRankingChartData を流用(圏外を平均計算から除外する扱いも既存実装準拠)。出力例: {"items":[{"keyword_id":4567,"keyword":"美容室 渋谷","average_ranking":3.2,"series":[{"ranking_at":"2026-06-01","ranking":3}]}],"total":8,"limit":50,"offset":0}

4.6 insight_summary

必要フラグ: is_enabled=1date_from / date_to 必須。InsightService::getInsightCountOfLocations(月間自動レポートの正解クエリ)をほぼそのまま流用する。

sql
SELECT SUM(i.all_views_count) AS all_views_count,
       SUM(i.views_count_by_search) AS views_count_by_search,
       SUM(i.views_count_by_map) AS views_count_by_map,
       SUM(i.action_count) AS action_count,
       SUM(i.access_count_website) AS access_count_website,
       SUM(i.access_count_route) AS access_count_route,
       SUM(i.call_count) AS call_count
FROM mappy_gbp_insights i
WHERE i.gbp_location_id IN (:scope_location_ids)
  AND i.insight_at BETWEEN :date_from AND :date_to

insight_at は unique[gbp_location_id, insight_at](日次 1 行 / 店舗)。action_rate = action_count ÷ all_views_count をサーバー側で算出して同梱する。出力例: {"period":{"date_from":"2026-06-01","date_to":"2026-06-30"},"all_views_count":15234,"views_count_by_search":9234,"views_count_by_map":6000,"action_count":2479,"access_count_website":1234,"access_count_route":789,"call_count":456,"action_rate":16.3}

4.7 post_list

必要フラグ: is_enabled=1。期間列は create_time。固有パラメータ: gbp_state(任意。'LIVE' 指定で公開済のみ。既定は state フィルタなし = 分析(統計)機能の集計と同形)。

sql
SELECT p.id, p.gbp_location_id, p.title, p.summary, p.topic_type,
       p.gbp_state, p.gbp_search_url, p.create_time
FROM mappy_gbp_posts p
WHERE p.gbp_location_id IN (:scope_location_ids)
  AND p.create_time BETWEEN :date_from AND :date_to
  AND (:gbp_state IS NULL OR p.gbp_state = :gbp_state)
ORDER BY p.create_time DESC LIMIT :limit OFFSET :offset

期間投稿記事数(post_count)は同条件の COUNT を summary として同梱(StatisticService::getPostOperationRanking と同形)。

4.8 media_list

必要フラグ: is_enabled=1。期間列は created_at。固有パラメータ: gbp_state(任意。'LIVE' フィルタはオプション)。mappy_gbp_media_schedules は予約投稿のメタ情報であり主データ源にしないoriginates_from_gbp 等の削除済み列は使用不可。

sql
SELECT m.id, m.gbp_location_id, m.gbp_media_url, m.gbp_media_thumbnail_url,
       m.category, m.gbp_state, m.created_at
FROM mappy_gbp_media_updated_locations m
WHERE m.gbp_location_id IN (:scope_location_ids)
  AND m.created_at BETWEEN :date_from AND :date_to
  AND (:gbp_state IS NULL OR m.gbp_state = :gbp_state)
ORDER BY m.created_at DESC LIMIT :limit OFFSET :offset

期間投稿写真数(picture_count)は同条件の COUNT を summary として同梱(StatisticService::getPictureOperationRanking と同形)。

4.9 keyword_list

必要フラグ: is_enabled=1 + 対象店舗の GBP 連携必須。DB ではなく GBP Performance API を直呼びする(恒久テーブルなし)。パラメータ: gbp_location_id(必須。API が店舗単位のため)/ month(YYYY-MM、必須)。

text
GET https://businessprofileperformance.googleapis.com/v1/{locationName}/searchkeywords/impressions/monthly
    ?monthlyRange.start_month.year=2026&monthlyRange.start_month.month=6
    &monthlyRange.end_month.year=2026&monthlyRange.end_month.month=6
  • KeywordService::retrieveMonthlyKeywordsImpressions + BusinessProfileApiService::getMonthlyKeywordsImpressions を流用
  • Google アクセストークンは MappyUser::getApiUser()(parent 遡上で root MAIN 解決)で取得(既存 getAccessTokenByUser 準拠)
  • insightsValue.value が空のキーワードは threshold 値で代替(Google 仕様: 少数件は閾値のみ返る)
  • mappy_report_keywords は鮮度保証のない使い捨てキャッシュのため使わない。mappy_keywords は順位計測用登録キーワードであり流入キーワードではない(ranking_list 側専用)
  • API 失敗(トークン失効・quota 等)は業務エラー(isError: true)で返す

出力例: {"month":"2026-06","keywords":[{"keyword":"美容室 渋谷","impressions":1234}],"top10":[...],"unique_keyword_count":58}

5. テナンシ・権限

スコープ解決は token → user_idmappy_integrated_gbp_locations(integratedLocations)準拠

available 境界は不採用

当初案の mappy_user_available_gbp_locations 境界はデータ層調査の結果不採用とした。available は「GBP アカウントで参照可能な全店舗(取込候補 UI 用)」であり、実利用スコープは integrated(月間自動レポート CreateAutomaticReports も integratedLocations を使用)と判明したため。v2 設計書の SQL に含まれる mappy_user_available_gbp_locations JOIN はすべて読み替え済み(§4 の SQL が確定版)。

対象判定
MAIN(user_type=1$user->integratedLocationsmappy_integrated_gbp_locations.user_id 一致)
GROUP(user_type=2mappy_group_location 経由の許可店舗(MappyUser::locations() 既存実装準拠)
user_type の扱いPhase 1 は本人の可視範囲のみ。MASTER(user_type=3)の配下横断参照はしない(未確定事項 §12 参照)
is_enabled=0認証段階で 401(tool まで到達させない)
search_ranking_enabled=0ranking_list の tools/call のみ業務エラー。tools/list からは隠さない(7 tool 固定列挙)
スコープ外 gbp_location_id 指定全 tool で必ず検証し、業務エラー(isError: true)。省略時はスコープ内全店舗を対象
keyword_list の Google トークンMappyUser::getApiUser()(parent 遡上で root MAIN 解決)で取得

なお本設計では 1 トークン = 1 製品ドメインに紐づくため、v2 にあった product クレームによる Aurora 振り分けは不要(クライアントが接続先ドメインを選ぶことがそのまま製品選択になる)。

6. レート制限・ログ

  • レート制限: ルートに throttle:60,1(60 req/分)。mappy.mcp.authAuth::shouldUse('mappy') で既定ガードを差し替えてから user をセットする(§2.2)ため、ThrottleRequests::resolveRequestSignature() の $request->user() がユーザーを解決し、ユーザー単位でキーイングされる。shouldUse なしでは sha1(domain|ip) の IP キーイングにフォールバックし、同一オフィス NAT 配下の複数利用者が 1 バケットを共有してしまうため不可。既存 guest ルートの throttle:20,1 個別付与と同じ慣行
  • ログ: config/logging.php に専用チャネル mcp(daily ドライバ、storage/logs/mcp-YYYY-MM-DD.log)を追加。1 リクエスト 1 行で user_id / token_id / method / tool 名 / 主要パラメータ / 所要 ms / 成否を記録。トークン平文・ハッシュはログに記録しない
  • last_used_at の 5 分間隔更新が簡易利用実績を兼ねる
  • 新規監査 DB テーブルは作らない(最小構成方針)。キャッシュ層・承認フローも追加しない

7. 管理画面トークン発行 UI

新規画面は作らず、既存ユーザー詳細 resources/js/mappy/views/admin/UserDetail.vue(/admin/users/:userId/details)に**第 3 タブ「MCPトークン」**を追加する。新規コンポーネント McpTokenSettings.vue を通知設定タブ(NotificationSettings.vue 埋め込み)と完全同型で実装し、tabBarElements{text:'MCPトークン', value:'MCP_TOKEN'} を追加する。

画面要素内容
タブ表示制御process.env.MIX_MCP_ENABLED === 'true' で出し分け(MIX_MAPPY_SYSTEM_NAME 判定と同じ既存慣行)
一覧テーブル発行済トークンの名前 / 作成日 / 最終使用 / 状態(有効・失効)
発行ボタン用途名を入力して発行 → 成功時に @mappy/include/modals/Modal平文トークンを一度だけ表示 + クリップボードコピー(再表示不可の注意文言付き)
失効ボタン各行に配置。確認モーダルは UserList.vue の delete-confirmation-modal と同パターン

操作フロー: 管理者ログイン → アカウント管理 → 対象ユーザー編集 → MCPトークンタブ → 発行 → モーダルでコピー → 利用者へ安全な手段で受け渡し。

却下案: 新規独立画面(ルータ / メニュー改修が増え最小構成に反する)、UserList 行アクション追加(行アクションが既に密)。

インタラクティブモック

本タブのインタラクティブモックは製造前レビュー時に追加予定。

8. クライアント接続手順

クライアント可否方式
Claude Code○(推奨・最も簡単)HTTP トランスポート + Bearer ヘッダ
Claude Desktop / claude.aimcp-remote プロキシ経由(クライアント PC に Node.js 必要)
ChatGPT×(Phase 1 スコープ外)静的 Bearer 未対応。OAuth 2.1 実装 + search/fetch tool が必要なため対象外

Claude Code:

bash
claude mcp add --transport http mappy https://gmac-g.com/api/mappy/mcp \
  --header "Authorization: Bearer <token>"
  • 平文を .mcp.json に残したくない場合は ${MCP_TOKEN} 環境変数展開を使う
  • 導入時は claude mcp list / /mcp + サーバーアクセスログで Authorization ヘッダ到達を必ず確認(過去のヘッダ欠落バグ #29562 / #50464 対策)
  • claude mcp add がトークンを stdout にエコーする点(#60909)に注意

Claude Desktop(カスタムコネクタ UI は OAuth 前提で静的 Bearer 未対応のため、mcp-remote を経由):

json
{ "mcpServers": { "mappy": {
    "command": "npx",
    "args": ["mcp-remote", "https://gmac-g.com/api/mappy/mcp",
             "--header", "Authorization:${AUTH_HEADER}", "--transport", "http-only"],
    "env": { "AUTH_HEADER": "Bearer <token>" } } } }

Windows のスペースエスケープバグ回避のため env 変数方式が必須--header に直接スペース入りの値を書かない)。

9. 4 製品への展開

env キー MCP_ENABLED(true/false)を 4 製品の env テンプレートに追加する。env() 直読みは config:cache で壊れるため、既存 mappy 系 config に 'mcp_enabled' => env('MCP_ENABLED', false) を追加し、McpController・McpTokenController 冒頭で false なら 404 を返す。Vue 側はビルド時変数 MIX_MCP_ENABLED でタブを出し分ける。

製品ドメインenv テンプレートPhase 1 MCP_ENABLED
GMACgmac-g.comenv.prod.templatetrue
GCORg-cor-m.comenv.gcor.prod.templatefalse
PIPIT(KingMeo)king-meo.comenv.pipit.prod.templatefalse
口コミONEkuchikomione.comenv.kuchikomi-one.templatefalse

コードは 4 製品共通(単一コードベース・env 切替)のため、Phase 2 以降の他製品展開は「env を true にする + migration 適用確認 + トークン発行」のみで完了する。

10. 実装タスク分解

体制は設計者 1 名 + 製造者 1 名の 2 名体制。見積は AI 実装 + 人によるレビュー / リテイクを 0.5 人日単位で積算(AIリテイク回数 × レビュー時間 0.5 日/回 = 人日)。

#作業項目内容AIリテイクレビュー工数(人日)担当
1技術設計書レビュー・製造指示本設計書の最終レビュー・修正指示 → 製造者への着手指示(提案書ガントの des1 に対応)1 回0.5 日/回0.5設計者
2DB・モデルmigration mappy_mcp_tokens + モデル McpToken(§2.1 DDL 通り)1 回0.5 日/回0.5製造者
3トークン管理 APIAdmin\McpTokenController(一覧 / 発行 / 失効の 3 本、auth:admin_api グループ)1 回0.5 日/回0.5製造者
4管理画面 UIMcpTokenSettings.vue 新規 + UserDetail.vue 第 3 タブ追加 + 平文一度だけ表示モーダル + 失効確認モーダル + MIX_MCP_ENABLED 出し分け2 回0.5 日/回1.0製造者
5認証ミドルウェアAuthenticateMcpToken(Bearer 検証 / is_enabled / last_used_at 5 分間隔更新 / Auth::shouldUse('mappy') + setUser / 401 JSON-RPC 形式)+ Kernel 登録1 回0.5 日/回0.5製造者
6MCP エンドポイントMcpController: JSON-RPC 2.0 ハンドラ(initialize / tools/list / tools/call / notification→202 / GET→405 / エラー表 §3.4 準拠 / Origin 検証)+ ツール定義層分離2 回0.5 日/回1.0製造者
7tool 実装(DB 系 6 本)location_list / review_list / ranking_list / insight_summary / post_list / media_list — 既存 Service 流用 + テナンシ境界 + ページング3 回0.5 日/回1.5製造者
8tool 実装(API 系 1 本)keyword_list — GBP Performance API 直呼び(KeywordService 流用、getApiUser トークン解決、エラーハンドリング)1 回0.5 日/回0.5製造者
9env・設定MCP_ENABLED を 4 製品 env テンプレート + config に追加、無効製品 404、logging.php に mcp チャネル追加1 回0.5 日/回0.5製造者
10結合テストClaude Code / Claude Desktop(mcp-remote)実接続確認(Authorization ヘッダ到達含む)、7 tool すべての tools/call 実行確認(ツール名の変換・拒否がないこと)、7 tool の値を管理画面表示と突合、throttle / 401 / 405 / 202 挙動確認2 回0.5 日/回1.0製造者
11ドキュメント利用者向け接続手順書(Claude Code / Desktop、ChatGPT 非対応の明記)+ 運用手順(発行 / 失効)1 回0.5 日/回0.5設計者
合計8.0設計者 1.0日 / 製造者 7.0日
  • 目標レンジ 5〜8 人日内。バッファは各項目のリテイク分に内包済み(別途バッファ行は設けない)
  • 前提: PHP 7.1 互換構文 / 外部パッケージ追加なし / 既存 Service 流用のため集計ロジックの新規設計工数は含まない
  • スコープ外(工数 0): OAuth・新規 AWS リソース・監査テーブル・書込み tool・ChatGPT 対応・PPTX 生成

11. 検収条件

  • [ ] 管理画面からトークン発行でき、平文が発行モーダルで一度だけ表示される(再表示 API が存在しない)
  • [ ] DB の mappy_mcp_tokens には SHA-256 ハッシュのみ保存されている(平文カラムなし)
  • [ ] Claude Code から claude mcp add で接続し、tools/list で 7 tool が列挙され、7 tool すべてが実際に tools/call で実行できる(ツール名の変換・拒否が発生しない)
  • [ ] Claude Desktop(mcp-remote 経由)からも同様に接続できる
  • [ ] 7 tool すべての返却値が管理画面(分析 / 口コミ / 順位 / レポート)の表示値と一致する(review_list は期間省略時 = 口コミ画面の全期間累計、期間指定時 = レポートの期間集計と突合。§4.4)
  • [ ] Authorization ヘッダ無し / 不正トークンで 401 + JSON-RPC -32001 が返る
  • [ ] 失効ボタン押下後、当該トークンでのアクセスが 401 になる
  • [ ] スコープ外の gbp_location_id 指定が業務エラー(isError: true)になる
  • [ ] search_ranking_enabled=0 のユーザーで ranking_list のみ業務エラーになる(他 6 tool は動作)
  • [ ] GROUP ユーザーのトークンでも ranking_list が親 MAIN の順位データを返す(§4.5 の getIntegratedMainUserIds() 解決)
  • [ ] ranking_list / insight_summarydate_from / date_to 未指定、または 13 ヶ月超の期間指定が -32602 エラーになる
  • [ ] limit に 200 超を指定した場合、200 件で打ち切られる
  • [ ] Google アクセストークン失効状態で keyword_list のみ業務エラー(isError: true)になり、他 6 tool は正常動作する
  • [ ] GET / PUT リクエストに 405、notifications/initialized に 202(body なし)が返る
  • [ ] 61 req/分 で 429 が返る(ユーザー単位キーイング)
  • [ ] MCP_ENABLED=false の製品ドメインでは /api/mappy/mcp・管理 API とも 404、管理画面にタブが出ない
  • [ ] 想定外 Origin ヘッダ付きリクエストが 403 になる
  • [ ] storage/logs/mcp-*.log に 1 リクエスト 1 行で記録され、トークン平文・ハッシュが含まれない

12. 未確定事項

#項目状態
1MASTER(user_type=3)ユーザーへのトークン発行可否とスコープ解決Phase 1 は MAIN / GROUP を前提。MASTER は発行対象外とするか実装時に確定
2tool description の言語日本語のみ / 英語併記(v2 からの持ち越し。AI クライアントの解釈精度に影響)
3throttle:60,1 の妥当性月間レポート生成 1 回あたりの tools/call 回数を結合テストで実測して調整
4keyword_list の GBP API quota・失敗時リトライ方針Google 側レート制限に対する再試行 / 案内文言を実装時に確定
5expires_at の運用Phase 1 は無期限。将来既定 TTL(例: 1 年)を入れるかは運用開始後に判断
6mcp ログチャネルの保持日数daily ドライバの days 設定値(既存チャネルの慣行に合わせるか個別設定か)
7star_rating の値域の最終確認実装着手前に本番で SELECT DISTINCT star_rating を実行し、数値文字列(0〜5、UNSPECIFIED=0)であることを確定する(§4.4 参照)
8MCPトークンタブのインタラクティブモック製造前レビュー時に wireframes/ の既存慣行(一覧 / 発行モーダル / 失効確認)で追加予定(§7 参照)。追加後に提案書へ画面モックセクションとして反映

13. 関連リンク