wiki-formant 0.70.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
@@ -207,6 +207,7 @@ What it gets right:
207
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".
208
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.
209
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`.
210
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.
211
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.
212
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.70.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",