mcplane 0.4.0 → 0.5.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +108 -18
  3. package/dist/agent-plugins.d.ts +6 -0
  4. package/dist/agent-plugins.js +76 -0
  5. package/dist/cdp.d.ts +49 -0
  6. package/dist/cdp.js +253 -0
  7. package/dist/checks/ard.d.ts +67 -0
  8. package/dist/checks/ard.js +215 -0
  9. package/dist/checks/auth.js +2 -1
  10. package/dist/checks/chain.d.ts +45 -0
  11. package/dist/checks/chain.js +171 -0
  12. package/dist/checks/listing.js +23 -14
  13. package/dist/checks/oauth.d.ts +45 -0
  14. package/dist/checks/oauth.js +64 -0
  15. package/dist/checks/readiness.d.ts +58 -0
  16. package/dist/checks/readiness.js +230 -0
  17. package/dist/checks/tools.js +38 -7
  18. package/dist/checks/vercel.d.ts +27 -0
  19. package/dist/checks/vercel.js +153 -0
  20. package/dist/cli.js +54 -6
  21. package/dist/core.d.ts +1 -0
  22. package/dist/core.js +20 -4
  23. package/dist/demo.d.ts +60 -0
  24. package/dist/demo.js +573 -0
  25. package/dist/drift.js +10 -3
  26. package/dist/evals.d.ts +34 -0
  27. package/dist/evals.js +75 -0
  28. package/dist/extras.js +1 -1
  29. package/dist/images.d.ts +21 -0
  30. package/dist/images.js +99 -0
  31. package/dist/listings.d.ts +14 -0
  32. package/dist/listings.js +40 -1
  33. package/dist/mcp-client.d.ts +1 -5
  34. package/dist/mcp-client.js +8 -1
  35. package/dist/mcp-server.js +21 -11
  36. package/dist/packs.d.ts +48 -18
  37. package/dist/packs.js +380 -90
  38. package/dist/publish.js +13 -4
  39. package/dist/report.js +30 -13
  40. package/dist/skills.d.ts +21 -0
  41. package/dist/skills.js +180 -0
  42. package/dist/submissions.js +2 -1
  43. package/dist/types.d.ts +37 -4
  44. package/dist/types.js +1 -0
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/dist/video.d.ts +58 -0
  48. package/dist/video.js +190 -0
  49. package/dist/zip.d.ts +16 -2
  50. package/dist/zip.js +65 -5
  51. package/package.json +4 -1
  52. package/schema.json +86 -12
  53. package/schemas/agent-plugins-1.0.0/LICENSE +202 -0
  54. package/schemas/agent-plugins-1.0.0/mcp.schema.json +120 -0
  55. package/schemas/agent-plugins-1.0.0/plugin.schema.json +65 -0
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Readiness for MCP 2026-07-28: warnings and notes, never blockers. The revision drops the
3
+ * initialize handshake for per-request _meta and server/discover, requires Mcp-Method and Mcp-Name
4
+ * headers on Streamable HTTP, deprecates Dynamic Client Registration for Client ID Metadata
5
+ * Documents, and has clients validate the issuer (RFC 8414 §3.3, RFC 9207). A warning means a
6
+ * 2026-07-28 client would fail today; a note means something deprecated or optional.
7
+ *
8
+ * Spec: https://modelcontextprotocol.io/specification/2026-07-28
9
+ * Key changes: https://modelcontextprotocol.io/specification/2026-07-28/changelog
10
+ * Release post: https://blog.modelcontextprotocol.io/posts/2026-07-28/
11
+ */
12
+ import { McpClient } from '../mcp-client.js';
13
+ import { VERSION } from '../version.js';
14
+ import { metadata } from './oauth.js';
15
+ export const MODERN = '2026-07-28';
16
+ /** Handshake-era revisions, any of which is a fine answer to an initialize. */
17
+ const LEGACY = ['2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25'];
18
+ /** Every modern request carries its version, client and capabilities in _meta (basic/versioning). */
19
+ const META = {
20
+ 'io.modelcontextprotocol/protocolVersion': MODERN,
21
+ 'io.modelcontextprotocol/clientInfo': { name: 'mcplane', version: VERSION },
22
+ 'io.modelcontextprotocol/clientCapabilities': {},
23
+ };
24
+ /** MCP-Protocol-Version and Mcp-Method go on every modern POST (basic/transports/streamable-http#request-metadata). */
25
+ const modernHeaders = (method) => ({ 'mcp-protocol-version': MODERN, 'mcp-method': method });
26
+ const check = (id, level, title, more = {}) => ({ id: `readiness.${id}`, level, title, ...more });
27
+ const rpcError = (r) => (r?.body?.error ? `JSON-RPC ${r.body.error.code}${r.body.error.message ? ` ${String(r.body.error.message).slice(0, 80)}` : ''}` : '');
28
+ const said = (r) => (r ? [`HTTP ${r.status}`, rpcError(r)].filter(Boolean).join(', ') : 'no answer');
29
+ /**
30
+ * Reads the answer to a modern server/discover request the way a dual-era client does
31
+ * (basic/versioning#backward-compatibility): a DiscoverResult or a modern error means a modern
32
+ * server; a 4xx or a JSON-RPC error that isn't modern means a legacy one, and the client falls
33
+ * back to initialize. A 5xx, or no JSON-RPC answer at all, gives it nothing to go on.
34
+ */
35
+ export function eraOf(r) {
36
+ if (!r)
37
+ return { era: 'broken', versions: [], detail: 'no answer' };
38
+ if (r.status === 401)
39
+ return { era: 'auth', versions: [], detail: 'HTTP 401' };
40
+ const versions = r.body?.result?.supportedVersions;
41
+ if (Array.isArray(versions))
42
+ return { era: 'modern', versions, detail: `supportedVersions ${versions.join(', ')}` };
43
+ // UnsupportedProtocolVersionError (-32022): modern, but not this version.
44
+ if (r.body?.error?.code === -32022)
45
+ return { era: 'modern', versions: r.body.error.data?.supported ?? [], detail: said(r) };
46
+ if (r.status >= 500)
47
+ return { era: 'broken', versions: [], detail: said(r) };
48
+ if (typeof r.body?.error?.code === 'number' || r.status >= 400)
49
+ return { era: 'legacy', versions: [], detail: said(r) };
50
+ return { era: 'broken', versions: [], detail: `HTTP ${r.status} without a JSON-RPC answer` };
51
+ }
52
+ export function eraCheck(e) {
53
+ const title = 'Speaks MCP 2026-07-28';
54
+ if (e.era === 'modern' && e.versions.includes(MODERN))
55
+ return check('era', 'pass', title, { detail: `server/discover: ${e.detail}` });
56
+ if (e.era === 'modern')
57
+ return check('era', 'info', title, { detail: `server/discover: ${e.detail}`, fix: 'Modern, but not 2026-07-28: clients retry with a version from your supported list.' });
58
+ if (e.era === 'auth')
59
+ return check('era', 'skip', title, { detail: 'the server answers 401 before sign-in', fix: 'Run again with --token <access token> to check how a 2026-07-28 client gets on after signing in.' });
60
+ if (e.era === 'legacy')
61
+ return check('era', 'info', title, {
62
+ detail: `legacy only: server/discover got ${e.detail}`,
63
+ fix: 'Nothing breaks yet: clients that speak both eras fall back to initialize. To serve 2026-07-28 clients directly, move to an SDK release that implements it (TypeScript, Python, Go and C# do) and answer server/discover.',
64
+ });
65
+ return check('era', 'warn', title, {
66
+ detail: `server/discover got ${e.detail}`,
67
+ fix: 'A 2026-07-28 client’s first request gets an error page. Clients that speak both eras fall back to initialize on a 4xx or a JSON-RPC error, so answer methods you don’t know with JSON-RPC -32601 instead of failing.',
68
+ });
69
+ }
70
+ /**
71
+ * An initialize that offers 2026-07-28. Under the 2025-11-25 lifecycle a server answers with the
72
+ * requested version only if it supports it, otherwise with one it does; 2026-07-28 has no initialize
73
+ * at all, so the right answer is a handshake-era version. A modern-only server rejects initialize
74
+ * and SHOULD name its versions in the error (basic/versioning).
75
+ * https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
76
+ */
77
+ export function initializeCheck(r, era) {
78
+ const title = 'An initialize that offers 2026-07-28 is negotiated down';
79
+ if (r?.status === 401)
80
+ return check('initialize', 'skip', title, { detail: 'HTTP 401', fix: 'Run again with --token <access token>.' });
81
+ const v = r?.body?.result?.protocolVersion;
82
+ if (v === MODERN)
83
+ return check('initialize', 'warn', title, {
84
+ detail: 'initialize answered with "2026-07-28"',
85
+ fix: '2026-07-28 has no initialize handshake, so answering one with it tells the client you speak something you don’t. Answer with the newest version you implement (e.g. 2025-11-25). A handler that copies params.protocolVersion back is the usual cause: check it against a supported list.',
86
+ });
87
+ if (typeof v === 'string' && LEGACY.includes(v))
88
+ return check('initialize', 'pass', title, { detail: `answered with ${v}` });
89
+ if (typeof v === 'string')
90
+ return check('initialize', 'warn', title, { detail: `answered with "${v}", which isn't a published version`, fix: 'Answer with a version you implement, such as 2025-11-25.' });
91
+ if (era === 'modern') {
92
+ const named = /\d{4}-\d{2}-\d{2}/.test(JSON.stringify(r?.body ?? ''));
93
+ return named
94
+ ? check('initialize', 'pass', title, { detail: `modern only: initialize got ${said(r)}, naming the versions it supports` })
95
+ : check('initialize', 'info', title, {
96
+ detail: `modern only: initialize got ${said(r)}`,
97
+ fix: 'Name the versions you support in that error. Clients from before 2026-07-28 have no way forward, and the error is the only thing they can show.',
98
+ });
99
+ }
100
+ return check('initialize', 'warn', title, {
101
+ detail: `initialize got ${said(r)}`,
102
+ fix: 'Answer with a version you support instead of an error. Under the 2025-11-25 lifecycle a server that doesn’t support the requested version replies with one it does, and clients that offer their newest version first fail against you otherwise.',
103
+ });
104
+ }
105
+ /** Modern servers: a tools/list carrying Mcp-Method is the ordinary case. A strict server rejects one without it (-32020). */
106
+ export function modernHeaderCheck(r) {
107
+ const title = 'Accepts the Mcp-Method and Mcp-Name headers';
108
+ return r && r.status < 300 && r.body?.result
109
+ ? check('headers', 'pass', title, { detail: 'a 2026-07-28 tools/list with Mcp-Method works (Mcp-Name only goes on tools/call, resources/read and prompts/get, which mcplane doesn’t call)' })
110
+ : check('headers', 'warn', title, { detail: `a 2026-07-28 tools/list with Mcp-Method got ${said(r)}`, fix: 'Mcp-Method is required on every 2026-07-28 POST and must match the body’s method.' });
111
+ }
112
+ /** Legacy servers: the same tools/list with and without the new headers should get the same answer. */
113
+ export function legacyHeaderCheck(plain, tagged) {
114
+ const title = 'Accepts the Mcp-Method and Mcp-Name headers';
115
+ const ok = (r) => !!r && r.status < 300 && !r.body?.error;
116
+ if (!ok(plain))
117
+ return check('headers', 'skip', title, { detail: `tools/list got ${said(plain)} without them, so there was nothing to compare` });
118
+ return ok(tagged)
119
+ ? check('headers', 'pass', title, { detail: 'tools/list answers the same with Mcp-Method and Mcp-Name set' })
120
+ : check('headers', 'warn', title, {
121
+ detail: `tools/list works, but with Mcp-Method and Mcp-Name set it got ${said(tagged)}`,
122
+ fix: '2026-07-28 clients send these on every request, and gateways route on them. A firewall rule, proxy or header allowlist is rejecting them: let Mcp-* headers through.',
123
+ });
124
+ }
125
+ /** Browser clients send both headers on every POST, so the CORS preflight must allow them. Null when the server doesn't answer CORS at all (server.cors covers that). */
126
+ export function corsCheck(allowOrigin, allowHeaders) {
127
+ if (!allowOrigin)
128
+ return null;
129
+ const allowed = (allowHeaders ?? '').toLowerCase().split(',').map((h) => h.trim());
130
+ const missing = ['mcp-method', 'mcp-name'].filter((h) => !allowed.includes(h) && !allowed.includes('*'));
131
+ return missing.length
132
+ ? check('cors', 'info', 'CORS allows the Mcp-Method and Mcp-Name headers', {
133
+ detail: `Access-Control-Allow-Headers: ${allowHeaders ?? 'missing'}`,
134
+ fix: `Add ${missing.join(' and ')} to Access-Control-Allow-Headers. Browser-based 2026-07-28 clients send them on every request, and the preflight fails without them.`,
135
+ })
136
+ : check('cors', 'pass', 'CORS allows the Mcp-Method and Mcp-Name headers');
137
+ }
138
+ /**
139
+ * MCP Events is a draft, not part of 2026-07-28: events/list, events/poll, events/stream and
140
+ * events/subscribe, from the Triggers and Events Working Group. Reported, never a warning.
141
+ * https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/main/docs/design-sketch-proposal.md
142
+ */
143
+ export function eventsCheck(r) {
144
+ const title = 'Lists MCP Events (events/list, a draft)';
145
+ const events = r?.body?.result?.events;
146
+ if (Array.isArray(events))
147
+ return check('events', 'pass', title, { detail: events.length ? events.map((e) => e.name).join(', ') : 'no event types yet' });
148
+ if (r?.status === 401)
149
+ return check('events', 'skip', title, { detail: 'HTTP 401' });
150
+ return check('events', 'info', title, {
151
+ detail: `events/list got ${said(r)}`,
152
+ fix: 'Nothing to do yet. Events is a draft from the Triggers and Events Working Group, not part of 2026-07-28; if it lands, servers declare event types with events/list.',
153
+ });
154
+ }
155
+ /** 2026-07-28 clients MUST check the metadata's issuer against the issuer they built the URL from, and MUST NOT use it otherwise (authorization-server-discovery). */
156
+ export function issuerCheck(d) {
157
+ const doc = d.oauth ?? d.oidc;
158
+ if (!doc?.forIssuer)
159
+ return null;
160
+ const title = 'The metadata’s issuer matches its URL';
161
+ const iss = doc.json?.issuer;
162
+ if (iss === doc.forIssuer)
163
+ return check('issuer', 'pass', title, { detail: iss });
164
+ return check('issuer', 'warn', title, {
165
+ detail: typeof iss === 'string' ? `${doc.url} says "${iss}", expected "${doc.forIssuer}"` : `${doc.url} has no issuer`,
166
+ fix: 'Make issuer exactly the identifier the well-known URL is built from, trailing slash included (RFC 8414 §3.3). 2026-07-28 clients must refuse metadata whose issuer differs.',
167
+ });
168
+ }
169
+ /** client_id_metadata_document_supported: DCR is deprecated in 2026-07-28 for CIMD, removable from the first revision on or after 2027-07-28. */
170
+ export function cimdCheck(md) {
171
+ const title = 'Client ID Metadata Documents are advertised';
172
+ return md.client_id_metadata_document_supported === true
173
+ ? check('cimd', 'pass', title)
174
+ : check('cimd', 'info', title, {
175
+ detail: md.registration_endpoint ? 'Dynamic Client Registration only' : 'neither CIMD nor Dynamic Client Registration',
176
+ fix: '2026-07-28 deprecates Dynamic Client Registration in favour of Client ID Metadata Documents; DCR can go in the first revision on or after 2027-07-28. Accept URL client_ids, fetch and validate the document, and advertise client_id_metadata_document_supported: true.',
177
+ });
178
+ }
179
+ /** authorization_response_iss_parameter_supported (RFC 9207): a SHOULD in 2026-07-28, expected to become a MUST. */
180
+ export function issCheck(md) {
181
+ const title = 'The authorization server returns iss (RFC 9207)';
182
+ return md.authorization_response_iss_parameter_supported === true
183
+ ? check('iss', 'pass', title)
184
+ : check('iss', 'info', title, {
185
+ detail: 'authorization_response_iss_parameter_supported not declared',
186
+ fix: 'Return iss in authorization responses, error responses included, and advertise authorization_response_iss_parameter_supported: true. It is a SHOULD in 2026-07-28 and expected to become a MUST; clients already compare any iss they get.',
187
+ });
188
+ }
189
+ export async function readinessChecks(url, auth, opts = {}) {
190
+ const bearer = opts.token ? { authorization: `Bearer ${opts.token}` } : {};
191
+ // Each modern request stands alone: a fresh client, so no session id goes with it.
192
+ const modern = (method) => new McpClient(url, bearer).request(method, { _meta: META }, { headers: modernHeaders(method) }).catch(() => null);
193
+ const e = eraOf(await modern('server/discover'));
194
+ const checks = [eraCheck(e)];
195
+ if (e.era !== 'auth') {
196
+ const init = await new McpClient(url, bearer).request('initialize', { protocolVersion: MODERN, capabilities: {}, clientInfo: { name: 'mcplane', version: VERSION } }).catch(() => null);
197
+ checks.push(initializeCheck(init, e.era));
198
+ if (e.era === 'modern') {
199
+ checks.push(modernHeaderCheck(await modern('tools/list')));
200
+ checks.push(eventsCheck(await modern('events/list')));
201
+ }
202
+ else {
203
+ const c = new McpClient(url, bearer);
204
+ const opened = await c.initialize().catch(() => null);
205
+ if (opened && opened.status < 300) {
206
+ const plain = await c.request('tools/list').catch(() => null);
207
+ const tagged = await c.request('tools/list', undefined, { headers: { 'mcp-method': 'tools/list', 'mcp-name': 'mcplane-probe' } }).catch(() => null);
208
+ checks.push(legacyHeaderCheck(plain, tagged));
209
+ checks.push(eventsCheck(await c.request('events/list').catch(() => null)));
210
+ }
211
+ }
212
+ const pre = await fetch(url, {
213
+ method: 'OPTIONS',
214
+ headers: { origin: 'https://chatgpt.com', 'access-control-request-method': 'POST', 'access-control-request-headers': 'content-type, mcp-protocol-version, mcp-method, mcp-name' },
215
+ signal: AbortSignal.timeout(10_000),
216
+ }).catch(() => null);
217
+ const cors = pre && pre.status < 300 ? corsCheck(pre.headers.get('access-control-allow-origin'), pre.headers.get('access-control-allow-headers')) : null;
218
+ if (cors)
219
+ checks.push(cors);
220
+ }
221
+ // From the discovery documents alone, so these run before sign-in too.
222
+ const md = metadata(opts.oauth);
223
+ if (auth === 'oauth' && opts.oauth && md) {
224
+ const iss = issuerCheck(opts.oauth);
225
+ if (iss)
226
+ checks.push(iss);
227
+ checks.push(cimdCheck(md), issCheck(md));
228
+ }
229
+ return checks;
230
+ }
@@ -16,6 +16,19 @@ const INSTRUCTION = [
16
16
  const UPSELL = /\b(upgrade (to|your|now)|pro plan|premium (plan|tier|account|subscription|version)|paid (plan|tier|account)|free trial|subscribe (to|now)|unlock (more|premium|pro|unlimited|all))\b/i;
17
17
  /** Inputs that suggest a write authorised only by a secret passed through the chat. */
18
18
  const BEARER_INPUT = /^(token|edit_token|update_token|secret|access_key|api_key|password)$/i;
19
+ /** OpenAI's destructive effects ("deletion, overwriting, cancellation, access revocation") in the words a tool uses about itself. */
20
+ const DESTRUCTIVE = /\b(overwrit\w*|replac\w*|revo[kc]\w*|delet\w*|cancel\w*|archiv\w*|expir\w*|passwords?|gat(?:e|es|ed|ing))\b/gi;
21
+ const NEGATED = /\b(never|not|no|without|nothing|refuses?|cannot|can['’]t|doesn['’]t|don['’]t|won['’]t)\b(\s+\w+){0,2}\s*$/i;
22
+ /** The destructive words in a tool's name, title and description, skipping negated ones ("never overwrites"). */
23
+ function destructiveWords(text) {
24
+ const found = new Set();
25
+ for (const m of text.matchAll(DESTRUCTIVE)) {
26
+ if (NEGATED.test(text.slice(Math.max(0, m.index - 40), m.index)))
27
+ continue;
28
+ found.add(m[1].toLowerCase());
29
+ }
30
+ return [...found];
31
+ }
19
32
  export function toolChecks(tools) {
20
33
  if (!tools.length)
21
34
  return [];
@@ -31,7 +44,7 @@ export function toolChecks(tools) {
31
44
  level: 'fail',
32
45
  title: 'Safety annotations are explicit on every tool',
33
46
  detail: missing.join(', '),
34
- fix: 'Set readOnlyHint, destructiveHint and openWorldHint to true or false on every tool. ChatGPT asks you to justify each explicit value and flags missing ones; Claude checks them against your read/write answer.',
47
+ fix: 'Set readOnlyHint, destructiveHint and openWorldHint to true or false on every tool. ChatGPT requires explicit values and its automated review checks them against what each tool does (justifications are no longer required; appeal if it flags one you believe is right). Claude checks them against your read/write answer.',
35
48
  stores: ['chatgpt', 'claude-connectors'],
36
49
  }
37
50
  : { id: 'tools.hints', level: 'pass', title: 'Safety annotations are explicit on every tool' });
@@ -70,8 +83,8 @@ export function toolChecks(tools) {
70
83
  stores: ['claude-connectors'],
71
84
  }
72
85
  : { id: 'tools.bearer-writes', level: 'pass', title: 'Writes are bound to an owner, not a token in the chat' });
