# `@sonordev/site-kit/mcp` — WebMCP & Model Context Protocol

Make a marketing site something an AI agent can **use**, not just read.

An agent that lands on a normal site can only scrape it. This module gives the
site three machine-facing surfaces, all driven by **one** set of tool
definitions:

| Surface                               | Who uses it                                         | Entry point                            |
| ------------------------------------- | --------------------------------------------------- | -------------------------------------- |
| Remote MCP endpoint (Streamable HTTP) | Off-browser agents — Claude, Cursor, any MCP client | `createMcpHandler`                     |
| MCP Server Card (SEP-2127)            | Crawlers and clients discovering the endpoint       | `createMcpServerCardHandler`           |
| In-page WebMCP                        | Browser-driving agents                              | `<WebMcpTools>`, `declarativeToolForm` |

One definition feeding all three is the point. The alternative — a tool list for
the endpoint and a separate one for the page — drifts, and a stale tool
definition is worse than none, because the agent believes it.

***

## The fast way: the built-in Sonor tools (7.0)

```bash
npx sonor-setup mcp --inquiry-form contact
```

That writes everything below for you, with tools you don't have to write:
`@sonordev/site-kit/mcp/sonor` reads Sonor through the same fetchers the
site's pages use, so an agent gets what a visitor gets.

| Tool                               | What an agent gets                                                                             |
| ---------------------------------- | ---------------------------------------------------------------------------------------------- |
| `get_business_profile`             | Who the business is, where it works, phone, email, address, hours                              |
| `list_services`                    | Its services, with links                                                                       |
| `search_faq`                       | Its answered questions, to quote instead of guessing                                           |
| `find_pages`                       | The page that covers a topic                                                                   |
| `list_articles`, `get_article`     | Its articles (`articles: false` drops them)                                                    |
| `get_reviews`                      | Reviews verbatim, with who wrote them, and the rating                                          |
| `list_offerings`                   | Priced products, services, events (opt-in: `offerings: { path }`); private prices are left out |
| `check_availability`               | Open appointment times, read only (opt-in: `booking: { path }`)                                |
| `get_inquiry_form`, `send_inquiry` | An inquiry for a person (opt-in: `inquiry: { form }`)                                          |

```ts
// lib/mcp.ts
import 'server-only'
import { sonorMcpServer } from '@sonordev/site-kit/mcp/sonor'

export const mcpServer = sonorMcpServer({
  businessName: 'Example Law',
  inquiry: { form: 'contact' },   // the form's "Agent inquiries" switch must be on in Sonor
  tools: [/* the site's own tools, served beside these */],
})

// app/api/mcp/route.ts
import { createMcpHandler } from '@sonordev/site-kit/mcp'
import { reportToolCallsToSonor } from '@sonordev/site-kit/mcp/sonor'
import { mcpServer } from '@/lib/mcp'

export const { POST, GET, DELETE, OPTIONS } = createMcpHandler({
  server: mcpServer,
  baseUrl: process.env.NEXT_PUBLIC_SITE_URL,
  onToolCall: reportToolCallsToSonor(),
})
```

**`send_inquiry` has a person behind it.** It refuses unless
`person_confirmed` is true (the person asked to be contacted and agreed to
share their details), files through Sonor's agent-inquiry door with the
agent's badge (`<host> MCP send_inquiry via <assistant>`) and
`human_approved`, and only for a form that opted in. It's left off the
in-page surface, where the person's browser has the site's own form.

**Sonor sees who called.** `onToolCall` hands `createMcpHandler`'s record of
each call (tool, outcome, in-page or remote, the agent's own name or its
User-Agent) to `reportToolCallsToSonor`, which sends it after the response
with Next's `after()`. Never the arguments or the answer. Sonor's AI
Visibility tab lists the agents and tools, and Echo offers the fix when a
tool keeps failing.

**Discovery.** llms.txt gains an "Agent access" section pointing at the
endpoint and card (automatic in the build-time file once `/api/mcp` exists),
and `createProxy({ llmsDiscovery: { siteUrl, mcpServerCard: true } })` adds
`Link: <.../.well-known/mcp-server-card>; rel="service-desc"`.

