MCP Endpoint — "the format IS the API"

Every MIDL site exposes a Model Context Protocol (MCP) server at a single endpoint:

POST /api/mcp

MCP is the interop protocol MCP-capable clients (Claude Desktop/Code, NLWeb agents, anything built on @modelcontextprotocol/sdk) already speak. Where agent.json, llms.txt, and the .midl.json / .facts.json twins let an agent read a MIDL site, MCP lets an agent drive it: discover the tools, read a page's document and facts, list pages, submit telemetry, and — with an API key — create or update a page. Because a MIDL page is already a JSON document, the format is the API; MCP is just the standard envelope.

  • Transport: MCP "Streamable HTTP", stateless — one JSON-RPC 2.0 message per POST, an application/json response. No session id, no server-push, no batching.
  • Protocol version: 2025-06-18 (older revisions are negotiated on initialize).
  • Auth: read + telemetry tools are open; create_frame / update_frame require a Bearer API key — the same key you mint at /admin/keys for the Write API. The page is created in the key's tenant.

The examples below use https://your-site.example as the origin — replace it with your deployment.

Tools

Tool Auth What it does
list_frames — List the published pages (id, slug, description, updated_at).
read_document — The raw MIDL document for a page + its schema.org JSON-LD + a ui:*→schema.org map.
read_facts — The RAG-indexable facts document (same as /render/<slug>.facts.json).
submit_telemetry — Record an impression/click/conversion/engagement event for the optimizer.
create_frame Bearer key Create a page from a MIDL document (validated before persist).
update_frame Bearer key Partial-update a page by id (content/slug/description/is_active).

1. Discover — initialize then tools/list

Every MCP session starts with an initialize handshake:

curl -sS https://your-site.example/api/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize",
       "params":{"protocolVersion":"2025-06-18","capabilities":{},
                 "clientInfo":{"name":"my-agent","version":"1.0"}}}'
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-06-18",
    "capabilities": { "tools": { "listChanged": false } },
    "serverInfo": { "name": "midl-site-mcp", "title": "MIDL Site (MCP)", "version": "0.1.0" },
    "instructions": "Read: list_frames, read_document ... Write ... requires an Authorization: Bearer key."
  }
}

A well-behaved client then sends the notifications/initialized notification (no id) — the server answers 202 Accepted with no body. List the tools:

curl -sS https://your-site.example/api/mcp \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

Each tool comes back with a JSON-Schema inputSchema and annotations (e.g. readOnlyHint) so a client can render and validate calls.

2. Read a page — read_document

curl -sS https://your-site.example/api/mcp \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
       "params":{"name":"read_document","arguments":{"slug":"pricing"}}}'

The tool result's content[0].text is a JSON string with the page's MIDL document, its schema.org jsonLd (@type: WebPage), and the ui:*→schema.org overlaps present on the page:

{
  "jsonrpc": "2.0", "id": 3,
  "result": {
    "content": [{ "type": "text", "text": "{ \"slug\": \"pricing\", \"url\": \".../render/pricing\", \"document\": { \"midl\": \"0.1\", \"frames\": [ ... ] }, \"jsonLd\": { \"@context\": \"https://schema.org\", \"@type\": \"WebPage\", ... }, \"schemaOrg\": [ { \"ui\": \"ui:Hero\", \"schemaOrg\": \"WPHeader\" }, { \"ui\": \"ui:Image\", \"schemaOrg\": \"ImageObject\" } ] }" }]
  }
}

3. Read a page's facts — read_facts

curl -sS https://your-site.example/api/mcp \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":4,"method":"tools/call",
       "params":{"name":"read_facts","arguments":{"slug":"pricing","lang":"en"}}}'

Returns de-duplicated bullet facts extracted from the page's semantic frames, with provenance — the same payload as GET /render/pricing.facts.json.

4. Submit telemetry — submit_telemetry

curl -sS https://your-site.example/api/mcp \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":5,"method":"tools/call",
       "params":{"name":"submit_telemetry",
                 "arguments":{"frame_id":"frame-123","event_type":"conversion","variant_id":"v-a"}}}'

A conversion event must carry a variant_id or design_system_id (so the optimizer can attribute it), exactly like POST /api/telemetry.

5. Write a page (requires a key) — create_frame

Mint a key at /admin/keys, then send it as Authorization: Bearer <key>:

curl -sS https://your-site.example/api/mcp \
  -H 'content-type: application/json' \
  -H 'authorization: Bearer midl_xxxxxxxxxxxx_...' \
  -d '{"jsonrpc":"2.0","id":6,"method":"tools/call",
       "params":{"name":"create_frame","arguments":{
         "slug":"launch",
         "description":"Launch page",
         "content":{
           "midl":"0.1",
           "ns":{"ui":"https://midl.ai/ui#"},
           "frames":[
             {"id":"hero","type":"ui:Hero","title":"Ship faster","subtitle":"The platform for teams"},
             {"id":"cta","type":"ui:Button","text":"Start free"}
           ]
         }}}}'

On success the tool returns { "id": "frame-…", "slug": "launch", "url": ".../render/launch" }, and the page is live at /render/launch. The document is validated before persist — an invalid MIDL body comes back as a tool error (isError: true) carrying the validation feedback, and nothing is written.

Without the Authorization header, create_frame/update_frame return a tool error with code unauthorized and persist nothing.

Update an existing page by id:

curl -sS https://your-site.example/api/mcp \
  -H 'content-type: application/json' \
  -H 'authorization: Bearer midl_xxxxxxxxxxxx_...' \
  -d '{"jsonrpc":"2.0","id":7,"method":"tools/call",
       "params":{"name":"update_frame","arguments":{"id":"frame-…","is_active":false}}}'

Connecting from a stock MCP client

Point any MCP client at the endpoint URL. With the TypeScript SDK (@modelcontextprotocol/sdk) over the Streamable-HTTP transport:

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

const transport = new StreamableHTTPClientTransport(
  new URL('https://your-site.example/api/mcp'),
  // for writes, attach your key on every request:
  { requestInit: { headers: { authorization: 'Bearer midl_xxxxxxxxxxxx_...' } } }
);
const client = new Client({ name: 'my-agent', version: '1.0.0' });
await client.connect(transport);

const { tools } = await client.listTools();
const doc = await client.callTool({ name: 'read_document', arguments: { slug: 'pricing' } });

schema.org mapping

read_document returns a schemaOrg array mapping each ui:* type the page uses to its schema.org counterpart, so agents and NLWeb tooling get ground-truth types without parsing HTML. The clear overlaps (e.g. ui:Page → WebPage, ui:Hero → WPHeader, ui:Image → ImageObject, ui:Nav → SiteNavigationElement, ui:Form → Action) are mapped; ui:* types with no faithful schema.org counterpart are simply omitted.

Errors

  • Protocol-level problems return a JSON-RPC error object: -32700 parse error, -32600 invalid request, -32601 unknown method, -32602 invalid params.
  • Tool-level problems (unknown tool, missing key, not-found, invalid MIDL) return a successful JSON-RPC response whose result has isError: true and a safe message + code in the text content — the MCP-idiomatic shape, so an agent can read the error and correct its call. Internal/database details are never leaked.

Discovery

The endpoint is advertised in the site's machine surfaces:

  • /.well-known/agent.json → endpoints.mcp and an mcp tool entry
  • /llms.txt → a link under "Machine-readable surfaces"