wiki-formant 0.18.0 → 0.20.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.
package/README.md CHANGED
@@ -120,8 +120,9 @@ What it gets right:
120
120
  - **Malformed JSON is `-32700` with a `400`**, never a 500.
121
121
  - **`GET` is an explicit 405 with CORS headers.** A framework's automatic 405 carries none, so a browser client cannot even read the refusal.
122
122
  - **The preflight allow-list includes `Accept` and `Mcp-Protocol-Version`.** One missing entry fails the preflight rather than the POST, which presents as "the server is down".
123
- - **Batches are capped** (default 20) with a teaching error, because the rate limiter charges one token per HTTP request before the body is parsed. Under 2025-06-18 they are refused outright, because the revision removed them.
124
- - **The protocol version is negotiated, not asserted.** `initialize` echoes what the client asked for when it is one of `2025-06-18`, `2025-03-26` or `2024-11-05`, and offers the newest otherwise; every response carries the negotiated version back in `MCP-Protocol-Version`. Answering a constant is legal and still costs the caller structured output, tool titles and `_meta` without ever saying so.
123
+ - **Batches are capped** (default 20) with a teaching error, because the rate limiter charges one token per HTTP request before the body is parsed. From 2025-06-18 on they are refused outright, because that revision removed them.
124
+ - **Both protocol eras, one endpoint.** A request carrying `io.modelcontextprotocol/protocolVersion` in `params._meta` is served as `2026-07-28`: `server/discover`, per-request version and header validation (`-32022` naming every version spoken, `-32020` on a routing header that disagrees with the body), `resultType` and cache hints on results, and 404 for the methods that revision removed. Everything else is the legacy era, where `initialize` echoes the client's version when it is one of `2025-11-25`, `2025-06-18`, `2025-03-26` or `2024-11-05` and offers the newest otherwise. The spec permits a dual-era server, and it is the only way to adopt the new revision without failing the handshake of every client already in the field.
125
+ - **The version is negotiated, not asserted.** Every response carries it back in `MCP-Protocol-Version`. Answering a constant is legal and still costs the caller structured output, tool titles and `_meta` without ever saying so.
125
126
  - **`Access-Control-Expose-Headers` is set.** Allow-Headers governs what a browser may send, Expose-Headers what it may read — without the second a browser client cannot see `Retry-After` on a 429, and a rate limit presents as a hang.
126
127
  - **The rate limit is declared, not wired.** Put `rateLimit: {capacity, refillPerSec}` on the config and `mcpResponse` enforces it before parsing the body, refuses with a JSON-RPC envelope, and states `RateLimit-Limit`/`-Remaining`/`-Reset` on every answer. Four routes had each transcribed that by hand through three differently-named local helpers, which is how a headroom header gets added to one surface and forgotten on the next.
127
128
  - **`mcpRateLimited(retryAfterSec)` is a JSON-RPC envelope.** A 429 whose body is `{"error": "..."}` is a string where the client's parser expects `{code, message}`, on the one response an agent meets exactly when it is working hard.
