-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.ts
More file actions
297 lines (284 loc) · 10.8 KB
/
Copy pathserver.ts
File metadata and controls
297 lines (284 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
import { McpServer } from "skybridge/server";
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { generateAvatarSvg, AVATAR_STYLES, DEFAULT_AVATAR_STYLE, GENDERS } from "./lib/avatars.js";
import { generateQrSvg } from "./lib/qr.js";
import { buildVcard } from "./lib/vcard.js";
import { buildIcs } from "./lib/ics.js";
import { renderBadgePng } from "./lib/badge-png.js";
const attendeeSchema = z.object({
name: z.string().describe("Attendee full name"),
role: z
.string()
.optional()
.describe("Short role label (e.g. 'Host', 'Judge', 'Hacker')"),
gender: z
.enum(GENDERS)
.optional()
.describe(
"Optional gender hint to make the avatar read more clearly: 'm' (no earrings, may have a beard), 'f' (no beard, may have earrings), or 'x'/omit for neutral. Only affects the lorelei & notionists styles. Infer from the name when the user clearly implies it, otherwise omit.",
),
// Contact details — encoded into the badge's vCard QR so scanning saves a
// full contact (not just a name). Extract any the user mentions; omit the rest.
email: z.string().optional().describe("Email address, e.g. 'lena@acme.io'"),
phone: z.string().optional().describe("Phone number, e.g. '+49 30 1234567'"),
company: z.string().optional().describe("Company / organization name"),
website: z.string().optional().describe("Personal or company website, e.g. 'acme.io'"),
linkedin: z.string().optional().describe("LinkedIn profile, e.g. 'linkedin.com/in/lenabauer'"),
address: z.string().optional().describe("Postal/contact address as freeform text"),
}).strict();
const server = new McpServer(
{
name: "lineup",
version: "0.1.0",
},
{ capabilities: {} },
).registerTool(
{
name: "generate-lineup",
description:
"Generate an event pack: a Luma-style event card with .ics + RSVP QR, and an identity badge per attendee with a deterministic avatar and vCard-encoded QR. " +
"Use when the user describes an event and the people attending (meetup, conference, wedding, hackathon, dinner) and wants shareable cards. " +
"Do NOT use to render a single downloadable PNG of one badge — use render-badge-png for that. " +
"Output is deterministic: the same attendee name + style always yields the same avatar.",
inputSchema: {
title: z.string().describe("Event title"),
dateISO: z
.string()
.describe("Event start time as ISO 8601 string (UTC or with offset)"),
venue: z.string().optional().describe("Venue / location text"),
accentHex: z
.string()
.optional()
.describe("Hex color for accent (e.g. '#ef4444'). Defaults to indigo."),
rsvpUrl: z
.string()
.optional()
.describe("URL to encode in the event RSVP QR code"),
durationHours: z
.number()
.optional()
.describe("Event duration in hours (default 3)"),
avatarStyle: z
.enum(AVATAR_STYLES)
.optional()
.describe(
"Avatar style for the whole crew. 'notionists' = Notion-style sketch (default, friendly meetups), 'lorelei' = monochrome line art (calm/professional), 'bottts' = colorful robots (hackathons / playful trading-card vibe), 'shapes' = abstract geometric (anonymous voting / ballots).",
),
attendees: z
.array(attendeeSchema)
.min(1)
.describe("Attendees to generate identity badges for"),
},
outputSchema: {
event: z
.object({
title: z.string(),
dateISO: z.string(),
venue: z.string().nullable(),
accentHex: z.string(),
rsvpUrl: z.string().nullable(),
avatarStyle: z.string(),
icsString: z.string(),
qrSvg: z.string(),
})
.describe("Event card data (title, date, venue, accent, RSVP QR, .ics)"),
badges: z
.array(
z.object({
name: z.string(),
role: z.string(),
gender: z.string().nullable(),
email: z.string().nullable(),
phone: z.string().nullable(),
company: z.string().nullable(),
website: z.string().nullable(),
linkedin: z.string().nullable(),
address: z.string().nullable(),
avatarSvg: z.string(),
vcardQrSvg: z.string(),
}),
)
.describe("One identity badge per attendee"),
},
annotations: {
title: "Generate an event Lineup",
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
_meta: {
"openai/toolInvocation/invoking": "Building your Lineup…",
"openai/toolInvocation/invoked": "Lineup ready.",
},
view: {
component: "generate-lineup",
description: "Event card + identity badge grid",
csp: {
resourceDomains: [
"https://fonts.googleapis.com",
"https://fonts.gstatic.com",
],
redirectDomains: [],
},
},
},
async ({
title,
dateISO,
venue,
accentHex,
rsvpUrl,
durationHours,
avatarStyle,
attendees,
}) => {
const accent = accentHex ?? "#6366f1";
const style = avatarStyle ?? DEFAULT_AVATAR_STYLE;
const icsString = buildIcs({ title, dateISO, venue, durationHours });
const eventQrPayload = rsvpUrl ?? `EVENT:${title}`;
const eventQrSvg = await generateQrSvg(eventQrPayload, accent);
const badges = await Promise.all(
attendees.map(async ({ name, role, gender, email, phone, company, website, linkedin, address }) => {
const avatarSvg = generateAvatarSvg(name, style, gender);
const vcard = buildVcard({ name, role, eventTitle: title, email, phone, company, website, linkedin, address });
const vcardQrSvg = await generateQrSvg(vcard, "#111111");
return {
name,
role: role ?? "Guest",
gender: gender ?? null,
email: email ?? null,
phone: phone ?? null,
company: company ?? null,
website: website ?? null,
linkedin: linkedin ?? null,
address: address ?? null,
avatarSvg,
vcardQrSvg,
};
}),
);
const structuredContent = {
event: {
title,
dateISO,
venue: venue ?? null,
accentHex: accent,
rsvpUrl: rsvpUrl ?? null,
avatarStyle: style,
icsString,
qrSvg: eventQrSvg,
},
badges,
};
const summary = `Lineup ready: "${title}" with ${badges.length} ${
badges.length === 1 ? "badge" : "badges"
}${venue ? ` at ${venue}` : ""}.`;
return {
structuredContent,
content: [{ type: "text", text: summary }],
isError: false,
};
},
).registerTool(
{
name: "render-badge-png",
description:
"Render a single identity badge as a shareable PNG (avatar + name + role + vCard QR) composed with Satori. " +
"Use when the user clicks download on one badge, or asks for a single saveable/printable badge image. " +
"Do NOT use to build the whole event view — use generate-lineup for that. " +
"Match avatarStyle/gender/accentHex to the badge shown in the Lineup so the PNG looks identical.",
inputSchema: {
name: z.string().describe("Attendee full name"),
role: z.string().optional().describe("Role label"),
accentHex: z.string().optional().describe("Accent hex color"),
eventTitle: z.string().optional().describe("Event title (printed on the badge)"),
avatarStyle: z
.enum(AVATAR_STYLES)
.optional()
.describe("Avatar style (same options as generate-lineup). Match the event's style."),
seed: z
.string()
.optional()
.describe("Avatar seed override (defaults to name). Used when the user re-rolled this face."),
gender: z
.enum(GENDERS)
.optional()
.describe("Gender hint (same as generate-lineup). Match the badge."),
},
outputSchema: {
pngDataUrl: z
.string()
.describe("The rendered badge as a base64 PNG data URL"),
},
annotations: {
title: "Download badge PNG",
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
_meta: {
"openai/toolInvocation/invoking": "Rendering badge…",
"openai/toolInvocation/invoked": "Badge ready.",
},
},
async ({ name, role, accentHex, eventTitle, avatarStyle, seed, gender }) => {
const pngDataUrl = await renderBadgePng({ name, role, accentHex, eventTitle, avatarStyle, seed, gender });
return {
structuredContent: { pngDataUrl },
content: [{ type: "text", text: `Rendered badge PNG for ${name}.` }],
isError: false,
};
},
);
if (process.env.NODE_ENV === "production") {
const { default: manifest } = await import("./vite-manifest.js");
server.setViteManifest(manifest);
}
// ── Legacy template absorber ────────────────────────────────────────────────
// Skybridge advertises a content-hashed view URI (…html?v=<hash>) and the server
// only serves the *current* hash. After any view change + redeploy, ChatGPT (and
// other Apps SDK hosts) messages that cached an older hash fail to render with
// "Failed to fetch template" — the stale URI now 404s. Per the OpenAI Apps SDK
// best practice (stable advertised URI + a ResourceTemplate that absorbs legacy
// versioned URIs), we register non-listed catch-all templates that delegate to
// the current registered handler, so previously-rendered widgets re-resolve to
// the latest content instead of breaking. New tool calls keep using the exact
// current URI (which wins via exact-match), so this is purely additive.
{
const registry = (
server as unknown as {
_registeredResources: Record<
string,
{ readCallback: (uri: URL, extra: unknown) => unknown }
>;
}
)._registeredResources;
const currentHandlerFor = (base: string) =>
Object.entries(registry).find(([uri]) => uri.startsWith(base))?.[1]
?.readCallback;
const absorbLegacy = (name: string, base: string) => {
const handler = currentHandlerFor(base);
if (!handler) return; // dev / no production hash → nothing to absorb
// `{?v}` matches both the unversioned URI and any prior ?v=<hash>.
server.registerResource(
name,
new ResourceTemplate(`${base}{?v}`, { list: undefined }),
{},
((uri: URL, _vars: unknown, extra: unknown) =>
handler(uri, extra)) as never,
);
};
absorbLegacy(
"generate-lineup-apps-legacy",
"ui://views/apps-sdk/generate-lineup.html",
);
absorbLegacy(
"generate-lineup-ext-legacy",
"ui://views/ext-apps/generate-lineup.html",
);
}
export default await server.run();
export type AppType = typeof server;