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
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
|
|
24
|
-
* one namespaced key per concern. Served in-band as the
|
|
25
|
-
* `mcp-context-card://server-card` resource and out-of-band at
|
|
26
|
-
*
|
|
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
|
|
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,
|
|
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 {
|
|
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
|
|
35
|
-
* one namespaced key per concern. Served in-band as the
|
|
36
|
-
* `mcp-context-card://server-card` resource and out-of-band at
|
|
37
|
-
*
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
+
}
|
package/dist/transport/http.d.ts
CHANGED
|
@@ -1,2 +1,19 @@
|
|
|
1
|
-
import { Hono } from "hono";
|
|
2
|
-
export
|
|
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;
|
package/dist/transport/http.js
CHANGED
|
@@ -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
|
-
*
|
|
15
|
-
* GET
|
|
16
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
46
|
-
app.get(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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, {
|
|
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:
|
|
139
|
+
mcp: MCP_PATH,
|
|
140
|
+
serverCard: SERVER_CARD_PATH,
|
|
70
141
|
card: "/card",
|
|
71
142
|
wellKnown: [
|
|
72
|
-
|
|
143
|
+
LEGACY_SERVER_CARD_PATH,
|
|
73
144
|
"/.well-known/ai-catalog.json",
|
|
74
145
|
"/.well-known/fafa",
|
|
75
146
|
],
|
package/docs/MECHANISMS.md
CHANGED
|
@@ -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
|
|
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
|
-
"
|
|
32
|
-
"
|
|
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
|
-
"
|
|
88
|
-
|
|
89
|
-
"
|
|
90
|
-
"description": "
|
|
91
|
-
"url": "
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
|
108
|
-
|
|
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
|
|
113
|
-
|
|
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
|
|
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
|
-
###
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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. **
|
|
108
|
-
`
|
|
109
|
-
|
|
110
|
-
|
|
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.
|