Guide/guides

エージェントインターフェース

AI エージェントは MCP を通じてアプリケーションを呼び出します。Guren では、エージェント向けにもう1つアプリケーションを書く必要はありません。エージェントツールは、ルートがすでに持っているコントラクトから導出されます。ルートの params・query・body スキーマがツールの入力スキーマになり、output スキーマがツールの出力スキーマになり、ミドルウェアチェーンが検査するポリシーがツールの認可になります。

エージェントインターフェース

AI エージェントは MCP を通じてアプリケーションを呼び出します。Guren では、エージェント向けにもう1つアプリケーションを書く必要はありません。エージェントツールは、ルートがすでに持っているコントラクトから導出されます。ルートの paramsquerybody スキーマがツールの入力スキーマになり、output スキーマがツールの出力スキーマになり、ミドルウェアチェーンが検査するポリシーがツールの認可になります。

ツールクラスを書く必要も、2つ目の JSON Schema を同期させ続ける必要もありません。形が1つしかないため、エンドポイントが検証しない形をツールが宣伝することは起こりえません。そしてツールが呼ばれると、その呼び出しは実際の HTTP リクエストとしてアプリケーションに再入します。検証もミドルウェアもポリシーも、いつもと同じ場所でちょうど1回だけ実行されます。

公開はルート単位のオプトインです。宣言しない限り、どのルートもツールにはなりません。

// routes/web.ts
import { Router, authorizeMiddleware } from '@guren/core'
import { PostController } from '@/app/Http/Controllers/PostController'
import { CreatePostSchema, PostListSchema, PostSchema } from '@/app/Http/Validators/PostValidator'

export function registerWebRoutes(router: Router): void {
  router
    .get('/posts', { output: PostListSchema }, [PostController, 'index'])
    .name('posts.index')
    .agent({ description: 'List published posts, newest first.' })

  router
    .post('/posts', { body: CreatePostSchema, output: PostSchema }, [PostController, 'store'])
    .name('posts.store')
    .middleware(authorizeMiddleware('create'))
    .agent({ description: 'Create a blog post as the authenticated user.' })
}

アプリケーション側の変更はこれだけです。以下は、そこから何が生まれたかを確認し、公開し、締めるための話です。

エージェントから見えるものを確認する

bunx guren tool:list
Tool        | Method | Path   | MCP | WebMCP | Auth   | Annotations
-----------------------------------------------------------------------------
posts.index | GET    | /posts | yes | yes    | -      | read-only, idempotent
posts.store | POST   | /posts | yes | yes    | create | destructive

Total: 2 tools

tool:inspect は1つのツールの導出結果をすべて表示します。マージ済みの入力、出力スキーマ、認可の ability、アノテーション、そのツールに該当する警告です。

bunx guren tool:inspect posts.store
posts.store  POST /posts
Description:   Create a blog post as the authenticated user.
Exposure:      mcp=yes webMcp=yes
Annotations:   destructive
Authorization: create

Input
  title: string
  body: string

Output
{
  "type": "object",
  "properties": {
    "id": { "type": "number" },
    "title": { "type": "string" }
  },
  "required": ["id", "title"]
}

どちらのコマンドも、生成ファイルを読むのではなくルートグラフから直接導出します。そのため .guren/agents.gen.ts が存在しない場合や古い場合でも正しく答えます。--json を付けると導出結果そのものが出力されます。

bunx guren codegen は、ツールを1つ以上公開しているアプリに対して同じ導出結果を .guren/agents.gen.ts に書き出し、1つも公開していないアプリではそのファイルを削除します。CLI: エージェントツールコマンドも参照してください。

.agent() を宣言する

書き方は2通りあり、意味は同じです。ルートの他の記述と並べて読みやすいほうを選んでください。

// 登録したルートに対してチェーンする
router
  .post('/posts', { body: CreatePostSchema }, [PostController, 'store'])
  .name('posts.store')
  .agent({ description: 'Create a blog post as the authenticated user.' })

// ルートコントラクトのキーとして
router.post('/posts', {
  name: 'posts.store',
  body: CreatePostSchema,
  agent: { description: 'Create a blog post as the authenticated user.' },
}, [PostController, 'store'])