### A custom MCP server is left alone

The built-in tools are opt-in, never automatic. A site that runs its own MCP
server (its own tools, transport names or llms.txt section, like a
re-site-kit site) is a **custom implementation**, and site-kit keeps its
hands off:

| What                            | Built-in server                            | Custom server                                                                                         |
| ------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| `npx sonor-setup mcp`           | writes the wiring (skips files that exist) | **writes nothing**, not even missing files, and says what's opt-in (`--force` replaces it, knowingly) |
| llms.txt "Agent access" section | added at build                             | **not added** (`writeLLMsTxtToPublic({ mcp: true })` opts in)                                         |
| Tool-call reporting to Sonor    | `onToolCall: reportToolCallsToSonor()`     | the same line, if you want it; nothing without it                                                     |
| Proxy `service-desc` link       | `llmsDiscovery.mcpServerCard: true`        | the same, opt-in                                                                                      |
| Mixing in built-in tools        | n/a                                        | `tools: [...sonorMcpTools({ businessName, exclude }), ...yourTools]`                                  |

"Built-in" means the site's `/api/mcp` server comes from `sonorMcpServer` or
`sonorMcpTools` (checked in the route and `lib/mcp*` by `detectMcpServer`, exported from
`@sonordev/site-kit/seo/llms`); anything else serving
`/api/mcp` or a server card is custom. To force the llms.txt section either
way: `writeLLMsTxtToPublic({ mcp: false | true })`,
`createSitemap({ llmsAgentAccess })`, or `sonor-register-sitemap --write-llms --no-agent-access` / `--agent-access`. It's also never added to markdown
that already names a server card.

Route files need no segment config: POST and GET route handlers are dynamic
by default in Next 16, and Cache Components rejects `dynamic`, `runtime` and
`revalidate` exports. (The hand-written examples below predate that; drop
those lines on a site with Cache Components on.)

***

## Quick start

### 1. Define the tools

```ts
// lib/mcp/server.ts
import { defineMcpTool, type McpServerDefinition } from '@sonordev/site-kit/mcp'

const getServices = defineMcpTool({
  name: 'get_services',
  title: 'Get service catalog',
  description:
    'List everything this company builds, with what each service is for and ' +
    'what it typically costs. Call this first when asked what they do.',
  inputSchema: {
    type: 'object',
    properties: {
      category: { type: 'string', description: 'Optional category filter.' },
    },
  },
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
  handler: ({ category }) => SERVICES.filter((s) => !category || s.category === category),
})

export const mcpServer: McpServerDefinition = {
  info: {
    name: 'io.example/site',      // reverse-DNS, EXACTLY one slash
    version: '1.0.0',             // concrete semver, no ranges
    title: 'Example Co.',
    description: 'Tools for exploring Example Co.’s services and requesting work.',
    instructions: 'Start with get_services. Use request_quote only with consent.',
  },
  tools: [getServices],
}
```

### 2. Mount the endpoint

```ts
// app/api/mcp/route.ts
import { createMcpHandler } from '@sonordev/site-kit/mcp'
import { mcpServer } from '@/lib/mcp/server'

export const { POST, GET, DELETE, OPTIONS } = createMcpHandler({
  server: mcpServer,
  baseUrl: process.env.NEXT_PUBLIC_SITE_URL,
})
export const dynamic = 'force-dynamic'
```

### 3. Mount the card at BOTH well-known paths

```ts
// app/.well-known/mcp-server-card/route.ts   ← canonical (SEP-2127)
// app/.well-known/mcp.json/route.ts          ← superseded SEP-1649, still probed
import { createMcpServerCardHandler } from '@sonordev/site-kit/mcp'
import { mcpServer } from '@/lib/mcp/server'

export const { GET, OPTIONS } = createMcpServerCardHandler({
  info: mcpServer.info,
  tools: mcpServer.tools,
  baseUrl: 'https://example.com',
})
export const revalidate = 3600
```

