wiki-formant 0.67.0 → 0.70.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
@@ -79,7 +79,7 @@ const view = await categoryView({ tree, taxonomy, pages: store }, 'ecosystem', s
79
79
 
80
80
  ## Wiki read tools
81
81
 
82
- `wikiReadTools` is the read half of a wiki's MCP server over its store and tree: `search_wiki`, `get_page`, `get_pages`, `list_pages`, `get_categories`, `get_recent_changes` and `get_full_corpus`. `searchWiki` is the same rows for an app's plain-GET `?q=` and its search page, so the three never disagree.
82
+ `wikiReadTools` is the read half of a wiki's MCP server over its store and tree: `search_wiki`, `get_page`, `get_pages`, `list_pages`, `get_categories`, `get_recent_changes`, `get_full_corpus` and `get_page_history`. `searchWiki` is the same rows for an app's plain-GET `?q=` and its search page, so the three never disagree.
83
83
 
84
84
  ```ts
85
85
  import { wikiReadTools, searchWiki } from 'wiki-formant/wiki-tools';
@@ -90,6 +90,8 @@ const { items, total } = await searchWiki(reading, 'validators', { page: 1, size
90
90
  ```
91
91
 
92
92
  - Every row carries `path`, which every read tool takes back, and `chars` where the page's text was read. A pasted URL resolves, with `pathPrefix` stripped; a category's path reads its hub article; a miss names the nearest pages.
93
+ - Every row carries the page `id` too, which a `pageList` block takes. `get_page` with `blocks: true` returns the stored blocks, the page `id`, its whole metadata and `version`: what an edit starts from. `forEdit` adds what the app's write tool takes beside them, and `authors` names each revision's author in `get_page_history`.
94
+ - `text` should be the serializer the page's markdown twin uses, and `resolve` the resolver its page uses to fill what is read live (a page list's pages): `get_page`, `get_pages` and the default corpus read text through both, so a page reads the same over MCP as at its twin, which `twinParity` checks.
93
95
  - `privateHidden` closes the back office to every tool; without it a hidden page is unlisted but readable by path, as its URL is.
94
96
  - `metadata`, `label`, `corpus`, `noMatch` and `skills` are the app's: what a row adds, how a category is named, the sections its llms-full.txt is made of, what an empty search says, and the A2A skills.
95
97
  - Writes stay with each app, because who may write is the one thing no two of them agree on.
@@ -248,6 +250,16 @@ process.exit(t.summary());
248
250
 
249
251
  What stays in your repo is fixtures: which tools you expect, what a good answer from each looks like, and which text surfaces you publish. `payloadBudget` weighs every listed call **and** every read-only tool that takes no required arguments, because the one tool nobody thought to list is the one that answers with 3.3 MB; pass a per-call `maxBytes` for a bulk-export tool that is deliberately large. It also asserts that a JSON answer arrived with `structuredContent`.
250
252
 
253
+ The drift checks hold an MCP server to what its own site does, so a tool cannot describe, accept or return something the site does not:
254
+
255
+ - `manifestParity(t, { writeAuth? })`: `/.well-known/mcp.json` is `tools/list` tool for tool, names the endpoint under test, and states write auth only where a write tool is listed (both ways, given `writeAuth`).
256
+ - `rowParity(t, label, mcp, rest)`: a tool's answer and the GET API's or `?q=` page's are identical, and not both empty.
257
+ - `twinParity(t, label, twinUrl, text)`: a read tool's text is the page's markdown twin, past its frontmatter and the title line.
258
+ - `editReadChecks(t, { path, rows?, validate?, endpoint? })`: rows carry page ids, `get_page` with `blocks: true` returns blocks the site's own `validate` accepts and a version, and `get_page_history`'s newest revision is that version.
259
+ - `roundTrip(t, label, { read, write })`: what a read tool hands out, the write tool takes back unchanged, and the next read matches.
260
+ - `openapiWriteParity(t, [[path, method, tool], …])`: every field an OpenAPI write documents, the matching tool takes.
261
+ - `rolaOriginCheck(t)`: a challenge tool's ROLA recipe names the origin under test.
262
+
251
263
  `standardVersionSources(t, openapiPaths)` is the `versionCoherence` map every S10 surface serves — both agent-card paths, `mcp.json`, the server card and each path the OpenAPI document answers at — so a suite names only the paths it adds. `t.rpcAt(endpoint, …)` and `t.callAt` drive a second MCP endpoint on the same surface with the same backoff, User-Agent and tally.
252
264
 