ルーターが登録時に強制するルールが2つあります。

  • オプションオブジェクトは第2引数、ハンドラーは最後の引数です。 router.post(path, options, handler) です。ルーターはオプションオブジェクトをキーの有無で判別し、そこには agent も含まれます。つまり agent だけを持つオブジェクトもハンドラーではなくオプションとして扱われます。
  • 宣言は1回だけです。 ルートオプションの agent.agent() チェーンを両方書くと例外になります。マージにしてしまうと、負けたほうの宣言が持っていたセキュリティ上重要なフィールド(approvalredact)が黙って落ちるためです。

ツール名はルート名がそのまま使われます。 MCP のツール名文法(^[A-Za-z0-9._-]{1,128}$)はドットを許すため、posts.store に変換は不要です。agent: { toolName: 'blog.createPost' } で上書きできるのは綴りだけで、要件そのものは変わりません。ツール名はツールの識別子そのものなので、.name() のないルートはツールになれず、guren check は failure として報告します。

リソースルート

resource() はアクションごとにメタデータを受け取ります。列挙しなかったアクションは公開されません

router.resource('/posts', PostController, {
  agent: {
    index: { description: 'List posts.' },
    show: { description: 'Fetch one post by id.' },
    // create/store/edit/update/destroy はルートとしては登録されるが、
    // エージェントツールにはならない
  },
})

デフォルト拒否であることが要点です。すべてのエンドポイントを自動でツールに変換するのは既知のアンチパターンで、肥大したカタログを作り、それを読むエージェントの性能を落とします。エージェントが実際に必要とする少数のルートだけを公開してください。なお、その resource() 呼び出しが登録しなかったアクション(onlyexcept で除外した、あるいはコントローラに存在しない)にメタデータを宣言すると例外になります。存在しえないツールへのメタデータは無視すべき記述ではなく、配線ミスだからです。

メタデータのフィールド

フィールド 意味
description ツールが何をするか。未指定ならルートの OpenAPI description、次に summary が使われます。あなたのアプリを見たことのないエージェントに向けて書いてください。
toolName ツール名をルート名から上書きします。
expose { mcp?, webMcp? } で、ツールが現れるプロトコル面を指定します。どちらも既定は true です。expose: { mcp: false } にすると MCP エンドポイントに載らなくなります。webMcp はまだ出荷されていないブラウザ側の面のために記録されます。
readOnlyHint ツールが何も変更しないという宣言です。アノテーションを参照してください。
destructiveHint false は「追加のみで破壊しない」という強い主張です。
idempotentHint 同じ引数で繰り返し呼んでも追加の効果がないという宣言です。
approval 'required' はサーバー側の承認が必要なツールであることを示します。承認キューが出荷されるまで、MCP エンドポイントは fail-closed で扱います。一覧にも出ず、呼び出しもできません。
redact 監査ログでマスクする引数のフィールド名です。監査ログを参照してください。

入力スキーマ

MCP はツールの入力が1つのオブジェクトであることを要求するため、ルートの paramsquerybody はこの順序で1つにマージされます。

router
  .get('/posts/:id/comments', {
    params: PostIdParamSchema,      // { id: number }
    query: CommentListQuerySchema,  // { page?: number, perPage?: number }
  }, [CommentController, 'index'])
  .name('posts.comments.index')
  .agent({ description: 'List the comments on one post.' })
Input
  id: number
  page?: number
  perPage?: number

押さえておきたい挙動は次のとおりです。

  • パスパラメータは常に必須です。 パスが宣言していて params スキーマが記述していないパラメータは、必須の文字列として補完されます。スキーマが記述しているパラメータも、スキーマの内容にかかわらず必須のままです。それがないと URL を組み立てられないためです(既知の制限: Hono のオプション修飾子 /posts/:id? も必須として提示されます。OpenAPI ドキュメントと同じ扱いです)。
  • オブジェクトでない body はネストします。 body が配列・プリミティブ・ユニオン・レコードの場合、フラットに展開されるのではなく body という1つのプロパティの下に入ります。ツールの入力はオブジェクトをルートに持つ必要があるためです。
  • キーの衝突はマージではなく報告されます。 2つのソースが同じキーを宣言した場合、後のほう(params → path → query → body の順)が勝ち、導出は双方を名指しする警告を出します。この警告は tool:list、該当ツールの tool:inspect、そして MCP プラグインが起動したときのサーバーログに現れます。どちらか一方をリネームしてください。マージされたツール入力の名前空間は1つだけです。
  • 提示される型はスキーマの入力側です。 z.coerce.default().transform() は、エージェントが書く側の型として描画されます。コントローラが受け取る型ではありません。実際の検証は従来どおりアプリケーション境界で1回だけ行われます。

