Skip to content

MCP And AI

github-actions[bot] edited this page Sep 7, 2026 · 14 revisions

MCP and AI

The differentiator. Not a chat widget, not an "AI SDK integration" — the framework is built so an agent can read it, drive it, and verify its own work, and so the apps it generates have the same property.

As of 2026-08. Stable API — semver from here (Upgrading). The MCP registry, wire protocol, dev-tool catalog, read-only SQL guard, and action projection are built, and so are the four that used to be contracted: llm() is an action factory (packages/ai/src/llm.ts), prompts are versioned, PgVectorStore fuses pgvector cosine with Postgres FTS via RRF, and evals gate on a committed baseline inside x verify's eval step.

Built-in MCP dev server

x dev starts an MCP server on the dev socket. Point Claude Code (or any MCP client) at it and the agent stops guessing.

Thirteen tools As of 2026-08 — the whole catalog, spelled exactly as they must be called. No aliases; renaming one is a major.

Tool Introspects / does Replaces the agent's usual guess
routes.list route table: url, render mode, hydrate, offline, budget grepping a router directory
schema.describe entities with columns, types and invariants reading migration files in order
policies.list every policy: permission, subject, where it is enforced "is this endpoint protected?"
actions.describe every action and query: input/output schema, policy, cache tags, MCP exposure reading api/ by hand
jobs.inspect job definitions, retry policy and steps; omit name for all reading jobs.ts and guessing the retry
queue.depth pending, running and failed counts per queue tailing a worker to see if it keeps up
manifest.read the whole x.manifest.json, as text ten separate reads
errors.explain X_* code → cause, exact fix command, docs URL web search
db.query read-only SQL, 100-row default and 1000-row maximum, EXPLAIN on request inventing a query and hoping
db.migrate apply pending migrations in a branch DB only mutating the dev database
tests.run run the suite or a substring filter, structured results parsing terminal output
verify.run the whole gate; fix: true applies safe autofixes guessing whether the work is shippable
logs.tail last N structured log lines, filterable by runtime role scrollback archaeology
Class Tools Exposure
read routes.list, schema.describe, policies.list, actions.describe, jobs.inspect, queue.depth, manifest.read, errors.explain scope dev:read, unrestricted in dev
gated read db.query, logs.tail scope db:read / dev:logs
executes code tests.run, verify.run scope dev:test; both declare destructive: true, so neither is metered as read chatter
write db.migrate scope db:migrate, branch environments only

None of them is exposed in ROLE=web. db.query accepts one statement, whose leading keyword must be SELECT/WITH/EXPLAIN/SHOW/TABLE/VALUES — necessary, never sufficient. Batches, any write keyword at statement level (a data-modifying CTE included), locking clauses (FOR UPDATE/FOR SHARE), EXPLAIN ANALYZE, and whole function families matched by prefix of the called name, quoted and schema-qualified spellings included — file access (pg_read_*, pg_ls_*, lo_*, dblink), locks (pg_advisory_*), session settings (set_config) and sleeps (pg_sleep*) — are refused, not discouraged — X_MCP_QUERY_REJECTED, enforced before the host sees the string. Its Postgres SELECT-only role is conditional on the connection's own rights; the answer's guards array names the defences that engaged. db.migrate refuses a target that is not a branch database — X_MCP_NOT_BRANCH_DB.

Rate limits on the HTTP transport

Enforced As of 2026-08-24. mcpHttpRoute meters itself, per caller, per class, per minute. It has to: all MCP traffic is one POST /mcp, so the HTTP pipeline's own buckets — which key on the route — cannot tell an initialize handshake from a migration.

Default Spent by
read 120 / minute every method that is not tools/call, plus any tool that does not declare destructive: true
write 20 / minute a tools/call naming a destructive: true tool, and any call this server cannot resolve — fail-closed, so a probing client never gets the cheap bucket
Rule Detail
Metered after the parse, before the tool the class comes out of the body, so it cannot move above the JSON parse. An unauthenticated caller is answered 401 four lines earlier and can never spend an actor's allowance
The key is the ACTOR mcp:<class>|actor:<id>, and it never reaches the caller: a 429 is provokable by anyone holding a valid token, so an actor or org id in one is a leak wearing a throttle's clothes
Refusal X_MCP_RATE_LIMITED, 429, with Retry-After. Its own code and not X_RATE_LIMITED, because that one's fix: names the HTTP pipeline's buckets, which do not govern this route — a fix line that runs and changes nothing is worse than none
The knob mcpHttpRoute({ rateLimits: { read, write } }), or defineAppMcp({ rateLimits })
Behind N replicas pass rateLimitStore: postgresRateLimitStore({ executor }) as well. The default is a per-process memory store — honest for x mcp serve, and a lie for N replicas behind one URL, each enforcing the full allowance on its own

