Claude Skill

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

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Download jovidecroock-pracht-skills_add-capabilities-8bcdd52.zip · 8 KB

Install

skills CLI npx skills add https://github.com/JoviDeCroock/pracht/tree/main/skills/add-capabilities
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jovidecroock-pracht@llmmart
Git 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, 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:

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, 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

// 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:

  • 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

// 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-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

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

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.

No comments yet.

Reviews (0)

No reviews yet.