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. When a tool is called, the framework's dispatcher rebuilds the call as a real HTTP request into your application, 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. The rest of the page follows the order you need it in: the declaration and the schemas it derives, checking and calling the tools locally, and serving them over MCP. The tokens, approvals and audit trail that lock them down are on their own page, Control and Audit Agent Tool Access.
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 is a legal tool
name. Not every client accepts one, though: the Claude and OpenAI tool APIs
restrict names to ^[A-Za-z0-9_-]{1,64}$, and Claude Managed Agents applies
that grammar to MCP tools and skips every tool outside it. The skip is silent
on your side: tools/list is answered correctly and the client drops the
entry. Give such a route a portable spelling with
agent: { toolName: 'posts_store' }. The route name, route() helpers and
the HTTP path stay as they are; only the advertised tool name changes.
guren check warns on a tool name that a client would drop.
toolName 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. Use it to give a dotted route name a spelling every client accepts (posts_store). |
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, and expose: { webMcp: false } out of the in-browser surface @guren/plugin-webmcp registers (experimental). |
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' means a call becomes a pending request for a human to approve instead of executing. See Approval-gated tools. With no queue configured the endpoint fails closed: 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.
The framework's auth guards never answer a tool with a redirect.
requireAuthenticated, requireGuest and requireVerifiedEmail recognize the request the dispatcher
builds (it carries X-Guren-Agent-Surface) and, where a browser would be sent
to their redirectTo, answer JSON instead: 401 for no user, 403 for a
signed-in user on a guest-only route or an unverified address. The call comes
back as an error result rather than a success naming /login. The header only
chooses the refusal's shape and grants nothing. A redirect the handler itself
returns, such as store answering this.redirect('/posts'), is still a
success.
createForceHttpsMiddleware() lets a tool call through rather than redirecting
it to https. The dispatcher builds the request on the origin its caller reached:
http:// for an MCP endpoint behind a TLS-terminating proxy, and
http://localhost for durable agents, guren tool:call, TestApp.agent() and
in-process AI agents with no APP_URL. It never leaves the process, so there
is no transport to upgrade. Unlike the guards above, the
middleware does not read the header: it lets through only the request object
the dispatcher built, so an outside HTTP request that copies its headers is
still redirected.
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 caller 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.
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.
Calling a tool yourself
tool:list describes the surface. tool:call uses it, with no MCP client, no
token and no running server:
bunx guren tool:call posts.store --input '{"title":"Hello agents"}'
posts.store POST /posts
Status: 201
Result
{
"id": 1,
"title": "Hello agents"
}
The command boots your application and dispatches through the same contract an
MCP client's call goes through: the tool is found by deriveAgentTools, the
HTTP request is rebuilt by the framework's dispatcher, and the response is
mapped back the same way. There is no CLI-only code path for any of that, so
the tool a call resolves to, the request it becomes, and the result you read
are what an agent gets.
What differs is how the caller proves who it is. An MCP client presents a
bearer token, which is scope-checked before the request is built and which
skips CSRF verification because it carries no cookies; tool:call
authenticates with --as and fetches a CSRF token the way a browser does. So
a green tool:call run tells you the tool works, not that a particular token
is scoped to reach it: guren token:issue is where that is decided.
Its tools come from the booted app's route graph, which is why there is no
--routes flag: a routes file cannot change what the running app serves, and a
tool you could name but not reach would be worse than no flag at all. Use
--app <dir> to point at an application root that is not the current
directory.
An unknown name answers with the names that exist, and a call that fails reports the application's own failure and exits non-zero:
bunx guren tool:call posts.store --input '{"title":"no"}' --json
{
"tool": "posts.store",
"method": "POST",
"path": "/posts",
"status": 422,
"isError": true,
"content": "{\"errors\":{\"title\":\"Too small: expected string to have >=3 characters\"}}"
}
--as bypasses authentication
--as user:42 runs the call as that user. It works by setting GUREN_TESTING=1
for the process, which makes the app accept an injected user instead of a real
credential (the same mechanism @guren/testing uses). The command says so every
time it is passed.
This is a development flag on the same trust boundary as bunx guren console:
it assumes whoever runs it can already execute code in this project. Never run
it against a shared or production database.
--preflight rehearses a call
bunx guren tool:call posts.store --input '{"title":"Rehearsal"}' --preflight
posts.store POST /posts
Status: 200
Preflight allowed (the handler did not run)
Validated: body
Unverified: authorization
Preflight only: the request passed this route's middleware and its body schema.
The handler did not run. The route's own middleware did run, so any effect a
middleware on this route has of its own has already happened. No authorization
middleware was found on this route, so any check inside the handler itself was
not evaluated.
The request runs the route's middleware and validates the contract the tool
advertises, then stops before the handler. unverified names what a real call
would still evaluate: a check made inside the action is one a rehearsal never reaches, because it
stops before the handler.
A rehearsal is not a dry run of the whole request. It stops at a point mounted after every other middleware, so that every gate in front of it is the real one, which is what makes the verdict worth having. It also means the route's middleware genuinely ran: a middleware that increments a quota, consumes a rate-limit bucket, touches a session, or calls something else has already done so. Only the handler is skipped.
MCP reaches the same rehearsal through a companion tool rather than a flag (see
Rehearsing a call over MCP). A tool that
advertises an outputSchema must answer with structuredContent conforming to
it, and a verdict conforms to no route's output, so the verdict needs a tool of
its own. tool:call and @guren/testing are not bound by that rule and ask
for a verdict on the call itself.
Testing a tool
app.agent() calls the tools of an app under test, through the same dispatch:
import { TestApp } from '@guren/testing'
const app = await TestApp.create({ routes: registerWebRoutes })
const result = await app.agent().call('posts.store', { title: 'Hello' }, { as: user })
result.assertOk()
const post = result.assertStructured<{ id: number; title: string }>()
expect(post.title).toBe('Hello')
Assertions chain on the pending call as well, like every other TestApp
request:
await app.agent().call('posts.index').assertOk()
await app.agent().call('posts.store', { title: 'no' }).assertStatus(422)
await app.agent().call('secret.show').assertDenied()
| Assertion | Passes when |
|---|---|
assertOk() |
the call did not come back as an error result (any 2xx/3xx status) |
assertStatus(code) |
the dispatch resolved to exactly that HTTP status |
assertDenied() |
the application answered 401 or 403 |
assertStructured<T>() |
the tool advertises an object output schema and returned one — awaited, it returns the payload |
await app.agent().tools() lists the tools the app exposes, exactly as
tool:list derives them.
Three things worth knowing:
{ as: user }isactingAs(user), theX-Testing-Userenvelope. There is no token here, soassertDenied()means "the application refused": its authentication or its policies. Bearer scopes belong to the MCP endpoint and are not reachable from a test. A call without{ as }to a route behindrequireAuthenticated({ redirectTo: '/login' })answers401, not the redirect.- Mount CSRF or skip it, deliberately. A dispatched tool call carries no
cookie and no bearer, so an app created with
authrefuses a mutating call with403before any policy is consulted, whichassertDenied()cannot tell apart from a policy refusal. Dispatch through(await app.withCsrf()).agent(), or test against an app that mounts no CSRF. - The app must carry a route graph.
TestApp.create({ routes })andTestApp.fromApp(app)do;TestApp.fromFetch()andTestApp.fromWorkers()are handed a bare fetch function and have none, soagent()says which constructor to use rather than reporting an empty tool list.
{ preflight: true } works here too, and answers the same verdict:
const result = await app.agent().call('posts.store', { title: 'x' }, { preflight: true })
result.assertOk()
expect(result.json<{ allowed: boolean }>().allowed).toBe(true)
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. It serves both the MCP 2026-07-28
protocol and clients that still open with the 2025-era initialize handshake
(2025-11-25 and earlier), from the same path. 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 - an authenticated
GETorDELETE→405, since there is no session stream to open or close - an authenticated
POSTwhoseContent-Typeis notapplication/json→415 - a
subscriptions/listenrequest → a JSON-RPC error, since the tool list never changes while the server runs and a held stream would serve nothing
A valid token is not enough to see a tool. The token also needs a tool: or
tools: scope, and the default ['*'] an ApiToken carries grants none, so a
token issued without one gets an empty tools/list. Issue one with
guren token:issue --tools.
You do not need to write a CSRF exemption for it. A request carrying
Authorization: Bearer and no Cookie header at all skips CSRF verification
framework-wide, since with no cookie there is no ambient authority to protect,
and the dispatcher builds cookie-less bearer requests by construction. The plugin also declares its own configured path as authenticating
without cookies, so a request that misses that rule (one with no bearer, or one
a browser attached cookies to) is answered by the 401 above rather than by a
CSRF 403 that hides the real reason. Neither skip authenticates anything: both
ways in are a bearer token and the identity an in-process caller (such as a
durable agent) installs, and a browser can present neither.
bunx guren audit reports that declaration, and any other package's, under
Plugin CSRF exemptions (the audit's only view into an exemption made from
inside node_modules). It names packages rather than paths: each path is an
argument computed at boot from that package's own configuration. A declaration
from a package outside the @guren/ scope is a warning, since only this
repository can vouch for its own. The packages it reads are the ones your
package.json declares, so a plugin reached only through another dependency is
outside its view.
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 |
approvals |
none | The approval queue: { store, notify, ttlMs? }. See Approval-gated tools |
audit |
none | Where the audit trail is written: { file?, days? } or { sink }. See Writing the trail down |
auth |
bearer tokens | 'external' trusts an authority in front of the app to verify each request instead; no token store is consulted |
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 request the dispatcher builds never arrived over a socket, so every MCP caller collapses into that route's shared bucket.
Rehearsing a call over MCP
The endpoint adds one tool of its own, guren_preflight. It answers whether a
call to another tool would be allowed, and never performs it:
{
"name": "guren_preflight",
"arguments": { "tool": "posts.store", "input": { "title": "Rehearsal" } }
}
{
"tool": "posts.store",
"allowed": true,
"status": 200,
"validated": ["body"],
"unverified": ["authorization"],
"message": "Preflight only: the request passed this route's middleware and its body schema. …"
}
It stops at the same point --preflight does: the checked tool's own
middleware runs, its advertised contract is validated, and the request stops
before the handler. The action itself does not happen, but the middleware
really did run, so anything it does of its own accord has taken effect.
A refusal is a successful result, not an error. The caller asked whether the call would be allowed, and "no, here is why" answers that:
{
"tool": "posts.store",
"allowed": false,
"status": 422,
"message": "The given data was invalid.",
"errors": { "title": ["Required"] }
}
validated and unverified are present only when the request reached that
point. A call refused earlier (by authentication or authorization middleware)
has no answer to give about checks it never reached, so those fields are
absent rather than empty.
Four rules worth knowing:
- Checking a tool needs the same scope as calling it. Otherwise the companion would be a way to probe the authorization surface of tools the token cannot call. An ungranted name is refused as an error result, the way a direct call to it is.
- A tool that requires approval can still be checked. Its real call files a request for a human, so a rehearsal is how you ask "would this be accepted?" without filing one, and the rehearsal executes nothing.
guren_preflightis listed only for a token that grants at least one tool. A token that can call nothing has nothing to rehearse.- The name is reserved. A route whose
.agent()tool name claims it failsbunx guren check, and the endpoint refuses to serve it. Two tools under one name makes an MCP client reject the whole catalogue.
Rehearsing is not requesting. Preflighting an approval-gated tool creates no pending request and notifies nobody.
Locking the tools down
Control and Audit Agent Tool Access covers the token scopes every MCP caller needs, plus two optional controls: approval gates for tools a person should confirm, and the audit trail.
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, a tool name reserved by the framework (guren_preflight), 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 tool name some clients drop (a dot, or more than 64
characters, which is where agent.toolName comes in; advisory, so check --ci
and guren gate stay green), 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 Checks and Audits in Depth: Agent-exposed routes.
Related
- Routing — Agent tools: where
.agent()sits among the other route contracts - Control and Audit Agent Tool Access: token scopes, approval gates and the audit trail
- API Tokens: the store the MCP endpoint verifies bearers against
- Authorization: the policies that decide what a principal may do
- CLI:
tool:listandtool:inspect - Checks and Audits in Depth: the check/audit finding keys for agent routes
- AI Agents: an agent class in your app that calls a model with these tools
- Durable Agents: hosting an agent of your own that calls this surface