@@ -369,6 +370,7 @@ const block = licenseBlock({ license, scope: 'The protocol compilation and prose
369
370
 
370
371
  ```ts
371
372
  addCopyButtons(el); // every <pre> gets one, once
373
+ sortTables(el); // every column-headed table sorts by its headers
372
374
  hydrateTweetEmbeds(el); // placeholders get a live src
373
375
  const off = onTweetResize(h => sizeTweetEmbeds(el, h));
374
376
  ```
@@ -377,6 +379,8 @@ const off = onTweetResize(h => sizeTweetEmbeds(el, h));
377
379
 
378
380
  `activateTabGroups` turns stored `[data-tabs]` markup into a working tab group. The editor persists tabs as nested divs, which is the right thing to store — it survives a markdown twin, a plain HTML render and a reader with JavaScript off, all of which show every tab in order. Making one of them pressable is a reader-side job, and it sits beside the other passes rather than inside a component.
379
381
 
382
+ `sortTables` makes the tables stored in article HTML sortable by their headers. They arrive as a string a `dangerouslySetInnerHTML` wrote, so React never sees their rows and cannot sort them. A column is dates if every filled cell starts with one, numbers if every one does, and text otherwise — one stray value makes the whole column text, which beats sorting half of it by one rule and half by another. A third press restores the author's order, which is often chronological or ranked and otherwise needs a reload. Label/value tables and tables with merged cells are left alone. The markup it writes — `aria-sort` on the cell, a `.sort-header` button inside it — is the markup a React sortable header should write too, so both kinds of table draw their arrows from one stylesheet rule.
383
+
380
384
  `TWITTER_ORIGIN` is written down once. It is both the embed host and the allow-list `onTweetResize` checks before believing a posted height, and it had been spelled out at four call sites across two repos. Any page can `postMessage`; only the embed host may size the embed.
381
385
 
382
386
  ## Editor nodes
@@ -34,12 +34,19 @@ export interface Rpc {
34
34
  protocolVersion?: string;
35
35
  capabilities?: Record<string, unknown>;
36
36
  instructions?: string;
37
+ resultType?: string;
38
+ supportedVersions?: string[];
39
+ ttlMs?: number;
40
+ cacheScope?: string;
41
+ _meta?: Record<string, unknown>;
37
42
  };
38
43
  error?: {
39
44
  code: number;
40
45
  message: string;
41
46
  data?: {
42
47
  availableTools?: string[];
48
+ supported?: string[];
49
+ requested?: string;
43
50
  };
44
51
  };
45
52
  }
@@ -15,7 +15,7 @@
15
15
  // Every value the transport assertions compare against is imported from `mcp.ts`,
16
16
  // not restated here. A suite that carries its own copy of the contract stops
17
17
  // testing the boundary the moment the contract moves and says nothing about it.
18
- import { DEFAULT_MAX_BATCH, MCP_CORS, MCP_PROTOCOL_VERSION } from './mcp.js';
18
+ import { DEFAULT_MAX_BATCH, MCP_CORS, MCP_LEGACY_PROTOCOL_VERSION, MCP_META, MCP_MODERN_VERSIONS, MCP_PROTOCOL_VERSIONS, } from './mcp.js';
19
19
  /**
20
20
  * Does the live preflight carry every token `MCP_CORS` declares for this header?
21
21
  *
@@ -111,11 +111,11 @@ expectedCapabilities = ['tools']) {
111
111
  // 2025-03-26 — forfeiting structured output, tool titles and `_meta` — while
112
112
  // every suite reported green.
113
113
  const echoed = await t.rpc('initialize', {
114
- protocolVersion: MCP_PROTOCOL_VERSION,
114
+ protocolVersion: MCP_LEGACY_PROTOCOL_VERSION,
115
115
  capabilities: {},
116
116
  clientInfo: { name: clientName, version: '1' },
117
117
  });
118
- t.check('protocol negotiated', echoed.result?.protocolVersion === MCP_PROTOCOL_VERSION, `asked ${MCP_PROTOCOL_VERSION}, got ${echoed.result?.protocolVersion}`);
118
+ t.check('protocol negotiated', echoed.result?.protocolVersion === MCP_LEGACY_PROTOCOL_VERSION, `asked ${MCP_LEGACY_PROTOCOL_VERSION}, got ${echoed.result?.protocolVersion}`);
119
119
  t.check('downgrades gracefully', (init.result?.protocolVersion ?? '').length > 0, `asked 2024-11-05, got ${init.result?.protocolVersion}`);
120
120
  console.log(`\n=== transport ===`);
121
121
  const opt = await fetch(t.endpoint, { method: 'OPTIONS' });
@@ -156,19 +156,67 @@ expectedCapabilities = ['tools']) {
156
156
  t.check('resources/templates/list', Array.isArray(templates.result?.resourceTemplates), templates.error ? `-${templates.error.code}` : `${templates.result?.resourceTemplates?.length} templates`);
157
157
  }
158
158
  const versioned = await rawPost(t.endpoint, { jsonrpc: '2.0', id: 1, method: 'ping' }, {
159
- 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
159
+ 'MCP-Protocol-Version': MCP_LEGACY_PROTOCOL_VERSION,
160
160
  });
161
161
  t.recordCall();
162
- t.check('MCP-Protocol-Version echoed', versioned.headers.get('mcp-protocol-version') === MCP_PROTOCOL_VERSION, `sent ${MCP_PROTOCOL_VERSION}, got ${versioned.headers.get('mcp-protocol-version')}`);
162
+ t.check('MCP-Protocol-Version echoed', versioned.headers.get('mcp-protocol-version') === MCP_LEGACY_PROTOCOL_VERSION, `sent ${MCP_LEGACY_PROTOCOL_VERSION}, got ${versioned.headers.get('mcp-protocol-version')}`);
163
163
  // Batching was removed in 2025-06-18. A server that keeps honouring it under
164
164
  // a version that forbids it is telling the client something untrue.
165
165
  const batched = await rawPost(t.endpoint, [{ jsonrpc: '2.0', id: 1, method: 'ping' }], {
166
- 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
166
+ 'MCP-Protocol-Version': MCP_LEGACY_PROTOCOL_VERSION,
167
167
  });
168
168
  t.recordCall();
169
- t.check('batch refused at 2025-06-18', batched.status === 400, `${batched.status}`);
169
+ t.check(`batch refused at ${MCP_LEGACY_PROTOCOL_VERSION}`, batched.status === 400, `${batched.status}`);
170
+ await modernChecks(t, caps);
170
171
  return init;
171
172
  }
173
+ /**
174
+ * A modern request exactly as a `2026-07-28` client sends it: version and
175
+ * capabilities in `_meta`, echoed by the headers a gateway routes on.
176
+ */
177
+ const modernPost = async (t, method, opts = {}) => {
178
+ const version = opts.version ?? MCP_MODERN_VERSIONS[0];
179
+ const res = await rawPost(t.endpoint, {
180
+ jsonrpc: '2.0',
181
+ id: method,
182
+ method,
183
+ params: { _meta: { [MCP_META.protocolVersion]: version, [MCP_META.clientCapabilities]: {} } },
184
+ }, { 'MCP-Protocol-Version': version, 'Mcp-Method': method, ...opts.headers });
185
+ t.recordCall();
186
+ return { status: res.status, json: (await res.json()) };
187
+ };
188
+ /**
189
+ * The modern era, served beside the legacy one. Every probe that graded these
190
+ * servers on `server/discover` saw a -32601 for a month while every legacy check
191
+ * here stayed green, because nothing asked the modern question.
192
+ */
193
+ async function modernChecks(t, legacyCapabilities) {
194
+ console.log(`\n=== modern era (${MCP_MODERN_VERSIONS[0]}) ===`);
195
+ const discover = await modernPost(t, 'server/discover');
196
+ const d = discover.json.result;
197
+ t.check('server/discover', discover.status === 200 &&
198
+ d?.resultType === 'complete' &&
199
+ MCP_PROTOCOL_VERSIONS.every(v => d.supportedVersions?.includes(v)) &&
200
+ !!d.instructions &&
201
+ typeof d.ttlMs === 'number' &&
202
+ !!d.cacheScope &&
203
+ !!d._meta?.[MCP_META.serverInfo], `${discover.status} ${JSON.stringify(d?.supportedVersions ?? discover.json.error)}`);
204
+ // One server, two ways to ask what it can do. They must give one answer.
205
+ t.check('discover agrees with initialize', JSON.stringify(d?.capabilities) === JSON.stringify(legacyCapabilities), `discover=${JSON.stringify(d?.capabilities)} initialize=${JSON.stringify(legacyCapabilities)}`);
206
+ const list = await modernPost(t, 'tools/list');
207
+ t.check('modern tools/list', list.status === 200 &&
208
+ list.json.result?.resultType === 'complete' &&
209
+ (list.json.result?.tools?.length ?? 0) > 0 &&
210
+ typeof list.json.result?.ttlMs === 'number', `${list.status} tools=${list.json.result?.tools?.length ?? JSON.stringify(list.json.error)} ttlMs=${list.json.result?.ttlMs}`);
211
+ const unsupported = await modernPost(t, 'tools/list', { version: '1999-01-01' });
212
+ t.check('unsupported version→-32022', unsupported.status === 400 &&
213
+ unsupported.json.error?.code === -32022 &&
214
+ JSON.stringify(unsupported.json.error?.data?.supported) === JSON.stringify(MCP_PROTOCOL_VERSIONS), `${unsupported.status} code=${unsupported.json.error?.code} supported=${JSON.stringify(unsupported.json.error?.data?.supported)}`);
215
+ const ping = await modernPost(t, 'ping');
216
+ t.check('removed method→404', ping.status === 404 && ping.json.error?.code === -32601, `ping: ${ping.status} code=${ping.json.error?.code}`);
217
+ const mismatch = await modernPost(t, 'tools/list', { headers: { 'Mcp-Method': 'prompts/list' } });
218
+ t.check('header mismatch→-32020', mismatch.status === 400 && mismatch.json.error?.code === -32020, `${mismatch.status} code=${mismatch.json.error?.code}`);
219
+ }
172
220
  /**
173
221
  * One service, many descriptors — server.json, the two agent-card paths, the
174
222
  * OpenAPI document, the MCP server card, and `initialize` — should never
package/dist/dom.d.ts CHANGED
@@ -65,3 +65,27 @@ export interface TabGroupClassNames {
65
65
  * silently reset it to the first tab.
66
66
  */
67
67
  export declare function activateTabGroups(root: ParentNode, classNames?: TabGroupClassNames): void;
68
+ export interface SortTablesOptions {
69
+ /** Class on the injected header button. Style it in your own stylesheet. */
70
+ className?: string;
71
+ }
72
+ /**
73
+ * Make every column-headed table under `root` sortable by its headers, once.
74
+ *
75
+ * Pressing a header sorts by that column. Text sorts A–Z first and numbers and
76
+ * dates largest first, the same as a React table sorted by `useTableSort`. A
77
+ * second press reverses the order and a third restores the author's order,
78
+ * which is often meaningful (chronological, or ranked) and otherwise could only
79
+ * be recovered by a reload.
80
+ *
81
+ * Tables stored in article HTML arrive as a string a `dangerouslySetInnerHTML`
82
+ * wrote, so React never sees their rows and cannot sort them — which is why
83
+ * this is a DOM pass beside `addCopyButtons` rather than a component. The
84
+ * markup it writes, `aria-sort` on the cell and a button inside it, is the
85
+ * markup a React sortable header should write too, so both kinds of table draw
86
+ * their arrows from one stylesheet rule.
87
+ *
88
+ * Skipped: tables with merged cells, where moving a row would break the grid;
89
+ * tables with fewer than two rows to sort; and rows that span several `<tbody>`s.
90
+ */
91
+ export declare function sortTables(root: ParentNode, options?: SortTablesOptions): void;
package/dist/dom.js CHANGED
@@ -163,3 +163,138 @@ export function activateTabGroups(root, classNames = {}) {
163
163
  group.appendChild(tabPanels);
164
164
  }
165
165
  }
