wiki-formant 0.71.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 +7 -0
- package/dist/conformance.d.ts +15 -0
- package/dist/conformance.js +88 -0
- package/dist/mcp.d.ts +12 -0
- package/dist/mcp.js +22 -0
- package/dist/well-known.d.ts +7 -0
- package/dist/well-known.js +11 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -213,6 +213,12 @@ What it gets right:
|
|
|
213
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.
|
|
214
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.
|
|
215
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
|
+
|
|
216
222
|
### Structured output, `_meta`, and the envelope gate
|
|
217
223
|
|
|
218
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.
|
|
@@ -260,6 +266,7 @@ The drift checks hold an MCP server to what its own site does, so a tool cannot
|
|
|
260
266
|
- `roundTrip(t, label, { read, write })`: what a read tool hands out, the write tool takes back unchanged, and the next read matches.
|
|
261
267
|
- `openapiWriteParity(t, [[path, method, tool], …])`: every field an OpenAPI write documents, the matching tool takes.
|
|
262
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.
|
|
263
270
|
|
|
264
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.
|
|
265
272
|
|
package/dist/conformance.d.ts
CHANGED
|
@@ -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>;
|
package/dist/conformance.js
CHANGED
|
@@ -600,3 +600,91 @@ export async function rolaOriginCheck(t, opts = {}) {
|
|
|
600
600
|
const message = answer?.sign?.message ?? '';
|
|
601
601
|
t.check('ROLA recipe names this origin', message.includes(`"${origin}"`), `expects "${origin}" in: …${message.slice(-90)}`);
|
|
602
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
|
@@ -240,6 +240,18 @@ export interface EnvelopeGate {
|
|
|
240
240
|
empty: boolean;
|
|
241
241
|
finish(dispatched: object | object[] | null): Promise<object | object[] | null>;
|
|
242
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;
|
|
243
255
|
/**
|
|
244
256
|
* A caller-fixable failure inside a handler (page not found, empty input).
|
|
245
257
|
* Reported as a tool result with `isError` so the model sees the text and can
|
package/dist/mcp.js
CHANGED
|
@@ -86,6 +86,28 @@ export function toolCard(tool) {
|
|
|
86
86
|
...(tool.annotations ? { annotations: tool.annotations } : {}),
|
|
87
87
|
};
|
|
88
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
|
+
}
|
|
89
111
|
/**
|
|
90
112
|
* A caller-fixable failure inside a handler (page not found, empty input).
|
|
91
113
|
* Reported as a tool result with `isError` so the model sees the text and can
|
package/dist/well-known.d.ts
CHANGED
|
@@ -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;
|
package/dist/well-known.js
CHANGED
|
@@ -178,6 +178,17 @@ export function registryAuthHandler(publicKey = process.env.MCP_REGISTRY_PUBLIC_
|
|
|
178
178
|
});
|
|
179
179
|
};
|
|
180
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
|
+
}
|
|
181
192
|
/**
|
|
182
193
|
* `/.well-known/mcp.json`: the third path an arriving agent probes, after the
|
|
183
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.
|
|
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",
|