73
- // OpenAI: openWorldHint is true for tools that reach the public internet or open-ended external entities,
74
- // read-only ones like web search included, and for writes that post, send, publish, push or submit.
86
+ // OpenAI: openWorldHint is true for public or open-ended entities, read-only web search and arbitrary destinations
87
+ // included; a tool confined to a bounded private account, workspace or catalog may be false. Connectivity alone doesn't decide it.
75
88
  const openWorld = tools.filter((t) => t.annotations?.openWorldHint === false &&
76
89
  /\b(send|sends|sent|e-?mail(s|ed)?|post|posts|publish|publishes|public|tweet|message|notify|notifies|web search|search the web|internet|any url|fetch(es)? (a |the |any )?url|scrape|scrapes|crawl|crawls)\b/i.test(`${t.description ?? ''} ${Object.keys(t.inputSchema?.properties ?? {}).join(' ')}`));
77
90
  checks.push(openWorld.length
@@ -79,12 +92,12 @@ export function toolChecks(tools) {
79
92
  id: 'tools.open-world',
80
93
  level: 'warn',
81
94
  title: 'openWorldHint matches what the tool does',
82
- detail: `${openWorld.map((t) => t.name).join(', ')}: openWorldHint is false, but the tool seems to send, post or publish`,
83
- fix: 'OpenAI: set openWorldHint to true "if the tool accesses the public internet or open-ended external entities", including read-only tools such as web search and tools that post, send messages to external recipients, publish, push code or submit forms. False only when it is limited to a bounded private account or workspace.',
95
+ detail: `${openWorld.map((t) => t.name).join(', ')}: openWorldHint is false, but the tool seems to reach public or open-ended destinations (sending, posting, publishing or searching the web)`,
96
+ fix: 'OpenAI: use true "for public or open-ended entities, including read-only web search and arbitrary destinations". A tool "confined to a bounded private account, workspace, or catalog may use false, even when externally hosted", and "connectivity alone does not determine this value". If it only reaches the user’s own account, false is right.',
84
97
  stores: ['chatgpt'],
85
98
  }
86
99
  : { id: 'tools.open-world', level: 'pass', title: 'openWorldHint matches what the tool does' });
