@sonordev/site-kit 7.0.1 → 7.0.2
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.
- package/CHANGELOG.md +3539 -0
- package/README.md +12 -13
- package/agent-manifest.json +1 -1
- package/dist/{AnalyticsProvider-EMM2TKRE.js → AnalyticsProvider-XXWTFKJH.js} +4 -4
- package/dist/{ArticleViewTracker-RA64BGL6.js → ArticleViewTracker-V4KZB6QN.js} +3 -3
- package/dist/{BlocksPopup-D25RFNOV.js → BlocksPopup-JHGHB6XW.js} +4 -4
- package/dist/{ChatWidget-RYI7BMJJ.js → ChatWidget-CG32POI3.js} +5 -5
- package/dist/{EngageWidget-UKFCN33M.js → EngageWidget-LQMR4LEX.js} +4 -4
- package/dist/{FileField-MUHA7LZR.js → FileField-KUG3CKXG.js} +3 -3
- package/dist/{FormSpotlight-TCLPWPLL.js → FormSpotlight-FVNPOCU3.js} +1 -1
- package/dist/{FormStage-CNYLP6I6.js → FormStage-C7VKRURJ.js} +1 -1
- package/dist/{ManagedForm-7ZL5SKTO.js → ManagedForm-VLNJKV65.js} +6 -6
- package/dist/{ManagedNewsletterForm-33B4JLX7.js → ManagedNewsletterForm-KJEU23BV.js} +4 -4
- package/dist/{SignalCore-L5FVDHFE.js → SignalCore-K2O46QG7.js} +3 -3
- package/dist/{SiteDesignReporter-4JOFL4FP.js → SiteDesignReporter-D7MD66GI.js} +5 -5
- package/dist/SitemapSync-NMXGMPCQ.js +8 -0
- package/dist/_client/booking-widget.js +5 -5
- package/dist/affiliates/index.js +3 -3
- package/dist/analytics/index.js +4 -4
- package/dist/articles/index.js +1 -1
- package/dist/articles/server-ui.js +1 -1
- package/dist/chat/index.js +5 -5
- package/dist/{chunk-QANVUXKH.js → chunk-42OXY4JV.js} +1 -1
- package/dist/{chunk-GYESATRY.js → chunk-56JNI463.js} +1 -1
- package/dist/{chunk-MV2MBTC3.js → chunk-5FBY2ZIH.js} +1 -1
- package/dist/{chunk-BMO3VGMR.js → chunk-7JIKGKWD.js} +7 -7
- package/dist/{chunk-QGHSMJKW.js → chunk-B6RZ2NRH.js} +1 -1
- package/dist/{chunk-OFOAHPUV.js → chunk-BEL7YFMC.js} +1 -1
- package/dist/{chunk-WATH55UY.js → chunk-BS7FWUOY.js} +1 -1
- package/dist/{chunk-FL4EPUWA.js → chunk-DKTSGYLM.js} +2 -2
- package/dist/{chunk-HGCK465A.js → chunk-GGD4P7UW.js} +1 -1
- package/dist/{chunk-FYBZ5SNP.js → chunk-GYY6ETGB.js} +1 -1
- package/dist/{chunk-CVTVNC2U.js → chunk-K5WZX776.js} +2 -2
- package/dist/{chunk-4IQ52CXL.js → chunk-LJZ3SUET.js} +2 -2
- package/dist/{chunk-P5J7VMQ3.js → chunk-O52CH273.js} +1 -1
- package/dist/{chunk-3KUUH2YP.js → chunk-OIETJKIL.js} +1 -1
- package/dist/{chunk-V6LSQRTH.js → chunk-P2GIIQH5.js} +1 -1
- package/dist/{chunk-4RMVXRBO.js → chunk-P72ZJRSX.js} +3 -3
- package/dist/{chunk-EGOD74PP.js → chunk-RU2RMTGT.js} +2 -2
- package/dist/{chunk-QZZIKMAT.js → chunk-SAUTJMK6.js} +1 -1
- package/dist/{chunk-T3MC4HOD.js → chunk-SWP36NCB.js} +1 -1
- package/dist/{chunk-5SEM2V4A.js → chunk-T4SY3FMN.js} +3 -3
- package/dist/{chunk-P4GRY6QP.js → chunk-ZRE4ZYEG.js} +1 -1
- package/dist/{chunk-UZN4ZYR2.js → chunk-ZSLRAMCK.js} +1 -1
- package/dist/client/index.js +3 -3
- package/dist/commerce/index.js +4 -4
- package/dist/engage/index.js +6 -6
- package/dist/fleet/index.js +4 -4
- package/dist/forms/index.js +7 -7
- package/dist/forms/server.js +2 -2
- package/dist/forms/types.d.ts +3 -1
- package/dist/images/index.js +4 -4
- package/dist/index.js +1 -1
- package/dist/layout/client.js +7 -7
- package/dist/layout/index.js +8 -8
- package/dist/maps/index.js +3 -3
- package/dist/mcp/sonor.js +6 -6
- package/dist/seo/client.js +4 -4
- package/dist/seo/index.js +4 -4
- package/dist/server/index.js +2 -2
- package/dist/shared/version.d.ts +1 -1
- package/dist/signal/index.js +2 -2
- package/dist/sync/index.js +5 -5
- package/dist/website/images.js +4 -4
- package/dist/website/index.js +5 -5
- package/dist/website/popups.js +4 -4
- package/docs/MIGRATING-TO-7.md +146 -0
- package/docs.json +67 -0
- package/package.json +9 -4
- package/src/admin-auth/README.md +88 -0
- package/src/analytics/README.md +264 -0
- package/src/articles/README.md +325 -0
- package/src/commerce/README.md +109 -0
- package/src/cta-bar/README.md +154 -0
- package/src/engage/README.md +241 -0
- package/src/forms/README.md +219 -0
- package/src/images/README.md +74 -0
- package/src/layout/README.md +66 -0
- package/src/llms/README.md +723 -0
- package/src/mcp/README.md +376 -0
- package/src/motion/README.md +372 -0
- package/src/og/README.md +304 -0
- package/src/proxy/README.md +152 -0
- package/src/redirects/README.md +74 -0
- package/src/reputation/README.md +64 -0
- package/src/seo/README.md +359 -0
- package/src/signal/README.md +115 -0
- package/src/sitemap/README.md +127 -0
- package/src/sync/README.md +115 -0
- package/dist/SitemapSync-7WKY4HXI.js +0 -8
|
@@ -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
|
+
```
|