mcp-context-card 1.4.0 → 1.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.
@@ -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
+ }
@@ -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.0";
4
+ export declare const VERSION = "1.5.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.0";
4
+ export const VERSION = "1.5.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. */
@@ -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);
@@ -9,6 +9,11 @@ export interface CardOptions {
9
9
  * `expanded` is the whole-page render, for a screenshot or a PR.
10
10
  */
11
11
  expanded?: boolean;
12
+ /**
13
+ * `private` shows how many facts memory holds but not the facts: for a card
14
+ * served beyond this machine without the publish opt-in. Default: `full`.
15
+ */
16
+ memory?: "full" | "private";
12
17
  }
13
18
  /** AAIF brand orange (aaif.io). The default accent. */
14
19
  export declare const AAIF_ACCENT = "#FF702D";
@@ -14,7 +14,7 @@ import { basename, join, resolve } from "node:path";
14
14
  import { parseAgentsMd } from "./agents-md.js";
15
15
  import { parseFafm } from "./memory.js";
16
16
  import { resolveIdentity, serverCardMeta, META_NS } from "./identity.js";
17
- import { SERVER_CARD_URI } from "./constants.js";
17
+ import { PUBLISH_MEMORY_ENV, SERVER_CARD_URI } from "./constants.js";
18
18
  import { escapeHtml, renderInline, renderMarkdown, slug } from "./md.js";
19
19
  /** AAIF brand orange (aaif.io). The default accent. */
20
20
  export const AAIF_ACCENT = "#FF702D";
