wiki-formant 0.67.0 → 0.69.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 +12 -1
- package/dist/conformance.d.ts +67 -0
- package/dist/conformance.js +125 -0
- package/dist/mcp.d.ts +24 -3
- package/dist/mcp.js +1 -1
- package/dist/wiki-tools.d.ts +8 -3
- package/dist/wiki-tools.js +66 -26
- package/package.json +1 -1
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 `
|
|
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,7 @@ 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`.
|
|
93
94
|
- `privateHidden` closes the back office to every tool; without it a hidden page is unlisted but readable by path, as its URL is.
|
|
94
95
|
- `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
96
|
- Writes stay with each app, because who may write is the one thing no two of them agree on.
|
|
@@ -248,6 +249,16 @@ process.exit(t.summary());
|
|
|
248
249
|
|
|
249
250
|
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
251
|
|
|
252
|
+
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:
|
|
253
|
+
|
|
254
|
+
- `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`).
|
|
255
|
+
- `rowParity(t, label, mcp, rest)`: a tool's answer and the GET API's or `?q=` page's are identical, and not both empty.
|
|
256
|
+
- `twinParity(t, label, twinUrl, text)`: a read tool's text is the page's markdown twin, past its frontmatter and the title line.
|
|
257
|
+
- `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.
|
|
258
|
+
- `roundTrip(t, label, { read, write })`: what a read tool hands out, the write tool takes back unchanged, and the next read matches.
|
|
259
|
+
- `openapiWriteParity(t, [[path, method, tool], …])`: every field an OpenAPI write documents, the matching tool takes.
|
|
260
|
+
- `rolaOriginCheck(t)`: a challenge tool's ROLA recipe names the origin under test.
|
|
261
|
+
|
|
251
262
|
`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
263
|
|
|
253
264
|
```ts
|
package/dist/conformance.d.ts
CHANGED
|
@@ -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>;
|
package/dist/conformance.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/wiki-tools.d.ts
CHANGED
|
@@ -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>;
|
|
@@ -19,6 +19,10 @@ export interface WikiToolsOptions<B extends DiffBlock> {
|
|
|
19
19
|
pathPrefix?: string;
|
|
20
20
|
/** Back-office pages are private here, not merely unlisted: no tool reads one. */
|
|
21
21
|
privateHidden?: boolean;
|
|
22
|
+
/** What `get_page` with `blocks: true` adds for the app's write tool, such as fields it keeps inside metadata. */
|
|
23
|
+
forEdit?: (page: WikiPageRow<B>) => Record<string, unknown>;
|
|
24
|
+
/** Display names by user id, for `get_page_history`. Without it a revision names no author. */
|
|
25
|
+
authors?: (ids: string[]) => Promise<Map<string, string>>;
|
|
22
26
|
/** What a listing row adds from the page's metadata, or null for nothing. */
|
|
23
27
|
metadata?: (tagPath: string, metadata: PageMetadata) => Record<string, unknown> | null;
|
|
24
28
|
/** A category's name as the tree lists it. Defaults to the node's `name`. */
|
|
@@ -32,7 +36,7 @@ export interface WikiToolsOptions<B extends DiffBlock> {
|
|
|
32
36
|
}
|
|
33
37
|
/** A read tool: the package's always say whether they write. */
|
|
34
38
|
export type WikiTool = McpTool & Required<Pick<McpTool, 'title' | 'annotations'>>;
|
|
35
|
-
type Rowable<B> = Pick<WikiPageListing, 'title' | 'tagPath' | 'slug' | 'snippet' | 'metadata' | 'updatedAt' | 'lastVerifiedAt'> & {
|
|
39
|
+
type Rowable<B> = Pick<WikiPageListing, 'id' | 'title' | 'tagPath' | 'slug' | 'snippet' | 'metadata' | 'updatedAt' | 'lastVerifiedAt'> & {
|
|
36
40
|
content?: B[];
|
|
37
41
|
};
|
|
38
42
|
/** The row every listing returns: search, list, recent changes, and the app's own `?q=` twin. */
|
|
@@ -42,6 +46,7 @@ declare const rowFor: <B extends DiffBlock>(o: Pick<WikiToolsOptions<B>, "url" |
|
|
|
42
46
|
snippet: string;
|
|
43
47
|
updatedAt: string;
|
|
44
48
|
chars?: number | undefined;
|
|
49
|
+
id: string;
|
|
45
50
|
title: string;
|
|
46
51
|
path: string;
|
|
47
52
|
url: string;
|
package/dist/wiki-tools.js
CHANGED
|
@@ -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,6 +75,21 @@ 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
|
};
|
|
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
|
+
};
|
|
71
93
|
const full = (page) => ({
|
|
72
94
|
...summary({ ...page, snippet: describe(openingText(page.content)) }),
|
|
73
95
|
version: page.version,
|
|
@@ -100,8 +122,8 @@ export function wikiReadTools(o) {
|
|
|
100
122
|
properties: {
|
|
101
123
|
query: { type: 'string', description: 'Search term, matched against page titles and body text' },
|
|
102
124
|
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)' },
|
|
125
|
+
page: { type: 'number', description: '1-based page number (default 1)', minimum: 1 },
|
|
126
|
+
pageSize: { type: 'number', description: 'Results per page (default 20, max 50)', minimum: 1, maximum: 50 },
|
|
105
127
|
},
|
|
106
128
|
required: ['query'],
|
|
107
129
|
},
|
|
@@ -129,31 +151,22 @@ export function wikiReadTools(o) {
|
|
|
129
151
|
description: `One page of ${noun} in full: its text, version, update date and metadata. ` +
|
|
130
152
|
'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
153
|
'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.'
|
|
154
|
+
'A page\'s URL with `.md` appended is its markdown. ' +
|
|
155
|
+
'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
156
|
inputSchema: {
|
|
134
157
|
type: 'object',
|
|
135
158
|
properties: {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
slug: { type: 'string', description: 'Page slug. Ignored when `path` is set.' },
|
|
159
|
+
...ADDRESS,
|
|
160
|
+
blocks: { type: 'boolean', description: 'Return the stored blocks rather than prose, for an edit' },
|
|
139
161
|
},
|
|
140
162
|
requireOneOf: ['path', 'slug'],
|
|
141
163
|
},
|
|
142
164
|
handler: async (args) => {
|
|
143
165
|
const r = readArgs(args);
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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));
|
|
166
|
+
const page = await named(r);
|
|
167
|
+
if (!r.bool('blocks'))
|
|
168
|
+
return withAdjustments(r, full(page));
|
|
169
|
+
return withAdjustments(r, { ...full(page), id: page.id, content: page.content, metadata: page.metadata, ...o.forEdit?.(page) });
|
|
157
170
|
},
|
|
158
171
|
},
|
|
159
172
|
{
|
|
@@ -167,7 +180,7 @@ export function wikiReadTools(o) {
|
|
|
167
180
|
type: 'object',
|
|
168
181
|
properties: {
|
|
169
182
|
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)' },
|
|
183
|
+
maxChars: { type: 'number', description: 'Total characters across the pages returned (default 250000, max 1000000)', minimum: 1_000, maximum: 1_000_000 },
|
|
171
184
|
},
|
|
172
185
|
required: ['paths'],
|
|
173
186
|
},
|
|
@@ -228,8 +241,8 @@ export function wikiReadTools(o) {
|
|
|
228
241
|
properties: {
|
|
229
242
|
tagPath: { type: 'string', description: 'The branch to list, e.g. a path from get_categories. Includes its descendants.' },
|
|
230
243
|
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)' },
|
|
244
|
+
page: { type: 'number', description: '1-based page number (default 1)', minimum: 1 },
|
|
245
|
+
pageSize: { type: 'number', description: 'Results per page (default 20, max 100)', minimum: 1, maximum: 100 },
|
|
233
246
|
},
|
|
234
247
|
},
|
|
235
248
|
handler: async (args) => {
|
|
@@ -285,8 +298,8 @@ export function wikiReadTools(o) {
|
|
|
285
298
|
inputSchema: {
|
|
286
299
|
type: 'object',
|
|
287
300
|
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)' },
|
|
301
|
+
days: { type: 'number', description: 'Look back this many days (default 7, max 30)', minimum: 1, maximum: 30 },
|
|
302
|
+
limit: { type: 'number', description: 'Most pages returned (default 20, max 50)', minimum: 1, maximum: 50 },
|
|
290
303
|
},
|
|
291
304
|
},
|
|
292
305
|
handler: async (args) => {
|
|
@@ -320,8 +333,8 @@ export function wikiReadTools(o) {
|
|
|
320
333
|
properties: {
|
|
321
334
|
sizeOnly: { type: 'boolean', description: 'Sizes and breakdowns only, no document. Do this before the first real pull.' },
|
|
322
335
|
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)' },
|
|
336
|
+
maxChars: { type: 'number', description: `Character budget for \`document\` (default ${CORPUS_BUDGET.default}, max ${CORPUS_BUDGET.max})`, minimum: CORPUS_BUDGET.min, maximum: CORPUS_BUDGET.max },
|
|
337
|
+
skip: { type: 'number', description: 'Resume from this page index: the `nextSkip` of the previous truncated call (default 0)', minimum: 0 },
|
|
325
338
|
},
|
|
326
339
|
},
|
|
327
340
|
handler: async (args) => {
|
|
@@ -337,6 +350,33 @@ export function wikiReadTools(o) {
|
|
|
337
350
|
return withAdjustments(r, { scope: o.corpusTitle, ...(tagPath ? { tagPath } : {}), ...slice });
|
|
338
351
|
},
|
|
339
352
|
},
|
|
353
|
+
{
|
|
354
|
+
name: 'get_page_history',
|
|
355
|
+
title: 'Page history',
|
|
356
|
+
annotations: READ,
|
|
357
|
+
description: `A page's revisions in ${noun}, newest first: each one's \`id\`, version, change type, message, date and the blocks it changed. ` +
|
|
358
|
+
'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).',
|
|
359
|
+
inputSchema: {
|
|
360
|
+
type: 'object',
|
|
361
|
+
properties: {
|
|
362
|
+
...ADDRESS,
|
|
363
|
+
limit: { type: 'number', description: 'Most revisions returned (default 20, max 100)', minimum: 1, maximum: 100 },
|
|
364
|
+
},
|
|
365
|
+
requireOneOf: ['path', 'slug'],
|
|
366
|
+
},
|
|
367
|
+
handler: async (args) => {
|
|
368
|
+
const r = readArgs(args);
|
|
369
|
+
const page = await named(r);
|
|
370
|
+
const revisions = await store.history(page.id, { limit: r.num('limit', 20, 1, 100) });
|
|
371
|
+
const names = o.authors && await o.authors([...new Set(revisions.flatMap(v => v.authorId ? [v.authorId] : []))]);
|
|
372
|
+
return withAdjustments(r, {
|
|
373
|
+
path: pathOf(page.tagPath, page.slug),
|
|
374
|
+
url: o.url(page.tagPath, page.slug),
|
|
375
|
+
currentVersion: page.version,
|
|
376
|
+
revisions: revisions.map(({ pageId: _, authorId, ...v }) => ({ ...v, ...(names ? { author: (authorId && names.get(authorId)) || null } : {}) })),
|
|
377
|
+
});
|
|
378
|
+
},
|
|
379
|
+
},
|
|
340
380
|
];
|
|
341
381
|
return tools.map(t => (o.skills?.[t.name] ? { ...t, skill: o.skills[t.name] } : t));
|
|
342
382
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.69.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",
|