253
265
  ```ts
@@ -193,3 +193,70 @@ export declare function payloadBudget(t: Tester, calls: ReadonlyArray<{
193
193
  * every unnamed crawler is telling two different stories to the same caller.
194
194
  */
195
195
  export declare function robotsChecks(t: Tester, paths: readonly string[]): Promise<void>;
196
+ /**
197
+ * `/.well-known/mcp.json` is `tools/list`, tool for tool, names the endpoint
198
+ * under test, and states write auth only where the server lists a write tool.
199
+ *
200
+ * Both lists are `toolCard` projections, so anything short of equality is a
201
+ * manifest built from something other than the server's own tools. Pass
202
+ * `writeAuth` where the server's writes take a credential the manifest should
203
+ * describe, and the check runs both ways.
204
+ */
205
+ export declare function manifestParity(t: Tester, opts?: {
206
+ url?: string;
207
+ writeAuth?: boolean;
208
+ }): Promise<void>;
209
+ /**
210
+ * Two surfaces answering one question answer it identically: a tool's rows and
211
+ * the GET API's, a tool's search and the `?q=` page's. Two empty answers are
212
+ * not agreement, so an empty one fails.
213
+ */
214
+ export declare function rowParity(t: Tester, label: string, mcp: unknown, rest: unknown): void;
215
+ /**
216
+ * A page reads the same over MCP as at its markdown twin: the tool's text is
217
+ * the twin's body, after the frontmatter, and after the `# Title` line the twin
218
+ * adds where the page's own blocks open with no h1.
219
+ */
220
+ export declare function twinParity(t: Tester, label: string, twinUrl: string, content: string): Promise<void>;
221
+ /**
222
+ * What an edit starts from, on any server built on the wiki read tools: rows
223
+ * carry the id a pageList block takes, `get_page` with `blocks: true` returns
224
+ * the stored blocks and the version a save takes as its base, and the history's
225
+ * newest revision is that version. `validate` is the site's own block check,
226
+ * so blocks the tool hands out are blocks the site's write takes back.
227
+ */
228
+ export declare function editReadChecks(t: Tester, opts: {
229
+ path: string;
230
+ endpoint?: string;
231
+ rows?: ReadonlyArray<{
232
+ id?: unknown;
233
+ }>;
234
+ validate?: (blocks: unknown[]) => true | string;
235
+ label?: string;
236
+ }): Promise<void>;
237
+ /**
238
+ * What a read tool hands out, a write tool takes back unchanged, and the page
239
+ * reads the same after: the read's shape is the write's. `write` gets the read
240
+ * as it came; `read` is called again after.
241
+ */
242
+ export declare function roundTrip(t: Tester, label: string, io: {
243
+ read: () => Promise<unknown>;
244
+ write: (value: unknown) => Promise<Rpc>;
245
+ }): Promise<void>;
246
+ /**
247
+ * Every field a REST write documents, the matching tool takes. An edit tool
248
+ * missing the version field the editor sends overwrites whatever landed between
249
+ * an agent's read and its write. `ops` pairs an OpenAPI path and method with
250
+ * the tool that does the same write.
251
+ */
252
+ export declare function openapiWriteParity(t: Tester, ops: ReadonlyArray<readonly [path: string, method: string, tool: string]>, specUrl?: string): Promise<void>;
253
+ /**
254
+ * The ROLA recipe a challenge tool hands out names the origin the verifier
255
+ * checks: the origin under test. One that named the canonical site while the
256
+ * verifier read another variable failed every signature off that site.
257
+ */
258
+ export declare function rolaOriginCheck(t: Tester, opts?: {
259
+ origin?: string;
260
+ endpoint?: string;
261
+ tool?: string;
262
+ }): Promise<void>;
@@ -468,3 +468,128 @@ export async function robotsChecks(t, paths) {
468
468
  t.check(`robots allows ${path}`, !match || match.allow, match ? `${match.allow ? 'Allow' : 'Disallow'}: ${match.path}` : 'no matching rule');
469
469
  }
470
470
  }
