The MCP server built into every app

Every Clank app with a backend is also its own MCP server. The same typed queries and mutations used by the browser become tools that an authenticated agent can discover and call directly. You do not maintain a second agent API, generate an

9 min read1,824 wordsClank 0.22.1

Every Clank app with a backend is also its own MCP server. The same typed queries and mutations used by the browser become tools that an authenticated agent can discover and call directly. You do not maintain a second agent API, generate an OpenAPI client, or run a separate MCP process.

Those tools can also render as interactive, sandboxed views in compatible hosts. Bind a validated ui:// HTML resource with agent: { app: view }; Clank publishes the tool metadata and resource without changing its handler, auth, or data boundary. See Interactive MCP Apps.

For a deployed project, connect to:

text
https://<project>.apps.clank.run/__clank/mcp

A custom domain uses the same path:

text
https://app.example.com/__clank/mcp

The generated starter mounts this endpoint automatically. During local development it is http://127.0.0.1:3000/__clank/mcp unless the application uses a different port.

One app, one contract, one data boundary

Each application has its own MCP endpoint, server identity, OAuth clients, application users, resource-bound tokens, backend contract, and isolated database. There is no global MCP server that can read every hosted project's data.

text
src/backend.ts
    ├── browser client ── query / mutation ─┐
    └── MCP client ───── tool call ─────────┤
                                            ▼
                                  same handler, auth,
                                  validation, transaction,
                                  and application database

This is the important rule: if a UI operation reads or changes server data, implement it as a backend query or mutation. The browser and MCP then stay aligned because both invoke the same function. Do not add a separate UI-only persistence route.

Queries and mutations become MCP tools

Consider a small authenticated Todo backend:

ts
import {
  defineAuth,
  defineBackend,
  defineDatabase,
  defineTable,
  s,
} from "@clank.run/framework";

const auth = defineAuth();
const schema = defineDatabase({
  todos: defineTable({
    title: s.string({ min: 1, max: 160 }),
    done: s.boolean(),
  }).owned(),
});

export const backend = defineBackend({ schema, auth }).functions(
  ({ query, mutation }) => ({
    todos: {
      list: query({
        description: "List the signed-in user's todos.",
        args: {},
        handler: ({ db }) => db.table("todos").collect(),
      }),

      add: mutation({
        description: "Create a todo for the signed-in user.",
        args: {
          title: s.string({
            min: 1,
            max: 160,
            description: "Todo title",
          }),
        },
        agent: { destructive: false },
        handler: ({ db }, { title }) =>
          db.table("todos").insert({ title, done: false }),
      }),

      remove: mutation({
        description: "Permanently delete a todo.",
        args: {
          id: s.id("todos"),
          version: s.number({ integer: true, min: 1 }),
        },
        agent: { destructive: true },
        handler: ({ db }, { id, version }) =>
          db.table("todos").delete(id, { ifVersion: version }),
      }),
    },
  }),
);

Clank derives the agent contract directly from that function tree:

Backend functionMCP toolPermissionBehavior
Query todos.listtodos_listagent:readRead-only and idempotent
Mutation todos.addtodos_addagent:writeValidated additive write
Mutation todos.removetodos_removeagent:writeValidated destructive write

The args validators become JSON Schema tool inputs. description explains the operation to people and agents. The optional agent metadata describes whether a mutation is destructive, idempotent, or able to communicate outside the app. Set agent: false or agent: { enabled: false } only when a server function is intentionally internal.

The browser uses the same references:

ts
const client = createClient<typeof backend>();
const todos = client.live(client.api.todos.list);

await client.mutate(client.api.todos.add, {
  title: "Connect the app to an agent",
});

An MCP client sees and invokes todos_list and todos_add; it does not automate those browser controls. Clank publishes strict-client-compatible names containing only letters, numbers, and underscores, capped at 64 characters. The tool's clank/actionPath metadata still identifies todos.list or todos.add. Both paths reach the same handler and therefore share runtime validation, .owned() user isolation, transaction rollback, optimistic concurrency, and live- update notifications.

Connect an agent

Give the application MCP URL to any remote MCP client that supports Streamable HTTP and OAuth. For Codex:

sh
codex mcp add my-app \
  --url https://my-app.apps.clank.run/__clank/mcp
codex mcp login my-app

