@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)
npx sonor-setup mcp --inquiry-form contactThat 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 }) |
// 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
// 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
// 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
// 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 = 36004. Register in-page (optional, for browser agents)
// 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:
// 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'] },
}// 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'// 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 withtimingSafeEqual. - 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'scontext, 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-storeand sets the transport header tonetlify-rate-limited-v1, which a release check can assert to prove a call went through the rate limit. - 503 when
MCP_TRANSPORT_SECRETis unset. The/api/mcproute is only enforced whenNODE_ENV === 'production', sonext devanswers a local client directly./api/mcp-internalis 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_metacarrying the protocol version, mirrored intoMCP-Protocol-Version.server/discoverreplaces the handshake. No sessions, no GET stream (both return405). - Legacy (
≤ 2025-11-25) — theinitializehandshake, 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_casenames:get_services,request_site_audit. - Say when to call it, not just what it returns: "Call this first when asked what they do."
- Set
annotationshonestly.readOnlyHinton lookups;destructiveHinton anything creating a record. Good agents use these to decide what needs a human. - Never
toolautosubmita 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
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'