87
- // OpenAI: destructiveHint is true for irreversible outcomes, "sending messages or transactions you can't undo" included.
100
+ // OpenAI: for writes, destructiveHint is true for destructive or irreversible effects, "irreversible sends or transactions" included.
88
101
  const sends = tools.filter((t) => t.annotations?.destructiveHint === false && t.annotations?.readOnlyHint !== true && /\b(sends?|sent|e-?mail(s|ed)?|sms|texts?|messag(es?|ed)|notif(y|ies|ied)|transfers?|pays?|payments?)\b/i.test(t.description ?? ''));
89
102
  if (sends.length) {
90
103
  checks.push({
@@ -92,7 +105,25 @@ export function toolChecks(tools) {
92
105
  level: 'warn',
93
106
  title: 'destructiveHint covers messages that can’t be unsent',
94
107
  detail: `${sends.map((t) => t.name).join(', ')}: destructiveHint is false, but the tool seems to send something`,
95
- fix: 'OpenAI sets destructiveHint to true for tools that can cause irreversible outcomes, "sending messages or transactions you can\'t undo" included, even through default parameters. If a user can\'t take it back, set it to true and say in the justification what safeguards exist (confirmation, scoping).',
108
+ fix: 'OpenAI: for writes, use true "for potentially destructive or irreversible effects, such as deletion, overwriting, cancellation, access revocation, or irreversible sends or transactions", and false "only for additive writes without destructive or irreversible effects". A message can’t be unsent, so set it to true, and ask for confirmation before it goes.',
109
+ stores: ['chatgpt'],
110
+ });
111
+ }
112
+ // The same definition covers overwriting, revoking access and deleting; an undo doesn't change that.
113
+ // Conservative: explicit write tools whose own words say so, negated mentions ("never overwrites") excluded.
114
+ const destroys = tools.flatMap((t) => {
115
+ if (t.annotations?.readOnlyHint !== false || t.annotations?.destructiveHint !== false)
116
+ return [];
117
+ const words = destructiveWords(`${t.name.replace(/[_-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2')} ${t.title ?? ''} ${t.description ?? ''}`);
118
+ return words.length ? [`${t.name} (${words.join(', ')})`] : [];
119
+ });
120
+ if (destroys.length) {
121
+ checks.push({
122
+ id: 'tools.destructive-overwrite',
123
+ level: 'warn',
124
+ title: 'destructiveHint covers overwriting, revoking and deleting',
125
+ detail: `${destroys.join('; ')}: destructiveHint is false, but the tool seems to overwrite, revoke access or delete`,
126
+ fix: 'OpenAI: for writes, use true "for potentially destructive or irreversible effects, such as deletion, overwriting, cancellation, access revocation, or irreversible sends or transactions". "Being able to undo an action does not, by itself, justify setting destructiveHint to false." If the tool only adds, keep false, and appeal if review flags it.',
96
127
  stores: ['chatgpt'],
97
128
  });
98
129
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Vercel Connect, the connector catalog for apps and agents, takes service submissions since
3
+ * 29 Sep 2026. These checks follow its "For Service Providers" page, from your discovery documents
4
+ * alone (nothing is registered). Its tiers set the level: Required blocks, Recommended warns,
5
+ * Optional is a note. What needs a real token (expires_in, refresh rotation, the redirect URL) is
6
+ * left to the reminders.
7
+ *
8
+ * https://vercel.com/docs/connect/providers
9
+ * https://vercel.com/changelog/vercel-connect-service-submissions
10
+ */
11
+ import type { Check, Manifest } from '../types.js';
12
+ import { type Discovery } from './oauth.js';
13
+ export declare const CONNECT_CALLBACK = "https://connect.vercel.com/callback";
14
+ /** The client authentication methods Vercel Connect registers clients with. */
15
+ export declare const CONNECT_AUTH_METHODS: string[];
16
+ /** Each grant, and the token subject type Connect offers for it. */
17
+ export declare const SUBJECTS: Record<string, string>;
18
+ /** What Connect can do about client creation, from the metadata: DCR, CIMD, both or neither. */
19
+ export declare function clientCreation(md: Record<string, any>): {
20
+ dcr: boolean;
21
+ cimd: boolean;
22
+ methods: string[] | undefined;
23
+ usable: string[];
24
+ };
25
+ export declare function vercelChecks(m: Manifest, d: Discovery | null): Check[];
26
+ /** What only a token or the form can show. */
27
+ export declare const VERCEL_REMINDERS: string[];
@@ -0,0 +1,153 @@
1
+ import { isJsonType, metadata, wellKnown } from './oauth.js';
2
+ const S = ['vercel-connect'];
3
+ export const CONNECT_CALLBACK = 'https://connect.vercel.com/callback';
4
+ /** The client authentication methods Vercel Connect registers clients with. */
5
+ export const CONNECT_AUTH_METHODS = ['client_secret_basic', 'client_secret_post', 'none', 'private_key_jwt'];
6
+ /** Each grant, and the token subject type Connect offers for it. */
7
+ export const SUBJECTS = {
8
+ authorization_code: 'user',
9
+ client_credentials: 'app',
10
+ 'urn:ietf:params:oauth:grant-type:jwt-bearer': 'jwt-bearer',
11
+ };
12
+ const check = (id, level, title, more = {}) => ({ id: `vercel.${id}`, level, title, ...more, stores: S });
13
+ /** What Connect can do about client creation, from the metadata: DCR, CIMD, both or neither. */
14
+ export function clientCreation(md) {
15
+ const methods = Array.isArray(md.token_endpoint_auth_methods_supported) ? md.token_endpoint_auth_methods_supported : undefined;
16
+ // RFC 8414 §2: when the list is left out, the default is client_secret_basic.
17
+ const usable = (methods ?? ['client_secret_basic']).filter((x) => CONNECT_AUTH_METHODS.includes(x));
18
+ const s256 = (md.code_challenge_methods_supported ?? []).includes('S256');
19
+ return {
20
+ dcr: typeof md.registration_endpoint === 'string' && usable.length > 0,
21
+ cimd: md.client_id_metadata_document_supported === true && (!!methods?.includes('private_key_jwt') || (!!methods?.includes('none') && s256)),
22
+ methods,
23
+ usable,
24
+ };
25
+ }
26
+ export function vercelChecks(m, d) {
27
+ if ((m.server.auth ?? 'none') !== 'oauth') {
28
+ return [
29
+ m.vercelConnect?.apiKey
30
+ ? check('method', 'pass', 'Vercel Connect has a connection method', { detail: 'API key: Vercel validates its configuration only, with no token test' })
31
+ : check('method', 'warn', 'Vercel Connect has a connection method', {
32
+ detail: 'server.auth is "none" and mcplane.json has no vercelConnect.apiKey',
33
+ fix: 'Vercel Connect brokers OAuth tokens and API keys, and a submission needs at least one connection method. An authless server has nothing to connect; if users need a key, describe it under vercelConnect.apiKey.',
34
+ }),
35
+ ];
36
+ }
37
+ if (!d)
38
+ return [];
39
+ const md = metadata(d);
40
+ const found = [d.oauth, d.oidc].filter((x) => x !== null);
41
+ const tried = [...d.oauthTried, ...d.oidcTried];
42
+ const out = [];
43
+ // Required: discovery documents, served as JSON, at the path-suffixed form when the issuer has a path.
44
+ if (!md) {
45
+ const notJson = tried.find((t) => t.status === 200 && t.json && !isJsonType(t.contentType));
46
+ out.push(check('metadata', 'fail', 'Authorization-server metadata is published as JSON', {
47
+ detail: notJson ? `${notJson.url} is served as ${notJson.contentType || 'no content type'}` : tried.map((t) => `${t.url} (${t.status || 'no answer'})`).join(', '),
48
+ fix: notJson
49
+ ? 'Serve it as application/json. Vercel Connect skips a document that exists but isn’t served as JSON.'
50
+ : `Publish RFC 8414 metadata (or OpenID Connect discovery) for ${d.issuer}. Vercel Connect reads your endpoints and capabilities from it; without it your users enter every endpoint by hand.`,
51
+ }));
52
+ return out;
53
+ }
54
+ const oauthForms = wellKnown(d.issuer, 'oauth-authorization-server');
55
+ const oidcForms = wellKnown(d.issuer, 'openid-configuration');
56
+ const originOnly = !!oauthForms.inserted && found.every((x) => x.url === oauthForms.origin || x.url === oidcForms.origin);
57
+ const title = 'Authorization-server metadata is published as JSON';
58
+ const pathFix = `For a URL with a path, Vercel Connect looks for the path-suffixed form and can't find metadata that is only at the origin. Serve it at ${oauthForms.inserted} too (RFC 8414 §3.1)`;
59
+ out.push(!originOnly
60
+ ? check('metadata', 'pass', title, { detail: found.map((x) => x.url).join(', ') })
61
+ : d.prm
62
+ ? // Your PRM names an issuer with a path, so every client builds the path-suffixed URL.
63
+ check('metadata', 'fail', title, { detail: `only at ${found.map((x) => x.url).join(', ')}, but the issuer ${d.issuer} has a path`, fix: `${pathFix}.` })
64
+ : // No PRM: Connect treats the MCP URL as the issuer. Entering the bare host still works.
65
+ check('metadata', 'warn', title, {
66
+ detail: `only at ${found.map((x) => x.url).join(', ')}, and with no protected-resource metadata Connect takes ${d.issuer} as the issuer`,
67
+ fix: `${pathFix}, or publish protected-resource metadata (RFC 9728) naming your issuer. Until then your users must enter ${new URL(d.issuer).host} rather than the MCP URL.`,
68
+ }));
69
+ // Required: with both documents, Connect fills gaps in the OAuth one from OpenID Connect, so they must agree.
70
+ if (d.oauth && d.oidc) {
71
+ const differ = ['issuer', 'token_endpoint'].filter((k) => d.oauth.json[k] !== d.oidc.json[k]);
72
+ out.push(differ.length
73
+ ? check('oidc-agrees', 'fail', 'OAuth and OpenID Connect metadata agree', {
74
+ detail: differ.map((k) => `${k}: "${d.oauth.json[k] ?? ''}" vs "${d.oidc.json[k] ?? ''}"`).join('; '),
75
+ fix: 'Vercel Connect fills gaps in your OAuth metadata from your OpenID Connect document, and both must declare the same issuer and token_endpoint.',
76
+ })
77
+ : check('oidc-agrees', 'pass', 'OAuth and OpenID Connect metadata agree'));
78
+ }
79
+ out.push(typeof md.token_endpoint === 'string'
80
+ ? check('token-endpoint', 'pass', 'token_endpoint is declared')
81
+ : check('token-endpoint', 'fail', 'token_endpoint is declared', { fix: 'Every Vercel Connect flow uses token_endpoint. Declare it in your metadata.' }));
82
+ // Required: grant types decide which token subject types a connector offers.
83
+ const grants = Array.isArray(md.grant_types_supported) ? md.grant_types_supported : [];
84
+ const subjects = grants.filter((g) => SUBJECTS[g]).map((g) => `${SUBJECTS[g]} (${g.replace('urn:ietf:params:oauth:grant-type:', '')})`);
85
+ out.push(!grants.length
86
+ ? check('grant-types', 'fail', 'grant_types_supported says what a connector can do', {
87
+ detail: 'grant_types_supported is missing',
88
+ fix: 'Declare it, e.g. ["authorization_code", "refresh_token"], plus client_credentials if you issue app tokens. Vercel Connect reads it to decide which token types a connector offers, and a grant you leave out never shows up.',
89
+ })
90
+ : !subjects.length
91
+ ? check('grant-types', 'fail', 'grant_types_supported says what a connector can do', {
92
+ detail: `declares ${grants.join(', ')}`,
93
+ fix: 'Include authorization_code (user tokens) or client_credentials (app tokens). None of the declared grants gives Vercel Connect a token to hand out.',
94
+ })
95
+ : check('grant-types', 'pass', 'grant_types_supported says what a connector can do', { detail: `subject types: ${subjects.join(', ')}` }));
96
+ if (grants.includes('authorization_code') && typeof md.authorization_endpoint !== 'string') {
97
+ out.push(check('authorization-endpoint', 'fail', 'authorization_endpoint is declared for user tokens', { fix: 'User tokens need authorization_endpoint alongside token_endpoint.' }));
98
+ }
99
+ // Recommended: Connect creates the OAuth client itself, through DCR or CIMD.
100
+ const cc = clientCreation(md);
101
+ out.push(cc.dcr || cc.cimd
102
+ ? check('registration', 'pass', 'Vercel Connect can create the OAuth client', { detail: [cc.dcr && 'Dynamic Client Registration', cc.cimd && 'Client ID Metadata Documents'].filter(Boolean).join(' and ') })
103
+ : check('registration', 'warn', 'Vercel Connect can create the OAuth client', {
104
+ detail: [
105
+ !md.registration_endpoint ? 'no registration_endpoint' : 'registration_endpoint, but no client authentication method Connect uses',
106
+ md.client_id_metadata_document_supported !== true ? 'client_id_metadata_document_supported not declared' : 'CIMD declared, but neither private_key_jwt nor public clients (none) with S256',
107
+ ].join('; '),
108
+ fix: `Declare a registration_endpoint (RFC 7591), or client_id_metadata_document_supported: true with private_key_jwt or public clients and S256. Otherwise each user registers an application with you, allows ${CONNECT_CALLBACK} and pastes in its client ID and secret.`,
109
+ }));
110
+ if (typeof md.registration_endpoint === 'string') {
111
+ out.push(!cc.methods
112
+ ? check('auth-methods', 'warn', 'token_endpoint_auth_methods_supported names a method Connect can use', {
113
+ detail: 'not declared (RFC 8414 defaults it to client_secret_basic)',
114
+ fix: `Declare the methods you accept. Vercel Connect registers clients with ${CONNECT_AUTH_METHODS.join(', ')}, and skips DCR when the list names none of them.`,
115
+ })
116
+ : cc.usable.length
117
+ ? check('auth-methods', 'pass', 'token_endpoint_auth_methods_supported names a method Connect can use', { detail: cc.usable.join(', ') })
118
+ : check('auth-methods', 'warn', 'token_endpoint_auth_methods_supported names a method Connect can use', {
119
+ detail: cc.methods.join(', '),
120
+ fix: `Add one of ${CONNECT_AUTH_METHODS.join(', ')}. Without one, Vercel Connect won't register a client and your users paste in a client ID and secret even though you support DCR.`,
121
+ }));
122
+ }
123
+ out.push((md.code_challenge_methods_supported ?? []).includes('S256')
124
+ ? check('pkce', 'pass', 'PKCE with S256 is declared')
125
+ : check('pkce', 'warn', 'PKCE with S256 is declared', { fix: 'Declare code_challenge_methods_supported: ["S256"]. Vercel Connect then uses PKCE (RFC 9700), and it is one of the two routes to CIMD for public clients.' }));
126
+ out.push(grants.includes('refresh_token')
127
+ ? check('refresh', 'pass', 'Refresh tokens are declared')
128
+ : check('refresh', 'warn', 'Refresh tokens are declared', { fix: 'Declare refresh_token in grant_types_supported and issue refresh tokens with the authorization code flow. Without them every expiry sends your user back through the consent prompt.' }));
129
+ // Optional: notes, never warnings.
130
+ const prmServers = d.prm?.json?.authorization_servers;
131
+ out.push(Array.isArray(prmServers) && prmServers.length
132
+ ? check('prm', 'pass', 'Protected-resource metadata names the authorization server', { detail: d.prm.url })
133
+ : check('prm', 'info', 'Protected-resource metadata names the authorization server', {
134
+ fix: 'Publish RFC 9728 metadata with a non-empty authorization_servers array, so users can give Vercel Connect the MCP URL they already know instead of looking up your authorization server.',
135
+ }));
136
+ out.push(typeof md.revocation_endpoint === 'string'
137
+ ? check('revocation', 'pass', 'revocation_endpoint is declared')
138
+ : check('revocation', 'info', 'revocation_endpoint is declared', {
139
+ fix: 'Declare one (RFC 7009). Vercel Connect then offers revocation and calls it; without it, revoking only removes Vercel’s copy and your side treats the token as live until it expires.',
140
+ }));
141
+ const scopes = Array.isArray(md.scopes_supported) ? md.scopes_supported : [];
142
+ out.push(!scopes.length
143
+ ? check('scopes', 'info', 'scopes_supported lists your scopes', { fix: 'List every scope a client can request. Vercel Connect offers this set when users configure your OAuth client.' })
144
+ : d.oidc && !scopes.includes('openid')
145
+ ? check('scopes', 'info', 'scopes_supported lists your scopes', { detail: scopes.join(' '), fix: 'You serve OpenID Connect, so include openid, plus profile and email where you support them. Vercel Connect shows those apart from your API scopes.' })
146
+ : check('scopes', 'pass', 'scopes_supported lists your scopes', { detail: scopes.join(' ') }));
147
+ return out;
148
+ }
149
+ /** What only a token or the form can show. */
150
+ export const VERCEL_REMINDERS = [
151
+ `Vercel Connect: allow the redirect URL ${CONNECT_CALLBACK} (exact match), return expires_in in token responses, and if you rotate refresh tokens return the new one in every refresh response. None of these can be checked without a token.`,
152
+ 'Vercel Connect: test each OAuth method in Submit a Service (Set Up Test Connector, then Test User Token, with the default scopes selected) and keep that connector and its token in your team until review ends. Vercel re-checks it when you submit, and any change to the method or your discovery documents needs a new test.',
153
+ ];
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { preflight } from './preflight.js';
7
7
  import { printPreflight } from './report.js';
8
8
  import { STORE_NAMES } from './types.js';
9
9
  import { recordDecision, recordSubmitted, status } from './submissions.js';
10
- import { chatgptPack, claudePack, directoriesPack, simplePack, writePack } from './packs.js';
10
+ import { PACKS, packFor, writePack } from './packs.js';
11
11
  import { drift, printDrift, takeSnapshot } from './drift.js';
12
12
  import { publish } from './publish.js';
13
13
  import { lanes, runLane, tryLinks } from './extras.js';
@@ -23,18 +23,30 @@ Usage
23
23
  --verbose List passing checks too
24
24
  --register Also test OAuth client registration (writes test clients)
25
25
  --token <token> Access token for servers behind sign-in (or MCPLANE_TOKEN)
26
+ --sign-in <url> A sign-in page your consent screen uses, checked for bot walls (repeatable)
26
27
  mcplane submitted <store> Record a submission and log it to Review Times
27
28
  --date YYYY-MM-DD --kind new|update|resubmission --version x.y.z
28
29
  --app-id asdk_app_... ChatGPT: your app id, so its draft date is checked
29
30
  --private Keep it local; don't log to Review Times
30
31
  mcplane decided <store> approved|rejected|withdrawn [--date YYYY-MM-DD]
31
32
  mcplane status Every submission, how long it's waited, and the store's typical wait
32
- mcplane pack <store> Write a submission pack to .mcplane/packs (chatgpt, claude-connectors, cursor, muse, directories)
33
+ mcplane pack <store> Write a submission pack to .mcplane/packs (chatgpt, claude-connectors, vercel-connect, cursor, muse, directories)
34
+ pack ard An ARD manifest for /.well-known/ard.json
35
+ pack claude-eval A "claude plugin eval" suite from your review tests
36
+ --tools <file> A saved tools/list result, for servers behind sign-in (or pass --token)
33
37
  mcplane drift [--store <id>]... What changed since each store saw your server, and what each needs
34
38
  --ci Exit 1 when a store needs a new version or an edit
35
39
  mcplane baseline <store> [--version x.y.z] Record a listing that's already live as the drift baseline
36
40
  mcplane publish <store> [--yes] Publish where the store allows it (mcp-registry, grok); dry run without --yes
37
41
  mcplane try Install links for every client, for you and your testers
42
+ mcplane demo Record the review demo video in ChatGPT desktop from your positive tests (macOS)
43
+ --only 1,3 Which tests to record (default: all)
44
+ --out <file> Where to write it (default: .mcplane/packs/chatgpt/demo.mp4)
45
+ --approve Click "Allow once" when ChatGPT asks to run a tool
46
+ --plugin <name> The plugin to @mention, as ChatGPT names it (default: title)
47
+ --work Record in Work mode instead of Chat
48
+ --keep-open Leave ChatGPT running afterwards
49
+ --yes Don't ask before driving ChatGPT
38
50
  mcplane listings [--store <id>]... Where you're listed, and whether what went live matches your server (--ci)
39
51
  mcplane lanes List lanes; run one with "mcplane <lane>" (built in: check, watch, release)
40
52
  mcplane fleet [--root <dir>] Every project under a folder: blockers, store updates needed, reviews waiting
@@ -65,6 +77,8 @@ async function main() {
65
77
  verbose: { type: 'boolean' },
66
78
  register: { type: 'boolean' },
67
79
  token: { type: 'string' },
80
+ tools: { type: 'string' },
81
+ 'sign-in': { type: 'string', multiple: true },
68
82
  date: { type: 'string' },
69
83
  kind: { type: 'string' },
70
84
  version: { type: 'string' },
@@ -74,6 +88,13 @@ async function main() {
74
88
  root: { type: 'string' },
75
89
  quick: { type: 'boolean' },
76
90
  yes: { type: 'boolean' },
91
+ only: { type: 'string' },
92
+ out: { type: 'string' },
93
+ approve: { type: 'boolean' },
94
+ plugin: { type: 'string' },
95
+ work: { type: 'boolean' },
96
+ 'keep-open': { type: 'boolean' },
97
+ port: { type: 'string' },
77
98
  },
78
99
  allowPositionals: true,
79
100
  });
@@ -108,6 +129,8 @@ async function main() {
108
129
  }
109
130
  if (cmd === 'preflight') {
110
131
  const m = values.url ? await draftManifest(values.url) : await loadManifest();
132
+ if (values['sign-in']?.length)
133
+ m.server.signIn = [...(m.server.signIn ?? []), ...values['sign-in']];
111
134
  const stores = (values.store ?? []);
112
135
  for (const s of stores)
113
136
  if (!(s in STORE_NAMES))
@@ -140,11 +163,13 @@ async function main() {
140
163
  }
141
164
  if (cmd === 'pack') {
142
165
  const m = await loadManifest();
143
- const store = positionals[0] === 'directories' ? null : asStore(positionals[0]);
144
- const token = values.token ?? process.env.MCPLANE_TOKEN;
145
- const pack = !store ? directoriesPack(m) : store === 'chatgpt' ? await chatgptPack(m, token) : store === 'claude-connectors' ? await claudePack(m, token) : await simplePack(m, store);
166
+ const name = positionals[0];
167
+ if (!PACKS.includes(name))
168
+ asStore(name);
169
+ const pack = await packFor(m, name, { token: values.token ?? process.env.MCPLANE_TOKEN, tools: values.tools });
146
170
  const written = await writePack(pack);
147
- console.log(`Wrote ${written.join(', ')}`);
171
+ const dirs = [...new Set(written.map((w) => w.split('/').slice(0, 3).join('/')))];
172
+ console.log(written.length > 6 ? `Wrote ${written.length} files under ${dirs.join(', ')}/` : `Wrote ${written.join(', ')}`);
148
173
  if (pack.problems.length) {
149
174
  console.log('\nFix before submitting:');
150
175
  for (const p of pack.problems)
@@ -226,6 +251,29 @@ async function main() {
226
251
  console.log(`${t.client.padEnd(22)} ${t.how}`);
227
252
  return;
228
253
  }
254
+ if (cmd === 'demo') {
255
+ const m = await loadManifest();
256
+ if (values.port && !/^\d+$/.test(values.port))
257
+ throw new Error('--port takes a port number, e.g. 9333.');
258
+ const { demo } = await import('./demo.js');
259
+ const r = await demo(m, {
260
+ only: values.only,
261
+ out: values.out,
262
+ approve: values.approve,
263
+ plugin: values.plugin,
264
+ work: values.work,
265
+ keepOpen: values['keep-open'],
266
+ yes: values.yes,
267
+ port: values.port ? Number(values.port) : undefined,
268
+ token: values.token ?? process.env.MCPLANE_TOKEN,
269
+ });
270
+ const mins = Math.floor(r.seconds / 60);
271
+ console.log(`\nWrote ${r.out} (${r.scenes} scene${r.scenes === 1 ? '' : 's'}, ${mins ? `${mins}m ` : ''}${Math.round(r.seconds % 60)}s).`);
272
+ for (const n of r.notes)
273
+ console.log(` ! ${n}`);
274
+ console.log('Watch it, upload it where a reviewer can open it without signing in, set chatgpt.demoVideo to that URL, then run "mcplane pack chatgpt".');
275
+ return;
276
+ }
229
277
  if (cmd === 'listings' || cmd === 'pull') {
230
278
  const m = await loadManifest();
231
279
  const stores = (values.store?.length ? values.store : storesOf(m));
package/dist/core.d.ts CHANGED
@@ -14,5 +14,6 @@ export declare function preflight(m: Manifest, opts?: {
14
14
  }): Promise<PreflightResult>;
15
15
  export { draftManifest, forStore, storesOf, SCHEMA_URL } from './draft.js';
16
16
  export { toolChecks } from './checks/tools.js';
17
+ export { validateArd, ardManifest } from './checks/ard.js';
17
18
  export { STORE_NAMES } from './types.js';
18
19
  export type { Check, Level, Manifest, StoreId, Tool } from './types.js';