166
+ const MONTHS = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'];
167
+ const monthIndex = (name) => MONTHS.findIndex(m => m.startsWith(name.toLowerCase()));
168
+ // A dash, a question mark or "n/a" means the cell has no value. It sorts last
169
+ // in both directions, so it never lands above real values.
170
+ const BLANK = /^(?:[-–—?]|n\/?a|tb[ad])?$/i;
171
+ const DAY_FIRST = /^(\d{1,2})(?:st|nd|rd|th)?\s+([a-z]{3,9})\.?,?\s+(\d{4})(?:,?\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?/i;
172
+ const MONTH_FIRST = /^([a-z]{3,9})\.?\s+(?:(\d{1,2})(?:st|nd|rd|th)?,?\s+)?(\d{4})/i;
173
+ /** The time a cell starts with, as ms. Accepts ISO dates, "4 Jun 2026", "June 4, 2026", "June 2026" and a bare year. */
174
+ function parseDate(s) {
175
+ const iso = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?(?:[ T](\d{2}):(\d{2}))?)?(?![\d,.])/.exec(s);
176
+ if (iso)
177
+ return Date.UTC(+iso[1], +(iso[2] ?? 1) - 1, +(iso[3] ?? 1), +(iso[4] ?? 0), +(iso[5] ?? 0));
178
+ const d = DAY_FIRST.exec(s);
179
+ if (d && monthIndex(d[2]) >= 0)
180
+ return Date.UTC(+d[3], monthIndex(d[2]), +d[1], +(d[4] ?? 0), +(d[5] ?? 0), +(d[6] ?? 0));
181
+ const m = MONTH_FIRST.exec(s);
182
+ if (m && monthIndex(m[1]) >= 0)
183
+ return Date.UTC(+m[3], monthIndex(m[1]), +(m[2] ?? 1));
184
+ return null;
185
+ }
186
+ const MULTIPLIER = {
187
+ k: 1e3, thousand: 1e3, m: 1e6, million: 1e6, b: 1e9, bn: 1e9, billion: 1e9, t: 1e12, trillion: 1e12,
188
+ kb: 1e3, mb: 1e6, gb: 1e9, tb: 1e12, kib: 2 ** 10, mib: 2 ** 20, gib: 2 ** 30, tib: 2 ** 40,
189
+ };
190
+ // The number a cell starts with: "~$3,500", "+137%", "−0.4", "142M XRD",
191
+ // "14.00 million", "3.4 MB". A version string like "1.18.4" is not a number, so a column
192
+ // of versions falls through to the natural text order, where 1.9 comes before 1.10.
193
+ const NUMBER = /^[~≈<>≤≥]?\s*([+\-−]?)\s*[#$€£¥]?\s*(\d[\d,]*(?:\.\d+)?|\.\d+)(?![.\d])(?:\s*(%|(?:thousand|million|billion|trillion|[kmgt]i?b|bn|[kmbt])(?![a-z])))?/i;
194
+ function parseNumber(s) {
195
+ const n = NUMBER.exec(s);
196
+ if (!n)
197
+ return null;
198
+ const value = parseFloat(n[2].replace(/,/g, '')) * (MULTIPLIER[n[3]?.toLowerCase() ?? ''] ?? 1);
199
+ return n[1] && n[1] !== '+' ? -value : value;
200
+ }
201
+ /**
202
+ * The column's sort keys. A column is dates if every filled cell starts with a
203
+ * date, numbers if every one starts with a number, and text otherwise. One
204
+ * stray value makes the whole column text, which is better than sorting it half
205
+ * by one rule and half by another.
206
+ */
207
+ function columnKeys(cells) {
208
+ const blank = cells.map(c => BLANK.test(c));
209
+ for (const parse of [parseDate, parseNumber]) {
210
+ const keys = cells.map((c, i) => (blank[i] ? null : parse(c)));
211
+ if (keys.every((k, i) => k !== null || blank[i]))
212
+ return keys;
213
+ }
214
+ return cells.map((c, i) => (blank[i] ? null : c));
215
+ }
216
+ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
217
+ function compare(a, b, direction) {
218
+ if (a === null || b === null)
219
+ return a === b ? 0 : a === null ? 1 : -1;
220
+ const order = typeof a === 'number' && typeof b === 'number' ? a - b : collator.compare(String(a), String(b));
221
+ return direction === 'ascending' ? order : -order;
222
+ }
223
+ /**
224
+ * The row whose cells label the columns: the last row of a `<thead>`, or the
225
+ * first row when every cell in it is a `<th>`. The editor writes the second
226
+ * form. An infobox's label/value table has neither form, so it is left alone.
227
+ */
228
+ function headerRow(table) {
229
+ const head = table.tHead?.rows;
230
+ if (head?.length)
231
+ return head[head.length - 1];
232
+ const first = table.rows[0];
233
+ return first && Array.from(first.cells).every(c => c.tagName === 'TH') ? first : undefined;
234
+ }
235
+ /**
236
+ * Make every column-headed table under `root` sortable by its headers, once.
237
+ *
238
+ * Pressing a header sorts by that column. Text sorts A–Z first and numbers and
239
+ * dates largest first, the same as a React table sorted by `useTableSort`. A
240
+ * second press reverses the order and a third restores the author's order,
241
+ * which is often meaningful (chronological, or ranked) and otherwise could only
242
+ * be recovered by a reload.
243
+ *
244
+ * Tables stored in article HTML arrive as a string a `dangerouslySetInnerHTML`
245
+ * wrote, so React never sees their rows and cannot sort them — which is why
246
+ * this is a DOM pass beside `addCopyButtons` rather than a component. The
247
+ * markup it writes, `aria-sort` on the cell and a button inside it, is the
248
+ * markup a React sortable header should write too, so both kinds of table draw
249
+ * their arrows from one stylesheet rule.
250
+ *
251
+ * Skipped: tables with merged cells, where moving a row would break the grid;
252
+ * tables with fewer than two rows to sort; and rows that span several `<tbody>`s.
253
+ */
254
+ export function sortTables(root, options = {}) {
255
+ const { className = 'sort-header' } = options;
256
+ for (const table of Array.from(root.querySelectorAll('table:not([data-sort-init])'))) {
257
+ table.setAttribute('data-sort-init', '');
258
+ const head = headerRow(table);
259
+ if (!head)
260
+ continue;
261
+ const rows = Array.from(table.tBodies).flatMap(b => Array.from(b.rows)).filter(r => r !== head);
262
+ const body = rows[0]?.parentElement;
263
+ if (!body || rows.length < 2 || rows.some(r => r.parentElement !== body))
264
+ continue;
265
+ if (Array.from(table.querySelectorAll('th, td')).some(c => c.colSpan > 1 || c.rowSpan > 1))
266
+ continue;
267
+ const headers = Array.from(head.cells);
268
+ const sortBy = (col, th) => {
269
+ const keys = columnKeys(rows.map(r => r.cells[col]?.textContent?.trim() ?? ''));
270
+ const first = typeof keys.find(k => k !== null) === 'string' ? 'ascending' : 'descending';
271
+ const current = th.getAttribute('aria-sort');
272
+ const next = current === first ? (first === 'ascending' ? 'descending' : 'ascending') : current === 'none' ? first : null;
273
+ for (const h of headers)
274
+ if (h.hasAttribute('aria-sort'))
275
+ h.setAttribute('aria-sort', 'none');
276
+ if (next)
277
+ th.setAttribute('aria-sort', next);
278
+ const order = next
279
+ ? rows.map((row, i) => ({ row, key: keys[i] ?? null })).sort((a, b) => compare(a.key, b.key, next)).map(x => x.row)
280
+ : rows;
281
+ body.append(...order);
282
+ };
283
+ headers.forEach((th, col) => {
284
+ // A header with no label has nothing to press, and a link inside a
285
+ // button would fire both.
286
+ if (!th.textContent?.trim() || th.querySelector('a, button'))
287
+ return;
288
+ const button = document.createElement('button');
289
+ button.type = 'button';
290
+ button.className = className;
291
+ // The editor wraps every cell's text in a <p>, which a button cannot hold.
292
+ const label = th.children.length === 1 && th.firstElementChild?.tagName === 'P' ? th.firstElementChild : th;
293
+ button.append(...Array.from(label.childNodes));
294
+ button.onclick = () => sortBy(col, th);
295
+ th.replaceChildren(button);
296
+ th.scope ||= 'col';
297
+ th.setAttribute('aria-sort', 'none');
298
+ });
299
+ }
300
+ }
package/dist/mcp.d.ts CHANGED
@@ -1,16 +1,38 @@
1
1
  /**
2
- * The versions this transport speaks, newest first.
2
+ * The modern era: every request carries its own version and client
3
+ * capabilities in `params._meta`. No handshake, no session, no `ping`, no batch.
4
+ */
5
+ export declare const MCP_MODERN_VERSIONS: readonly ["2026-07-28"];
6
+ /**
7
+ * The legacy era, negotiated by an `initialize` handshake.
3
8
  *
4
- * This was a constant answered unconditionally, which is legal and still wrong:
5
- * a client asking for 2025-06-18 was told 2025-03-26 and silently gave up tool
6
- * titles, structured output and `_meta`. The last one is where a payment
7
- * receipt rides, so the one surface that needed it rebuilt this module's HTTP
8
- * shell by hand to reach it.
9
+ * Served beside the modern era rather than replaced by it. The spec lets one
10
+ * endpoint speak both, and a legacy client meeting a dual-era server works;
11
+ * going modern-only would fail the handshake of every client in the field.
9
12
  */
10
- export declare const MCP_PROTOCOL_VERSIONS: readonly ["2025-06-18", "2025-03-26", "2024-11-05"];
13
+ export declare const MCP_LEGACY_VERSIONS: readonly ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
14
+ /**
15
+ * Every version this transport speaks, newest first. It is the `supported` list
16
+ * a `-32022` names and the `supportedVersions` of `server/discover`, so a modern
17
+ * client that cannot use the newest still learns a legacy fallback exists.
18
+ */
19
+ export declare const MCP_PROTOCOL_VERSIONS: readonly ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
11
20
  export type McpProtocolVersion = (typeof MCP_PROTOCOL_VERSIONS)[number];
12
- /** The newest version spoken here, and what an unrecognised ask falls back to. */
21
+ type LegacyVersion = (typeof MCP_LEGACY_VERSIONS)[number];
22
+ /** The newest version spoken here. */
13
23
  export declare const MCP_PROTOCOL_VERSION: McpProtocolVersion;
24
+ /**
25
+ * The newest version `initialize` can negotiate, and what an unrecognised ask is
26
+ * offered. A handshake cannot land on a modern version — the modern era has no
27
+ * handshake — so this, not `MCP_PROTOCOL_VERSION`, is what a legacy client gets.
28
+ */
29
+ export declare const MCP_LEGACY_PROTOCOL_VERSION: LegacyVersion;
30
+ /** The reserved `_meta` keys the modern era is spelled in. */
31
+ export declare const MCP_META: {
32
+ readonly protocolVersion: "io.modelcontextprotocol/protocolVersion";
33
+ readonly clientCapabilities: "io.modelcontextprotocol/clientCapabilities";
34
+ readonly serverInfo: "io.modelcontextprotocol/serverInfo";
35
+ };
14
36
  import { type RateLimitOptions } from './rate-limit.js';
15
37
  export type ToolParam = {
16
38
  type: 'string' | 'number' | 'boolean' | 'array' | 'object';
@@ -122,6 +144,12 @@ export interface McpServerConfig {
122
144
  prompts?: McpPrompt[];
123
145
  /** Appended to the GET refusal so a browser that lands here learns where to go. */
124
146
  docsUrl?: string;
147
+ /**
148
+ * How long, in ms, a modern client may reuse `server/discover`, a list or a
149
+ * read. The modern era requires the hint on each of those. Defaults to five
150
+ * minutes: tool and prompt lists only change on deploy.
151
+ */
152
+ cacheTtlMs?: number;
125
153
  /**
126
154
  * Cap on JSON-RPC batch size. The rate limiter charges one token per HTTP
127
155
  * request, before the body is parsed — an unbounded batch would let a single
@@ -199,9 +227,16 @@ export declare const toolText: (id: RpcRequest["id"], text: string, isError?: bo
199
227
  }[];
200
228
  };
201
229
  };
230
+ /** The methods one era has and the other does not. Everything else is shared. */
231
+ declare const ERA_METHODS: {
232
+ readonly legacy: readonly ["initialize", "ping"];
233
+ readonly modern: readonly ["server/discover"];
234
+ };
235
+ type Era = keyof typeof ERA_METHODS;
202
236
  /** Everything the dispatcher needs that is not in the JSON-RPC entry itself. */
203
237
  interface Dispatch {
204
238
  protocolVersion: McpProtocolVersion;
239
+ era: Era;
205
240
  request?: Request;
206
241
  }
207
242
  /** Dispatch a parsed body. `null` means notification-only — answer 202, not 200. */
package/dist/mcp.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // mcp.ts — a minimal Model Context Protocol server over Streamable HTTP
2
- // (JSON-RPC). Spec: https://modelcontextprotocol.io/specification/2025-06-18
2
+ // (JSON-RPC), serving both protocol eras from one endpoint.
3
+ // Spec: https://modelcontextprotocol.io/specification/2026-07-28
3
4
  //
4
5
  // Web-standard `Request`/`Response` only, so this runs unchanged on Next route
5
6
  // handlers (NextResponse extends Response), Hono, Bun, Deno and workers.
@@ -13,31 +14,58 @@
13
14
  // A caller-fixable mistake is a tool result with `isError`, never a -32603.
14
15
  // That split is the one most implementations get wrong.
15
16
  /**
16
- * The versions this transport speaks, newest first.
17
+ * The modern era: every request carries its own version and client
18
+ * capabilities in `params._meta`. No handshake, no session, no `ping`, no batch.
19
+ */
20
+ export const MCP_MODERN_VERSIONS = ['2026-07-28'];
21
+ /**
22
+ * The legacy era, negotiated by an `initialize` handshake.
17
23
  *
18
- * This was a constant answered unconditionally, which is legal and still wrong:
19
- * a client asking for 2025-06-18 was told 2025-03-26 and silently gave up tool
20
- * titles, structured output and `_meta`. The last one is where a payment
21
- * receipt rides, so the one surface that needed it rebuilt this module's HTTP
22
- * shell by hand to reach it.
24
+ * Served beside the modern era rather than replaced by it. The spec lets one
25
+ * endpoint speak both, and a legacy client meeting a dual-era server works;
26
+ * going modern-only would fail the handshake of every client in the field.
27
+ */
28
+ export const MCP_LEGACY_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
29
+ /**
30
+ * Every version this transport speaks, newest first. It is the `supported` list
31
+ * a `-32022` names and the `supportedVersions` of `server/discover`, so a modern
32
+ * client that cannot use the newest still learns a legacy fallback exists.
23
33
  */
24
- export const MCP_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
25
- /** The newest version spoken here, and what an unrecognised ask falls back to. */
34
+ export const MCP_PROTOCOL_VERSIONS = [...MCP_MODERN_VERSIONS, ...MCP_LEGACY_VERSIONS];
35
+ /** The newest version spoken here. */
26
36
  export const MCP_PROTOCOL_VERSION = MCP_PROTOCOL_VERSIONS[0];
27
- /** What a request carrying no `MCP-Protocol-Version` header means, per the spec. */
37
+ /**
38
+ * The newest version `initialize` can negotiate, and what an unrecognised ask is
39
+ * offered. A handshake cannot land on a modern version — the modern era has no
40
+ * handshake — so this, not `MCP_PROTOCOL_VERSION`, is what a legacy client gets.
41
+ */
42
+ export const MCP_LEGACY_PROTOCOL_VERSION = MCP_LEGACY_VERSIONS[0];
43
+ /** The reserved `_meta` keys the modern era is spelled in. */
44
+ export const MCP_META = {
45
+ protocolVersion: 'io.modelcontextprotocol/protocolVersion',
46
+ clientCapabilities: 'io.modelcontextprotocol/clientCapabilities',
47
+ serverInfo: 'io.modelcontextprotocol/serverInfo',
48
+ };
49
+ /** What a legacy request carrying no `MCP-Protocol-Version` header means, per the spec. */
28
50
  const ASSUMED_VERSION = '2025-03-26';
29
- const speaks = (v) => MCP_PROTOCOL_VERSIONS.includes(v);
30
- /** Echo the client's version when it is one we speak, else offer the newest. */
51
+ const member = (list) => (v) => list.includes(v);
52
+ const speaksLegacy = member(MCP_LEGACY_VERSIONS);
53
+ const speaksModern = member(MCP_MODERN_VERSIONS);
54
+ /**
55
+ * Echo the client's version when it is one we speak, else offer the newest.
56
+ * Answering a constant instead is legal and still wrong: it silently held every
57
+ * caller below the version that carries structured output and `_meta`.
58
+ */
31
59
  function negotiateProtocol(requested) {
32
- return speaks(requested) ? requested : MCP_PROTOCOL_VERSION;
60
+ return speaksLegacy(requested) ? requested : MCP_LEGACY_PROTOCOL_VERSION;
33
61
  }
34
62
  /** The version a post-initialize request is operating under. */
35
63
  function requestProtocol(request) {
36
64
  const header = request.headers.get('mcp-protocol-version');
37
- return speaks(header) ? header : ASSUMED_VERSION;
65
+ return speaksLegacy(header) ? header : ASSUMED_VERSION;
38
66
  }
39
- /** JSON-RPC batching was removed in 2025-06-18; it stays legal below that. */
40
- const allowsBatch = (v) => v !== '2025-06-18';
67
+ /** JSON-RPC batching was removed in 2025-06-18 and stays removed; it is legal only below that. */
68
+ const allowsBatch = (v) => v === '2025-03-26' || v === '2024-11-05';
41
69
  import { clientKey, rateLimit, rateLimitHeaders, withRateLimit } from './rate-limit.js';
42
70
  /**
43
71
  * A caller-fixable failure inside a handler (page not found, empty input).
@@ -137,10 +165,16 @@ function validateArgs(tool, args) {
137
165
  `Expected schema: ${JSON.stringify(tool.inputSchema)}`,
138
166
  ].join('\n');
139
167
  }
140
- const BASE_METHODS = ['initialize', 'ping', 'tools/list', 'tools/call'];
141
- function methodsFor(config) {
168
+ /** The methods one era has and the other does not. Everything else is shared. */
169
+ const ERA_METHODS = {
170
+ legacy: ['initialize', 'ping'],
171
+ modern: ['server/discover'],
172
+ };
173
+ function methodsFor(config, era) {
142
174
  return [
143
- ...BASE_METHODS,
175
+ ...ERA_METHODS[era],
176
+ 'tools/list',
177
+ 'tools/call',
144
178
  // The list methods answer whether or not anything is registered: an empty
145
179
  // list is a better answer to a client that asked than a -32601 it has to
146
180
  // interpret.
@@ -151,11 +185,30 @@ function methodsFor(config) {
151
185
  ...(config.prompts?.length ? ['prompts/get'] : []),
152
186
  ];
153
187
  }
188
+ const methodNotFound = (id, method, config, era) => {
189
+ const methods = methodsFor(config, era);
190
+ return rpcError(id, -32601, `Method not found: "${method}". This server implements: ${quote(methods)}.`, { supportedMethods: methods });
191
+ };
192
+ /**
193
+ * Only the capabilities the config actually populates — an advertised
194
+ * `resources` whose list comes back empty reads as a bug to a client, not as
195
+ * honesty. One function, so `initialize` and `server/discover` cannot disagree.
196
+ */
197
+ const capabilitiesOf = (config) => ({
198
+ tools: {},
199
+ ...(config.resources?.length ? { resources: {} } : {}),
200
+ ...(config.prompts?.length ? { prompts: {} } : {}),
201
+ });
154
202
  async function handleRpc(req, config, dispatch) {
155
203
  const { id, method, params } = req;
156
204
  const p = (params ?? {});
157
205
  const resources = config.resources ?? [];
158
206
  const prompts = config.prompts ?? [];
207
+ // A method the other era owns is not found in this one, however well the
208
+ // server knows it: a modern `ping` is a removed method, not a pong.
209
+ const foreign = ERA_METHODS[dispatch.era === 'modern' ? 'legacy' : 'modern'];
210
+ if (foreign.includes(method))
211
+ return methodNotFound(id, method, config, dispatch.era);
159
212
  try {
160
213
  switch (method) {
161
214
  case 'initialize':
@@ -163,21 +216,24 @@ async function handleRpc(req, config, dispatch) {
163
216
  jsonrpc: '2.0',
164
217
  id,
165
218
  result: {
166
- // Echo what the client asked for when we speak it. Answering a
167
- // constant is what silently held every caller at 2025-03-26.
168
219
  protocolVersion: negotiateProtocol(p.protocolVersion),
169
- // Only advertise capabilities the config actually populates — an
170
- // advertised `resources` whose list comes back empty reads as a bug
171
- // to a client, not as honesty.
172
- capabilities: {
173
- tools: {},
174
- ...(resources.length ? { resources: {} } : {}),
175
- ...(prompts.length ? { prompts: {} } : {}),
176
- },
220
+ capabilities: capabilitiesOf(config),
177
221
  serverInfo: config.serverInfo,
178
222
  instructions: config.instructions,
179
223
  },
180
224
  };
225
+ // What `initialize` tells a legacy client, asked the modern way. The
226
+ // transport adds `resultType`, the cache hints and `_meta` serverInfo.
227
+ case 'server/discover':
228
+ return {
229
+ jsonrpc: '2.0',
230
+ id,
231
+ result: {
232
+ supportedVersions: [...MCP_PROTOCOL_VERSIONS],
233
+ capabilities: capabilitiesOf(config),
234
+ instructions: config.instructions,
235
+ },
236
+ };
181
237
  // Notifications have no response at all.
182
238
  case 'notifications/initialized':
183
239
  case 'notifications/cancelled':
@@ -306,10 +362,8 @@ async function handleRpc(req, config, dispatch) {
306
362
  return toolText(id, JSON.stringify({ error: err.message, ...err.details }, null, 2), true);
307
363
  }
308
364
  }
309
- default: {
310
- const methods = methodsFor(config);
311
- return rpcError(id, -32601, `Method not found: "${method}". This server implements: ${quote(methods)}.`, { supportedMethods: methods });
312
- }
365
+ default:
366
+ return methodNotFound(id, method, config, dispatch.era);
313
367
  }
314
368
  }
315
369
  catch (err) {
@@ -318,7 +372,7 @@ async function handleRpc(req, config, dispatch) {
318
372
  }
319
373
  }
320
374
  /** Dispatch a parsed body. `null` means notification-only — answer 202, not 200. */
321
- export async function handleMcp(body, config, dispatch = { protocolVersion: ASSUMED_VERSION }) {
375
+ export async function handleMcp(body, config, dispatch = { protocolVersion: ASSUMED_VERSION, era: 'legacy' }) {
322
376
  const maxBatch = config.maxBatch ?? DEFAULT_MAX_BATCH;
323
377
  const isBatch = Array.isArray(body);
324
378
  if (isBatch && body.length > maxBatch) {
@@ -337,12 +391,13 @@ export async function handleMcp(body, config, dispatch = { protocolVersion: ASSU
337
391
  // the spec's Origin-validation MUST exists to protect localhost servers from
338
392
  // DNS rebinding, which is the opposite situation. The allow-headers list
339
393
  // matters more than it looks — MCP clients preflight with `Accept` and
340
- // `Mcp-Protocol-Version`, and one missing entry fails the preflight, not the
341
- // POST, which reads as "the server is down".
394
+ // `Mcp-Protocol-Version` — and a modern client adds `Mcp-Method` and `Mcp-Name`
395
+ // on every call — and one missing entry fails the preflight, not the POST,
396
+ // which reads as "the server is down".
342
397
  export const MCP_CORS = {
343
398
  'Access-Control-Allow-Origin': '*',
344
399
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
345
- 'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID',
400
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Mcp-Method, Mcp-Name, Last-Event-ID',
346
401
  // Allow-Headers governs what a browser may send; without Expose-Headers it
347
402
  // may read none of what comes back. A browser client could not see the
348
403
  // negotiated version, and could not see `Retry-After` on the 429 telling it
@@ -386,7 +441,6 @@ export function mcpRateLimited(retryAfterSec, message) {
386
441
  }
387
442
  /** The whole POST leg: parse, gate, track, dispatch, and answer with the right status. */
388
443
  export async function mcpResponse(request, config) {
389
- const protocolVersion = requestProtocol(request);
390
444
  // Before `request.json()`: an unparsed body must not cost a query, which is
391
445
  // also why the refusal carries a null id.
392
446
  let headroom = {};
@@ -397,12 +451,10 @@ export async function mcpResponse(request, config) {
397
451
  }
398
452
  headroom = rateLimitHeaders(verdict, config.rateLimit);
399
453
  }
400
- // Echoed on every response so a client can see which version it is actually
401
- // being answered under, rather than inferring it from the initialize it sent
402
- // some requests ago. The headroom rides alongside on every answer, not only
403
- // on the 429 — a budget discoverable only by exceeding it is one an agent
404
- // meets when it is least able to act on it.
405
- const headers = { ...MCP_CORS, 'MCP-Protocol-Version': protocolVersion, ...headroom };
454
+ // The headroom rides on every answer, not only on the 429 — a budget
455
+ // discoverable only by exceeding it is one an agent meets when it is least
456
+ // able to act on it.
457
+ const base = { ...MCP_CORS, ...headroom };
406
458
  let body;
407
459
  try {
408
460
  body = await request.json();
@@ -410,20 +462,49 @@ export async function mcpResponse(request, config) {
410
462
  catch {
411
463
  return Response.json(rpcError(null, -32700, 'Parse error: request body is not valid JSON.'), {
412
464
  status: 400,
413
- headers,
465
+ headers: { ...base, 'MCP-Protocol-Version': requestProtocol(request) },
414
466
  });
415
467
  }
468
+ // Tracked before either era refuses anything: a probe the server turns away,
469
+ // and a call an agent walked away from behind a gate, are exactly the ones
470
+ // worth counting.
471
+ config.onCall?.(request, body);
472
+ return isModern(request, body)
473
+ ? modernResponse(request, body, config, base)
474
+ : legacyResponse(request, body, config, base);
475
+ }
476
+ /**
477
+ * Which era a POST belongs to. The spec's rule: modern `_meta` selects the modern
478
+ * era and `initialize` selects the legacy one. A modern version header and
479
+ * `server/discover` can only mean one thing too. Anything else is legacy,
480
+ * because a body without `_meta` is exactly what every client in the field sends.
481
+ */
482
+ function isModern(request, body) {
483
+ if (!body || typeof body !== 'object' || Array.isArray(body))
484
+ return false;
485
+ const { method, params } = body;
486
+ if (method === 'initialize')
487
+ return false;
488
+ const meta = params?._meta;
489
+ return ((!!meta && typeof meta === 'object' && MCP_META.protocolVersion in meta) ||
490
+ method === 'server/discover' ||
491
+ speaksModern(request.headers.get('mcp-protocol-version')));
492
+ }
493
+ async function dispatchThroughGate(body, config, dispatch) {
494
+ const gate = await config.gate?.(body);
495
+ const dispatched = gate?.empty ? null : await handleMcp(gate ? gate.body : body, config, dispatch);
496
+ return gate ? gate.finish(dispatched) : dispatched;
497
+ }
498
+ async function legacyResponse(request, body, config, base) {
499
+ const protocolVersion = requestProtocol(request);
500
+ // Echoed on every response so a client can see which version it is actually
501
+ // being answered under, rather than inferring it from the initialize it sent
502
+ // some requests ago.
503
+ const headers = { ...base, 'MCP-Protocol-Version': protocolVersion };
416
504
  if (Array.isArray(body) && !allowsBatch(protocolVersion)) {
417
505
  return Response.json(rpcError(null, -32600, `JSON-RPC batching was removed in MCP ${protocolVersion}. Send one request per POST, or negotiate ${ASSUMED_VERSION} to keep batching.`), { status: 400, headers });
418
506
  }
419
- // Tracked on the original body, before a gate withholds anything: a call an
420
- // agent walked away from is exactly the one worth counting.
421
- config.onCall?.(request, body);
422
- const gate = await config.gate?.(body);
423
- const dispatched = gate?.empty
424
- ? null
425
- : await handleMcp(gate ? gate.body : body, config, { protocolVersion, request });
426
- const result = gate ? await gate.finish(dispatched) : dispatched;
507
+ const result = await dispatchThroughGate(body, config, { protocolVersion, era: 'legacy', request });
427
508
  // Notification-only input produces no response bodies; the spec requires a
428
509
  // bare 202 there, not a 200 carrying a JSON `null`.
429
510
  if (result == null || (Array.isArray(result) && !result.length)) {
@@ -431,3 +512,125 @@ export async function mcpResponse(request, config) {
431
512
  }
432
513
  return Response.json(result, { headers });
433
514
  }
515
+ /** The modern era maps a protocol error to an HTTP status; the legacy era answered 200. */
516
+ const MODERN_STATUS = {
517
+ [-32601]: 404,
518
+ [-32602]: 400,
519
+ [-32020]: 400,
520
+ [-32021]: 400,
521
+ [-32022]: 400,
522
+ };
523
+ /** The results a modern client may cache. A call or a prompt is never among them. */
524
+ const CACHEABLE = new Set([
525
+ 'server/discover',
526
+ 'tools/list',
527
+ 'prompts/list',
528
+ 'resources/list',
529
+ 'resources/templates/list',
530
+ 'resources/read',
531
+ ]);
532
+ const DEFAULT_CACHE_TTL_MS = 300_000;
533
+ /** `Mcp-Name` carries the target of the three calls that have one, for a gateway to route on. */
534
+ const NAME_PARAM = {
535
+ 'tools/call': 'name',
536
+ 'prompts/get': 'name',
537
+ 'resources/read': 'uri',
538
+ };
539
+ /** A header value, decoded from the `=?base64?…?=` form a client uses for non-ASCII. */
540
+ function headerValue(raw) {
541
+ const encoded = raw?.match(/^=\?base64\?(.*)\?=$/i)?.[1];
542
+ if (encoded === undefined)
543
+ return raw;
544
+ try {
545
+ return new TextDecoder().decode(Uint8Array.from(atob(encoded), c => c.charCodeAt(0)));
546
+ }
547
+ catch {
548
+ return null;
549
+ }
550
+ }
551
+ /** The first routing header that disagrees with the body, described. */
552
+ function headerMismatch(request, method, params, version) {
553
+ const expected = [
554
+ ['MCP-Protocol-Version', version],
555
+ ['Mcp-Method', method],
556
+ ];
557
+ const nameKey = NAME_PARAM[method];
558
+ // A call missing its name is reported by the dispatcher, which can list the names.
559
+ if (nameKey && typeof params[nameKey] === 'string')
560
+ expected.push(['Mcp-Name', params[nameKey]]);
561
+ for (const [name, want] of expected) {
562
+ const got = headerValue(request.headers.get(name));
563
+ if (got !== want) {
564
+ return `Header ${name} must equal ${JSON.stringify(want)}; received ${got === null ? 'none' : JSON.stringify(got)}.`;
565
+ }
566
+ }
567
+ return null;
568
+ }
569
+ /**
570
+ * A modern result: `resultType` on every one, cache hints on the cacheable ones,
571
+ * and serverInfo in `_meta` now that no handshake carries it. Applied after the
572
+ * gate, so an answer a gate builds in place of a call is shaped the same way.
573
+ */
574
+ function modernResult(response, method, config) {
575
+ if (!('result' in response))
576
+ return response;
577
+ const result = response.result;
578
+ return {
579
+ ...response,
580
+ result: {
581
+ resultType: 'complete',
582
+ ...result,
583
+ ...(CACHEABLE.has(method)
584
+ ? {
585
+ ttlMs: config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS,
586
+ // Nothing here varies by caller, except a read a gate may have
587
+ // charged for, which a shared cache must not hand to the next one.
588
+ cacheScope: method === 'resources/read' ? 'private' : 'public',
589
+ }
590
+ : {}),
591
+ _meta: { ...result._meta, [MCP_META.serverInfo]: config.serverInfo },
592
+ },
593
+ };
594
+ }
595
+ async function modernResponse(request, body, config, base) {
596
+ const params = (body.params ?? {});
597
+ const meta = (params._meta ?? {});
598
+ const requested = meta[MCP_META.protocolVersion];
599
+ const headers = {
600
+ ...base,
601
+ 'MCP-Protocol-Version': speaksModern(requested) ? requested : MCP_MODERN_VERSIONS[0],
602
+ };
603
+ const refuse = (code, message, data) => Response.json(rpcError(body.id ?? null, code, message, data), {
604
+ status: MODERN_STATUS[code] ?? 400,
605
+ headers,
606
+ });
607
+ if (typeof requested !== 'string') {
608
+ return refuse(-32602, `Missing params._meta["${MCP_META.protocolVersion}"]. A ${MCP_MODERN_VERSIONS[0]} request carries its version and client capabilities on every call; to use ${MCP_LEGACY_PROTOCOL_VERSION} instead, send "initialize".`);
609
+ }
610
+ if (!speaksModern(requested)) {
611
+ return refuse(-32022, 'Unsupported protocol version', {
612
+ supported: [...MCP_PROTOCOL_VERSIONS],
613
+ requested,
614
+ });
615
+ }
616
+ const capabilities = meta[MCP_META.clientCapabilities];
617
+ if (!capabilities || typeof capabilities !== 'object' || Array.isArray(capabilities)) {
618
+ return refuse(-32602, `Missing params._meta["${MCP_META.clientCapabilities}"]. Send {} when the client declares none.`);
619
+ }
620
+ const mismatch = headerMismatch(request, body.method, params, requested);
621
+ if (mismatch)
622
+ return refuse(-32020, mismatch);
623
+ const result = await dispatchThroughGate(body, config, {
624
+ protocolVersion: requested,
625
+ era: 'modern',
626
+ request,
627
+ });
628
+ if (result == null)
629
+ return new Response(null, { status: 202, headers });
630
+ const response = modernResult(result, body.method, config);
631
+ const code = response.error?.code;
632
+ return Response.json(response, {
633
+ status: (code !== undefined && MODERN_STATUS[code]) || 200,
634
+ headers,
635
+ });
636
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, and conditional-GET plumbing.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -193,6 +193,7 @@
193
193
  "@types/node": "^22.0.0",
194
194
  "@types/react": "^19.0.0",
195
195
  "jose": "^6.2.10",
196
+ "jsdom": "^30.1.0",
196
197
  "react": "^19.2.4",
197
198
  "typescript": "^5.9.0"
198
199
  },
@@ -200,18 +201,18 @@
200
201
  "@radixdlt/rola": ">=2",
201
202
  "@tiptap/core": ">=2.10",
202
203
  "@tiptap/extension-code-block": ">=2.10",
203
- "@tiptap/extension-youtube": ">=2.10",
204
- "@tiptap/react": ">=2.10",
205
- "jose": ">=5",
206
- "react": ">=18",
207
- "@tiptap/starter-kit": ">=2.10",
208
- "@tiptap/extension-link": ">=2.10",
209
204
  "@tiptap/extension-image": ">=2.10",
205
+ "@tiptap/extension-link": ">=2.10",
206
+ "@tiptap/extension-placeholder": ">=2.10",
210
207
  "@tiptap/extension-table": ">=2.10",
211
- "@tiptap/extension-table-row": ">=2.10",
212
208
  "@tiptap/extension-table-cell": ">=2.10",
213
209
  "@tiptap/extension-table-header": ">=2.10",
214
- "@tiptap/extension-placeholder": ">=2.10"
210
+ "@tiptap/extension-table-row": ">=2.10",
211
+ "@tiptap/extension-youtube": ">=2.10",
212
+ "@tiptap/react": ">=2.10",
213
+ "@tiptap/starter-kit": ">=2.10",
214
+ "jose": ">=5",
215
+ "react": ">=18"
215
216
  },
216
217
  "peerDependenciesMeta": {
217
218
  "react": {