Application MCP surface
A TesseraQL application can declare its own Model Context
Protocol surface — tools an agent calls, resources it
attaches as context, interactive UI it renders, and prompts it reuses — the way the app
already declares HTTP routes. Each is a YAML document placed under mcp/ instead of web/,
compiled through the same route pipeline (security, input validation, 2-way SQL, telemetry),
and served by the runtime at /_tesseraql/mcp, so the running business application is
AI-enabled. This page is for building MCP features into the app you ship; for the
development-time tooling — the tesseraql mcp server a coding agent uses to lint, test, and
scaffold the app itself — see AI-assisted development (MCP).
Sub-folders under mcp/ are for tidiness only. Unlike web/, where the directory path is
the URL, an MCP document is addressed by the name it declares — a tool by its id:, a
resource by its uri: — so moving a file between folders changes nothing a client sees, and
two documents declaring one name collide wherever they sit (TQL-MCP-1014). Colocated files
resolve beside their document, so mcp/sales/orders/tool.yml reads
mcp/sales/orders/list.sql.
A tool is a query-json or command-json definition placed under mcp/ instead of web/:
same recipe, input constraints, 2-way SQL, and security as a route — only the entry point
differs.
version: tesseraql/v1id: find-orderskind: toolrecipe: query-jsondescription: Find orders for a customer, newest first. Use when asked about a customer's orders.
input: customerId: type: integer required: true limit: type: integer default: 20 min: 1 max: 100
security: auth: bearer policy: orders.read
sources: main: sql: file: find-orders.sql mode: query params: customerId: query.customerId limit: query.limitThe runtime serves every declared tool over the Streamable HTTP transport at
/_tesseraql/mcp (the same protocol the dev-tool HTTP transport speaks). On
startup the compiler turns each tool into an internal route running the full pipeline —
telemetry, the tool’s own authentication and authorization, input validation, the SQL or
transactional command — and the MCP endpoint dispatches a tools/call to it. So:
- Security is per-tool and identical to a route. The MCP request’s
Authorization: Bearerrides into the tool’s route, where its declaredauth/policyrun. A tool with no security is public; a tool with a policy enforces it; an unauthorized call comes back as an MCP tool error. Discovery (tools/list) is open so a client can see what the app offers. - The endpoint judges the caller before the message. A request that names an
Originis a browser page’s: the endpoint answers one from a loopback origin or from the application’s own external origin and refuses every other with403(TQL-MCP-4265) — the MCP specification’s guard against DNS rebinding, which makes a web page same-origin to a local server. APOSTmust declareapplication/json(415,TQL-MCP-4266), the one content type a browser cannot send without a preflight; aMCP-Protocol-Versionthe server does not speak is400(TQL-MCP-4267); and every request afterinitializecarries theMcp-Session-Idit was given (400,TQL-MCP-4268; an unknown or idle-expired one is404,TQL-MCP-4269). A JSON-RPC batch is accepted and answered as an array. - The input schema is derived from the route’s
input:constraints (types, required, ranges,minLength/maxLength,pattern, theemail/uuid/urlformats, enums), so the model is guided toward valid arguments; validation still runs server-side. - The result is the SQL/command result as JSON (
{ "rows": [...], "rowCount": n }for a query), or a custom shape if the tool declares aresponse: { json: ... }block. - Governance, lint, and coverage extend to tools. A write (command) tool must declare an
authorization policy or lint fails (
TQL-MCP-4030): deny-by-default, because an agent must not mutate data unauthorized. The governance gate scores and gates tools like routes, so a write tool reachable without authentication isadvancedand needs approval. Anmcpcoverage kind tracks which tools your declarative suites exercise.
Set tesseraql.mcp.enabled: false to stop serving the endpoint (tools, resources, UI
resources, and prompts alike).
Resources
Section titled “Resources”Alongside its tools, an application declares read-only resources — context an agent attaches,
the way a person pastes a document into a chat. A resource is a query-json definition placed
under mcp/ with kind: resource: it is addressed by a stable uri instead of a name, takes no
arguments (its uri is the whole address), and runs the same read pipeline a query-json route
runs.
version: tesseraql/v1id: active-userskind: resourcerecipe: query-jsonuri: tesseraql://users/activemimeType: application/jsondescription: Active users (id, name). Attach for user-directory context.
security: auth: bearer policy: users.read
sources: main: sql: file: active-users.sql mode: queryThe runtime serves every declared resource over the same /_tesseraql/mcp endpoint as the tools.
On startup the compiler turns each resource into a read-only internal route — telemetry and the
audit trail, the resource’s own authentication and authorization, tenancy and locale resolution,
the 2-way SQL — and the MCP endpoint answers resources/list and resources/read from it. So:
- Discovery and read.
resources/listadvertises every resource (uri,name,mimeType,description);resources/read { "uri": ... }runs the SQL and returns the JSON result as the resource’scontents, tagged with itsuriandmimeType.resources/templates/listis empty (no URI-templated resources are modeled). - Security is per-resource and identical to a route. The request’s
Authorization: Bearerrides into the resource’s route, where its declaredauth/policyrun. Discovery is open; reading an unauthorized resource comes back as aresources/readJSON-RPC error (the connection stays up, so the agent can read the message). - Read-only by construction. Lint rejects a resource that is not
query-jsonwith query-mode SQL (TQL-MCP-1003), that declares nouri(TQL-MCP-1004) or anyinput:(TQL-MCP-1006), and fails fast on a duplicate uri (TQL-MCP-1007); a missingdescriptionis a warning (TQL-MCP-1005). The governance gate scores a resource like a read route (neveradvanced, since it cannot write), and anmcp-resourcecoverage kind tracks which resources your declarative suites exercise.
MCP Apps UI
Section titled “MCP Apps UI”A tool can hand back a rendered view beside its JSON — the MCP Apps
extension
(SEP-1865). The view is a server-rendered hc-* fragment, not a client-side template: an
application declares a UI resource as a kind: ui document under mcp/ — a query-html
(or page) definition, addressed by a stable ui:// uri — and a kind: tool document
references it with a ui: field. What the host receives is a snapshot of the resource’s own
query, rendered when it is read; the section below says exactly what that snapshot is and is
not.
version: tesseraql/v1id: orders-boardkind: uirecipe: query-htmluri: ui://orders/boarddescription: A board of open orders, rendered as a Hypermedia Components fragment.
security: auth: bearer policy: orders.read
sources: main: sql: file: orders-board.sql mode: query
response: html: template: orders-board.html model: orders: main.rows
ui: prefersBorder: true csp: connectDomains: ["https://api.example.com"]# mcp/find-orders.yml — the tool links to the UI resource it renders intoui: ui://orders/boardOn startup the compiler turns each UI resource into a read-only internal route running the
same read-and-render pipeline a query-html route runs: telemetry and the audit trail, the
resource’s own authentication and authorization, tenancy and locale resolution, the 2-way SQL,
then the Thymeleaf template. It therefore renders the same hc-* fragment a page would. UI work
follows the blessed patterns in docs/hypermedia-ui.md, and any gap
belongs upstream in the kit rather than in app CSS.
What the fragment is in a host. An MCP Apps host renders the resource in a sandboxed frame
of its own origin, with a content-security policy that admits nothing the resource does not
declare, and delivers the tool’s result to it over the extension’s ui/initialize handshake.
The fragment TesseraQL serves is the template’s output and nothing more. It links no
stylesheet, so the kit’s hc-* classes render as the host’s default text unless the template
itself links hc.min.css by an absolute URL under the application’s external origin and
resourceDomains names that origin. It loads no htmx, so hx-* attributes are inert. It runs
no script, so it never takes part in the handshake: it shows the rows its own sources: read
at resources/read time, not the arguments or the result of the tool that linked it. A UI
resource is therefore a static snapshot of its own query, styled only by what its
template carries. The csp values are origins (https://api.example.com), never CSP keywords:
'self' in a host’s sandbox means the sandbox origin, not the application
(docs/audit-low-leads.md, G5).
The runtime serves it over the same /_tesseraql/mcp endpoint as the tools and resources.
So:
- The extension is negotiated. When the app serves any UI resource,
initializeadvertises it undercapabilities.extensions["io.modelcontextprotocol/ui"]withtext/html;profile=mcp-app, the MCP Apps content type. - Discovery and read mirror resources.
resources/listadvertises every UI resource (itsui://uri,name, thetext/html;profile=mcp-appmimeType,description, and_meta.uirendering hints —prefersBorder, content-security-policy domains);resources/read { "uri": ... }runs the route and returns the renderedhc-*fragment as the resource’scontents. - Tools link to a UI resource. A tool’s
ui:field is advertised as its_meta.ui.resourceUri, so a host that supports the extension renders the linked fragment beside the tool’s result — the fragment’s own snapshot, as above, not a rendering of that result. - Security is per-resource and identical to a route. The request’s
Authorization: Bearerrides into the UI resource’s route, where its declaredauth/policyrun; an unauthorized read comes back as aresources/readJSON-RPC error. Discovery is open. - Read-only by construction, governed like a read. Lint keeps a UI resource
HTML-rendering and uri-addressed (
TQL-MCP-1008/1009/1011), warns on a missing description (TQL-MCP-1014), and rejects a tool whoseui:link dangles (TQL-MCP-1012). The governance gate scores a UI resource like a read route — neveradvanced, since it cannot write. Anmcp-uicoverage kind tracks which UI resources your declarative suites exercise.
Prompts
Section titled “Prompts”An app can also declare an MCP prompt — a parameterized, reusable message the connecting
agent surfaces to its model (an IDE slash command, say). A prompt is a document under mcp/
with kind: prompt and the prompt-text recipe, and it is a route exactly like the other three
kinds: same input:, same security:, same sources:, ending in a response.text: whose
rendered string is the message. So a prompt reads data — “draft a welcome for customer 4711”
looks 4711 up instead of asking the agent to fetch it first:
version: tesseraql/v1id: draft-welcomekind: promptrecipe: prompt-textdescription: Draft a welcome message for a customer, in that customer's own context.
security: auth: bearer policy: customers.read
input: customerId: type: integer required: true description: The customer to welcome. tone: type: string default: warm enum: [warm, formal] description: How the message should read.
sources: main: sql: file: customer.sql mode: query params: customerId: params.customerId
response: text: template: draft-welcome.txt.tpl model: customer: main tone: params.tone# mcp/draft-welcome.txt.tpl (Thymeleaf TEXT mode)Write a [(${tone})] welcome message for [(${customer.rows[0].name})],who joined on [(${customer.rows[0].signedUpOn})] and is on the [(${customer.rows[0].plan})] plan.The source publishes its rows; the template reads the head row by index. (A route source
publishes no first — a job step’s read does.)
A prompt with nothing to look up declares no sources: and renders from its arguments alone —
that is the whole document minus the two blocks.
- A route like the others.
prompts/getruns the same pipeline a tool call runs: telemetry and the audit trail, the prompt’s ownsecurity:, tenancy and locale resolution, input binding, the declaredsources:, then the template. The arguments arrive as strings and are coerced and validated by theinput:declaration, sotype: integeris enforced rather than documented — a caller sending"abc"gets an error instead of a message withabcin it. security:is available and optional. A prompt may declareauth:/policy:like any route, which is what the example above does because it reads customer data. Declaring nothing leaves the prompt open, which is right for a template that only rephrases its arguments. Discovery stays open either way:prompts/listadvertises every prompt, and a declared policy is enforced onprompts/get, exactly as a tool’s is ontools/call.- The arguments are the
input:.prompts/listadvertises each declared field’s name,description:and whether it isrequired:— one declaration, so what the agent is told and what the binder enforces cannot drift. - A prompt is a read.
prompts/getis a read in the protocol’s own vocabulary, so a prompt declaringsteps:or a source inmode: updateis refused (TQL-MCP-1016, andTQL-ROUTE-3116if it reaches the compiler). A prompt that writes is a tool. - Only the input keys a prompt can act on. An argument is a full route
input:field. The three keys with nothing to act on it here —policy:,writable:,widget:— are refused (TQL-MCP-1015) rather than silently accepted. A prompt renders a message rather than a form, and its arguments come from the caller, so the field-level write gate can only refuse the call. A key a shareddomain:supplies is not refused — the author did not write it here, and a domain must stay usable from every surface that references it. - Advertised like the rest. The runtime serves prompts at the same
/_tesseraql/mcpendpoint;prompts/listenumerates them and thepromptscapability is negotiated ininitializewhen an app declares any. Anmcp-promptcoverage kind tracks which prompts’ SQL your declarative suites exercise (a prompt that reads nothing declares no SQL, so it is not counted). This is the application-side counterpart of the dev tool’sstudio_copilotprompt (AI-assisted development) — TesseraQL ships the workflow, the agent’s own model does the reasoning, no embedded LLM.
Mounted-app tools
Section titled “Mounted-app tools”A TesseraQL runtime hosts one application plus the framework’s own surfaces — the ops console,
Studio, IAM admin, the account pages and the sign-in pages. Each is a plain
YAML/SQL/template tree compiled by the same route compiler, so each may declare its own MCP tools,
resources, and UI resources under mcp/. The runtime serves them all from the one
/_tesseraql/mcp endpoint, so an agent sees one catalog spanning every hosted app:
- One endpoint, every app.
tools/listandresources/listadvertise the tools, resources, and UI resources of the application and the framework’s surfaces together; the MCP Apps UI extension is negotiated ininitializewhen any hosted app serves aui://resource. The singletesseraql.mcp.enabledflag governs the whole endpoint. - Security stays per-route. A tool’s
tools/call(or resource read) runs the route that declared it, with that route’s ownauth/policy. The MCP request’s bearer token rides into it the same way for every hosted app, which share the application’s configuration (datasources, security policies, JWT verification), so a policy and the token verifier resolve the same way across them. - Names and uris are unique across apps. Because every app’s surface shares the one endpoint, a
tool name (a tool’s
id), a resourceuri, and a UIui://uri must be unique across all hosted apps — resources and UI resources share one uri namespace. The startup route-conflict check (the same guard that rejects duplicate HTTP route ids and method+path pairs) rejects the collision with a clear error, so a clash fails the mount rather than silently shadowing a tool.
This needs no new YAML: an app declares its MCP surface the same way whether it is the application a runtime serves or one of the framework’s own surfaces.
Error codes
Section titled “Error codes”Lint findings:
| Code | Meaning |
|---|---|
TQL-MCP-1001 |
an application MCP tool uses a recipe other than query-json / command-json |
TQL-MCP-1002 |
(warning) an application MCP tool has no description |
TQL-MCP-1003 |
an application MCP resource is not read-only (query-json with query-mode SQL) |
TQL-MCP-1004 |
an application MCP resource declares no uri |
TQL-MCP-1005 |
(warning) an application MCP resource has no description |
TQL-MCP-1006 |
an application MCP resource declares input: (a resource takes no arguments) |
TQL-MCP-1007 |
two application MCP resources declare the same uri (UI resources share the namespace) |
TQL-MCP-1008 |
an MCP Apps UI resource does not render HTML (use query-html or page) |
TQL-MCP-1009 |
an MCP Apps UI resource declares no ui:// uri |
TQL-MCP-1010 |
(warning) an MCP Apps UI resource has no description |
TQL-MCP-1011 |
an MCP Apps UI resource declares input: (a UI resource takes no arguments) |
TQL-MCP-1012 |
a tool’s ui: link resolves to no declared UI resource |
TQL-MCP-1013 |
an mcp/ document declares a kind: outside tool / resource / ui / prompt |
TQL-MCP-1014 |
two application MCP tools, or two prompts, declare the same id (the folders name nothing) |
TQL-MCP-1015 |
a prompt argument declares policy:, writable: or widget:, which a prompt cannot act on |
TQL-MCP-1016 |
a prompt declares steps: or a source in mode: update (prompts/get is a read) |
TQL-MCP-4030 |
a write MCP tool declares no authorization policy |
At runtime, a tool call that fails (a bad argument, an unauthorized write) comes back as an
MCP tool result with isError: true and the message — the connection stays up so the agent
can read the error and correct course. Protocol-level mistakes (unknown method, malformed
JSON-RPC) use the standard JSON-RPC error codes.
- ai-mcp.md — the framework-authoring server, rather than your app.
- authentication.md — how an agent caller is authorized.