@sonordev/site-kit 7.0.1 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +3606 -0
  2. package/README.md +12 -13
  3. package/agent-manifest.json +11 -5
  4. package/dist/{AnalyticsProvider-EMM2TKRE.js → AnalyticsProvider-ZMQUV33M.js} +4 -4
  5. package/dist/{ArticleViewTracker-RA64BGL6.js → ArticleViewTracker-V7NUXYBY.js} +3 -3
  6. package/dist/{BlocksPopup-D25RFNOV.js → BlocksPopup-52EU7OUY.js} +4 -4
  7. package/dist/{ChatWidget-RYI7BMJJ.js → ChatWidget-5BHMNR57.js} +5 -5
  8. package/dist/{EngageWidget-UKFCN33M.js → EngageWidget-PCGLX7SO.js} +4 -4
  9. package/dist/{FileField-MUHA7LZR.js → FileField-TSFGNAMY.js} +3 -3
  10. package/dist/{FormSpotlight-TCLPWPLL.js → FormSpotlight-XLBWEOTE.js} +1 -1
  11. package/dist/{FormStage-CNYLP6I6.js → FormStage-IJQ5Q2X6.js} +1 -1
  12. package/dist/{ManagedForm-7ZL5SKTO.js → ManagedForm-Z3PKOPIZ.js} +6 -6
  13. package/dist/{ManagedNewsletterForm-33B4JLX7.js → ManagedNewsletterForm-QAO3POLC.js} +4 -4
  14. package/dist/{SignalCore-L5FVDHFE.js → SignalCore-RBA3VDBL.js} +3 -3
  15. package/dist/{SiteDesignReporter-4JOFL4FP.js → SiteDesignReporter-C4LR5X2V.js} +5 -5
  16. package/dist/SitemapSync-XVMGKCF3.js +8 -0
  17. package/dist/_client/booking-widget.js +5 -5
  18. package/dist/affiliates/index.js +3 -3
  19. package/dist/analytics/index.js +4 -4
  20. package/dist/articles/index.js +1 -1
  21. package/dist/articles/server-ui.js +1 -1
  22. package/dist/chat/index.js +5 -5
  23. package/dist/{chunk-FYBZ5SNP.js → chunk-3G2SE2J4.js} +1 -1
  24. package/dist/{chunk-HGCK465A.js → chunk-3J2ERO3I.js} +1 -1
  25. package/dist/{chunk-KXPBMCFL.js → chunk-3QI26673.js} +3 -1
  26. package/dist/{chunk-BMO3VGMR.js → chunk-3XPJKZ6D.js} +30 -7
  27. package/dist/{chunk-QGHSMJKW.js → chunk-4JQQDCMO.js} +1 -1
  28. package/dist/{chunk-6HDT4G4A.js → chunk-4YTYGG2C.js} +2 -2
  29. package/dist/{chunk-N2UVOR3X.js → chunk-662ILEZ6.js} +2 -0
  30. package/dist/chunk-6G43IRWR.js +4 -0
  31. package/dist/{chunk-LVESVYCE.js → chunk-7MHHWZKC.js} +11 -117
  32. package/dist/{chunk-MV2MBTC3.js → chunk-7QTMMHUO.js} +1 -1
  33. package/dist/{chunk-KPAZG65P.js → chunk-CGWUXUYZ.js} +138 -46
  34. package/dist/{chunk-4IQ52CXL.js → chunk-DUAO4Q75.js} +2 -2
  35. package/dist/{chunk-4RMVXRBO.js → chunk-EIULXXUJ.js} +3 -3
  36. package/dist/{chunk-3KUUH2YP.js → chunk-EVFZ7KEW.js} +1 -1
  37. package/dist/{chunk-OFOAHPUV.js → chunk-F42R35NV.js} +1 -1
  38. package/dist/{chunk-QANVUXKH.js → chunk-FLR3EMK6.js} +1 -1
  39. package/dist/{chunk-P4GRY6QP.js → chunk-GIAOPEN6.js} +1 -1
  40. package/dist/{chunk-P5J7VMQ3.js → chunk-GWUKQ26F.js} +1 -1
  41. package/dist/{chunk-TT63HHIT.js → chunk-HAG4YIZY.js} +1 -1
  42. package/dist/{chunk-QZZIKMAT.js → chunk-HVH37YPX.js} +1 -1
  43. package/dist/{chunk-SSUQKA7L.js → chunk-J4D6ZXRW.js} +1 -1
  44. package/dist/{chunk-GYESATRY.js → chunk-L2DJD5Y4.js} +1 -1
  45. package/dist/{chunk-WATH55UY.js → chunk-LFXVE32I.js} +1 -1
  46. package/dist/chunk-LPH5FANE.js +169 -0
  47. package/dist/{chunk-V6LSQRTH.js → chunk-PLUP2KN5.js} +1 -1
  48. package/dist/chunk-RYVDGXC2.js +19 -0
  49. package/dist/{chunk-EGOD74PP.js → chunk-U35H2JIQ.js} +2 -2
  50. package/dist/chunk-VCJYLYJV.js +49 -0
  51. package/dist/{chunk-FL4EPUWA.js → chunk-W2CL2DB3.js} +2 -2
  52. package/dist/{chunk-UZN4ZYR2.js → chunk-XD3ZQET6.js} +1 -1
  53. package/dist/{chunk-CVTVNC2U.js → chunk-XNVSCQ2O.js} +2 -2
  54. package/dist/{chunk-T3MC4HOD.js → chunk-YLSEB32F.js} +1 -1
  55. package/dist/chunk-ZETJTCMV.js +118 -0
  56. package/dist/{chunk-5SEM2V4A.js → chunk-ZIMFQWGJ.js} +3 -3
  57. package/dist/client/index.js +3 -3
  58. package/dist/cms/CmsPage.d.ts +1 -0
  59. package/dist/cms/CmsPreview.d.ts +1 -0
  60. package/dist/cms/CmsSection.d.ts +1 -0
  61. package/dist/cms/index.d.ts +6 -0
  62. package/dist/cms/server-api.d.ts +3 -0
  63. package/dist/commerce/index.js +4 -4
  64. package/dist/config/index.js +1 -1
  65. package/dist/contracts/entries.d.ts +1 -1
  66. package/dist/contracts/site-cache.d.ts +55 -0
  67. package/dist/contracts/site-edit-param.d.ts +7 -0
  68. package/dist/contracts/site-edit.d.ts +77 -0
  69. package/dist/contracts/slot-content.d.ts +111 -0
  70. package/dist/contracts/slots.d.ts +39 -25
  71. package/dist/engage/index.js +6 -6
  72. package/dist/fleet/index.js +4 -4
  73. package/dist/forms/index.js +8 -8
  74. package/dist/forms/server.js +2 -2
  75. package/dist/forms/types.d.ts +3 -1
  76. package/dist/images/index.js +4 -4
  77. package/dist/index.js +1 -1
  78. package/dist/layout/client.js +8 -7
  79. package/dist/layout/index.js +9 -8
  80. package/dist/llms/index.js +4 -2
  81. package/dist/llms/seo-revalidate.d.ts +8 -1
  82. package/dist/maps/index.js +3 -3
  83. package/dist/mcp/sonor.js +6 -6
  84. package/dist/overlay-RXV6U6QC.js +353 -0
  85. package/dist/proxy/index.js +2 -2
  86. package/dist/proxy/securityHeaders.d.ts +4 -0
  87. package/dist/revalidate/index.d.ts +44 -0
  88. package/dist/revalidate/index.js +27 -0
  89. package/dist/seo/ManagedContent.d.ts +2 -0
  90. package/dist/seo/client.js +4 -4
  91. package/dist/seo/index.js +9 -8
  92. package/dist/seo/llms.js +4 -2
  93. package/dist/seo/register-sitemap-cli.js +1 -1
  94. package/dist/seo/server.js +3 -2
  95. package/dist/seo/sitemap.js +2 -2
  96. package/dist/server/index.js +2 -2
  97. package/dist/{server-api-GJJQZVG7.js → server-api-BVCBLJKL.js} +2 -1
  98. package/dist/shared/build-entries.d.ts +1 -0
  99. package/dist/shared/edit-bridge.d.ts +8 -0
  100. package/dist/shared/version.d.ts +1 -1
  101. package/dist/signal/index.js +2 -2
  102. package/dist/sitemap/index.js +2 -2
  103. package/dist/slots/ManagedLink.d.ts +31 -0
  104. package/dist/slots/ManagedList.d.ts +30 -0
  105. package/dist/slots/ManagedRichText.d.ts +31 -0
  106. package/dist/slots/contract.js +2 -1
  107. package/dist/slots/edit/locate.d.ts +30 -0
  108. package/dist/slots/edit/overlay.d.ts +18 -0
  109. package/dist/slots/index.d.ts +12 -4
  110. package/dist/slots/index.js +4 -2
  111. package/dist/slots/revalidate.d.ts +8 -3
  112. package/dist/slots/rich.d.ts +7 -0
  113. package/dist/slots/server-api.d.ts +6 -2
  114. package/dist/sync/index.js +5 -5
  115. package/dist/website/images.js +4 -4
  116. package/dist/website/index.js +5 -5
  117. package/dist/website/popups.js +4 -4
  118. package/dist/website/slots/contract.js +2 -1
  119. package/dist/website/slots.js +4 -2
  120. package/dist/{writeLLMsTxt-UMHKGNRR.js → writeLLMsTxt-QR23OQUE.js} +1 -1
  121. package/docs/MIGRATING-TO-7.md +146 -0
  122. package/docs.json +69 -0
  123. package/package.json +14 -4
  124. package/src/admin-auth/README.md +88 -0
  125. package/src/analytics/README.md +264 -0
  126. package/src/articles/README.md +325 -0
  127. package/src/commerce/README.md +109 -0
  128. package/src/cta-bar/README.md +154 -0
  129. package/src/engage/README.md +241 -0
  130. package/src/forms/README.md +219 -0
  131. package/src/images/README.md +74 -0
  132. package/src/layout/README.md +66 -0
  133. package/src/llms/README.md +723 -0
  134. package/src/mcp/README.md +376 -0
  135. package/src/motion/README.md +372 -0
  136. package/src/og/README.md +304 -0
  137. package/src/proxy/README.md +152 -0
  138. package/src/redirects/README.md +74 -0
  139. package/src/reputation/README.md +64 -0
  140. package/src/revalidate/README.md +82 -0
  141. package/src/seo/README.md +346 -0
  142. package/src/signal/README.md +115 -0
  143. package/src/sitemap/README.md +127 -0
  144. package/src/slots/README.md +168 -0
  145. package/src/sync/README.md +115 -0
  146. package/dist/SitemapSync-7WKY4HXI.js +0 -8
  147. package/dist/chunk-SS636UDN.js +0 -35
