Skip to content
brust

Every brust app is also an MCP server. The framework already knows what an agent needs — your actions are typed endpoints and your route loaders return structured data — so it exposes both over the Model Context Protocol (revision 2025-06-18) at:

POST /_brust/mcp        (JSON-RPC 2.0)

There is nothing to enable: brust.run() builds the manifest at boot and wires the server into every worker. The transport is plain HTTP POST — no SSE notifications or streaming (deferred) — which any MCP client that speaks Streamable HTTP can use, as can curl:

curl -s -X POST http://localhost:1337/_brust/mcp \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Tools — from defineActions

Each endpoint registered on the defineActions chain in your actions.ts becomes one tool, named <method>_<path-slug>: POST /notespost_notes, GET /notes/{id}get_notes_by_id, a root path → get_root. Two endpoints slugging to the same name fail the build; an endpoint with a non-literal path string is skipped with a warning.

Schemas are extracted from your TypeScript — no hand-written JSON Schema:

Schema part Source
inputSchema.params {x} segments in the path — each a required string.
inputSchema.body The endpoint's body validator, via the handler ctx type.
inputSchema.query The endpoint's query validator, likewise.
outputSchema The handler's return type (Promise unwrapped).
description The endpoint's description option.

tools/call arguments nest by routing role — { params?, query?, body? } — and dispatch through the same path as a real HTTP request: body/query validation (422 on failure), the endpoint middleware chain, and the typed error contract all apply identically. The same auth that protects users protects agents. The tool result carries the response body as text, with isError: true for any status ≥ 400.

Resources — from route loaders

Every route with a loader is listed as a resource at brust://<path-template> (e.g. brust:///pokemon/{name}). resources/read matches the URI against the route table — {param} segments capture — runs the loader, and returns its result as JSON. Output schemas for resources are not extracted yet.

The manifest

A boot-time extractor walks actions.ts and routes.tsx with the TypeScript compiler API and caches the result as .brust/mcp-manifest.json. brust build ships it as dist/mcp-manifest.json, so a deployed dist serves agents without doing any type analysis at boot. An app with no actions.ts exposes zero tools — loaders still become resources.

Protocol surface

Method Behavior
initialize Protocol 2025-06-18; capabilities: tools, resources, prompts, logging.
tools/list, tools/call As above.
resources/list, resources/read As above.
prompts/list Always empty (no prompts surface).
logging/setLevel Accepted; no notifications are emitted.

That is the whole surface today: tools and resources derived from code you already wrote, over POST. There is no separate agent SDK, CLI command, or configuration file.

Driving the UI — window.Brust

MCP is the server-side surface. When an agent is driving a real browser, window.Brust gives its JavaScript evaluator a framework-aware view of the current page and actions that dispatch normal DOM events. The results are plain JSON, so the same calls work through Playwright, CDP, or an in-browser eval tool.

The runtime is opt-in outside development:

Mode Activation
brust dev On automatically.
Programmatic production run({ ai: true }) or BRUST_AI=1.
Prebuilt production brust build --ai (or build with BRUST_AI=1).

When it is off, the runtime chunk, manifest routes, and document script tag are absent. There is no client cost.

See the app

pages() reads the app's route manifest without crawling. Each entry includes the route template, params, page kind, and shell id:

const pages = await Brust.pages()
// [{ path: '/notes/{id}', params: ['id'], kind: 'react', shellId: 'L:…', … }]

struct() describes the page that is currently rendered: headings, links, buttons, forms and fields, standalone inputs, React islands, and native x-data behaviors. Every actionable element has a short opaque ref:

await (async () => {
  const page = await Brust.struct({ maxText: 120 })
  if ('ok' in page && page.ok === false) return page
  return page.buttons.find((button) => button.text === 'Create note')
})()
// { ref: 'e7', text: 'Create note', disabled: false, kind: 'button' }

Scope a snapshot with either a ref or a CSS selector:

await Brust.struct({ within: '#account-settings', maxText: 80 })

Refs remain valid while their element remains attached, but every navigation invalidates the whole generation. A stale ref returns code: 'stale-ref'; run Brust.struct() again before the next action.

Act on the page

Every action target accepts a ref or a CSS selector. Actions scroll the target into view, perform the corresponding native or synthetic DOM action, wait for the page to settle, then return the resulting URL and any errors raised during the action.

Method Use
action.click(target) Pointer down/up/click sequence.
action.focus(target), action.blur(target) Move focus.
action.fill(target, value) Fill input, textarea, select, or contenteditable.
action.form(nameOrRef, values, options?) Fill named fields and submit with requestSubmit().
action.press(target, key) Keydown/keypress/keyup sequence.
action.select(target, valueOrLabel) Select an option by value or label.
action.check(target, boolean) Set a checkbox or radio.

The form name comes from data-ai-name, then name, then id:

await Brust.action.form('new-note', {
  title: 'Release notes',
  body: 'Document the browser runtime',
})

form() matches fields by their name attribute. If a controlled React input has no name, take its ref from struct() and fill it directly:

await (async () => {
  const page = await Brust.struct({ within: '#new-note' })
  if ('ok' in page && page.ok === false) return page
  const unnamed = page.forms[0]?.fields.find((field) => field.name === '')
  if (!unnamed) return { ok: false, error: { code: 'not-found', message: 'field not found' } }
  return Brust.action.fill(unnamed.ref, 'Release notes')
})()
const result = await Brust.navigate('/notes', { struct: true })
// same-shell: { ok: true, status: 'spa', url: '…', struct: { … } }

await Brust.back()
await Brust.reload()

navigate() waits for a successful SPA transition and newly mounted islands before resolving. { struct: true } includes a fresh snapshot in the same round-trip. A cross-shell destination returns status: 'full-load' before the document unloads; the browser harness observes the load and evaluates again in the new page. External destinations report status: 'external'.

Errors and redaction

Public methods resolve instead of throwing across the evaluator boundary. An error has one envelope:

{
  ok: false,
  error: {
    code: 'stale-ref',
    message: 'ref e7 is no longer valid',
    hint: 're-run Brust.struct()'
  }
}

struct() returns null instead of the value of password inputs and fields marked data-ai-redact. A subtree marked data-ai-ignore is omitted entirely.

The browser loads the runtime from /_brust/ai.js; pages() fetches /_brust/ai/manifest.json once and caches it. These routes exist only when the runtime is enabled.

Brust.wait, Brust.state, Brust.nav, Brust.api.list, Brust.api.call, and Brust.errors are declared for the next phase but currently resolve a disabled error. The P1 surface is pages, struct, actions, and navigation.