Agent Interface
AI agents call applications over MCP. Guren does not ask you to write a second
Agent Interface
AI agents call applications over MCP. Guren does not ask you to write a second
application for them: an agent tool is derived from the contracts a route
already carries. The route's params, query and body schemas become the
tool's input schema, its output schema becomes the tool's output schema, and
the policy its middleware chain checks becomes the tool's authorization.
There is no tool class to write and no second JSON Schema to keep in sync. A tool cannot advertise a shape the endpoint does not validate, because there is only one shape — and when a tool is called, the call re-enters your application as a real HTTP request, so validation, middleware and policies run exactly once, in the place they already run.
Exposure is opt-in per route. Nothing becomes a tool until you say so.
// 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.' })
}
That is the whole change to the application. Everything below is about seeing what it produced, serving it, and locking it down.
Seeing what an agent sees
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 shows one tool's whole derivation — the merged input, the output
schema, the authorization ability, the annotations, and any warning that
applies to that tool:
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"]
}
Both commands derive live from your route graph rather than reading a
generated file, so they answer correctly even when .guren/agents.gen.ts is
missing or stale. Pass --json to either for the raw derivation.
bunx guren codegen writes the same derivation to .guren/agents.gen.ts for
apps that expose at least one tool, and removes the file for apps that expose
none. See CLI — Agent Tool Commands.
Declaring .agent()
Two spellings, and they are equivalent. Use whichever reads better beside the rest of the route:
// Fluent, after the route is registered
router
.post('/posts', { body: CreatePostSchema }, [PostController, 'store'])
.name('posts.store')
.agent({ description: 'Create a blog post as the authenticated user.' })
// As a route contract key
router.post('/posts', {
name: 'posts.store',
body: CreatePostSchema,
agent: { description: 'Create a blog post as the authenticated user.' },
}, [PostController, 'store'])
Two rules the router enforces at registration:
- The options object is the second argument, the handler the last.
router.post(path, options, handler). The router recognizes an options object by its keys —agentincluded — so an object carrying onlyagentis still options and not a handler. - Declare it once. Passing
agentin the route options and chaining.agent()throws. A merge would silently drop security-relevant fields (approval,redact) from whichever declaration lost.
The tool name is the route name, verbatim. The MCP name grammar
(^[A-Za-z0-9._-]{1,128}$) permits dots, so posts.store needs no
transformation. agent: { toolName: 'blog.createPost' } overrides the
spelling, not the requirement: a route with no .name() cannot become a tool,
because the name is the tool's identity. guren check fails on one.
Resource routes
resource() takes per-action metadata, and an action not listed is not
exposed:
router.resource('/posts', PostController, {
agent: {
index: { description: 'List posts.' },
show: { description: 'Fetch one post by id.' },
// create/store/edit/update/destroy are registered as routes,
// but they are not agent tools
},
})
Deny by default is the point. Auto-converting every endpoint into a tool is the
known anti-pattern: it produces oversized catalogs that degrade the agents
reading them. Expose the few routes an agent actually needs. Declaring metadata
for an action this call did not register (excluded via only/except, or
absent from the controller) throws — a tool that cannot exist is a wiring
mistake, not a no-op.
Metadata fields
| Field | Meaning |
|---|---|
description |
What the tool does. Falls back to the route's OpenAPI description, then its summary. Write it for an agent that has never seen your app. |
toolName |
Overrides the route name as the tool name. |
expose |
{ mcp?, webMcp? } — which protocol surfaces the tool appears on. Both default to true; expose: { mcp: false } keeps a tool out of the MCP endpoint. webMcp is recorded for a browser surface that is not shipped yet. |
readOnlyHint |
The tool changes nothing. See Annotations. |
destructiveHint |
false is the strong claim "additive updates only". |
idempotentHint |
Repeat calls with the same arguments add no effect. |
approval |
'required' marks the tool as needing server-side approval. Until an approval queue ships, the MCP endpoint fails closed on it: the tool is neither listed nor callable. |
redact |
Argument field names to mask in the audit trail. See The audit trail. |
The input schema
MCP requires a tool input to be a single object, so the route's params,
query and body are merged into one, in that order:
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
The details worth knowing:
- Path parameters are always required. A parameter the path declares but
the
paramsschema does not describe is supplemented as a required string; one the schema does describe stays required whatever the schema says, since the URL cannot be built without it. (Known limitation: Hono's optional modifier,/posts/:id?, is advertised as required too — the same rendering the OpenAPI document uses.) - A non-object body nests. If
bodyis an array, a primitive, a union or a record, it lands under a singlebodyproperty rather than flattening, because the tool input has to have an object root. - A key collision is reported, not merged. When two sources declare the
same key, the later one wins (params → path → query → body) and the
derivation emits a warning naming both. You will see it in
tool:list, intool:inspectfor that tool, and in the server log when the MCP plugin boots. Rename one of the two: merged tool input has one namespace. - The advertised type is the input side of the schema.
z.coerce,.default()and.transform()are rendered as the type an agent writes, not the type your controller receives. The real validation still happens once, at the application boundary.
A body-carrying route with no body schema derives its input from the path and
query alone, which leaves the agent guessing at the payload. guren check
warns about it.
The output
Three rungs, in order:
| Priority | Source | What the tool gets |
|---|---|---|
| 1 | the route's output schema |
a JSON Schema outputSchema, and structuredContent on every successful call |
| 2 | a resource hint |
no schema; bunx guren codegen embeds the Resource's extracted payload type into the tool description |
| 3 | neither | no output shape at all; guren check warns |
output outranks the hint whenever both are declared — the output schema is
the one shape validated at runtime, and carrying both would leave two
descriptions of one response with nothing keeping them in agreement.
structuredContent is offered only for an object outputSchema; MCP
allows no other root. A route whose output is an array or a primitive still
advertises nothing structured, and its result rides as text.
How a response becomes a tool result:
| Response | Result |
|---|---|
| 2xx JSON | serialized as text, plus structuredContent when the tool advertises an object output schema |
| 2xx Inertia page JSON | unwrapped to page.props — only for a tool with no output schema, so the advertised shape can never disagree with the result |
| 204 / 3xx | a text line naming the status and Location; not an error |
| 4xx / 5xx | isError: true carrying the exception handler's JSON body — a 422's { message, errors } is an application failure the agent should read, not a protocol fault |
| non-JSON | capped text |
One rule overrides that table: a tool advertising an object output schema whose route answers with something that cannot fill it (a 204, a redirect, a JSON array, a non-JSON body) comes back as an error result naming the mismatch, rather than a success the client would reject after the route has already run.
An action answering with this.inertia(...) returns whatever the page happens
to pass its component — a shape nothing checks and any UI change can move.
Prefer output plus this.json(...) on agent-facing routes; guren check
warns about the Inertia case.
Annotations
MCP annotations describe a tool to its client. Guren resolves all three to explicit values so nothing downstream has to reapply a default:
| Annotation | Default |
|---|---|
readOnlyHint |
true for GET and QUERY, false otherwise |
destructiveHint |
the inverse of readOnlyHint — the MCP spec default for anything not read-only is true |
idempotentHint |
true for GET, QUERY, PUT and DELETE |
Annotations are hints for client UX. They enforce nothing. Enforcement lives in your policies (evaluated inside the dispatched request, exactly as for a browser) and in token scopes (evaluated before the request is even synthesized). That is precisely why the two claims that weaken a check are held against the controller body:
readOnlyHint: trueis what exempts a route from the authorization rule, soguren checkwarns when a read-only tool's action deletes, updates or force-writes — for the GET/QUERY default as much as for a hint you wrote.destructiveHint: falseclaims "additive updates only", soguren auditwarns when the action deletes, updates or force-writes.
Authentication is not authorization
An agent calls with a token, not a browser session. this.auth.userOrFail()
proves who is calling; it does not decide whether that caller may perform
this action. A non-read-only tool protected only by authentication hands
every principal holding any token the whole action.
So every non-read-only tool needs one of:
// on the route — the ability is then derivable, and tool:list shows it
router
.delete('/posts/:id', { params: PostIdParamSchema }, [PostController, 'destroy'])
.name('posts.destroy')
.middleware(authorizeMiddleware('posts.destroy'))
.agent({ description: 'Delete a post.' })
// or in the action
await this.authorize('delete', [Post, post])
this.can(...) is not enough: it returns a boolean and enforces nothing.
guren check fails a non-read-only agent route with neither.
Serving the tools
The tools are served by @guren/plugin-mcp, a separate package so that apps
which do not expose an agent surface never carry the MCP transport.
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()],
})
// Required: the endpoint verifies bearers against this store.
app.auth.useTokens(new DatabaseApiTokenStore(apiTokens))
export default app
The endpoint mounts at /mcp and speaks streamable HTTP, stateless: one MCP
server per request, no session to keep. Bearer authentication is required,
so the app must configure an API token store:
- no bearer, or one that is invalid, expired or revoked →
401withWWW-Authenticate: Bearer, before any MCP framing - no token store configured at all →
500namingauth.useTokens(store), so a misconfiguration reads as a misconfiguration rather than a rejected token
You do not need a CSRF exemption for it. A request that carries
Authorization: Bearer and no Cookie header at all skips CSRF verification
framework-wide — there is no ambient authority to defend, and the dispatcher
synthesizes cookie-less bearer requests by construction.
Configuration
mcpPlugin({
path: '/mcp',
serverInfo: { name: 'blog', version: '1.0.0' },
rateLimit: { max: 60, writeMax: 20, windowMs: 60_000 },
updateLastUsed: true,
})
| Option | Default | Meaning |
|---|---|---|
path |
'/mcp' |
Where the endpoint mounts |
serverInfo |
{ name: 'guren-app', version: '1.0.0' } |
Server identity advertised to clients |
rateLimit |
{ max: 60, writeMax: 20, windowMs: 60_000 } |
Per-token budget; false disables it |
updateLastUsed |
true |
Whether verifying a bearer writes the token's lastUsedAt |
Rate limits are keyed on the token id, not an IP: budgets follow credentials. They are enforced in process memory, so one long-running server enforces them exactly while a fleet or a serverless deployment enforces them per instance. A global budget still needs a shared store and your app's own rate-limit middleware.
Your app's own rate-limit middleware on an agent route cannot substitute for this one. Its default key comes from the socket peer, and the re-entrant request never arrived over a socket — so every MCP caller collapses into that route's shared bucket.
Tokens and scopes
An existing ['*'] token grants no agent tools. Only tool: and tools:
abilities are read as tool scopes; every other ability — including the default
['*'] an ApiToken carries — matches nothing here. This is deliberate: an
app declaring its first .agent() route must not hand its whole agent surface
to every token issued before agent tools existed. Access to the agent surface
is granted explicitly or not at all.
Four scope forms, and no more:
| Scope | Grants |
|---|---|
tool:posts.store |
exactly that one tool |
tools:read |
every tool whose resolved readOnlyHint is true |
tools:posts.* |
every tool named posts.… (the dot is part of the match, so not posts itself) |
tools:* |
every tool |
Scopes are additive and there is no deny form. A tool the token's scopes do not
cover is not merely refused — it is absent from tools/list, so an
ungranted catalog cannot map your write surface for a read-only agent.
Issuing a token
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)
An agent that also writes gets its own token, rather than a wider scope on this one:
bunx guren token:issue --name blog-writer --user 42 --tools 'posts.store' --expires 30d
--tools accepts shorthand: a bare name becomes tool:<name>, posts.*
becomes tools:posts.*, read becomes tools:read, * becomes tools:*.
| Option | Meaning |
|---|---|
--name |
Required. How the token is identified when someone revokes it. |
--user |
Required. The user ID the token authenticates as. |
--tools |
Required. Comma-separated scopes. |
--read-only |
Restrict the grant to read-only tools. |
--expires |
30d, 12h, 45m. Omit for a non-expiring token. |
--allow-unmatched |
Accept a scope matching no current tool. |
--yes |
Required to accept tools:*. |
--json |
Emit the issued token as JSON, warnings included. |
The command refuses more than it warns, because a typo on a credential command line is cheapest to fix while you are still looking at it:
- A scope matching no current tool is refused. It is either a typo or a
latent grant — a stored pattern that would activate, with nobody's consent,
the moment a matching tool is added.
--allow-unmatchedoverrides it and warns about exactly that. tools:*needs--yes. It grants every tool the app exposes now and every one it gains later, destructive ones included.--read-onlystores concrete entries. The grant is expanded at issuance and written astool:<name>entries, never as the pattern — the grammar has no "read-only subset ofposts.*" form. That is fail-closed: a write tool added to theposts.family later joins no stored entry. Under--read-onlyan unmatched scope is refused even with--allow-unmatched, since it could never grant anything at any later point.
Two issuance-time warnings, neither of them a refusal:
- a token that never expires stays valid until someone revokes it by hand
- a token granting both read and write tools is the shape the known injection incidents took: an agent that reads attacker-influenced content and can also write it back can be steered by that content. Split the two across separate tokens where you can.
The audit trail
Every invocation and every denial is emitted as a framework event, so you forward them wherever you already forward events.
| Event | When | Carries |
|---|---|---|
AgentToolInvoked |
the call reached the application | principal, tool, arguments, status, durationMs, surface |
AgentToolDenied |
the adapter refused before any HTTP happened | principal, tool, arguments, reason, surface |
reason is one of 'auth', 'scope', 'approval', 'rate-limit' — exactly
the checks that precede the request. A policy denial is not one of them:
policies evaluate inside the dispatched request, so it arrives as an
AgentToolInvoked with status 403. A denial carries no status or duration
because nothing ran.
// app/Providers/EventServiceProvider.ts (or wherever you register listeners)
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 })
})
An event manager has to be bound for any of this to happen — register
EventServiceProvider (or your app's own event provider) alongside
mcpPlugin(). Without one the plugin warns at boot and emits nothing.
Redaction
event.arguments is masked before the event is constructed. Two sources are
unioned: a built-in list of sensitive key fragments every app gets without
asking (password, passphrase, secret, token, apikey, authorization,
credential, cookie, session) and the route's own redact metadata.
router
.post('/integrations', { body: CreateIntegrationSchema }, [IntegrationController, 'store'])
.name('integrations.store')
.agent({
description: 'Connect an external integration.',
redact: ['webhookUrl'],
})
Matching is blunt on purpose, in the safe direction:
- a key matches when its lowercased, separator-stripped name contains a
fragment, so
apiKey,api_keyandx-api-keyare all covered byapikey - the same containment applies to entries you declare, so
redact: ['id']also masksuserId - the key decides before the value's shape does: a nested object under a key
named
tokenis masked whole, not walked
Masked values are replaced with [REDACTED]. The walk is total — a cycle
becomes [Circular] and an absurdly deep payload [Truncated] — because it
runs while recording that something happened, including denials taken before
your route's own validation.
Dev MCP is a different endpoint
Guren has shipped an MCP endpoint for a while, and it is not this one. Keep them apart:
| Dev MCP | App MCP | |
|---|---|---|
| Path | /_guren/mcp |
/mcp (configurable) |
| Ships in | the framework | @guren/plugin-mcp |
| Operates on | your project on disk | your application's data |
| Audience | your coding agent | agents your users point at the app |
| Gate | GUREN_MCP=1 and a verified loopback peer; fails closed |
a bearer token, then its tool scopes |
| Tools | fixed framework tools (context, checks, scaffolding) | the routes you gave .agent() |
| In production | absent — the gate is settled at bundle time by every deploy plugin | mounted |
Never put a tunnel in front of a dev server running with GUREN_MCP=1: that
endpoint can write files into your project. See
Spec-Anchored Development for the dev-side story.
What the checks enforce
The rules run in the normal bunx guren check suite and are
content-activated: an app with no agent routes produces no findings and has no
controller scanned.
check fails on a nameless agent route, a tool name outside the MCP
grammar, two routes resolving to one tool name, and a non-read-only tool whose
middleware chain carries no authorization capability and whose action never
calls this.authorize(...).
check warns on a missing output shape, an Inertia response, a
body-carrying route with no body schema, a read-only tool whose action
mutates, and any verdict it could not reach (an inline handler, an unreadable
controller file, two controller classes sharing a name).
bunx guren audit treats the same routes more strictly: a body-validation
finding that is a warning for an ordinary route becomes a failure for an
agent-exposed one, and destructiveHint: false on an action that deletes,
updates or force-writes warns.
The full finding-key tables are in CLI — Agent-exposed routes.
Related
- Routing — Agent tools — where
.agent()sits among the other route contracts - API Tokens — the store the MCP endpoint verifies bearers against
- Authorization — the policies that decide what a principal may do
- Events — listener registration and the event manager
- CLI —
tool:list,tool:inspect, and the check/audit finding keys