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 つのみ。
- ログインユーザー毎のトークン払い出し(管理画面)
- Laravel で MCP 用 API 作成
- MCP 用エンドポイント用意
- 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 確定)
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'))配下に追加する。
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 が prefixapi+apiミドルウェアグループを付与) - VerifyCsrfToken は api グループに含まれないため CSRF の except 追加は不要
- ミドルウェアスタック(実効順):
throttle:120,1(api 既定)→bindings→EncryptCookies→AddQueuedCookiesToResponse→mappy.config.override→mappy.mcp.auth→throttle: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 — リクエスト / レスポンス:
{ "jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": { "protocolVersion": "2025-03-26", "capabilities": {},
"clientInfo": { "name": "claude-code", "version": "2.x" } } }{ "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):
{ "jsonrpc": "2.0", "method": "notifications/initialized" }tools/list — 7 tool を固定列挙(inputSchema は JSON Schema を PHP 連想配列で静的定義):
{ "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 に文字列で格納):
{ "jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": { "name": "insight_summary",
"arguments": { "date_from": "2026-06-01", "date_to": "2026-06-30" } } }{ "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 エラーレスポンス形式(確定表)
| 事象 | HTTP | JSON-RPC |
|---|---|---|
Authorization ヘッダ無し / トークン不一致・失効・期限切れ / is_enabled=0 | 401 | {"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"Unauthorized"}} |
MCP_ENABLED=false の製品 | 404 | (Laravel 標準 404) |
| GET / PUT 等 | 405 | body なし |
| レート超過 | 429 | (Laravel ThrottleRequests 標準) |
| JSON パース不能 | 200 | error -32700 |
| 不正リクエスト形式 | 200 | error -32600 |
| 未知メソッド | 200 | error -32601 |
| tools/call パラメータ不正(未知 tool 名含む) | 200 | error -32602 |
tool 実行中の業務エラー(スコープ外店舗 / search_ranking_enabled=0 / GBP API 失敗等) | 200 | result.content[0].text にエラー説明 + isError: true |
| サーバー内部エラー | 200 | error -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 名 | データ源 | 主要出力 | 備考 |
|---|---|---|---|---|
| 1 | location_list | mappy_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 参照) |
| 2 | review_list | mappy_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 可 |
| 3 | ranking_list | mappy_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 のユーザーは業務エラー |
| 4 | insight_summary | mappy_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 (月間レポートの正解クエリ) ほぼそのまま流用 |
| 5 | post_list | mappy_gbp_posts | 投稿一覧 (title / summary / topic_type / gbp_state / create_time / gbp_search_url) + 期間投稿記事数 | 期間列は create_time。件数集計は StatisticService::getPostOperationRanking と同形 (state フィルタなし)。gbp_state='LIVE' フィルタはオプションパラメータとして提供。現行 DownloadableReport に投稿数セクションは無く、分析(統計)機能が集計の正 |
| 6 | media_list | mappy_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 等の削除済み列は使用不可 |
| 7 | keyword_list | GBP 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_id | integer 任意 | #1〜#6 | 省略時はスコープ内全店舗を対象(v2 慣行踏襲)。スコープ外 ID 指定は業務エラー(isError: true) |
limit / offset | integer 任意 | 一覧系(#1, #2, #3, #5, #6) | 一覧応答は既定 50 件 / limit 上限 200 |
date_from / date_to | date | 集計系(#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(完全一致)。
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 :offsetGROUP ユーザー(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 参照)。
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 配列前提である。
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 :offsetranking が 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=1。date_from / date_to 必須。InsightService::getInsightCountOfLocations(月間自動レポートの正解クエリ)をほぼそのまま流用する。
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_toinsight_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 フィルタなし = 分析(統計)機能の集計と同形)。
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 等の削除済み列は使用不可。
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、必須)。
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_id → mappy_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->integratedLocations(mappy_integrated_gbp_locations.user_id 一致) |
GROUP(user_type=2) | mappy_group_location 経由の許可店舗(MappyUser::locations() 既存実装準拠) |
user_type の扱い | Phase 1 は本人の可視範囲のみ。MASTER(user_type=3)の配下横断参照はしない(未確定事項 §12 参照) |
is_enabled=0 | 認証段階で 401(tool まで到達させない) |
search_ranking_enabled=0 | ranking_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.authがAuth::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.ai | △ | mcp-remote プロキシ経由(クライアント PC に Node.js 必要) |
| ChatGPT | ×(Phase 1 スコープ外) | 静的 Bearer 未対応。OAuth 2.1 実装 + search/fetch tool が必要なため対象外 |
Claude Code:
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 を経由):
{ "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 |
|---|---|---|---|
| GMAC | gmac-g.com | env.prod.template | true |
| GCOR | g-cor-m.com | env.gcor.prod.template | false |
| PIPIT(KingMeo) | king-meo.com | env.pipit.prod.template | false |
| 口コミONE | kuchikomione.com | env.kuchikomi-one.template | false |
コードは 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 | 設計者 |
| 2 | DB・モデル | migration mappy_mcp_tokens + モデル McpToken(§2.1 DDL 通り) | 1 回 | 0.5 日/回 | 0.5 | 製造者 |
| 3 | トークン管理 API | Admin\McpTokenController(一覧 / 発行 / 失効の 3 本、auth:admin_api グループ) | 1 回 | 0.5 日/回 | 0.5 | 製造者 |
| 4 | 管理画面 UI | McpTokenSettings.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 | 製造者 |
| 6 | MCP エンドポイント | McpController: JSON-RPC 2.0 ハンドラ(initialize / tools/list / tools/call / notification→202 / GET→405 / エラー表 §3.4 準拠 / Origin 検証)+ ツール定義層分離 | 2 回 | 0.5 日/回 | 1.0 | 製造者 |
| 7 | tool 実装(DB 系 6 本) | location_list / review_list / ranking_list / insight_summary / post_list / media_list — 既存 Service 流用 + テナンシ境界 + ページング | 3 回 | 0.5 日/回 | 1.5 | 製造者 |
| 8 | tool 実装(API 系 1 本) | keyword_list — GBP Performance API 直呼び(KeywordService 流用、getApiUser トークン解決、エラーハンドリング) | 1 回 | 0.5 日/回 | 0.5 | 製造者 |
| 9 | env・設定 | 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_summaryでdate_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. 未確定事項
| # | 項目 | 状態 |
|---|---|---|
| 1 | MASTER(user_type=3)ユーザーへのトークン発行可否とスコープ解決 | Phase 1 は MAIN / GROUP を前提。MASTER は発行対象外とするか実装時に確定 |
| 2 | tool description の言語 | 日本語のみ / 英語併記(v2 からの持ち越し。AI クライアントの解釈精度に影響) |
| 3 | throttle:60,1 の妥当性 | 月間レポート生成 1 回あたりの tools/call 回数を結合テストで実測して調整 |
| 4 | keyword_list の GBP API quota・失敗時リトライ方針 | Google 側レート制限に対する再試行 / 案内文言を実装時に確定 |
| 5 | expires_at の運用 | Phase 1 は無期限。将来既定 TTL(例: 1 年)を入れるかは運用開始後に判断 |
| 6 | mcp ログチャネルの保持日数 | daily ドライバの days 設定値(既存チャネルの慣行に合わせるか個別設定か) |
| 7 | star_rating の値域の最終確認 | 実装着手前に本番で SELECT DISTINCT star_rating を実行し、数値文字列(0〜5、UNSPECIFIED=0)であることを確定する(§4.4 参照) |
| 8 | MCPトークンタブのインタラクティブモック | 製造前レビュー時に wireframes/ の既存慣行(一覧 / 発行モーダル / 失効確認)で追加予定(§7 参照)。追加後に提案書へ画面モックセクションとして反映 |
13. 関連リンク
- 案件提案書(v3) — 本設計の親ドキュメント
- MCP サーバー ツール一覧(v2) — SQL 記述の流用元(アーカイブ)
- 案件提案書(v2) / アーキテクチャ全体像(v2) / 認証・テナンシ(v2) — 旧構成(アーカイブ)
- MCP 仕様: Streamable HTTP — 単発 request/response モードの根拠