docs
    site-kit: MCP and agent tools
    v7.2.0.md

    @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:

    SurfaceWho uses itEntry point
    Remote MCP endpoint (Streamable HTTP)Off-browser agents — Claude, Cursor, any MCP clientcreateMcpHandler
    MCP Server Card (SEP-2127)Crawlers and clients discovering the endpointcreateMcpServerCardHandler
    In-page WebMCPBrowser-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 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.

    ToolWhat an agent gets
    get_business_profileWho the business is, where it works, phone, email, address, hours
    list_servicesIts services, with links
    search_faqIts answered questions, to quote instead of guessing
    find_pagesThe page that covers a topic
    list_articles, get_articleIts articles (articles: false drops them)
    get_reviewsReviews verbatim, with who wrote them, and the rating
    list_offeringsPriced products, services, events (opt-in: offerings: { path }); private prices are left out
    check_availabilityOpen appointment times, read only (opt-in: booking: { path })
    get_inquiry_form, send_inquiryAn 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:

    WhatBuilt-in serverCustom server
    npx sonor-setup mcpwrites 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" sectionadded at buildnot added (writeLLMsTxtToPublic({ mcp: true }) opts in)
    Tool-call reporting to SonoronToolCall: reportToolCallsToSonor()the same line, if you want it; nothing without it
    Proxy service-desc linkllmsDiscovery.mcpServerCard: truethe same, opt-in
    Mixing in built-in toolsn/atools: [...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 = 3600

    4. 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 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

    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'