The browser opens the application's sign-in and consent page. After approval, it redirects to the MCP client's registered callback. The client receives a short-lived token restricted to this application and exact MCP resource. Nothing needs to be copied back, and the person connecting does not need the Clank deployment CLI or access to the deployment account.

Hosted browser clients can connect directly. Authenticated app MCP endpoints include the credential-free CORS preflight and response headers needed by browser transports, while the application session uses SameSite=Lax so a top-level authorization launch can recognize an existing login. Authenticated session checks also upgrade cookies minted under the older Strict default, and the authorization page performs one same-site recheck so an existing Strict cookie can be recognized. A sandboxed popup's password form carries a five-minute, one-time proof bound to its exact authorization return path and a private browser cookie. Clank atomically consumes the proof, so hosted proxies may normalize Fetch Metadata without turning opaque-origin login into a broad origin bypass. MCP calls never use the browser cookie: they require the explicit resource- bound bearer token.

Access tokens expire after one hour and the MCP client refreshes them without reopening the browser. Clank rotates the refresh token on every exchange. If two processes belonging to the same client retry the immediately previous token before they have persisted its replacement, Clank returns the exact same successor pair for up to 15 minutes. That response is stored only as an AES-GCM envelope keyed by the presented predecessor, so the database never contains recoverable plaintext credentials or their encryption key.

Adaptive rotation is enabled by default for public-client interoperability. Some MCP clients have multiple credential replicas and can later submit an older refresh token even after another replica adopts its successor. Clank retains a bounded chain of AES-GCM handoffs, walks at most 64 links, and returns the one current unspent successor without creating a second refresh-token branch. Each link is encrypted with its predecessor credential, contains no plaintext credential or server-held key, and expires with its immediate successor. An invalid, expired, corrupted, or overlong adaptive chain is rejected without revoking a newer replica's grant. Recovery never extends the current successor's 30-day refresh lifetime.

This compatibility behavior cannot distinguish a legitimate lagging replica from someone holding a copied predecessor bearer token. Use strict rotation when replay-driven family revocation is more important than interoperability with replicated public clients.

Applications that need a shorter handoff window can configure it when opening the backend:

ts
await openBackend(backend, {
  agent: {
    refreshTokenRetryLifetimeMs: 2 * 60 * 1000,
  },
});

The window accepts one second through one hour; 15 minutes is the interoperability-focused default for returning the exact same response. It does not create another refresh-token branch.

Security-sensitive applications can require strict rotation. In strict mode, a predecessor used after the exact-response window revokes the family immediately and no multi-generation chain is retained:

ts
await openBackend(backend, {
  agent: {
    refreshTokenRotationMode: "strict",
  },
});

The agent never receives the user's password, browser session cookie, or CSRF token. A read grant can list and call queries. A write grant can also call mutations. Normal application checks still apply, including required login, verified email, roles, record ownership, argument validation, and document-version conflicts.

Each signed-in user can review their active clients at:

text
https://my-app.apps.clank.run/__clank/oauth/access

Making a grant read-only or revoking it changes the authority checked on the next request; every stateless MCP request revalidates its resource-bound token and current scopes. The inbox and its JSON API are stored in this app's isolated database. See Agent access inbox and scoped grants.

Keep the UI and MCP contract synchronized

Treat src/backend.ts as the single source of truth:

  1. Add or change the backend query or mutation.
  2. Update its argument schema, description, and agent safety metadata.
  3. Make the UI call that same typed function reference.
  4. Build, test both paths, and deploy them together.

Bind server-backed controls to the typed reference rather than copying its name into a string:

tsx
import { createApi } from "@clank.run/framework";
import type { backend } from "./backend.ts";

const api = createApi<typeof backend>();

<button
  agentId="todo-add"
  agentAction={api.todos.add}
  onClick={() => client.mutate(api.todos.add, { title })}
>
  Add
</button>

Clank serializes the reference as data-clank-action="todos.add" in both SSR and browser rendering. assertAgentActionParity() compares rendered controls with GET /__clank/manifest; verifyAgentActionParity() fetches that no-store manifest and also binds the deployment-sensitive revision header. Unknown, internal, undocumented, duplicate-ID, and missing required actions fail with a structured clank-agent-action-parity/1 report. Generated apps run this assertion from npm test.

