add-capabilities
Expose an app operation as a typed pracht capability — one contract projected into direct server calls, an HTTP endpoint, a route-scoped WebMCP page tool, and a remote MCP tool — plus `defineApp({ agents })` trust config, typed clients, `<Form capability>`, and `pracht eval` scen
Install
npx skills add https://github.com/JoviDeCroock/pracht/tree/main/skills/add-capabilities
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jovidecroock-pracht@llmmart
git clone https://github.com/JoviDeCroock/pracht.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole jovidecroock/pracht collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Pracht Add Capabilities
A capability is a protocol-neutral operation. Every projection runs the identical pipeline, so rules never diverge per transport:
input validation → named middleware chain → run() → output validation
Registration is opt-in and private by default; loaders and API routes are never inferred as capabilities, and an app with none ships no dispatch surface.
Non-Pracht app: use createCapabilityHost() and the signal-owned WebMCP
registrar. See https://pracht.resynapse.dev/docs/standalone-capabilities.
Step 1: Decide the contract before writing code
Settle these with AskUserQuestion when the request is vague:
- Name — dot-separated segments (
notes.search); this is the agent-visible identity and the MCP tool name (dots become underscores). - Effect —
read,write, ordestructive. This drives confirmation gating, client revalidation, and MCP annotations. Classify honestly. - Exposure — private (omit
expose),http,webmcp(requireshttp),mcp(mcp: { scopes }limits the tool to OAuth tokens with those scopes). - Router — manifest apps use a
defineApp({ capabilities })key; pages apps auto-discoversrc/capabilities/, where each module declaresname: "notes.search"(or takes its file stem) and the name must map back to its file with dots as hyphens (notes-search.ts). Step 4 covers the rest. - Authorization — which named middleware runs, and whether the endpoint
requires a verified agent (
agentPolicy: "require").
Exposure matrix the runtime, defineCapability(), and pracht verify all
enforce:
| Effect | http |
webmcp |
mcp |
|---|---|---|---|
read / write |
yes | yes (needs http) |
yes (needs agents.mcp) |
destructive |
yes — always confirmation-gated | rejected | yes — needs agents.mcp.destructive and a registered approval store |
Step 2: Install and scaffold
create-pracht does not add the package, because an app without capabilities
should not carry it:
npm install @pracht/capabilities
pracht generate capability --name notes.search --effect read --expose http,webmcp \
--description "Find notes whose title or body matches the query."
The generator writes and registers src/capabilities/notes-search.ts.
--description is required with --expose; the MCP generate_capability tool
has the same contract.
If dispatch answers 500 internal_error and pracht inspect capabilities
prints capabilities as unreadable, the package is missing — that is the
symptom.
Step 3: Write the capability
// src/capabilities/notes-search.ts
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",
middleware: ["auth"], // names from the app manifest
expose: { http: true, webmcp: true },
// webmcp: { untrustedContent: true } advertises untrustedContentHint for
// page tools whose results carry user-generated content.
// agentPolicy: "require", // verified Web Bot Auth agents only —
// // never combine with webmcp: page-tool
// // calls are unsigned and would always 401
async run({ input, context, request, signal }: CapabilityRunArgs<SearchInput>) {
return { notes: searchNotes(input.query, input.limit) };
},
});
Schema rules that bite:
- Only a subset of JSON Schema is accepted:
type,properties,required,additionalProperties,items(single schema),enum,const,minimum,maximum,minLength,maxLength,default, plustitleanddescription.oneOf,anyOf,allOf,$ref,pattern,format, and tupleitemsthrow at definition time — a keyword the validator would ignore could widen what an exposed capability accepts. - Inputs and outputs are JSON data only.
File,Blob,Date,Map,undefined, and cycles are rejected — keep uploads in API routes. exposeandeffectmust be inline literals.inputandoutputmay be Standard Schema + Standard JSON Schema validators such as Zod 4. Pracht derives the supported JSON subset server-side and runs the validator; async validation, defaults, and transforms work without adding it to WebMCP.- MCP exposure additionally requires both schemas rooted at
type: "object". - Annotate
run()withCapabilityRunArgs<Input>so TypeScript still infers the output;defineCapability<Input>alone leaves the outputunknown.
Step 4: Register it, and configure agents only if needed
// src/routes.ts
import { defineApp, route } from "@pracht/core";
export const app = defineApp({
capabilities: {
"notes.search": () => import("./capabilities/notes-search.ts"),
},
agents: {
// Verified agent identity (public keys — safe in the manifest).
webBotAuth: { policy: "observe", directories: ["https://signature-agent.cloudflare.com"] },
// Destructive prepare/commit tuning.
confirmation: { ttlSeconds: 120 },
// Remote MCP endpoint; without this, `expose.mcp` serves nothing.
mcp: { serverInfo: { name: "notes", version: "1.0.0" }, instructions: "…" },
},
routes: [
route("/notes", "./routes/notes.tsx", {
// expose.webmcp makes it eligible; this makes it active on this page.
capabilities: ["notes.search"],
}),
],
});
Pages apps have no manifest: the same capabilities come from
src/capabilities/ and the same agents object is export const agents in
src/pages/_app.config.ts. Activate WebMCP tools on each page that needs them:
// src/pages/notes.tsx
export const CAPABILITIES = ["notes.search"];
CAPABILITIES must be an inline array of non-empty registered names and cannot
appear on _app or 404. In a manifest, group capability lists are additive.
Unknown names, capabilities without expose.webmcp, and activation on
hydration: "none" routes are rejected. Initial hydration registers the matched
route's set; every committed client navigation replaces it, so never assume a
tool exposed on one page persists globally.
In dev, the tab's pracht_page_tools WebMCP tool lists the tools active on
the route and why a declared capability is not one.
Each agents sub-option is independent — add only what the app uses. Web Bot
Auth policy: "require" gates capability HTTP endpoints (not pages or API
routes) with 401 agent_required; agentPolicy: "require" on a capability
fails closed even when webBotAuth is unconfigured.
Authenticating the MCP endpoint (agents.mcp.auth)
agents: { mcp: {} } alone serves an open endpoint — anyone who can reach
the URL can call every expose.mcp tool, and authentication is whatever the
capability's named middleware does with the forwarded Authorization header.
That is fine for a public read surface and wrong for anything scoped to a user.
Add auth and /mcp becomes an OAuth 2.0 protected resource: pracht publishes
RFC 9728 metadata at /.well-known/oauth-protected-resource, answers
unauthenticated calls with the WWW-Authenticate challenge MCP hosts follow,
and calls your verify module. This is what makes a real host (Claude, a
ChatGPT connector) able to connect at all.
mcp: {
serverInfo: { name: "notes", version: "1.0.0" },
auth: {
resource: "https://app.example.com/mcp", // absolute; token audience
authorizationServers: ["https://auth.example.com"],
scopesSupported: ["notes.read", "notes.write"],
requiredScopes: ["notes.read"], // optional per-request gate
verify: () => import("./server/mcp-token.ts"), // server-only module
},
},
Rules to hold the user to:
verifyis a module reference, never an inline function. The manifest is bundled into the client; a JWKS client in it would ship to every visitor. Put the module insrc/server/and default-export the verifier function. It must live undersrc/server/,src/middleware/, orsrc/capabilities/— those are the only directories the build globs into the module registry, and a verifier anywhere else is never loadable, so every/mcprequest 401s forever.pracht verifyerrors on that, but do not create the file elsewhere. If the same suffix exists in more than one registry directory, lookup rejects it as ambiguous; use a root-relative reference such as() => import("/src/server/mcp-token.ts").- Security option names are exact. Unknown keys under
agents.mcpandagents.mcp.authare rejected instead of ignored; do not work around the error with casts. The MCP path must also differ from every explicit API route path, orpracht verifyrejects the graph and the runtime fails closed with 500 before the API handler can bypass MCP's gates. - Pracht is not an authorization server. Do not offer to implement token
issuance, refresh, or dynamic client registration — those belong to the
user's identity provider. Write
verifywith their library (joseworks on Workers and Vercel Edge) and bindaudienceto theresourcevalue, or a token minted for another service on the same issuer is accepted. - It fails closed.
null, a throw, or a malformed principal all give401 invalid_token; a missing required scope gives403 insufficient_scope. Challenges advertiserequiredScopes. The verifier gets a request clone; reading its body is safe. - The principal is
context.tokenAuth— a frozen{ subject, scopes?, clientId?, claims? }, alongsidecontext.agent. Use it in named middleware andrun()for authorization; the framework only authenticates. Nested calls retain the transport-verified principal. Capture it separately when audit events must identify the account. resourcemust be the endpoint's real deployed URL: absolute, free of query/fragment and exactly matching the deployed MCP path, including the app base (https://app.example.com/app/mcp). Use HTTPS outside loopback.resolveApp()andpracht verifyreject malformed values and scope strings; mismatched authenticated request URLs redirect to the canonical resource.- The bare
/.well-known/oauth-protected-resourcepath is reserved for discovery and cannot be used asmcp.path. Production adapters route both metadata forms ahead of copied static files. pracht plansnapshots the OAuth policy separately from the endpoint path. Removingauthor a required scope, or trusting another authorization server, is a guard weakening even when/mcpitself did not move.
See https://pracht.resynapse.dev/docs/capabilities for the metadata document and the verify recipe.
Step 5: Destructive capabilities
destructive (delete, publish, pay, send, change access) may be exposed over
http and mcp, never webmcp, and every dispatch is gated:
- Set
PRACHT_CONFIRMATION_SECRETin the server environment (or callsetCapabilityConfirmationSecret()from@pracht/core/server). Without it, calls fail closed with403 confirmation_unavailableandpracht verifyfails — verify reads the environment, so the variable must be set even when the app registers the secret programmatically. - A call without a token answers
409 confirmation_requiredwith a token bound to principal + capability + canonical input + expiry. - The commit repeats the call with byte-identical input plus the confirmation header.
Be honest about what this buys, and say so to the user
(https://pracht.resynapse.dev/docs/agent-trust): stateless HMAC cannot prevent replay inside the TTL,
the calling agent can hand the token back to itself, and without Web Bot Auth
or setCapabilityApprovalPrincipalResolver() both phases run as "anonymous".
Register a CapabilityApprovalStore for exactly-once commits, and
confirmation: { mode: "human" } for a real human decision — that mode fails
closed without both a store and an end-user identity (a principal resolver, or
agents.mcp.auth on remote MCP); a Web Bot Auth key alone is refused.
createSqlApprovalStore({ execute }) from @pracht/core/server is the
first-party durable store — one implementation for Postgres, Cloudflare D1, and
SQLite/Turso. Pass a parameterized-query function and run the migration from
the Agent Trust page; use dialect: "postgres" for $1 placeholders.
createMemoryApprovalStore() is for tests only. A non-SQL backend needs atomic
conditional writes (Durable Objects, Redis — not Cloudflare KV).
Destructive over remote MCP
Off by default. To serve one:
agents: { mcp: { destructive: true } }indefineApp().- Register an approval store from a server entry or a capability module, so it
exists before the graph is served — a token handed to the committing agent
must be consumable exactly once. The endpoint refuses to serve at all when
the store,
PRACHT_CONFIRMATION_SECRET, or (in human mode) any resolvable principal is missing;pracht verifywarns when it cannot find the registration in the configured source directories. - The flow is unchanged; only the channel differs. Prepare answers
isError: truewith the token in_meta["io.pracht/error"], and the commit repeatstools/callwith identicalargumentsplus_meta["io.pracht/confirmation"].
Nested invokeCapability() under an MCP tool still refuses destructive callees
unless the served tool is itself a destructive capability that already cleared
prepare/commit.
Step 6: Call it
// Server: loaders, API routes, middleware — works for private capabilities too.
import { invokeCapability } from "@pracht/core/server";
const result = await invokeCapability("notes.search", { query: "roadmap" }, { request, context, signal });
// Browser: generated, typed, http-exposed names only.
import { capabilities, useCapability } from "virtual:pracht/capabilities";
const result = await capabilities.notes.search({ query: "roadmap" });
Unresolvable in TS? Add "@pracht/vite-plugin/virtual" to tsconfig types.
// One contract for the human form and the agent tool.
<Form capability="notes.create" onCapabilityResult={(result) => { /* … */ }}>
<input name="title" />
<button type="submit">Create</button>
</Form>
- Prefer a loader +
invokeCapability()for data a page needs on load;useCapability()dispatches on interaction, never during render. - After a successful non-
readcall the route's data revalidates automatically (revalidate: falseopts out). - Capability modules are server-only: importing one from client code is a build
error, because nothing would strip
run()and its database client out of the browser bundle.
Step 7: Types, inspection, and proof
pracht typegen # emits src/pracht-capabilities.d.ts
pracht inspect capabilities --json
pracht verify --json # contract, exposure, and projection checks
pracht eval --start "pracht preview"
Once the declaration exists the compiler rejects unknown names, bad input,
browser calls to private capabilities, destructive calls without
prepare/confirm, and computed names (assert as HttpCapabilityName).
Re-run pracht typegen --check in CI.
pracht eval runs JSON scenarios against the live app and exits 1 on a failed
expectation. Steps can reference earlier results
($steps[0].error.confirmationToken); a scenario-level signAs block signs
every step as a verified agent.
A scenario targets HTTP by default; scenario-level "transport": "mcp" runs
the same steps over the remote MCP endpoint (initialize, then one
tools/call per step, names mapped notes.search → notes_search). Write one
of each for any expose.mcp capability — passing over HTTP does not prove an
MCP host can reach it.
If agents.mcp.auth protects the endpoint, add scenario-level
"mcpHeaders": { "authorization": "Bearer …" }; it applies to initialize and
every later request, and step-level headers.authorization overrides it for
one call. Inject test tokens in CI. Expectations are portable: expect.status
is the capability dispatch status on both transports.
Three MCP limits fail loudly: a step for a capability without expose.mcp, a
step header other than authorization (the projection forwards nothing else),
and a destructive step whose app has not enabled agents.mcp.destructive with
an approval store. For an exposed destructive MCP tool, confirm completes the
same prepare/commit round trip as HTTP, with the token in the call's
_meta["io.pracht/confirmation"].
createCapabilityTestHost() from @pracht/core covers the same pipeline in
unit tests, without a server.
WebMCP: names must fit the draft's 1–128 ASCII [a-zA-Z0-9_.-] grammar;
Chrome advises 30 chars/name, 500/tool description, 150/parameter description,
and 1.5K/result. pracht verify checks static limits; bound outputs yourself.
Stable Chrome needs an origin-trial token. No mainstream production agent
consumes these tools yet, so retain HTTP or remote MCP. See the capabilities
site page for current compatibility. An effective agentPolicy: "require"
always 401s unsigned page-tool calls and is warned.
To audit what the whole agent surface exposes, run /audit-agent-surface.
Rules
- Never expose a
destructivecapability overwebmcp; expose it overmcponly withagents.mcp.destructiveand a durable approval store, and say so to the user. Never reclassify a destructive operation aswriteto escape the confirmation gate. - Never widen a schema (drop
required, openadditionalProperties, raise amaximum) without saying so —pracht planreports it as a widening of the agent-reachable surface for a reason. - Keep
exposeandeffectinline; reuse a Standard JSON Schema validator forinput/outputwhen the app already has one. - Treat
expose.webmcpas eligibility, not activation. Add the capability to only the manifest routes/groups or PagesCAPABILITIESexports where an in-page agent should see it. - Put authentication, authorization, and rate limiting in named middleware —
the framework ships no rate limiting, no write-idempotency helper, and no
result-size budget. Bound outputs with a
limitinput and a schemamaximum. - Design
writeinputs to be safely repeatable; agents retry, and onlydestructivecalls are token-gated. - Never register an app-wide approval endpoint or UI without your own authorization — who may approve is an application decision.
- Re-run
pracht typegenafter changing a schema, name, exposure, or route activation, andpracht verifybefore committing.
$ARGUMENTS
Files (pracht)
-
SKILL.md 19.5 KB
--- name: add-capabilities version: 1.3.0 description: | Expose an app operation as a typed pracht capability — one contract projected into direct server calls, an HTTP endpoint, a route-scoped WebMCP page tool, and a remote MCP tool — plus `defineApp({ agents })` trust config, typed clients, `<Form capability>`, and `pracht eval` scenarios. Use for "add a capability", "expose this to agents", "add an MCP tool", "add WebMCP", "serve remote MCP", "make my app agent-callable". allowed-tools: - Bash - Read - Write - Edit - Grep - Glob - AskUserQuestion --- # Pracht Add Capabilities A capability is a protocol-neutral operation. Every projection runs the identical pipeline, so rules never diverge per transport: ```text input validation → named middleware chain → run() → output validation ``` Registration is opt-in and private by default; loaders and API routes are never inferred as capabilities, and an app with none ships no dispatch surface. Non-Pracht app: use `createCapabilityHost()` and the signal-owned WebMCP registrar. See <https://pracht.resynapse.dev/docs/standalone-capabilities>. ## Step 1: Decide the contract before writing code Settle these with `AskUserQuestion` when the request is vague: - **Name** — dot-separated segments (`notes.search`); this is the agent-visible identity and the MCP tool name (dots become underscores). - **Effect** — `read`, `write`, or `destructive`. This drives confirmation gating, client revalidation, and MCP annotations. Classify honestly. - **Exposure** — private (omit `expose`), `http`, `webmcp` (requires `http`), `mcp` (`mcp: { scopes }` limits the tool to OAuth tokens with those scopes). - **Router** — manifest apps use a `defineApp({ capabilities })` key; pages apps auto-discover `src/capabilities/`, where each module declares `name: "notes.search"` (or takes its file stem) and the name must map back to its file with dots as hyphens (`notes-search.ts`). Step 4 covers the rest. - **Authorization** — which named middleware runs, and whether the endpoint requires a verified agent (`agentPolicy: "require"`). Exposure matrix the runtime, `defineCapability()`, and `pracht verify` all enforce: | Effect | `http` | `webmcp` | `mcp` | | ------ | ------ | -------- | ----- | | `read` / `write` | yes | yes (needs `http`) | yes (needs `agents.mcp`) | | `destructive` | yes — always confirmation-gated | rejected | yes — needs `agents.mcp.destructive` **and** a registered approval store | ## Step 2: Install and scaffold `create-pracht` does not add the package, because an app without capabilities should not carry it: ```bash npm install @pracht/capabilities pracht generate capability --name notes.search --effect read --expose http,webmcp \ --description "Find notes whose title or body matches the query." ``` The generator writes and registers `src/capabilities/notes-search.ts`. `--description` is required with `--expose`; the MCP `generate_capability` tool has the same contract. If dispatch answers `500 internal_error` and `pracht inspect capabilities` prints capabilities as `unreadable`, the package is missing — that is the symptom. ## Step 3: Write the capability ```ts // src/capabilities/notes-search.ts 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", middleware: ["auth"], // names from the app manifest expose: { http: true, webmcp: true }, // webmcp: { untrustedContent: true } advertises untrustedContentHint for // page tools whose results carry user-generated content. // agentPolicy: "require", // verified Web Bot Auth agents only — // // never combine with webmcp: page-tool // // calls are unsigned and would always 401 async run({ input, context, request, signal }: CapabilityRunArgs<SearchInput>) { return { notes: searchNotes(input.query, input.limit) }; }, }); ``` Schema rules that bite: - Only a **subset** of JSON Schema is accepted: `type`, `properties`, `required`, `additionalProperties`, `items` (single schema), `enum`, `const`, `minimum`, `maximum`, `minLength`, `maxLength`, `default`, plus `title` and `description`. `oneOf`, `anyOf`, `allOf`, `$ref`, `pattern`, `format`, and tuple `items` throw at definition time — a keyword the validator would ignore could widen what an exposed capability accepts. - Inputs and outputs are JSON data only. `File`, `Blob`, `Date`, `Map`, `undefined`, and cycles are rejected — keep uploads in API routes. - `expose` and `effect` must be **inline literals**. `input` and `output` may be Standard Schema + Standard JSON Schema validators such as Zod 4. Pracht derives the supported JSON subset server-side and runs the validator; async validation, defaults, and transforms work without adding it to WebMCP. - MCP exposure additionally requires both schemas rooted at `type: "object"`. - Annotate `run()` with `CapabilityRunArgs<Input>` so TypeScript still infers the output; `defineCapability<Input>` alone leaves the output `unknown`. ## Step 4: Register it, and configure `agents` only if needed ```ts // src/routes.ts import { defineApp, route } from "@pracht/core"; export const app = defineApp({ capabilities: { "notes.search": () => import("./capabilities/notes-search.ts"), }, agents: { // Verified agent identity (public keys — safe in the manifest). webBotAuth: { policy: "observe", directories: ["https://signature-agent.cloudflare.com"] }, // Destructive prepare/commit tuning. confirmation: { ttlSeconds: 120 }, // Remote MCP endpoint; without this, `expose.mcp` serves nothing. mcp: { serverInfo: { name: "notes", version: "1.0.0" }, instructions: "…" }, }, routes: [ route("/notes", "./routes/notes.tsx", { // expose.webmcp makes it eligible; this makes it active on this page. capabilities: ["notes.search"], }), ], }); ``` Pages apps have no manifest: the same `capabilities` come from `src/capabilities/` and the same `agents` object is `export const agents` in `src/pages/_app.config.ts`. Activate WebMCP tools on each page that needs them: ```ts // src/pages/notes.tsx export const CAPABILITIES = ["notes.search"]; ``` `CAPABILITIES` must be an inline array of non-empty registered names and cannot appear on `_app` or `404`. In a manifest, group capability lists are additive. Unknown names, capabilities without `expose.webmcp`, and activation on `hydration: "none"` routes are rejected. Initial hydration registers the matched route's set; every committed client navigation replaces it, so never assume a tool exposed on one page persists globally. In dev, the tab's `pracht_page_tools` WebMCP tool lists the tools active on the route and why a declared capability is not one. Each `agents` sub-option is independent — add only what the app uses. Web Bot Auth `policy: "require"` gates capability HTTP endpoints (not pages or API routes) with `401 agent_required`; `agentPolicy: "require"` on a capability fails closed even when `webBotAuth` is unconfigured. ### Authenticating the MCP endpoint (`agents.mcp.auth`) `agents: { mcp: {} }` alone serves an **open** endpoint — anyone who can reach the URL can call every `expose.mcp` tool, and authentication is whatever the capability's named middleware does with the forwarded `Authorization` header. That is fine for a public read surface and wrong for anything scoped to a user. Add `auth` and `/mcp` becomes an OAuth 2.0 protected resource: pracht publishes RFC 9728 metadata at `/.well-known/oauth-protected-resource`, answers unauthenticated calls with the `WWW-Authenticate` challenge MCP hosts follow, and calls your `verify` module. This is what makes a real host (Claude, a ChatGPT connector) able to connect at all. ```ts mcp: { serverInfo: { name: "notes", version: "1.0.0" }, auth: { resource: "https://app.example.com/mcp", // absolute; token audience authorizationServers: ["https://auth.example.com"], scopesSupported: ["notes.read", "notes.write"], requiredScopes: ["notes.read"], // optional per-request gate verify: () => import("./server/mcp-token.ts"), // server-only module }, }, ``` Rules to hold the user to: - **`verify` is a module reference, never an inline function.** The manifest is bundled into the client; a JWKS client in it would ship to every visitor. Put the module in `src/server/` and default-export the verifier function. It must live under `src/server/`, `src/middleware/`, or `src/capabilities/` — those are the only directories the build globs into the module registry, and a verifier anywhere else is never loadable, so every `/mcp` request 401s forever. `pracht verify` errors on that, but do not create the file elsewhere. If the same suffix exists in more than one registry directory, lookup rejects it as ambiguous; use a root-relative reference such as `() => import("/src/server/mcp-token.ts")`. - **Security option names are exact.** Unknown keys under `agents.mcp` and `agents.mcp.auth` are rejected instead of ignored; do not work around the error with casts. The MCP path must also differ from every explicit API route path, or `pracht verify` rejects the graph and the runtime fails closed with 500 before the API handler can bypass MCP's gates. - **Pracht is not an authorization server.** Do not offer to implement token issuance, refresh, or dynamic client registration — those belong to the user's identity provider. Write `verify` with their library (`jose` works on Workers and Vercel Edge) and **bind `audience` to the `resource` value**, or a token minted for another service on the same issuer is accepted. - **It fails closed.** `null`, a throw, or a malformed principal all give `401 invalid_token`; a missing required scope gives `403 insufficient_scope`. Challenges advertise `requiredScopes`. The verifier gets a request clone; reading its body is safe. - **The principal is `context.tokenAuth`** — a frozen `{ subject, scopes?, clientId?, claims? }`, alongside `context.agent`. Use it in named middleware and `run()` for authorization; the framework only authenticates. Nested calls retain the transport-verified principal. Capture it separately when audit events must identify the account. - `resource` must be the endpoint's **real deployed URL**: absolute, free of query/fragment and exactly matching the deployed MCP path, including the app base (`https://app.example.com/app/mcp`). Use HTTPS outside loopback. `resolveApp()` and `pracht verify` reject malformed values and scope strings; mismatched authenticated request URLs redirect to the canonical resource. - The bare `/.well-known/oauth-protected-resource` path is reserved for discovery and cannot be used as `mcp.path`. Production adapters route both metadata forms ahead of copied static files. - `pracht plan` snapshots the OAuth policy separately from the endpoint path. Removing `auth` or a required scope, or trusting another authorization server, is a guard weakening even when `/mcp` itself did not move. See <https://pracht.resynapse.dev/docs/capabilities> for the metadata document and the `verify` recipe. ## Step 5: Destructive capabilities `destructive` (delete, publish, pay, send, change access) may be exposed over `http` and `mcp`, never `webmcp`, and every dispatch is gated: 1. Set `PRACHT_CONFIRMATION_SECRET` in the server environment (or call `setCapabilityConfirmationSecret()` from `@pracht/core/server`). Without it, calls fail closed with `403 confirmation_unavailable` and `pracht verify` fails — verify reads the environment, so the variable must be set even when the app registers the secret programmatically. 2. A call without a token answers `409 confirmation_required` with a token bound to principal + capability + canonical input + expiry. 3. The commit repeats the call with byte-identical input plus the confirmation header. Be honest about what this buys, and say so to the user (<https://pracht.resynapse.dev/docs/agent-trust>): stateless HMAC cannot prevent replay inside the TTL, the calling agent can hand the token back to itself, and without Web Bot Auth or `setCapabilityApprovalPrincipalResolver()` both phases run as `"anonymous"`. Register a `CapabilityApprovalStore` for exactly-once commits, and `confirmation: { mode: "human" }` for a real human decision — that mode fails closed without both a store and an end-user identity (a principal resolver, or `agents.mcp.auth` on remote MCP); a Web Bot Auth key alone is refused. `createSqlApprovalStore({ execute })` from `@pracht/core/server` is the first-party durable store — one implementation for Postgres, Cloudflare D1, and SQLite/Turso. Pass a parameterized-query function and run the migration from the Agent Trust page; use `dialect: "postgres"` for `$1` placeholders. `createMemoryApprovalStore()` is for tests only. A non-SQL backend needs atomic conditional writes (Durable Objects, Redis — not Cloudflare KV). ### Destructive over remote MCP Off by default. To serve one: 1. `agents: { mcp: { destructive: true } }` in `defineApp()`. 2. Register an approval store from a server entry or a capability module, so it exists before the graph is served — a token handed to the committing agent must be consumable exactly once. The endpoint refuses to serve at all when the store, `PRACHT_CONFIRMATION_SECRET`, or (in human mode) any resolvable principal is missing; `pracht verify` warns when it cannot find the registration in the configured source directories. 3. The flow is unchanged; only the channel differs. Prepare answers `isError: true` with the token in `_meta["io.pracht/error"]`, and the commit repeats `tools/call` with identical `arguments` plus `_meta["io.pracht/confirmation"]`. Nested `invokeCapability()` under an MCP tool still refuses destructive callees unless the served tool is itself a destructive capability that already cleared prepare/commit. ## Step 6: Call it ```ts // Server: loaders, API routes, middleware — works for private capabilities too. import { invokeCapability } from "@pracht/core/server"; const result = await invokeCapability("notes.search", { query: "roadmap" }, { request, context, signal }); ``` ```ts // Browser: generated, typed, http-exposed names only. import { capabilities, useCapability } from "virtual:pracht/capabilities"; const result = await capabilities.notes.search({ query: "roadmap" }); ``` Unresolvable in TS? Add `"@pracht/vite-plugin/virtual"` to tsconfig `types`. ```tsx // One contract for the human form and the agent tool. <Form capability="notes.create" onCapabilityResult={(result) => { /* … */ }}> <input name="title" /> <button type="submit">Create</button> </Form> ``` - Prefer a loader + `invokeCapability()` for data a page needs on load; `useCapability()` dispatches on interaction, never during render. - After a successful non-`read` call the route's data revalidates automatically (`revalidate: false` opts out). - Capability modules are server-only: importing one from client code is a build error, because nothing would strip `run()` and its database client out of the browser bundle. ## Step 7: Types, inspection, and proof ```bash pracht typegen # emits src/pracht-capabilities.d.ts pracht inspect capabilities --json pracht verify --json # contract, exposure, and projection checks pracht eval --start "pracht preview" ``` Once the declaration exists the compiler rejects unknown names, bad input, browser calls to private capabilities, destructive calls without `prepare`/`confirm`, and computed names (assert `as HttpCapabilityName`). Re-run `pracht typegen --check` in CI. `pracht eval` runs JSON scenarios against the live app and exits 1 on a failed expectation. Steps can reference earlier results (`$steps[0].error.confirmationToken`); a scenario-level `signAs` block signs every step as a verified agent. A scenario targets HTTP by default; scenario-level `"transport": "mcp"` runs the same steps over the remote MCP endpoint (`initialize`, then one `tools/call` per step, names mapped `notes.search` → `notes_search`). Write one of each for any `expose.mcp` capability — passing over HTTP does not prove an MCP host can reach it. If `agents.mcp.auth` protects the endpoint, add scenario-level `"mcpHeaders": { "authorization": "Bearer …" }`; it applies to `initialize` and every later request, and step-level `headers.authorization` overrides it for one call. Inject test tokens in CI. Expectations are portable: `expect.status` is the capability dispatch status on both transports. Three MCP limits fail loudly: a step for a capability without `expose.mcp`, a step header other than `authorization` (the projection forwards nothing else), and a destructive step whose app has not enabled `agents.mcp.destructive` with an approval store. For an exposed destructive MCP tool, `confirm` completes the same prepare/commit round trip as HTTP, with the token in the call's `_meta["io.pracht/confirmation"]`. `createCapabilityTestHost()` from `@pracht/core` covers the same pipeline in unit tests, without a server. WebMCP: names must fit the draft's 1–128 ASCII `[a-zA-Z0-9_.-]` grammar; Chrome advises 30 chars/name, 500/tool description, 150/parameter description, and 1.5K/result. `pracht verify` checks static limits; bound outputs yourself. Stable Chrome needs an origin-trial token. No mainstream production agent consumes these tools yet, so retain HTTP or remote MCP. See the capabilities site page for current compatibility. An effective `agentPolicy: "require"` always 401s unsigned page-tool calls and is warned. To audit what the whole agent surface exposes, run `/audit-agent-surface`. ## Rules 1. Never expose a `destructive` capability over `webmcp`; expose it over `mcp` only with `agents.mcp.destructive` and a durable approval store, and say so to the user. Never reclassify a destructive operation as `write` to escape the confirmation gate. 2. Never widen a schema (drop `required`, open `additionalProperties`, raise a `maximum`) without saying so — `pracht plan` reports it as a widening of the agent-reachable surface for a reason. 3. Keep `expose` and `effect` inline; reuse a Standard JSON Schema validator for `input`/`output` when the app already has one. 4. Treat `expose.webmcp` as eligibility, not activation. Add the capability to only the manifest routes/groups or Pages `CAPABILITIES` exports where an in-page agent should see it. 5. Put authentication, authorization, and rate limiting in named middleware — the framework ships no rate limiting, no write-idempotency helper, and no result-size budget. Bound outputs with a `limit` input and a schema `maximum`. 6. Design `write` inputs to be safely repeatable; agents retry, and only `destructive` calls are token-gated. 7. Never register an app-wide approval endpoint or UI without your own authorization — who may approve is an application decision. 8. Re-run `pracht typegen` after changing a schema, name, exposure, or route activation, and `pracht verify` before committing. $ARGUMENTS
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.