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/render-card.js
CHANGED
|
@@ -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
|
|
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
|
-
:
|
|
33
|
-
--bg:#000
|
|
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
|
-
:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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(--
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
.
|
|
53
|
-
.
|
|
54
|
-
|
|
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
|
|
67
|
-
margin:0 -
|
|
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
|
-
|
|
119
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
.
|
|
132
|
-
.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
//
|
|
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 =
|
|
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>${
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
.map((
|
|
163
|
-
.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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"
|
|
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)} —
|
|
181
|
-
<style>${CSS(accent)}</style>
|
|
393
|
+
<title>${escapeHtml(name)} — business card</title>
|
|
394
|
+
<style>${CSS(accent)}${tabCss}</style>
|
|
182
395
|
</head>
|
|
183
|
-
<body>
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
<
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
</
|
|
207
|
-
|
|
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
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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
|
|
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
|
-
|
|
224
|
-
var sync=function(){btn.textContent=secs.every(function(d){return d.open})?"Collapse all":"Expand all"};
|
|
225
|
-
btn.
|
|
226
|
-
|
|
227
|
-
var open=!secs.every(function(d){return d.open});
|
|
228
|
-
secs.forEach(function(d){d.
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
|
|
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} —
|
|
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
|
+
}
|