wiki-formant 0.69.0 → 0.71.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 +2 -0
- package/dist/a2a.d.ts +1 -0
- package/dist/a2a.js +95 -0
- package/dist/conformance.js +7 -0
- package/dist/mcp.d.ts +10 -0
- package/dist/mcp.js +4 -1
- package/dist/store.d.ts +1 -1
- package/dist/well-known.js +12 -2
- package/dist/wiki-tools.d.ts +7 -0
- package/dist/wiki-tools.js +11 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -91,6 +91,7 @@ const { items, total } = await searchWiki(reading, 'validators', { page: 1, size
|
|
|
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
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.
|
|
94
95
|
- `privateHidden` closes the back office to every tool; without it a hidden page is unlisted but readable by path, as its URL is.
|
|
95
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.
|
|
96
97
|
- Writes stay with each app, because who may write is the one thing no two of them agree on.
|
|
@@ -206,6 +207,7 @@ What it gets right:
|
|
|
206
207
|
- **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".
|
|
207
208
|
- **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.
|
|
208
209
|
- **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.
|
|
210
|
+
- **The A2A call an agent card invites is answered.** Every card names this endpoint as its JSON-RPC `url`, so `message/send` (v0.3) and `SendMessage` (v1.0) get a message back: the result of the tool `answer` names (`{ tool: 'search_wiki', argument: 'query' }`), or `instructions` and the tool names without one. The task methods are `-32001`, since no task is ever created; streaming and push are `-32004`.
|
|
209
211
|
- **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.
|
|
210
212
|
- **`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.
|
|
211
213
|
- **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.
|
package/dist/a2a.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/a2a.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// a2a.ts — the A2A calls an agent card invites, answered on the MCP endpoint.
|
|
2
|
+
//
|
|
3
|
+
// Every card here names the MCP endpoint as its `url` with `preferredTransport:
|
|
4
|
+
// JSONRPC`, which to an A2A client means "send `message/send` here". The
|
|
5
|
+
// endpoint answered -32601, so the card promised an interface the server then
|
|
6
|
+
// refused: A2A clients and the registries that probe for them sent about 140
|
|
7
|
+
// such calls per origin in a fortnight (October 2026) and got nothing back.
|
|
8
|
+
// Now a message is answered with the site's own search, and the task methods
|
|
9
|
+
// say plainly that no task exists, since every answer is a direct message.
|
|
10
|
+
//
|
|
11
|
+
// Both revisions in the field are spoken: v0.3 (`message/send`, a Message with
|
|
12
|
+
// `kind`) and v1.0 (`SendMessage`, a `{ message }` with proto-JSON enums).
|
|
13
|
+
import { McpToolError } from './mcp.js';
|
|
14
|
+
const SEND = { 'message/send': '0.3', SendMessage: '1.0' };
|
|
15
|
+
/** Task methods: none can succeed, because no task is ever created. */
|
|
16
|
+
const TASK = new Set(['tasks/get', 'tasks/cancel', 'tasks/resubscribe', 'GetTask', 'CancelTask', 'SubscribeToTask']);
|
|
17
|
+
/** Everything else either revision defines, which this server declines. */
|
|
18
|
+
const UNSUPPORTED = new Set([
|
|
19
|
+
'message/stream',
|
|
20
|
+
'tasks/pushNotificationConfig/set',
|
|
21
|
+
'tasks/pushNotificationConfig/get',
|
|
22
|
+
'tasks/pushNotificationConfig/list',
|
|
23
|
+
'tasks/pushNotificationConfig/delete',
|
|
24
|
+
'agent/getAuthenticatedExtendedCard',
|
|
25
|
+
'SendStreamingMessage',
|
|
26
|
+
'ListTasks',
|
|
27
|
+
'CreateTaskPushNotificationConfig',
|
|
28
|
+
'GetTaskPushNotificationConfig',
|
|
29
|
+
'ListTaskPushNotificationConfigs',
|
|
30
|
+
'DeleteTaskPushNotificationConfig',
|
|
31
|
+
'GetExtendedAgentCard',
|
|
32
|
+
]);
|
|
33
|
+
/** @internal */
|
|
34
|
+
export const isA2aMethod = (method) => method in SEND || TASK.has(method) || UNSUPPORTED.has(method);
|
|
35
|
+
const error = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
|
36
|
+
/** The text of a message's text parts, whichever revision shaped them. */
|
|
37
|
+
function textOf(message) {
|
|
38
|
+
const parts = message?.parts;
|
|
39
|
+
if (!Array.isArray(parts))
|
|
40
|
+
return '';
|
|
41
|
+
return parts
|
|
42
|
+
.map(p => (p && typeof p.text === 'string' ? p.text : ''))
|
|
43
|
+
.join(' ')
|
|
44
|
+
.trim();
|
|
45
|
+
}
|
|
46
|
+
/** What a message gets when there is no search to run: what this server is and how to use it. */
|
|
47
|
+
const orientation = (config) => `${config.instructions}\n\nThis endpoint also speaks MCP (JSON-RPC over POST): send "initialize", then "tools/list". Tools: ${config.tools.map(t => t.name).join(', ')}.`;
|
|
48
|
+
async function reply(text, config) {
|
|
49
|
+
const tool = config.answer && config.tools.find(t => t.name === config.answer.tool);
|
|
50
|
+
if (!text || !tool)
|
|
51
|
+
return { text: orientation(config) };
|
|
52
|
+
try {
|
|
53
|
+
const out = await tool.handler({ [config.answer.argument]: text }, {
|
|
54
|
+
meta: {},
|
|
55
|
+
setMeta: () => { },
|
|
56
|
+
protocolVersion: '2025-11-25',
|
|
57
|
+
});
|
|
58
|
+
const lead = `${config.serverInfo.name} ${tool.name} for ${JSON.stringify(text)}. For more, call this endpoint over MCP: "initialize", then "tools/list".`;
|
|
59
|
+
if (typeof out === 'string')
|
|
60
|
+
return { text: `${lead}\n\n${out}` };
|
|
61
|
+
// The text twin stays beside the data, as it does on an MCP tool result:
|
|
62
|
+
// a client that reads only text parts still has to be able to read the answer.
|
|
63
|
+
return { text: `${lead}\n\n${JSON.stringify(out, null, 2)}`, ...(out && typeof out === 'object' && !Array.isArray(out) ? { data: out } : {}) };
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
if (err instanceof McpToolError)
|
|
67
|
+
return { text: `${err.message}\n\n${orientation(config)}` };
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Answer one A2A call. Only reached for a method `isA2aMethod` accepts. @internal */
|
|
72
|
+
export async function handleA2a(req, config) {
|
|
73
|
+
const { id, method } = req;
|
|
74
|
+
if (TASK.has(method)) {
|
|
75
|
+
return error(id, -32001, 'Task not found: this agent keeps no tasks. Every message it is sent is answered directly with a message.');
|
|
76
|
+
}
|
|
77
|
+
const revision = SEND[method];
|
|
78
|
+
if (!revision) {
|
|
79
|
+
const send = method.includes('/') ? 'message/send' : 'SendMessage';
|
|
80
|
+
return error(id, -32004, `Unsupported operation: "${method}". This agent answers ${send} only, without streaming or push notifications.`);
|
|
81
|
+
}
|
|
82
|
+
const message = req.params?.message;
|
|
83
|
+
if (!message || typeof message !== 'object') {
|
|
84
|
+
return error(id, -32602, 'Missing params.message: send a message with at least one text part.');
|
|
85
|
+
}
|
|
86
|
+
const { text, data } = await reply(textOf(message), config);
|
|
87
|
+
const contextId = typeof message.contextId === 'string' ? message.contextId : crypto.randomUUID();
|
|
88
|
+
const messageId = crypto.randomUUID();
|
|
89
|
+
if (revision === '1.0') {
|
|
90
|
+
const parts = [{ text }, ...(data ? [{ data }] : [])];
|
|
91
|
+
return { jsonrpc: '2.0', id, result: { message: { messageId, contextId, role: 'ROLE_AGENT', parts } } };
|
|
92
|
+
}
|
|
93
|
+
const parts = [{ kind: 'text', text }, ...(data ? [{ kind: 'data', data }] : [])];
|
|
94
|
+
return { jsonrpc: '2.0', id, result: { kind: 'message', messageId, contextId, role: 'agent', parts } };
|
|
95
|
+
}
|
package/dist/conformance.js
CHANGED
|
@@ -294,6 +294,13 @@ export async function agentCardParity(t) {
|
|
|
294
294
|
const probe = await rawPost(endpoint, { jsonrpc: '2.0', id: 1, method: 'ping' });
|
|
295
295
|
t.recordCall();
|
|
296
296
|
t.check('agent card url is callable', (probe.headers.get('content-type') ?? '').includes('json'), `POST ${endpoint} -> ${probe.status} ${probe.headers.get('content-type')}`);
|
|
297
|
+
// Callable is not the same as answering. A `JSONRPC` url invites A2A's own
|
|
298
|
+
// first call, and a -32601 to it refuses the interface the card names.
|
|
299
|
+
const send = await t.rpcAt(endpoint, 'message/send', {
|
|
300
|
+
message: { kind: 'message', role: 'user', messageId: 'conformance', parts: [{ kind: 'text', text: String(a.name ?? 'help') }] },
|
|
301
|
+
});
|
|
302
|
+
const reply = send.result;
|
|
303
|
+
t.check('agent card url answers A2A', reply?.kind === 'message' && typeof reply.parts?.[0]?.text === 'string', reply ? `message/send -> ${reply.parts?.length} part(s)` : `message/send -> ${JSON.stringify(send.error)}`);
|
|
297
304
|
}
|
|
298
305
|
/**
|
|
299
306
|
* Any surface an agent recrawls should answer a conditional GET with a 304.
|
package/dist/mcp.d.ts
CHANGED
|
@@ -179,6 +179,16 @@ export interface McpServerConfig {
|
|
|
179
179
|
* clicks when they do not have one yet.
|
|
180
180
|
*/
|
|
181
181
|
prompts?: McpPrompt[];
|
|
182
|
+
/**
|
|
183
|
+
* The tool an A2A `message/send` is answered with, and the argument its text
|
|
184
|
+
* fills: usually the search tool and its query. Without it a message is
|
|
185
|
+
* answered with `instructions` and the tool names, which is still an answer
|
|
186
|
+
* where -32601 was a refusal of the interface the agent card names.
|
|
187
|
+
*/
|
|
188
|
+
answer?: {
|
|
189
|
+
tool: string;
|
|
190
|
+
argument: string;
|
|
191
|
+
};
|
|
182
192
|
/** Appended to the GET refusal so a browser that lands here learns where to go. */
|
|
183
193
|
docsUrl?: string;
|
|
184
194
|
/**
|
package/dist/mcp.js
CHANGED
|
@@ -66,6 +66,7 @@ function requestProtocol(request) {
|
|
|
66
66
|
}
|
|
67
67
|
/** JSON-RPC batching was removed in 2025-06-18 and stays removed; it is legal only below that. */
|
|
68
68
|
const allowsBatch = (v) => v === '2025-03-26' || v === '2024-11-05';
|
|
69
|
+
import { handleA2a, isA2aMethod } from './a2a.js';
|
|
69
70
|
import { clientKey, rateLimit, rateLimitHeaders, withRateLimit } from './rate-limit.js';
|
|
70
71
|
/**
|
|
71
72
|
* A tool as every surface advertises it — `tools/list`, the agent card and
|
|
@@ -418,6 +419,8 @@ async function handleRpc(req, config, dispatch) {
|
|
|
418
419
|
}
|
|
419
420
|
}
|
|
420
421
|
default:
|
|
422
|
+
if (isA2aMethod(method))
|
|
423
|
+
return await handleA2a(req, config);
|
|
421
424
|
return methodNotFound(id, method, config, dispatch.era);
|
|
422
425
|
}
|
|
423
426
|
}
|
|
@@ -452,7 +455,7 @@ export async function handleMcp(body, config, dispatch = { protocolVersion: ASSU
|
|
|
452
455
|
export const MCP_CORS = {
|
|
453
456
|
'Access-Control-Allow-Origin': '*',
|
|
454
457
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
455
|
-
'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Mcp-Method, Mcp-Name, Last-Event-ID',
|
|
458
|
+
'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Mcp-Method, Mcp-Name, Last-Event-ID, A2A-Version',
|
|
456
459
|
// Allow-Headers governs what a browser may send; without Expose-Headers it
|
|
457
460
|
// may read none of what comes back. A browser client could not see the
|
|
458
461
|
// negotiated version, and could not see `Retry-After` on the 429 telling it
|
package/dist/store.d.ts
CHANGED
|
@@ -147,8 +147,8 @@ export declare function wikiStore<B extends DiffBlock = DiffBlock>(o: WikiStoreO
|
|
|
147
147
|
items: {
|
|
148
148
|
content: B[];
|
|
149
149
|
title: string;
|
|
150
|
-
metadata: PageMetadata;
|
|
151
150
|
id: string;
|
|
151
|
+
metadata: PageMetadata;
|
|
152
152
|
tagPath: string;
|
|
153
153
|
slug: string;
|
|
154
154
|
siteId: string;
|
package/dist/well-known.js
CHANGED
|
@@ -84,10 +84,20 @@ export function agentCard(config) {
|
|
|
84
84
|
// The endpoint, not the homepage. A card's `url` is where a client sends
|
|
85
85
|
// its first call, and every card here pointed at an HTML page — so a client
|
|
86
86
|
// that believed the card got markup back, or a 405. MCP is JSON-RPC over
|
|
87
|
-
// HTTP POST,
|
|
88
|
-
//
|
|
87
|
+
// HTTP POST, and the endpoint answers A2A's `message/send` (v0.3) and
|
|
88
|
+
// `SendMessage` (v1.0) from the site's own search (`McpServerConfig.answer`).
|
|
89
89
|
url: endpoint,
|
|
90
90
|
preferredTransport: 'JSONRPC',
|
|
91
|
+
// v1.0 reads the interfaces from here and ignores `url`; v0.3 the reverse.
|
|
92
|
+
...(mcpEndpoint === null
|
|
93
|
+
? {}
|
|
94
|
+
: {
|
|
95
|
+
supportedInterfaces: ['1.0', '0.3'].map(protocolVersion => ({
|
|
96
|
+
url: endpoint,
|
|
97
|
+
protocolBinding: 'JSONRPC',
|
|
98
|
+
protocolVersion,
|
|
99
|
+
})),
|
|
100
|
+
}),
|
|
91
101
|
version,
|
|
92
102
|
capabilities: { streaming: false, pushNotifications: false },
|
|
93
103
|
skills,
|
package/dist/wiki-tools.d.ts
CHANGED
|
@@ -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. */
|
package/dist/wiki-tools.js
CHANGED
|
@@ -90,21 +90,23 @@ export function wikiReadTools(o) {
|
|
|
90
90
|
hint: 'Paths are exact. Use search_wiki for a keyword, or list_pages with the tagPath to enumerate the branch.',
|
|
91
91
|
});
|
|
92
92
|
};
|
|
93
|
-
|
|
93
|
+
/** Blocks as the page shows them. */
|
|
94
|
+
const shown = (blocks) => (o.resolve ? o.resolve(blocks) : Promise.resolve(blocks));
|
|
95
|
+
const head = (page) => ({
|
|
94
96
|
...summary({ ...page, snippet: describe(openingText(page.content)) }),
|
|
95
97
|
version: page.version,
|
|
96
|
-
content: o.text(page.content),
|
|
97
98
|
});
|
|
99
|
+
const full = async (page) => ({ ...head(page), content: o.text(await shown(page.content)) });
|
|
98
100
|
const sections = async (tagPath) => {
|
|
99
101
|
if (o.corpus)
|
|
100
102
|
return o.corpus(tagPath);
|
|
101
103
|
const pages = (await store.corpus())
|
|
102
104
|
.filter(p => !(p.tagPath === '' && p.slug === '') && (!tagPath || p.tagPath === tagPath || p.tagPath.startsWith(`${tagPath}/`)))
|
|
103
105
|
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
|
|
104
|
-
return pages.map(p => {
|
|
106
|
+
return Promise.all(pages.map(async (p) => {
|
|
105
107
|
const path = pathOf(p.tagPath, p.slug);
|
|
106
|
-
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)}` };
|
|
107
|
-
});
|
|
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
|
+
}));
|
|
108
110
|
};
|
|
109
111
|
const tools = [
|
|
110
112
|
{
|
|
@@ -165,8 +167,9 @@ export function wikiReadTools(o) {
|
|
|
165
167
|
const r = readArgs(args);
|
|
166
168
|
const page = await named(r);
|
|
167
169
|
if (!r.bool('blocks'))
|
|
168
|
-
return withAdjustments(r, full(page));
|
|
169
|
-
|
|
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) });
|
|
170
173
|
},
|
|
171
174
|
},
|
|
172
175
|
{
|
|
@@ -207,7 +210,7 @@ export function wikiReadTools(o) {
|
|
|
207
210
|
missing.push(input);
|
|
208
211
|
continue;
|
|
209
212
|
}
|
|
210
|
-
const row = full(page);
|
|
213
|
+
const row = await full(page);
|
|
211
214
|
// At least one page always comes back, so one oversized page can still be read.
|
|
212
215
|
if (pages.length && used + row.content.length > maxChars) {
|
|
213
216
|
omitted.push({ path: row.path, chars: row.content.length });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.71.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",
|