mcp-context-card 1.5.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.
@@ -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,18 +276,19 @@ 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
291
+ const memLabel = `${mem.facts.length} fact${mem.facts.length === 1 ? "" : "s"}${opts.memory === "private" && mem.facts.length ? ", kept private" : ""}`;
157
292
  const memoryBody = opts.memory === "private" && mem.facts.length
158
293
  ? `<p class="none">Kept private on this page. To show the facts, set <code>${PUBLISH_MEMORY_ENV}=1</code>.</p>`
159
294
  : mem.facts.length
@@ -167,6 +302,27 @@ export function renderCard(root, opts = {}) {
167
302
  })
168
303
  .join("")
169
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("");
170
326
  // DISCOVERY
171
327
  const rows = Object.entries(meta)
172
328
  .map(([k, v]) => {
@@ -174,63 +330,129 @@ export function renderCard(root, opts = {}) {
174
330
  return `<tr><td>${concern}</td><td><code>${escapeHtml(v.source)}</code></td><td><code>${escapeHtml(v.mediaType)}</code></td></tr>`;
175
331
  })
176
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>`;
177
388
  return `<!doctype html>
178
- <html lang="en"${htmlAttr(theme)}>
389
+ <html lang="en">
179
390
  <head>
180
391
  <meta charset="utf-8">
181
392
  <meta name="viewport" content="width=device-width,initial-scale=1">
182
- <title>${escapeHtml(name)} — context card</title>
183
- <style>${CSS(accent)}</style>
393
+ <title>${escapeHtml(name)} — business card</title>
394
+ <style>${CSS(accent)}${tabCss}</style>
184
395
  </head>
185
- <body>
186
- <main class="card">
187
- <div class="top">
188
- <h1>${escapeHtml(name)}</h1>
189
- ${pills ? `<div class="pills">${pills}</div>` : ""}
190
- </div>
191
- <section>
192
- <p class="label">Context — AGENTS.md</p>
193
- ${contextBody}
194
- </section>
195
- <section>
196
- <p class="label">Memory — ${mem.facts.length} fact${mem.facts.length === 1 ? "" : "s"}${opts.memory === "private" && mem.facts.length ? ", kept private" : ""}</p>
197
- ${memoryBody}
198
- </section>
199
- <section>
200
- <p class="label">Discovery</p>${id?.id ? `\n <p class="fetch">Agent ID <code>${escapeHtml(id.id)}</code></p>` : ""}
201
- <table class="disc"><thead><tr><th>concern</th><th>source</th><th>media type</th></tr></thead><tbody>${rows}</tbody></table>
202
- <p class="fetch">A machine reads this over <b>MCP</b> from the
203
- <code>${escapeHtml(SERVER_CARD_URI)}</code> resource; over <b>HTTP</b> also
204
- from <code>GET /mcp/server-card</code> and
205
- <code>GET /.well-known/ai-catalog.json</code>.</p>
206
- </section>
207
- <div class="foot">${escapeHtml(name)} · context card</div>
208
- </main>
209
- ${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}
210
427
  </body>
211
428
  </html>
212
429
  `;
213
430
  }
214
431
  /**
215
- * Expand-all / Collapse-all. The only script in the card — a progressive
216
- * enhancement: with it off, every section still opens and closes on its own
217
- * (native `<details>`), just without the bulk button. It also opens every
218
- * section for printing, since browsers don't agree on whether a closed
219
- * `<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>`).
220
438
  */
221
- const TOGGLE_SCRIPT = `<script>
439
+ const CARD_SCRIPT = `<script>
222
440
  (function(){
223
- var btn=document.querySelector(".xall");
224
441
  var secs=[].slice.call(document.querySelectorAll("details.ctx-section"));
225
- if(!btn||!secs.length)return;
226
- var sync=function(){btn.textContent=secs.every(function(d){return d.open})?"Collapse all":"Expand all"};
227
- btn.hidden=false;
228
- btn.addEventListener("click",function(){
229
- var open=!secs.every(function(d){return d.open});
230
- secs.forEach(function(d){d.open=open});sync();
231
- });
232
- secs.forEach(function(d){d.addEventListener("toggle",sync)});
233
- 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
+ };
234
456
  addEventListener("hashchange",openHash);openHash();
235
457
  var pre=[];
236
458
  addEventListener("beforeprint",function(){pre=secs.map(function(d){return d.open});secs.forEach(function(d){d.open=true})});
@@ -272,7 +494,10 @@ export function renderCardText(root, opts = {}) {
272
494
  const meta = serverCardMeta();
273
495
  const name = id?.displayName ?? id?.name ?? basename(resolve(root));
274
496
  const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
275
- const out = [`### ${name} — context card`];
497
+ const out = [`### ${name} — business card`];
498
+ const title = cardTitle(id);
499
+ if (title)
500
+ out.push(`**${title}**`);
276
501
  const pills = [
277
502
  id?.vendor && id.vendor !== id.status ? id.vendor : null,
278
503
  id?.agentVersion ? `v${id.agentVersion}` : null,
package/dist/server.js CHANGED
@@ -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/`.
@@ -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
  {
@@ -11,7 +11,7 @@
11
11
  * transports in a map; this one deliberately does not.
12
12
  *
13
13
  * Alongside the MCP endpoint it serves the discovery documents (MCP Server
14
- * Cards, SEP-2127 Final; AI Catalog 1.0):
14
+ * Cards, SEP-2127 Final; AI Catalog specVersion 1.0):
15
15
  * GET /mcp/server-card — the Server Card (the spec's reserved
16
16
  * `<streamable-http-url>/server-card`)
17
17
  * GET /.well-known/mcp/server-card — the same card, the 1.x location (alias)
@@ -125,6 +125,7 @@ export function httpApp(root = ROOT, opts = {}) {
125
125
  return c.body(renderCard(root, {
126
126
  theme,
127
127
  accent: safeAccent(q.accent),
128
+ layout: q.layout === "portrait" ? "portrait" : "landscape",
128
129
  expanded: q.expand === "all",
129
130
  // A card that may be read beyond this machine keeps memory private
130
131
  // unless the project publishes it.
@@ -32,7 +32,7 @@ Streamable HTTP endpoint at `<origin>/mcp` and its protocol versions):
32
32
  {
33
33
  "$schema": "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json",
34
34
  "name": "io.github.Wolfe-Jam/mcp-context-card",
35
- "version": "1.5.0",
35
+ "version": "1.6.0",
36
36
  "title": "MCP Context Card",
37
37
  "description": "MCP server for a project's context (AGENTS.md), memory, and identity — base or drop-in extension.",
38
38
  "websiteUrl": "https://github.com/Wolfe-Jam/mcp-context-card",
@@ -85,39 +85,52 @@ publisher lists artifacts, each entry keyed by its **media type** (`type`).
85
85
  GET /.well-known/ai-catalog.json
86
86
  ```
87
87
 
88
+ Served from `https://ctx.example.com` (1.6.0, this repo's own files):
89
+
88
90
  ```jsonc
89
91
  {
90
92
  "specVersion": "1.0",
91
93
  "host": { "displayName": "mcp-context-card", "identifier": "https://github.com/Wolfe-Jam/mcp-context-card" },
92
94
  "entries": [
93
- {
94
- "identifier": "urn:air:mcp-context-card:context",
95
- "type": "text/markdown",
96
- "mediaType": "text/markdown",
97
- "description": "…derived from the real AGENTS.md — section count + headings…",
98
- "url": "./AGENTS.md"
99
- },
100
- { "identifier": "urn:air:mcp-context-card:memory", "type": "application/vnd.fafm+yaml", "…": "…" },
101
- { "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
+ "…": "…" }
102
105
  ]
103
106
  }
104
107
  ```
105
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
+
106
113
  **Why it looks like this:**
107
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.
108
117
  - **`type` is the routing key.** A consumer scanning catalogs for
109
118
  `text/markdown` context, or `application/vnd.fafm+yaml` memory, finds the
110
119
  entry without knowing this publisher.
111
120
  - **`identifier` is a `urn:air:` URN** scoped to the publisher
112
- (`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
113
125
  [trust‑manifest ADRs](https://github.com/Agent-Card/ai-catalog/tree/main/adr),
114
- `urn:air` identifiers carry a publisher‑domain‑aligned trust manifest — the
115
- 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.
116
128
  - **`description` is derived from real content** — the live AGENTS.md heading
117
129
  list, the current fact count, the agent's own description — not a blurb that
118
130
  drifts. See `buildCatalog()` in [`src/catalog-gen.ts`](../src/catalog-gen.ts).
119
- - **`url` is relative.** Served over HTTP it resolves against the origin; in a
120
- 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.
121
134
 
122
135
  ---
123
136