471
+ // ---- drift: what the MCP server says against what the site does -----------
472
+ //
473
+ // Each app had written these against its own surfaces after an audit found its
474
+ // MCP server describing, accepting or returning something its site did not:
475
+ // an edit tool without the version guard the editor sends, page reads that
476
+ // flattened what the markdown twin kept, a manifest stating write auth on a
477
+ // deployment with no writes. The same check, once per app, is how the checks
478
+ // themselves drift, so they live here beside the transport they test.
479
+ /** A JSON value that says nothing: comparing two of them proves nothing. */
480
+ const vacuous = (v) => v == null || (Array.isArray(v) ? v.length === 0 : typeof v === 'object' && Object.keys(v).length === 0);
481
+ /** Where two JSON values first part, for a check's note. */
482
+ const firstDifference = (a, b) => {
483
+ const [x, y] = [JSON.stringify(a) ?? 'undefined', JSON.stringify(b) ?? 'undefined'];
484
+ let i = 0;
485
+ while (i < x.length && x[i] === y[i])
486
+ i++;
487
+ return `at char ${i}: MCP …${x.slice(Math.max(0, i - 20), i + 40)}… vs …${y.slice(Math.max(0, i - 20), i + 40)}…`;
488
+ };
489
+ /**
490
+ * `/.well-known/mcp.json` is `tools/list`, tool for tool, names the endpoint
491
+ * under test, and states write auth only where the server lists a write tool.
492
+ *
493
+ * Both lists are `toolCard` projections, so anything short of equality is a
494
+ * manifest built from something other than the server's own tools. Pass
495
+ * `writeAuth` where the server's writes take a credential the manifest should
496
+ * describe, and the check runs both ways.
497
+ */
498
+ export async function manifestParity(t, opts = {}) {
499
+ const manifest = (await (await fetch(opts.url ?? `${t.base}/.well-known/mcp.json`)).json());
500
+ const listed = (await t.rpc('tools/list')).result?.tools ?? [];
501
+ const names = (xs) => xs.map(x => x.name).sort().join(' ');
502
+ const same = JSON.stringify(manifest.tools) === JSON.stringify(listed);
503
+ t.check('mcp.json is tools/list', same && listed.length > 0, same ? `${listed.length} tools` : names(manifest.tools ?? []) === names(listed) ? firstDifference(manifest.tools, listed) : `mcp.json: ${names(manifest.tools ?? [])} | tools/list: ${names(listed)}`);
504
+ // By path: the manifest names the canonical origin, and a run against a dev server is not it.
505
+ const path = (u) => (u ? new URL(u, t.base).pathname : '');
506
+ t.check('mcp.json names the endpoint', path(manifest.mcp?.endpoint) === path(t.endpoint), `${manifest.mcp?.endpoint}`);
507
+ const writes = listed.filter(x => x.annotations?.readOnlyHint === false).length;
508
+ const states = !!manifest.auth?.writes;
509
+ t.check('mcp.json write auth', (!states || writes > 0) && (opts.writeAuth === undefined || states === opts.writeAuth), `auth.writes ${states ? 'stated' : 'absent'}, ${writes} write tools listed${opts.writeAuth === undefined ? '' : `, expected ${opts.writeAuth ? 'stated' : 'absent'}`}`);
510
+ }
511
+ /**
512
+ * Two surfaces answering one question answer it identically: a tool's rows and
513
+ * the GET API's, a tool's search and the `?q=` page's. Two empty answers are
514
+ * not agreement, so an empty one fails.
515
+ */
516
+ export function rowParity(t, label, mcp, rest) {
517
+ const same = JSON.stringify(mcp) === JSON.stringify(rest);
518
+ t.check(label, same && !vacuous(mcp), vacuous(mcp) ? 'nothing to compare: the MCP answer is empty' : same ? 'identical' : firstDifference(mcp, rest));
519
+ }
520
+ /**
521
+ * A page reads the same over MCP as at its markdown twin: the tool's text is
522
+ * the twin's body, after the frontmatter, and after the `# Title` line the twin
523
+ * adds where the page's own blocks open with no h1.
524
+ */
525
+ export async function twinParity(t, label, twinUrl, content) {
526
+ const url = twinUrl.startsWith('http') ? twinUrl : `${t.base}/${twinUrl.replace(/^\//, '')}`;
527
+ const res = await fetch(url);
528
+ let body = (await res.text()).replace(/^---\n[\s\S]*?\n---\n+/, '').trim();
529
+ const text = content.trim();
530
+ if (!text.startsWith('# '))
531
+ body = body.replace(/^# [^\n]*\n+/, '');
532
+ t.check(label, res.ok && !!text && body === text, !res.ok ? `${res.status} ${url}` : !text ? 'nothing to compare: the MCP text is empty' : body === text ? `${text.length} chars` : firstDifference(text, body));
533
+ }
534
+ /**
535
+ * What an edit starts from, on any server built on the wiki read tools: rows
536
+ * carry the id a pageList block takes, `get_page` with `blocks: true` returns
537
+ * the stored blocks and the version a save takes as its base, and the history's
538
+ * newest revision is that version. `validate` is the site's own block check,
539
+ * so blocks the tool hands out are blocks the site's write takes back.
540
+ */
541
+ export async function editReadChecks(t, opts) {
542
+ const endpoint = opts.endpoint ?? t.endpoint;
543
+ const label = (s) => (opts.label ? `[${opts.label}] ${s}` : s);
544
+ if (opts.rows) {
545
+ const rows = opts.rows;
546
+ t.check(label('rows carry the page id'), rows.length > 0 && rows.every(r => typeof r.id === 'string' && r.id.length > 0), `${rows.length} rows, first id=${String(rows[0]?.id)}`);
547
+ }
548
+ const page = t.payload(await t.callAt(endpoint, 'get_page', { path: opts.path, blocks: true }));
549
+ const blocks = Array.isArray(page?.content) ? page.content : null;
550
+ const verdict = blocks && opts.validate ? opts.validate(blocks) : true;
551
+ t.check(label('get_page(blocks)'), !!page?.id && !!page.version && !!blocks && typeof page.metadata === 'object' && verdict === true, !blocks ? `no block array: ${JSON.stringify(page).slice(0, 80)}` : verdict !== true ? `the site's block check refuses them: ${verdict}` : `${blocks.length} blocks, v${page.version}`);
552
+ const history = t.payload(await t.callAt(endpoint, 'get_page_history', { path: opts.path, limit: 3 }));
553
+ const newest = history?.revisions?.[0];
554
+ t.check(label('get_page_history'), !!newest?.id && newest.version === history.currentVersion && newest.version === page?.version && 'author' in newest, `${history?.revisions?.length ?? 0} revisions, newest v${newest?.version} of v${history?.currentVersion}, page v${page?.version}`);
555
+ }
556
+ /**
557
+ * What a read tool hands out, a write tool takes back unchanged, and the page
558
+ * reads the same after: the read's shape is the write's. `write` gets the read
559
+ * as it came; `read` is called again after.
560
+ */
561
+ export async function roundTrip(t, label, io) {
562
+ const before = await io.read();
563
+ const wrote = await io.write(before);
564
+ const after = await io.read();
565
+ const failed = wrote.error ? `-${wrote.error.code} ${wrote.error.message}` : wrote.result?.isError ? String(t.payload(wrote)).slice(0, 100) : '';
566
+ t.check(label, !vacuous(before) && !failed && JSON.stringify(after) === JSON.stringify(before), vacuous(before) ? 'nothing to compare: the read is empty' : failed ? `the write refused its own read: ${failed}` : JSON.stringify(after) === JSON.stringify(before) ? 'unchanged' : firstDifference(after, before));
567
+ }
568
+ /**
569
+ * Every field a REST write documents, the matching tool takes. An edit tool
570
+ * missing the version field the editor sends overwrites whatever landed between
571
+ * an agent's read and its write. `ops` pairs an OpenAPI path and method with
572
+ * the tool that does the same write.
573
+ */
574
+ export async function openapiWriteParity(t, ops, specUrl = `${t.base}/.well-known/openapi.json`) {
575
+ const spec = (await (await fetch(specUrl)).json());
576
+ const tools = (await t.rpc('tools/list')).result?.tools ?? [];
577
+ for (const [path, method, tool] of ops) {
578
+ const content = spec.paths?.[path]?.[method.toLowerCase()]?.requestBody?.content ?? {};
579
+ const fields = [...new Set(Object.values(content).flatMap(c => Object.keys(c.schema?.properties ?? {})))];
580
+ const props = Object.keys(tools.find(x => x.name === tool)?.inputSchema?.properties ?? {});
581
+ const missing = fields.filter(f => !props.includes(f));
582
+ t.check(`${tool} takes ${method.toUpperCase()} ${path}`, fields.length > 0 && missing.length === 0, !fields.length ? `the spec documents no body for ${method.toUpperCase()} ${path}` : missing.length ? `missing: ${missing.join(', ')}` : `${fields.length} fields`);
583
+ }
584
+ }
585
+ /**
586
+ * The ROLA recipe a challenge tool hands out names the origin the verifier
587
+ * checks: the origin under test. One that named the canonical site while the
588
+ * verifier read another variable failed every signature off that site.
589
+ */
590
+ export async function rolaOriginCheck(t, opts = {}) {
591
+ const origin = opts.origin ?? new URL(t.base).origin;
592
+ const answer = t.payload(await t.callAt(opts.endpoint ?? t.endpoint, opts.tool ?? 'get_challenge'));
593
+ const message = answer?.sign?.message ?? '';
594
+ t.check('ROLA recipe names this origin', message.includes(`"${origin}"`), `expects "${origin}" in: …${message.slice(-90)}`);
595
+ }
package/dist/mcp.d.ts CHANGED
@@ -34,13 +34,34 @@ export declare const MCP_META: {
34
34
  readonly serverInfo: "io.modelcontextprotocol/serverInfo";
35
35
  };
36
36
  import { type RateLimitOptions } from './rate-limit.js';
37
+ /**
38
+ * JSON Schema as far as a tool's parameters go. Open past the named keywords,
39
+ * because an array's items may be a whole block type, which a narrower shape
40
+ * made every app type as `{ type: 'object' }` and leave the rest unsaid.
41
+ */
42
+ export type JsonSchema = {
43
+ type?: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object';
44
+ description?: string;
45
+ enum?: readonly (string | number)[];
46
+ items?: JsonSchema;
47
+ properties?: Record<string, JsonSchema>;
48
+ required?: readonly string[];
49
+ minimum?: number;
50
+ maximum?: number;
51
+ [keyword: string]: unknown;
52
+ };
37
53
  export type ToolParam = {
38
54
  type: 'string' | 'number' | 'boolean' | 'array' | 'object';
39
55
  description: string;
40
56
  enum?: string[];
41
- items?: {
42
- type?: 'string' | 'number' | 'object';
43
- };
57
+ items?: JsonSchema;
58
+ /**
59
+ * Bounds a client can read before it calls. The handler still clamps (an
60
+ * argument out of range comes back adjusted, not refused), so these state the
61
+ * clamp rather than enforce it.
62
+ */
63
+ minimum?: number;
64
+ maximum?: number;
44
65
  };
45
66
  export type ToolSchema = {
46
67
  type: 'object';
package/dist/mcp.js CHANGED
@@ -233,7 +233,7 @@ function validateArgs(tool, args) {
233
233
  // No `items` means strings. Items with no one type (a `oneOf` of shapes,
234
234
  // as a zod union emits) are the tool's own to check: read as strings, every
235
235
  // well-formed call was refused.
236
- const itemType = spec.items ? spec.items.type : 'string';
236
+ const itemType = spec.items ? (spec.items.type === 'integer' ? 'number' : spec.items.type) : 'string';
237
237
  if (actual !== 'array') {
238
238
  problems.push(`Parameter "${key}" must be an array${itemType ? ` of ${itemType}s` : ''}; received ${actual} (${JSON.stringify(value)}).`);
239
239
  }
@@ -1,9 +1,9 @@
1
1
  import { type McpTool } from './mcp.js';
2
2
  import { type CorpusSection } from './corpus.js';
3
3
  import { type WikiNode, type WikiTree } from './tree.js';
4
- import type { WikiStore, WikiPageListing, PageMetadata } from './store.js';
4
+ import type { WikiStore, WikiPageListing, WikiPageRow, PageMetadata } from './store.js';
5
5
  import type { DiffBlock } from './revisions.js';
6
- type ToolName = 'search_wiki' | 'get_page' | 'get_pages' | 'list_pages' | 'get_categories' | 'get_recent_changes' | 'get_full_corpus';
6
+ type ToolName = 'search_wiki' | 'get_page' | 'get_pages' | 'list_pages' | 'get_categories' | 'get_recent_changes' | 'get_full_corpus' | 'get_page_history';
7
7
  export interface WikiToolsOptions<B extends DiffBlock> {
8
8
  store: WikiStore<B>;
9
9
  tree: WikiTree<WikiNode>;
@@ -11,6 +11,13 @@ export interface WikiToolsOptions<B extends DiffBlock> {
11
11
  url: (tagPath: string, slug: string) => string;
12
12
  /** A page's blocks as plain prose: what `get_page` returns and `chars` counts. */
13
13
  text: (blocks: B[]) => string;
14
+ /**
15
+ * A page's blocks with what is read live filled in, such as a page list's
16
+ * pages: the resolver the app's page and markdown twin use. `get_page`,
17
+ * `get_pages` and the default corpus read their text through it, so a list
18
+ * reads there as it does on the twin rather than as its stored placeholder.
19
+ */
20
+ resolve?: (blocks: B[]) => Promise<B[]>;
14
21
  /** The wiki as the descriptions name it: "the Radix Wiki", "this DAO's wiki". */
15
22
  noun: string;
16
23
  /** The corpus document's heading. */
@@ -19,6 +26,10 @@ export interface WikiToolsOptions<B extends DiffBlock> {
19
26
  pathPrefix?: string;
20
27
  /** Back-office pages are private here, not merely unlisted: no tool reads one. */
21
28
  privateHidden?: boolean;
29
+ /** What `get_page` with `blocks: true` adds for the app's write tool, such as fields it keeps inside metadata. */
30
+ forEdit?: (page: WikiPageRow<B>) => Record<string, unknown>;
31
+ /** Display names by user id, for `get_page_history`. Without it a revision names no author. */
32
+ authors?: (ids: string[]) => Promise<Map<string, string>>;
22
33
  /** What a listing row adds from the page's metadata, or null for nothing. */
23
34
  metadata?: (tagPath: string, metadata: PageMetadata) => Record<string, unknown> | null;
24
35
  /** A category's name as the tree lists it. Defaults to the node's `name`. */
@@ -32,7 +43,7 @@ export interface WikiToolsOptions<B extends DiffBlock> {
32
43
  }
33
44
  /** A read tool: the package's always say whether they write. */
34
45
  export type WikiTool = McpTool & Required<Pick<McpTool, 'title' | 'annotations'>>;
35
- type Rowable<B> = Pick<WikiPageListing, 'title' | 'tagPath' | 'slug' | 'snippet' | 'metadata' | 'updatedAt' | 'lastVerifiedAt'> & {
46
+ type Rowable<B> = Pick<WikiPageListing, 'id' | 'title' | 'tagPath' | 'slug' | 'snippet' | 'metadata' | 'updatedAt' | 'lastVerifiedAt'> & {
36
47
  content?: B[];
37
48
  };
38
49
  /** The row every listing returns: search, list, recent changes, and the app's own `?q=` twin. */
@@ -42,6 +53,7 @@ declare const rowFor: <B extends DiffBlock>(o: Pick<WikiToolsOptions<B>, "url" |
42
53
  snippet: string;
43
54
  updatedAt: string;
44
55
  chars?: number | undefined;
56
+ id: string;
45
57
  title: string;
46
58
  path: string;
47
59
  url: string;
@@ -21,12 +21,19 @@ import { openingText } from './text.js';
21
21
  const MAX_BATCH_PATHS = 20;
22
22
  const READ = { readOnlyHint: true, idempotentHint: true, openWorldHint: false };
23
23
  const pathOf = (tagPath, slug) => wikiPath(tagPath, slug).slice(1);
24
+ /** How a single-page tool takes its page. */
25
+ const ADDRESS = {
26
+ path: { type: 'string', description: 'The page\'s whole path, e.g. "developers/scrypto/blueprints". Preferred over tagPath + slug.' },
27
+ tagPath: { type: 'string', description: 'Tag path only ("" for a top-level page). Ignored when `path` is set.' },
28
+ slug: { type: 'string', description: 'Page slug. Ignored when `path` is set.' },
29
+ };
24
30
  /** Whether a tag path is closed to these tools: a back office the wiki keeps private. */
25
31
  const offFor = (o) => (tagPath) => !!tagPath && !!o.privateHidden && o.store.isHidden(tagPath);
26
32
  /** The row every listing returns: search, list, recent changes, and the app's own `?q=` twin. */
27
33
  const rowFor = (o) => (p) => {
28
34
  const meta = o.metadata?.(p.tagPath, (p.metadata ?? {}));
29
35
  return {
36
+ id: p.id,
30
37
  title: p.title,
31
38
  path: pathOf(p.tagPath, p.slug),
32
39
  url: o.url(p.tagPath, p.slug),
@@ -68,21 +75,38 @@ export function wikiReadTools(o) {
68
75
  const page = (await store.get(t.tagPath, t.slug)) ?? (await store.get(pathOf(t.tagPath, t.slug), ''));
69
76
  return page && !off(page.tagPath) ? page : null;
70
77
  };
71
- const full = (page) => ({
78
+ /** The page a call names by `path` or `tagPath` + `slug`; a miss names the nearest pages, so a wrong guess is fixed in one retry. */
79
+ const named = async (r) => {
80
+ const raw = r.str('path');
81
+ const t = raw ? target(raw) : { tagPath: r.str('tagPath'), slug: r.str('slug') };
82
+ if (!t?.slug)
83
+ throw new McpToolError('Pass either `path` ("tag/path/slug") or `slug` (with optional `tagPath`).');
84
+ const page = await read(t);
85
+ if (page)
86
+ return page;
87
+ const near = await store.search(t.slug.replace(/-/g, ' '), { take: 5 });
88
+ throw new McpToolError(`No page at path "${pathOf(t.tagPath, t.slug)}".`, {
89
+ didYouMean: near.items.filter(p => !off(p.tagPath)).map(p => ({ path: pathOf(p.tagPath, p.slug), title: p.title })),
90
+ hint: 'Paths are exact. Use search_wiki for a keyword, or list_pages with the tagPath to enumerate the branch.',
91
+ });
92
+ };
93
+ /** Blocks as the page shows them. */
94
+ const shown = (blocks) => (o.resolve ? o.resolve(blocks) : Promise.resolve(blocks));
95
+ const head = (page) => ({
72
96
  ...summary({ ...page, snippet: describe(openingText(page.content)) }),
73
97
  version: page.version,
74
- content: o.text(page.content),
75
98
  });
99
+ const full = async (page) => ({ ...head(page), content: o.text(await shown(page.content)) });
76
100
  const sections = async (tagPath) => {
77
101
  if (o.corpus)
78
102
  return o.corpus(tagPath);
79
103
  const pages = (await store.corpus())
80
104
  .filter(p => !(p.tagPath === '' && p.slug === '') && (!tagPath || p.tagPath === tagPath || p.tagPath.startsWith(`${tagPath}/`)))
81
105
  .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
82
- return pages.map(p => {
106
+ return Promise.all(pages.map(async (p) => {
83
107
  const path = pathOf(p.tagPath, p.slug);
84
- return { path, tagPath: p.tagPath, section: `## ${p.title}\n\nPath: ${path}\nURL: ${o.url(p.tagPath, p.slug)}\nUpdated: ${isoDate(p.updatedAt)}\n\n${o.text(p.content)}` };
85
- });
108
+ return { path, tagPath: p.tagPath, section: `## ${p.title}\n\nPath: ${path}\nURL: ${o.url(p.tagPath, p.slug)}\nUpdated: ${isoDate(p.updatedAt)}\n\n${o.text(await shown(p.content))}` };
109
+ }));
86
110
  };
87
111
  const tools = [
88
112
  {
@@ -100,8 +124,8 @@ export function wikiReadTools(o) {
100
124
  properties: {
101
125
  query: { type: 'string', description: 'Search term, matched against page titles and body text' },
102
126
  tagPath: { type: 'string', description: 'Only pages at this tag path, e.g. a path from get_categories' },
103
- page: { type: 'number', description: '1-based page number (default 1)' },
104
- pageSize: { type: 'number', description: 'Results per page (default 20, max 50)' },
127
+ page: { type: 'number', description: '1-based page number (default 1)', minimum: 1 },
128
+ pageSize: { type: 'number', description: 'Results per page (default 20, max 50)', minimum: 1, maximum: 50 },
105
129
  },
106
130
  required: ['query'],
107
131
  },
@@ -129,31 +153,23 @@ export function wikiReadTools(o) {
129
153
  description: `One page of ${noun} in full: its text, version, update date and metadata. ` +
130
154
  'Address it by `path` ("tag/path/slug", exactly the `path` or `url` a listing returns), or by `tagPath` + `slug`; a category\'s own path reads its hub article. Not a title. ' +
131
155
  'For 2 to 20 pages call get_pages, one round trip rather than N. A miss names the nearest pages, so a wrong guess is fixed in one retry. ' +
132
- 'A page\'s URL with `.md` appended is its markdown.',
156
+ 'A page\'s URL with `.md` appended is its markdown. ' +
157
+ 'With `blocks: true`, `content` is the stored block array, ids and all, beside the page `id` and its whole `metadata`: read a page this way before editing it, and send its `version` back as the edit\'s baseVersion.',
133
158
  inputSchema: {
134
159
  type: 'object',
135
160
  properties: {
136
- path: { type: 'string', description: 'The page\'s whole path, e.g. "developers/scrypto/blueprints". Preferred over tagPath + slug.' },
137
- tagPath: { type: 'string', description: 'Tag path only ("" for a top-level page). Ignored when `path` is set.' },
138
- slug: { type: 'string', description: 'Page slug. Ignored when `path` is set.' },
161
+ ...ADDRESS,
162
+ blocks: { type: 'boolean', description: 'Return the stored blocks rather than prose, for an edit' },
139
163
  },
140
164
  requireOneOf: ['path', 'slug'],
141
165
  },
142
166
  handler: async (args) => {
143
167
  const r = readArgs(args);
144
- const raw = r.str('path');
145
- const t = raw ? target(raw) : { tagPath: r.str('tagPath'), slug: r.str('slug') };
146
- if (!t?.slug)
147
- throw new McpToolError('Pass either `path` ("tag/path/slug") or `slug` (with optional `tagPath`).');
148
- const page = await read(t);
149
- if (!page) {
150
- const near = await store.search(t.slug.replace(/-/g, ' '), { take: 5 });
151
- throw new McpToolError(`No page at path "${pathOf(t.tagPath, t.slug)}".`, {
152
- didYouMean: near.items.filter(p => !off(p.tagPath)).map(p => ({ path: pathOf(p.tagPath, p.slug), title: p.title })),
153
- hint: 'Paths are exact. Use search_wiki for a keyword, or list_pages with the tagPath to enumerate the branch.',
154
- });
155
- }
156
- return withAdjustments(r, full(page));
168
+ const page = await named(r);
169
+ if (!r.bool('blocks'))
170
+ return withAdjustments(r, await full(page));
171
+ // Stored, not resolved: what an edit sends back is what was stored.
172
+ return withAdjustments(r, { ...head(page), id: page.id, content: page.content, metadata: page.metadata, ...o.forEdit?.(page) });
157
173
  },
158
174
  },
159
175
  {
@@ -167,7 +183,7 @@ export function wikiReadTools(o) {
167
183
  type: 'object',
168
184
  properties: {
169
185
  paths: { type: 'array', items: { type: 'string' }, description: `Page paths, 1 to ${MAX_BATCH_PATHS} per call` },
170
- maxChars: { type: 'number', description: 'Total characters across the pages returned (default 250000, max 1000000)' },
186
+ maxChars: { type: 'number', description: 'Total characters across the pages returned (default 250000, max 1000000)', minimum: 1_000, maximum: 1_000_000 },
171
187
  },
172
188
  required: ['paths'],
173
189
  },
@@ -194,7 +210,7 @@ export function wikiReadTools(o) {
194
210
  missing.push(input);
195
211
  continue;
196
212
  }
197
- const row = full(page);
213
+ const row = await full(page);
198
214
  // At least one page always comes back, so one oversized page can still be read.
199
215
  if (pages.length && used + row.content.length > maxChars) {
200
216
  omitted.push({ path: row.path, chars: row.content.length });
@@ -228,8 +244,8 @@ export function wikiReadTools(o) {
228
244
  properties: {
229
245
  tagPath: { type: 'string', description: 'The branch to list, e.g. a path from get_categories. Includes its descendants.' },
230
246
  sort: { type: 'string', enum: ['title', 'updatedAt'], description: 'Sort order (default "updatedAt", newest first)' },
231
- page: { type: 'number', description: '1-based page number (default 1)' },
232
- pageSize: { type: 'number', description: 'Results per page (default 20, max 100)' },
247
+ page: { type: 'number', description: '1-based page number (default 1)', minimum: 1 },
248
+ pageSize: { type: 'number', description: 'Results per page (default 20, max 100)', minimum: 1, maximum: 100 },
233
249
  },
234
250
  },
235
251
  handler: async (args) => {
@@ -285,8 +301,8 @@ export function wikiReadTools(o) {
285
301
  inputSchema: {
286
302
  type: 'object',
287
303
  properties: {
288
- days: { type: 'number', description: 'Look back this many days (default 7, max 30)' },
289
- limit: { type: 'number', description: 'Most pages returned (default 20, max 50)' },
304
+ days: { type: 'number', description: 'Look back this many days (default 7, max 30)', minimum: 1, maximum: 30 },
305
+ limit: { type: 'number', description: 'Most pages returned (default 20, max 50)', minimum: 1, maximum: 50 },
290
306
  },
291
307
  },
292
308
  handler: async (args) => {
@@ -320,8 +336,8 @@ export function wikiReadTools(o) {
320
336
  properties: {
321
337
  sizeOnly: { type: 'boolean', description: 'Sizes and breakdowns only, no document. Do this before the first real pull.' },
322
338
  tagPath: { type: 'string', description: 'One branch and its descendants. Omit for everything.' },
323
- maxChars: { type: 'number', description: `Character budget for \`document\` (default ${CORPUS_BUDGET.default}, max ${CORPUS_BUDGET.max})` },
324
- skip: { type: 'number', description: 'Resume from this page index: the `nextSkip` of the previous truncated call (default 0)' },
339
+ maxChars: { type: 'number', description: `Character budget for \`document\` (default ${CORPUS_BUDGET.default}, max ${CORPUS_BUDGET.max})`, minimum: CORPUS_BUDGET.min, maximum: CORPUS_BUDGET.max },
340
+ skip: { type: 'number', description: 'Resume from this page index: the `nextSkip` of the previous truncated call (default 0)', minimum: 0 },
325
341
  },
326
342
  },
327
343
  handler: async (args) => {
@@ -337,6 +353,33 @@ export function wikiReadTools(o) {
337
353
  return withAdjustments(r, { scope: o.corpusTitle, ...(tagPath ? { tagPath } : {}), ...slice });
338
354
  },
339
355
  },
356
+ {
357
+ name: 'get_page_history',
358
+ title: 'Page history',
359
+ annotations: READ,
360
+ description: `A page's revisions in ${noun}, newest first: each one's \`id\`, version, change type, message, date and the blocks it changed. ` +
361
+ 'Address the page as get_page does. A revision `id` is what a restore takes, where this server has one. Defaults: limit 20 (max 100).',
362
+ inputSchema: {
363
+ type: 'object',
364
+ properties: {
365
+ ...ADDRESS,
366
+ limit: { type: 'number', description: 'Most revisions returned (default 20, max 100)', minimum: 1, maximum: 100 },
367
+ },
368
+ requireOneOf: ['path', 'slug'],
369
+ },
370
+ handler: async (args) => {
371
+ const r = readArgs(args);
372
+ const page = await named(r);
373
+ const revisions = await store.history(page.id, { limit: r.num('limit', 20, 1, 100) });
374
+ const names = o.authors && await o.authors([...new Set(revisions.flatMap(v => v.authorId ? [v.authorId] : []))]);
375
+ return withAdjustments(r, {
376
+ path: pathOf(page.tagPath, page.slug),
377
+ url: o.url(page.tagPath, page.slug),
378
+ currentVersion: page.version,
379
+ revisions: revisions.map(({ pageId: _, authorId, ...v }) => ({ ...v, ...(names ? { author: (authorId && names.get(authorId)) || null } : {}) })),
380
+ });
381
+ },
382
+ },
340
383
  ];
341
384
  return tools.map(t => (o.skills?.[t.name] ? { ...t, skill: o.skills[t.name] } : t));
342
385
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.67.0",
3
+ "version": "0.70.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, conditional-GET plumbing, and the headers and CSP every origin sends.",
5
5
  "type": "module",
6
6
  "license": "MIT",