body スキーマのないボディ付きルートは、パスとクエリだけから入力を導出することになり、エージェントはペイロードを推測するしかなくなります。guren check はこれを warn します。

出力

優先順位は3段です。

優先度 供給元 ツールが得るもの
1 ルートの output スキーマ JSON Schema の outputSchema と、成功呼び出しごとの structuredContent
2 resource ヒント スキーマはなし。bunx guren codegen が Resource から抽出した型テキストをツールの説明文に埋め込みます
3 どちらもなし 出力の形は一切なし。guren check が warn します

両方が宣言されている場合は output が勝ちます。ランタイムで検証される形は output スキーマだけであり、両方を持ち回ると1つのレスポンスに2つの記述が並び、両者を一致させ続けるものがなくなるためです。

structuredContent が提供されるのは、outputSchemaオブジェクトの場合だけです。MCP はそれ以外のルートを認めていません。output が配列やプリミティブのルートは structured なものを提示せず、結果はテキストとして返ります。

レスポンスがツール結果になるまでの対応は次のとおりです。

レスポンス 結果
2xx JSON テキストとしてシリアライズされ、ツールがオブジェクトの出力スキーマを提示している場合は structuredContent も付きます
2xx の Inertia ページ JSON page.props に展開されます。出力スキーマを持たないツールに限られるため、提示した形と結果が食い違うことはありません
204 / 3xx ステータスと Location を示すテキスト1行。エラー扱いにはなりません
4xx / 5xx isError: true として例外ハンドラーの JSON ボディを載せます。422 の { message, errors } はプロトコル障害ではなく、エージェントが読むべきアプリケーションの失敗です
JSON でないもの 上限付きのテキスト

この表を上書きするルールが1つあります。オブジェクトの出力スキーマを提示しているツールで、ルートがそれを満たせないもの(204、リダイレクト、JSON 配列、JSON でないボディ)を返した場合、成功結果ではなく不一致を名指しするエラー結果になります。ルートがすでに実行されたあとでクライアントに拒否されるより、その場で理由が分かるほうが有用だからです。

this.inertia(...) で応答するアクションは、ページがコンポーネントに渡した内容をそのまま返します。これは何も検査しない形であり、UI の変更で簡単に動きます。エージェント向けのルートでは outputthis.json(...) を使ってください。Inertia のケースは guren check が warn します。

アノテーション

MCP のアノテーションは、ツールをクライアントに説明するためのものです。Guren は3つすべてを明示的な値に解決するため、下流が既定値を再適用する必要はありません。

アノテーション 既定値
readOnlyHint GET と QUERY は true、それ以外は false
destructiveHint readOnlyHint の逆。read-only でないものに対する MCP 仕様の既定値が true です
idempotentHint GET・QUERY・PUT・DELETE は true

アノテーションはクライアント UX 向けのヒントであり、何も強制しません。 強制するのはポリシー(ブラウザからの場合とまったく同じく、ディスパッチされたリクエストの内側で評価されます)と、トークンのスコープ(リクエストが組み立てられる前に評価されます)です。だからこそ、検査を弱める側の2つの主張はコントローラ本体と突き合わせて検査されます。

  • readOnlyHint: true は認可ルールの適用を免除するものなので、read-only なツールのアクションがレコードを削除・更新・force-write していれば guren check が warn します。自分で書いたヒントだけでなく、GET・QUERY の既定値についても同じです。
  • destructiveHint: false は「追加のみ」という主張なので、アクションが削除・更新・force-write していれば guren audit が warn します。

認証は認可ではありません

エージェントはブラウザセッションではなくトークンで呼び出します。this.auth.userOrFail() が証明するのは誰が呼んでいるかであり、その呼び出し元がこのアクションを実行してよいかを決めるものではありません。認証だけで守られた read-only でないツールは、何かしらのトークンを持つあらゆる principal にそのアクション全体を渡すことになります。

そのため、read-only でないツールには次のいずれかが必要です。

