wiki-formant 0.70.0 → 0.73.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,11 +207,18 @@ 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.
213
214
  - **`mcpRateLimited(retryAfterSec)` is a JSON-RPC envelope.** A 429 whose body is `{"error": "..."}` is a string where the client's parser expects `{code, message}`, on the one response an agent meets exactly when it is working hard.
214
215
 
216
+ ### A read-only surface
217
+
218
+ `readOnly(config, hide?)` is the same server with its writes taken off: the tools marked `readOnlyHint: true` and not named in `hide`, with every instruction line and prompt that names a dropped tool dropped too, and `destructiveHint` and `openWorldHint` stated on each tool that is kept. It exists for ChatGPT plugins, whose review refuses crypto transfers and checkout links and whose reviewers cannot sign a ROLA challenge. Mount it at its own route (`mcpResponse(req, readOnly(config))`), so the main endpoint keeps its writes, and hold the plugin package to it with `pluginParity` (Conformance). Keep each instruction a line of its own, so a line that names a write takes nothing else with it.
219
+
220
+ `openaiChallengeHandler()` from `wiki-formant/well-known` serves `/.well-known/openai-apps-challenge`: the portal's domain token from `OPENAI_APPS_CHALLENGE`, as bare text, or a 404 while it is unset.
221
+
215
222
  ### Structured output, `_meta`, and the envelope gate
216
223
 
217
224
  **Every object a handler returns comes back as `structuredContent` beside the text block**, so a client reads the answer rather than scraping prose for it. This used to be gated on declaring an `outputSchema`, and the result was that across four live servers and thirty-two tools — every one of them answering in JSON — not a single response ever carried it. `outputSchema` remains the stronger contract, because a client validates against it; it is no longer the price of admission. A handler returning a string is left alone.
@@ -259,6 +266,7 @@ The drift checks hold an MCP server to what its own site does, so a tool cannot
259
266
  - `roundTrip(t, label, { read, write })`: what a read tool hands out, the write tool takes back unchanged, and the next read matches.
260
267
  - `openapiWriteParity(t, [[path, method, tool], …])`: every field an OpenAPI write documents, the matching tool takes.
261
268
  - `rolaOriginCheck(t)`: a challenge tool's ROLA recipe names the origin under test.
269
+ - `pluginParity(t, { plugin, mcp })`: a ChatGPT plugin package (`chatgpt/plugin.json` and `chatgpt/mcp.json`) agrees with the endpoint it names, rebased onto the origin under test: one streamable-HTTP server that answers; every tool a review case triggers is listed there; every tool states `readOnlyHint`, `destructiveHint` and `openWorldHint` as booleans; `capabilities` claims `Write` exactly when a write tool is listed; no instruction, description or prompt names a tool the main endpoint has and the plugin's lacks; the listing is within the portal's limits with five positive and three negative cases; and each listing page on the site's own origin answers, with its `#anchor` where it names one. The demo recording is the one review field it cannot check.
262
270
 
