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/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. */
package/dist/server.js CHANGED
@@ -23,22 +23,22 @@ import { fileURLToPath, pathToFileURL } from "node:url";
23
23
  import { findSection, parseAgentsMd } from "./agents-md.js";
24
24
  import { authorAgentsMd } from "./author.js";
25
25
  import { forget, parseFafm, recall, remember } from "./memory.js";
26
- import { identity, resolveIdentity, serverCardMeta, whoami } from "./identity.js";
26
+ import { identity, resolveIdentity, whoami } from "./identity.js";
27
27
  import { renderCard, renderCardText, safeAccent } from "./render-card.js";
28
28
  export { NAME, VERSION, SERVER_CARD_URI } from "./constants.js";
29
- import { NAME, VERSION, SERVER_CARD_URI, CARD_UI_URI, MCP_APP_MIME, UI_EXTENSION } from "./constants.js";
29
+ import { VERSION, SERVER_CARD_URI, CARD_UI_URI, MCP_APP_MIME, UI_EXTENSION, REGISTRY_NAME, TITLE, SERVER_CARD_MEDIA_TYPE } from "./constants.js";
30
+ import { serverCard } from "./server-card.js";
30
31
  const here = dirname(fileURLToPath(import.meta.url));
31
32
  /** Default package root — `dist/` at runtime, `src/` under tsx. Both are one up. */
32
33
  export const ROOT = join(here, "..");
33
34
  /**
34
- * The Server Card — this server's identity plus the `_meta` context block,
35
- * one namespaced key per concern. Served in-band as the
36
- * `mcp-context-card://server-card` resource and out-of-band at
37
- * `/.well-known/mcp/server-card`.
35
+ * The Server Card (SEP-2127, schema v1): this server's identity plus the `_meta`
36
+ * context block, one namespaced key per concern. Served in-band as the
37
+ * `mcp-context-card://server-card` resource and out-of-band at `/mcp/server-card`
38
+ * (the spec's reserved `<streamable-http-url>/server-card`; the 1.x path
39
+ * `/.well-known/mcp/server-card` is kept as an alias). Built in server-card.ts.
38
40
  */
39
- export function serverCard() {
40
- return { name: NAME, version: VERSION, _meta: serverCardMeta() };
41
- }
41
+ export { serverCard } from "./server-card.js";
42
42
  const text = (s) => ({ content: [{ type: "text", text: s }] });
