wiki-formant 0.17.1 → 0.19.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.
@@ -18,11 +18,20 @@
18
18
  // are utilities is derived, not listed — anything the build emits that globals.css
19
19
  // does not define is Tailwind's.
20
20
  //
21
+ // And the reverse: a selector globals.css defines that nothing the build ships
22
+ // references. The shared blocks and chrome render their markup inside
23
+ // wiki-formant while each app styles it, so references are read from the build
24
+ // output, which holds this repo's code, the package's components and bundled
25
+ // libraries alike. A class renamed in the package surfaces here as its old
26
+ // selector going unreferenced. HTML stored in a database is not in the build,
27
+ // so a selector only stored content uses is reported too.
28
+ //
21
29
  // npx check-classes # exit 1 on any dead token
22
30
  // npx check-classes --warn # report and exit 0
23
31
  // npx check-classes --compositions # also fail on 3+ inline utilities
32
+ // npx check-classes --unused # also fail on unreferenced selectors
24
33
  //
25
- // Run it from the repo root: `.next/static` and `src` are resolved against cwd.
34
+ // Run it from the repo root: `.next` and `src` are resolved against cwd.
26
35
  //
27
36
  // Needs a build first (npm run build): without one there is nothing to check
28
37
  // against, and the script says so and exits 0 rather than failing blind.
@@ -30,7 +39,7 @@ import fs from 'node:fs';
30
39
  import path from 'node:path';
31
40
 
32
41
  const WARN_ONLY = process.argv.includes('--warn');
33
- const CSS_DIR = '.next/static';
42
+ const STATIC_DIR = '.next/static';
34
43
  const SRC_DIR = 'src';
35
44
 