### 4. Register in-page (optional, for browser agents)

```tsx
// app/layout.tsx — a CHILDLESS SIBLING, never a wrapper
<SiteKitLayout>{children}</SiteKitLayout>
<WebMcpTools endpoint="/api/mcp" />
```

Pass no tool list. The component checks for WebMCP support first and only then
fetches `tools/list` from the endpoint — so an ordinary visitor does no work
and the page carries no extra bytes, while an agent gets the same catalog the
endpoint serves. Handing it descriptors from a server component instead would
put the whole catalog in every page's RSC payload.

### 5. Rate-limit the public endpoint (Netlify)

A public MCP endpoint is an open door for scripted callers, and Netlify can
only rate-limit a **native function** (declarative `config.rateLimit`), never a
Next.js route handler. `@sonordev/site-kit/mcp/transport` is the relay that
puts the endpoint behind one. Three files, plus `MCP_TRANSPORT_SECRET`
(`openssl rand -hex 32`) in every deploy context, Functions scope:

```js
// netlify/functions/mcp.mjs: owns /api/mcp in production
import { createNetlifyMcpRelay } from '@sonordev/site-kit/mcp/transport'

export default createNetlifyMcpRelay()

// Literal, in THIS file: Netlify reads path and rateLimit statically,
// so they can't be imported or spread from a constant.
export const config = {
  path: '/api/mcp',
  rateLimit: { windowSize: 60, windowLimit: 60, aggregateBy: ['ip', 'domain'] },
}
```

```ts
// app/api/mcp/route.ts: answers only relayed (signed) calls in production
import { createMcpHandler } from '@sonordev/site-kit/mcp'
import { protectMcpHandlers } from '@sonordev/site-kit/mcp/transport'
import { mcpServer } from '@/lib/mcp/server'

export const { POST, GET, DELETE, OPTIONS } = protectMcpHandlers(
  createMcpHandler({ server: mcpServer, baseUrl: process.env.NEXT_PUBLIC_SITE_URL, allowedOrigins: '*' }),
)
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
```

```ts
// app/api/mcp-internal/route.ts: the relay's target, 403 unless signed
import { createMcpInternalRoute } from '@sonordev/site-kit/mcp/transport'
import * as mcp from '../mcp/route'

export const { POST, GET, DELETE, OPTIONS } = createMcpInternalRoute(mcp)
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
```

What the relay does, so you don't have to re-derive it:

- Signs each request with `HMAC-SHA256(MCP_TRANSPORT_SECRET, label)` in a
  transport header, overwriting anything the client sent under that name. The
  routes verify it with `timingSafeEqual`.
- Relays to `https://<deploy-id>--<site>.netlify.app/api/mcp-internal`, the
  permalink of the deploy that took the call. The base URL comes from the
  function's `context`, never from a request header.