43
43
  /** Sent to every client at initialize. Hosts that support MCP Apps show the
44
44
  * card inline; for the rest, this steers the model to save the card as a
@@ -57,12 +57,14 @@ function cardOptions(args) {
57
57
  theme: (["light", "dark", "auto"].includes(theme) ? theme : "auto"),
58
58
  accent: safeAccent(args.accent),
59
59
  expanded: args.expanded === true || args.expanded === "true",
60
+ layout: (args.layout === "portrait" ? "portrait" : "landscape"),
60
61
  };
61
62
  }
62
63
  const CARD_ARGS = {
63
64
  theme: { type: "string", enum: ["light", "dark", "auto"], description: "default: auto" },
64
65
  accent: { type: "string", description: "CSS hex colour, e.g. #FF702D (default: the AAIF palette)" },
65
66
  expanded: { type: "boolean", description: "render every AGENTS.md section open (default: collapsed)" },
67
+ layout: { type: "string", enum: ["landscape", "portrait"], description: "business-card view (default: landscape)" },
66
68
  };
67
69
  /**
68
70
  * @param root directory holding `AGENTS.md`, `project.fafm`, `.well-known/`.
@@ -76,7 +78,7 @@ function hostRendersApps(server) {
76
78
  return Array.isArray(ext?.mimeTypes) && ext.mimeTypes.includes(MCP_APP_MIME);
77
79
  }
78
80
  export function createServer(root = ROOT, opts = {}) {
79
- const server = new Server({ name: NAME, version: VERSION }, { capabilities: { tools: {}, resources: {} }, instructions: INSTRUCTIONS });
81
+ const server = new Server({ name: REGISTRY_NAME, title: TITLE, version: VERSION }, { capabilities: { tools: {}, resources: {} }, instructions: INSTRUCTIONS });
80
82
  // ── Which project? ────────────────────────────────────────────────────
81
83
  // Resolved per call, not at startup: a client's roots are only known once
82
84
  // it has connected, and they can change (goose sends its session's working
@@ -136,7 +138,7 @@ export function createServer(root = ROOT, opts = {}) {
136
138
  uri: SERVER_CARD_URI,
137
139
  name: "Server Card",
138
140
  description: "This server's identity + the _meta context block.",
139
- mimeType: "application/json",
141
+ mimeType: SERVER_CARD_MEDIA_TYPE,
140
142
  },
141
143
  {
142
144
  uri: CARD_UI_URI,
@@ -160,7 +162,7 @@ export function createServer(root = ROOT, opts = {}) {
160
162
  }
161
163
  return {
162
164
  contents: [
163
- { uri: SERVER_CARD_URI, mimeType: "application/json", text: JSON.stringify(serverCard(), null, 2) },
165
+ { uri: SERVER_CARD_URI, mimeType: SERVER_CARD_MEDIA_TYPE, text: JSON.stringify(serverCard(), null, 2) },
164
166
  ],
165
167
  };
166
168
  });
@@ -276,7 +278,7 @@ export function createServer(root = ROOT, opts = {}) {
276
278
  // resource. Both key forms, as the official ext-apps helper writes them.
277
279
  _meta: { ui: { resourceUri: CARD_UI_URI }, "ui/resourceUri": CARD_UI_URI },
278
280
  annotations: { title: "Render Context Card", readOnlyHint: true, idempotentHint: true, openWorldHint: false },
279
- description: "Render the whole card — identity, AGENTS.md, memory, discovery — as one self-contained HTML page a person can read or screenshot. AGENTS.md sections collapse by default; pass expanded:true for the full render. Also served at GET /card (?expand=all) over the HTTP transport.",
281
+ description: "Render the project's business card as one self-contained HTML page a person can read or screenshot. Front: name, title, one-liner. Flip it for the back: About, Skills (when the .fafa lists any), Context (AGENTS.md), Memory, Discovery. layout:\"portrait\" for the tall view; expanded:true renders both faces flat with every section open. Also served at GET /card (?expand=all) over the HTTP transport.",
280
282
  inputSchema: { type: "object", properties: CARD_ARGS },
281
283
  },
282
284
  {
@@ -410,7 +412,7 @@ export function createServer(root = ROOT, opts = {}) {
410
412
  card: `resource ${CARD_UI_URI} (MCP App, ${MCP_APP_MIME})`,
411
413
  },
412
414
  http: {
413
- serverCard: "GET /.well-known/mcp/server-card",
415
+ serverCard: "GET /mcp/server-card",
414
416
  aiCatalog: "GET /.well-known/ai-catalog.json",
415
417
  card: "GET /card",
416
418
  },
@@ -0,0 +1,35 @@
1
+ /**
2
+ * guard — DNS-rebinding and cross-origin protection for the HTTP transport.
3
+ *
4
+ * The MCP transports spec (Streamable HTTP, Security Warning):
5
+ * 1. Servers MUST validate the `Origin` header on all incoming connections;
6
+ * a present, invalid `Origin` gets 403 Forbidden.
7
+ * 2. When running locally, servers SHOULD bind only to localhost.
8
+ * 3. Servers SHOULD authenticate all connections (left to the deployer; see
9
+ * the README).
10
+ *
11
+ * `hostGuard` mirrors the SDK's Express `hostHeaderValidation` (hostname only,
12
+ * any port; 403 with a JSON-RPC error and `id: null`) for Hono. A local server
13
+ * accepts loopback Host names only, which is what stops a rebound DNS name
14
+ * from reaching it: plain GETs often carry no `Origin`, but always a `Host`.
15
+ *
16
+ * `originGuard` passes requests without an `Origin` (non-browser clients) and
17
+ * browser requests from loopback pages, an allowlisted origin, or the server's
18
+ * own origin. Everything else is refused before any route runs.
19
+ */
20
+ import type { MiddlewareHandler } from "hono";
21
+ /** Loopback host names, as a URL parser reports them (IPv6 in brackets). */
22
+ export declare const LOOPBACK_HOSTNAMES: readonly ["localhost", "127.0.0.1", "[::1]"];
23
+ export declare const isLoopbackHostname: (hostname: string) => boolean;
24
+ /** A bind address that keeps the server on this machine. */
25
+ export declare const isLoopbackBind: (address: string) => boolean;
26
+ /** Comma-separated env value → trimmed, non-empty entries. */
27
+ export declare const listFromEnv: (value: string | undefined) => string[];
28
+ /** Refuse any request whose Host name is not loopback or in `allowed`. */
29
+ export declare function hostGuard(allowed?: readonly string[]): MiddlewareHandler;
30
+ /**
31
+ * Refuse a request whose `Origin` is present and not loopback, allowlisted or
32
+ * this server's own. `exempt(path)` skips paths that the Server Card spec
33
+ * requires to be readable from any origin (`Access-Control-Allow-Origin: *`).
34
+ */
35
+ export declare function originGuard(allowedOrigins?: readonly string[], exempt?: (path: string) => boolean): MiddlewareHandler;
@@ -0,0 +1,63 @@
1
+ /** Loopback host names, as a URL parser reports them (IPv6 in brackets). */
2
+ export const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]"];
3
+ export const isLoopbackHostname = (hostname) => {
4
+ const h = hostname.toLowerCase();
5
+ return LOOPBACK_HOSTNAMES.includes(h) || /^127(\.\d{1,3}){3}$/.test(h);
6
+ };
7
+ /** A bind address that keeps the server on this machine. */
8
+ export const isLoopbackBind = (address) => isLoopbackHostname(address.startsWith("[") || !address.includes(":") ? address : `[${address}]`);
9
+ /** Comma-separated env value → trimmed, non-empty entries. */
10
+ export const listFromEnv = (value) => (value ?? "").split(",").map((s) => s.trim()).filter(Boolean);
11
+ function forbidden(c, message) {
12
+ return c.json({ jsonrpc: "2.0", error: { code: -32000, message }, id: null }, 403);
13
+ }
14
+ function hostnameOf(hostHeader) {
15
+ try {
16
+ return new URL(`http://${hostHeader}`).hostname.toLowerCase();
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ /** Refuse any request whose Host name is not loopback or in `allowed`. */
23
+ export function hostGuard(allowed = []) {
24
+ const extra = allowed.map((h) => h.toLowerCase());
25
+ return async (c, next) => {
26
+ // Real HTTP always carries Host; an in-process request falls back to its URL.
27
+ const host = c.req.header("host") ?? new URL(c.req.url).host;
28
+ if (!host)
29
+ return forbidden(c, "Missing Host header");
30
+ const hostname = hostnameOf(host);
31
+ if (!hostname)
32
+ return forbidden(c, `Invalid Host header: ${host}`);
33
+ if (!isLoopbackHostname(hostname) && !extra.includes(hostname))
34
+ return forbidden(c, `Invalid Host: ${hostname}`);
35
+ await next();
36
+ };
37
+ }
38
+ /**
39
+ * Refuse a request whose `Origin` is present and not loopback, allowlisted or
40
+ * this server's own. `exempt(path)` skips paths that the Server Card spec
41
+ * requires to be readable from any origin (`Access-Control-Allow-Origin: *`).
42
+ */
43
+ export function originGuard(allowedOrigins = [], exempt = () => false) {
44
+ const allowed = new Set(allowedOrigins.map((o) => o.replace(/\/+$/, "").toLowerCase()));
45
+ return async (c, next) => {
46
+ const origin = c.req.header("origin");
47
+ if (origin === undefined || exempt(c.req.path))
48
+ return next();
49
+ let url;
50
+ try {
51
+ url = new URL(origin);
52
+ }
53
+ catch {
54
+ return forbidden(c, `Invalid Origin: ${origin}`);
55
+ }
56
+ const ok = isLoopbackHostname(url.hostname) ||
57
+ allowed.has(url.origin.toLowerCase()) ||
58
+ url.host.toLowerCase() === (c.req.header("host") ?? new URL(c.req.url).host).toLowerCase();
59
+ if (!ok)
60
+ return forbidden(c, `Invalid Origin: ${origin}`);
61
+ await next();
62
+ };
63
+ }
@@ -1,2 +1,19 @@
1
- import { Hono } from "hono";
2
- export declare function httpApp(root?: string): Hono;
1
+ import { Hono, type Context } from "hono";
2
+ export interface HttpAppOptions {
3
+ /** Serve `project.fafm` and list it in the catalog. Default: the env switch. */
4
+ publishMemory?: boolean;
5
+ /**
6
+ * `local` (default): only loopback Host names are served, and the card shows
7
+ * memory in full (the reader is on this machine). `exposed`: bound beyond
8
+ * this machine; no Host check unless `allowedHosts` is given, and the card
9
+ * keeps memory private unless `publishMemory` is on.
10
+ */
11
+ exposure?: "local" | "exposed";
12
+ /** Extra Host names to accept (a reverse proxy's public name). */
13
+ allowedHosts?: string[];
14
+ /** Browser origins allowed to call this server, e.g. `https://app.example.com`. */
15
+ allowedOrigins?: string[];
16
+ }
17
+ /** The public origin of this request, honouring a reverse proxy's forwarded headers. */
18
+ export declare function originOf(c: Context): string;
19
+ export declare function httpApp(root?: string, opts?: HttpAppOptions): Hono;
@@ -10,12 +10,30 @@
10
10
  * notifications or resumability would set a `sessionIdGenerator` and hold
11
11
  * transports in a map; this one deliberately does not.
12
12
  *
13
- * Alongside the MCP endpoint it serves the discovery documents:
14
- * GET /.well-known/mcp/server-card — the Server Card + _meta block
15
- * GET /.well-known/ai-catalog.json — the three sibling entries
16
- * GET /.well-known/fafa — the agent identity card
13
+ * Alongside the MCP endpoint it serves the discovery documents (MCP Server
14
+ * Cards, SEP-2127 Final; AI Catalog specVersion 1.0):
15
+ * GET /mcp/server-card — the Server Card (the spec's reserved
16
+ * `<streamable-http-url>/server-card`)
17
+ * GET /.well-known/mcp/server-card — the same card, the 1.x location (alias)
18
+ * GET /.well-known/ai-catalog.json — the card + its sibling entries
19
+ * GET /AGENTS.md · /.well-known/fafa — the files the catalog links
20
+ * GET /project.fafm — only with MCP_CONTEXT_CARD_PUBLISH_MEMORY=1:
21
+ * memory is session data, private by default
22
+ *
23
+ * Local by default (MCP transports spec, Security Warning): the bin binds
24
+ * 127.0.0.1, `hostGuard` serves loopback Host names only (DNS rebinding), and
25
+ * `originGuard` refuses foreign browser origins with 403 on every route except
26
+ * the public discovery documents. Exposing the server (HOST=0.0.0.0) is a
27
+ * deliberate act; there is no authentication, so put it behind your own.
28
+ *
29
+ * Discovery documents carry the spec's CORS (GET only, `Content-Type` and
30
+ * `If-None-Match` allowed, `ETag` exposed), `Cache-Control: public,
31
+ * max-age=3600`, and an `ETag` honoured with `304 Not Modified`. Serve them over
32
+ * HTTPS in production (TLS is the host's job; HTTP is for local development).
17
33
  */
18
- import { readFileSync } from "node:fs";
34
+ import { createHash } from "node:crypto";
35
+ import { existsSync, readFileSync } from "node:fs";
36
+ import { join } from "node:path";
19
37
  import { Hono } from "hono";
20
38
  import { cors } from "hono/cors";
21
39
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
@@ -23,11 +41,57 @@ import { buildCatalog } from "../catalog-gen.js";
23
41
  import { fafaFile } from "../identity.js";
24
42
  import { createServer, NAME, ROOT, VERSION, serverCard } from "../server.js";
25
43
  import { renderCard, safeAccent } from "../render-card.js";
26
- export function httpApp(root = ROOT) {
44
+ import { AI_CATALOG_MEDIA_TYPE, LEGACY_SERVER_CARD_PATH, MCP_PATH, SERVER_CARD_MEDIA_TYPE, publishMemoryFromEnv, ALLOWED_HOSTS_ENV, ALLOWED_ORIGINS_ENV, SERVER_CARD_PATH, } from "../constants.js";
45
+ import { hostGuard, listFromEnv, originGuard } from "./guard.js";
46
+ /**
47
+ * Public-by-design discovery documents: the Server Card spec requires them to
48
+ * be readable from any origin, so they get its CORS and skip the Origin check.
49
+ * They carry no private data (SEP-2127 rules that out).
50
+ */
51
+ const PUBLIC_PATHS = [SERVER_CARD_PATH, LEGACY_SERVER_CARD_PATH, "/.well-known/ai-catalog.json", "/.well-known/fafa"];
52
+ /** CORS exactly as the Server Card spec lists it. */
53
+ const discoveryCors = cors({
54
+ origin: "*",
55
+ allowMethods: ["GET"],
56
+ allowHeaders: ["Content-Type", "If-None-Match"],
57
+ exposeHeaders: ["ETag"],
58
+ });
59
+ /** The public origin of this request, honouring a reverse proxy's forwarded headers. */
60
+ export function originOf(c) {
61
+ const url = new URL(c.req.url);
62
+ const proto = (c.req.header("x-forwarded-proto") ?? url.protocol.replace(":", "")).split(",")[0].trim();
63
+ const host = (c.req.header("x-forwarded-host") ?? c.req.header("host") ?? url.host).split(",")[0].trim();
64
+ return `${proto}://${host}`;
65
+ }
66
+ /** A cacheable discovery response: Content-Type, Cache-Control, ETag, and 304 on a match. */
67
+ function discovery(c, body, contentType) {
68
+ const etag = `"${createHash("sha256").update(body).digest("base64url").slice(0, 27)}"`;
69
+ c.header("Cache-Control", "public, max-age=3600");
70
+ c.header("ETag", etag);
71
+ const match = c.req.header("if-none-match");
72
+ if (match && match.split(",").some((t) => t.trim().replace(/^W\//, "") === etag)) {
73
+ return c.body(null, 304);
74
+ }
75
+ c.header("Content-Type", contentType);
76
+ return c.body(body);
77
+ }
78
+ export function httpApp(root = ROOT, opts = {}) {
79
+ const publishMemory = opts.publishMemory ?? publishMemoryFromEnv();
80
+ const exposure = opts.exposure ?? "local";
81
+ const allowedHosts = opts.allowedHosts ?? listFromEnv(process.env[ALLOWED_HOSTS_ENV]);
82
+ const allowedOrigins = opts.allowedOrigins ?? listFromEnv(process.env[ALLOWED_ORIGINS_ENV]);
27
83
  const app = new Hono();
28
- app.use("*", cors());
84
+ // DNS-rebinding and cross-origin protection first, before any route runs.
85
+ if (exposure === "local" || allowedHosts.length)
86
+ app.use("*", hostGuard(allowedHosts));
87
+ app.use("*", originGuard(allowedOrigins, (path) => PUBLIC_PATHS.includes(path)));
88
+ for (const p of PUBLIC_PATHS)
89
+ app.use(p, discoveryCors);
90
+ app.use(MCP_PATH, cors());
91
+ app.use("/card", cors());
92
+ app.use("/", cors());
29
93
  // ── MCP endpoint — stateless ────────────────────────────────────────
30
- app.all("/mcp", async (c) => {
94
+ app.all(MCP_PATH, async (c) => {
31
95
  const server = createServer(root);
32
96
  const transport = new WebStandardStreamableHTTPServerTransport({
33
97
  sessionIdGenerator: undefined, // stateless
@@ -42,34 +106,41 @@ export function httpApp(root = ROOT) {
42
106
  return res;
43
107
  });
44
108
  // ── Discovery documents ─────────────────────────────────────────────
45
- app.get("/.well-known/mcp/server-card", (c) => c.json(serverCard()));
46
- app.get("/.well-known/ai-catalog.json", (c) => {
47
- c.header("content-type", "application/ai-catalog+json");
48
- return c.body(JSON.stringify(buildCatalog(root), null, 2));
49
- });
50
- // Served at the well-known path, read from agent.fafa or .well-known/fafa.
51
- app.get("/.well-known/fafa", (c) => {
52
- const file = fafaFile(root);
53
- if (!file)
54
- return c.notFound();
55
- c.header("content-type", "application/vnd.fafa+yaml");
56
- return c.body(readFileSync(file, "utf8"));
57
- });
109
+ const card = (c) => discovery(c, JSON.stringify(serverCard({ origin: originOf(c) }), null, 2), SERVER_CARD_MEDIA_TYPE);
110
+ app.get(SERVER_CARD_PATH, card);
111
+ app.get(LEGACY_SERVER_CARD_PATH, card);
112
+ app.get("/.well-known/ai-catalog.json", (c) => discovery(c, JSON.stringify(buildCatalog(root, { origin: originOf(c), publishMemory }), null, 2), AI_CATALOG_MEDIA_TYPE));
113
+ // The files the catalog links to, each with the type its entry declares.
114
+ // Fixed names only: nothing from the request picks a path.
115
+ const file = (path, type) => (c) => path && existsSync(path) ? discovery(c, readFileSync(path, "utf8"), type) : c.notFound();
116
+ app.get("/.well-known/fafa", (c) => file(fafaFile(root), "application/vnd.fafa+yaml")(c));
117
+ app.get("/AGENTS.md", file(join(root, "AGENTS.md"), "text/markdown; charset=utf-8"));
118
+ if (publishMemory)
119
+ app.get("/project.fafm", file(join(root, "project.fafm"), "application/vnd.fafm+yaml"));
58
120
  // ── The card — the view for people ──────────────────────────────────
59
121
  app.get("/card", (c) => {
60
122
  const q = c.req.query();
61
123
  const theme = (["light", "dark", "auto"].includes(q.theme ?? "") ? q.theme : "auto");
62
124
  c.header("content-type", "text/html; charset=utf-8");
63
- return c.body(renderCard(root, { theme, accent: safeAccent(q.accent), expanded: q.expand === "all" }));
125
+ return c.body(renderCard(root, {
126
+ theme,
127
+ accent: safeAccent(q.accent),
128
+ layout: q.layout === "portrait" ? "portrait" : "landscape",
129
+ expanded: q.expand === "all",
130
+ // A card that may be read beyond this machine keeps memory private
131
+ // unless the project publishes it.
132
+ memory: exposure === "exposed" && !publishMemory ? "private" : "full",
133
+ }));
64
134
  });
65
135
  // ── Index ───────────────────────────────────────────────────────────
66
136
  app.get("/", (c) => c.json({
67
137
  name: NAME,
68
138
  version: VERSION,
69
- mcp: "/mcp",
139
+ mcp: MCP_PATH,
140
+ serverCard: SERVER_CARD_PATH,
70
141
  card: "/card",
71
142
  wellKnown: [
72
- "/.well-known/mcp/server-card",
143
+ LEGACY_SERVER_CARD_PATH,
73
144
  "/.well-known/ai-catalog.json",
74
145
  "/.well-known/fafa",
75
146
  ],
@@ -21,15 +21,22 @@ top‑level `_meta`. So the card is served the two ways a client can consume it:
21
21
 
22
22
  ```
23
23
  resources/read mcp-context-card://server-card # in band
24
- GET /.well-known/mcp/server-card # out of band (http transport)
24
+ GET /mcp/server-card # out of band (http transport; the 1.x
25
+ # /.well-known/mcp/server-card still answers)
25
26
  ```
26
27
 
27
- Both return:
28
+ Both return the card below (over HTTP it also carries `remotes`, the
29
+ Streamable HTTP endpoint at `<origin>/mcp` and its protocol versions):
28
30
 
29
31
  ```jsonc
30
32
  {
31
- "name": "mcp-context-card",
32
- "version": "1.4.0",
33
+ "$schema": "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json",
34
+ "name": "io.github.Wolfe-Jam/mcp-context-card",
35
+ "version": "1.6.0",
36
+ "title": "MCP Context Card",
37
+ "description": "MCP server for a project's context (AGENTS.md), memory, and identity — base or drop-in extension.",
38
+ "websiteUrl": "https://github.com/Wolfe-Jam/mcp-context-card",
39
+ "repository": { "url": "https://github.com/Wolfe-Jam/mcp-context-card", "source": "github" },
33
40
  "_meta": {
34
41
  "io.github.Wolfe-Jam.mcp-context-card/context": {
35
42
  "source": "AGENTS.md",
@@ -78,39 +85,52 @@ publisher lists artifacts, each entry keyed by its **media type** (`type`).
78
85
  GET /.well-known/ai-catalog.json
79
86
  ```
80
87
 
88
+ Served from `https://ctx.example.com` (1.6.0, this repo's own files):
89
+
81
90
  ```jsonc
82
91
  {
83
92
  "specVersion": "1.0",
84
93
  "host": { "displayName": "mcp-context-card", "identifier": "https://github.com/Wolfe-Jam/mcp-context-card" },
85
94
  "entries": [
86
- {
87
- "identifier": "urn:air:mcp-context-card:context",
88
- "type": "text/markdown",
89
- "mediaType": "text/markdown",
90
- "description": "…derived from the real AGENTS.md — section count + headings…",
91
- "url": "./AGENTS.md"
92
- },
93
- { "identifier": "urn:air:mcp-context-card:memory", "type": "application/vnd.fafm+yaml", "…": "…" },
94
- { "identifier": "urn:air:mcp-context-card:identity", "type": "application/vnd.fafa+yaml", "…": "…" }
95
+ { "identifier": "urn:air:faf.one:mcp:mcp-context-card", "type": "application/mcp-server-card+json",
96
+ "url": "https://ctx.example.com/mcp/server-card" },
97
+ { "identifier": "urn:air:faf.one:context:mcp-context-card", "type": "text/markdown",
98
+ "displayName": "mcp-context-card — project context (AGENTS.md)",
99
+ "description": "Agent instructions for this project — 9 section(s): Setup, Build, Test, …",
100
+ "url": "https://ctx.example.com/AGENTS.md" },
101
+ { "identifier": "urn:air:faf.one:identity:mcp-context-card", "type": "application/vnd.fafa+yaml",
102
+ "url": "https://ctx.example.com/.well-known/fafa",
103
+ "extensions": { "io.github.Wolfe-Jam.mcp-context-card": { "iana": "https://www.iana.org/assignments/media-types/application/vnd.fafa+yaml" } },
104
+ "…": "…" }
95
105
  ]
96
106
  }
97
107
  ```
98
108
 
109
+ The static `.well-known/ai-catalog.json` is the same, with relative URLs and the
110
+ Server Card inline as `data`. Memory (`application/vnd.fafm+yaml`) is listed only
111
+ with `MCP_CONTEXT_CARD_PUBLISH_MEMORY=1`.
112
+
99
113
  **Why it looks like this:**
100
114
 
115
+ - **The Server Card comes first** (`application/mcp-server-card+json`). It
116
+ carries its own name, description and version, so the entry repeats none.
101
117
  - **`type` is the routing key.** A consumer scanning catalogs for
102
118
  `text/markdown` context, or `application/vnd.fafm+yaml` memory, finds the
103
119
  entry without knowing this publisher.
104
120
  - **`identifier` is a `urn:air:` URN** scoped to the publisher
105
- (`urn:air:<host>:<concern>`). In ai-catalog's
121
+ (`urn:air:{domain}:{namespace}:{name}`). The domain is the one your `.fafa`
122
+ declares (`faf card init` writes it into `agent.id`); `faf.one` above is this
123
+ repo's. With no `.fafa` domain, the served catalog uses the serving host and
124
+ the static file uses plain IDs. No domain is ever invented. In ai-catalog's
106
125
  [trust‑manifest ADRs](https://github.com/Agent-Card/ai-catalog/tree/main/adr),
107
- `urn:air` identifiers carry a publisher‑domain‑aligned trust manifest — the
108
- entries here align to `github.com/Wolfe-Jam/mcp-context-card`.
126
+ `urn:air` identifiers carry a publisher‑domain‑aligned trust manifest, so the
127
+ domain in the identifier is the one a publisher would sign with.
109
128
  - **`description` is derived from real content** — the live AGENTS.md heading
110
129
  list, the current fact count, the agent's own description — not a blurb that
111
130
  drifts. See `buildCatalog()` in [`src/catalog-gen.ts`](../src/catalog-gen.ts).
112
- - **`url` is relative.** Served over HTTP it resolves against the origin; in a
113
- repo browser, against the tree.
131
+ - **`url` is absolute when served** (against the request's origin, honouring a
132
+ reverse proxy's forwarded headers) and relative in the static file, so it also
133
+ resolves in a repo browser.
114
134
 
115
135
  ---
116
136
 
package/docs/TRANSPORT.md CHANGED
@@ -6,8 +6,9 @@ the Server Card `_meta` block, and the memory file are identical either way
6
6
 
7
7
  ```
8
8
  mcp-context-card → stdio (default; an MCP host spawns this)
9
- mcp-context-card --http → Streamable HTTP :3000
10
- PORT=8080 mcp-context-card → Streamable HTTP :8080 (a hosted deploy sets PORT)
9
+ mcp-context-card --http → Streamable HTTP 127.0.0.1:3000 (this machine only)
10
+ PORT=8080 mcp-context-card → Streamable HTTP 127.0.0.1:8080
11
+ HOST=0.0.0.0 PORT=8080 … → exposed beyond this machine (a hosted deploy or container)
11
12
  mcp-context-card --stdio → force stdio even when PORT is set
12
13
  ```
13
14
 
@@ -79,8 +80,40 @@ when the server needs to:
79
80
  "memory" is a file on disk, not a live subscription. A fork that adds
80
81
  streaming tools would flip this. See `src/transport/http.ts`.
81
82
 
82
- ### DNS-rebinding protection
83
-
84
- Off by default (the server should run anywhere with no config). For a real
85
- deployment, pass `allowedHosts` / `allowedOrigins` to the transport and set
86
- `enableDnsRebindingProtection: true`.
83
+ ### Security: local by default
84
+
85
+ The MCP transports spec (Streamable HTTP, Security Warning) asks three things
86
+ of a server, and this one does them as follows:
87
+
88
+ | Spec | Here |
89
+ |---|---|
90
+ | MUST validate `Origin`; refuse an invalid one with 403 | `originGuard` on every route except the public discovery documents: a request with no `Origin` (a non-browser client), from a page on this machine, from an allowlisted origin, or from the server's own origin passes; any other gets 403 with a JSON-RPC error (`id: null`) |
91
+ | SHOULD bind only to localhost when running locally | binds `127.0.0.1` unless `HOST` says otherwise; the startup line says which |
92
+ | SHOULD authenticate every connection | not built in: keep it local, or put it behind your own auth |
93
+ | SHOULD rate-limit the Server Card endpoint (Server Card spec) | not built in: your proxy or host's limiter |
94
+
95
+ A local server also checks the `Host` header (`hostGuard`, the SDK's Express
96
+ `hostHeaderValidation` semantics for Hono): only loopback names are served, so a
97
+ DNS name rebound to 127.0.0.1 is refused, including on plain GETs, which often
98
+ carry no `Origin`.
99
+
100
+ The discovery documents (`/mcp/server-card`, `/.well-known/ai-catalog.json`,
101
+ `/.well-known/fafa`) stay readable from any origin with
102
+ `Access-Control-Allow-Origin: *`, because the Server Card spec requires it and
103
+ they carry no private data.
104
+
105
+ | Setting | Effect |
106
+ |---|---|
107
+ | `HOST=0.0.0.0` | expose the server (containers, hosted deploys); the Host check is then off unless names are listed below |
108
+ | `MCP_CONTEXT_CARD_ALLOWED_HOSTS=ctx.example.com` | extra Host names to accept, e.g. a reverse proxy's public name in front of a local bind |
109
+ | `MCP_CONTEXT_CARD_ALLOWED_ORIGINS=https://app.example.com` | browser origins allowed to call the server |
110
+ | `MCP_CONTEXT_CARD_PUBLISH_MEMORY=1` | serve `project.fafm`, list it in the AI Catalog, and show memory facts on an exposed `/card` |
111
+
112
+ ### Where memory shows
113
+
114
+ | Surface | Reader | Memory facts |
115
+ |---|---|---|
116
+ | `mcp-context-card card`, the MCP App panel, `render_context_card`, `save_context_card` | you and your agent | shown |
117
+ | `GET /card`, local server | a browser on this machine | shown |
118
+ | `GET /card`, exposed server | anyone who can reach it | the count only, unless `MCP_CONTEXT_CARD_PUBLISH_MEMORY=1` |
119
+ | `GET /project.fafm`, the AI Catalog | anyone, and crawlers | not served or listed, unless `MCP_CONTEXT_CARD_PUBLISH_MEMORY=1` |
package/docs/WIRING.md CHANGED
@@ -46,7 +46,7 @@
46
46
  ### Streamable HTTP (remote)
47
47
 
48
48
  ```bash
49
- PORT=8080 npx mcp-context-card # or: npx mcp-context-card --http
49
+ HOST=0.0.0.0 PORT=8080 npx mcp-context-card # exposed; without HOST it binds 127.0.0.1 only
50
50
  ```
51
51
 
52
52
  ```jsonc
@@ -104,10 +104,15 @@ To serve *your* artifacts:
104
104
  1. **Replace the three files** — `AGENTS.md`, `project.fafm`, `.well-known/fafa`
105
105
  — with your own, or point `MCP_CONTEXT_CARD_ROOT` at a directory that has them.
106
106
  `AGENTS.md` is the one with a real standard; the other two are swappable.
107
- 2. **Rename the namespace.** `serverCardMeta()` in `src/identity.ts` uses
108
- `io.github.Wolfe-Jam.mcp-context-card/*` keys, and `buildCatalog()` in
109
- `src/catalog-gen.ts` uses `urn:air:mcp-context-card:*` identifiers. Change both to
110
- a domain or GitHub identity you control ([MECHANISMS.md](./MECHANISMS.md)).
107
+ 2. **Set your domain in your `.fafa`, not in code.** The AI Catalog identifiers
108
+ (`urn:air:{domain}:{namespace}:{name}`) take the domain and short name from
109
+ your project's `.fafa`: `npx faf-cli card init` (or `--domain example.com`)
110
+ writes `agent.id: urn:air:example.com:agent:<short-name>`, and the catalog
111
+ follows it. No `.fafa`, no invented domain: the served catalog uses the host
112
+ it's served from, and the static file uses plain IDs. Only the Server Card
113
+ `_meta` key namespace (`io.github.Wolfe-Jam.mcp-context-card/*`,
114
+ `META_NS` in `src/identity.ts`) is a code constant. Change it if you fork
115
+ ([MECHANISMS.md](./MECHANISMS.md)).
111
116
  3. **Swap the media types** in `serverCardMeta()` if your memory / identity
112
117
  artifacts aren't `.fafm` / `.fafa`. Drop the `iana` field for any that isn't
113
118
  a registered type.