// ルート側。こちらなら ability が導出可能になり、tool:list にも表示されます
router
  .delete('/posts/:id', { params: PostIdParamSchema }, [PostController, 'destroy'])
  .name('posts.destroy')
  .middleware(authorizeMiddleware('posts.destroy'))
  .agent({ description: 'Delete a post.' })
// またはアクションの中で
await this.authorize('delete', [Post, post])

this.can(...) では足りません。真偽値を返すだけで、何も強制しないためです。どちらもない read-only でないエージェントルートを、guren checkfailure として報告します。

ツールを公開する

ツールを配信するのは @guren/plugin-mcp です。エージェント向けの面を持たないアプリが MCP のトランスポートを抱え込まないよう、別パッケージになっています。

bunx guren plugin @guren/plugin-mcp
bun add @guren/plugin-mcp
// src/app.ts
import { createApp, EventServiceProvider, DatabaseApiTokenStore } from '@guren/core'
import { mcpPlugin } from '@guren/plugin-mcp'
import { apiTokens } from '@/db/schema'
import { registerWebRoutes } from '@/routes/web'

const app = createApp({
  routes: registerWebRoutes,
  providers: [EventServiceProvider, mcpPlugin()],
})

// 必須: エンドポイントはこのストアに対して bearer を検証します。
app.auth.useTokens(new DatabaseApiTokenStore(apiTokens))

export default app

エンドポイントは /mcp にマウントされ、ステートレスな streamable HTTP を話します。リクエストごとに MCP サーバーを1つ作るため、保持すべきセッションはありません。bearer 認証は必須なので、アプリは API トークンストアを設定する必要があります。

  • bearer がない、または無効・期限切れ・失効している場合: MCP のフレーミングに入る前に 401WWW-Authenticate: Bearer を返します
  • トークンストアがまったく設定されていない場合: auth.useTokens(store) を名指しする 500 を返します。設定ミスが、トークンを拒否したかのようにではなく設定ミスとして読めるようにするためです

このエンドポイントのために CSRF の例外を書く必要はありません。Authorization: Bearer を持ち、Cookie ヘッダーをまったく持たないリクエストは、フレームワーク全体で CSRF 検証をスキップします。守るべき ambient authority が存在しないためであり、ディスパッチャは構造上 cookie を持たない bearer リクエストを組み立てます。

設定

mcpPlugin({
  path: '/mcp',
  serverInfo: { name: 'blog', version: '1.0.0' },
  rateLimit: { max: 60, writeMax: 20, windowMs: 60_000 },
  updateLastUsed: true,
})
オプション 既定値 意味
path '/mcp' エンドポイントのマウント先
serverInfo { name: 'guren-app', version: '1.0.0' } クライアントに提示するサーバー識別情報
rateLimit { max: 60, writeMax: 20, windowMs: 60_000 } トークンごとの予算。false で無効化
updateLastUsed true bearer の検証時にトークンの lastUsedAt を更新するか

レート制限のキーは IP ではなくトークン ID です。予算は資格情報に紐づきます。プロセスメモリ上で強制されるため、常駐サーバーが1台なら正確に効き、複数インスタンスやサーバーレスではインスタンスごとの制限になります。全体で1つの予算にするには、共有ストアとアプリ自身のレート制限ミドルウェアが必要です。

エージェントルートにアプリ自身のレート制限ミドルウェアを置いても、この制限の代わりにはなりません。既定のキーはソケットのピアから作られますが、再入するリクエストはソケットを通っていないため、すべての MCP 呼び出し元がそのルートの共有バケットにまとまってしまいます。

トークンとスコープ

既存の ['*'] トークンはエージェントツールを1つも grant しません。 ツールスコープとして読まれるのは tool:tools: の ability だけで、それ以外の ability は、ApiToken の既定値である ['*'] を含めて何にもマッチしません。これは意図的な設計です。アプリが最初の .agent() ルートを宣言した瞬間に、エージェントツールが存在する前に発行された全トークンへエージェント面全体が渡ってしまう、という事故を防ぐためです。エージェント面へのアクセスは明示的に grant されるか、まったく grant されないかのどちらかです。

スコープの形は4つだけです。