@@ -0,0 +1,376 @@
1
+ # `@sonordev/site-kit/mcp` — WebMCP & Model Context Protocol
2
+
3
+ Make a marketing site something an AI agent can **use**, not just read.
4
+
5
+ An agent that lands on a normal site can only scrape it. This module gives the
6
+ site three machine-facing surfaces, all driven by **one** set of tool
7
+ definitions:
8
+
9
+ | Surface | Who uses it | Entry point |
10
+ |---|---|---|
11
+ | Remote MCP endpoint (Streamable HTTP) | Off-browser agents — Claude, Cursor, any MCP client | `createMcpHandler` |
12
+ | MCP Server Card (SEP-2127) | Crawlers and clients discovering the endpoint | `createMcpServerCardHandler` |
13
+ | In-page WebMCP | Browser-driving agents | `<WebMcpTools>`, `declarativeToolForm` |
14
+
15
+ One definition feeding all three is the point. The alternative — a tool list for
16
+ the endpoint and a separate one for the page — drifts, and a stale tool
17
+ definition is worse than none, because the agent believes it.
18
+
19
+ ---
20
+
21
+ ## The fast way: the built-in Sonor tools (7.0)
22
+
23
+ ```bash
24
+ npx sonor-setup mcp --inquiry-form contact
25
+ ```
26
+
27
+ That writes everything below for you, with tools you don't have to write:
28
+ `@sonordev/site-kit/mcp/sonor` reads Sonor through the same fetchers the
29
+ site's pages use, so an agent gets what a visitor gets.
30
+
31
+ | Tool | What an agent gets |
32
+ |---|---|
33
+ | `get_business_profile` | Who the business is, where it works, phone, email, address, hours |
34
+ | `list_services` | Its services, with links |
35
+ | `search_faq` | Its answered questions, to quote instead of guessing |
36
+ | `find_pages` | The page that covers a topic |
37
+ | `list_articles`, `get_article` | Its articles (`articles: false` drops them) |
38
+ | `get_reviews` | Reviews verbatim, with who wrote them, and the rating |
39
+ | `list_offerings` | Priced products, services, events (opt-in: `offerings: { path }`); private prices are left out |
40
+ | `check_availability` | Open appointment times, read only (opt-in: `booking: { path }`) |
41
+ | `get_inquiry_form`, `send_inquiry` | An inquiry for a person (opt-in: `inquiry: { form }`) |
42
+
43
+ ```ts
44
+ // lib/mcp.ts
45
+ import 'server-only'
46
+ import { sonorMcpServer } from '@sonordev/site-kit/mcp/sonor'
47
+
48
+ export const mcpServer = sonorMcpServer({
49
+ businessName: 'Example Law',
50
+ inquiry: { form: 'contact' }, // the form's "Agent inquiries" switch must be on in Sonor
51
+ tools: [/* the site's own tools, served beside these */],
52
+ })
53
+
54
+ // app/api/mcp/route.ts
55
+ import { createMcpHandler } from '@sonordev/site-kit/mcp'
56
+ import { reportToolCallsToSonor } from '@sonordev/site-kit/mcp/sonor'
57
+ import { mcpServer } from '@/lib/mcp'
58
+
59
+ export const { POST, GET, DELETE, OPTIONS } = createMcpHandler({
60
+ server: mcpServer,
61
+ baseUrl: process.env.NEXT_PUBLIC_SITE_URL,
62
+ onToolCall: reportToolCallsToSonor(),
63
+ })
64
+ ```
65
+
66
+ **`send_inquiry` has a person behind it.** It refuses unless
67
+ `person_confirmed` is true (the person asked to be contacted and agreed to
68
+ share their details), files through Sonor's agent-inquiry door with the
69
+ agent's badge (`<host> MCP send_inquiry via <assistant>`) and
70
+ `human_approved`, and only for a form that opted in. It's left off the
71
+ in-page surface, where the person's browser has the site's own form.
72
+
73
+ **Sonor sees who called.** `onToolCall` hands `createMcpHandler`'s record of
74
+ each call (tool, outcome, in-page or remote, the agent's own name or its
75
+ User-Agent) to `reportToolCallsToSonor`, which sends it after the response
76
+ with Next's `after()`. Never the arguments or the answer. Sonor's AI
77
+ Visibility tab lists the agents and tools, and Echo offers the fix when a
78
+ tool keeps failing.
79
+
80
+ **Discovery.** llms.txt gains an "Agent access" section pointing at the
81
+ endpoint and card (automatic in the build-time file once `/api/mcp` exists),
82
+ and `createProxy({ llmsDiscovery: { siteUrl, mcpServerCard: true } })` adds
83
+ `Link: <.../.well-known/mcp-server-card>; rel="service-desc"`.
84
+
85
+ ### A custom MCP server (upforge.io) is left alone
86
+
87
+ The built-in tools are opt-in, never automatic. A site that runs its own MCP
88
+ server (its own tools, transport names or llms.txt section, like upforge.io
89
+ or a re-site-kit site) is a **custom implementation**, and site-kit keeps its
90
+ hands off:
91
+
92
+ | What | Built-in server | Custom server |
93
+ |---|---|---|
94
+ | `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) |
95
+ | llms.txt "Agent access" section | added at build | **not added** (`writeLLMsTxtToPublic({ mcp: true })` opts in) |
96
+ | Tool-call reporting to Sonor | `onToolCall: reportToolCallsToSonor()` | the same line, if you want it; nothing without it |
97
+ | Proxy `service-desc` link | `llmsDiscovery.mcpServerCard: true` | the same, opt-in |
98
+ | Mixing in built-in tools | n/a | `tools: [...sonorMcpTools({ businessName, exclude }), ...yourTools]` |
99
+
100
+ "Built-in" means the site's `/api/mcp` server comes from `sonorMcpServer` or
101
+ `sonorMcpTools` (checked in the route and `lib/mcp*` by `detectMcpServer`, exported from
102
+ `@sonordev/site-kit/seo/llms`); anything else serving
103
+ `/api/mcp` or a server card is custom. To force the llms.txt section either
104
+ way: `writeLLMsTxtToPublic({ mcp: false | true })`,
105
+ `createSitemap({ llmsAgentAccess })`, or `sonor-register-sitemap --write-llms
106
+ --no-agent-access` / `--agent-access`. It's also never added to markdown
107
+ that already names a server card.
108
+
109
+ Route files need no segment config: POST and GET route handlers are dynamic
110
+ by default in Next 16, and Cache Components rejects `dynamic`, `runtime` and
111
+ `revalidate` exports. (The hand-written examples below predate that; drop
112
+ those lines on a site with Cache Components on.)
113
+
114
+ ---
115
+
116
+ ## Quick start
117
+
118
+ ### 1. Define the tools
119
+
120
+ ```ts
121
+ // lib/mcp/server.ts
122
+ import { defineMcpTool, type McpServerDefinition } from '@sonordev/site-kit/mcp'
123
+
124
+ const getServices = defineMcpTool({
125
+ name: 'get_services',
126
+ title: 'Get service catalog',
127
+ description:
128
+ 'List everything this company builds, with what each service is for and ' +
129
+ 'what it typically costs. Call this first when asked what they do.',
130
+ inputSchema: {
131
+ type: 'object',
132
+ properties: {
133
+ category: { type: 'string', description: 'Optional category filter.' },
134
+ },
135
+ },
136
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
137
+ handler: ({ category }) => SERVICES.filter((s) => !category || s.category === category),
138
+ })
139
+
140
+ export const mcpServer: McpServerDefinition = {
141
+ info: {
142
+ name: 'io.example/site', // reverse-DNS, EXACTLY one slash
143
+ version: '1.0.0', // concrete semver, no ranges
144
+ title: 'Example Co.',
145
+ description: 'Tools for exploring Example Co.’s services and requesting work.',
146
+ instructions: 'Start with get_services. Use request_quote only with consent.',
147
+ },
148
+ tools: [getServices],
149
+ }
150
+ ```
151
+
152
+ ### 2. Mount the endpoint
153
+
154
+ ```ts
155
+ // app/api/mcp/route.ts
156
+ import { createMcpHandler } from '@sonordev/site-kit/mcp'
157
+ import { mcpServer } from '@/lib/mcp/server'
158
+
159
+ export const { POST, GET, DELETE, OPTIONS } = createMcpHandler({
160
+ server: mcpServer,
161
+ baseUrl: process.env.NEXT_PUBLIC_SITE_URL,
162
+ })
163
+ export const dynamic = 'force-dynamic'
164
+ ```
165
+
166
+ ### 3. Mount the card at BOTH well-known paths
167
+
168
+ ```ts
169
+ // app/.well-known/mcp-server-card/route.ts ← canonical (SEP-2127)
170
+ // app/.well-known/mcp.json/route.ts ← superseded SEP-1649, still probed
171
+ import { createMcpServerCardHandler } from '@sonordev/site-kit/mcp'
172
+ import { mcpServer } from '@/lib/mcp/server'
173
+
174
+ export const { GET, OPTIONS } = createMcpServerCardHandler({
175
+ info: mcpServer.info,
176
+ tools: mcpServer.tools,
177
+ baseUrl: 'https://example.com',
178
+ })
179
+ export const revalidate = 3600
180
+ ```
181
+
182
+ ### 4. Register in-page (optional, for browser agents)
183
+
184
+ ```tsx
185
+ // app/layout.tsx — a CHILDLESS SIBLING, never a wrapper
186
+ <SiteKitLayout>{children}</SiteKitLayout>
187
+ <WebMcpTools endpoint="/api/mcp" />
188
+ ```
189
+
190
+ Pass no tool list. The component checks for WebMCP support first and only then
191
+ fetches `tools/list` from the endpoint — so an ordinary visitor does no work
192
+ and the page carries no extra bytes, while an agent gets the same catalog the
193
+ endpoint serves. Handing it descriptors from a server component instead would
194
+ put the whole catalog in every page's RSC payload.
195
+
196
+ ### 5. Rate-limit the public endpoint (Netlify)
197
+
198
+ A public MCP endpoint is an open door for scripted callers, and Netlify can
199
+ only rate-limit a **native function** (declarative `config.rateLimit`), never a
200
+ Next.js route handler. `@sonordev/site-kit/mcp/transport` is the relay that
201
+ puts the endpoint behind one. Three files, plus `MCP_TRANSPORT_SECRET`
202
+ (`openssl rand -hex 32`) in every deploy context, Functions scope:
203
+
204
+ ```js
205
+ // netlify/functions/mcp.mjs: owns /api/mcp in production
206
+ import { createNetlifyMcpRelay } from '@sonordev/site-kit/mcp/transport'
207
+
208
+ export default createNetlifyMcpRelay()
209
+
210
+ // Literal, in THIS file: Netlify reads path and rateLimit statically,
211
+ // so they can't be imported or spread from a constant.
212
+ export const config = {
213
+ path: '/api/mcp',
214
+ rateLimit: { windowSize: 60, windowLimit: 60, aggregateBy: ['ip', 'domain'] },
215
+ }
216
+ ```
217
+
218
+ ```ts
219
+ // app/api/mcp/route.ts: answers only relayed (signed) calls in production
220
+ import { createMcpHandler } from '@sonordev/site-kit/mcp'
221
+ import { protectMcpHandlers } from '@sonordev/site-kit/mcp/transport'
222
+ import { mcpServer } from '@/lib/mcp/server'
223
+
224
+ export const { POST, GET, DELETE, OPTIONS } = protectMcpHandlers(
225
+ createMcpHandler({ server: mcpServer, baseUrl: process.env.NEXT_PUBLIC_SITE_URL, allowedOrigins: '*' }),
226
+ )
227
+ export const dynamic = 'force-dynamic'
228
+ export const runtime = 'nodejs'
229
+ ```
230
+
231
+ ```ts
232
+ // app/api/mcp-internal/route.ts: the relay's target, 403 unless signed
233
+ import { createMcpInternalRoute } from '@sonordev/site-kit/mcp/transport'
234
+ import * as mcp from '../mcp/route'
235
+
236
+ export const { POST, GET, DELETE, OPTIONS } = createMcpInternalRoute(mcp)
237
+ export const dynamic = 'force-dynamic'
238
+ export const runtime = 'nodejs'
239
+ ```
240
+
241
+ What the relay does, so you don't have to re-derive it:
242
+
243
+ - Signs each request with `HMAC-SHA256(MCP_TRANSPORT_SECRET, label)` in a
244
+ transport header, overwriting anything the client sent under that name. The
245
+ routes verify it with `timingSafeEqual`.
246
+ - Relays to `https://<deploy-id>--<site>.netlify.app/api/mcp-internal`, the
247
+ permalink of the deploy that took the call. The base URL comes from the
248
+ function's `context`, never from a request header.
249
+ - Refuses POST bodies over 64 KB (413), uses `redirect: 'error'` and a 55 s
250
+ timeout (under Netlify's 60 s limit), and answers 502 when the upstream fails.
251
+ - Stamps responses `Cache-Control: no-store` and sets the transport header to
252
+ `netlify-rate-limited-v1`, which a release check can assert to prove a call
253
+ went through the rate limit.
254
+ - 503 when `MCP_TRANSPORT_SECRET` is unset. The `/api/mcp` route is only
255
+ enforced when `NODE_ENV === 'production'`, so `next dev` answers a local
256
+ client directly. `/api/mcp-internal` is enforced everywhere.
257
+
258
+ The header and label default to `x-site-mcp-transport` and
259
+ `site-mcp-transport-v1`. A site with names already live passes the same
260
+ `{ header, label }` to all three factories (upforge.io uses
261
+ `x-upforge-mcp-transport` / `upforge-mcp-transport-v1`). This entry is Node
262
+ only and imports no `server-only`, so the plain-Node function can load it. It
263
+ is not re-exported from `@sonordev/site-kit/mcp`, which stays runtime-neutral.
264
+
265
+ ---
266
+
267
+ ## Design notes
268
+
269
+ ### The card does not list tools — on purpose
270
+
271
+ SEP-2127 deliberately omits primitives from the card: what a server exposes can
272
+ vary with auth state and flags, so the authoritative list is whatever
273
+ `tools/list` returns at call time. A card that inlined tools would be a second
274
+ source of truth that goes stale silently.
275
+
276
+ We still publish a **summary** (name + description + read-only flag) under
277
+ `_meta['io.sonor.site-kit/tools']`. `_meta` is the spec's sanctioned extension
278
+ point and requires a reverse-DNS prefix, so the hint rides along without
279
+ pretending to be standard — useful for crawlers that index the card and never
280
+ connect.
281
+
282
+ ### Two well-known paths
283
+
284
+ SEP-1649 proposed `/.well-known/mcp.json`; the ratified SEP-2127 moved to
285
+ `/.well-known/mcp-server-card`. Deployed validators still probe the old path.
286
+ Serving one document from two URLs costs nothing, so mount both.
287
+
288
+ ### Dual-era protocol support
289
+
290
+ `dispatch()` answers both protocol eras on one endpoint:
291
+
292
+ - **Modern (`2026-07-28`)** — stateless, per-request `_meta` carrying the
293
+ protocol version, mirrored into `MCP-Protocol-Version`. `server/discover`
294
+ replaces the handshake. No sessions, no GET stream (both return `405`).
295
+ - **Legacy (`≤ 2025-11-25`)** — the `initialize` handshake, which is what most
296
+ shipped clients and SDKs still speak.
297
+
298
+ Supporting only the current revision would be spec-correct and unusable today.
299
+
300
+ ### Header mirroring: mismatch is fatal, absence is not
301
+
302
+ The modern revision mirrors `method` and `params.name` into `Mcp-Method` and
303
+ `Mcp-Name` so intermediaries can route without parsing bodies, and requires
304
+ servers to reject disagreements (`-32020`).
305
+
306
+ We always reject a **mismatch** — that is the real security property, stopping
307
+ a load balancer and the server from acting on different values. A merely
308
+ **absent** header is tolerated unless you set `strictHeaders: true`, because a
309
+ public marketing endpoint exists to be reachable and today's clients frequently
310
+ omit the mirrors.
311
+
312
+ ### Tool failures come back as results, not transport errors
313
+
314
+ A missing argument or a thrown handler returns a normal result with
315
+ `isError: true`. That text goes back to the **model**, which can read it and
316
+ retry correctly. A JSON-RPC error goes to the client harness and usually
317
+ surfaces as a dead end.
318
+
319
+ ### Handler results are emitted twice
320
+
321
+ Structured returns become both `structuredContent` (for agents that parse) and
322
+ pretty JSON inside a text block (for agents that only read `content`). Emitting
323
+ one or the other makes you invisible to a large slice of the ecosystem.
324
+
325
+ ### Discovery is lazy, and gated on support
326
+
327
+ `<WebMcpTools>` does nothing at all unless `document.modelContext` exists. That
328
+ gate comes before the `tools/list` fetch, so the cost for a human visitor is a
329
+ single property check — not a request, and not a byte of page weight.
330
+
331
+ ### In-page tools are proxied, not re-implemented
332
+
333
+ `<WebMcpTools>` registers thin wrappers that POST `tools/call` to this site's
334
+ own endpoint, so the in-page tool and the remote tool run the same server-side
335
+ handler. It also keeps `SONOR_API_KEY` out of the client bundle — registering
336
+ real handlers client-side would pull server code into a `'use client'` graph,
337
+ which is how a raw `sonor_` key once shipped in a public chunk.
338
+
339
+ Tools that genuinely need live DOM state go in `localTools` and run in-page.
340
+
341
+ ### Registration is deferred
342
+
343
+ `<WebMcpTools>` waits for the page to go quiet (`useDeferredActivation`) before
344
+ touching `document.modelContext`. Agents poll or listen for `toolchange`, so a
345
+ few hundred milliseconds costs nothing — a blocked LCP costs a lot.
346
+
347
+ ---
348
+
349
+ ## Writing good tools
350
+
351
+ The `description` is the highest-leverage field in this module. It is the only
352
+ thing a model reads when deciding whether to call the tool.
353
+
354
+ - **Verb-led `snake_case` names**: `get_services`, `request_site_audit`.
355
+ - **Say when to call it**, not just what it returns: *"Call this first when
356
+ asked what they do."*
357
+ - **Set `annotations` honestly.** `readOnlyHint` on lookups; `destructiveHint`
358
+ on anything creating a record. Good agents use these to decide what needs a
359
+ human.
360
+ - **Never `toolautosubmit` a lead form.** That is how an agent files fifty
361
+ audit requests by accident.
362
+ - **Return the caveat with the data.** A pricing tool should return the ranges
363
+ *and* the fact that they are ranges — otherwise the model quotes a number as
364
+ a commitment.
365
+
366
+ ## Testing an endpoint by hand
367
+
368
+ ```bash
369
+ curl -s https://example.com/.well-known/mcp.json | jq
370
+
371
+ curl -s -X POST https://example.com/api/mcp \
372
+ -H 'Content-Type: application/json' \
373
+ -H 'Accept: application/json, text/event-stream' \
374
+ -H 'Mcp-Method: tools/list' \
375
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools[].name'
376
+ ```