@trillet-ai/mcp 0.7.3 → 0.8.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/dist/index.js CHANGED
@@ -25,6 +25,7 @@ import { registerFlowSettingTools } from "./tools/flowSettings.js";
25
25
  import { registerSecretTools } from "./tools/secrets.js";
26
26
  import { registerAccountTools } from "./tools/account.js";
27
27
  import { registerTextCallTools } from "./tools/textCalls.js";
28
+ import { registerBrandingTools } from "./tools/branding.js";
28
29
  import { runTextCallWorker } from "./textcall/worker.js";
29
30
  import { VERSION } from "./version.js";
30
31
  async function main() {
@@ -44,6 +45,7 @@ async function main() {
44
45
  registerSecretTools(server, client);
45
46
  registerCallTools(server, client);
46
47
  registerTextCallTools(server, client);
48
+ registerBrandingTools(server, client);
47
49
  registerAccountTools(server, client);
48
50
  const transport = new StdioServerTransport();
49
51
  await server.connect(transport);
package/dist/registry.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Transport-agnostic tool registry.
3
3
  *
4
- * The same 76 tools power three surfaces: the stdio MCP server (Claude Code
4
+ * The same 83 tools power three surfaces: the stdio MCP server (Claude Code
5
5
  * plugin + Desktop .mcpb), a future remote MCP, and the in-dashboard AI
6
6
  * assistant (trillet-backend embeds the bundled build of this module and runs
7
7
  * the tools inside its own Anthropic loop).
@@ -10,7 +10,7 @@
10
10
  * register*Tools() functions, so the tool files stay exactly as written for
11
11
  * the MCP SDK — no churn, no drift between surfaces. Handlers close over the
12
12
  * TrilletClient passed to buildRegistry(), so embedders build one registry
13
- * per authenticated session (cheap: 76 object literals).
13
+ * per authenticated session (cheap: 83 object literals).
14
14
  */
15
15
  import { z } from "zod";
16
16
  import { zodToJsonSchema } from "zod-to-json-schema";
@@ -27,6 +27,7 @@ import { registerFlowSettingTools } from "./tools/flowSettings.js";
27
27
  import { registerSecretTools } from "./tools/secrets.js";
28
28
  import { registerAccountTools } from "./tools/account.js";
29
29
  import { registerTextCallTools } from "./tools/textCalls.js";
30
+ import { registerBrandingTools } from "./tools/branding.js";
30
31
  const REGISTER_FNS = [
31
32
  registerAgentTools,
32
33
  registerCallFlowTools,
@@ -38,6 +39,7 @@ const REGISTER_FNS = [
38
39
  registerSecretTools,
39
40
  registerCallTools,
40
41
  registerTextCallTools,
42
+ registerBrandingTools,
41
43
  registerAccountTools,
42
44
  ];
43
45
  /** Collect every tool, with handlers bound to the given client. */
@@ -0,0 +1,416 @@
1
+ import { z } from "zod";
2
+ import { readFile } from "node:fs/promises";
3
+ import { basename } from "node:path";
4
+ import { ok, guard, workspaceParam } from "../helpers.js";
5
+ /**
6
+ * Website widget + custom demo page tools.
7
+ *
8
+ * Both designs live OUTSIDE draft-synced settings and apply to production the
9
+ * moment they are saved (the backend ignores x-trillet-write-target on these
10
+ * routes, exactly like the dashboard designers). Both save routes REPLACE the
11
+ * whole design and fill missing keys with defaults, so every setter here reads
12
+ * the stored design first and sends the merged result.
13
+ */
14
+ const WIDGET_SCRIPT_URL = "https://unpkg.com/@trillet-ai/web-sdk/dist/widget.umd.js";
15
+ const APP_ORIGIN = "https://app.trillet.ai";
16
+ const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
17
+ const hex = z
18
+ .string()
19
+ .regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, "hex colour like #1060ff");
20
+ const httpsUrl = z.string().url().refine((u) => u.startsWith("https://"), "must be an https URL");
21
+ function escapeAttr(value) {
22
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
23
+ }
24
+ /** Same two lines the dashboard's "Get embed code" produces. */
25
+ function widgetSnippet(agentId, workspaceId) {
26
+ return [
27
+ `<script src="${WIDGET_SCRIPT_URL}" async type="text/javascript"></script>`,
28
+ `<trillet-widget agent-id="${escapeAttr(agentId)}" workspace-id="${escapeAttr(workspaceId)}"></trillet-widget>`,
29
+ ].join("\n");
30
+ }
31
+ function demoUrl(hash) {
32
+ return `${APP_ORIGIN}/call-flows/demo/${hash}`;
33
+ }
34
+ function iframeSnippet(hash, title) {
35
+ return `<iframe src="${demoUrl(hash)}?embed=1" title="${escapeAttr(title)}" allow="microphone; autoplay" style="width:100%;max-width:640px;height:720px;border:0"></iframe>`;
36
+ }
37
+ const DEMO_URL_NOTE = "URLs use app.trillet.ai. A whitelabel (agency) workspace shares the same path on its custom domain: https://<customDomain>/call-flows/demo/<hash>.";
38
+ /** Sniff PNG/JPEG/WebP from the bytes (the backend does the same and rejects anything else). */
39
+ function sniffImage(buf) {
40
+ if (buf.length < 12)
41
+ return null;
42
+ if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)
43
+ return { mime: "image/png", ext: "png" };
44
+ if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
45
+ return { mime: "image/jpeg", ext: "jpg" };
46
+ const ascii = (a, b) => String.fromCharCode(...buf.slice(a, b));
47
+ if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP")
48
+ return { mime: "image/webp", ext: "webp" };
49
+ return null;
50
+ }
51
+ async function loadImage(file_path, image_url) {
52
+ if (file_path) {
53
+ return { data: new Uint8Array(await readFile(file_path)), name: basename(file_path) };
54
+ }
55
+ if (!image_url || !image_url.startsWith("https://")) {
56
+ throw new Error("Pass file_path (a local PNG/JPG/WebP) or an https image_url.");
57
+ }
58
+ const res = await fetch(image_url);
59
+ if (!res.ok)
60
+ throw new Error(`Could not download ${image_url}: HTTP ${res.status}`);
61
+ const data = new Uint8Array(await res.arrayBuffer());
62
+ return { data, name: basename(new URL(image_url).pathname) || "logo" };
63
+ }
64
+ /** Plain-object deep merge: patch wins, arrays replace, undefined is skipped. */
65
+ function deepMerge(base, patch) {
66
+ if (patch === undefined)
67
+ return base;
68
+ if (patch === null || typeof patch !== "object" || Array.isArray(patch))
69
+ return patch;
70
+ const out = { ...(base && typeof base === "object" && !Array.isArray(base) ? base : {}) };
71
+ for (const [k, v] of Object.entries(patch)) {
72
+ if (v === undefined)
73
+ continue;
74
+ out[k] = deepMerge(out[k], v);
75
+ }
76
+ return out;
77
+ }
78
+ function summarizeLink(l) {
79
+ const a = l.appearance;
80
+ return {
81
+ link_id: l._id,
82
+ name: l.name,
83
+ enabled: l.enabled,
84
+ url: l.hash ? demoUrl(l.hash) : null,
85
+ expiresAt: l.expiresAt ?? null,
86
+ perVisitorLimit: l.perIpLimit,
87
+ totalLimit: l.totalLimit,
88
+ showCountdownTimer: l.showCountdownTimer,
89
+ dynamicVariables: l.dynamicVariables,
90
+ pinnedDraft: l.draftId ? { draftId: l.draftId, draftName: l.draftName } : null,
91
+ page: a?.template === "custom" ? "custom" : "classic",
92
+ appearance: a ?? null,
93
+ };
94
+ }
95
+ const widgetDesignShape = {
96
+ mode: z
97
+ .enum(["voice", "text", "both"])
98
+ .optional()
99
+ .describe('"voice" = call button (default), "text" = chat, "both" = type-or-talk (one session the visitor can type or speak in)'),
100
+ placement: z.enum(["bottom-right", "bottom-left", "top-right", "top-left"]).optional(),
101
+ variant: z.enum(["compact", "expanded"]).optional(),
102
+ theme: z.enum(["light", "dark"]).optional(),
103
+ avatarStyle: z.enum(["orb", "image", "initials"]).optional().describe('"image" uses `avatar`'),
104
+ brandColor: hex.nullable().optional().describe("Main button colour"),
105
+ orbColor1: hex.nullable().optional().describe("Orb gradient start (avatarStyle orb)"),
106
+ orbColor2: hex.nullable().optional().describe("Orb gradient end"),
107
+ backgroundColor: hex.nullable().optional().describe("Card/panel background; null follows the theme"),
108
+ textColor: hex.nullable().optional(),
109
+ buttonTextColor: hex.nullable().optional(),
110
+ brandName: z.string().max(60).nullable().optional(),
111
+ brandLogo: httpsUrl.nullable().optional().describe("https image URL (rendered on the customer's site, so it must be public)"),
112
+ avatar: httpsUrl.nullable().optional().describe("https image URL for avatarStyle image"),
113
+ title: z.string().max(60).nullable().optional().describe("Header title (defaults to the agent name)"),
114
+ launcherText: z.string().max(40).nullable().optional().describe("Text on the collapsed launcher"),
115
+ greeting: z.string().max(280).nullable().optional().describe("Line shown above the input before a conversation"),
116
+ chatWelcome: z
117
+ .string()
118
+ .max(400)
119
+ .nullable()
120
+ .optional()
121
+ .describe("First agent message in chats (text/both). Replaces the flow's phone greeting for chats only"),
122
+ inputPlaceholder: z.string().max(80).nullable().optional(),
123
+ startCallText: z.string().max(30).nullable().optional(),
124
+ messageText: z.string().max(30).nullable().optional(),
125
+ suggestedPrompts: z.array(z.string().max(80)).max(4).optional().describe("Up to 4 quick-start chips"),
126
+ termsText: z
127
+ .string()
128
+ .max(2000)
129
+ .nullable()
130
+ .optional()
131
+ .describe("Markdown the visitor must accept before their first conversation"),
132
+ feedback: z.enum(["none", "end"]).optional().describe('"end" asks for thumbs up/down after a conversation'),
133
+ };
134
+ const demoAppearanceShape = {
135
+ template: z
136
+ .enum(["classic", "custom"])
137
+ .optional()
138
+ .describe('"custom" turns on the branded page; "classic" (default) is the original page and ignores every design field'),
139
+ brand: z
140
+ .object({
141
+ companyName: z.string().max(80).optional(),
142
+ logoPosition: z.enum(["left", "right"]).optional(),
143
+ logoSize: z.enum(["sm", "md", "lg"]).optional(),
144
+ colors: z
145
+ .object({
146
+ primary: hex.optional(),
147
+ background: hex.optional(),
148
+ surface: hex.optional(),
149
+ text: hex.optional(),
150
+ buttonText: hex.optional(),
151
+ })
152
+ .optional(),
153
+ font: z
154
+ .enum([
155
+ "Inter",
156
+ "Inter Tight",
157
+ "DM Sans",
158
+ "Manrope",
159
+ "Plus Jakarta Sans",
160
+ "Work Sans",
161
+ "Roboto",
162
+ "Open Sans",
163
+ "Lato",
164
+ "Poppins",
165
+ "Montserrat",
166
+ "Source Sans 3",
167
+ "IBM Plex Sans",
168
+ "Nunito Sans",
169
+ "Merriweather",
170
+ "Playfair Display",
171
+ ])
172
+ .optional(),
173
+ radius: z.enum(["sharp", "soft", "round"]).optional(),
174
+ edgeStripe: z.boolean().optional().describe("Thin primary-colour stripe along the page edge"),
175
+ })
176
+ .optional()
177
+ .describe("Logo and tab icon are NOT set here: use trillet_upload_demo_page_logo"),
178
+ content: z
179
+ .object({
180
+ headline: z.string().max(120).optional(),
181
+ subheadline: z.string().max(280).optional(),
182
+ agentName: z.string().max(60).optional(),
183
+ startLabel: z.string().max(40).optional(),
184
+ endedTitle: z.string().max(120).optional(),
185
+ endedMessage: z.string().max(280).optional(),
186
+ cta: z
187
+ .object({ label: z.string().max(40), url: z.string() })
188
+ .optional()
189
+ .describe("Button after the call; url must be https. Empty label removes it"),
190
+ })
191
+ .optional(),
192
+ features: z
193
+ .object({
194
+ liveTranscript: z.boolean().optional(),
195
+ callTimer: z.boolean().optional(),
196
+ postCallSummary: z.boolean().optional(),
197
+ postCallFields: z
198
+ .array(z.string())
199
+ .optional()
200
+ .describe("Post-call analysis field NAMES to show after the call; unknown names are dropped"),
201
+ callAgain: z.boolean().optional(),
202
+ muteButton: z.boolean().optional(),
203
+ poweredBy: z.boolean().optional(),
204
+ })
205
+ .optional(),
206
+ embed: z
207
+ .object({
208
+ allowedOrigins: z
209
+ .array(z.string())
210
+ .max(10)
211
+ .optional()
212
+ .describe("Sites that may iframe the page AND receive post-call results, as bare https origins (https://www.example.com)"),
213
+ allowLocalhost: z.boolean().optional().describe("Also allow http://localhost while the client builds"),
214
+ })
215
+ .optional(),
216
+ };
217
+ export function registerBrandingTools(server, client) {
218
+ // ── Website widget ──────────────────────────────────────────────────────
219
+ server.registerTool("trillet_get_website_widget", {
220
+ title: "Get website widget design",
221
+ description: "Read a call flow's website widget design (the <trillet-widget> embed: voice, chat or type-or-talk) plus the embed snippet to paste on a site. `saved: false` means defaults are in use. Visitors can only start conversations when publicAccess is on.",
222
+ inputSchema: { flow_id: z.string(), ...workspaceParam },
223
+ annotations: { readOnlyHint: true, openWorldHint: true },
224
+ }, guard(async ({ flow_id, workspace_id }) => {
225
+ const res = await client.request("GET", `/v1/api/call-flows/${flow_id}/widget`, {
226
+ workspaceId: workspace_id,
227
+ });
228
+ const ws = workspace_id || client.workspaceId;
229
+ return ok({
230
+ ...res,
231
+ embed_snippet: res.agentId ? widgetSnippet(res.agentId, ws) : null,
232
+ });
233
+ }));
234
+ server.registerTool("trillet_set_website_widget", {
235
+ title: "Design the website widget",
236
+ description: "Create or update a call flow's website widget design: voice/chat/type-or-talk mode, colours, logo, copy, suggested prompts, terms, feedback. Only the fields you pass change (the stored design is read and merged; null clears a field). Applies to live embeds immediately (not staged in a draft; existing embeds pick it up within about a minute). Attributes written on a site's <trillet-widget> tag override the saved design. Returns the embed snippet. Set public_access true unless the flow already allows public calls, or visitors get 403.",
237
+ inputSchema: {
238
+ flow_id: z.string(),
239
+ ...widgetDesignShape,
240
+ reset: z.boolean().default(false).describe("Start from defaults instead of the stored design"),
241
+ public_access: z
242
+ .boolean()
243
+ .optional()
244
+ .describe("Also set settings.security.publicAccess (required for website visitors)"),
245
+ ...workspaceParam,
246
+ },
247
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
248
+ }, guard(async ({ flow_id, reset, public_access, workspace_id, ...design }) => {
249
+ const current = await client.request("GET", `/v1/api/call-flows/${flow_id}/widget`, {
250
+ workspaceId: workspace_id,
251
+ });
252
+ const widget = deepMerge(reset ? {} : current.widget ?? {}, design);
253
+ const saved = await client.request("PUT", `/v1/api/call-flows/${flow_id}/widget`, {
254
+ body: { widget },
255
+ workspaceId: workspace_id,
256
+ });
257
+ let publicAccess = Boolean(current.publicAccess);
258
+ if (public_access !== undefined) {
259
+ await client.request("PUT", `/v1/api/call-flows/${flow_id}`, {
260
+ body: { settings: { security: { publicAccess: public_access } } },
261
+ workspaceId: workspace_id,
262
+ });
263
+ publicAccess = public_access;
264
+ }
265
+ const ws = workspace_id || client.workspaceId;
266
+ return ok({
267
+ widget: saved.widget,
268
+ publicAccess,
269
+ ...(publicAccess
270
+ ? {}
271
+ : { warning: "publicAccess is off: website visitors cannot start a conversation. Pass public_access: true." }),
272
+ embed_snippet: current.agentId ? widgetSnippet(current.agentId, ws) : null,
273
+ });
274
+ }));
275
+ // ── Demo links + custom demo page ───────────────────────────────────────
276
+ server.registerTool("trillet_list_demo_links", {
277
+ title: "List demo links",
278
+ description: `List a call flow's shareable demo links (max 5 per flow) with their URL, limits, and custom page design. ${DEMO_URL_NOTE} GOTCHA: while the flow has an active draft, this read shows the draft's copy of the links, which can be stale; has_active_draft flags it.`,
279
+ inputSchema: { flow_id: z.string(), ...workspaceParam },
280
+ annotations: { readOnlyHint: true, openWorldHint: true },
281
+ }, guard(async ({ flow_id, workspace_id }) => {
282
+ const res = await client.request("GET", `/v1/api/call-flows/${flow_id}`, { workspaceId: workspace_id });
283
+ const p = res.pathway ?? res;
284
+ const drafts = await client
285
+ .request("GET", `/v1/api/call-flows/${flow_id}/drafts`, { workspaceId: workspace_id })
286
+ .catch(() => []);
287
+ const draftList = Array.isArray(drafts) ? drafts : drafts.drafts ?? [];
288
+ return ok({
289
+ publicAccess: Boolean(p.settings?.security?.publicAccess),
290
+ has_active_draft: draftList.some((d) => d.isActive),
291
+ postCallFieldNames: (p.settings?.postCallAnalysis ?? []).map((f) => f?.name).filter(Boolean),
292
+ links: (p.settings?.publicDemoLinks ?? []).map(summarizeLink),
293
+ });
294
+ }));
295
+ server.registerTool("trillet_create_demo_link", {
296
+ title: "Create demo link",
297
+ description: `Create a shareable demo link for a call flow (anyone with the URL can talk to the agent in the browser). Saved to production immediately and turns publicAccess on. Max 5 links per flow. Optional usage caps protect the wallet: per_visitor_minutes and total_minutes. Style the page afterwards with trillet_set_demo_page_design and trillet_upload_demo_page_logo. ${DEMO_URL_NOTE}`,
298
+ inputSchema: {
299
+ flow_id: z.string(),
300
+ name: z.string().default("New Demo Link").describe("Internal label, shown in the dashboard"),
301
+ expires_in_days: z.number().int().positive().optional(),
302
+ per_visitor_minutes: z.number().int().positive().optional().describe("Cap per visitor (by IP); omit for no cap"),
303
+ total_minutes: z.number().int().positive().optional().describe("Cap across all visitors; omit for no cap"),
304
+ show_countdown_timer: z.boolean().default(false).describe("Show the remaining-minutes countdown on the page"),
305
+ dynamic_variables: z
306
+ .array(z.object({
307
+ key: z.string(),
308
+ mode: z.enum(["preset", "prompt"]).describe("preset = fixed value; prompt = the visitor fills it in before calling"),
309
+ value: z.string().optional().describe("preset only"),
310
+ label: z.string().optional().describe("prompt only"),
311
+ placeholder: z.string().optional(),
312
+ required: z.boolean().optional(),
313
+ }))
314
+ .optional()
315
+ .describe("{{variables}} the flow uses, filled per link"),
316
+ draft_id: z.string().optional().describe("Pin the link to a draft so it tests unpublished changes"),
317
+ ...workspaceParam,
318
+ },
319
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
320
+ }, guard(async (a) => {
321
+ const res = await client.request("POST", `/v1/api/call-flows/${a.flow_id}/demo/generate-hash`, {
322
+ body: {
323
+ name: a.name,
324
+ expiresInDays: a.expires_in_days,
325
+ perIpLimit: { enabled: a.per_visitor_minutes !== undefined, minutes: a.per_visitor_minutes ?? 5 },
326
+ totalLimit: { enabled: a.total_minutes !== undefined, minutes: a.total_minutes ?? 100 },
327
+ showCountdownTimer: a.show_countdown_timer,
328
+ dynamicVariables: a.dynamic_variables ?? [],
329
+ draftId: a.draft_id,
330
+ },
331
+ workspaceId: a.workspace_id,
332
+ });
333
+ const link = res.demoLink ?? res;
334
+ return ok({ ...summarizeLink(link), iframe_snippet: iframeSnippet(link.hash, link.name) });
335
+ }));
336
+ server.registerTool("trillet_set_demo_page_design", {
337
+ title: "Design a demo link's page",
338
+ description: "Brand one demo link's page: template custom, colours, font, corner radius, copy, CTA button, which features show (live transcript, timer, post-call summary and chosen post-call answers, call again, mute, Powered by), and which sites may iframe it and receive results. Only the fields you pass change (the stored design is read and merged). Live immediately. Set template \"custom\" or nothing shows. Upload the logo or tab icon with trillet_upload_demo_page_logo. Returns the page URL and an iframe snippet (?embed=1 = transparent background + trillet-demo:* postMessage events).",
339
+ inputSchema: {
340
+ flow_id: z.string(),
341
+ link_id: z.string().describe("link_id from trillet_list_demo_links / trillet_create_demo_link"),
342
+ ...demoAppearanceShape,
343
+ clear_logo: z.boolean().optional(),
344
+ clear_favicon: z.boolean().optional(),
345
+ reset: z.boolean().default(false).describe("Start from defaults instead of the stored design (keeps the uploaded logo unless clear_logo)"),
346
+ ...workspaceParam,
347
+ },
348
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
349
+ }, guard(async ({ flow_id, link_id, clear_logo, clear_favicon, reset, workspace_id, ...patch }) => {
350
+ const res = await client.request("GET", `/v1/api/call-flows/${flow_id}`, { workspaceId: workspace_id });
351
+ const p = res.pathway ?? res;
352
+ const link = (p.settings?.publicDemoLinks ?? []).find((l) => String(l?._id) === link_id);
353
+ if (!link && !reset) {
354
+ throw new Error("Demo link not found on this flow's current view. If the flow has an active draft, its stale copy hides newer links: run trillet_deactivate_drafts, or retry with reset: true to send a full design without reading the stored one.");
355
+ }
356
+ // Prefer production's copy of the design (the flow GET overlays an active
357
+ // draft's settings). The public page route reads production directly.
358
+ let stored = link?.appearance ?? {};
359
+ if (link?.hash && link.enabled) {
360
+ const pub = await client
361
+ .request("GET", `/v1/api/public/demo/${link.hash}/info`, { workspaceId: workspace_id })
362
+ .catch(() => null);
363
+ if (pub?.appearance) {
364
+ stored = {
365
+ ...pub.appearance,
366
+ features: {
367
+ ...pub.appearance.features,
368
+ postCallFields: (pub.appearance.features?.postCallFields ?? []).map((f) => f?.name ?? f),
369
+ },
370
+ };
371
+ }
372
+ }
373
+ const appearance = deepMerge(reset ? {} : stored, patch);
374
+ // The backend keeps the uploaded images unless told to clear them.
375
+ appearance.brand = { ...(appearance.brand ?? {}) };
376
+ delete appearance.brand.logo;
377
+ delete appearance.brand.favicon;
378
+ if (clear_logo)
379
+ appearance.brand.logo = null;
380
+ if (clear_favicon)
381
+ appearance.brand.favicon = null;
382
+ const saved = await client.request("PUT", `/v1/api/call-flows/${flow_id}/demo-links/${link_id}/appearance`, { body: { appearance }, workspaceId: workspace_id });
383
+ const hash = link?.hash;
384
+ return ok({
385
+ appearance: saved.appearance,
386
+ ...(hash ? { url: demoUrl(hash), iframe_snippet: iframeSnippet(hash, link.name ?? "Demo") } : {}),
387
+ ...(saved.appearance?.template !== "custom"
388
+ ? { note: 'template is "classic": the design is stored but the original page shows until template is "custom".' }
389
+ : {}),
390
+ });
391
+ }));
392
+ server.registerTool("trillet_upload_demo_page_logo", {
393
+ title: "Upload demo page logo or tab icon",
394
+ description: "Upload the logo (or, with kind favicon, the browser-tab icon) for one demo link's custom page. PNG, JPG or WebP under 2MB; SVG is rejected. Pass a local file_path or an https image_url (downloaded here, then uploaded). Live immediately. The returned logo url is a public https image, so it can also be reused as the website widget's brandLogo.",
395
+ inputSchema: {
396
+ flow_id: z.string(),
397
+ link_id: z.string(),
398
+ file_path: z.string().optional().describe("Absolute path to a local PNG/JPG/WebP"),
399
+ image_url: z.string().optional().describe("https URL of the image, e.g. from the client's website"),
400
+ kind: z.enum(["logo", "favicon"]).default("logo"),
401
+ ...workspaceParam,
402
+ },
403
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
404
+ }, guard(async ({ flow_id, link_id, file_path, image_url, kind, workspace_id }) => {
405
+ const { data, name } = await loadImage(file_path, image_url);
406
+ if (data.length > MAX_IMAGE_BYTES)
407
+ throw new Error("Image must be under 2MB.");
408
+ const type = sniffImage(data);
409
+ if (!type)
410
+ throw new Error("Image must be a PNG, JPG or WebP (SVG and other formats are rejected).");
411
+ const filename = name.includes(".") ? name : `${name}.${type.ext}`;
412
+ const query = kind === "favicon" ? "?kind=favicon" : "";
413
+ const res = await client.upload(`/v1/api/call-flows/${flow_id}/demo-links/${link_id}/logo${query}`, "file", filename, data, type.mime, workspace_id);
414
+ return ok({ [kind]: res[kind], template: res.appearance?.template });
415
+ }));
416
+ }
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@
4
4
  * with .claude-plugin/plugin.json, mcpb/manifest.json, and package.json when
5
5
  * releasing (see README "Maintainers").
6
6
  */
7
- export const VERSION = "0.7.3";
7
+ export const VERSION = "0.8.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trillet-ai/mcp",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "Official Trillet AI MCP server: build and manage voice agents, call flows, in-call API tools, knowledge bases, workflows, and call history from Claude.",
5
5
  "license": "MIT",
6
6
  "type": "module",