スコープ grant する範囲
tool:posts.store そのツール1つだけ
tools:read 解決後の readOnlyHint が true のツールすべて
tools:posts.* posts.… という名前のツールすべて(ドットも一致条件なので、posts 自身は含まれません)
tools:* すべてのツール

スコープは加算的で、拒否の形はありません。トークンのスコープに含まれないツールは、単に拒否されるだけでなく tools/list に現れません。grant されていないカタログが、read-only なエージェントに書き込み面の地図を渡してしまわないようにするためです。

トークンを発行する

bunx guren token:issue --name blog-reader --user 42 --tools 'tools:read' --expires 30d
✔ Issued token "blog-reader" for user 42.

Token (shown once — it is stored hashed and cannot be recovered)
  1|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Expires  2026-09-29T09:00:00.000Z
Abilities  tools:read
Granted tools
  read: posts.index, posts.show
  write: (none)

書き込みも行うエージェントには、このトークンのスコープを広げるのではなく、別のトークンを発行します。

bunx guren token:issue --name blog-writer --user 42 --tools 'posts.store' --expires 30d

--tools は短縮形も受け付けます。名前だけなら tool:<name>posts.*tools:posts.*readtools:read*tools:* になります。

オプション 意味
--name 必須。誰かが失効させるときにこのトークンを識別する名前です。
--user 必須。トークンが認証する対象のユーザー ID です。
--tools 必須。カンマ区切りのスコープです。
--read-only grant を read-only なツールに限定します。
--expires 30d12h45m。省略すると期限なしのトークンになります。
--allow-unmatched 現在どのツールにもマッチしないスコープを受け入れます。
--yes tools:* を受け入れるために必須です。
--json 発行したトークンを JSON で出力します。警告も含まれます。

このコマンドは warn より refuse を選びます。資格情報を扱うコマンドラインのタイプミスは、まだ画面を見ている間に直すのが一番安いからです。

  • 現在どのツールにもマッチしないスコープは拒否されます。 タイプミスであるか、latent grant であるかのどちらかです。latent grant とは、マッチするツールが追加された瞬間に、誰の同意もなく有効になる保存済みパターンのことです。--allow-unmatched で上書きでき、そのときはまさにその点を warn します。
  • tools:* には --yes が必要です。 アプリが今公開しているツールと、これから増えるツールのすべてを grant します。破壊的なものも含めてです。
  • --read-only は具体的なエントリを保存します。 grant は発行時に展開され、パターンではなく tool:<name> エントリとして書き込まれます。この文法には「posts.* の read-only な部分集合」という形が存在しないためです。これは fail-closed です。あとから posts. ファミリーに書き込み系ツールが追加されても、保存済みエントリのどれにも加わりません。また --read-only のもとでは、マッチしないスコープは --allow-unmatched を付けても拒否されます。あとから何かを grant することも決してできないためです。

発行時の警告は2つあり、どちらも拒否ではありません。

  • 期限なしのトークンは、誰かが手で失効させるまで有効なままです
  • 読み取り系と書き込み系のツールを両方含むトークンは、既知のインジェクション事例が取った形そのものです。攻撃者の影響下にあるコンテンツを読み、かつ書き戻せるエージェントは、そのコンテンツに誘導されうるからです。可能なら2つのトークンに分けてください

監査ログ

すべての呼び出しと、すべての拒否がフレームワークのイベントとして発行されます。イベントをすでに転送している先へ、そのまま転送できます。

イベント 発生タイミング 保持する情報
AgentToolInvoked 呼び出しがアプリケーションに到達した principaltoolargumentsstatusdurationMssurface
AgentToolDenied HTTP が発生する前にアダプタが拒否した principaltoolargumentsreasonsurface

reason'auth''scope''approval''rate-limit' のいずれかで、これはリクエストに先立つ検査そのものです。ポリシーによる拒否はここに含まれません。 ポリシーはディスパッチされたリクエストの内側で評価されるため、ステータス 403AgentToolInvoked として届きます。拒否はステータスも所要時間も持ちません。何も実行されていないからです。

// app/Providers/EventServiceProvider.ts など、リスナーを登録している場所
import { AgentToolInvoked, AgentToolDenied, createFacades } from '@guren/core'

const { Events, Log } = createFacades(app.container)