Every action is an MCP tool

mcp: { expose: true, description: 'Publish a draft post' },

That line is the entire integration. From the existing declaration:

MCP requirement Source
tool name the action's export name, verbatimpublishPost, never publish_post. The one name tools/call accepts, scopes: is keyed on, and every published catalog spells. As of 2026-08: openapi.json's x-ultimate.mcpTool and describe().mcp.tool published a snake_case name until then, and an agent that trusted either called a tool the server answers ToolNotFound for
JSON Schema for input the input schema (Standard Schema → JSON Schema, via introspect())
output schema output
description mcp.description
authorization the action's policy — unchanged, unwrapped, identical
audit trail the same OTel span and log line as an HTTP call

The projected tool's run is invoke — the same entry point the HTTP route calls (packages/mcp/src/projectable.ts). Policy runs inside it, so there is nothing to keep in sync. The projection itself adds no MCP scope: a second gate hard-coded into the projection would sit in front of the only gate that matters, and the two would eventually disagree. defineAppMcp's scopes: map may still attach one from outside — a property of the connection's token, never invented by the projection.

No MCP-specific permission table, no service account with broad rights. Exposure is opt-in; silence exposes nothing.

The user's own agents can therefore operate the user's product — refund an order, re-run an import, publish a post — with the exact permissions that user has in the UI. See Admin dashboard and Actions.

The app's own endpoint is mounted by the web role

As of 2026-09-05. defineAppMcp({ …, resolveToken }) builds mcp.route, and app.config.ts declares ai: { mcp: { expose: true, path: '/mcp' } } by default — and until this date nothing between the two served it: POST /mcp was X_ROUTE_NOT_FOUND under x dev and in every container. The contract is one file:

Piece Where Rule
the declaration apps/<app>/mcp.ts export const mcp = defineAppMcp({ include: 'exposed', resolveToken })
the switch and the path app.config.tsai.mcp expose (default true), path (default /mcp)
the mount x dev and runRole, through one call POST <path>mcp.route.handle(request); the boot log says app mcp mounted, x dev's summary prints mcp POST /mcp
auth the route's own auth: 'public', enforcedBy: 'handler' on the http route — the bearer token is read by resolveToken, never pre-judged by the pipeline
exposed and unmountable X_MCP_APP_UNMOUNTED, logged once no file exports mcp, or it was built without resolveToken (no route); the fix names the file to write

The same discovery serves an app's RuntimeOverrides: apps/<app>/runtime.ts exporting runtime reaches x dev (its middleware and rate-limit store) and runRole when apps/web/server.ts passes none — one middleware chain in development and in the container, not two.

Three outcomes, deliberately different

Role, scope and policy refuse in three distinguishable ways. The difference is the security property, not an implementation detail.

Situation Response Wire
The actor's role can never invoke the tool absent from tools/list; a direct call answers ToolNotFound JSON-RPC -32601, message tool not found: <name> — call tools/list to read the catalog this caller may use, no data at all. The hint is the same sentence on both branches — absent and hidden — so it instructs without saying which
The role could invoke it, but the connection's scope does not include it explicit refusal naming the missing scope JSON-RPC -32600, data: { code: 'X_MCP_SCOPE_DENIED', scope, fix }
The tool was invoked and the policy denied this input X_FORBIDDEN with the denial reason a normal result with isError: true — identical to the HTTP answer for the same call

Hidden means hidden: Forbidden on a hidden tool is an enumeration oracle — an agent, or an attacker driving one, walks a name list and reads the org's feature set, entity names and internal operations off the difference between "not found" and "forbidden". A scope refusal is the opposite case: a well-behaved client can legitimately fix it, so hiding it would only strand the caller.

