mcplane 0.4.1 → 0.5.1

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 (54) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +109 -19
  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 +120 -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 +21 -5
  23. package/dist/demo.d.ts +60 -0
  24. package/dist/demo.js +573 -0
  25. package/dist/drift.js +17 -7
  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 +39 -0
  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 +53 -18
  37. package/dist/packs.js +426 -92
  38. package/dist/report.js +30 -13
  39. package/dist/skills.d.ts +21 -0
  40. package/dist/skills.js +180 -0
  41. package/dist/submissions.js +2 -1
  42. package/dist/types.d.ts +37 -4
  43. package/dist/types.js +1 -0
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/dist/video.d.ts +58 -0
  47. package/dist/video.js +190 -0
  48. package/dist/zip.d.ts +16 -2
  49. package/dist/zip.js +65 -5
  50. package/package.json +4 -1
  51. package/schema.json +86 -12
  52. package/schemas/agent-plugins-1.0.0/LICENSE +202 -0
  53. package/schemas/agent-plugins-1.0.0/mcp.schema.json +120 -0
  54. package/schemas/agent-plugins-1.0.0/plugin.schema.json +65 -0
@@ -0,0 +1,171 @@
1
+ import { metadata } from './oauth.js';
2
+ const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36';
3
+ /** Where ChatGPT sends the user back; an authorization server reporting an error redirects here, and we stop. */
4
+ const REDIRECT_URI = 'https://chatgpt.com/connector_platform_oauth_redirect';
5
+ /** RFC 7636's example S256 challenge: fixed, so the request is the same every run. */
6
+ const CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
7
+ const MAX_REDIRECTS = 5;
8
+ const MAX_PAGES = 6;
9
+ /** Every GET the chain makes, redirects included. */
10
+ const MAX_REQUESTS = 16;
11
+ /** Host or path words that mark a sign-in page or an identity provider. */
12
+ const AUTHISH = /(^|[./-])(accounts?|auth|authorize|login|log-in|signin|sign-in|sso|oauth2?|identity|idp|clerk|auth0|okta|cognito|stytch|workos|kinde|descope|frontegg|microsoftonline|appleid)([./-]|$)/i;
13
+ const ASSET = /\.(css|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|map)(\?|$)/i;
14
+ /** A bot challenge or a refusal, read from the response the way a browser would get it. */
15
+ export function wallOf(status, headers) {
16
+ const cf = headers.get('cf-mitigated');
17
+ if (cf && /challenge/i.test(cf))
18
+ return 'Cloudflare challenge (cf-mitigated: challenge)';
19
+ const aws = headers.get('x-amzn-waf-action');
20
+ if (aws && /challenge|captcha/i.test(aws))
21
+ return `AWS WAF ${aws.toLowerCase()} (x-amzn-waf-action)`;
22
+ const vercel = headers.get('x-vercel-mitigated');
23
+ if (vercel && /challenge/i.test(vercel))
24
+ return 'Vercel challenge (x-vercel-mitigated: challenge)';
25
+ if (status === 403)
26
+ return 'HTTP 403';
27
+ return null;
28
+ }
29
+ /** Sign-in pages and identity-provider URLs a page links to or loads. */
30
+ export function authLinks(html, base) {
31
+ const found = new Set();
32
+ const add = (raw) => {
33
+ try {
34
+ const u = new URL(raw.replace(/&/g, '&'), base);
35
+ if (u.protocol !== 'https:' && u.protocol !== 'http:')
36
+ return;
37
+ u.hash = '';
38
+ if (ASSET.test(u.pathname) || !AUTHISH.test(`${u.hostname}${u.pathname}`))
39
+ return;
40
+ found.add(u.toString());
41
+ }
42
+ catch { }
43
+ };
44
+ for (const m of html.matchAll(/\b(?:href|src|action)\s*=\s*["']([^"'<>\s]+)["']/gi))
45
+ add(m[1]);
46
+ for (const m of html.matchAll(/https:\/\/[a-z0-9.-]+\.[a-z]{2,}(?:\/[^\s"'<>`)\\]*)?/gi))
47
+ add(m[0]);
48
+ for (const m of html.matchAll(/<meta[^>]+http-equiv\s*=\s*["']refresh["'][^>]*content\s*=\s*["'][^"']*?\burl\s*=\s*([^"']+)["']/gi))
49
+ add(m[1].trim());
50
+ return [...found];
51
+ }
52
+ async function fetchHop(url) {
53
+ const res = await fetch(url, {
54
+ redirect: 'manual',
55
+ headers: { 'user-agent': BROWSER_UA, accept: 'text/html,application/xhtml+xml,*/*;q=0.8', 'accept-language': 'en' },
56
+ signal: AbortSignal.timeout(10_000),
57
+ }).catch((e) => e);
58
+ if (res instanceof Error)
59
+ return { hop: { url, status: 0, wall: null, error: res.name === 'TimeoutError' ? 'timed out' : res.message } };
60
+ const type = res.headers.get('content-type') ?? '';
61
+ const location = res.headers.get('location') ?? undefined;
62
+ const text = /html|json|text/.test(type) && !location ? await res.text().catch(() => '') : '';
63
+ let said;
64
+ if (res.status >= 400 && text) {
65
+ const json = /json/.test(type) ? (() => { try {
66
+ return JSON.parse(text);
67
+ }
68
+ catch {
69
+ return null;
70
+ } })() : null;
71
+ said = json?.error ? String(json.error) : /plain/.test(type) ? text.trim().slice(0, 60) : undefined;
72
+ }
73
+ const hop = { url, status: res.status, wall: wallOf(res.status, res.headers), location, contentType: type.split(';')[0] || undefined, said };
74
+ return { hop, html: /html/.test(type) ? text : undefined };
75
+ }
76
+ /** GETs a URL and follows its redirects by hand, recording every hop. Never follows into the client's redirect URI. */
77
+ export async function walk(start, budget = { left: MAX_REQUESTS }) {
78
+ const hops = [];
79
+ let url = start;
80
+ for (let i = 0; i <= MAX_REDIRECTS && budget.left > 0; i++) {
81
+ budget.left--;
82
+ const { hop, html } = await fetchHop(url);
83
+ hops.push(hop);
84
+ if (!hop.location || hop.status < 300 || hop.status >= 400)
85
+ return { hops, html, last: url };
86
+ const next = new URL(hop.location, url).toString();
87
+ if (next.startsWith(REDIRECT_URI) || !/^https?:/.test(next))
88
+ return { hops, last: url };
89
+ url = next;
90
+ }
91
+ return { hops, last: url };
92
+ }
93
+ /** The authorization request a client would open, for a client that isn't registered. */
94
+ export function authorizeUrl(endpoint, md, resource) {
95
+ const u = new URL(endpoint);
96
+ const scopes = Array.isArray(md.scopes_supported) ? md.scopes_supported : [];
97
+ const q = {
98
+ response_type: 'code',
99
+ client_id: 'mcplane-preflight',
100
+ redirect_uri: REDIRECT_URI,
101
+ state: 'mcplane',
102
+ code_challenge: CHALLENGE,
103
+ code_challenge_method: 'S256',
104
+ resource,
105
+ ...(scopes.length ? { scope: scopes.join(' ') } : {}),
106
+ };
107
+ for (const [k, v] of Object.entries(q))
108
+ u.searchParams.set(k, v);
109
+ return u.toString();
110
+ }
111
+ const show = (h) => `${h.url.split('?')[0]}: ${h.wall ?? (h.error ? h.error : `HTTP ${h.status}${h.said ? ` (${h.said})` : ''}`)}`;
112
+ /** The chain's hops as one check. Exported for tests. */
113
+ export function chainCheck(start, pages, opts) {
114
+ const title = 'Reviewers can reach your sign-in page';
115
+ const all = [...start, ...pages.flat()];
116
+ const walls = all.filter((h) => h.wall);
117
+ if (walls.length) {
118
+ return {
119
+ id: 'auth.chain',
120
+ level: 'warn',
121
+ title,
122
+ detail: walls.map(show).join('; '),
123
+ fix: 'A bot challenge or a 403 on the sign-in chain leaves the OAuth window blank for reviewers, who sign in from data-centre networks, often through a TLS-inspecting proxy. ChatGPT reports it as "Sign-in could not load". Exempt the authorization endpoint, consent page and sign-in hosts from bot challenges (in Cloudflare, a WAF skip rule for Bot Fight Mode and managed challenges on those hosts and paths), or serve sign-in from a host without one. A pass from your own network proves less: run this again from a cloud VM or CI.',
124
+ };
125
+ }
126
+ const visited = [...new Set(all.filter((h) => !h.error).map((h) => new URL(h.url).host))];
127
+ const failed = all.filter((h) => h.error);
128
+ if (opts.stoppedEarly && !opts.seeded && !pages.length) {
129
+ return {
130
+ id: 'auth.chain',
131
+ level: 'info',
132
+ title,
133
+ detail: `stopped at the authorization endpoint: ${show(start[start.length - 1])}. Without a registered client it doesn't send anyone on to sign in`,
134
+ fix: 'List the sign-in page and identity-provider URLs your consent screen uses in server.signIn in mcplane.json (or pass --sign-in <url>), and preflight checks them for bot challenges and 403s too.',
135
+ };
136
+ }
137
+ return {
138
+ id: 'auth.chain',
139
+ level: 'pass',
140
+ title,
141
+ detail: `no bot challenge or 403 on ${visited.join(', ') || 'the chain'}${failed.length ? `; no answer from ${failed.map(show).join(', ')}` : ''}`,
142
+ };
143
+ }
144
+ export async function chainChecks(m, d) {
145
+ const md = metadata(d);
146
+ const endpoint = md?.authorization_endpoint;
147
+ if (typeof endpoint !== 'string' || !/^https?:\/\//.test(endpoint))
148
+ return [];
149
+ const budget = { left: MAX_REQUESTS };
150
+ const start = await walk(authorizeUrl(endpoint, md, m.server.url), budget);
151
+ const last = start.hops[start.hops.length - 1];
152
+ // A 4xx with no page to read means the server wants a registered client before it goes any further.
153
+ const stoppedEarly = !start.html && last.status >= 400;
154
+ const seeds = (Array.isArray(m.server.signIn) ? m.server.signIn : typeof m.server.signIn === 'string' ? [m.server.signIn] : []).filter((u) => /^https?:\/\//.test(u));
155
+ const seen = new Set([...start.hops.map((h) => h.url)]);
156
+ const queue = [...seeds, ...(start.html ? authLinks(start.html, start.last) : [])].filter((u) => !seen.has(u));
157
+ const pages = [];
158
+ // Two rounds: the pages the chain names, then the identity-provider hosts those pages load.
159
+ for (let round = 0; round < 2 && queue.length && pages.length < MAX_PAGES && budget.left > 0; round++) {
160
+ const batch = [...new Set(queue.splice(0))].filter((u) => !seen.has(u)).slice(0, Math.min(MAX_PAGES - pages.length, budget.left));
161
+ batch.forEach((u) => seen.add(u));
162
+ const results = await Promise.all(batch.map((u) => walk(u, budget)));
163
+ for (const r of results) {
164
+ pages.push(r.hops);
165
+ r.hops.forEach((h) => seen.add(h.url));
166
+ if (r.html)
167
+ queue.push(...authLinks(r.html, r.last));
168
+ }
169
+ }
170
+ return [chainCheck(start.hops, pages, { stoppedEarly, seeded: seeds.length > 0 })];
171
+ }
@@ -1,38 +1,43 @@
1
1
  import { forStore } from '../draft.js';
2
+ import { imageInfo } from '../images.js';
2
3
  const get = (url) => fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(12_000), headers: { 'user-agent': 'mcplane' } }).catch(() => null);
3
- /** Width and height from a PNG's IHDR chunk. */
4
- function pngSize(buf) {
5
- if (buf.length < 24 || buf[0] !== 0x89 || buf[1] !== 0x50)
6
- return null;
7
- const v = new DataView(buf.buffer, buf.byteOffset);
8
- return { w: v.getUint32(16), h: v.getUint32(20) };
9
- }
10
4
  const has = (stores, ...s) => s.some((x) => stores.includes(x));
5
+ const MIB = 1024 * 1024;
6
+ /** OpenAI's limit for plugin.json's root description, which the ChatGPT package fills from the one-liner. */
7
+ const PLUGIN_DESCRIPTION_MAX = 1024;
11
8
  export async function listingChecks(m, tools, stores) {
12
9
  const checks = [];
13
10
  const origin = new URL(m.server.url).origin;
14
11
  // Icon
15
12
  if (!m.icon) {
16
- checks.push({ id: 'listing.icon', level: 'warn', title: 'An icon URL is set', fix: 'Set "icon" to a direct PNG URL, at least 512×512. ChatGPT needs one uploaded; Claude asks for a direct .png/.svg URL when the favicon fails.' });
13
+ checks.push({ id: 'listing.icon', level: 'warn', title: 'An icon URL is set', fix: 'Set "icon" to a direct PNG URL, at least 512×512. ChatGPT needs one in its package; Claude asks for a direct .png/.svg URL when the favicon fails.' });
14
+ }
15
+ else if (!/^https?:\/\//i.test(m.icon)) {
16
+ // A local file works for the ChatGPT package, which bundles it; every other store needs a link.
17
+ const others = stores.filter((s) => s !== 'chatgpt');
18
+ checks.push(others.length
19
+ ? { id: 'listing.icon', level: 'warn', title: 'The icon is a URL', detail: `${m.icon} is a local file`, fix: '"mcplane pack chatgpt" bundles a local icon, but Claude and the other stores need a direct URL. Set "icon" to the URL and "chatgpt.logo" to the file.', stores: others }
20
+ : { id: 'listing.icon', level: 'info', title: 'The icon is a local file', detail: m.icon, fix: '"mcplane pack chatgpt" bundles it and checks its size.', stores: ['chatgpt'] });
17
21
  }
18
22
  else {
19
23
  const res = await get(m.icon);
20
24
  const type = res?.headers.get('content-type') ?? '';
21
25
  const buf = res?.ok ? new Uint8Array(await res.arrayBuffer()) : null;
22
- const size = buf ? pngSize(buf) : null;
26
+ const img = buf ? imageInfo(buf) : null;
27
+ const size = img?.width !== undefined && img.height !== undefined ? `${img.width}×${img.height}` : '';
23
28
  if (!res?.ok) {
24
29
  checks.push({ id: 'listing.icon', level: 'fail', title: 'The icon URL loads', detail: `HTTP ${res?.status ?? 'error'}`, fix: 'Point "icon" at a URL that returns the image directly.' });
25
30
  }
26
31
  else if (!/\.(png|svg|jpe?g|gif|ico|webp)(\?|$)/i.test(m.icon)) {
27
32
  checks.push({ id: 'listing.icon', level: 'warn', title: 'The icon URL ends in an image extension', detail: m.icon, fix: 'Anthropic asks for an icon URL ending in .png, .svg, .jpg, .gif, .ico or .webp.', stores: ['claude-connectors'] });
28
33
  }
29
- else if (type.includes('png') && size && (size.w < 512 || size.w !== size.h)) {
30
- checks.push({ id: 'listing.icon', level: 'warn', title: 'The icon is square and at least 512 px', detail: `${size.w}×${size.h}`, fix: 'Use a square PNG of 512×512 or more; ChatGPT’s directory icon must be at least 256, and a 512 master covers every store.' });
34
+ else if (img?.format && size && (img.width !== img.height || (img.format !== 'svg' && img.width < 512))) {
35
+ checks.push({ id: 'listing.icon', level: 'warn', title: 'The icon is square and at least 512 px', detail: `${img.format.toUpperCase()}, ${size}`, fix: 'Use a square image of 512×512 or more, or a square SVG. ChatGPT’s package takes 48 to 4096 px, and a 512 master covers every store.' });
31
36
  }
32
37
  else {
33
- checks.push({ id: 'listing.icon', level: 'pass', title: 'The icon loads', detail: `${type}${size ? `, ${size.w}×${size.h}` : ''}, ${buf ? Math.round(buf.length / 1024) : '?'} KB` });
34
- if (buf && buf.length > 10 * 1024 && has(stores, 'chatgpt')) {
35
- checks.push({ id: 'listing.icon-small', level: 'warn', title: 'A small icon is available for ChatGPT’s dialogs', detail: `${Math.round(buf.length / 1024)} KB`, fix: 'ChatGPT’s icon upload in developer mode caps at 10 KB. Export an 8-bit PNG (pngquant or `magick in.png PNG8:out.png`).', stores: ['chatgpt'] });
38
+ checks.push({ id: 'listing.icon', level: 'pass', title: 'The icon loads', detail: [img?.format?.toUpperCase() ?? type, size, buf && `${Math.round(buf.length / 1024)} KB`].filter(Boolean).join(', ') });
39
+ if (buf && buf.length > 5 * MIB && has(stores, 'chatgpt')) {
40
+ checks.push({ id: 'listing.icon-size', level: 'fail', title: 'The icon fits ChatGPT’s package (5 MiB)', detail: `${(buf.length / MIB).toFixed(1)} MiB`, fix: 'Images in the plugin ZIP are capped at 5 MiB. Export a smaller PNG.', stores: ['chatgpt'] });
36
41
  }
37
42
  }
38
43
  }
@@ -105,6 +110,10 @@ export async function listingChecks(m, tools, stores) {
105
110
  checks.push({ id: 'listing.prompts', level: 'pass', title: 'Starter prompts are short, unique and free of @mentions', stores: ['chatgpt'] });
106
111
  }
107
112
  len(g.description, 4000, 'listing.chatgpt-description', 'Long description for ChatGPT', ['chatgpt']);
113
+ // plugin.json's root description comes from the one-liner (or subtitle, or title).
114
+ const root = g.oneLiner ?? g.subtitle ?? g.title;
115
+ if (root && root.length > PLUGIN_DESCRIPTION_MAX)
116
+ checks.push({ id: 'listing.plugin-description', level: 'fail', title: `Plugin description fits (${PLUGIN_DESCRIPTION_MAX} characters)`, detail: `${root.length} characters (plugin_description_too_long)`, fix: 'The ChatGPT package uses your one-liner as plugin.json’s description. Shorten it, or give ChatGPT its own in listing.chatgpt.oneLiner.', stores: ['chatgpt'] });
108
117
  // The plugin package requires all four listing URLs for MCP review.
109
118
  const missing = ['website', 'support', 'privacy', 'terms'].filter((k) => !g.links?.[k]?.startsWith('https://'));
110
119
  checks.push(missing.length
@@ -0,0 +1,45 @@
1
+ /**
2
+ * OAuth discovery the way Vercel Connect and MCP clients run it, from the documents alone:
3
+ * protected-resource metadata (RFC 9728) on the MCP host, then the authorization server's
4
+ * RFC 8414 metadata and OpenID Connect discovery, path-suffixed forms included. Read-only:
5
+ * nothing is registered.
6
+ *
7
+ * https://vercel.com/docs/connect/providers
8
+ * https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery
9
+ */
10
+ export interface Doc {
11
+ url: string;
12
+ /** 0 when nothing answered. */
13
+ status: number;
14
+ contentType: string;
15
+ /** The body, when it parsed as a JSON object. */
16
+ json: Record<string, any> | null;
17
+ /** The issuer identifier this URL was built from (RFC 8414 §3.3: the document's issuer must equal it). */
18
+ forIssuer?: string;
19
+ }
20
+ export interface Discovery {
21
+ /** The MCP server URL discovery started from. */
22
+ resource: string;
23
+ /** Protected-resource metadata, when a well-known URL answered with it. */
24
+ prm: Doc | null;
25
+ /** The first authorization server named in the PRM, else the MCP URL itself (what Vercel Connect is given). */
26
+ issuer: string;
27
+ /** Every RFC 8414 URL tried, in order, and the one that answered with JSON. */
28
+ oauthTried: Doc[];
29
+ oauth: Doc | null;
30
+ /** Every OpenID Connect URL tried, in order, and the one that answered with JSON. */
31
+ oidcTried: Doc[];
32
+ oidc: Doc | null;
33
+ }
34
+ /** Well-known URLs for an issuer or resource. RFC 8414 §3.1 inserts the suffix between host and path; OIDC also appends it. */
35
+ export declare function wellKnown(base: string, suffix: string): {
36
+ inserted: string | null;
37
+ origin: string;
38
+ appended: string | null;
39
+ };
40
+ export declare const isJsonType: (t: string) => boolean;
41
+ /** A document a client would use: 200, served as JSON, a JSON object. */
42
+ export declare const usable: (d: Doc | null | undefined) => d is Doc;
43
+ export declare function discover(resource: string): Promise<Discovery>;
44
+ /** What a client reads: the OAuth document, with gaps filled from OpenID Connect discovery (Vercel Connect does this). */
45
+ export declare const metadata: (d: Discovery | null | undefined) => Record<string, any> | null;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * OAuth discovery the way Vercel Connect and MCP clients run it, from the documents alone:
3
+ * protected-resource metadata (RFC 9728) on the MCP host, then the authorization server's
4
+ * RFC 8414 metadata and OpenID Connect discovery, path-suffixed forms included. Read-only:
5
+ * nothing is registered.
6
+ *
7
+ * https://vercel.com/docs/connect/providers
8
+ * https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery
9
+ */
10
+ /** Well-known URLs for an issuer or resource. RFC 8414 §3.1 inserts the suffix between host and path; OIDC also appends it. */
11
+ export function wellKnown(base, suffix) {
12
+ const u = new URL(base);
13
+ const path = u.pathname.replace(/\/+$/, '');
14
+ return {
15
+ inserted: path ? `${u.origin}/.well-known/${suffix}${path}` : null,
16
+ origin: `${u.origin}/.well-known/${suffix}`,
17
+ appended: path ? `${u.origin}${path}/.well-known/${suffix}` : null,
18
+ };
19
+ }
20
+ export const isJsonType = (t) => /\bjson\b/i.test(t);
21
+ async function getDoc(url, forIssuer) {
22
+ const res = await fetch(url, { headers: { accept: 'application/json', 'user-agent': 'mcplane' }, redirect: 'follow', signal: AbortSignal.timeout(10_000) }).catch(() => null);
23
+ const contentType = res?.headers.get('content-type') ?? '';
24
+ const text = res ? await res.text().catch(() => '') : '';
25
+ let json = null;
26
+ try {
27
+ const v = JSON.parse(text);
28
+ if (v && typeof v === 'object' && !Array.isArray(v))
29
+ json = v;
30
+ }
31
+ catch { }
32
+ return { url, status: res?.status ?? 0, contentType, json, forIssuer };
33
+ }
34
+ /** A document a client would use: 200, served as JSON, a JSON object. */
35
+ export const usable = (d) => !!d && d.status === 200 && !!d.json && isJsonType(d.contentType);
36
+ /** Tries each [url, issuer it was built from] in order, skipping forms that don't apply, and stops at the first usable document. */
37
+ async function firstOf(...urls) {
38
+ const tried = [];
39
+ for (const [url, issuer] of urls) {
40
+ if (!url)
41
+ continue;
42
+ const d = await getDoc(url, issuer);
43
+ tried.push(d);
44
+ if (usable(d))
45
+ return { tried, hit: d };
46
+ }
47
+ return { tried, hit: null };
48
+ }
49
+ export async function discover(resource) {
50
+ const pr = wellKnown(resource, 'oauth-protected-resource');
51
+ const prm = await firstOf([pr.inserted, ''], [pr.origin, '']);
52
+ const named = prm.hit?.json?.authorization_servers;
53
+ const issuer = Array.isArray(named) && typeof named[0] === 'string' ? named[0] : resource;
54
+ const as = wellKnown(issuer, 'oauth-authorization-server');
55
+ const oi = wellKnown(issuer, 'openid-configuration');
56
+ // The issuer a document must declare is the identifier its URL was built from, exactly. For a path
57
+ // issuer, the origin form (which Vercel Connect also reads) was built from the origin.
58
+ const atOrigin = as.inserted ? new URL(issuer).origin : issuer;
59
+ // MCP clients try the path-suffixed forms first.
60
+ const [oauth, oidc] = await Promise.all([firstOf([as.inserted, issuer], [as.origin, atOrigin]), firstOf([oi.inserted, issuer], [oi.appended, issuer], [oi.origin, atOrigin])]);
61
+ return { resource, prm: prm.hit, issuer, oauthTried: oauth.tried, oauth: oauth.hit, oidcTried: oidc.tried, oidc: oidc.hit };
62
+ }
63
+ /** What a client reads: the OAuth document, with gaps filled from OpenID Connect discovery (Vercel Connect does this). */
64
+ export const metadata = (d) => d && (d.oauth || d.oidc) ? { ...(d.oidc?.json ?? {}), ...(d.oauth?.json ?? {}) } : null;
@@ -0,0 +1,58 @@
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 { type RpcReply } from '../mcp-client.js';
13
+ import type { Check } from '../types.js';
14
+ import { type Discovery } from './oauth.js';
15
+ export declare const MODERN = "2026-07-28";
16
+ export type Era = 'modern' | 'legacy' | 'auth' | 'broken';
17
+ /**
18
+ * Reads the answer to a modern server/discover request the way a dual-era client does
19
+ * (basic/versioning#backward-compatibility): a DiscoverResult or a modern error means a modern
20
+ * server; a 4xx or a JSON-RPC error that isn't modern means a legacy one, and the client falls
21
+ * back to initialize. A 5xx, or no JSON-RPC answer at all, gives it nothing to go on.
22
+ */
23
+ export declare function eraOf(r: RpcReply | null): {
24
+ era: Era;
25
+ versions: string[];
26
+ detail: string;
27
+ };
28
+ export declare function eraCheck(e: ReturnType<typeof eraOf>): Check;
29
+ /**
30
+ * An initialize that offers 2026-07-28. Under the 2025-11-25 lifecycle a server answers with the
31
+ * requested version only if it supports it, otherwise with one it does; 2026-07-28 has no initialize
32
+ * at all, so the right answer is a handshake-era version. A modern-only server rejects initialize
33
+ * and SHOULD name its versions in the error (basic/versioning).
34
+ * https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
35
+ */
36
+ export declare function initializeCheck(r: RpcReply | null, era: Era): Check;
37
+ /** Modern servers: a tools/list carrying Mcp-Method is the ordinary case. A strict server rejects one without it (-32020). */
38
+ export declare function modernHeaderCheck(r: RpcReply | null): Check;
39
+ /** Legacy servers: the same tools/list with and without the new headers should get the same answer. */
40
+ export declare function legacyHeaderCheck(plain: RpcReply | null, tagged: RpcReply | null): Check;
41
+ /** 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). */
42
+ export declare function corsCheck(allowOrigin: string | null, allowHeaders: string | null): Check | null;
43
+ /**
44
+ * MCP Events is a draft, not part of 2026-07-28: events/list, events/poll, events/stream and
45
+ * events/subscribe, from the Triggers and Events Working Group. Reported, never a warning.
46
+ * https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/main/docs/design-sketch-proposal.md
47
+ */
48
+ export declare function eventsCheck(r: RpcReply | null): Check;
49
+ /** 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). */
50
+ export declare function issuerCheck(d: Discovery): Check | null;
51
+ /** 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. */
52
+ export declare function cimdCheck(md: Record<string, any>): Check;
53
+ /** authorization_response_iss_parameter_supported (RFC 9207): a SHOULD in 2026-07-28, expected to become a MUST. */
54
+ export declare function issCheck(md: Record<string, any>): Check;
55
+ export declare function readinessChecks(url: string, auth: 'none' | 'oauth', opts?: {
56
+ token?: string;
57
+ oauth?: Discovery | null;
58
+ }): Promise<Check[]>;