MCP認証・認可
LaseのMCP経路は、個人利用にOAuth 2.1 Authorization Code + PKCE、AIエージェントにエージェントのトークン(Service Token)を使用する。エージェントについてはagents.mdを参照する。旧Sanctum PAT方式はMCPから削除した。Sanctumは通常のWeb/APIセッション用途では引き続き利用できる。
本番要件と環境変数
- HTTPSを必須にする(
localhostと*.localhostだけは開発用HTTPを許容)。 - PHPの
ext-sodiumを導入する。 - Passport署名鍵は環境ごとに一度だけ生成し、秘密鍵をSecret Managerから
PASSPORT_PRIVATE_KEYへ注入する。公開鍵は秘密鍵からLaseが作るため渡さなくてよい。デプロイごとに再生成してはいけない。 - Proxy配下ではLaravelのtrusted proxyを正しく設定し、外部URLがHTTPSで生成されるようにする。
MCPをまだ提供しないアプリは、LASE_MCP_ENABLED=falseを設定する。このときOAuth Discovery・Token・認可・動的クライアント登録と、テナントのService Token・OAuth同意APIは登録されない。TenantMcpRegistrarによるMCP endpointの登録も例外で拒否される。
| 変数 | 既定値 | 用途 |
|---|---|---|
LASE_MCP_ENABLED | true | MCPのルート・API・endpoint登録を有効化する。未提供のアプリではfalseを設定する |
PASSPORT_PRIVATE_KEY | なし | OAuth Access Tokenを署名するPassport秘密鍵。Secret Managerから注入する |
PASSPORT_PUBLIC_KEY | 秘密鍵から作る | OAuth Access Tokenの署名を検証するPassport公開鍵。秘密ではない。空なら、LaseがPASSPORT_PRIVATE_KEYから作る(鍵を替えるときに食い違わないよう、通常は渡さない) |
LASE_MCP_OAUTH_ISSUER | APP_URL | Authorization Serverのissuer。外部から到達できる固定URL |
LASE_MCP_RESOURCE_URI_TEMPLATE | https://{tenant}.APP_DOMAIN/mcp | tenantごとの正規resource URI |
LASE_MCP_ACCESS_TOKEN_TTL_MINUTES | 60 | Access Token TTL |
LASE_MCP_REFRESH_TOKEN_TTL_DAYS | 30 | Refresh Token TTL |
LASE_MCP_SERVICE_TOKEN_TTL_DAYS | 90 | Service Token既定TTL |
LASE_MCP_SERVICE_TOKEN_MAX_TTL_DAYS | 365 | Service Token最大TTL |
LASE_MCP_REQUIRE_ACTIVE_SUBSCRIPTION | true | 現在の契約期間外のtenantを拒否 |
LASE_MCP_RATE_LIMIT_PER_MINUTE | 60 | tenant/principal/IP単位の上限 |
LASE_MCP_ALLOWED_ORIGINS | 空 | 同一Origin以外に追加で許可するOrigin |
php artisan migrateはOAuth、consent、Service Token、監査ログ用テーブルを作成し、tenants.mcp_enabledを既定値trueで追加する。無効tenant、削除tenant、契約期間外tenantは各リクエストで拒否する。契約の判定が製品固有の場合はMcpTenantEligibilityをアプリ側で再bindする。
McpTenantEligibility::denialReason(Tenant)は、使えるならnullを、使えないならその理由(McpDenialReason)を返す。
| 理由 | 条件 | 画面・エラーの説明 |
|---|---|---|
TENANT_UNAVAILABLE | 削除済み・緊急停止中 | 組織が利用停止中のため、MCP 連携を使えません。 |
MCP_DISABLED | tenants.mcp_enabledがfalse | この組織では MCP 連携が無効になっています。使うには運営にお問い合わせください。 |
NO_ACTIVE_SUBSCRIPTION | 現在の契約が無い・期間外(LASE_MCP_REQUIRE_ACTIVE_SUBSCRIPTIONがtrueのとき) | 有効な契約がないため、MCP 連携を使えません。契約の内容を確かめてください。 |
使えないtenantでService Tokenを発行・rotationすると、403で{"code": "MCP_UNAVAILABLE", "reason": "<理由>", "message": "<説明>"}を返す(McpUnavailableException)。エージェントの一覧(GET /api/tenant/agents)はmcp(available・reason・message)を返し、エージェントの画面は理由を示してトークンの発行を止める。契約が無いときは、契約の画面(settings.billing.plan)を置いていれば案内する。
mcp_enabledは運営管理画面のテナント編集から切り替える。無効にすると、そのtenantへのMCP接続は発行済みService Token・OAuth Tokenを含めてすべて拒否される。
緊急停止中(tenants.suspended_atが設定されている)のtenantも同様に、発行済みトークンを含めてすべて拒否される。
Passport 13は上記の標準環境変数を直接参照する。通常のデプロイ処理ではphp artisan passport:keysを実行しない。鍵の変更は既存OAuth Tokenを無効化するため、初回構築または明示的な鍵ローテーション手順としてのみ実施する。Service Token認証はPassport鍵を使用せず、OAuth Bearer Tokenを検証するときだけPassport公開鍵を解決する。
PASSPORT_PUBLIC_KEYが空でPASSPORT_PRIVATE_KEYがあれば、Laseは起動時に秘密鍵から公開鍵を作ってpassport.public_keyに入れる(PassportPublicKey)。公開鍵を渡していればそれを使い、どちらも無ければstorage/oauth-*.keyを使う。
鍵が環境変数にもstorage/oauth-*.keyにも無いと、/oauth/authorizeと/oauth/tokenはパラメーターにかかわらず500になり、ログにLogicException: Invalid key supplied(CryptKey.php)が出る。PEMは改行を含むため、.envには改行を\nにした1行を二重引用符で書く(Passportが改行に戻す)。デプロイ後は、/oauth/authorizeをパラメーター無しで開いて400番台が返ることを確かめる。
$this->app->bind(
\CodebaseJp\Lase\Contracts\McpTenantEligibility::class,
\App\Mcp\FlowtTenantEligibility::class,
);final class FlowtTenantEligibility implements \CodebaseJp\Lase\Contracts\McpTenantEligibility
{
public function denialReason(Tenant $tenant): ?McpDenialReason
{
// 使えるなら null、使えなければ理由を返す
}
}Service Tokenは必ずAIエージェント(users.type = AGENT)に紐づき、認証するとそのエージェントとして動く。発行者が退職・停止しても自動失効せず、明示的な失効、エージェントの停止・削除、またはtenantの利用停止で止める。発行者IDは追跡用に保持する。
Discovery
MCPクライアントは未認証アクセスの401とWWW-Authenticateのresource_metadataから次を発見する。
/.well-known/oauth-protected-resource{/resource/path}: RFC 9728 Protected Resource Metadata/.well-known/oauth-authorization-server: RFC 8414 Authorization Server Metadata/oauth/authorize: 認可画面/oauth/token: token endpoint
認可・tokenリクエストの両方に同じRFC 8707 resourceを渡す。Access Tokenは単一resource/tenantに固定され、別ホスト・別パス・別tenantでは拒否される。OIDCログインを提供していないためOIDC Discoveryは公開しない。
MCPクライアントは、Authorization Server Metadataが見つかるまで、issuer以外の場所(tenantのドメイン、パスを付けた/.well-known/oauth-authorization-server/mcp、OIDCの/.well-known/openid-configurationと/mcp/.well-known/openid-configurationなど)も探す。Laseはこれらの場所に404(JSON)を返す。アプリの受け皿(SPAのRoute::fallbackなど)が画面のHTMLを200で返すと、クライアントはJSONとして読めずにそこで失敗するため。issuerの/.well-known/oauth-authorization-serverだけは情報を返す。OAuth Client ID Metadata Documentsのリモート取得は、SSRFを避けるため未対応であり、metadataでもclient_id_metadata_document_supported: falseを返す。Clientの登録には、次節の動的クライアント登録(RFC 7591)を使う。
OAuth Client登録と個人認可
動的クライアント登録(既定)
MCPクライアントはregistration_endpoint({issuer}/oauth/register)へRFC 7591の登録要求を送ることで、事前登録なしに接続できる。Discoveryのメタデータにregistration_endpointを広告するため、対応クライアントは自動で登録する。
curl -X POST https://flowt.example.com/oauth/register \
-H 'Content-Type: application/json' \
-d '{"client_name":"Claude Code","redirect_uris":["http://localhost:33418/callback"]}'{
"client_id": "9f1c...",
"client_id_issued_at": 1788000000,
"client_name": "Claude Code",
"redirect_uris": ["http://localhost:33418/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}token_endpoint_auth_methodは認可サーバーのメタデータが広告するnone・client_secret_basic・client_secret_postを受け付ける。none以外を指定した場合はconfidential clientとして登録し、client_secretとclient_secret_expires_at(無期限を表す0)を返す。平文のsecretはこの応答でしか取得できない。ネイティブクライアントはPKCEを使うpublic client(none)を選ぶ。
受け入れるRedirect URIは次のとおり。1回の登録で最大5個まで、grant_typesはauthorization_codeとrefresh_token、response_typesはcodeを受け付ける。
| スキーム | 可否 |
|---|---|
https | 許可 |
http | ループバック(localhost・127.0.0.1・::1・*.localhost)のみ許可 |
| 独自スキーム(RFC 8252 §7.1のprivate-use URI scheme) | 許可。逆引きDNS形式(com.example.app:/oauth2redirect/cb)を推奨 |
javascript・data・file・about・blob・ws/wssなど | 拒否 |
いずれの場合もfragment・userinfo・ホストのwildcardは拒否する。
登録しただけではテナントのデータへ到達できない。実際の利用にはユーザーのログインと、テナント・Scopeを選ぶ同意が必要であり、同意は個人の設定の「連携中のアプリ」(account.connected-apps)から解除できる。誰でも登録できる経路のため、既定で1時間あたり10回のレート制限をかけている。client_nameは登録側が自由に設定できるため、同意画面には認可後の転送先(redirect_uri)も表示し、ユーザーが名前だけで判断しないようにしている。
自動登録を止める場合はLASE_MCP_DYNAMIC_REGISTRATION_ENABLED=falseを設定する。無効にするとエンドポイントを登録せず、メタデータにもregistration_endpointを広告しない。
事前登録(動的登録を無効にする場合)
php artisan passport:client --public --name="固定済みのMCP Client名" \
--redirect_uri="http://localhost:PORT/callback"このコマンドはRedirect URI確定後の初回構築時に一度だけ、管理された運用作業として実行し、発行されたClient IDをクライアント設定へ安全に反映する。Redirect URIはクライアントが送る値と完全一致させる。ネイティブクライアントの多くはhttp://localhost:{port}/callbackを使うため、127.0.0.1で登録すると一致しない点に注意する。
利用中のPassportバージョンで--publicがない場合は対話式コマンドでpublic clientを選ぶ。wildcard redirect URIは禁止する。Clientのgrant_typesはauthorization_codeとrefresh_tokenだけにする。固定Clientの冪等初期登録を将来実装する場合も、Client IDを安定させ、登録済みRedirect URIとの完全一致を確認する独立した初期構築処理とし、通常デプロイからは分離する。
認可リクエストにはresponse_type=code、登録済みclient_id/redirect_uri、推測困難なstate、code_challenge_method=S256、PKCE challenge、scope、絶対resourceが必要である。Laseはstateの存在を必須化してそのまま返す。state値の一致検証はOAuth仕様どおりclient側の責務である。認可画面はログイン済みユーザーのACTIVE membershipだけを表示し、CSRF保護されたPOSTでtenantを確定する。コードはPassportにより短時間・単回利用となり、refresh tokenは利用のたびにrotate/revokeされる。
ユーザーはGET /api/tenant/mcp-oauth-consentsでClient別同意を確認し、DELETE /api/tenant/mcp-oauth-consents/{id}でClient単位に同意、Access Token、Refresh Tokenを一括失効できる。
ScopeとTool
何ができるかは、実行者(人・エージェント)の役割の権限だけで決める。トークンで決めるのは、読むだけか・書き込みも許すかの1点だけにする。2つのリストを持つと、役割を変えたときにトークンとずれるためである。
| Scope | 意味 | 使える Tool |
|---|---|---|
mcp:read | 読むだけ | 読むだけの Tool(readOnlyHint) |
mcp:write | 読み書き(mcp:readも付く) | すべての Tool |
- Scope は Lase が
CodebaseJp\Lase\Enums\McpScopeで固定し、アプリは追加しない。Tool に Scope を書く必要もない(Tool の印から決まる) - OAuth の同意画面では「読むだけ」か「読み書き」を選ぶ。クライアントが Scope を指定しなければ、両方を求めたことにする
- エージェントのトークンは範囲を絞らない(常に読み書き)。用途ごとに絞りたいときは、エージェントを分けて役割を変える
- 使えない Tool は
tools/listに出さず、呼ばれてもエラーにする - 役割の権限を変えると、発行済みのトークンにもすぐ効く(同意のやり直しは要らない)
ToolはTenantToolを継承する。handleTenantには認証方式に依存しないMcpPrincipalContextが渡る。読むだけの Tool には#[IsReadOnly]を付ける(付けないと書き込みの Tool としてmcp:writeが要る)。
use CodebaseJp\Lase\Contexts\McpPrincipalContext;
use CodebaseJp\Lase\Mcp\Tools\TenantTool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
final class ListWorkflows extends TenantTool
{
protected function handleTenant(Request $request, Tenant $tenant, McpPrincipalContext $principal): Response
{
return Response::structured(['workflows' => Workflow::where('tenant_id', $principal->tenantId)->get()]);
}
}既存tenant permissionも要求する場合はrequiredPermission()をoverrideする。エージェントも人と同じく、付けた役割の権限で判定する(役割が無ければ、この種のToolはエラーになる)。権限は、MCPのendpointに付くauthorize:tenantミドルウェアが読み込む。
画面と同じ API を呼ぶ Tool(ApiTool)
画面と同じ API があるものは、ApiToolを継承して API を呼ぶ Tool にするのを基本とする。Tool に業務処理を書かないため、画面とエージェントで動き(権限・入力の検証・通知・履歴)がずれない。
use CodebaseJp\Lase\Mcp\Tools\ApiTool;
class AddTaskComment extends ApiTool
{
protected string $description = 'タスクにコメントします。';
// {名前} は同じ名前の引数で埋め、残りの引数は GET ならクエリ、それ以外なら JSON の本文として送る
protected string $endpoint = 'POST /api/tenant/tasks/{taskId}/comments';
public function schema(JsonSchema $schema): array
{
return [
'taskId' => $schema->string()->description('タスクID。')->required(),
'comment' => $schema->string()->description('コメントの本文。')->required(),
];
}
}- API は
InternalApiがプロセスの中から呼ぶ。外からの HTTP を経由しないため、エージェントのトークンは MCP の入口でしか通らない。呼び出しは MCP のリクエストと同じホスト(テナント)・接続元で、認証した主体のまま、画面と同じミドルウェア(テナントの特定・auth・authorize:tenant・ルートの権限)とコントローラーを通る - 認証やブラウザ由来のヘッダー(
Authorization・Cookie・Origin・Referer)と、メソッドを上書きするヘッダー(X-HTTP-Method-Override)は引き継がない。GET のクエリは本物の HTTP と同じく文字列にする(trueは"1") - 成功した応答はそのまま返す(一覧の配列は
itemsに入れる)。失敗は、API のmessageと入力の誤り(errors)をまとめて Tool のエラーにする - 何を公開するかは、Tool を用意するかどうかで決める(API の側には印を付けない)。エージェントに使わせたくない操作は Tool を作らない
- 権限は API のルートで確かめるため、
ApiToolではrequiredPermission()を指定しなくてよい - MCP の印(読むだけか・取り消せない操作か)は、呼ぶ API の HTTP メソッドから自動で付ける(GET は読むだけ、DELETE は取り消せない操作、それ以外は取り消せる書き込み)。POST でも取り消せない操作(中止など)は、クラスに
#[IsDestructive]を付ける。読むだけの印は Scope の判定にも使うため、GET の Tool はmcp:readで、それ以外はmcp:writeで使える - API に送るのは
schema()に書いた引数だけ。API が受け付ける項目でも、schema()に無いものはエージェントから変えられない - パスの
{名前}に入れる値に/を含む場合と、値が.・..の場合は、API を呼ばずにエラーにする(Tool に無い別の API に届かないようにするため) - 引数の名前は API の項目名(画面と同じ)に合わせる。引数を変換したいときは
arguments()、結果の形を変えたいときはpresent()を override する
返す項目を選ぶ(fields)
画面の API の応答は、画面の描き直しのために関連まで含んで大きいことが多い。AI が毎回すべてを読むとトークンを使いすぎるため、どのApiToolにも、返してほしい項目を選ぶfields引数を自動で加える(各 Tool のschema()には書かない。API にも送らない)。
{ "taskId": "…", "fields": ["id", "name", "status.name", "taskAssignments.role", "taskAssignments.tenantUser.user.name"] }- 項目は、点でつないだ道筋の配列で指定する。一覧(配列)は、中の1件ずつに同じ指定を当てはめる
*はその階層の入れ子でない値(文字列・数値・真偽値・null)すべて(例:["*", "taskAssignments.*"])。*は道筋の最後にだけ書ける- 無い項目は無視する。API の応答から削るだけなので、もともと返さない項目が見えることはない
- ページ送りの一覧(
dataとmetaを持つ応答)は、dataの1件ずつに当てはめ、meta(件数・ページ)は残す - 省略したときは、読むだけの Tool(GET)はすべて、書き込みの Tool は上の階層の入れ子でない値だけ(
["*"])を返す。Tool ごとに変えるときは$defaultFieldsに書く
class GetMe extends ApiTool
{
protected string $endpoint = 'GET /api/auth/me';
// 契約などの情報は、エージェントの仕事に要らないため既定では返さない
protected ?array $defaultFields = ['id', 'name', 'type', 'currentTenantUser.id'];
}- 削るのは API が応答を組み立てたあとのため、データベースから読む量は変わらない(減るのは AI が読む量)
アプリの API が満たすこと
ApiToolで呼ぶアプリの API は、次を満たすこと。
- トークンで認証しない。 画面の API はセッション(
authのwebguard)だけで認証し、auth:apiなどのトークンの guard を付けない。MCP のトークン(エージェント・OAuth)が MCP の入口以外で使えると、Tool で公開範囲を絞った意味がなくなる。アプリは「MCP のトークンで画面の API を呼ぶと 401」になることをテストで確かめておく - セッションの状態に頼らない。
InternalApiの呼び出しにはセッションが無い。選択中のものをセッションに覚えるような API は、Tool から正しく動かない - JSON で答える。 応答はそのまま AI に渡す。ファイルのアップロード・ダウンロードは対象外(署名付き URL などの別の仕組みにする)
- テナントのサブドメインで動く。
InternalApiは MCP のリクエストと同じホストへ送る
MCP endpoint登録
Route::domain('{tenantCode}.'.config('app.domain'))->group(function () {
app(\CodebaseJp\Lase\Mcp\TenantMcpRegistrar::class)
->web('/mcp', \App\Mcp\Servers\FlowtServer::class);
});順序はchallenge付与、Origin検証、tenant特定、OAuth/Service Token認証、tenant・user・membership・resource検証、監査、rate limit、Tool実行である。challenge付与はlaravel/mcpがresource_metadataなしで上書きするWWW-Authenticateを最も外側で設定し直し、401応答から必ずProtected Resource Metadataへ辿れるようにする。McpPrincipalContextにはtype (user=人の代理のOAuth / agent=エージェントのトークン)、ID、tenant、scopes、該当するuser/client/token IDが入る。どちらもuserIdが入り、認証した主体はauth()->user()でも取れる。
Toolが監査へ業務属性を残す場合は、秘密情報や個人情報を除いた値だけを明示する。文字列は512文字に制限され、未加工の引数は自動記録されない。
app(\CodebaseJp\Lase\Mcp\McpAuditAttributes::class)->add('workflow_id', $workflow->id);エージェントとトークンの運用
組織の設定(createSettingsRoutes())の「エージェント」を使うか、以下のAPIを利用する。操作にはtenant permission mcp.service-tokens:manageが必要である。利用アプリ独自のpermission enumにも同じ値を追加する。
GET /api/tenant/agents(エージェントとトークンの一覧)POST /api/tenant/agents/PUT /api/tenant/agents/{id}/DELETE /api/tenant/agents/{id}POST /api/tenant/agents/{id}/tokens(トークンの発行)POST /api/tenant/mcp-service-tokens/{id}/rotateDELETE /api/tenant/mcp-service-tokens/{id}
import { useAgentApi, useMcpServiceTokenApi } from '@codebase-jp/lase/api/tenant';
import { createSettingsRoutes } from '@codebase-jp/lase/routes/tenant';平文は発行/rotationレスポンスで一度だけ返り、DBには高エントロピーtokenのSHA-256だけを保存する。token単位で期限、IP/CIDR allowlist、最終利用日時、失効日時を持つ(範囲は持たず、エージェントの役割の権限で決まる)。連携先ごとに個別発行する。
通常のrotationは新tokenを安全な保管先へ設定して疎通後に旧tokenを失効する。APIのrotate操作は旧tokenを即時失効するため、停止時間を避ける場合は新規発行→切替→旧token失効の順に行う。漏洩時は対象tokenを即時DELETEし、OAuthならconsentを取り消し、mcp_audit_logsでprincipal、Tool、IP、request IDを確認する。監査ログはAuthorization header、token、未加工Tool引数を保存しない。
Flowt移行
- 旧
LASE_MCP_GUARD、LASE_MCP_ABILITY、mcp:usePAT発行処理を削除する。 - Scope は Lase の
mcp:read・mcp:writeだけを使う(アプリ独自の Scope は持たない)。 handleTenantの第3引数をUserからMcpPrincipalContextへ変更する。- MCP endpointの正規URIを
LASE_MCP_RESOURCE_URI_TEMPLATEと一致させる。 - 環境ごとに一度生成したPassport秘密鍵をSecret Managerから
PASSPORT_PRIVATE_KEYへ注入する(公開鍵はLaseが秘密鍵から作る)。 - OAuth Clientは動的クライアント登録で登録されるため、事前登録は要らない。動的登録を無効にする場合だけ、Redirect URI確定後に完全一致で一度だけ事前登録する。
- migration、署名鍵、Discovery、OAuth、Service Token、tenant分離、失効をstagingで確認する。