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 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
+ }
@@ -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;
@@ -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, so an A2A caller arriving at it is answered in a shape it can
88
- // parse: a -32601 naming every method the server does implement.
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,
@@ -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. */
@@ -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
- const full = (page) => ({
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
- return withAdjustments(r, { ...full(page), id: page.id, content: page.content, metadata: page.metadata, ...o.forEdit?.(page) });
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.69.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",