Clank fingerprints every agent-visible name, schema, description, scope, and annotation. A contract change produces a new revision. Discovery responses require revalidation and tool lists have a zero freshness lifetime. MCP 2026-07-28 requests carry their complete protocol and client context on every POST, so rolling deploys and replica changes do not depend on process-local session state. An unknown stale tool also returns a structured refresh hint.

This prevents the MCP action list from silently remaining on an older release while the UI moves ahead. Legacy clients through 2025-11-25 still use bounded compatibility sessions and rediscover after a deployment invalidates one; current clients simply make the next stateless request.

Apps that register durable workflow graphs also publish a workflows section in GET /__clank/manifest: input/output schemas, step job paths, dependency edges, descriptions, and side-effect metadata. The graph is documentation, not an authorization bypass. Make a workflow callable by wrapping jobs.startWorkflow() in an ordinary mutation; that mutation then supplies the MCP tool, authentication, roles, scopes, validation, confirmation policy, and audit boundary.

Managed buckets follow the same freshness rule. Every declared bucket adds current bucket_<name>_list, read, put, and delete tools; image variants add transform. Bucket definitions are included in MCP metadata and the backend manifest, so changing MIME policy, ownership, quotas, or variants changes the contract revision and refreshes connected clients. OAuth supplies the owner identity—bucket tools never accept a user ID argument. See Managed buckets.

Verify an app's MCP surface

Before deployment, check that every server-backed UI operation has one matching backend function and that internal functions are intentionally hidden. npm test performs this check for generated apps. A custom app can run:

ts
await verifyAgentActionParity(document, {
  requiredActions: [api.todos.add, api.todos.remove],
});

After deployment:

  • open https://<project>.apps.clank.run/.well-known/clank to confirm the advertised endpoint and contract revision;
  • compare the agent-enabled entries in GET /__clank/manifest with authenticated MCP tools/list;
  • confirm every tool has a precise description, bounded input schema, correct read/write scope, and honest destructive/idempotent annotations; and
  • call representative queries and mutations as two different users to prove owned data remains isolated.

For UI-backed tools, also authenticate MCPJam before running its MCP Apps conformance suite. An anonymous protocol check correctly receives OAuth 401 before it can inspect tools/list or resources/read; this is not a malformed JSON-RPC response.

Continue with Agent protocol for the full MCP transport, discovery, OAuth, scope, freshness, and security contract. Read Full-stack applications for backend implementation details and Authentication for application identity and authorization.

Agent activity explorer

Enable bounded, persistent activity metadata on the application backend and add it to a local DevTools panel:

ts
const backend = await openBackend(definition, {
  path: "app.sqlite",
  agentActivity: { maxEntries: 1000, maxAgeMs: 7 * 24 * 60 * 60 * 1000 },
});
const inspector = createDevtools({ agentActivity: () => backend.inspectAgentActivity() });
const panel = await serveDevtools(inspector);

Each recognized tool attempt records its declared name, required scope, granted read/write scopes, completion outcome (ok, error, or denied), start time, and duration. Backend function calls also record database revisions observed before and after execution. Those ranges can be compared with document history; concurrent writers may contribute changes inside them, so they are not an exclusive attribution of every revision to that tool. Bucket tools and authorization denials have no backend revision range. Anonymous/public calls show no granted OAuth scopes.

backend.inspectAgentActivity({ tool, outcome, scope, since }) filters retained events, newest first. renderAgentActivity(snapshot) provides escaped HTML for an existing authorized operator view. The backend inspection method is trusted server code, never an automatically exposed RPC or MCP tool. The built-in DevTools server binds to loopback only. Do not mount an unguarded inspector in the public application.

Retention defaults to 1,000 calls/seven days, configurable up to 10,000 calls/30 days. Pruning runs on record and inspection. Records survive backend restarts in the application SQLite database. Arguments, outputs, error messages, cookies, bearer tokens, request URLs, and principal identities are excluded. Unknown tools and requests rejected before tool authorization are not retained.

Standalone MCP servers can set onToolActivity(event, request) for their own metadata sink. The backend's sink reports storage failures through onError, while tool results remain intact. This explorer is an operational history, not a tamper-proof or transactionally complete audit log; a process crash or storage failure after a tool commits can leave a missing event.