mcp-context-card 1.4.0 → 1.6.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/.well-known/ai-catalog.json +39 -17
- package/.well-known/fafa +10 -1
- package/CHANGELOG.md +86 -0
- package/README.md +66 -23
- package/dist/bin.d.ts +6 -1
- package/dist/bin.js +29 -4
- package/dist/catalog-gen.d.ts +58 -7
- package/dist/catalog-gen.js +51 -26
- package/dist/conformance/discovery.d.ts +60 -0
- package/dist/conformance/discovery.js +281 -0
- package/dist/constants.d.ts +30 -1
- package/dist/constants.js +30 -1
- package/dist/faf/parse-fafa.js +21 -0
- package/dist/faf/types.d.ts +15 -0
- package/dist/identity.d.ts +13 -0
- package/dist/identity.js +16 -0
- package/dist/render-card.d.ts +51 -0
- package/dist/render-card.js +325 -98
- package/dist/server-card.d.ts +39 -0
- package/dist/server-card.js +38 -0
- package/dist/server.d.ts +6 -25
- package/dist/server.js +16 -14
- package/dist/transport/guard.d.ts +35 -0
- package/dist/transport/guard.js +63 -0
- package/dist/transport/http.d.ts +19 -2
- package/dist/transport/http.js +95 -24
- package/docs/MECHANISMS.md +38 -18
- package/docs/TRANSPORT.md +40 -7
- package/docs/WIRING.md +10 -5
- package/docs/card-dark.html +180 -62
- package/docs/card-light.html +180 -62
- package/docs/card.html +178 -60
- package/docs/img/card-context.png +0 -0
- package/docs/img/card-dark.png +0 -0
- package/docs/img/card-identity.png +0 -0
- package/docs/img/card-light.png +0 -0
- package/docs/img/card-memory.png +0 -0
- package/docs/index.html +178 -60
- package/package.json +7 -4
- package/server.json +2 -2
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conformance/discovery — a portable checker for MCP Server Card discovery
|
|
3
|
+
* (SEP-2127, Final), the AI Catalog (spec 1.0), and the Streamable HTTP
|
|
4
|
+
* transport's security rules, run against any server URL.
|
|
5
|
+
*
|
|
6
|
+
* Self-contained on purpose: no imports from this package, only the global
|
|
7
|
+
* `fetch` (and `node:http` for the one check that must forge a Host header,
|
|
8
|
+
* which `fetch` drops), so the file can be lifted into another tool as-is.
|
|
9
|
+
* The transport checks send one JSON-RPC `ping`, which changes nothing. Point it at a
|
|
10
|
+
* server's Streamable HTTP endpoint and it returns one result per requirement,
|
|
11
|
+
* each tagged MUST or SHOULD exactly as the spec words it. Requirements the
|
|
12
|
+
* spec leaves at MAY (where the card lives, whether a catalog exists) are
|
|
13
|
+
* never failures: a missing optional document turns its checks into skips.
|
|
14
|
+
*
|
|
15
|
+
* JSON Schema validation is optional: pass `validateCard` (for example Ajv
|
|
16
|
+
* against the official v1 schema) and it runs as one more card check.
|
|
17
|
+
*/
|
|
18
|
+
export declare const SERVER_CARD_SCHEMA_URL = "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json";
|
|
19
|
+
export declare const SERVER_CARD_TYPE = "application/mcp-server-card+json";
|
|
20
|
+
export declare const AI_CATALOG_TYPE = "application/ai-catalog+json";
|
|
21
|
+
export type Tier = "card" | "hosting" | "catalog" | "transport";
|
|
22
|
+
export type Level = "must" | "should";
|
|
23
|
+
export type Status = "pass" | "fail" | "skip";
|
|
24
|
+
export interface CheckResult {
|
|
25
|
+
id: string;
|
|
26
|
+
tier: Tier;
|
|
27
|
+
level: Level;
|
|
28
|
+
title: string;
|
|
29
|
+
status: Status;
|
|
30
|
+
detail?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface DiscoveryTarget {
|
|
33
|
+
/** The server's Streamable HTTP endpoint, e.g. `https://ctx.example.com/mcp`. */
|
|
34
|
+
mcpUrl: string;
|
|
35
|
+
/** Where to read the card. Default: `<mcpUrl>/server-card`, then the catalog's card entry. */
|
|
36
|
+
cardUrl?: string;
|
|
37
|
+
/** Where to read the catalog. Default: `<origin>/.well-known/ai-catalog.json`. */
|
|
38
|
+
catalogUrl?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface DiscoveryOptions {
|
|
41
|
+
fetch?: typeof fetch;
|
|
42
|
+
/** Returns null when the card is valid, else a short error. */
|
|
43
|
+
validateCard?: (card: unknown) => string | null;
|
|
44
|
+
/** Per-request timeout in milliseconds (default 10000). */
|
|
45
|
+
timeoutMs?: number;
|
|
46
|
+
/** POST with exact headers (Host included); returns the status, or null. Default: node:http. */
|
|
47
|
+
rawStatus?: (url: string, headers: Record<string, string>, body: string, timeoutMs: number) => Promise<number | null>;
|
|
48
|
+
}
|
|
49
|
+
export interface DiscoveryReport {
|
|
50
|
+
mcpUrl: string;
|
|
51
|
+
cardUrl: string | null;
|
|
52
|
+
catalogUrl: string;
|
|
53
|
+
results: CheckResult[];
|
|
54
|
+
passed: number;
|
|
55
|
+
failed: number;
|
|
56
|
+
skipped: number;
|
|
57
|
+
/** Failed MUST checks: zero means nothing the spec requires is broken. */
|
|
58
|
+
mustFailures: number;
|
|
59
|
+
}
|
|
60
|
+
export declare function checkDiscovery(target: DiscoveryTarget, opts?: DiscoveryOptions): Promise<DiscoveryReport>;
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conformance/discovery — a portable checker for MCP Server Card discovery
|
|
3
|
+
* (SEP-2127, Final), the AI Catalog (spec 1.0), and the Streamable HTTP
|
|
4
|
+
* transport's security rules, run against any server URL.
|
|
5
|
+
*
|
|
6
|
+
* Self-contained on purpose: no imports from this package, only the global
|
|
7
|
+
* `fetch` (and `node:http` for the one check that must forge a Host header,
|
|
8
|
+
* which `fetch` drops), so the file can be lifted into another tool as-is.
|
|
9
|
+
* The transport checks send one JSON-RPC `ping`, which changes nothing. Point it at a
|
|
10
|
+
* server's Streamable HTTP endpoint and it returns one result per requirement,
|
|
11
|
+
* each tagged MUST or SHOULD exactly as the spec words it. Requirements the
|
|
12
|
+
* spec leaves at MAY (where the card lives, whether a catalog exists) are
|
|
13
|
+
* never failures: a missing optional document turns its checks into skips.
|
|
14
|
+
*
|
|
15
|
+
* JSON Schema validation is optional: pass `validateCard` (for example Ajv
|
|
16
|
+
* against the official v1 schema) and it runs as one more card check.
|
|
17
|
+
*/
|
|
18
|
+
export const SERVER_CARD_SCHEMA_URL = "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json";
|
|
19
|
+
export const SERVER_CARD_TYPE = "application/mcp-server-card+json";
|
|
20
|
+
export const AI_CATALOG_TYPE = "application/ai-catalog+json";
|
|
21
|
+
const NAME_RE = /^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/;
|
|
22
|
+
const EXT_KEY_RE = /^(https?:\/\/\S+|[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+)$/;
|
|
23
|
+
const URN_RE = /^urn:air:[^:]+\.[^:]+:[^:]+:[^:]+$/i;
|
|
24
|
+
const SECRET_RE = /(api[_-]?key|secret|password|bearer\s|token["']?\s*:)/i;
|
|
25
|
+
const PRIVATE_HOST_RE = /\b(localhost|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)\b/i;
|
|
26
|
+
/** POST through node:http, which (unlike fetch) sends a Host header as given. */
|
|
27
|
+
async function rawStatus(url, headers, body, timeoutMs) {
|
|
28
|
+
try {
|
|
29
|
+
const u = new URL(url);
|
|
30
|
+
const mod = u.protocol === "https:" ? await import("node:https") : await import("node:http");
|
|
31
|
+
return await new Promise((resolve) => {
|
|
32
|
+
const req = mod.request(u, { method: "POST", headers: { ...headers, "Content-Length": String(Buffer.byteLength(body)) }, timeout: timeoutMs }, (res) => {
|
|
33
|
+
res.resume();
|
|
34
|
+
resolve(res.statusCode ?? null);
|
|
35
|
+
});
|
|
36
|
+
req.on("error", () => resolve(null));
|
|
37
|
+
req.on("timeout", () => {
|
|
38
|
+
req.destroy();
|
|
39
|
+
resolve(null);
|
|
40
|
+
});
|
|
41
|
+
req.end(body);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
49
|
+
const isLoopback = (u) => ["localhost", "127.0.0.1", "[::1]", "::1"].includes(u.hostname);
|
|
50
|
+
function parseJson(text) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(text);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function checkDiscovery(target, opts = {}) {
|
|
59
|
+
const doFetch = opts.fetch ?? fetch;
|
|
60
|
+
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
61
|
+
const results = [];
|
|
62
|
+
const add = (tier, level, id, title, status, detail) => results.push({ id, tier, level, title, status, ...(detail ? { detail } : {}) });
|
|
63
|
+
const check = (tier, level, id, title, ok, detail) => add(tier, level, id, title, ok ? "pass" : "fail", ok ? undefined : detail);
|
|
64
|
+
const get = async (url, headers = {}, method = "GET") => {
|
|
65
|
+
try {
|
|
66
|
+
const r = await doFetch(url, { method, headers, signal: AbortSignal.timeout(timeoutMs) });
|
|
67
|
+
return { status: r.status, headers: r.headers, text: method === "OPTIONS" ? "" : await r.text() };
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const mcp = new URL(target.mcpUrl);
|
|
74
|
+
const catalogUrl = target.catalogUrl ?? new URL("/.well-known/ai-catalog.json", mcp).href;
|
|
75
|
+
// ── Catalog first: it may be where the card is found ──────────────────────
|
|
76
|
+
const catRes = await get(catalogUrl, { Accept: AI_CATALOG_TYPE });
|
|
77
|
+
const catalog = catRes && catRes.status === 200 ? parseJson(catRes.text) : undefined;
|
|
78
|
+
const entries = isObject(catalog) && Array.isArray(catalog.entries) ? catalog.entries : [];
|
|
79
|
+
const cardEntry = entries.find((e) => isObject(e) && e.type === SERVER_CARD_TYPE);
|
|
80
|
+
// ── Find the card: explicit → <mcp>/server-card → catalog entry ──────────
|
|
81
|
+
const candidates = [
|
|
82
|
+
target.cardUrl,
|
|
83
|
+
`${target.mcpUrl.replace(/\/+$/, "")}/server-card`,
|
|
84
|
+
cardEntry && typeof cardEntry.url === "string" ? new URL(cardEntry.url, catalogUrl).href : undefined,
|
|
85
|
+
].filter((u) => !!u);
|
|
86
|
+
let cardUrl = null;
|
|
87
|
+
let cardRes = null;
|
|
88
|
+
for (const u of [...new Set(candidates)]) {
|
|
89
|
+
const r = await get(u, { Accept: SERVER_CARD_TYPE });
|
|
90
|
+
if (r && r.status === 200) {
|
|
91
|
+
cardUrl = u;
|
|
92
|
+
cardRes = r;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const inlineCard = !cardRes && cardEntry && isObject(cardEntry.data) ? cardEntry.data : undefined;
|
|
97
|
+
// ── Tier: card ────────────────────────────────────────────────────────────
|
|
98
|
+
const card = cardRes ? parseJson(cardRes.text) : inlineCard;
|
|
99
|
+
if (card === undefined && !cardRes) {
|
|
100
|
+
add("card", "should", "card.found", "A Server Card is published (hosted or in the catalog)", "fail", `none at ${candidates.join(", ")}`);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
add("card", "should", "card.found", "A Server Card is published (hosted or in the catalog)", "pass");
|
|
104
|
+
check("card", "must", "card.json", "The card is a JSON object", isObject(card), "body is not a JSON object");
|
|
105
|
+
if (isObject(card)) {
|
|
106
|
+
check("card", "must", "card.schema-url", "$schema is the v1 Server Card schema URL", card.$schema === SERVER_CARD_SCHEMA_URL, `got ${JSON.stringify(card.$schema)}`);
|
|
107
|
+
check("card", "must", "card.name", "name is reverse-DNS with one slash", typeof card.name === "string" && NAME_RE.test(card.name) && card.name.length >= 3 && card.name.length <= 200, `got ${JSON.stringify(card.name)}`);
|
|
108
|
+
check("card", "must", "card.version", "version is present", typeof card.version === "string" && card.version.length > 0 && card.version.length <= 255, `got ${JSON.stringify(card.version)}`);
|
|
109
|
+
check("card", "must", "card.description", "description is 1–100 characters", typeof card.description === "string" && card.description.length >= 1 && card.description.length <= 100, typeof card.description === "string" ? `${card.description.length} characters` : "missing");
|
|
110
|
+
if (opts.validateCard) {
|
|
111
|
+
const err = opts.validateCard(card);
|
|
112
|
+
check("card", "must", "card.schema-valid", "The card validates against the v1 JSON Schema", err === null, err ?? "");
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
add("card", "must", "card.schema-valid", "The card validates against the v1 JSON Schema", "skip", "no schema validator supplied");
|
|
116
|
+
}
|
|
117
|
+
const text = JSON.stringify(card);
|
|
118
|
+
const leak = SECRET_RE.exec(text) ?? PRIVATE_HOST_RE.exec(text);
|
|
119
|
+
check("card", "must", "card.no-secrets", "No credentials or private endpoints in the card", !leak || isLoopback(mcp), `found "${leak?.[0]}"`);
|
|
120
|
+
const remotes = Array.isArray(card.remotes) ? card.remotes : [];
|
|
121
|
+
if (remotes.length) {
|
|
122
|
+
const urls = remotes.filter(isObject).map((r) => r.url);
|
|
123
|
+
check("card", "should", "card.remote-matches", "The card's remotes include the endpoint it was found for", urls.includes(target.mcpUrl), `remotes: ${urls.join(", ")}`);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
add("card", "should", "card.remote-matches", "The card's remotes include the endpoint it was found for", "skip", "the card declares no remotes");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// ── Tier: hosting (only for a hosted card) ───────────────────────────────
|
|
131
|
+
const hostingTitles = [
|
|
132
|
+
["should", "hosting.content-type", `Served as ${SERVER_CARD_TYPE}`],
|
|
133
|
+
["must", "hosting.cors-origin", "CORS: Access-Control-Allow-Origin is *"],
|
|
134
|
+
["must", "hosting.cors-expose", "CORS: ETag is exposed"],
|
|
135
|
+
["must", "hosting.cors-preflight", "CORS preflight allows GET with Content-Type and If-None-Match"],
|
|
136
|
+
["should", "hosting.cache-control", "Cache-Control is public with a max-age"],
|
|
137
|
+
["should", "hosting.etag", "An ETag is returned and If-None-Match gets 304"],
|
|
138
|
+
["must", "hosting.https", "Served over HTTPS (HTTP only for local development)"],
|
|
139
|
+
];
|
|
140
|
+
if (!cardRes || !cardUrl) {
|
|
141
|
+
for (const [level, id, title] of hostingTitles)
|
|
142
|
+
add("hosting", level, id, title, "skip", "no hosted card");
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
const h = cardRes.headers;
|
|
146
|
+
const ct = h.get("content-type") ?? "";
|
|
147
|
+
check("hosting", "should", "hosting.content-type", hostingTitles[0][2], ct.split(";")[0].trim().toLowerCase() === SERVER_CARD_TYPE, `got "${ct}"`);
|
|
148
|
+
const withOrigin = await get(cardUrl, { Origin: "https://conformance.example" });
|
|
149
|
+
const acao = withOrigin?.headers.get("access-control-allow-origin") ?? "";
|
|
150
|
+
check("hosting", "must", "hosting.cors-origin", hostingTitles[1][2], acao === "*", `got "${acao}"`);
|
|
151
|
+
const expose = withOrigin?.headers.get("access-control-expose-headers") ?? "";
|
|
152
|
+
check("hosting", "must", "hosting.cors-expose", hostingTitles[2][2], /(^|,)\s*etag\s*(,|$)/i.test(expose), `got "${expose}"`);
|
|
153
|
+
const pre = await get(cardUrl, {
|
|
154
|
+
Origin: "https://conformance.example",
|
|
155
|
+
"Access-Control-Request-Method": "GET",
|
|
156
|
+
"Access-Control-Request-Headers": "content-type, if-none-match",
|
|
157
|
+
}, "OPTIONS");
|
|
158
|
+
const methods = (pre?.headers.get("access-control-allow-methods") ?? "").toUpperCase();
|
|
159
|
+
const allowed = (pre?.headers.get("access-control-allow-headers") ?? "").toLowerCase();
|
|
160
|
+
check("hosting", "must", "hosting.cors-preflight", hostingTitles[3][2], /(^|,)\s*GET\s*(,|$)/.test(methods) && allowed.includes("content-type") && allowed.includes("if-none-match"), `methods "${methods}", headers "${allowed}"`);
|
|
161
|
+
const cc = (h.get("cache-control") ?? "").toLowerCase();
|
|
162
|
+
check("hosting", "should", "hosting.cache-control", hostingTitles[4][2], /\bpublic\b/.test(cc) && /\bmax-age=\d+/.test(cc), `got "${cc}"`);
|
|
163
|
+
const etag = h.get("etag");
|
|
164
|
+
const again = etag ? await get(cardUrl, { "If-None-Match": etag }) : null;
|
|
165
|
+
check("hosting", "should", "hosting.etag", hostingTitles[5][2], !!etag && again?.status === 304, etag ? `If-None-Match answered ${again?.status}` : "no ETag header");
|
|
166
|
+
const u = new URL(cardUrl);
|
|
167
|
+
if (u.protocol === "https:")
|
|
168
|
+
add("hosting", "must", "hosting.https", hostingTitles[6][2], "pass");
|
|
169
|
+
else if (isLoopback(u))
|
|
170
|
+
add("hosting", "must", "hosting.https", hostingTitles[6][2], "skip", "local development");
|
|
171
|
+
else
|
|
172
|
+
add("hosting", "must", "hosting.https", hostingTitles[6][2], "fail", `served from ${u.protocol}//${u.host}`);
|
|
173
|
+
}
|
|
174
|
+
// ── Tier: catalog (optional: absent → skipped) ───────────────────────────
|
|
175
|
+
const catTitles = [
|
|
176
|
+
["should", "catalog.content-type", `Served as ${AI_CATALOG_TYPE}`],
|
|
177
|
+
["must", "catalog.spec-version", "specVersion is present"],
|
|
178
|
+
["must", "catalog.entries", "entries is an array"],
|
|
179
|
+
["must", "catalog.host", "host.displayName is present when host is"],
|
|
180
|
+
["must", "catalog.entry-fields", "Every entry has identifier and type"],
|
|
181
|
+
["must", "catalog.entry-one-of", "Every entry has exactly one of url or data"],
|
|
182
|
+
["should", "catalog.identifiers", "Identifiers use urn:air:{publisher-domain}:{namespace}:{name}"],
|
|
183
|
+
["should", "catalog.extensions", "Custom data sits in extensions under URL or reverse-DNS keys"],
|
|
184
|
+
["should", "catalog.card-entry", "The catalog lists the Server Card"],
|
|
185
|
+
["should", "catalog.card-entry-lean", "The card entry repeats no displayName, description or version"],
|
|
186
|
+
["should", "catalog.links", "Every entry URL resolves with the type its entry declares"],
|
|
187
|
+
];
|
|
188
|
+
if (!catRes || catRes.status !== 200 || !isObject(catalog)) {
|
|
189
|
+
const why = !catRes ? "unreachable" : catRes.status !== 200 ? `HTTP ${catRes.status}` : "not a JSON object";
|
|
190
|
+
for (const [level, id, title] of catTitles)
|
|
191
|
+
add("catalog", level, id, title, "skip", `no catalog (${why})`);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
const t = (i) => catTitles[i];
|
|
195
|
+
const ct = catRes.headers.get("content-type") ?? "";
|
|
196
|
+
check("catalog", t(0)[0], t(0)[1], t(0)[2], ct.split(";")[0].trim().toLowerCase() === AI_CATALOG_TYPE, `got "${ct}"`);
|
|
197
|
+
check("catalog", t(1)[0], t(1)[1], t(1)[2], typeof catalog.specVersion === "string", "missing");
|
|
198
|
+
check("catalog", t(2)[0], t(2)[1], t(2)[2], Array.isArray(catalog.entries), "missing or not an array");
|
|
199
|
+
check("catalog", t(3)[0], t(3)[1], t(3)[2], catalog.host === undefined || (isObject(catalog.host) && typeof catalog.host.displayName === "string"), "host without displayName");
|
|
200
|
+
const objs = entries.filter(isObject);
|
|
201
|
+
const bad = (pred) => objs.filter((e) => !pred(e)).map((e) => String(e.identifier ?? "(no identifier)"));
|
|
202
|
+
const noFields = bad((e) => typeof e.identifier === "string" && typeof e.type === "string");
|
|
203
|
+
check("catalog", t(4)[0], t(4)[1], t(4)[2], noFields.length === 0 && objs.length === entries.length, noFields.join(", ") || "non-object entry");
|
|
204
|
+
const notOne = bad((e) => (e.url !== undefined) !== (e.data !== undefined));
|
|
205
|
+
check("catalog", t(5)[0], t(5)[1], t(5)[2], notOne.length === 0, notOne.join(", "));
|
|
206
|
+
const notUrn = bad((e) => typeof e.identifier === "string" && URN_RE.test(e.identifier));
|
|
207
|
+
check("catalog", t(6)[0], t(6)[1], t(6)[2], notUrn.length === 0, notUrn.join(", "));
|
|
208
|
+
const badExt = objs.flatMap((e) => isObject(e.extensions) ? Object.keys(e.extensions).filter((k) => !EXT_KEY_RE.test(k)) : []);
|
|
209
|
+
check("catalog", t(7)[0], t(7)[1], t(7)[2], badExt.length === 0, badExt.join(", "));
|
|
210
|
+
check("catalog", t(8)[0], t(8)[1], t(8)[2], !!cardEntry, `no entry of type ${SERVER_CARD_TYPE}`);
|
|
211
|
+
if (cardEntry) {
|
|
212
|
+
const repeated = ["displayName", "description", "version"].filter((k) => cardEntry[k] !== undefined);
|
|
213
|
+
check("catalog", t(9)[0], t(9)[1], t(9)[2], repeated.length === 0, repeated.join(", "));
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
add("catalog", t(9)[0], t(9)[1], t(9)[2], "skip", "no card entry");
|
|
217
|
+
}
|
|
218
|
+
const linked = objs.filter((e) => typeof e.url === "string");
|
|
219
|
+
if (!linked.length) {
|
|
220
|
+
add("catalog", t(10)[0], t(10)[1], t(10)[2], "skip", "no entry has a url");
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
const broken = [];
|
|
224
|
+
for (const e of linked) {
|
|
225
|
+
const u = new URL(e.url, catalogUrl).href;
|
|
226
|
+
const r = await get(u, { Accept: String(e.type) });
|
|
227
|
+
const rt = (r?.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
228
|
+
if (!r || r.status !== 200)
|
|
229
|
+
broken.push(`${u} → ${r ? `HTTP ${r.status}` : "unreachable"}`);
|
|
230
|
+
else if (rt !== String(e.type).toLowerCase())
|
|
231
|
+
broken.push(`${u} → "${rt}"`);
|
|
232
|
+
}
|
|
233
|
+
check("catalog", t(10)[0], t(10)[1], t(10)[2], broken.length === 0, broken.join("; "));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// ── Tier: transport (Streamable HTTP, Security Warning) ─────────────────
|
|
237
|
+
const ping = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" });
|
|
238
|
+
const rpcHeaders = { "Content-Type": "application/json", Accept: "application/json, text/event-stream" };
|
|
239
|
+
let foreign = null;
|
|
240
|
+
try {
|
|
241
|
+
foreign = await doFetch(target.mcpUrl, {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: { ...rpcHeaders, Origin: "https://conformance-check.invalid" },
|
|
244
|
+
body: ping,
|
|
245
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
246
|
+
});
|
|
247
|
+
await foreign.body?.cancel();
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
foreign = null;
|
|
251
|
+
}
|
|
252
|
+
if (!foreign) {
|
|
253
|
+
add("transport", "must", "transport.origin-403", "A foreign Origin is refused with 403", "skip", "MCP endpoint unreachable");
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
check("transport", "must", "transport.origin-403", "A foreign Origin is refused with 403", foreign.status === 403, `answered ${foreign.status}`);
|
|
257
|
+
}
|
|
258
|
+
if (!isLoopback(mcp)) {
|
|
259
|
+
add("transport", "should", "transport.rebinding", "A local server refuses a foreign Host (DNS rebinding)", "skip", "not a local server");
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
const status = await (opts.rawStatus ?? rawStatus)(target.mcpUrl, { ...rpcHeaders, Host: "rebind.invalid" }, ping, timeoutMs);
|
|
263
|
+
if (status === null) {
|
|
264
|
+
add("transport", "should", "transport.rebinding", "A local server refuses a foreign Host (DNS rebinding)", "skip", "MCP endpoint unreachable");
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
check("transport", "should", "transport.rebinding", "A local server refuses a foreign Host (DNS rebinding)", status === 403, `Host: rebind.invalid answered ${status}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const count = (s) => results.filter((r) => r.status === s).length;
|
|
271
|
+
return {
|
|
272
|
+
mcpUrl: target.mcpUrl,
|
|
273
|
+
cardUrl,
|
|
274
|
+
catalogUrl,
|
|
275
|
+
results,
|
|
276
|
+
passed: count("pass"),
|
|
277
|
+
failed: count("fail"),
|
|
278
|
+
skipped: count("skip"),
|
|
279
|
+
mustFailures: results.filter((r) => r.status === "fail" && r.level === "must").length,
|
|
280
|
+
};
|
|
281
|
+
}
|
package/dist/constants.d.ts
CHANGED
|
@@ -1,8 +1,37 @@
|
|
|
1
1
|
/** Server identity constants, in their own module so any file can import
|
|
2
2
|
* them without pulling in the whole server. */
|
|
3
3
|
export declare const NAME = "mcp-context-card";
|
|
4
|
-
export declare const VERSION = "1.
|
|
4
|
+
export declare const VERSION = "1.6.0";
|
|
5
5
|
export declare const SERVER_CARD_URI = "mcp-context-card://server-card";
|
|
6
|
+
/** The server's registry identity: reverse-DNS, the same as server.json `name`.
|
|
7
|
+
* It is also `serverInfo.name`, so a Server Card never contradicts the live
|
|
8
|
+
* connection (MCP Server Cards, SEP-2127: "Consistency with Runtime Behavior"). */
|
|
9
|
+
export declare const REGISTRY_NAME = "io.github.Wolfe-Jam/mcp-context-card";
|
|
10
|
+
/** Human-readable name: `serverInfo.title` and the Server Card `title`. */
|
|
11
|
+
export declare const TITLE = "MCP Context Card";
|
|
12
|
+
/** Same text as server.json `description` (1–100 chars, required on a Server Card). */
|
|
13
|
+
export declare const DESCRIPTION = "MCP server for a project's context (AGENTS.md), memory, and identity \u2014 base or drop-in extension.";
|
|
14
|
+
export declare const REPOSITORY_URL = "https://github.com/Wolfe-Jam/mcp-context-card";
|
|
15
|
+
/** MCP Server Cards (SEP-2127, Final): schema v1 and media types. */
|
|
16
|
+
export declare const SERVER_CARD_SCHEMA = "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json";
|
|
17
|
+
export declare const SERVER_CARD_MEDIA_TYPE = "application/mcp-server-card+json";
|
|
18
|
+
export declare const AI_CATALOG_MEDIA_TYPE = "application/ai-catalog+json";
|
|
19
|
+
/** Where a card is hosted: the spec's reserved `<streamable-http-url>/server-card`. */
|
|
20
|
+
export declare const MCP_PATH = "/mcp";
|
|
21
|
+
export declare const SERVER_CARD_PATH = "/mcp/server-card";
|
|
22
|
+
/** The 1.x location, kept as an alias so existing links keep working. */
|
|
23
|
+
export declare const LEGACY_SERVER_CARD_PATH = "/.well-known/mcp/server-card";
|
|
24
|
+
/**
|
|
25
|
+
* Set to `1` to publish the memory file (`project.fafm`) over HTTP and list it
|
|
26
|
+
* in the AI Catalog. Off by default: memory is written during sessions, and
|
|
27
|
+
* discovery documents must not carry user- or session-specific data (SEP-2127).
|
|
28
|
+
*/
|
|
29
|
+
export declare const PUBLISH_MEMORY_ENV = "MCP_CONTEXT_CARD_PUBLISH_MEMORY";
|
|
30
|
+
/** Extra Host names the HTTP server accepts (comma-separated), e.g. a reverse proxy's public name. */
|
|
31
|
+
export declare const ALLOWED_HOSTS_ENV = "MCP_CONTEXT_CARD_ALLOWED_HOSTS";
|
|
32
|
+
/** Browser origins allowed to call the HTTP server (comma-separated). */
|
|
33
|
+
export declare const ALLOWED_ORIGINS_ENV = "MCP_CONTEXT_CARD_ALLOWED_ORIGINS";
|
|
34
|
+
export declare const publishMemoryFromEnv: (env?: NodeJS.ProcessEnv) => boolean;
|
|
6
35
|
/** MCP Apps (io.modelcontextprotocol/ui): the card as an inline UI resource.
|
|
7
36
|
* A host that supports MCP Apps fetches this resource and renders it in a
|
|
8
37
|
* sandboxed iframe next to the conversation. */
|
package/dist/constants.js
CHANGED
|
@@ -1,8 +1,37 @@
|
|
|
1
1
|
/** Server identity constants, in their own module so any file can import
|
|
2
2
|
* them without pulling in the whole server. */
|
|
3
3
|
export const NAME = "mcp-context-card";
|
|
4
|
-
export const VERSION = "1.
|
|
4
|
+
export const VERSION = "1.6.0";
|
|
5
5
|
export const SERVER_CARD_URI = "mcp-context-card://server-card";
|
|
6
|
+
/** The server's registry identity: reverse-DNS, the same as server.json `name`.
|
|
7
|
+
* It is also `serverInfo.name`, so a Server Card never contradicts the live
|
|
8
|
+
* connection (MCP Server Cards, SEP-2127: "Consistency with Runtime Behavior"). */
|
|
9
|
+
export const REGISTRY_NAME = "io.github.Wolfe-Jam/mcp-context-card";
|
|
10
|
+
/** Human-readable name: `serverInfo.title` and the Server Card `title`. */
|
|
11
|
+
export const TITLE = "MCP Context Card";
|
|
12
|
+
/** Same text as server.json `description` (1–100 chars, required on a Server Card). */
|
|
13
|
+
export const DESCRIPTION = "MCP server for a project's context (AGENTS.md), memory, and identity — base or drop-in extension.";
|
|
14
|
+
export const REPOSITORY_URL = "https://github.com/Wolfe-Jam/mcp-context-card";
|
|
15
|
+
/** MCP Server Cards (SEP-2127, Final): schema v1 and media types. */
|
|
16
|
+
export const SERVER_CARD_SCHEMA = "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json";
|
|
17
|
+
export const SERVER_CARD_MEDIA_TYPE = "application/mcp-server-card+json";
|
|
18
|
+
export const AI_CATALOG_MEDIA_TYPE = "application/ai-catalog+json";
|
|
19
|
+
/** Where a card is hosted: the spec's reserved `<streamable-http-url>/server-card`. */
|
|
20
|
+
export const MCP_PATH = "/mcp";
|
|
21
|
+
export const SERVER_CARD_PATH = `${MCP_PATH}/server-card`;
|
|
22
|
+
/** The 1.x location, kept as an alias so existing links keep working. */
|
|
23
|
+
export const LEGACY_SERVER_CARD_PATH = "/.well-known/mcp/server-card";
|
|
24
|
+
/**
|
|
25
|
+
* Set to `1` to publish the memory file (`project.fafm`) over HTTP and list it
|
|
26
|
+
* in the AI Catalog. Off by default: memory is written during sessions, and
|
|
27
|
+
* discovery documents must not carry user- or session-specific data (SEP-2127).
|
|
28
|
+
*/
|
|
29
|
+
export const PUBLISH_MEMORY_ENV = "MCP_CONTEXT_CARD_PUBLISH_MEMORY";
|
|
30
|
+
/** Extra Host names the HTTP server accepts (comma-separated), e.g. a reverse proxy's public name. */
|
|
31
|
+
export const ALLOWED_HOSTS_ENV = "MCP_CONTEXT_CARD_ALLOWED_HOSTS";
|
|
32
|
+
/** Browser origins allowed to call the HTTP server (comma-separated). */
|
|
33
|
+
export const ALLOWED_ORIGINS_ENV = "MCP_CONTEXT_CARD_ALLOWED_ORIGINS";
|
|
34
|
+
export const publishMemoryFromEnv = (env = process.env) => env[PUBLISH_MEMORY_ENV] === "1";
|
|
6
35
|
/** MCP Apps (io.modelcontextprotocol/ui): the card as an inline UI resource.
|
|
7
36
|
* A host that supports MCP Apps fetches this resource and renders it in a
|
|
8
37
|
* sandboxed iframe next to the conversation. */
|
package/dist/faf/parse-fafa.js
CHANGED
|
@@ -23,6 +23,7 @@ export function parseFafa(path) {
|
|
|
23
23
|
description: str(agent.description),
|
|
24
24
|
status: str(agent.status),
|
|
25
25
|
license: str(agent.license),
|
|
26
|
+
...extras(doc),
|
|
26
27
|
};
|
|
27
28
|
}
|
|
28
29
|
function str(v) {
|
|
@@ -31,3 +32,23 @@ function str(v) {
|
|
|
31
32
|
const s = String(v).trim().replace(/\s+/g, " ");
|
|
32
33
|
return s.length ? s : undefined;
|
|
33
34
|
}
|
|
35
|
+
/** Where it runs, what it can do, and what installs it: `.fafa` fields outside `agent`. */
|
|
36
|
+
function extras(doc) {
|
|
37
|
+
const list = (v) => Array.isArray(v) ? v.filter((x) => !!x && typeof x === "object") : [];
|
|
38
|
+
const endpoints = list(doc.endpoints)
|
|
39
|
+
.filter((e) => typeof e.protocol === "string" && e.protocol.trim())
|
|
40
|
+
.map((e) => ({ protocol: String(e.protocol).trim().toLowerCase(), ...(typeof e.location === "string" ? { location: e.location } : {}) }));
|
|
41
|
+
const skills = list(doc.capabilities)
|
|
42
|
+
.filter((c) => typeof c.name === "string" && c.name.trim())
|
|
43
|
+
.map((c) => ({ name: String(c.name), ...(typeof c.description === "string" ? { description: c.description } : {}) }));
|
|
44
|
+
const meta = doc.metadata && typeof doc.metadata === "object" ? doc.metadata : {};
|
|
45
|
+
const cards = meta.cards && typeof meta.cards === "object" ? meta.cards : {};
|
|
46
|
+
const packages = list(cards.packages)
|
|
47
|
+
.filter((p) => typeof p.registryType === "string" && typeof p.identifier === "string")
|
|
48
|
+
.map((p) => ({ registryType: String(p.registryType), identifier: String(p.identifier) }));
|
|
49
|
+
return {
|
|
50
|
+
...(endpoints.length ? { endpoints } : {}),
|
|
51
|
+
...(skills.length ? { skills } : {}),
|
|
52
|
+
...(packages.length ? { packages } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
package/dist/faf/types.d.ts
CHANGED
|
@@ -33,4 +33,19 @@ export interface AgentIdentity {
|
|
|
33
33
|
description?: string;
|
|
34
34
|
status?: string;
|
|
35
35
|
license?: string;
|
|
36
|
+
/** Where it runs (`.fafa` `endpoints`), as `faf card init` writes them: protocol `a2a` or `mcp`. */
|
|
37
|
+
endpoints?: {
|
|
38
|
+
protocol: string;
|
|
39
|
+
location?: string;
|
|
40
|
+
}[];
|
|
41
|
+
/** What it can do (`.fafa` `capabilities`). */
|
|
42
|
+
skills?: {
|
|
43
|
+
name: string;
|
|
44
|
+
description?: string;
|
|
45
|
+
}[];
|
|
46
|
+
/** Installable packages (`.fafa` `metadata.cards.packages`), e.g. an npm MCP server. */
|
|
47
|
+
packages?: {
|
|
48
|
+
registryType: string;
|
|
49
|
+
identifier: string;
|
|
50
|
+
}[];
|
|
36
51
|
}
|
package/dist/identity.d.ts
CHANGED
|
@@ -13,6 +13,19 @@ export declare function identity(root: string): AgentIdentity | null;
|
|
|
13
13
|
* `.fafa`). null only when neither exists.
|
|
14
14
|
*/
|
|
15
15
|
export declare function resolveIdentity(root: string): AgentIdentity | null;
|
|
16
|
+
/**
|
|
17
|
+
* Who publishes this project's catalog entries: the domain and short name from
|
|
18
|
+
* the project's own `.fafa`. `faf card init` writes them into `agent.id` as
|
|
19
|
+
* `urn:air:{domain}:agent:{short name}` (short name also in `agent.name`).
|
|
20
|
+
* No `.fafa` domain → `domain` is undefined; callers then fall back to the HTTP
|
|
21
|
+
* host or a plain identifier. A domain is never invented here: each project
|
|
22
|
+
* publishes under its own (AI Catalog: "the domain name of the organization
|
|
23
|
+
* publishing the artifact").
|
|
24
|
+
*/
|
|
25
|
+
export declare function catalogPublisher(root: string): {
|
|
26
|
+
domain?: string;
|
|
27
|
+
handle: string;
|
|
28
|
+
};
|
|
16
29
|
/** Human-readable one-liner for the `whoami` tool. */
|
|
17
30
|
export declare function whoami(root: string): string;
|
|
18
31
|
/**
|
package/dist/identity.js
CHANGED
|
@@ -55,6 +55,22 @@ function fromPackageJson(root) {
|
|
|
55
55
|
export function resolveIdentity(root) {
|
|
56
56
|
return identity(root) ?? fromPackageJson(root);
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Who publishes this project's catalog entries: the domain and short name from
|
|
60
|
+
* the project's own `.fafa`. `faf card init` writes them into `agent.id` as
|
|
61
|
+
* `urn:air:{domain}:agent:{short name}` (short name also in `agent.name`).
|
|
62
|
+
* No `.fafa` domain → `domain` is undefined; callers then fall back to the HTTP
|
|
63
|
+
* host or a plain identifier. A domain is never invented here: each project
|
|
64
|
+
* publishes under its own (AI Catalog: "the domain name of the organization
|
|
65
|
+
* publishing the artifact").
|
|
66
|
+
*/
|
|
67
|
+
export function catalogPublisher(root) {
|
|
68
|
+
const id = identity(root);
|
|
69
|
+
const m = /^urn:air:([^:]+):/i.exec(id?.id ?? "");
|
|
70
|
+
const domain = m && m[1].includes(".") ? m[1].toLowerCase() : undefined;
|
|
71
|
+
const handle = (id?.name ?? "").trim() || "mcp-context-card";
|
|
72
|
+
return { domain, handle };
|
|
73
|
+
}
|
|
58
74
|
/** Human-readable one-liner for the `whoami` tool. */
|
|
59
75
|
export function whoami(root) {
|
|
60
76
|
const id = resolveIdentity(root);
|
package/dist/render-card.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AgentIdentity } from "./faf/types.js";
|
|
1
2
|
export type Theme = "light" | "dark" | "auto";
|
|
2
3
|
export interface CardOptions {
|
|
3
4
|
theme?: Theme;
|
|
@@ -9,11 +10,61 @@ export interface CardOptions {
|
|
|
9
10
|
* `expanded` is the whole-page render, for a screenshot or a PR.
|
|
10
11
|
*/
|
|
11
12
|
expanded?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* `private` shows how many facts memory holds but not the facts: for a card
|
|
15
|
+
* served beyond this machine without the publish opt-in. Default: `full`.
|
|
16
|
+
*/
|
|
17
|
+
memory?: "full" | "private";
|
|
18
|
+
/** Business-card view. Default `landscape`; the reader can switch it too. */
|
|
19
|
+
layout?: "landscape" | "portrait";
|
|
12
20
|
}
|
|
13
21
|
/** AAIF brand orange (aaif.io). The default accent. */
|
|
14
22
|
export declare const AAIF_ACCENT = "#FF702D";
|
|
15
23
|
export declare function safeAccent(a?: string): string;
|
|
24
|
+
/** What the front calls this thing, from where it runs: "MCP server", "A2A agent", or both. */
|
|
25
|
+
export declare function cardKind(id: AgentIdentity | null): string[];
|
|
26
|
+
/** The front's title line: what it is, then its version. Empty when nothing is known. */
|
|
27
|
+
export declare function cardTitle(id: AgentIdentity | null): string;
|
|
28
|
+
/** The front's logo: the first letters of the name's first two words ("mcp-context-card" → "MC"). */
|
|
29
|
+
export declare function cardInitials(name: string): string;
|
|
30
|
+
/** The front's one-liner: the first sentence of the description. */
|
|
31
|
+
export declare function cardOneLiner(id: AgentIdentity | null, max?: number): string;
|
|
32
|
+
/**
|
|
33
|
+
* A business card, independent of where its facts came from: everything the
|
|
34
|
+
* renderer draws. `readCard` builds one from a project's files; another reader
|
|
35
|
+
* (an A2A Agent Card, an MCP Server Card, a person's card) can build one too,
|
|
36
|
+
* and `renderBusinessCard` draws any of them the same way.
|
|
37
|
+
*/
|
|
38
|
+
export interface BusinessCard {
|
|
39
|
+
name: string;
|
|
40
|
+
/** What it is (and its version): "MCP server · v1.5.0", or a person's role. */
|
|
41
|
+
title?: string;
|
|
42
|
+
oneLiner?: string;
|
|
43
|
+
/** Shown bottom-left on the front, e.g. the publisher domain. */
|
|
44
|
+
domain?: string;
|
|
45
|
+
/** Footer chips; `accent` highlights one (e.g. the status). Plain text. */
|
|
46
|
+
chips: {
|
|
47
|
+
text: string;
|
|
48
|
+
accent?: boolean;
|
|
49
|
+
}[];
|
|
50
|
+
/** The back's tabs, in order. `key` is [a-z0-9-]; `html` is already-safe markup. */
|
|
51
|
+
tabs: {
|
|
52
|
+
key: string;
|
|
53
|
+
label: string;
|
|
54
|
+
heading: string;
|
|
55
|
+
html: string;
|
|
56
|
+
}[];
|
|
57
|
+
/** The (i) panel, as already-safe markup. */
|
|
58
|
+
about: string;
|
|
59
|
+
}
|
|
60
|
+
/** How to draw a card: the owner's colour and accent, the view, and the flat (print) layout. */
|
|
61
|
+
export type RenderOptions = Pick<CardOptions, "theme" | "accent" | "layout" | "expanded">;
|
|
62
|
+
/** The project's card: read its files, then draw. */
|
|
16
63
|
export declare function renderCard(root: string, opts?: CardOptions): string;
|
|
64
|
+
/** Read a project's AGENTS.md, memory and identity into a business card. */
|
|
65
|
+
export declare function readCard(root: string, opts?: Pick<CardOptions, "expanded" | "memory">): BusinessCard;
|
|
66
|
+
/** Draw any business card as one self-contained HTML page. */
|
|
67
|
+
export declare function renderBusinessCard(card: BusinessCard, opts?: RenderOptions): string;
|
|
17
68
|
/** tl;dr: the first few facts, each cut to about a line. `full`: every fact, whole. */
|
|
18
69
|
export type Detail = "tldr" | "full";
|
|
19
70
|
/**
|