263
271
  `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.
264
272
 
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
+ }
@@ -260,3 +260,18 @@ export declare function rolaOriginCheck(t: Tester, opts?: {
260
260
  endpoint?: string;
261
261
  tool?: string;
262
262
  }): Promise<void>;
263
+ /**
264
+ * A ChatGPT plugin package agrees with the server it names. The ZIP is uploaded
265
+ * once and OpenAI rescans the server every day, so a renamed tool, a write that
266
+ * reaches the plugin's endpoint, a description naming a tool the endpoint lacks,
267
+ * or a moved legal page fails review with nothing in the repo noticing. Pass the
268
+ * parsed `chatgpt/plugin.json` and `chatgpt/mcp.json`.
269
+ *
270
+ * URLs on the plugin's own origin are rebased onto the origin under test, so a
271
+ * run against a dev server checks the dev server. The demo recording is the
272
+ * one review field no code can hold, so it is not checked.
273
+ */
274
+ export declare function pluginParity(t: Tester, pkg: {
275
+ plugin: unknown;
276
+ mcp: unknown;
277
+ }): Promise<void>;
@@ -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.
@@ -593,3 +600,91 @@ export async function rolaOriginCheck(t, opts = {}) {
593
600
  const message = answer?.sign?.message ?? '';
594
601
  t.check('ROLA recipe names this origin', message.includes(`"${origin}"`), `expects "${origin}" in: …${message.slice(-90)}`);
595
602
  }
603
+ const LISTING_LIMITS = { displayName: 30, shortDescription: 30, longDescription: 4000, developerName: 80, category: 80 };
604
+ const LISTING_URLS = ['websiteURL', 'supportURL', 'privacyPolicyURL', 'termsOfServiceURL'];
605
+ const HINTS = ['readOnlyHint', 'destructiveHint', 'openWorldHint'];
606
+ /**
607
+ * A ChatGPT plugin package agrees with the server it names. The ZIP is uploaded
608
+ * once and OpenAI rescans the server every day, so a renamed tool, a write that
609
+ * reaches the plugin's endpoint, a description naming a tool the endpoint lacks,
610
+ * or a moved legal page fails review with nothing in the repo noticing. Pass the
611
+ * parsed `chatgpt/plugin.json` and `chatgpt/mcp.json`.
612
+ *
613
+ * URLs on the plugin's own origin are rebased onto the origin under test, so a
614
+ * run against a dev server checks the dev server. The demo recording is the
615
+ * one review field no code can hold, so it is not checked.
616
+ */
617
+ export async function pluginParity(t, pkg) {
618
+ const openai = pkg.plugin.extensions?.['com.openai'] ?? {};
619
+ const listing = openai.interface ?? {};
620
+ const cases = openai.review?.test_cases ?? {};
621
+ const servers = Object.values(pkg.mcp.mcpServers ?? {});
622
+ const named = servers[0]?.url ?? '';
623
+ t.check('plugin names one server', servers.length === 1 && servers[0]?.type === 'streamable-http' && named.startsWith('https://'), `${servers.length} server(s): ${named || 'no url'}`);
624
+ if (!named.startsWith('https://'))
625
+ return;
626
+ const canonical = new URL(named).origin;
627
+ const here = (url) => {
628
+ const u = new URL(url);
629
+ return u.origin === canonical ? new URL(`${u.pathname}${u.search}`, t.base).href : url;
630
+ };
631
+ const endpoint = here(named);
632
+ const tools = (await t.rpcAt(endpoint, 'tools/list')).result?.tools ?? [];
633
+ const names = new Set(tools.map(x => x.name));
634
+ t.check('plugin endpoint answers', tools.length > 0, `${endpoint}: ${tools.length} tools`);
635
+ const triggered = [...new Set((cases.positive ?? []).flatMap(c => (c.tools_triggered ?? '').split(/[\s,]+/).filter(Boolean)))];
636
+ const absent = triggered.filter(n => !names.has(n));
637
+ t.check('review cases name live tools', triggered.length > 0 && absent.length === 0, absent.length ? `not on the endpoint: ${absent.join(', ')}` : `${triggered.join(', ')}`);
638
+ const vague = tools.filter(x => HINTS.some(k => typeof x.annotations?.[k] !== 'boolean'));
639
+ t.check('plugin hints are booleans', vague.length === 0, vague.length ? vague.map(x => x.name).join(', ') : `${HINTS.join(', ')} on ${tools.length} tools`);
640
+ const writes = tools.filter(x => x.annotations?.readOnlyHint === false).map(x => x.name);
641
+ const claimsWrite = Array.isArray(listing.capabilities) && listing.capabilities.includes('Write');
642
+ t.check('plugin capabilities', claimsWrite === writes.length > 0, `capabilities ${claimsWrite ? 'claim' : 'omit'} Write; ${writes.length ? `writes: ${writes.join(', ')}` : 'no write tools'}`);
643
+ // A tool the main endpoint has and the plugin's lacks, named anywhere the
644
+ // model reads on the plugin's endpoint, sends it to a call that cannot work.
645
+ const lost = ((await t.rpc('tools/list')).result?.tools ?? []).map(x => x.name).filter(n => !names.has(n));
646
+ const init = await t.rpcAt(endpoint, 'initialize', {
647
+ protocolVersion: MCP_LEGACY_PROTOCOL_VERSION,
648
+ capabilities: {},
649
+ clientInfo: { name: 'plugin-parity', version: '1' },
650
+ });
651
+ const prompts = (await t.rpcAt(endpoint, 'prompts/list')).result?.prompts ?? [];
652
+ const texts = [
653
+ ['instructions', init.result?.instructions ?? ''],
654
+ ...tools.map((x) => [`${x.name} description`, x.description ?? '']),
655
+ ];
656
+ for (const p of prompts) {
657
+ const got = (await t.rpcAt(endpoint, 'prompts/get', { name: p.name })).result;
658
+ texts.push([`prompt ${p.name}`, (got?.messages ?? []).map(m => m.content?.text ?? '').join('\n')]);
659
+ }
660
+ const strays = texts.flatMap(([where, text]) => lost.filter(n => new RegExp(`\\b${n}\\b`).test(text)).map(n => `${where} names ${n}`));
661
+ t.check('plugin text names only its tools', strays.length === 0, strays.length ? strays.join('; ') : `${texts.length} texts, ${lost.length} tools withheld`);
662
+ const str = (k) => (typeof listing[k] === 'string' ? listing[k] : '');
663
+ const starters = Array.isArray(listing.defaultPrompt) ? listing.defaultPrompt : [];
664
+ const positive = cases.positive ?? [];
665
+ const faults = [
666
+ ...Object.entries(LISTING_LIMITS).filter(([k, n]) => !str(k) || str(k).length > n).map(([k, n]) => `${k} ${str(k).length}/${n}`),
667
+ ...(starters.length > 3 || starters.some(p => typeof p !== 'string' || p.length > 128) ? ['defaultPrompt: at most 3, 128 each'] : []),
668
+ ...(typeof listing.logo === 'string' ? [] : ['no logo']),
669
+ ...(positive.length === 5 && positive.every(c => c.description && c.prompt && c.tools_triggered && c.expected_behavior)
670
+ ? []
671
+ : ['5 positive cases, each with description, prompt, tools_triggered and expected_behavior']),
672
+ ...((cases.negative ?? []).length === 3 && (cases.negative ?? []).every(c => c.description && c.prompt) ? [] : ['3 negative cases']),
673
+ ];
674
+ t.check('plugin listing', faults.length === 0, faults.length ? faults.join('; ') : `${str('displayName')}: ${str('shortDescription')}`);
675
+ for (const k of LISTING_URLS) {
676
+ const url = str(k);
677
+ if (!url.startsWith('https://')) {
678
+ t.check(`listing ${k}`, false, url ? `not https: ${url}` : 'missing');
679
+ continue;
680
+ }
681
+ if (new URL(url).origin !== canonical) {
682
+ t.check(`listing ${k}`, true, `${url} (another origin, not fetched)`);
683
+ continue;
684
+ }
685
+ const res = await fetch(here(url));
686
+ const anchor = decodeURIComponent(new URL(url).hash.slice(1));
687
+ const found = !anchor || (await res.text()).includes(`id="${anchor}"`);
688
+ t.check(`listing ${k}`, res.ok && found, `${res.status} ${url}${found ? '' : ` (no id="${anchor}")`}`);
689
+ }
690
+ }
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
  /**
@@ -230,6 +240,18 @@ export interface EnvelopeGate {
230
240
  empty: boolean;
231
241
  finish(dispatched: object | object[] | null): Promise<object | object[] | null>;
232
242
  }
243
+ /**
244
+ * The same server with its writes taken off, for a client that may not carry
245
+ * them: ChatGPT's plugin review refuses crypto transfers and checkout links, and
246
+ * a reviewer cannot sign a ROLA challenge. It keeps the tools marked
247
+ * `readOnlyHint: true` and not in `hide`, then drops every instruction line and
248
+ * prompt that names a tool it lost, so nothing sends an agent to a tool that is
249
+ * not there.
250
+ *
251
+ * The kept tools state all three hints that review reads. A read-only tool
252
+ * destroys nothing, and an unstated `openWorldHint` is the spec's default, true.
253
+ */
254
+ export declare function readOnly(config: McpServerConfig, hide?: readonly string[]): McpServerConfig;
233
255
  /**
234
256
  * A caller-fixable failure inside a handler (page not found, empty input).
235
257
  * Reported as a tool result with `isError` so the model sees the text and can
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
@@ -85,6 +86,28 @@ export function toolCard(tool) {
85
86
  ...(tool.annotations ? { annotations: tool.annotations } : {}),
86
87
  };
87
88
  }
89
+ /**
90
+ * The same server with its writes taken off, for a client that may not carry
91
+ * them: ChatGPT's plugin review refuses crypto transfers and checkout links, and
92
+ * a reviewer cannot sign a ROLA challenge. It keeps the tools marked
93
+ * `readOnlyHint: true` and not in `hide`, then drops every instruction line and
94
+ * prompt that names a tool it lost, so nothing sends an agent to a tool that is
95
+ * not there.
96
+ *
97
+ * The kept tools state all three hints that review reads. A read-only tool
98
+ * destroys nothing, and an unstated `openWorldHint` is the spec's default, true.
99
+ */
100
+ export function readOnly(config, hide = []) {
101
+ const kept = config.tools.filter(t => t.annotations?.readOnlyHint === true && !hide.includes(t.name));
102
+ const lost = config.tools.filter(t => !kept.includes(t)).map(t => t.name);
103
+ const names = (text) => lost.some(name => new RegExp(`\\b${name}\\b`).test(text));
104
+ return {
105
+ ...config,
106
+ instructions: config.instructions.split('\n').filter(line => !names(line)).join('\n'),
107
+ tools: kept.map(t => ({ ...t, annotations: { destructiveHint: false, openWorldHint: true, ...t.annotations } })),
108
+ prompts: config.prompts?.filter(p => !names(p.text)),
109
+ };
110
+ }
88
111
  /**
89
112
  * A caller-fixable failure inside a handler (page not found, empty input).
90
113
  * Reported as a tool result with `isError` so the model sees the text and can
@@ -418,6 +441,8 @@ async function handleRpc(req, config, dispatch) {
418
441
  }
419
442
  }
420
443
  default:
444
+ if (isA2aMethod(method))
445
+ return await handleA2a(req, config);
421
446
  return methodNotFound(id, method, config, dispatch.era);
422
447
  }
423
448
  }
@@ -452,7 +477,7 @@ export async function handleMcp(body, config, dispatch = { protocolVersion: ASSU
452
477
  export const MCP_CORS = {
453
478
  'Access-Control-Allow-Origin': '*',
454
479
  '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',
480
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Mcp-Method, Mcp-Name, Last-Event-ID, A2A-Version',
456
481
  // Allow-Headers governs what a browser may send; without Expose-Headers it
457
482
  // may read none of what comes back. A browser client could not see the
458
483
  // 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;
@@ -104,6 +104,13 @@ protocolVersions: readonly string[]): Record<string, unknown>;
104
104
  * configured" instead of failing verification for a reason nobody sees.
105
105
  */
106
106
  export declare function registryAuthHandler(publicKey?: string | undefined, keyType?: string | undefined): () => Response;
107
+ /**
108
+ * `/.well-known/openai-apps-challenge`: the token OpenAI's plugin portal issues
109
+ * to prove the MCP origin is the publisher's, served as the bare text it checks
110
+ * for. An env var for the same reason as the registry key: the portal issues it
111
+ * per plugin, after the code has shipped. Unset → 404.
112
+ */
113
+ export declare function openaiChallengeHandler(token?: string | undefined): () => Response;
107
114
  /** A tool as `/.well-known/mcp.json` lists it: what tools/list says, no handler. */
108
115
  export interface ManifestTool {
109
116
  name: 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,
@@ -168,6 +178,17 @@ export function registryAuthHandler(publicKey = process.env.MCP_REGISTRY_PUBLIC_
168
178
  });
169
179
  };
170
180
  }
181
+ /**
182
+ * `/.well-known/openai-apps-challenge`: the token OpenAI's plugin portal issues
183
+ * to prove the MCP origin is the publisher's, served as the bare text it checks
184
+ * for. An env var for the same reason as the registry key: the portal issues it
185
+ * per plugin, after the code has shipped. Unset → 404.
186
+ */
187
+ export function openaiChallengeHandler(token = process.env.OPENAI_APPS_CHALLENGE) {
188
+ return () => token
189
+ ? new Response(token.trim(), { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } })
190
+ : new Response('Not found', { status: 404 });
191
+ }
171
192
  /**
172
193
  * `/.well-known/mcp.json`: the third path an arriving agent probes, after the
173
194
  * agent card and the OpenAPI spec. All three origins wrote the same envelope,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.70.0",
3
+ "version": "0.73.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",