Rule Detail
visibleTo takes two forms a role allowlist, or a predicate over the caller. A tool that declares neither is visible to everyone
Both forms are fail-closed a role list admits only the roles it names, so a caller whose role is not in it — including a caller with no role at all — is refused; a caller with no role sees only tools that declare no visibleTo
tools/list is per connection answered per caller, never a static catalog
Visibility is input-independent the predicate takes the caller and nothing else — it structurally cannot read call arguments, so existence cannot be probed by varying them
Gate order is fixed visibility → scope → arguments → policy. Scope runs before the policy, so a refusal never depends on evaluating a policy against attacker-supplied input; arguments are validated after both gates, so a schema never leaks to a caller who may not see the tool
Every outcome is audited one structured log line per tools/call: surface: 'mcp', tool name, actor id, outcome. ToolNotFound, scope denials and policy denials log at warn, a successful call at info — ToolNotFound is warn on purpose, because an enumeration attempt is a detectable pattern
Audit lines carry no payload tool name, outcome and error code only — never call arguments, never row data
No trusted-tool mode there is no flag that skips policy evaluation, on any MCP surface
The actor cannot exceed the human the actor is the signed-in user's session; an agent inherits exactly those permissions

Where the first two outcomes are declared:

Outcome Declared Property
Hidden (role) mcp: { visibleTo: [...] }, on the action or query itself readonly string[] — a role allowlist. A primitive declares the list form only: a declared fact stays static and serialisable. The predicate form of McpVisibility is for a surface that builds its catalog programmatically (@ultimat3/admin derives visibility from the actor's admin permissions) and hands @ultimat3/mcp a tool directly. Both are fail-closed: an unnamed role — including a caller carrying no role at all — gets ToolNotFound, never Forbidden. A catalog audience, not an authz rule; the primitive's policy still decides every call
Scope scopes: on defineAppMcp Readonly<Record<string, readonly string[]>> — scope name → tool names. A capability of the connection's token, so it is declared once per app rather than beside every primitive. A name the catalog does not contain, or one claimed by two scope entries, refuses at boot: X_MCP_SCOPE_UNKNOWN, X_MCP_SCOPE_CONFLICT

Rationale for each: docs/architecture/11-ai-surface.md.

Generated facts, hand-written conventions

Artifact Author Contents Rule
x.manifest.json generated, every build routes, entities, actions, mutators, queries, jobs, tasks, policies, cache tags, MCP tools, budgets, build ID never hand-edited; drift is a x verify failure
openapi.json generated HTTP surface from action/query declarations contract diff in x verify
AGENTS.md human-authored, short project-specific conventions an agent cannot infer never generated, never auto-appended
CLAUDE.md human-authored, short same, compressed-config style, <600 lines never generated, never auto-appended

LLM-generated context files measurably reduce task success. A model writing "here is what this codebase does" produces confident, plausible, partly-wrong prose, and the next agent treats it as ground truth — errors compound and cannot be distinguished from facts. So: facts come from code (structured, verifiable, regenerated every build), conventions come from a human (short, opinionated, stable). Ultimate never generates prose documentation at runtime, and x new scaffolds AGENTS.md as a terse human-editable stub, not an essay.

LLM gateway

One typed entry point for model calls — provider-agnostic, observable, cached, evaluated.

export const summarize = llm({
  model: 'claude-sonnet-5',
  input:  t.object({ postId: t.uuid }),
  output: t.object({ summary: t.string, tags: t.string.array() }),
  prompt: summarizePrompt,                       // versioned artifact
  cache:  { semantic: { threshold: 0.97, ttl: '7d' } },
  budget: { tokensIn: 8000, costPerCall: { minor: 5, currency: 'USD' } },
  policy: can('post:read'),
});
Feature Behavior
Structured output output schema drives tool-use/JSON mode; a parse failure retries once, then throws X_LLM_OUTPUT_INVALID
Streaming .stream() on the returned action; policy, budget, cache and span unchanged; the done chunk carries the validated value
Cost + token accounting per call, per tenant, per prompt version; exceeding budget throws before spending
Retries typed on provider errors; rate limits back off, content refusals do not retry
Caching semantic cache from Caching and invalidation
Tracing one OTel span per call with model, tokens, cache hit, prompt version
Fallback ordered provider list per model; the provider that answered is on the span as llm.provider, never silent. A refusal names a more capable model for the declaration to adopt — models are never swapped at runtime
Money Money = { minor, currency } — never a float → Money

A stream is the same action over a different transport, with one difference that is forced rather than chosen: there is no repair turn. The non-streaming call retries once on a parse failure; a stream cannot, because the tokens are already on the reader's screen and a second answer is two answers to one question. So a stream yields unvalidated text increments plus one final done carrying the value that did satisfy output, and a schema disagreement is X_LLM_STREAM_INVALID naming the non-streaming call as the fix. A structured schema wants the non-streaming call.

Long or multi-call chains are jobs with steps, so a model call that fails on step 4 retries step 4 only. See Jobs and workflows.

Agents, hives and agent jobs → Agents

llm() is one model call. A tool-using loop is agent(), and it is the same kind of thing — an action factory, not a ninth primitive. Everything the loop needs is a factory over a primitive already in the vocabulary:

Factory Over Gives you
llm() action() one model call, streamable, semantically cacheable
agent() action() the turn loop: real actions as tools, forced structured output, maxTurns, a run budget, ctx.signal, onTurn. No .stream()
hive() action() one action fanned out over many inputs: bounded concurrency, split-order results, three-way ok/failed/skipped member outcomes
agentJob() job() an agent as durable, retried, cancellable queue work — As of 2026-08 the only way an agent reaches a queue
backfill() job() the resumable, paced sweep those run over → Migrations and backfills

A tool is a real action() carrying mcp: { expose: true } — the same predicate an external MCP client is filtered by, so an in-app agent and an external one are offered exactly the same catalogue and authorize identically. An agent() returns an action, so an agent is a tool of another agent with no supervisor primitive anywhere.

Full reference, including the at-least-once trap that makes every mutating tool's idempotency your obligation: Agents.

Prompts as versioned artifacts

apps/web/app/posts/prompts/summarize.v3.md      # the prompt, plain markdown + typed slots
apps/web/app/posts/prompts/summarize.evals.ts   # evals attached to it
Rule Why
A prompt is a file with a version, not a string literal diffable, reviewable, attributable in traces
Editing a prompt requires a version bump invalidates the semantic cache; keeps A/B honest
Slots are typed a missing variable is a compile error, not a {{undefined}} in production
Every prompt has an evals file — no evals is a x verify failure an unevaluated prompt is untested code
Old versions retained traces stay interpretable; rollback is a config line

Vectors and hybrid search

pgvector in the same Postgres. No second datastore.

Piece Detail
Embeddings declared on an entity: embed: { field: 'body', model: 'text-embedding-3-large' }
Backfill generated as a job with steps, resumable, rate-limited per tenant
Index HNSW, created by the generated migration
Hybrid search one query primitive fusing pgvector cosine + Postgres FTS with Reciprocal Rank Fusion; weights are config
Filtering tenant + policy filters applied in SQL, so vector search cannot leak across tenants
Re-embed content-hash change triggers a job; unchanged text is never re-embedded

Evals as a test type

eval is one of the six test types in Testing.

Aspect Detail
Shape fixture set + assertions: exact, schema, rubric (LLM judge), or regression-vs-baseline
Determinism temperature 0 where possible; judge model and prompt version pinned
Gate x verify fails on a score drop beyond the declared tolerance, not on absolute score
Cost reported per run; x test eval --sample 20 for the fast local loop
Output --json with per-case scores, so an agent iterating on a prompt sees which case it broke

Branch environments

The shipped surface is x db branch, with three verbs. x branch (no db) is planned and exits X_NOT_IMPLEMENTED; the build-id and MCP-socket rows below are what it will add.

x db branch create feat-new-billing --json
x db branch ls --json
x db branch drop feat-new-billing --json
Property Detail As of 2026-08
DB CREATE DATABASE "<source>_branch_<slug>" TEMPLATE "<source>" copy-on-write clone — cheap, isolated, disposable. <slug> is the name with every character outside [A-Za-z0-9_] replaced by _ — a hyphen is not legal in an unquoted Postgres identifier — so create feat-new-billing clones into myapp_branch_feat_new_billing. Embedded: a copied pgdata-<name> directory, which keeps the name as typed shipped
Migrations db.migrate applies here, never to the shared dev DB (X_MCP_NOT_BRANCH_DB) shipped
Preview URL http://<name>.localhost:<PORT>, reported on data.preview the URL is computed; nothing routes that subdomain for you
Teardown x db branch drop <name> — it may only drop what ls shows shipped
Build ID scopes the SW a per-branch build id giving the branch its own SW scope and cache namespace, so a preview can never poison prod cache → PWA and offline planned, part of x branch
Scoped MCP socket ws://localhost:9229/<name> planned, part of x branch
Agent use an agent can migrate, seed, test, and browse a preview without risking anything shared

--json everywhere

Every command and every error has a machine-readable form. Same content, different encoding.

$ x verify --json
{"ok":false,"checks":[{"name":"budgets","ok":false,"failures":[
  {"route":"site/pricing","metric":"js","actual":"61kb","limit":"40kb",
   "cause":"chart.js via shared/ui/button.tsx",
   "fix":"x fix boundary site/pricing/page.tsx"}]}]}
Surface Machine form
CLI --json on every subcommand
Errors UltimateError serializes to { code, cause, fix, docs }
HTTP errors same JSON body, same codes
Dev overlay the identical string a terminal shows
MCP tool errors carry the same code + fix

Errors

Code Cause Fix
X_MCP_TOOL_UNKNOWN no visible tool answers that name (role-hidden and absent are indistinguishable) tools/list to read the catalog this caller may use
X_MCP_ARGS_INVALID arguments failed the tool's declared JSON Schema re-read inputSchema from tools/list and resend
X_MCP_SCOPE_DENIED the connection's token does not carry the tool's scope reconnect with a token whose scopes include the one cause names — the app's resolveToken(token) is what returns them — or drop that scope from defineAppMcp({ scopes }). Scopes are fixed for the life of a connection, so a grant takes effect on the next one. Not x token grant: that command is PLANNED and exits X_NOT_IMPLEMENTED
X_MCP_SCOPE_UNKNOWN defineAppMcp's scopes: names a tool the server does not project spell the name as one of the tools the server actually projects, or drop it from that scopes entry
X_MCP_SCOPE_CONFLICT two scopes: entries claim the same tool keep the tool under the single scope a token must hold for it, and remove the other entry
X_MCP_QUERY_REJECTED db.query was not given one read-only statement send exactly one read-only SELECT/WITH/EXPLAIN/SHOW/TABLE/VALUES — a data-modifying CTE is not a read
X_MCP_NOT_BRANCH_DB db.migrate pointed at a database that is not a branch x db branch create <name> # then retry db.migrate — point the host at the database the create reported (DATABASE_URL=…/<source>_branch_<slug>). The target is read from the database's own name, so a shared one can never pass
X_MCP_PROTOCOL malformed envelope, a JSON-RPC batch (an array — this server answers one request per message and never walks one), or an unsupported method — a client bug, not an authz outcome send a JSON-RPC 2.0 body, one request per POST /mcp (one per line over stdio); the -32600 carries data: { code, fix } naming which
X_MCP_BODY_TOO_LARGE one message over bodyLimitBytes (HTTP, 413) or lineLimitBytes (stdio) — on a JSON-RPC -32600 with data: { code, cause, fix, limit } send less in one message, or raise the cap where the route is built: mcpHttpRoute({ bodyLimitBytes }) / defineAppMcp({ bodyLimitBytes }) / serveStdio({ lineLimitBytes })
X_MCP_RATE_LIMITED this caller spent its per-minute allowance for the request's class (above) wait out the Retry-After, or raise it where the route is built: mcpHttpRoute({ rateLimits }) / defineAppMcp({ rateLimits }). Never X_RATE_LIMITED's buckets — they do not govern this route
X_FORBIDDEN the action's policy refused this actor — identical to the HTTP denial call policies.list for the permission this tool enforces, then grant it to the actor's role in apps/web/shared/policies.ts
X_LLM_OUTPUT_INVALID model output failed the output schema twice tighten the prompt or widen the schema; bump the prompt version
X_AGENT_TOOL_UNEXPOSED an agent() lists an action that is not MCP-exposed — refused at declaration add mcp: { expose: true, description } to the action, or drop it from tools
X_AGENT_MAX_TURNS an agent() used every turn without answering tell the template when to stop and answer through the respond tool, then bump its version
X_HIVE_EMPTY a hive() split into zero members return at least one member input, or guard the call site
X_NOT_IMPLEMENTED a remote driver stub was reached configure the local/PGlite driver, or wait for the release named in fix

Full list: Error codes. CLI surface: CLI reference.

Rules

  • One authz system. An MCP call and an HTTP call reach the same policy with the same actor resolution.
  • One name. A projected tool is called the primitive's export name, verbatim, in tools/list, in scopes:, in the LLM tool list, in openapi.json and in describe(). There is no second spelling to derive.
  • Exposure is opt-in per action; the projection carries no scope of its own — defineAppMcp's scopes: map may still attach one, from outside the primitive.
  • Visibility is fail-closed and computed per connection. A hidden tool answers ToolNotFound, never Forbidden.
  • Gate order is visibility → scope → arguments → policy, and every outcome is audited. There is no trusted-tool mode.
  • Write tools are branch-scoped. The dev server is never reachable in ROLE=web.
  • Facts are generated every build; conventions are hand-written and short.
  • Never generate prose documentation at runtime.
  • Every command and every error has a --json form; budgets throw before spending, in Money.
  • Every prompt is a versioned file with an evals file.

Source: packages/mcp/src

Clone this wiki locally