Events.on(AgentToolInvoked, (event) => {
  Log.info('agent tool invoked', {
    tool: event.tool,
    principal: event.principal?.id,
    status: event.status,
    durationMs: event.durationMs,
    arguments: event.arguments,
  })
})

Events.on(AgentToolDenied, (event) => {
  Log.warn('agent tool denied', { tool: event.tool, reason: event.reason })
})

これらが動くにはイベントマネージャがバインドされている必要があります。mcpPlugin() と並べて EventServiceProvider(またはアプリ自身のイベントプロバイダ)を登録してください。ないままだとプラグインは起動時に警告を出し、イベントを1つも発行しません。

redaction

event.arguments は、イベントが構築される前にマスクされます。2つのソースの和集合です。1つはすべてのアプリが何もせずに得られる機微なキー断片の組み込みリスト(passwordpassphrasesecrettokenapikeyauthorizationcredentialcookiesession)、もう1つはルート自身の redact メタデータです。

router
  .post('/integrations', { body: CreateIntegrationSchema }, [IntegrationController, 'store'])
  .name('integrations.store')
  .agent({
    description: 'Connect an external integration.',
    redact: ['webhookUrl'],
  })

一致判定は意図的に大雑把で、安全な側に倒してあります。

  • キーは、小文字化して区切り文字を除いた名前が断片を含むときに一致します。したがって apiKeyapi_keyx-api-key はいずれも apikey ひとつでカバーされます
  • 同じ包含判定が自分で宣言したエントリにも適用されるため、redact: ['id']userId もマスクします
  • 値の形より先にキーが決めます。token という名前のキーの下にあるネストしたオブジェクトは、走査されずまるごとマスクされます

マスクされた値は [REDACTED] に置き換えられます。走査は必ず終わります。循環は [Circular] に、極端に深いペイロードは [Truncated] になります。これは「何かが起きた」ことを記録している最中に走る処理であり、ルート自身の検証より前に取られた拒否も含むためです。

dev MCP は別のエンドポイントです

Guren は以前から MCP エンドポイントを提供していますが、それはこのエンドポイントではありません。2つは区別してください。

dev MCP アプリ MCP
パス /_guren/mcp /mcp(設定可能)
提供元 フレームワーク本体 @guren/plugin-mcp
操作対象 ディスク上のプロジェクト アプリケーションのデータ
想定利用者 自分のコーディングエージェント ユーザーがアプリに向けるエージェント
ゲート GUREN_MCP=1 かつ検証済みの loopback ピア。fail-closed bearer トークン、次にそのツールスコープ
ツール フレームワーク固定のツール(context・checks・scaffolding) .agent() を付けたルート
本番環境 存在しない。ゲートは各デプロイプラグインがバンドル時に確定させる マウントされる

GUREN_MCP=1 で動かしている開発サーバーの前にトンネルを置いてはいけません。このエンドポイントはプロジェクトにファイルを書き込めます。開発側の話はスペック起点の開発を参照してください。

チェックが守るもの

これらのルールは通常の bunx guren check スイートで実行され、内容によって有効化されます。エージェントルートのないアプリでは findings は生成されず、コントローラの走査も行われません。

checkfailure とするのは、名前のないエージェントルート、MCP の文法から外れたツール名、同じツール名に解決される2つ以上のルート、そしてミドルウェアチェーンに認可 capability がなくアクションでも this.authorize(...) を呼んでいない read-only でないツールです。

checkwarn するのは、出力の形がないこと、Inertia レスポンス、body スキーマのないボディ付きルート、アクションが変更を伴う read-only ツール、そして判定に到達できなかった場合(インラインハンドラ、読み取れなかったコントローラファイル、同名のコントローラクラスが2つある場合)です。

bunx guren audit は同じルートをより厳しく扱います。通常のルートでは warning になるボディ検証の finding が、エージェント公開ルートでは failure になり、レコードを削除・更新・force-write するアクションの destructiveHint: false は warn になります。

finding key の一覧は CLI: エージェントに公開したルートにあります。

関連

  • ルーティング: エージェントツール: 他のルートコントラクトの中で .agent() がどこに位置するか
  • API トークン: MCP エンドポイントが bearer を検証する相手のストア
  • 認可: principal が何をしてよいかを決めるポリシー
  • イベント: リスナーの登録方法とイベントマネージャ
  • CLI: tool:listtool:inspect と check・audit の finding key