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.
@@ -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";
@@ -23,48 +23,137 @@ export function safeAccent(a) {
23
23
  return a && ACCENT_OK.test(a) ? a : AAIF_ACCENT;
24
24
  }
25
25
  const CSS = (accent) => `
26
+ /* The page and the card are themed independently. The page follows the
27
+ viewer's OS. The card is the owner's choice: body[data-theme] (light | dark;
28
+ none = follow the OS), and the reader can switch it with the ◐ toggle (#ink). */
26
29
  :root{
27
30
  --accent:${accent};
28
- --bg:#f4f4f5; --card:#fff; --fg:#0a0a0a; --muted:#6b6b70;
29
- --line:rgba(0,0,0,.09); --chip:rgba(0,0,0,.05);
30
- --card-shadow:0 1px 3px rgba(0,0,0,.06), 0 12px 32px rgba(0,0,0,.10);
31
+ --page-bg:#f4f4f5;--page-muted:#6b6b70;--page-line:rgba(0,0,0,.09);--page-chip:rgba(0,0,0,.05);
31
32
  }
32
- :root[data-theme="dark"]{
33
- --bg:#000; --card:#0d0d0d; --fg:#fafafa; --muted:#9a9aa0;
34
- --line:rgba(255,255,255,.13); --chip:rgba(255,255,255,.07);
35
- --card-shadow:0 0 0 1px rgba(255,255,255,.16),
36
- 0 8px 40px color-mix(in srgb, var(--accent) 20%, transparent);
33
+ @media (prefers-color-scheme:dark){
34
+ :root{--page-bg:#000;--page-muted:#9a9aa0;--page-line:rgba(255,255,255,.13);--page-chip:rgba(255,255,255,.07)}
37
35
  }
36
+ .bcard{--card:#fff;--fg:#0a0a0a;--muted:#6b6b70;--line:rgba(0,0,0,.09);--chip:rgba(0,0,0,.05);--card-shadow:0 1px 3px rgba(0,0,0,.06),0 12px 32px rgba(0,0,0,.10)}
37
+ body[data-theme="dark"] .bcard,
38
+ body[data-theme="light"] #ink:checked~.stage .bcard{--card:#0d0d0d;--fg:#fafafa;--muted:#9a9aa0;--line:rgba(255,255,255,.13);--chip:rgba(255,255,255,.07);--card-shadow:0 0 0 1px rgba(255,255,255,.16),0 8px 40px color-mix(in srgb,var(--accent) 20%,transparent)}
39
+ body[data-theme="dark"] #ink:checked~.stage .bcard{--card:#fff;--fg:#0a0a0a;--muted:#6b6b70;--line:rgba(0,0,0,.09);--chip:rgba(0,0,0,.05);--card-shadow:0 1px 3px rgba(0,0,0,.06),0 12px 32px rgba(0,0,0,.10)}
38
40
  @media (prefers-color-scheme:dark){
39
- :root:not([data-theme="light"]){
40
- --bg:#000; --card:#0d0d0d; --fg:#fafafa; --muted:#9a9aa0;
41
- --line:rgba(255,255,255,.13); --chip:rgba(255,255,255,.07);
42
- --card-shadow:0 0 0 1px rgba(255,255,255,.16),
43
- 0 8px 40px color-mix(in srgb, var(--accent) 20%, transparent);
44
- }
41
+ body:not([data-theme]) .bcard{--card:#0d0d0d;--fg:#fafafa;--muted:#9a9aa0;--line:rgba(255,255,255,.13);--chip:rgba(255,255,255,.07);--card-shadow:0 0 0 1px rgba(255,255,255,.16),0 8px 40px color-mix(in srgb,var(--accent) 20%,transparent)}
42
+ body:not([data-theme]) #ink:checked~.stage .bcard{--card:#fff;--fg:#0a0a0a;--muted:#6b6b70;--line:rgba(0,0,0,.09);--chip:rgba(0,0,0,.05);--card-shadow:0 1px 3px rgba(0,0,0,.06),0 12px 32px rgba(0,0,0,.10)}
43
+ }
44
+ @media (prefers-color-scheme:light){
45
+ body:not([data-theme]) #ink:checked~.stage .bcard{--card:#0d0d0d;--fg:#fafafa;--muted:#9a9aa0;--line:rgba(255,255,255,.13);--chip:rgba(255,255,255,.07);--card-shadow:0 0 0 1px rgba(255,255,255,.16),0 8px 40px color-mix(in srgb,var(--accent) 20%,transparent)}
45
46
  }
47
+ .bcard{color:var(--fg)}
46
48
  *{box-sizing:border-box}
47
- body{margin:0;background:var(--bg);color:var(--fg);
49
+ body{margin:0;background:var(--page-bg);color:var(--page-muted);
48
50
  font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
49
- padding:40px 18px}
50
- .card{max-width:760px;margin:0 auto;background:var(--card);border:1px solid var(--line);
51
- border-radius:14px;overflow:clip;box-shadow:var(--card-shadow)}
52
- .card>*{padding:26px 30px}
53
- .top{border-top:4px solid var(--accent);border-bottom:1px solid var(--line)}
54
- h1{margin:0 0 10px;font-size:1.7rem;letter-spacing:-.02em}
51
+ padding:40px 16px}
52
+ /* ── the business card: two faces, flip, two views ─────────────────── */
53
+ .sr{position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
54
+ .stage{max-width:760px;margin:0 auto}
55
+ .views{display:flex;justify-content:flex-end;margin:0 0 10px}
56
+ .view,.hint label{cursor:pointer}
57
+ .view{font-size:.78rem;font-weight:600;padding:3px 11px;border-radius:20px;border:1px solid var(--page-line);
58
+ background:var(--page-chip);color:var(--page-muted);letter-spacing:.2em}
59
+ .views{gap:8px}
60
+ .view:hover,.hint label:hover{color:var(--accent);border-color:var(--accent)}
61
+ .bcard{margin:0 auto;width:100%;aspect-ratio:7/4;perspective:1600px}
62
+ #portrait:checked~.stage{max-width:480px}
63
+ #portrait:checked~.stage .bcard{aspect-ratio:4/7}
64
+ .faces{position:relative;width:100%;height:100%;transform-style:preserve-3d;transition:transform .6s ease}
65
+ #flip:checked~.stage .faces{transform:rotateY(180deg)}
66
+ .face{position:absolute;inset:0;background:var(--card);border:1px solid var(--line);border-radius:16px;
67
+ box-shadow:var(--card-shadow);backface-visibility:hidden;-webkit-backface-visibility:hidden;overflow:hidden}
68
+ .back{transform:rotateY(180deg) translateZ(1px);display:flex;flex-direction:column}
69
+ /* The face turned away takes no pointer or wheel events, so the visible face
70
+ scrolls (a hidden backface can still win hit-testing, notably in Chrome/Safari on macOS). */
71
+ #flip:checked~.stage .front,#flip:not(:checked)~.stage .back{pointer-events:none;visibility:hidden;transition:visibility 0s .3s}
72
+ #flip:checked~.stage .back,#flip:not(:checked)~.stage .front{visibility:visible;transition:visibility 0s .3s}
73
+ .panes{overscroll-behavior:contain;-webkit-overflow-scrolling:touch}
74
+ .front{display:flex;flex-direction:column;justify-content:center;padding:34px 44px;
75
+ border-top:5px solid var(--accent);
76
+ background:linear-gradient(135deg,color-mix(in srgb,var(--accent) 10%,var(--card)) 0%,var(--card) 55%)}
77
+ .front .id{max-width:34em}
78
+ h1{margin:0;font-size:2.1rem;line-height:1.15;letter-spacing:-.025em}
79
+ .title{margin:8px 0 0;font-size:.82rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--accent)}
80
+ .oneliner{margin:16px 0 0;font-size:1.05rem;color:var(--muted);line-height:1.5}
81
+ .foot-front{position:absolute;left:44px;right:130px;bottom:22px;display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px}
82
+ .domain{font:.8rem ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted)}
83
+ .corner{position:absolute;width:30px;height:30px;border-radius:50%;display:grid;place-items:center;
84
+ cursor:pointer;font-weight:700;font-size:.9rem;color:var(--muted);background:var(--chip);border:1px solid var(--line);z-index:4}
85
+ .corner:hover{color:var(--accent);border-color:var(--accent)}
86
+ .corner.tr{top:16px;right:16px;font-family:Georgia,serif;font-style:italic}
87
+ .logo{position:absolute;top:34px;left:44px;width:84px;height:84px;border-radius:50%;display:grid;place-items:center;
88
+ color:#fff;font-weight:800;font-size:2rem;letter-spacing:-.02em;line-height:1;
89
+ background:radial-gradient(circle at 30% 25%,color-mix(in srgb,var(--accent) 80%,#fff),var(--accent) 45%,color-mix(in srgb,var(--accent) 50%,#000));
90
+ box-shadow:0 0 0 3px var(--card),0 0 0 4px color-mix(in srgb,var(--accent) 45%,transparent),0 6px 20px color-mix(in srgb,var(--accent) 30%,transparent)}
91
+ .front .id{margin-top:70px}
92
+ .corner.br{bottom:16px;right:16px}
93
+ .flipbtn{position:absolute;right:18px;bottom:16px;z-index:4;cursor:pointer;width:42px;height:42px;border-radius:50%;
94
+ display:grid;place-items:center;color:var(--fg);background:transparent;box-shadow:inset 0 0 0 2px var(--fg);
95
+ transition:transform .2s ease}
96
+ .flipbtn span{font-size:1.3rem;line-height:1;font-weight:700}
97
+
98
+ /* the back's button: same spot and outline as the front's; ↺ instead of ↻. Over
99
+ scrolling text it takes the card's colour, so the arrow stays readable. */
100
+ .flipbtn.back-btn{background:var(--card)}
101
+ /* fast tooltip: shows at once on hover or keyboard focus (a title tooltip waits ~1 s) */
102
+ .flipbtn[data-tip]::after{content:attr(data-tip);position:absolute;right:0;bottom:calc(100% + 8px);white-space:nowrap;
103
+ font:600 .74rem/1 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;letter-spacing:0;padding:6px 9px;border-radius:7px;
104
+ color:var(--card);background:var(--fg);opacity:0;transform:translateY(3px);pointer-events:none;transition:opacity .08s,transform .08s}
105
+ .flipbtn[data-tip]:hover::after,#flip:focus-visible~.stage .flipbtn[data-tip]::after{opacity:1;transform:none}
106
+ .flipbtn:hover{transform:none}.flipbtn:hover span{transform:rotate(-25deg)}.flipbtn span{transition:transform .2s ease}
107
+ .whatis{position:absolute;top:16px;right:16px;z-index:5}
108
+ .whatis>summary{position:static;list-style:none}
109
+ .whatis>summary::-webkit-details-marker{display:none}
110
+ .whatis[open]>summary{color:var(--accent);border-color:var(--accent)}
111
+ .whatis-panel{position:absolute;right:0;top:38px;width:min(320px,70vw);padding:14px 16px;border-radius:12px;
112
+ background:var(--card);border:1px solid var(--line);box-shadow:var(--card-shadow);font-size:.84rem;line-height:1.5}
113
+ .whatis-panel p{margin:0 0 8px}.whatis-head{font-weight:800;color:var(--accent);letter-spacing:-.01em;font-size:.95rem}
114
+ .whatis-foot{color:var(--muted);margin:0!important}.whatis-panel code{font:.86em ui-monospace,SFMono-Regular,Menlo,monospace}
115
+ .tabbar{display:flex;flex-wrap:wrap;gap:4px;padding:14px 60px 0 18px;border-bottom:1px solid var(--line);border-top:5px solid var(--accent)}
116
+ .tabbar label{cursor:pointer;font-size:.8rem;font-weight:600;color:var(--muted);padding:6px 11px;border-radius:8px 8px 0 0}
117
+ .tabbar label:hover{color:var(--fg)}
118
+ .panes{flex:1;overflow:auto;padding:20px 26px 72px}
119
+ .pane{display:none}
120
+ .lead{margin:0 0 14px;font-size:1rem;line-height:1.6}
121
+ .kv{border-collapse:collapse;font-size:.86rem;width:100%}
122
+ .kv th{text-align:left;font-weight:600;color:var(--muted);padding:6px 14px 6px 0;white-space:nowrap;vertical-align:top}
123
+ .kv td{padding:6px 0;border-bottom:1px solid var(--line)}
124
+ .kv code,.lead code{font:.86em ui-monospace,SFMono-Regular,Menlo,monospace}
125
+ .hint{text-align:center;color:var(--page-muted);font-size:.78rem;margin:14px 0 0}
126
+ .hint label{color:var(--accent)}
127
+ #flip:focus-visible~.stage .flipbtn,#portrait:focus-visible~.stage .view[for=portrait],#ink:focus-visible~.stage .view[for=ink]{outline:2px solid var(--accent);outline-offset:2px}
128
+ @media (max-width:640px){
129
+ .bcard,#portrait:checked~.stage .bcard{aspect-ratio:auto;height:min(78vh,720px)}
130
+ .front{padding:28px 26px}.foot-front{left:26px}
131
+ .logo{top:24px;left:26px;width:64px;height:64px;font-size:1.5rem}.front .id{margin-top:56px}
132
+ h1{font-size:1.7rem}
133
+ }
134
+ /* flat: expanded render and print — both faces, every tab, no flip */
135
+ .flat .faces,.flat .face{position:static;transform:none!important;height:auto}
136
+ .flat .bcard{aspect-ratio:auto;perspective:none}
137
+ .flat .face{margin:0 0 18px}.flat .front{min-height:260px;position:relative}
138
+ .flat .pane{display:block;margin:0 0 22px}.flat .bcard{color:var(--fg)}.flat .tabbar,.flat .views,.flat .hint,.flat .corner,.flat .flipbtn,.flat .whatis{display:none}
139
+ .flat .panes{overflow:visible}
140
+ @media print{
141
+ .faces,.face{position:static!important;transform:none!important;height:auto!important}
142
+ .bcard{aspect-ratio:auto!important}.face{margin:0 0 18px;box-shadow:none}
143
+ .pane{display:block!important;margin:0 0 22px}.tabbar,.views,.hint,.corner,.flipbtn,.whatis{display:none!important}
144
+ .panes{overflow:visible}
145
+ }
55
146
  .pills{display:flex;flex-wrap:wrap;gap:6px}
56
147
  .pill{font-size:.74rem;font-weight:600;padding:3px 9px;border-radius:20px;background:var(--chip);color:var(--muted)}
57
148
  .pill.accent{background:color-mix(in srgb,var(--accent) 16%,transparent);color:var(--accent)}
58
- section{border-bottom:1px solid var(--line)}
59
- section:last-child{border-bottom:0}
60
149
  .label{font-size:.7rem;font-weight:700;letter-spacing:.16em;text-transform:uppercase;
61
150
  color:var(--accent);margin:0 0 14px}
62
151
  .toc{display:flex;flex-wrap:wrap;gap:6px 14px;margin:0;padding:0;list-style:none}
63
152
  .toc a{font-size:.82rem;color:var(--muted);text-decoration:none}
64
153
  .toc a:hover{color:var(--accent)}
65
154
  /* ── collapsible context ─────────────────────────────────────────── */
66
- .ctx-nav{position:sticky;top:0;z-index:3;background:var(--card);
67
- margin:0 -30px 16px;padding:11px 30px;border-bottom:1px solid var(--line);
155
+ .ctx-nav{position:sticky;top:-20px;z-index:3;background:var(--card);
156
+ margin:0 -26px 16px;padding:11px 26px;border-bottom:1px solid var(--line);
68
157
  display:flex;flex-wrap:wrap;align-items:flex-start;gap:8px 16px}
69
158
  .xall{margin-left:auto;flex:none;font:inherit;font-size:.76rem;font-weight:600;
70
159
  white-space:nowrap;padding:3px 11px;border-radius:20px;border:1px solid var(--line);
@@ -110,29 +199,74 @@ details.ctx-section>.md{padding:0 0 16px}
110
199
  .disc code{font:.86em ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted)}
111
200
  .fetch{margin:14px 0 0;font-size:.82rem;color:var(--muted)}
112
201
  .fetch code{background:var(--chip);padding:1px 5px;border-radius:5px}
113
- .foot{color:var(--muted);font-size:.78rem;text-align:center;border-top:1px solid var(--line)}
114
202
  .none{color:var(--muted);font-style:italic}
115
203
  `;