36
45
  const walk = (dir, ext, out = []) => {
@@ -43,7 +52,7 @@ const walk = (dir, ext, out = []) => {
43
52
  return out;
44
53
  };
45
54
 
46
- const cssFiles = walk(CSS_DIR, '.css');
55
+ const cssFiles = walk(STATIC_DIR, '.css');
47
56
  if (!cssFiles.length) {
48
57
  console.log('check-classes: no built CSS under .next/static — run `npm run build` first. Skipping.');
49
58
  process.exit(0);
@@ -59,12 +68,18 @@ for (const f of cssFiles) {
59
68
 
60
69
  // Classes this project defines itself. Everything else the build emitted is a
61
70
  // Tailwind utility, which is what makes the composition count derivable rather
62
- // than a maintained prefix list.
71
+ // than a maintained prefix list. Read from rule preludes only, so a decimal in a
72
+ // value or a class named in a comment is not taken for a selector.
63
73
  const globalsFile = walk(SRC_DIR, '.css').find(f => f.endsWith('globals.css'));
64
74
  const named = new Set();
65
75
  if (globalsFile) {
66
- for (const m of fs.readFileSync(globalsFile, 'utf8').matchAll(/\.((?:\\.|[-\w])+)/g)) {
67
- named.add(m[1].replace(/\\/g, ''));
76
+ const css = fs.readFileSync(globalsFile, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
77
+ for (const [, prelude] of css.matchAll(/([^{};]*)\{/g)) {
78
+ if (prelude.trim().startsWith('@')) continue;
79
+ for (const m of prelude.matchAll(/\.((?:\\.|[-\w])+)/g)) {
80
+ const name = m[1].replace(/\\/g, '');
81
+ if (/^[A-Za-z_-]/.test(name)) named.add(name);
82
+ }
68
83
  }
69
84
  }
70
85
 
@@ -92,6 +107,7 @@ for (const f of walk(SRC_DIR, '.tsx')) {
92
107
  }
93
108
 
94
109
  const CHECK_COMPOSITIONS = process.argv.includes('--compositions');
110
+ const CHECK_UNUSED = process.argv.includes('--unused');
95
111
 
96
112
  if (compositions.length) {
97
113
  const verb = CHECK_COMPOSITIONS ? 'must be named' : 'should be named (advisory)';
@@ -103,9 +119,30 @@ if (compositions.length) {
103
119
  console.error('');
104
120
  }
105
121
 
122
+ // Every token in the JavaScript the build ships, server and client. A selector
123
+ // is referenced when its name appears whole, or when a prefix of it ending in `-`
124
+ // or `_` does, which is how `editorial-banner-${variant}` is written.
125
+ const shipped = new Set();
126
+ for (const f of [...walk(STATIC_DIR, '.js'), ...walk('.next/server', '.js')]) {
127
+ for (const [tok] of fs.readFileSync(f, 'utf8').matchAll(/[\w-]+/g)) shipped.add(tok);
128
+ }
129
+ const referenced = name =>
130
+ shipped.has(name) ||
131
+ [...name].some((c, i) => i >= 3 && (c === '-' || c === '_') && shipped.has(name.slice(0, i + 1)));
132
+ const unused = [...named].filter(n => !referenced(n)).sort();
133
+
134
+ if (unused.length) {
135
+ const verb = CHECK_UNUSED ? 'must be deleted' : 'advisory';
136
+ console.error(`check-classes: ${unused.length} selector(s) in ${globalsFile} referenced by nothing the build ships (${verb}):\n`);
137
+ console.error(` ${unused.join(', ')}\n`);
138
+ console.error(' Class names inside HTML stored in a database do not count, so check stored content before deleting one.\n');
139
+ }
140
+
141
+ const advisoryFailed = (CHECK_COMPOSITIONS && compositions.length > 0) || (CHECK_UNUSED && unused.length > 0);
142
+
106
143
  if (!dead.size) {
107
144
  console.log(`check-classes: clean — every className token resolves against ${emitted.size} emitted selectors.`);
108
- process.exit(CHECK_COMPOSITIONS && compositions.length && !WARN_ONLY ? 1 : 0);
145
+ process.exit(advisoryFailed && !WARN_ONLY ? 1 : 0);
109
146
  }
110
147
 
111
148
  console.error(`check-classes: ${dead.size} className token(s) style nothing:\n`);
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- // block-views.tsx — the block renderers both wikis had written twice.
3
+ // block-views.tsx — the block leaves the wikis share.
4
4
  //
5
5
  // Behind its own subpath for the reason `react.tsx` is: React is an OPTIONAL
6
6
  // peer, so a consumer that only wants the taxonomy or the MCP transport still
@@ -10,13 +10,12 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
10
10
  // that way (see the Block Model note in the workspace CLAUDE.md) — a closed
11
11
  // union is what makes `switch (block.type)` exhaustive, so a new block type is
12
12
  // a compile error rather than a silent blank. What is NOT per-repo is what a
13
- // codeTabs or a linkGrid LOOKS like once you have dispatched to it: those were
14
- // byte-identical in both repos, down to the class names. So the dispatch stays
15
- // with the caller and the leaves move here.
13
+ // codeTabs or a linkGrid LOOKS like once you have dispatched to it, down to the
14
+ // class names. So the dispatch stays with the caller and the leaves live here.
16
15
  //
17
- // The class names are NOT props. Like the rail's `wiki-rail__*` tree they are
18
- // the shared convention both stylesheets already implement, and making them
19
- // configurable would only let that convention fork again. Everything that
16
+ // The class names are NOT props. They are the shared convention every
17
+ // stylesheet implements, and making them configurable would let that convention
18
+ // fork. Everything that
20
19
  // genuinely differs — the prose of a banner, whether references run through an
21
20
  // HTML processor, the router's link — arrives as a prop.
22
21
  import { Fragment, useState } from 'react';
package/dist/combobox.js CHANGED
@@ -4,21 +4,12 @@
4
4
  // pure, so it can be tested without a DOM, and `react.tsx` is left holding only
5
5
  // the hook that calls it.
6
6
  //
7
- // `useTypeahead` already shared the state machine across five surfaces in three
8
- // wikis. The ARIA did not travel with it, and all five had drifted into
9
- // different wrongness:
10
- //
11
- // - one put `aria-selected` on a plain `<button>`, which is not a role that
12
- // takes it, and gave the container no `role="listbox"` at all;
13
- // - one gave the rows `role="option"` but still no listbox, so the options
14
- // had no owner;
15
- // - two had no roles whatsoever;
16
- // - one used a `data-highlighted` attribute, which no assistive technology
17
- // reads.
18
- //
19
- // None of the five set `aria-activedescendant`, which is the attribute that
20
- // actually announces the highlighted row as the reader arrows through it. A
21
- // combobox without it is a text field that silently changes what Enter does.
7
+ // `useTypeahead` shares the state machine; this is the ARIA that goes with it:
8
+ // a `role="listbox"` container that owns `role="option"` rows, `aria-selected`
9
+ // on the option rather than on a plain `<button>`, and `aria-activedescendant`,
10
+ // the attribute that announces the highlighted row as the reader arrows
11
+ // through it. A combobox without it is a text field that silently changes what
12
+ // Enter does.
22
13
  /** The id of one option row. Exported because a caller that scrolls the
23
14
  * highlighted row into view has to find it by the same id the input points at. */
24
15
  export function optionId(baseId, listKey, index) {
@@ -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,12 +15,11 @@
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
  *
22
- * Derived rather than listed: the assertion used to name three headers by hand,
23
- * so a fourth added to `MCP_CORS` was tested by nobody.
22
+ * Derived rather than listed, so a header added to `MCP_CORS` is tested too.
24
23
  */
25
24
  const corsCovers = (res, header) => {
26
25
  const live = res.headers.get(header) ?? '';
@@ -112,11 +111,11 @@ expectedCapabilities = ['tools']) {
112
111
  // 2025-03-26 — forfeiting structured output, tool titles and `_meta` — while
113
112
  // every suite reported green.
114
113
  const echoed = await t.rpc('initialize', {
115
- protocolVersion: MCP_PROTOCOL_VERSION,
114
+ protocolVersion: MCP_LEGACY_PROTOCOL_VERSION,
116
115
  capabilities: {},
117
116
  clientInfo: { name: clientName, version: '1' },
118
117
  });
119
- 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}`);
120
119
  t.check('downgrades gracefully', (init.result?.protocolVersion ?? '').length > 0, `asked 2024-11-05, got ${init.result?.protocolVersion}`);
121
120
  console.log(`\n=== transport ===`);
122
121
  const opt = await fetch(t.endpoint, { method: 'OPTIONS' });
@@ -157,19 +156,67 @@ expectedCapabilities = ['tools']) {
157
156
  t.check('resources/templates/list', Array.isArray(templates.result?.resourceTemplates), templates.error ? `-${templates.error.code}` : `${templates.result?.resourceTemplates?.length} templates`);
158
157
  }
159
158
  const versioned = await rawPost(t.endpoint, { jsonrpc: '2.0', id: 1, method: 'ping' }, {
160
- 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
159
+ 'MCP-Protocol-Version': MCP_LEGACY_PROTOCOL_VERSION,
161
160
  });
162
161
  t.recordCall();
163
- 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')}`);
164
163
  // Batching was removed in 2025-06-18. A server that keeps honouring it under
165
164
  // a version that forbids it is telling the client something untrue.
166
165
  const batched = await rawPost(t.endpoint, [{ jsonrpc: '2.0', id: 1, method: 'ping' }], {
167
- 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
166
+ 'MCP-Protocol-Version': MCP_LEGACY_PROTOCOL_VERSION,
168
167
  });
169
168
  t.recordCall();
170
- 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);
171
171
  return init;
172
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
+ }
173
220
  /**
174
221
  * One service, many descriptors — server.json, the two agent-card paths, the
175
222
  * OpenAPI document, the MCP server card, and `initialize` — should never
@@ -306,10 +353,9 @@ export async function annotationChecks(t, opts = {}) {
306
353
  */
307
354
  export async function payloadBudget(t, calls, maxBytes = 120_000) {
308
355
  // Every read-only tool a caller can invoke with no arguments at all, whether
309
- // or not the suite thought to name it. This check used to weigh only the
310
- // listed calls, which is how a tool taking no parameters and answering with
311
- // 3.3 MB — 27× the budget it was exempt from — passed a suite that measured
312
- // the four tools beside it. A tool with required arguments still has to be
356
+ // or not the suite thought to name it, so a no-argument tool answering with
357
+ // megabytes cannot pass by going unlisted. A tool with required arguments
358
+ // still has to be
313
359
  // listed: the suite is the only thing that knows a valid pair.
314
360
  const listed = new Set(calls.map(c => c.name));
315
361
  const bare = ((await t.rpc('tools/list')).result?.tools ?? [])
package/dist/crawlers.js CHANGED
@@ -1,22 +1,16 @@
1
1
  // crawlers.ts — one roster of AI crawler tokens, for both surfaces that need it.
2
2
  //
3
- // Every wiki in the workspace kept this list twice: once in the proxy, keyed by
4
- // user-agent substring, to count an "AI Bot Visit"; and once in `robots.ts`, as
5
- // the set of agents that get their own group. The two copies were each
6
- // byte-identical across all three repos — and they were different lists.
3
+ // The proxy counts an "AI Bot Visit" by user-agent substring, and `robots.ts`
4
+ // gives each agent its own group. Both read this one roster.
7
5
  //
8
- // Only one direction of that difference was deliberate. `Applebot-Extended`
6
+ // One difference between the two uses is deliberate. `Applebot-Extended`
9
7
  // never fetches a page: it is a robots.txt-only token that Applebot consults
10
8
  // before using already-crawled data for AI, so counting it would count nothing.
11
9
  // That belongs in robots and not in the matcher, and it is declared here.
12
10
  //
13
- // The other direction was not deliberate. Bytespider, CCBot, cohere-ai,
14
- // Claude-Web and Meta-ExternalFetcher were matched by every proxy and named by
15
- // no robots.txt — and a crawler obeys only its most-specific matching group, so
16
- // an agent with no group of its own falls through to `*` and is granted
17
- // whatever that grants. The wikis were measuring five crawlers they had never
18
- // addressed. One roster makes that a property of the data rather than of which
19
- // file you happened to edit.
11
+ // Every other token appears in both, because a crawler obeys only its
12
+ // most-specific matching group: an agent with no group of its own falls through
13
+ // to `*` and is granted whatever that grants.
20
14
  /**
21
15
  * Order is significant: `detectAiBot` returns the first token the user agent
22
16
  * contains, so a token that is a substring of another must come after it.
package/dist/dom.d.ts CHANGED
@@ -19,8 +19,7 @@ export interface CopyButtonOptions {
19
19
  export declare function addCopyButtons(root: ParentNode, options?: CopyButtonOptions): number;
20
20
  /**
21
21
  * The one place this origin is written down. It is both the embed host and the
22
- * allow-list the resize listener checks, and it was previously spelled out at
23
- * four call sites across the two wikis.
22
+ * allow-list the resize listener checks.
24
23
  */
25
24
  export declare const TWITTER_ORIGIN = "https://platform.twitter.com";
26
25
  /** The embed iframe's src. `dnt=true` opts the embed out of Twitter's tracking. */
package/dist/dom.js CHANGED
@@ -55,8 +55,7 @@ export function addCopyButtons(root, options = {}) {
55
55
  // ---- twitter embeds ---------------------------------------------------------
56
56
  /**
57
57
  * The one place this origin is written down. It is both the embed host and the
58
- * allow-list the resize listener checks, and it was previously spelled out at
59
- * four call sites across the two wikis.
58
+ * allow-list the resize listener checks.
60
59
  */
61
60
  export const TWITTER_ORIGIN = 'https://platform.twitter.com';
62
61
  /** The embed iframe's src. `dnt=true` opts the embed out of Twitter's tracking. */
package/dist/editor.d.ts CHANGED
@@ -5,8 +5,7 @@ import { type ChangeEvent, type RefObject } from 'react';
5
5
  *
6
6
  * Each of these packages ships a `declare module '@tiptap/core'` block that adds
7
7
  * its commands to `ChainedCommands` — `toggleBold`, `insertTable`, `setLink`,
8
- * `setImage`. A consumer that imported the extensions itself picked those up as
9
- * a side effect of the import; now that this module owns them, it has to carry
8
+ * `setImage`. Because this module owns the extensions, it has to carry
10
9
  * the augmentation across the package boundary, and a re-exported type is what
11
10
  * makes the emitted `.d.ts` load the module that declares it. Without these
12
11
  * four lines every `editor.chain().focus().toggleBold()` in every consumer
@@ -16,19 +15,6 @@ export type { StarterKitOptions } from '@tiptap/starter-kit';
16
15
  export type { LinkOptions } from '@tiptap/extension-link';
17
16
  export type { ImageOptions } from '@tiptap/extension-image';
18
17
  export type { TableOptions } from '@tiptap/extension-table';
19
- /**
20
- * Reduce pasted HTML to structure plus the three attributes that carry meaning.
21
- *
22
- * A paste from a word processor or a web page arrives carrying its whole
23
- * stylesheet inline. Keeping any of it means the wiki's own typography loses to
24
- * whatever the author copied from, per paragraph, invisibly — and `style` on a
25
- * pasted node is also the cheapest way to smuggle a full-bleed overlay into a
26
- * page body.
27
- *
28
- * Browser-only: it parses with `DOMParser`. Called from `transformPastedHTML`,
29
- * which only ever runs in response to a paste.
30
- */
31
- export declare function cleanPastedHtml(html: string): string;
32
18
  export interface WikiEditorExtensionOptions {
33
19
  /** Empty-document prompt. */
34
20
  placeholder?: string;
@@ -42,15 +28,6 @@ export interface WikiEditorExtensionOptions {
42
28
  */
43
29
  nodes?: readonly AnyExtension[];
44
30
  }
45
- /**
46
- * The editor's extension set.
47
- *
48
- * `codeBlock: false` on StarterKit is load-bearing: the consumer registers its
49
- * own via `createCodeBlock`, and leaving StarterKit's in place would give the
50
- * schema two nodes claiming the same name. Headings stop at h2 — the page title
51
- * is the only h1 a wiki page has.
52
- */
53
- export declare function wikiEditorExtensions({ placeholder, nodes, }?: WikiEditorExtensionOptions): AnyExtension[];
54
31
  /**
55
32
  * Turn a pasted URL into the richest node that fits it, falling back to a bare
56
33
  * iframe. Order matters: a YouTube URL is also a valid iframe source, so the
package/dist/editor.js CHANGED
@@ -4,25 +4,16 @@
4
4
  // `tiptap.tsx` holds the custom NODES both wikis needed (iframe, tweet, map,
5
5
  // code block, tabs). This holds what sits around them: which extensions are
6
6
  // configured how, what a paste is scrubbed down to, how a pasted URL becomes
7
- // the right embed, and the state a toolbar reads. Those were byte-identical in
8
- // both repos — the same twelve-entry extension array, the same paste scrubber,
9
- // the same embed dispatch including the shortened-map async swap.
7
+ // the right embed, and the state a toolbar reads.
10
8
  //
11
- // TWO BUGS ARE FIXED HERE RATHER THAN PROPAGATED, and the reason this file
12
- // exists at all is that each repo had exactly one of them:
9
+ // TWO GUARDS HERE ARE LOAD-BEARING:
13
10
  //
14
- // 1. `onChangeRef.current = onChange` was written DURING render in one copy.
15
- // A ref mutated mid-render can tear under concurrent rendering. It is
16
- // written in an effect here, and only read from events and timeouts, which
17
- // run later.
18
- // 2. The other copy had no `onBlur`. The change is debounced 150ms, and
19
- // clicking Save blurs the editor before the click lands — so the final
20
- // keystroke of every edit that ended in a click was dropped. Blur flushes
21
- // the pending debounce.
22
- //
23
- // Neither repo was "behind": each had shipped the fix the other lacked, which
24
- // is the drift that costs the most, because neither file looks like the one to
25
- // fix.
11
+ // 1. `onChangeRef.current = onChange` is written in an effect, never during
12
+ // render: a ref mutated mid-render can tear under concurrent rendering.
13
+ // It is only read from events and timeouts, which run later.
14
+ // 2. Blur flushes the pending debounce. The change is debounced 150ms, and
15
+ // clicking Save blurs the editor before the click lands, so without the
16
+ // flush the final keystroke of an edit that ends in a click is dropped.
26
17
  //
27
18
  // Every @tiptap package here is an OPTIONAL PEER. A consumer that only wants
28
19
  // the taxonomy or the MCP transport installs none of them.
@@ -50,7 +41,7 @@ import { toMapEmbedUrl } from './maps.js';
50
41
  * Browser-only: it parses with `DOMParser`. Called from `transformPastedHTML`,
51
42
  * which only ever runs in response to a paste.
52
43
  */
53
- export function cleanPastedHtml(html) {
44
+ function cleanPastedHtml(html) {
54
45
  const doc = new DOMParser().parseFromString(html, 'text/html');
55
46
  doc.querySelectorAll('style, script, meta, link, svg, canvas, noscript').forEach(el => el.remove());
56
47
  doc.querySelectorAll('*').forEach(el => {
@@ -72,7 +63,7 @@ export function cleanPastedHtml(html) {
72
63
  * schema two nodes claiming the same name. Headings stop at h2 — the page title
73
64
  * is the only h1 a wiki page has.
74
65
  */
75
- export function wikiEditorExtensions({ placeholder = '', nodes = [], } = {}) {
66
+ function wikiEditorExtensions({ placeholder = '', nodes = [], } = {}) {
76
67
  return [
77
68
  StarterKit.configure({ heading: { levels: [2, 3, 4] }, codeBlock: false }),
78
69
  TiptapLink.configure({ openOnClick: false, HTMLAttributes: { class: 'link' } }),
package/dist/headings.js CHANGED
@@ -1,12 +1,6 @@
1
1
  // headings.ts — stable ids on a wiki page's headings, and the list a table of
2
2
  // contents is built from.
3
3
  //
4
- // Two of the three wikis had already written this out (caper's `injectHeadingIds`,
5
- // radix-wiki's heading branch in `processHtml`), down to byte-identical
6
- // `stripTags` and `getAttr` helpers — caper's file says "ported in spirit from
7
- // radix-wiki" at the top, which is the drift admitting itself. What they had
8
- // drifted on is below.
9
- //
10
4
  // The slug rule is a parameter, not a decision this module makes. A heading id
11
5
  // is a live URL: readers link to `#the-shape-of-a-code`, and so does the page's
12
6
  // own permalink anchor. Unifying two slug rules would silently move every
@@ -15,8 +9,7 @@
15
9
  //
16
10
  // Deduping, by contrast, is not a choice: two headings with the same text
17
11
  // otherwise mint the same id twice and every link to the second one lands on
18
- // the first. That was already a bug in the copy that lacked it, so this always
19
- // dedupes.
12
+ // the first, so this always dedupes.
20
13
  import { getAttr, stripTags } from './html.js';
21
14
  /** The default slug rule: lowercase words joined by hyphens. */
22
15
  export function slugifyHeading(text) {
@@ -1,18 +1,15 @@
1
- // link-check.ts — the dead-link probe both wikis' sweep scripts had written.
1
+ // link-check.ts — the dead-link probe wiki sweep scripts share.
2
2
  //
3
3
  // Node-only (fetch, AbortSignal, URL). No framework, no database: what a
4
4
  // checker does with the verdicts — which pages to walk, what counts as an
5
5
  // internal path, how to report — stays with the caller, because that is the
6
6
  // half that is genuinely per-wiki.
7
7
  //
8
- // EVERY GUARD BELOW WAS PAID FOR BY A FALSE POSITIVE, and the two copies had
9
- // each learned a different half of the lesson. One knew that npmjs.com 403s
10
- // scripted requests, that an expired certificate is not a dead host, and that a
11
- // YouTube /embed/ URL answers 200 for a deleted video. The other knew that a
12
- // connect refusal is usually concurrency rather than death, and that
13
- // serialising per hostname is what fixes it. Neither copy was behind. A sweep
14
- // running either one alone strips good citations for reasons the other repo had
15
- // already written down — which is the entire argument for this file.
8
+ // EVERY GUARD BELOW WAS PAID FOR BY A FALSE POSITIVE. npmjs.com 403s scripted
9
+ // requests, an expired certificate is not a dead host, a YouTube /embed/ URL
10
+ // answers 200 for a deleted video, and a connect refusal is usually concurrency
11
+ // rather than death, which serialising per hostname fixes. A sweep missing any
12
+ // one of these strips good citations.
16
13
  import { stripTags } from './html.js';
17
14
  const DEFAULTS = { timeoutMs: 12_000, slowTimeoutMs: 40_000 };
18
15
  /**
package/dist/maps.d.ts CHANGED
@@ -3,8 +3,6 @@ export interface MapCoords {
3
3
  lon: number;
4
4
  zoom?: number | undefined;
5
5
  }
6
- /** A plain embed URL for a coordinate pair. */
7
- export declare function mapsEmbedUrl(lat: number, lon: number, zoom?: number): string;
8
6
  /**
9
7
  * Dig a coordinate pair out of a maps URL. Four shapes in descending
10
8
  * specificity: the `@lat,lon,zoom` path segment, a `/search/lat,lon`, the
package/dist/maps.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // pure string work over Google and Apple Maps URL shapes, with no framework or
5
5
  // database in it, which is why neither copy had a reason to diverge.
6
6
  /** A plain embed URL for a coordinate pair. */
7
- export function mapsEmbedUrl(lat, lon, zoom = 15) {
7
+ function mapsEmbedUrl(lat, lon, zoom = 15) {
8
8
  return `https://maps.google.com/maps?q=${lat},${lon}&z=${zoom}&output=embed`;
9
9
  }
10
10
  /**
package/dist/mcp.d.ts CHANGED
@@ -1,20 +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;
14
- /** Echo the client's version when it is one we speak, else offer the newest. */
15
- export declare function negotiateProtocol(requested: unknown): McpProtocolVersion;
16
- /** The version a post-initialize request is operating under. */
17
- export declare function requestProtocol(request: Request): 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
+ };
18
36
  import { type RateLimitOptions } from './rate-limit.js';
19
37
  export type ToolParam = {
20
38
  type: 'string' | 'number' | 'boolean' | 'array' | 'object';
@@ -126,6 +144,12 @@ export interface McpServerConfig {
126
144
  prompts?: McpPrompt[];
127
145
  /** Appended to the GET refusal so a browser that lands here learns where to go. */
128
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;
129
153
  /**
130
154
  * Cap on JSON-RPC batch size. The rate limiter charges one token per HTTP
131
155
  * request, before the body is parsed — an unbounded batch would let a single
@@ -203,9 +227,16 @@ export declare const toolText: (id: RpcRequest["id"], text: string, isError?: bo
203
227
  }[];
204
228
  };
205
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;
206
236
  /** Everything the dispatcher needs that is not in the JSON-RPC entry itself. */
207
237
  interface Dispatch {
208
238
  protocolVersion: McpProtocolVersion;
239
+ era: Era;
209
240
  request?: Request;
210
241
  }
211
242
  /** Dispatch a parsed body. `null` means notification-only — answer 202, not 200. */