- Refuses POST bodies over 64 KB (413), uses `redirect: 'error'` and a 55 s
  timeout (under Netlify's 60 s limit), and answers 502 when the upstream fails.
- Stamps responses `Cache-Control: no-store` and sets the transport header to
  `netlify-rate-limited-v1`, which a release check can assert to prove a call
  went through the rate limit.
- 503 when `MCP_TRANSPORT_SECRET` is unset. The `/api/mcp` route is only
  enforced when `NODE_ENV === 'production'`, so `next dev` answers a local
  client directly. `/api/mcp-internal` is enforced everywhere.

The header and label default to `x-site-mcp-transport` and
`site-mcp-transport-v1`. A site with names already live passes the same
`{ header, label }` to all three factories (for example
`x-example-mcp-transport` / `example-mcp-transport-v1`). This entry is Node
only and imports no `server-only`, so the plain-Node function can load it. It
is not re-exported from `@sonordev/site-kit/mcp`, which stays runtime-neutral.

***

## Design notes

### The card does not list tools — on purpose

SEP-2127 deliberately omits primitives from the card: what a server exposes can
vary with auth state and flags, so the authoritative list is whatever
`tools/list` returns at call time. A card that inlined tools would be a second
source of truth that goes stale silently.

We still publish a **summary** (name + description + read-only flag) under
`_meta['io.sonor.site-kit/tools']`. `_meta` is the spec's sanctioned extension
point and requires a reverse-DNS prefix, so the hint rides along without
pretending to be standard — useful for crawlers that index the card and never
connect.

### Two well-known paths

SEP-1649 proposed `/.well-known/mcp.json`; the ratified SEP-2127 moved to
`/.well-known/mcp-server-card`. Deployed validators still probe the old path.
Serving one document from two URLs costs nothing, so mount both.

### Dual-era protocol support

`dispatch()` answers both protocol eras on one endpoint:

- **Modern (`2026-07-28`)** — stateless, per-request `_meta` carrying the
  protocol version, mirrored into `MCP-Protocol-Version`. `server/discover`
  replaces the handshake. No sessions, no GET stream (both return `405`).
- **Legacy (`≤ 2025-11-25`)** — the `initialize` handshake, which is what most
  shipped clients and SDKs still speak.

Supporting only the current revision would be spec-correct and unusable today.

### Header mirroring: mismatch is fatal, absence is not

The modern revision mirrors `method` and `params.name` into `Mcp-Method` and
`Mcp-Name` so intermediaries can route without parsing bodies, and requires
servers to reject disagreements (`-32020`).

We always reject a **mismatch** — that is the real security property, stopping
a load balancer and the server from acting on different values. A merely
**absent** header is tolerated unless you set `strictHeaders: true`, because a
public marketing endpoint exists to be reachable and today's clients frequently
omit the mirrors.

### Tool failures come back as results, not transport errors

A missing argument or a thrown handler returns a normal result with
`isError: true`. That text goes back to the **model**, which can read it and
retry correctly. A JSON-RPC error goes to the client harness and usually
surfaces as a dead end.

### Handler results are emitted twice

Structured returns become both `structuredContent` (for agents that parse) and
pretty JSON inside a text block (for agents that only read `content`). Emitting
one or the other makes you invisible to a large slice of the ecosystem.

### Discovery is lazy, and gated on support

`<WebMcpTools>` does nothing at all unless `document.modelContext` exists. That
gate comes before the `tools/list` fetch, so the cost for a human visitor is a
single property check — not a request, and not a byte of page weight.

### In-page tools are proxied, not re-implemented

`<WebMcpTools>` registers thin wrappers that POST `tools/call` to this site's
own endpoint, so the in-page tool and the remote tool run the same server-side
handler. It also keeps `SONOR_API_KEY` out of the client bundle — registering
real handlers client-side would pull server code, and the key with it, into a
`'use client'` graph and a public chunk.

Tools that genuinely need live DOM state go in `localTools` and run in-page.

### Registration is deferred

`<WebMcpTools>` waits for the page to go quiet (`useDeferredActivation`) before
touching `document.modelContext`. Agents poll or listen for `toolchange`, so a
few hundred milliseconds costs nothing — a blocked LCP costs a lot.

***

## Writing good tools

The `description` is the highest-leverage field in this module. It is the only
thing a model reads when deciding whether to call the tool.

- **Verb-led `snake_case` names**: `get_services`, `request_site_audit`.
- **Say when to call it**, not just what it returns: *"Call this first when
  asked what they do."*
- **Set `annotations` honestly.** `readOnlyHint` on lookups; `destructiveHint`
  on anything creating a record. Good agents use these to decide what needs a
  human.
- **Never `toolautosubmit` a lead form.** That is how an agent files fifty
  audit requests by accident.
- **Return the caveat with the data.** A pricing tool should return the ranges
  *and* the fact that they are ranges — otherwise the model quotes a number as
  a commitment.

## Testing an endpoint by hand

```bash
curl -s https://example.com/.well-known/mcp.json | jq

curl -s -X POST https://example.com/api/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'Mcp-Method: tools/list' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools[].name'
```