116
204
  const htmlAttr = (theme) => theme === "auto" ? "" : ` data-theme="${theme}"`;
205
+ /** What the front calls this thing, from where it runs: "MCP server", "A2A agent", or both. */
206
+ export function cardKind(id) {
207
+ const kinds = [];
208
+ const protocols = new Set((id?.endpoints ?? []).map((e) => e.protocol));
209
+ if (protocols.has("a2a"))
210
+ kinds.push("A2A agent");
211
+ if (protocols.has("mcp") || (id?.packages ?? []).length)
212
+ kinds.push("MCP server");
213
+ return kinds;
214
+ }
215
+ /** The front's title line: what it is, then its version. Empty when nothing is known. */
216
+ export function cardTitle(id) {
217
+ return [cardKind(id).join(" · "), id?.agentVersion ? `v${id.agentVersion}` : ""].filter(Boolean).join(" · ");
218
+ }
219
+ /** The front's logo: the first letters of the name's first two words ("mcp-context-card" → "MC"). */
220
+ export function cardInitials(name) {
221
+ const words = name.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
222
+ return words
223
+ .slice(0, 2)
224
+ .map((w) => Array.from(w)[0].toUpperCase())
225
+ .join("");
226
+ }
227
+ /** The front's one-liner: the first sentence of the description. */
228
+ export function cardOneLiner(id, max = 140) {
229
+ const d = id?.description;
230
+ if (!d)
231
+ return "";
232
+ const first = clip(d, max);
233
+ if (!first.endsWith("…"))
234
+ return first;
235
+ // A long first sentence: stop at its first natural pause (— ; :) when that
236
+ // still says something, rather than cutting a phrase in half.
237
+ const pause = /\s[—–]\s|;\s|:\s/.exec(d);
238
+ return pause && pause.index >= 40 && pause.index <= max ? d.slice(0, pause.index).trim() : first;
239
+ }
240
+ /** The project's card: read its files, then draw. */
117
241
  export function renderCard(root, opts = {}) {
118
- const theme = opts.theme ?? "auto";
119
- const accent = safeAccent(opts.accent);
242
+ return renderBusinessCard(readCard(root, opts), opts);
243
+ }
244
+ /** The (i) panel for an agent or server card. */
245
+ const AGENT_ABOUT = `<p class="whatis-head">Business Cards for Agents</p>
246
+ <p>This is the public card of an AI agent or MCP server: who it is, what it does, where to reach it. Flip it for the detail.</p>
247
+ <p>People read it here. Machines read the same facts from its MCP Server Card and AI Catalog, before they ever connect.</p>
248
+ <p class="whatis-foot">Made with <code>mcp-context-card</code></p>`;
249
+ /** Read a project's AGENTS.md, memory and identity into a business card. */
250
+ export function readCard(root, opts = {}) {
251
+ const flat = !!opts.expanded;
120
252
  const agents = parseAgentsMd(join(root, "AGENTS.md"));
121
253
  const mem = parseFafm(join(root, "project.fafm"));
122
254
  const id = resolveIdentity(root);
123
255
  const meta = serverCardMeta();
124
256
  const name = id?.displayName ?? id?.name ?? basename(resolve(root));
125
- const pills = [
126
- id?.vendor && id.vendor !== id.status && `<span class="pill">${escapeHtml(id.vendor)}</span>`,
127
- id?.agentVersion && `<span class="pill">v${escapeHtml(id.agentVersion)}</span>`,
128
- id?.status && `<span class="pill accent">${escapeHtml(id.status)}</span>`,
129
- id?.license && `<span class="pill">${escapeHtml(id.license)}</span>`,
130
- ]
131
- .filter(Boolean)
132
- .join("");
133
- // CONTEXT — one <details> per AGENTS.md section, collapsed by default (the card
134
- // scans in one screen); `expanded` renders them all open. The "# AGENTS.md"
135
- // top-level heading is dropped; its intro rides above the sections.
257
+ const title = cardTitle(id);
258
+ const oneLiner = cardOneLiner(id);
259
+ const domain = /^urn:air:([^:]+):/i.exec(id?.id ?? "")?.[1];
260
+ // The version rides in the title line, so the front's chips skip it.
261
+ const chips = [
262
+ id?.vendor && id.vendor !== id.status ? { text: id.vendor } : null,
263
+ !title && id?.agentVersion ? { text: `v${id.agentVersion}` } : null,
264
+ id?.status ? { text: id.status, accent: true } : null,
265
+ id?.license ? { text: id.license } : null,
266
+ ].filter((c) => !!c);
267
+ // CONTEXT — one <details> per AGENTS.md section, collapsed by default;
268
+ // `expanded` renders them all open. The "# AGENTS.md" top-level heading is
269
+ // dropped; its intro rides above the sections.
136
270
  const bodySections = agents?.sections.filter((s) => s.level > 1) ?? [];
137
271
  const toc = bodySections.length
138
272
  ? `<ul class="toc">${bodySections
@@ -142,29 +276,53 @@ export function renderCard(root, opts = {}) {
142
276
  const preamble = [agents?.preamble, agents?.sections.find((s) => s.level === 1)?.body ?? ""]
143
277
  .filter(Boolean)
144
278
  .join("\n\n");
145
- const openAttr = opts.expanded ? " open" : "";
279
+ const openAttr = flat ? " open" : "";
146
280
  const sections = bodySections
147
281
  .map((s) => `<details class="ctx-section"${openAttr} id="${slug(s.heading)}"><summary>${escapeHtml(s.heading)}</summary><div class="md">${renderMarkdown(s.body)}</div></details>`)
148
282
  .join("");
149
283
  const contextBody = agents
150
284
  ? `<div class="ctx-nav">${toc}${bodySections.length
151
- ? `<button type="button" class="xall" hidden>${opts.expanded ? "Collapse all" : "Expand all"}</button>`
285
+ ? `<button type="button" class="xall" hidden>${flat ? "Collapse all" : "Expand all"}</button>`
152
286
  : ""}</div>
153
287
  ${preamble ? `<div class="ctx-preamble md">${renderMarkdown(preamble)}</div>` : ""}
154
288
  <div class="ctx-body">${sections}</div>`
155
289
  : `<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
290
  // 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>`;
291
+ const memLabel = `${mem.facts.length} fact${mem.facts.length === 1 ? "" : "s"}${opts.memory === "private" && mem.facts.length ? ", kept private" : ""}`;
292
+ const memoryBody = opts.memory === "private" && mem.facts.length
293
+ ? `<p class="none">Kept private on this page. To show the facts, set <code>${PUBLISH_MEMORY_ENV}=1</code>.</p>`
294
+ : mem.facts.length
295
+ ? mem.facts
296
+ .map((f) => {
297
+ const verified = f.verification_status === "verified";
298
+ const tags = (f.tags ?? [])
299
+ .map((t) => `<span class="tag">${escapeHtml(t)}</span>`)
300
+ .join("");
301
+ 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>`;
302
+ })
303
+ .join("")
304
+ : `<p class="none">No facts yet. Ask your agent to remember something, and it lands here.</p>`;
305
+ // ABOUT — the identity, in full
306
+ const aboutRows = [
307
+ id?.id && ["Agent ID", `<code>${escapeHtml(id.id)}</code>`],
308
+ id?.vendor && ["Publisher", escapeHtml(id.vendor)],
309
+ id?.agentVersion && ["Version", `v${escapeHtml(id.agentVersion)}`],
310
+ id?.status && ["Status", escapeHtml(id.status)],
311
+ id?.license && ["License", escapeHtml(id.license)],
312
+ ...(id?.endpoints ?? []).map((e) => [
313
+ e.protocol === "a2a" ? "A2A endpoint" : e.protocol === "mcp" ? "MCP endpoint" : escapeHtml(e.protocol),
314
+ e.location ? `<code>${escapeHtml(e.location)}</code>` : "—",
315
+ ]),
316
+ ...(id?.packages ?? []).map((pk) => [`Package (${escapeHtml(pk.registryType)})`, `<code>${escapeHtml(pk.identifier)}</code>`]),
317
+ ].filter(Boolean);
318
+ const aboutBody = `${id?.description ? `<p class="lead">${escapeHtml(id.description)}</p>` : `<p class="none">No description yet. <code>npx faf-cli card init</code> writes one into the project's <code>.fafa</code>.</p>`}${aboutRows.length
319
+ ? `<table class="kv"><tbody>${aboutRows.map(([k, v]) => `<tr><th>${k}</th><td>${v}</td></tr>`).join("")}</tbody></table>`
320
+ : ""}`;
321
+ // SKILLS — only when the .fafa lists capabilities
322
+ const skills = id?.skills ?? [];
323
+ const skillsBody = skills
324
+ .map((sk) => `<div class="fact"><p><b>${escapeHtml(sk.name)}</b>${sk.description ? ` — ${escapeHtml(sk.description)}` : ""}</p></div>`)
325
+ .join("");
168
326
  // DISCOVERY
169
327
  const rows = Object.entries(meta)
170
328
  .map(([k, v]) => {
@@ -172,63 +330,129 @@ export function renderCard(root, opts = {}) {
172
330
  return `<tr><td>${concern}</td><td><code>${escapeHtml(v.source)}</code></td><td><code>${escapeHtml(v.mediaType)}</code></td></tr>`;
173
331
  })
174
332
  .join("");
333
+ const discoveryBody = `<table class="disc"><thead><tr><th>concern</th><th>source</th><th>media type</th></tr></thead><tbody>${rows}</tbody></table>
334
+ <p class="fetch">A machine reads this over <b>MCP</b> from the
335
+ <code>${escapeHtml(SERVER_CARD_URI)}</code> resource; over <b>HTTP</b> also
336
+ from <code>GET /mcp/server-card</code> and
337
+ <code>GET /.well-known/ai-catalog.json</code>.</p>`;
338
+ // The back: tabs (CSS radios; every pane shows when flat or printed).
339
+ // [key, tab label, pane heading, body]
340
+ const tabs = [
341
+ ["about", "About", "About", aboutBody],
342
+ ...(skills.length
343
+ ? [["skills", "Skills", `Skills — ${skills.length}`, skillsBody]]
344
+ : []),
345
+ ["context", "Context", "Context — AGENTS.md", contextBody],
346
+ ["memory", "Memory", `Memory — ${memLabel}`, memoryBody],
347
+ ["discovery", "Discovery", "Discovery", discoveryBody],
348
+ ];
349
+ return {
350
+ name,
351
+ title,
352
+ oneLiner,
353
+ domain,
354
+ chips,
355
+ tabs: tabs.map(([key, label, heading, html]) => ({ key, label, heading, html })),
356
+ about: AGENT_ABOUT,
357
+ };
358
+ }
359
+ /** Draw any business card as one self-contained HTML page. */
360
+ export function renderBusinessCard(card, opts = {}) {
361
+ const theme = opts.theme ?? "auto";
362
+ const accent = safeAccent(opts.accent);
363
+ const portrait = opts.layout === "portrait";
364
+ const flat = !!opts.expanded;
365
+ const { name, title, oneLiner, domain } = card;
366
+ const tabs = card.tabs.map((t) => ({ ...t, key: t.key.replace(/[^a-z0-9-]/gi, "").toLowerCase() || "tab" }));
367
+ const pills = card.chips
368
+ .map((c) => `<span class="pill${c.accent ? " accent" : ""}">${escapeHtml(c.text)}</span>`)
369
+ .join("");
370
+ const radios = tabs
371
+ .map((t, i) => `<input type="radio" name="tab" id="t-${t.key}" class="sr"${i === 0 ? " checked" : ""}>`)
372
+ .join("");
373
+ const tabbar = tabs.map((t) => `<label for="t-${t.key}">${escapeHtml(t.label)}</label>`).join("");
374
+ const panes = tabs
375
+ .map((t) => `<div class="pane p-${t.key}"><p class="label">${escapeHtml(t.heading)}</p>${t.html}</div>`)
376
+ .join("");
377
+ // One CSS rule pair per tab: its pane shows, its label lights, when its radio is checked.
378
+ const tabCss = tabs
379
+ .map((t) => `#t-${t.key}:checked~.panes .p-${t.key}{display:block}#t-${t.key}:checked~.tabbar label[for=t-${t.key}]{color:var(--accent);background:var(--chip)}`)
380
+ .join("");
381
+ // The (i) panel: the same control, in the same spot, on both faces.
382
+ const WHATIS = `<details class="whatis">
383
+ <summary class="corner tr" title="About this card" aria-label="About this card">i</summary>
384
+ <div class="whatis-panel">
385
+ ${card.about}
386
+ </div>
387
+ </details>`;
175
388
  return `<!doctype html>
176
- <html lang="en"${htmlAttr(theme)}>
389
+ <html lang="en">
177
390
  <head>
178
391
  <meta charset="utf-8">
179
392
  <meta name="viewport" content="width=device-width,initial-scale=1">
180
- <title>${escapeHtml(name)} — context card</title>
181
- <style>${CSS(accent)}</style>
393
+ <title>${escapeHtml(name)} — business card</title>
394
+ <style>${CSS(accent)}${tabCss}</style>
182
395
  </head>
183
- <body>
184
- <main class="card">
185
- <div class="top">
186
- <h1>${escapeHtml(name)}</h1>
187
- ${pills ? `<div class="pills">${pills}</div>` : ""}
188
- </div>
189
- <section>
190
- <p class="label">Context — AGENTS.md</p>
191
- ${contextBody}
192
- </section>
193
- <section>
194
- <p class="label">Memory — ${mem.facts.length} fact${mem.facts.length === 1 ? "" : "s"}</p>
195
- ${memoryBody}
196
- </section>
197
- <section>
198
- <p class="label">Discovery</p>${id?.id ? `\n <p class="fetch">Agent ID <code>${escapeHtml(id.id)}</code></p>` : ""}
199
- <table class="disc"><thead><tr><th>concern</th><th>source</th><th>media type</th></tr></thead><tbody>${rows}</tbody></table>
200
- <p class="fetch">A machine reads this over <b>MCP</b> from the
201
- <code>${escapeHtml(SERVER_CARD_URI)}</code> resource; over <b>HTTP</b> also
202
- from <code>GET /.well-known/mcp/server-card</code> and
203
- <code>GET /.well-known/ai-catalog.json</code>.</p>
204
- </section>
205
- <div class="foot">${escapeHtml(name)} · context card</div>
206
- </main>
207
- ${bodySections.length ? TOGGLE_SCRIPT : ""}
396
+ <body${flat ? ` class="flat"` : ""}${htmlAttr(theme)}>
397
+ <input type="checkbox" id="flip" class="sr" aria-label="Flip the card">
398
+ <input type="checkbox" id="ink" class="sr" aria-label="Switch the card between light and dark">
399
+ <input type="checkbox" id="portrait" class="sr" aria-label="Portrait view"${portrait ? " checked" : ""}>
400
+ <div class="stage">
401
+ <div class="views"><label for="ink" class="view" title="Card: light or dark">◐</label><label for="portrait" class="view" title="Landscape or portrait">▭ ▯</label></div>
402
+ <main class="bcard">
403
+ <div class="faces">
404
+ <section class="face front">
405
+ <div class="logo" aria-hidden="true">${escapeHtml(cardInitials(name))}</div>
406
+ ${WHATIS}
407
+ <div class="id">
408
+ <h1>${escapeHtml(name)}</h1>
409
+ ${title ? `<p class="title">${escapeHtml(title)}</p>` : ""}
410
+ ${oneLiner ? `<p class="oneliner">${escapeHtml(oneLiner)}</p>` : ""}
411
+ </div>
412
+ <div class="foot-front">${domain ? `<span class="domain">${escapeHtml(domain)}</span>` : ""}${pills ? `<div class="pills">${pills}</div>` : ""}</div>
413
+ <label for="flip" class="flipbtn" data-tip="Flip the card" aria-label="Flip the card"><span aria-hidden="true">↻</span></label>
414
+ </section>
415
+ <section class="face back">
416
+ ${radios}
417
+ ${WHATIS}
418
+ <div class="tabbar">${tabbar}</div>
419
+ <div class="panes">${panes}</div>
420
+ <label for="flip" class="flipbtn back-btn" data-tip="Flip back" aria-label="Flip back"><span aria-hidden="true">↺</span></label>
421
+ </section>
422
+ </div>
423
+ </main>
424
+ <p class="hint">${escapeHtml(name)} · business card · <label for="flip">flip it over</label></p>
425
+ </div>
426
+ ${CARD_SCRIPT}
208
427
  </body>
209
428
  </html>
210
429
  `;
211
430
  }
212
431
  /**
213
- * Expand-all / Collapse-all. The only script in the card — a progressive
214
- * enhancement: with it off, every section still opens and closes on its own
215
- * (native `<details>`), just without the bulk button. It also opens every
216
- * section for printing, since browsers don't agree on whether a closed
217
- * `<details>` prints its content.
432
+ * The card's only script — a progressive enhancement. Flip, tabs and the view
433
+ * toggle are CSS (checkbox and radio inputs) and work without it. It adds:
434
+ * Expand all / Collapse all for the context sections, opening the section a
435
+ * `#hash` names (flipping to the back and its Context tab first), and opening
436
+ * every section for printing (browsers disagree on printing a closed
437
+ * `<details>`).
218
438
  */
219
- const TOGGLE_SCRIPT = `<script>
439
+ const CARD_SCRIPT = `<script>
220
440
  (function(){
221
- var btn=document.querySelector(".xall");
222
441
  var secs=[].slice.call(document.querySelectorAll("details.ctx-section"));
223
- if(!btn||!secs.length)return;
224
- var sync=function(){btn.textContent=secs.every(function(d){return d.open})?"Collapse all":"Expand all"};
225
- btn.hidden=false;
226
- btn.addEventListener("click",function(){
227
- var open=!secs.every(function(d){return d.open});
228
- secs.forEach(function(d){d.open=open});sync();
229
- });
230
- secs.forEach(function(d){d.addEventListener("toggle",sync)});
231
- var openHash=function(){var d=document.getElementById(location.hash.slice(1));if(d&&d.tagName==="DETAILS")d.open=true};
442
+ var btn=document.querySelector(".xall");
443
+ var sync=function(){if(btn)btn.textContent=secs.every(function(d){return d.open})?"Collapse all":"Expand all"};
444
+ if(btn&&secs.length){
445
+ btn.hidden=false;
446
+ btn.addEventListener("click",function(){var open=!secs.every(function(d){return d.open});secs.forEach(function(d){d.open=open});sync()});
447
+ secs.forEach(function(d){d.addEventListener("toggle",sync)});
448
+ }
449
+ var openHash=function(){
450
+ var d=document.getElementById(decodeURIComponent(location.hash.slice(1)));
451
+ if(!d||d.tagName!=="DETAILS")return;
452
+ var f=document.getElementById("flip"),t=document.getElementById("t-context");
453
+ if(f)f.checked=true;if(t)t.checked=true;d.open=true;sync();
454
+ setTimeout(function(){d.scrollIntoView({block:"nearest"})},620);
455
+ };
232
456
  addEventListener("hashchange",openHash);openHash();
233
457
  var pre=[];
234
458
  addEventListener("beforeprint",function(){pre=secs.map(function(d){return d.open});secs.forEach(function(d){d.open=true})});
@@ -270,7 +494,10 @@ export function renderCardText(root, opts = {}) {
270
494
  const meta = serverCardMeta();
271
495
  const name = id?.displayName ?? id?.name ?? basename(resolve(root));
272
496
  const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
273
- const out = [`### ${name} — context card`];
497
+ const out = [`### ${name} — business card`];
498
+ const title = cardTitle(id);
499
+ if (title)
500
+ out.push(`**${title}**`);
274
501
  const pills = [
275
502
  id?.vendor && id.vendor !== id.status ? id.vendor : null,
276
503
  id?.agentVersion ? `v${id.agentVersion}` : null,
@@ -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
+ }