Capabilities
Define a typed operation once and pracht projects it everywhere — direct server calls, a generated HTTP endpoint, a WebMCP page tool for in-browser agents, and a tool on your app's own remote MCP endpoint. Explicit, validated, and private by default.
Both routers. Manifest apps register capabilities through
defineApp({ capabilities }). Pages-router apps auto-discover every module insrc/capabilities/and configureagentsfromsrc/pages/_app.config.ts; everything on this page — HTTP endpoints, WebMCP, remote MCP, typed clients,pracht eval— works the same in both.
One Contract, Many Surfaces
A capability is a typed, protocol-neutral application operation: JSON Schema input and output, an effect class (read, write, or destructive), optional named middleware, and a server-only run() function. From that single contract pracht generates:
- Direct server invocation —
invokeCapability()from loaders, API routes, and middleware. - An HTTP endpoint —
POST /api/capabilities/<name>whenexpose.httpis set. - A WebMCP page tool — eligible for route-scoped browser registration when
expose.webmcpis set. - A remote MCP tool — served at your app's own endpoint when
expose.mcpis set, for agents that never open a browser. See Remote MCP.
Every projection runs the same pipeline, so business rules never diverge between transports:
input validation → middleware chain → run() → output validation → audit eventRegister capabilities
Capabilities are registered in defineApp(), exactly like shells and middleware. Registration is deliberately opt-in — no API route or loader is ever inferred as a capability. Registration and WebMCP activation are separate: expose.webmcp makes a capability eligible to be a page tool, while each route lists which eligible tools exist on that page. When client navigation commits, pracht removes the previous route's tools and registers the destination route's set.
With the pages router, every module in src/capabilities/ is registered automatically. Name it with defineCapability({ name }) or let its filename provide the name (notes-search.ts becomes notes.search). A page opts into its tools with export const CAPABILITIES = ["notes.search"]. A root src/pages/_app.config.ts can export agents and constraints, so capability HTTP endpoints, WebMCP, remote MCP, typed clients, and pracht eval work in both router modes. See Pages Router.
On hydration: "islands" routes, Pracht retains the page bootstrap whenever that route activates a WebMCP tool, even when a response renders zero island components. Routes with no active tools do not retain it. hydration: "none" remains deliberately zero-JavaScript and cannot activate in-page WebMCP tools.
import { defineApp, route } from "@pracht/core";
export const app = defineApp({
capabilities: {
"notes.search": () => import("./capabilities/notes-search.ts"),
"notes.create": () => import("./capabilities/notes-create.ts"),
},
routes: [
route("/", "./routes/home.tsx"),
route("/notes", "./routes/notes.tsx", {
capabilities: ["notes.search", "notes.create"],
}),
],
});Group declarations are additive: group({ capabilities: ["notes.search"] }, routes) adds that tool to every child, while route-level names add to and de-duplicate the inherited set. pracht verify rejects activation of unknown capabilities, capabilities without expose.webmcp, and tools on hydration: "none" routes.
export const CAPABILITIES = ["notes.search", "notes.create"];Pages-router CAPABILITIES must be an inline array of non-empty names. Put it on the page itself, not _app.tsx or 404.tsx.
import { defineCapability } from "@pracht/capabilities";
export default defineCapability({
name: "notes.search",
title: "Search notes",
description: "Find notes whose title or body matches the query.",
effect: "read",
input: { type: "object", properties: {}, additionalProperties: false },
output: { type: "object", properties: {}, additionalProperties: false },
run: async () => ({}),
});Define the Contract
import { defineCapability, type CapabilityRunArgs } from "@pracht/capabilities";
import { searchNotes } from "../server/notes-store.ts";
interface SearchInput {
query: string;
limit: number;
}
export default defineCapability({
title: "Search notes",
description: "Find notes whose title or body matches the query.",
input: {
type: "object",
properties: {
query: { type: "string", minLength: 1 },
limit: { type: "integer", minimum: 1, maximum: 20, default: 10 },
},
required: ["query"],
additionalProperties: false,
},
output: {
type: "object",
properties: { notes: { type: "array", items: { type: "object" } } },
required: ["notes"],
},
effect: "read",
expose: { http: true, webmcp: true },
async run({ input }: CapabilityRunArgs<SearchInput>) {
return { notes: searchNotes(input.query, input.limit) };
},
});Plain JSON Schema uses Pracht's dependency-free subset validator. Unsupported keywords (oneOf, $ref, pattern, …) are rejected at definition time and by pracht verify, so an exposed capability can never silently accept more than its schema says.
If your app already has a validator for defineApi(), <Form schema>, or loader-side validation, reuse it directly when it also implements Standard JSON Schema. Zod 4 does:
import * as z from "zod";
export const searchInput = z.object({
query: z.string().trim().min(1),
limit: z.number().int().min(1).max(20).default(10),
});
export const searchOutput = z.object({ notes: z.array(z.string()) });import { defineCapability } from "@pracht/capabilities";
import { searchInput, searchOutput } from "../schemas/notes.ts";
import { searchNotes } from "../server/notes-store.ts";
export default defineCapability({
title: "Search notes",
description: "Find notes whose title or body matches the query.",
input: searchInput,
output: searchOutput,
effect: "read",
expose: { http: true, webmcp: true },
async run({ input }) {
// Typed, defaulted, transformed, and validated by searchInput.
return { notes: await searchNotes(input.query, input.limit) };
},
});Pracht derives draft-07 input/output schemas for the graph and agent protocols, then runs the original Standard Schema validator during dispatch. Async validation, transforms, defaults, and issue paths are preserved. The capability projection adds only the derived JSON object to WebMCP; if <Form> also imports the validator for client-side feedback, that form import still follows the normal client bundle. A validator without Standard JSON Schema support cannot be used as a capability contract.
Standard JSON Schema supplies run()'s validated input type directly. With plain JSON Schema, annotate run() with CapabilityRunArgs<Input> while letting TypeScript infer the concrete output. That preserves both types when the capability is passed to createCapabilityTestHost(). Avoid supplying only defineCapability<Input> — TypeScript then uses the default unknown output instead of inferring it. Use defineCapability<Input, Output> when you prefer to state both explicitly.
Call It from Anywhere
Server-side — including private capabilities that have no expose at all:
import { invokeCapability } from "@pracht/core/server";
export async function loader({ request, context, signal }) {
const result = await invokeCapability("notes.search", { query: "roadmap" }, { request, context, signal });
return result.ok ? result.data : { notes: [] };
}invokeCapability() is trusted server composition. It runs the callee's input validation, named middleware, body, and output validation, but not app-level API middleware. Remote MCP is the safety exception: nested calls re-apply the callee's agentPolicy, refuse destructive effects unless the tool being served already cleared prepare/commit — a request-scoped grant covering every destructive callee, just like a confirmed HTTP endpoint — and rebind authenticated context.tokenAuth. Private non-destructive capabilities remain composable. HTTP and WebMCP composing capabilities must still own any transport-specific authorization they need. Under a served HTTP or MCP request, nested context and audit identity remain bound to what the transport verified rather than replacement context.agent or context.tokenAuth fields. Every nested audit event uses transport: "server" and via to retain the trusted request transport that caused it.
From the browser — virtual:pracht/capabilities contains only http-exposed names, endpoints, and effect classes; capability modules never enter the client bundle:
import { callCapability, capabilities } from "virtual:pracht/capabilities";
const result = await callCapability("notes.create", { title });
// or through the generated client — dotted names become object paths:
const same = await capabilities.notes.create({ title });Both take the same path (one endpoint table, one settled event, one revalidation rule); capabilities is a nested view of the same call, and private capabilities are absent from it entirely. Reach for the nested form when typing a name by hand — its members are real property accesses, so a typo gets Did you mean 'search'? where a string literal argument gets no suggestion.
TypeScript resolves the virtual:pracht/* module declarations from @pracht/vite-plugin/virtual. New scaffolds include it in compilerOptions.types; an app created before that adds it next to vite/client:
{ "compilerOptions": { "types": ["vite/client", "@pracht/vite-plugin/virtual"] } }For calls driven by interaction — a button, a search box, a picker — useCapability() owns the pending/error/result state:
import { useCapability } from "virtual:pracht/capabilities";
const search = useCapability("notes.search");
<button disabled={search.pending} onClick={() => search.call({ query })}>
{search.pending ? "Searching…" : "Search"}
</button>;
{search.error ? <p>{search.error.message}</p> : null}
{search.data ? <p>{search.data.notes.length} found</p> : null}Concurrent calls are last-one-wins, so a search box never renders a stale response, and data stays visible while a follow-up call is pending. It dispatches when you call it, never during render: for data a page needs on load, run the capability in a loader with invokeCapability() so the result is server-rendered instead of fetched after hydration.
The nested client also carries each capability's generated title and description as JSDoc, so hovering capabilities.notes.search shows the same contract prose an agent reads.
Capability modules are server-only, and the build enforces that: importing one from client code fails with a pointer to these helpers rather than silently bundling run() and everything it imports for every visitor.
HTTP-exposed capabilities must declare effect as an inline "read", "write", or "destructive" string because the browser projection is generated by static analysis. Custom expose.http.path values must be exact same-origin pathnames beginning with /; protocol-relative URLs, queries, and fragments are rejected.
A destructive capability is confirmation-gated, and the call options say which half of the flow you are in — { prepare: true } to obtain the token without running the operation, then the identical input with { confirm: token } to commit:
import { capabilities } from "virtual:pracht/capabilities";
const prepared = await capabilities.notes.purge({ titlePrefix: "Old" }, { prepare: true });
const confirmationToken =
!prepared.ok && prepared.error.code === "confirmation_required"
? prepared.error.confirmationToken
: undefined;
if (confirmationToken) {
await capabilities.notes.purge({ titlePrefix: "Old" }, { confirm: confirmationToken });
}Full options: { headers, signal, prepare, confirm, revalidate }. prepare is not sent over the wire; the client uses it to strip any inherited confirmation header before dispatch. See Agent Trust for what the server checks on each half.
Or declaratively — the framework's <Form> posts straight to a capability, so the human form and the agent tool share one contract. Fields are coerced onto the input schema server-side, and without JavaScript the endpoint accepts the form-encoded post and redirects back:
import { Form } from "@pracht/core";
<Form capability="notes.create" onCapabilityResult={(result) => setStatus(result)}>
<input name="title" />
<button type="submit">Create note</button>
</Form>;capability accepts only http-exposed names once typegen has run — a private one has no endpoint to post to, so naming it is a compile error rather than a 404 at submit time. Set action explicitly for a capability with a custom expose.http.path; a root-absolute action automatically receives Vite's deploy base. A button-level formaction is native child markup, so wrap a local root-absolute override with withBase() when the app uses a deploy base.
Mutations keep the page honest automatically: capabilities are effect-classed, so after any successful non-read call from the browser (callCapability, the capabilities client, or <Form capability>) the active route's loader data revalidates — no manual revalidate() bookkeeping. Opt out per call with { revalidate: false }.
Over HTTP — every response uses a typed envelope, with path-scoped validation issues an agent can act on:
curl -X POST /api/capabilities/notes/search -H 'content-type: application/json' -d '{"query":"roadmap"}'
# { "ok": true, "data": { "notes": [...] } }
# { "ok": false, "error": { "code": "invalid_input", "issues": [{ "path": "/limit", "message": "must be <= 20" }] } }A capability middleware that short-circuits with status 429 produces the typed
rate_limited error code on every projection. HTTP callers also keep the
middleware's Retry-After header.
And every call above is fully typed: pracht typegen writes each capability's input/output types, effect class, and exposure into src/pracht-capabilities.d.ts, so invokeCapability(), callCapability(), the capabilities client, and <Form capability> all read the contract from the capability name — no per-call generics. With that file in the program the compiler rejects:
| Mistake | Result |
|---|---|
| Unknown or misspelled capability name | compile error (a "did you mean" suggestion through the nested capabilities client) |
| Input that does not match the schema | compile error |
| Calling a private capability from the browser | compile error — it has no HTTP endpoint |
Committing a destructive call without confirm |
compile error |
| A capability name computed at runtime | compile error — assert as HttpCapabilityName |
A capability whose input schema requires nothing is callable with no argument at all: capabilities.notes.stats(). Where the name is a union rather than one literal, the input may be omitted only if every member accepts empty input, and any supplied input must be valid for every possible member — narrow the name first when their contracts differ. An explicit prepare or confirm is required if any member is destructive; the gate closes when destructive is possible, not only when it is certain.
Apps that have not run pracht typegen keep the untyped form and accept any name. Two things to know when adopting it:
- Once anything is registered, the untyped fallback no longer applies — that is what turns a mistake into a build failure. The explicit
invokeCapability<Output>(name, …)type-argument form goes with it; drop the type argument and let inference do the work. - Re-run
pracht typegenafter upgrading pracht. A declaration file generated beforeeffectandexposedexisted keeps working, but the exposure and confirmation checks cannot apply to it.pracht typegen --checkcatches a stale file in CI.
Runtime validation is unchanged either way, and it is the runtime — not the compiler — that answers an unknown name with an unknown_capability envelope carrying a "did you mean" suggestion.
WebMCP: Tools for In-Browser Agents
With expose.webmcp: true, the client runtime can register the capability as a WebMCP page tool via document.modelContext.registerTool() on routes that activate its name. Initial hydration installs only the matched route's set. After each SPA navigation commits, pracht aborts the old registrations and installs the destination set; navigating to a route with no tools clears them. The tool's execute() dispatches through the HTTP projection, so the agent acts as the signed-in user in their tab while validation, middleware, and policy all stay server-side. If the WebMCP host cancels execution, its AbortSignal aborts the capability's HTTP request too, and the returned value is the capability envelope itself ({ ok, data } or { ok: false, error }) — the host serializes it per the spec, so there is no extra wrapping for an agent to unpick.
The registered descriptor carries the capability's title, its description, the input JSON Schema, and WebMCP's effect-derived readOnlyHint. Inline JSON Schema stays the non-executing build fast path. Imported and builder-produced Standard JSON Schemas are derived by loading the server-only capability module during code generation; only the resulting JSON is emitted. Keep expose and effect inline because they still determine the browser endpoint table statically. If a capability module imports an edge-only runtime at the top level, move that import inside run() or keep its WebMCP input inline.
Remote MCP derives its additional destructiveHint and idempotentHint separately because those annotations are not part of WebMCP. WebMCP also defines consequentialHint, but pracht does not infer it: consequential operations belong to pracht's destructive class, and destructive page tools are rejected instead of relying on a host hint for enforcement. Capabilities whose results include user-generated or third-party content can advertise untrustedContentHint with the options form:
expose: {
http: true,
webmcp: { untrustedContent: true },
},(The options form opts into WebMCP exactly like webmcp: true — an empty object or untrustedContent: false still registers the tool.)
The shim ships as its own chunk behind feature detection: browsers without the API never download it, apps without webmcp-exposed capabilities never reference it, routes with no active page tools do not load it, and it works in both full-hydration and islands modes.
Hosts and the origin trial
WebMCP remains a W3C Community Group Draft, not a W3C Standard or Standards Track deliverable. The Web Machine Learning Working Group charter lists WebNN, not WebMCP, so there is no formal commitment to advance this API. Its current imperative surface is document.modelContext (registerTool(), getTools(), and executeTool()). The spec moved there from navigator.modelContext in July 2026; pracht targets only the current document shape.
Chrome's origin trial covers versions 149–156. The document.modelContext getter landed in Chromium 150 and the deprecated navigator.modelContext alias was removed in 152, so trial builds older than 150 register no tools. Polyfills such as @mcp-b/webmcp-polyfill install the current document shape too.
Treat WebMCP as an experimental progressive enhancement, not your only agent integration. As of September 2026, no mainstream browser agent consumes arbitrary WebMCP page tools in broad production availability. Chrome's own WebMCP integration remains an early/developer preview (and is separate from the generally available Gemini-in-Chrome surface), while Anthropic has closed WebMCP support for the Claude Chrome extension as not planned. Keep the HTTP or remote MCP projection available for agents that need a production transport.
For stable Chrome visitors, the page must carry an origin-trial token during the trial window or document.modelContext never exists and the tools silently stay off. Register your origin, then emit the token from your shell's head():
import { publicEnv } from "@pracht/core";
export function head() {
return {
meta: [{ "http-equiv": "origin-trial", content: publicEnv.PRACHT_PUBLIC_WEBMCP_OT_TOKEN }],
};
}The token is origin-bound and public by design, so the PRACHT_PUBLIC_ prefix is the right home for it.
For local testing without a token, enable chrome://flags/#enable-webmcp-testing (plus #devtools-webmcp-support for the DevTools Application-panel WebMCP pane), or fake the API in Playwright — see Testing.
pracht verify guards the projection: it errors on names outside the draft's hard grammar (1–128 ASCII letters, digits, _, -, or .) and warns when a page tool can never work (a "require" agent policy 401s the page's unsigned fetches) or statically readable metadata exceeds Chrome's advisory budgets (30 characters per tool or parameter name, 500 per tool description, and 150 per parameter description). Chrome also recommends no more than 1.5K characters per individual tool result. Pracht does not truncate a validated result — bound arrays and prose through input limits, pagination, and output-schema limits so every transport sees the same value.
Registrations are origin-restrictive by default. The integrated pracht projection does not set exposedTo, so cross-origin documents cannot discover or execute its tools. Standalone hosts may pass a deliberate allowlist through registerWebmcpTools(..., { exposedTo: [...] }); only list secure origins you trust with the same user data and actions.
Remote MCP: Tools for Agents Without a Browser
WebMCP puts your operations in front of an agent standing in the user's tab. Remote MCP puts the same operations in front of an agent that never opens a browser at all — a coding assistant, a scheduled workflow, someone's terminal. It is a transport over the dispatch you already have, not a second pipeline.
Nothing is served until you ask for it twice: the app has to configure an endpoint, and each capability has to declare the exposure.
export const app = defineApp({
agents: {
mcp: {
// path: "/mcp", // default
serverInfo: { name: "notes", version: "1.4.0" }, // reported by initialize
instructions: "Search and file notes for the signed-in account.",
},
},
capabilities: {
"notes.search": () => import("./capabilities/notes-search.ts"),
},
});export default defineCapability({
// ...
expose: { http: true, mcp: true },
});pracht dev prints the endpoint next to the capability table, and pracht verify warns when a capability declares expose.mcp that no endpoint serves — a declared-but-dead transport is never mistaken for a live one.
Custom paths must be exact same-origin pathnames beginning with /; invalid values fail manifest validation. The endpoint must not equal a capability's HTTP exposure path; capability resolution fails until one path moves. Once configured, the endpoint remains active with an empty capability graph (tools/list returns an empty list), and graph resolution failures stay on the endpoint as JSON-RPC errors. Endpoint matching accepts one trailing slash, so /mcp and /mcp/ address the same projection.
Remote MCP requires a request runtime. @pracht/adapter-static rejects any agents.mcp configuration during pracht verify and pracht build; remove the endpoint or deploy with a serverful adapter.
expose.mcp does not require expose.http. A capability can be reachable by remote agents with no public browser endpoint at all.
A destructive capability needs a third opt-in — see Destructive Tools.
The supported MCP versions require both tool schemas to be rooted at { type: "object" }. defineCapability(), the runtime registry, and pracht verify reject expose.mcp when either the input or output schema uses another root; those schemas remain valid for private, HTTP, and WebMCP capabilities.
pracht dev-mcp is a different thing entirely: a stdio server that gives coding agents access to your app graph while you build. This section is about your deployed app's own tools. See Coding Agents.
A Transport, Not a Second Pipeline
tools/call synthesizes the request the HTTP projection would have received and hands it to the same dispatch function /api/capabilities/* uses:
POST /mcp
→ transport checks (method, Accept, Origin, protocol version)
→ tools/list = projection of the resolved capability graph
→ tools/call = the capability HTTP dispatch, verbatimInput validation, named middleware, agentPolicy, output validation, and the audit event are identical across HTTP, WebMCP, and MCP by construction — there is no second copy of the rules that could drift from the first.
The synthesized request carries the same request-bound capability host, so named middleware and capability bodies can compose private non-destructive operations with invokeCapability(). Trusted MCP provenance adds fail-closed rules to ordinary server composition: the nested call re-applies the callee's agentPolicy, refuses destructive effects before middleware or the body can run unless the tool being served already cleared prepare/commit, and rebinds context.tokenAuth to the OAuth principal the transport verified. The incoming transport request carries that provenance too, so adapter context that retains it cannot escape the nested-call guard. Every nested attempt audits as { transport: "server", via: "mcp" }, keeping indirect effects and denials attributable to the agent that caused them.
The endpoint is stateless: no session id, no server→client stream, no resumability. That is what the Node, Cloudflare, Netlify, and Vercel adapters already serve, so the same app runs unchanged on all four.
What an Agent Sees
tools/list projects the capability's own JSON Schemas. Nothing is regenerated, and nothing is re-described in a second place:
{
"name": "notes_search",
"title": "Search notes",
"description": "Find notes whose title or body matches the query.",
"inputSchema": { /* the capability's input schema */ },
"outputSchema": { /* the capability's output schema */ },
"annotations": {
"readOnlyHint": true, // derived from effect: "read"
"destructiveHint": false,
"idempotentHint": true
}
}Annotations are hints for the client's UX — never enforcement. The effect class that produced them is what the server actually enforces. Pracht does not claim that a tool is closed-world, so it leaves openWorldHint unset and preserves MCP's default. Likewise, write capabilities omit destructiveHint: a write mutates state but is not necessarily purely additive, so MCP's conservative default applies. A destructive capability sets destructiveHint: true and carries its confirmation contract in both the description and _meta.
Capability names are dot-separated; MCP hosts widely constrain tool names to ^[a-zA-Z0-9_-]{1,64}$, so notes.search becomes notes_search. Two capabilities that would collide (notes.search and notes_search) are a pracht verify error, and the runtime refuses to serve an ambiguous tool list rather than picking a winner. Projected names longer than 64 characters are rejected by verification and the runtime as well.
Results carry both the validated output and a text rendering, so hosts that only read text still get something useful:
{
"content": [{ "type": "text", "text": "{ \"notes\": [ … ] }" }],
"structuredContent": { "notes": [/* … */] },
"isError": false
}Failures split by what actually failed. An unknown tool or malformed params is a JSON-RPC error; a validation failure, middleware rejection, or policy denial is an isError: true result whose text names the error and whose _meta["io.pracht/error"] carries the machine-readable code and issues. Error results omit structuredContent, because structured results must match the capability's advertised output schema.
Once a valid JSON-RPC request has been accepted, JSON-RPC errors use HTTP 200 so standard Streamable HTTP clients parse the error payload. Non-2xx statuses are reserved for transport failures such as invalid HTTP methods, origins, or protocol versions.
Destructive Tools
Off by default. A destructive capability that sets expose.mcp is filtered out of tools/list and tools/call, and nested invokeCapability() refuses it. Two things turn it on, both explicit:
export const app = defineApp({
agents: {
mcp: {
serverInfo: { name: "notes", version: "1.4.0" },
destructive: true, // serve destructive tools, still confirmation-gated
},
},
});import { createSqlApprovalStore, setCapabilityApprovalStore } from "@pracht/core/server";
// Import this module from a server entry or a capability module so the
// registration runs before the capability graph is served.
setCapabilityApprovalStore(createSqlApprovalStore({ execute }));The setup may instead be imported by app-level capability/API middleware or named middleware on the destructive capability. The endpoint imports those applied middleware modules before checking its preconditions, without running the middleware functions during tools/list. /_pracht evaluates the real server entry and those applied setup modules, so a failed runtime gate is reported as mcp(unserved). Graph-only commands (pracht dev, pracht inspect capabilities, and MCP inspection) deliberately skip the adapter server entry; when their local runtime still lacks a precondition, they report mcp(unverified) rather than falsely claiming that a server-entry registration is absent. JSON inspection always reports mcpEndpoint, mcpDestructive, mcpRuntimeStatus, and mcpUnavailableReasons. The status is not-configured, ready, blocked for a runtime-verified failure, or unverified for an inconclusive graph-only check.
The store is not optional. Over MCP the confirmation token is handed to the very agent that will commit with it, and a stateless HMAC token replays until it expires — so exactly-once consumption is the whole reason the transport may carry a destructive effect at all. The runtime is the gate: the endpoint answers an explanatory JSON-RPC error instead of serving destructive tools whenever the store, the confirmation secret, or (in mode: "human") any resolvable principal is missing. A policy-only webBotAuth: {} block is not an identity source: configure at least one valid 32-byte base64url Ed25519 static key or HTTPS directory, or register an application principal resolver. pracht verify warns when it cannot find a setCapabilityApprovalStore() call in the configured source directories — a warning, not an error, because a source scan cannot see a registration that lives in a workspace package. There is no silent downgrade in either direction: without the opt-in the tool is invisible; with the opt-in and an unmet precondition, nothing is served. In that state, runtime-backed /_pracht marks every MCP exposure as mcp(unserved); graph-only CLI inspection uses mcp(unverified) when the same preconditions may still be registered by the skipped adapter server entry. See Durable Approvals for the store itself.
Prepare and Commit over tools/call
The flow is the same one HTTP uses. MCP has no per-call header channel, and the token cannot ride in arguments — it is bound to a hash of them — so it travels in _meta, the protocol's extension slot.
// 1. Prepare — nothing runs.
{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"notes_purge","arguments":{"titlePrefix":"Old"}}}
// → an isError result. The token is in _meta *and* in the text, so hosts
// that only read text can complete the flow too.
{
"content": [{ "type": "text", "text": "confirmation_required: …\nConfirmation token …: v2.…" }],
"isError": true,
"_meta": {
"io.pracht/status": 409,
"io.pracht/error": {
"code": "confirmation_required",
"confirmationToken": "v2.<claims>.<hmac>",
"expiresAt": 1735689720,
"approvalId": "…"
}
}
}
// 2. Commit — identical arguments plus the token.
{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"notes_purge","arguments":{"titlePrefix":"Old"},
"_meta":{"io.pracht/confirmation":"v2.<claims>.<hmac>"}}}Everything the HTTP flow guarantees holds here: the token binds the principal, the capability, and the exact input; a tampered, expired, or replayed token answers confirmation_invalid; and in mode: "human" the commit answers confirmation_pending until a person decides. Each tools/list descriptor advertises the contract as _meta["io.pracht/confirmation"] = { required: true, metaKey: "io.pracht/confirmation" }, so a host can drive the flow without parsing prose.
Transport Security
Every capability guarantee carries over. The projection adds three of its own:
Cookie-bearing requests are rejected. Adapter context factories can decode a session before framework dispatch, so dropping cookie only from the synthesized capability request would be too late. The MCP endpoint returns 403 whenever its transport request carries a cookie, ensuring a browser session cannot authenticate remote MCP. Authorization is forwarded, so your middleware sees the MCP credential.
Browser-originated requests are rejected. Remote MCP has no browser use case, so requests carrying Origin or Sec-Fetch-Site receive 403. This avoids trusting a Host-derived request URL during Origin validation, closing the DNS-rebinding path. Non-browser MCP clients send neither header and are unaffected.
Destructive capabilities are unreachable without the opt-in. Without agents.mcp.destructive they are filtered out of tools/list and tools/call, and invokeCapability() refuses a destructive callee while serving an MCP tool, even when that callee is private. With the opt-in, a remote agent reaches a destructive effect only through the prepare/commit flow: composition refuses destructive callees unless the tool being served is itself a destructive capability that already cleared its own gate. Note what that does not say — once a tool has cleared it, that tool's own run() may compose any destructive capability, private ones included, as often as it likes for the rest of the request, exactly as an HTTP endpoint can. The confirmation gates the agent's entry point, not the first-party code behind it; the scope dies with the request.
Every dispatch emits an audit event with transport: "mcp" — passed as internal dispatch state rather than read from the public transport-marker header, so unlike the client-declared "webmcp" marker it is trustworthy — and anything the tool composes emits its own event carrying via: "mcp".
Authentication has two shapes. Leave agents.mcp.auth off and the endpoint is open: authentication is your app's, in the capability's named middleware, which sees the forwarded Authorization header. Turn it on and the transport itself becomes an OAuth 2.0 protected resource.
OAuth: Letting a Real Host Connect
An MCP host — Claude, a ChatGPT connector, Inspector — cannot connect to an authenticated server it has to be told about out of band. The MCP authorization spec answers that with two standards: RFC 9728 protected-resource metadata, and an RFC 6750 WWW-Authenticate challenge that points at it. Pracht implements the resource server half of both.
It is not, and will not become, an authorization server. Token issuance, refresh, consent screens, and client registration belong to the identity provider you already run. Pracht's job is to publish where that provider is, and to check what it issued.
export const app = defineApp({
agents: {
mcp: {
serverInfo: { name: "notes", version: "1.4.0" },
auth: {
// Absolute URL of this endpoint. It is the RFC 8707 audience tokens
// must be bound to, and the base for the metadata URL hosts discover.
resource: "https://app.example.com/mcp",
authorizationServers: ["https://auth.example.com"],
scopesSupported: ["notes.read", "notes.write"],
requiredScopes: ["notes.read"], // optional gate on every call
// Server-only module; its default export verifies one bearer token.
verify: () => import("./server/mcp-token.ts"),
},
},
},
});verify is a module reference, not an inline function, for the same reason capabilities and middleware are: the manifest is bundled into the client, and a token verifier — with its JWKS client and issuer configuration — must never be. Its module must default-export a function. Verifier lookup rejects ambiguous suffixes across src/server/, src/middleware/, and src/capabilities/; use a root-relative reference such as () => import("/src/server/mcp-token.ts") when duplicate suffixes exist. Overlapping source directories are supported: duplicate registry entries for the same normalized file count as one verifier, not an ambiguous reference. pracht verify and manifest resolution reject a relative resource, a resource carrying a query, fragment, or non-root trailing slash, a resource whose path does not exactly identify the served endpoint, a non-loopback cleartext URL, a non-canonical resource or issuer spelling, an authorization-server issuer with a query or fragment, an empty authorizationServers, a scope token outside OAuth's printable-ASCII grammar, unknown keys under agents.mcp or agents.mcp.auth, and a missing or non-callable default verify export. HTTP is accepted only on loopback during local development; deployed resource and issuer URLs must use HTTPS. Use canonical URL spellings: uppercase hosts, default ports, and dot segments can serialize to a different OAuth identifier. /mcp/ is not equivalent to /mcp, even though routing accepts either spelling. Authenticated endpoints redirect every non-canonical request spelling, alternate host, and query-bearing request to resource with 308 before challenging or verifying it.
Graph-only inspection loads the configured verifier before calling a protected endpoint ready. A missing module or non-callable default export sets mcpRuntimeStatus to blocked, records the reason in mcpUnavailableReasons, and renders its MCP exposures as mcp(unserved) in pracht dev and pracht inspect.
The MCP path must be distinct from every explicit API route. pracht verify rejects exact and dynamic collisions, such as agents.mcp.path: "/api/mcp" alongside either src/api/mcp.ts or an API pattern like /api/:name; the request runtime returns 500 rather than letting the API handler shadow MCP's transport and authentication gates.
The endpoint also owns its pathname ahead of page rendering and deployment static rewrites. Do not assign a page route the same path; Vercel's generated route table sends the MCP endpoint to the runtime before any matching SSG rewrite, so a method-agnostic static rule cannot intercept POST /mcp.
The committed app-graph snapshot records whether the endpoint is OAuth protected and its resource, authorization servers, required and advertised scopes, and verifier module. pracht plan reports those policy changes as well as enabling protection. Removing a required scope, trusting another authorization server, or removing auth from a still-live endpoint is a guard weakening, even when the /mcp path itself did not change.
The Metadata Document
Served unauthenticated and CORS-open — discovery happens before a host has a token — at the RFC 9728 path, where the well-known segment goes between the host and the resource's path:
curl -s https://app.example.com/.well-known/oauth-protected-resource/mcp{
"resource": "https://app.example.com/mcp",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["notes.read", "notes.write"],
"bearer_methods_supported": ["header"]
}The body is byte-stable across requests. The bare /.well-known/oauth-protected-resource answers with the same document, because hosts in the wild probe either form. bearer_methods_supported is always ["header"]: pracht reads the Authorization header and nothing else — never a form field or query parameter.
Under a deploy base, the document is still at the origin root. RFC 9728 inserts the well-known segment between the host and the resource's path, so the base ends up inside the suffix rather than in front of it. An app mounted at /app/ whose endpoint is https://app.example.com/app/mcp publishes at:
https://app.example.com/.well-known/oauth-protected-resource/app/mcpThat is what the challenge advertises and what the runtime serves — the path is matched before base stripping, precisely so the advertised URL is fetchable. Set resource to the endpoint's real deployed URL, base included; pracht derives the rest. A reverse proxy that re-prefixes the base onto the well-known path is tolerated too. If agents.mcp.path is /, the resource is the deployed app root itself (https://app.example.com/app in this example). At the origin root, use the canonical slashless identifier https://app.example.com; URL serialization still routes requests at /.
Because the match happens before routing and production-adapter static lookup, neither an application route nor a copied static file can shadow the document on Node, Cloudflare, Netlify, or Vercel. The bare metadata path is reserved and cannot be used as agents.mcp.path; choose another endpoint path instead. When MCP OAuth is enabled, Netlify excludedPath entries that would bypass either the protected MCP resource path or this reserved namespace are rejected; apps without agents.mcp.auth keep their existing exclusions because they serve no protected-resource metadata.
The Challenge
| Situation | Answer |
|---|---|
Request URL is not exactly resource |
308 to the configured canonical URL; no challenge or token verification |
No Authorization: Bearer |
401, WWW-Authenticate: Bearer resource_metadata="…" and configured scope="…" |
| Token present but rejected | 401, plus error="invalid_token" and configured scope="…" |
| Token valid, scope missing | 403, plus error="insufficient_scope", scope="…" |
WWW-Authenticate: Bearer error="invalid_token",
error_description="The bearer token is invalid or expired.",
resource_metadata="https://app.example.com/.well-known/oauth-protected-resource/mcp",
scope="notes.read"resource_metadata is the whole point: it is how a host that has never seen this server discovers which authorization server to talk to. Per RFC 6750 the no-credentials challenge carries no error code — "authenticate", not "your token is bad". When requiredScopes is configured, every challenge includes it so the host requests the right grant on its first authorization attempt.
The check runs alongside the transport hardening above, before the JSON-RPC body is parsed and long before a tool is resolved, so an unauthenticated caller learns nothing about the graph — not even whether a tool name exists. Method, Origin, and cookie rejections still come first: a cookie-bearing request is 403 whether or not it also carries a token.
Writing verify
import { createRemoteJWKSet, jwtVerify } from "jose"; // your dependency, not pracht's
import type { McpTokenVerifier } from "@pracht/core";
const jwks = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
const verify: McpTokenVerifier = async (token) => {
const { payload } = await jwtVerify(token, jwks, {
issuer: "https://auth.example.com",
// Bind the audience to the resource identifier (RFC 8707). Without this a
// token minted for another service on the same issuer would be accepted.
audience: "https://app.example.com/mcp",
});
return {
subject: payload.sub!,
scopes: typeof payload.scope === "string" ? payload.scope.split(" ") : [],
clientId: typeof payload.client_id === "string" ? payload.client_id : null,
};
};
export default verify;jose is a documentation choice, not a framework dependency — it runs on Workers and Vercel Edge, which is why the recipe uses it. Any library, or an introspection call to your provider, works the same way.
The hook fails closed. Returning null, throwing, or returning anything that is not a principal with a non-empty string subject all produce the same 401 invalid_token. A thrown error's message never reaches the caller — it may carry provider internals — and is logged once instead. A verify module that cannot be loaded at all answers 401 for every request rather than serving tools unguarded.
The verifier's second argument contains an independent request clone. It may inspect headers, URL, or even read the JSON-RPC body without consuming the body the MCP dispatcher reads afterward.
The Verified Principal
The principal is bound to the request context as context.tokenAuth, alongside context.agent:
async run({ context }) {
context.tokenAuth; // { subject, scopes?, clientId?, claims? } — frozen
}It is a frozen snapshot on a non-writable, non-configurable framework-owned field of a fresh request-local context overlay. Middleware may derive its own authorization state elsewhere on context, but cannot rewrite the identity a later capability or audit check sees. The adapter-supplied base context is left unchanged, so reusing it cannot carry one caller's principal into another request. When an MCP tool composes another capability, a replacement context.tokenAuth passed to invokeCapability() is shadowed by this verified principal. tokenAuth is absent on every other request path; an unauthenticated MCP request never reaches route middleware, API handlers, or capability code. An adapter's createContext hook may run before MCP authentication, so treat its request as untrusted and avoid privileged or expensive work based only on reachability.
Precisely what happens to the context object:
| Context | Result |
|---|---|
| Ordinary object, class instance, function, or array | Fresh overlay; reads and receiver-sensitive methods still reach the supplied context |
| Same supplied context reused for another request or principal | Fresh overlay with only that request's principal; the supplied context remains unchanged |
Already owns a tokenAuth field |
500; the field is framework-reserved |
| Frozen or sealed ordinary context | Accepted through the request-local overlay |
Native built-in requiring internal slots, such as Map or Date |
500 with guidance to wrap it in an ordinary request context |
The overlay preserves private-field and accessor receivers for class instances, keeps array behavior, and composes with the context.agent overlay. Native built-ins fail closed because a proxy cannot preserve their internal-slot identity; wrap one as a property of an ordinary context instead.
claims is frozen shallowly — its own keys cannot be added, removed, or rewritten, but nested values are whatever your verifier returned and stay mutable. Deep-freezing would reach into objects your code still owns (a jose JWT payload, say). The framework never reads claims, and the whole principal is request-local, so even a nested mutation cannot carry authorization state into a later request through a reused adapter context.
The two identities compose: context.agent says which agent software signed the request, context.tokenAuth says on whose behalf it is acting. One gap worth knowing: the capability audit event carries agent, not tokenAuth, so an audited MCP dispatch names the calling software but not the account behind it. Capture the principal in named middleware or capability code while request context is available and send it to the same audit sink until the event gains a field for it.
Apps that leave auth off pay nothing for it — no metadata route, no header, and no bundle bytes: the auth module sits behind its own dynamic import inside the MCP runtime, which is itself only loaded when agents.mcp is configured.
Talking to It
curl -sX POST http://localhost:3000/mcp \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
curl -sX POST http://localhost:3000/mcp \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"notes_search","arguments":{"query":"roadmap"}}}'Protocol versions are negotiated on initialize, newest first: 2025-11-25, 2025-06-18. The 2026-07-28 profile is not advertised until its self-describing request headers and result codec are implemented together.
For a repeatable check instead of a curl, a pracht eval scenario with "transport": "mcp" drives this endpoint the way a host does: one initialize handshake, then a tools/call per step. For a protected endpoint, add "mcpHeaders": { "authorization": "Bearer …" }; the runner sends it on initialization and every later request. Keep real tokens out of committed scenario files. That is the difference between a capability that declares expose.mcp and one you have proven an MCP host can call.
Once agents.mcp.auth is configured, add the token — and point the host at the endpoint, not at the metadata URL; it discovers that itself:
curl -sX POST https://app.example.com/mcp \
-H 'content-type: application/json' \
-H "authorization: Bearer $TOKEN" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'Not built yet: an authorization server (pracht is the resource server — issuance and consent stay with your identity provider), resources/* and prompts/*, streaming and progress, and MCP Apps UI views.
Private by Default
- A capability without
exposeis never reachable over the network. - Exposure requires a complete contract —
pracht verifyfails for exposed capabilities missing a description, schema, or effect class. destructivecapabilities are gated by a server-verified confirmation flow. They may be exposed over HTTP and over remote MCP — the latter only with theagents.mcp.destructiveopt-in and a registered approval store — never as a WebMCP page tool. See Agent Trust.- Output is validated too: a handler returning data outside its output schema produces a redacted 500, never the raw value.
- HTTP-exposed capabilities are listed in the generated
/llms.txtwith their endpoint, effect class, and description, so agents can discover them without scraping.
Cost When Unused
Apps that register no capabilities and configure no agents do not ship the agent surface. During a production build, the vite plugin reads the manifest and lets the bundler drop both the capability dispatch and Web Bot Auth verifier when neither can be present, including when llmsTxt only indexes pages and API routes. Development keeps the runtime available so adding a capability does not require restarting the dev server.
The analysis fails conservatively: unreadable or non-literal manifests, parse failures, spreads, shorthand registrations, computed keys, regular-expression literals, and other opaque syntax keep the runtime. Static analysis may preserve a few unused bytes, but it never silently disables a capability or agent configuration that works at runtime.
The client stays opt-in too. Capability metadata only reaches the browser through virtual:pracht/capabilities, and the WebMCP shim is emitted only for capabilities that set expose.webmcp.
Inspect the Graph
The capability graph feeds every inspection surface: the pracht dev startup banner, pracht inspect capabilities [--json], the /_pracht devtools page, the inspect_capabilities and inspect_agents tools on the pracht dev-mcp server, and the static checks in pracht verify. Capability rows include their webmcpRoutes; pracht inspect routes and the devtools route table show the active tool names from the other direction. Runtime-backed devtools label a blocked declaration mcp(unserved). Graph-only CLI inspection labels it mcp(unverified) when the unmet precondition may instead be registered by the skipped adapter server entry. Destructive declarations without agents.mcp.destructive remain mcp(unserved). JSON inspection always includes mcpEndpoint, mcpDestructive, mcpRuntimeStatus, and mcpUnavailableReasons, so automation can distinguish declared exposure, verified runtime failure, and incomplete inspection.
pracht inspect capabilities
# notes.search read http,webmcp,mcp /api/capabilities/notes/search
# notes.create write http,mcp /api/capabilities/notes/createpracht inspect agents rolls the same graph up against defineApp({ agents }) — the Web Bot Auth policy and keys, the destructive-confirmation mode, the remote MCP endpoint and OAuth policy, whether llms.txt is generated, and how many capabilities each transport exposes. Its JSON payload preserves the same MCP runtime-status fields, while text output marks affected declarations mcp(unserved) or mcp(unverified) instead of presenting a declared transport as proof that the tool is reachable.
The CLI, MCP, startup-banner, and Capabilities-table views describe the static configured surface. To see whether agents actually are calling it, read the live audit events in the Agents panel on /_pracht in dev, or register a production sink with addCapabilityAuditListener(). Retained traffic keeps the panel visible after HMR removes the final capability, until the dev server restarts. See Agent trust.
In dev, the page itself is an inspection surface too: every document pracht dev serves registers read-only pracht_* WebMCP tools — matched route, loader data, islands, last error, and the app's own page tools on that route — for an agent-driven browser testing the tab. See Dev page tools.
Coming next: MCP Apps UI views rendered with Preact, so a capability can return an interactive result into an agent's chat.
For the story behind the design, read The Agentic Web; for the identity, confirmation, and audit rules every projection enforces, read Agent Trust; for unit, E2E, and WebMCP testing patterns, see the Testing recipe.