@@ -154,17 +154,19 @@ export function renderCard(root, opts = {}) {
154
154
  <div class="ctx-body">${sections}</div>`
155
155
  : `<p class="none">No AGENTS.md yet. Ask your agent to draft one: <code>author_agents_md</code> builds it from this repo's real build and test commands, nothing invented.</p>`;
156
156
  // MEMORY
157
- const memoryBody = mem.facts.length
158
- ? mem.facts
159
- .map((f) => {
160
- const verified = f.verification_status === "verified";
161
- const tags = (f.tags ?? [])
162
- .map((t) => `<span class="tag">${escapeHtml(t)}</span>`)
163
- .join("");
164
- return `<div class="fact"><p>${renderInline(f.text)}</p><div class="meta">${tags}<span class="dot${verified ? "" : " pending"}" title="${verified ? "verified" : f.verification_status ?? "unverified"}"></span></div></div>`;
165
- })
166
- .join("")
167
- : `<p class="none">No facts yet. Ask your agent to remember something, and it lands here.</p>`;
157
+ const memoryBody = opts.memory === "private" && mem.facts.length
158
+ ? `<p class="none">Kept private on this page. To show the facts, set <code>${PUBLISH_MEMORY_ENV}=1</code>.</p>`
159
+ : mem.facts.length
160
+ ? mem.facts
161
+ .map((f) => {
162
+ const verified = f.verification_status === "verified";
163
+ const tags = (f.tags ?? [])
164
+ .map((t) => `<span class="tag">${escapeHtml(t)}</span>`)
165
+ .join("");
166
+ return `<div class="fact"><p>${renderInline(f.text)}</p><div class="meta">${tags}<span class="dot${verified ? "" : " pending"}" title="${verified ? "verified" : f.verification_status ?? "unverified"}"></span></div></div>`;
167
+ })
168
+ .join("")
169
+ : `<p class="none">No facts yet. Ask your agent to remember something, and it lands here.</p>`;
168
170
  // DISCOVERY
169
171
  const rows = Object.entries(meta)
170
172
  .map(([k, v]) => {
@@ -191,7 +193,7 @@ export function renderCard(root, opts = {}) {
191
193
  ${contextBody}
192
194
  </section>
193
195
  <section>
194
- <p class="label">Memory — ${mem.facts.length} fact${mem.facts.length === 1 ? "" : "s"}</p>
196
+ <p class="label">Memory — ${mem.facts.length} fact${mem.facts.length === 1 ? "" : "s"}${opts.memory === "private" && mem.facts.length ? ", kept private" : ""}</p>
195
197
  ${memoryBody}
196
198
  </section>
197
199
  <section>
@@ -199,7 +201,7 @@ export function renderCard(root, opts = {}) {
199
201
  <table class="disc"><thead><tr><th>concern</th><th>source</th><th>media type</th></tr></thead><tbody>${rows}</tbody></table>
200
202
  <p class="fetch">A machine reads this over <b>MCP</b> from the
201
203
  <code>${escapeHtml(SERVER_CARD_URI)}</code> resource; over <b>HTTP</b> also
202
- from <code>GET /.well-known/mcp/server-card</code> and
204
+ from <code>GET /mcp/server-card</code> and
203
205
  <code>GET /.well-known/ai-catalog.json</code>.</p>
204
206
  </section>
205
207
  <div class="foot">${escapeHtml(name)} · context card</div>
@@ -0,0 +1,39 @@
1
+ export interface ServerCardOptions {
2
+ /** Public origin this server is reached at (e.g. `https://ctx.example.com`).
3
+ * When set, the card advertises the streamable-HTTP endpoint at `<origin>/mcp`. */
4
+ origin?: string;
5
+ }
6
+ export declare function serverCard(opts?: ServerCardOptions): {
7
+ _meta: {
8
+ readonly "io.github.Wolfe-Jam.mcp-context-card/context": {
9
+ readonly source: "AGENTS.md";
10
+ readonly mediaType: "text/markdown";
11
+ };
12
+ readonly "io.github.Wolfe-Jam.mcp-context-card/memory": {
13
+ readonly source: "project.fafm";
14
+ readonly mediaType: "application/vnd.fafm+yaml";
15
+ readonly iana: string;
16
+ readonly note: "no de-facto standard for agent memory yet — this is one instantiation";
17
+ };
18
+ readonly "io.github.Wolfe-Jam.mcp-context-card/identity": {
19
+ readonly source: ".well-known/fafa";
20
+ readonly mediaType: "application/vnd.fafa+yaml";
21
+ readonly iana: string;
22
+ };
23
+ };
24
+ remotes?: {
25
+ type: "streamable-http";
26
+ url: string;
27
+ supportedProtocolVersions: string[];
28
+ }[] | undefined;
29
+ $schema: string;
30
+ name: string;
31
+ version: string;
32
+ title: string;
33
+ description: string;
34
+ websiteUrl: string;
35
+ repository: {
36
+ url: string;
37
+ source: string;
38
+ };
39
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * server-card — this server's MCP Server Card (SEP-2127, Final; schema v1).
3
+ *
4
+ * Required: `$schema`, `name` (reverse-DNS), `version`, `description`.
5
+ * Optional fields carried here: `title`, `websiteUrl`, `repository`, `remotes`
6
+ * (only when the caller knows the public origin, i.e. served over HTTP) and the
7
+ * namespaced `_meta` context block. No tools, resources or prompts: a card
8
+ * describes identity and connectivity, and primitives stay runtime-listed.
9
+ *
10
+ * `name`, `title` and `version` are the same values the live connection reports
11
+ * in `serverInfo`, so the card never contradicts runtime.
12
+ */
13
+ import { SUPPORTED_PROTOCOL_VERSIONS } from "@modelcontextprotocol/sdk/types.js";
14
+ import { DESCRIPTION, MCP_PATH, REGISTRY_NAME, REPOSITORY_URL, SERVER_CARD_SCHEMA, TITLE, VERSION, } from "./constants.js";
15
+ import { serverCardMeta } from "./identity.js";
16
+ export function serverCard(opts = {}) {
17
+ return {
18
+ $schema: SERVER_CARD_SCHEMA,
19
+ name: REGISTRY_NAME,
20
+ version: VERSION,
21
+ title: TITLE,
22
+ description: DESCRIPTION,
23
+ websiteUrl: REPOSITORY_URL,
24
+ repository: { url: REPOSITORY_URL, source: "github" },
25
+ ...(opts.origin
26
+ ? {
27
+ remotes: [
28
+ {
29
+ type: "streamable-http",
30
+ url: `${opts.origin}${MCP_PATH}`,
31
+ supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS],
32
+ },
33
+ ],
34
+ }
35
+ : {}),
36
+ _meta: serverCardMeta(),
37
+ };
38
+ }
package/dist/server.d.ts CHANGED
@@ -20,32 +20,13 @@ export { NAME, VERSION, SERVER_CARD_URI } from "./constants.js";
20
20
  /** Default package root — `dist/` at runtime, `src/` under tsx. Both are one up. */
21
21
  export declare const ROOT: string;
22
22
  /**
23
- * The Server Card — this server's identity plus the `_meta` context block,
24
- * one namespaced key per concern. Served in-band as the
25
- * `mcp-context-card://server-card` resource and out-of-band at
26
- * `/.well-known/mcp/server-card`.
23
+ * The Server Card (SEP-2127, schema v1): this server's identity plus the `_meta`
24
+ * context block, one namespaced key per concern. Served in-band as the
25
+ * `mcp-context-card://server-card` resource and out-of-band at `/mcp/server-card`
26
+ * (the spec's reserved `<streamable-http-url>/server-card`; the 1.x path
27
+ * `/.well-known/mcp/server-card` is kept as an alias). Built in server-card.ts.
27
28
  */
28
- export declare function serverCard(): {
29
- name: string;
30
- version: string;
31
- _meta: {
32
- readonly "io.github.Wolfe-Jam.mcp-context-card/context": {
33
- readonly source: "AGENTS.md";
34
- readonly mediaType: "text/markdown";
35
- };
36
- readonly "io.github.Wolfe-Jam.mcp-context-card/memory": {
37
- readonly source: "project.fafm";
38
- readonly mediaType: "application/vnd.fafm+yaml";
39
- readonly iana: string;
40
- readonly note: "no de-facto standard for agent memory yet — this is one instantiation";
41
- };
42
- readonly "io.github.Wolfe-Jam.mcp-context-card/identity": {
43
- readonly source: ".well-known/fafa";
44
- readonly mediaType: "application/vnd.fafa+yaml";
45
- readonly iana: string;
46
- };
47
- };
48
- };
29
+ export { serverCard } from "./server-card.js";
49
30
  /** Sent to every client at initialize. Hosts that support MCP Apps show the
50
31
  * card inline; for the rest, this steers the model to save the card as a
51
32
  * file instead of pasting a whole HTML page into the chat. */