@unboundai/tb-ppt-mgagent 0.5.0 → 0.7.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.
Files changed (3) hide show
  1. package/README.md +52 -6
  2. package/build/index.js +236 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,25 +2,69 @@
2
2
 
3
3
  A Model Context Protocol (MCP) server that hands a presentation outline to Anthropic's hosted **Managed Agent** for building Traffic Builders-branded PowerPoints, then retrieves the finished file once it's done.
4
4
 
5
- Brand assets (the design brief, logos, Outfit font) are **not** assumed to already exist in the agent's environment. A Managed Agents "deployment" (scheduled/cron runs) was investigated as a way to pin a session to a specific pre-configured setup, but it only re-sends a fixed baked-in message on a schedule — it offers no extra asset-targeting capability over a plain session. So instead, this server uploads each brand asset once via the Files API (a one-time step, see [Setup](#setup)) and attaches them explicitly to **every** session it creates via the `resources` array, mounted into the sandbox for the agent to read.
5
+ Brand assets (the design brief, logos, Outfit and Figtree fonts) are **not** assumed to already exist in the agent's environment. A Managed Agents "deployment" (scheduled/cron runs) was investigated as a way to pin a session to a specific pre-configured setup, but it only re-sends a fixed baked-in message on a schedule — it offers no extra asset-targeting capability over a plain session. So instead, this server uploads each brand asset once via the Files API (a one-time step, see [Setup](#setup)) and attaches them explicitly to **every** session it creates via the `resources` array, mounted into the sandbox for the agent to read.
6
6
 
7
7
  Built against the official Managed Agents docs (session creation, session operations, events & streaming, files, event/type reference) using the official `@anthropic-ai/sdk` client, which sets the `managed-agents-2026-04-01` beta header automatically for session endpoints.
8
8
 
9
9
  ## Available tools
10
10
 
11
- - **`generate_tb_powerpoint(outline, deckType?, clientName?, filename?)`** — creates a session against the configured agent + environment, attaches whichever brand asset `resources` are configured (see Setup), sends the outline as the first message, returns `{ success, sessionId, message }`. Does not wait for the deck to finish.
11
+ - **`generate_tb_powerpoint(outline, deckType?, clientName?, filename?, attachments?)`** — creates a session against the configured agent + environment, attaches whichever brand asset `resources` are configured (see Setup), uploads any chat attachments to the Files API and mounts them too, sends the outline as the first message, returns `{ success, sessionId, message, attachmentsAttached, imageWarnings? }`. Does not wait for the deck to finish. `attachments` is an optional array (max 10) of `{ path? | url? | data? | fileId?, filename?, mimeType?, placement? }` — any file type (images to embed, PDFs/spreadsheets/documents as source material). `images` still works as a deprecated alias. See [Chat attachments](#chat-attachments).
12
12
 
13
13
  - **`get_tb_powerpoint_result(sessionId)`** — checks on a generation started above:
14
14
  1. Retrieves the session. If its `status` isn't `idle`, returns `{ ready: false, status }` — still running, try again later.
15
15
  2. If idle, finds the most recent `session.status_idle` event and checks its `stop_reason.type`. Only proceeds if it's `end_turn` (a clean finish) — a different stop reason (e.g. a budget cap) also returns `{ ready: false, ... }` rather than treating the session as done.
16
16
  3. Lists files scoped to the session, filters for a `.pptx` filename (preferring one whose path looks like it's under an outputs directory), downloads it via the Files API into `TB_PPT_OUTPUT_DIR` (the local folder synced to GCS outside this server), and returns a download link built from `TB_PPT_PUBLIC_URL_BASE`.
17
17
 
18
+ ## Chat attachments
19
+
20
+ An MCP tool call cannot see chat attachments by itself, so the calling model passes a reference:
21
+
22
+ - **`path`** (preferred in Claude Desktop / Claude Code): the attachment is stored on disk and the model knows its absolute path. The server reads the file itself, so no bytes travel through the model.
23
+ - **`url`**: a directly fetchable URL (server-side `fetch`).
24
+ - **`data`**: base64 bytes, as a last resort (costs tokens).
25
+ - **`fileId`**: a file already in the Files API, mounted without re-uploading.
26
+
27
+ Each file is uploaded to the Anthropic Files API and mounted at `/mnt/session/uploads/user-attachments/<n>-<name>`; the first message tells the agent to insert images at the given `placement` and to use other files as source material. A source that fails (bad path, HTTP error) is skipped and reported in `imageWarnings` instead of failing the whole call.
28
+
29
+ Optional hardening: set `TB_PPT_ATTACHMENT_ROOTS` to one or more folders (separated by `;` on Windows, `:` elsewhere); `path` attachments outside them are refused. Unset means any readable local file is allowed.
30
+
31
+ ### TypingMind (URL flow)
32
+
33
+ A plain MCP tool call (which is what `generate_tb_powerpoint` is) has no visibility into chat attachments at all — TypingMind only exposes `resources.userMessage.attachments` to a **JavaScript-implementation regular plugin** with the `read_user_message` permission, never to a native/remote MCP tool. So getting an attached image into a deck is a two-step chain:
34
+
35
+ 1. **A small companion TypingMind plugin surfaces the attachment.** In TypingMind's Admin Panel → Plugins → Create New Plugin, set up a plugin like this (a "Regular Plugin", separate from this MCP server's registration):
36
+ - **Name:** `get_chat_attachments`
37
+ - **Description** (used for tool selection): "Returns the direct URL, MIME type, and filename of any file/image the user attached to their most recent message in this chat. Call this whenever the user references an attachment (e.g. 'insert this logo', 'use this screenshot') so its URL can be passed to another tool such as generate_tb_powerpoint's `images` parameter."
38
+ - **Parameters:** none
39
+ - **Permissions:** `read_user_message`
40
+ - **Implementation type:** JavaScript Code
41
+ ```javascript
42
+ async function get_chat_attachments(params, userSettings, resources) {
43
+ const attachments = (resources && resources.userMessage && resources.userMessage.attachments) || [];
44
+ if (attachments.length === 0) {
45
+ return JSON.stringify({ attachments: [], message: "No attachments found on the user's most recent message." });
46
+ }
47
+ return JSON.stringify({
48
+ attachments: attachments.map(function (a) {
49
+ return { url: a.url, type: a.type, name: a.name };
50
+ })
51
+ });
52
+ }
53
+ ```
54
+ - **Plugin Context** (recommended, injected into the system prompt): "When the user says they attached/added/pasted an image or file and references it (e.g. 'zet dit logo op de titelslide'), call get_chat_attachments first to get its URL, then pass that URL into generate_tb_powerpoint's `images` array along with a `placement` describing what the user asked for, in their own words."
55
+
56
+ 2. **The model chains the two tools itself.** With both plugins active in the same chat, the flow the model follows is: user attaches an image and says where it goes → model calls `get_chat_attachments` → gets back `{ url, type, name }` → calls `generate_tb_powerpoint` with `images: [{ url, mimeType: type, filename: name, placement: "<what the user asked for>" }]`. This server then fetches that URL itself (a plain server-side fetch — no browser/CORS involved), uploads the bytes to the Files API exactly like the brand logos, and mounts it into the session at `/user-images/image-N.ext`, telling the agent in the message text where to insert it.
57
+
58
+ **One real unknown, worth testing before relying on this:** it isn't confirmed whether the `url` TypingMind's attachment object returns is a plain, publicly-fetchable link, or one scoped to TypingMind's own browser session (which this server's server-side `fetch()` couldn't reach). Test it once by attaching an image, calling `get_chat_attachments`, and trying to fetch the returned URL independently (e.g. `curl -I <url>`) from outside TypingMind. **If that fails**, the fallback is already built in: `images` entries also accept `data` (base64 image bytes) instead of `url` — change the plugin's JS code above to fetch the attachment's `url` itself (`fetch(a.url).then(r => r.arrayBuffer())`, browser-side, where TypingMind's own session auth applies) and return base64-encoded `data` instead of `url`; no MCP-server code change is needed either way.
59
+
18
60
  ## What's still an assumption
19
61
 
20
62
  Every SDK call in `src/index.ts` (`sessions.create`, `sessions.events.send`, `sessions.retrieve`, `sessions.events.list`, `files.list`, `files.download`) is checked against `@anthropic-ai/sdk`'s own `.d.ts`/source (installed version, not just the docs prose) — that's how a real bug was caught in 0.1.0: the docs' TypeScript sample for downloading a file shows `content.writeToFile(path)`, but that method doesn't exist on the installed SDK. `files.download()` actually resolves to a plain `Response`; 0.1.1 fixes this by reading it with `.arrayBuffer()` and writing the bytes directly. The `resources` shape (`{ type: "file", file_id, mount_path }`) is likewise checked against the SDK source, not just the docs.
21
63
 
22
64
  The one thing still **not** confirmed against a real response is the exact `filename`/path convention the Files API returns for a file the agent wrote to its outputs directory — the code guesses by matching `/outputs?[\/]/i` against the filename and falling back to any `.pptx` file on the session if nothing matches that pattern. If a real run's file list looks different, adjust the filter in `get_tb_powerpoint_result` in `src/index.ts`.
23
65
 
66
+ `generate_tb_powerpoint`'s image-forwarding path (`client.beta.files.upload(...)`, via the SDK's `toFile()` helper) is checked against the SDK source the same way, and is exercised by the same `resources` mechanism already used for brand assets — but the TypingMind-side attachment flow that's meant to feed it a URL has not been run end-to-end yet; see [Sending an attached image from TypingMind](#sending-an-attached-image-from-typingmind) for the one open question there.
67
+
24
68
  ## Setup
25
69
 
26
70
  1. **Install dependencies**:
@@ -37,7 +81,7 @@ The one thing still **not** confirmed against a real response is the exact `file
37
81
  ```
38
82
  .\uploadAssets.ps1 -ApiKey "sk-ant-..."
39
83
  ```
40
- Uploads `TrafficBuilders-PPT-Handoff.md`, all five `logos/*.png`, and `fonts/Outfit-VariableFont_wght.ttf` to the Files API, and prints a `file_id` per asset along with the matching env var name. These are just config, not code — plug them into step 5 below and there's nothing else to change or republish.
84
+ Uploads `TrafficBuilders-PPT-Handoff.md`, all five `logos/*.png`, and both `fonts/*-VariableFont_wght.ttf` (Outfit, Figtree) to the Files API, and prints a `file_id` per asset along with the matching env var name. These are just config, not code — plug them into step 5 below and there's nothing else to change or republish.
41
85
 
42
86
  4. **Configure environment variables**:
43
87
  - `ANTHROPIC_API_KEY` — Anthropic API key with Managed Agents access.
@@ -47,7 +91,8 @@ The one thing still **not** confirmed against a real response is the exact `file
47
91
  - `TB_PPT_OUTPUT_DIR` — required for `get_tb_powerpoint_result`. Local folder that's synced to GCS by something outside this server; finished `.pptx` files are written here.
48
92
  - `TB_PPT_PUBLIC_URL_BASE` — required for `get_tb_powerpoint_result`. Base URL the synced folder is served from (e.g. `https://storage.googleapis.com/bucket/prefix`), used to build the returned download link.
49
93
  - `TB_PPT_LOG_FILE` — optional. Path to the task log file. Defaults to `logs/tasks.log` next to this package.
50
- - `TB_DESIGN_BRIEF_FILE_ID`, `TB_LOGO_HORIZONTAL_FILE_ID`, `TB_LOGO_HORIZONTAL_WHITE_FILE_ID`, `TB_LOGO_ICON_FILE_ID`, `TB_LOGO_ICON_WHITE_FILE_ID`, `TB_LOGO_ICON_TAGLINE_FILE_ID`, `TB_FONT_FILE_ID` — all optional but strongly recommended, from step 3. Any left unset are just skipped (with a warning in the log) rather than failing the call — the deck still gets built, just without that asset available to the agent.
94
+ - `TB_PPT_ATTACHMENT_ROOTS` — optional. Folders that `path` attachments must live in.
95
+ - `TB_DESIGN_BRIEF_FILE_ID`, `TB_LOGO_HORIZONTAL_FILE_ID`, `TB_LOGO_HORIZONTAL_WHITE_FILE_ID`, `TB_LOGO_ICON_FILE_ID`, `TB_LOGO_ICON_WHITE_FILE_ID`, `TB_LOGO_ICON_TAGLINE_FILE_ID`, `TB_FONT_FILE_ID`, `TB_FONT_BODY_FILE_ID` — all optional but strongly recommended, from step 3. Any left unset are just skipped (with a warning in the log) rather than failing the call — the deck still gets built, just without that asset available to the agent.
51
96
 
52
97
  5. **Register with your MCP client** (Claude Desktop / TypingMind's local MCP connector — same pattern as this repo's siblings, e.g. `tb-gcs-assembly-mcp`). Published to npm as [`@unboundai/tb-ppt-mgagent`](https://www.npmjs.com/package/@unboundai/tb-ppt-mgagent), so `npx` can run it without a local checkout:
53
98
 
@@ -70,7 +115,8 @@ The one thing still **not** confirmed against a real response is the exact `file
70
115
  "TB_LOGO_ICON_FILE_ID": "file_...",
71
116
  "TB_LOGO_ICON_WHITE_FILE_ID": "file_...",
72
117
  "TB_LOGO_ICON_TAGLINE_FILE_ID": "file_...",
73
- "TB_FONT_FILE_ID": "file_..."
118
+ "TB_FONT_FILE_ID": "file_...",
119
+ "TB_FONT_BODY_FILE_ID": "file_..."
74
120
  }
75
121
  }
76
122
  ```
@@ -138,4 +184,4 @@ Both tools were smoke-tested end-to-end over the real MCP protocol (`tools/list`
138
184
 
139
185
  ## Design brief reference
140
186
 
141
- `TrafficBuilders-PPT-Handoff.md`, `logos/`, and `fonts/` in this repo are the source material — pulled from the "Traffic Builders Design System" Claude Design project (colors, type scale, the 19 slide archetypes, brand logo PNGs, Outfit variable font) — that `uploadAssets.ps1` uploads and every session attaches via `resources`. Update the files here first if the design system changes, then re-run `uploadAssets.ps1` and update the env vars with the new file_ids. No slide-image assets are attached separately — the handoff brief embeds a literal HTML/CSS reference snippet per archetype, which is enough on its own (see its closing section for when a visual PNG reference would be worth adding).
187
+ `TrafficBuilders-PPT-Handoff.md`, `logos/`, and `fonts/` in this repo are the source material — pulled from the "Traffic Builders Design System" Claude Design project (colors, type scale, the 21 slide archetypes, brand logo PNGs, Outfit variable font) — that `uploadAssets.ps1` uploads and every session attaches via `resources`. Update the files here first if the design system changes, then re-run `uploadAssets.ps1` and update the env vars with the new file_ids. No slide-image assets are attached separately — the handoff brief embeds a literal HTML/CSS reference snippet per archetype, which is enough on its own (see its closing section for when a visual PNG reference would be worth adding).
package/build/index.js CHANGED
@@ -51,18 +51,31 @@
51
51
  * - TB_DESIGN_BRIEF_FILE_ID, TB_LOGO_HORIZONTAL_FILE_ID,
52
52
  * TB_LOGO_HORIZONTAL_WHITE_FILE_ID, TB_LOGO_ICON_FILE_ID,
53
53
  * TB_LOGO_ICON_WHITE_FILE_ID, TB_LOGO_ICON_TAGLINE_FILE_ID,
54
- * TB_FONT_FILE_ID: all optional but strongly recommended. Files API
54
+ * TB_FONT_FILE_ID (Outfit), TB_FONT_BODY_FILE_ID (Figtree): all optional but strongly recommended. Files API
55
55
  * file_ids (from uploadAssets.ps1) for the design brief, each logo
56
- * variant, and the Outfit font. Any that are set get mounted into every
56
+ * variant, and the Outfit and Figtree fonts. Any that are set get mounted into every
57
57
  * session via `resources`; any left unset are just skipped (with a
58
58
  * warning), so the agent falls back to approximating the brand from the
59
59
  * prompt text alone.
60
+ *
61
+ * User-supplied attachments (a client logo, screenshot, PDF, spreadsheet...):
62
+ * generate_tb_powerpoint's optional `attachments` argument takes
63
+ * `{ path | url | data | fileId, filename?, mimeType?, placement? }` entries
64
+ * (`images` is kept as a deprecated alias). A plain MCP tool call cannot see a
65
+ * chat attachment by itself, but Claude Desktop / Claude Code store attachments
66
+ * on disk and the model knows their path, so `path` is the preferred source;
67
+ * `url` and base64 `data` remain for clients that expose attachments that way.
68
+ * This server reads/fetches each file itself, uploads the bytes to the Files
69
+ * API (like uploadAssets.ps1 does for brand assets), and mounts the result into
70
+ * the session's `resources` under /user-attachments/, with the `placement` text
71
+ * relayed to the agent. Set TB_PPT_ATTACHMENT_ROOTS (optional, folders separated
72
+ * by the OS path delimiter) to restrict which local folders `path` may read from.
60
73
  */
61
74
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
62
75
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
63
76
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
64
77
  import { z } from "zod";
65
- import Anthropic from "@anthropic-ai/sdk";
78
+ import Anthropic, { toFile } from "@anthropic-ai/sdk";
66
79
  import fs from "fs";
67
80
  import path from "path";
68
81
  import { fileURLToPath } from "url";
@@ -116,7 +129,8 @@ const ASSET_RESOURCES = [
116
129
  { envVar: "TB_LOGO_ICON_FILE_ID", mountPath: "/design/logos/logo-icon.png", label: "logo-icon.png (full-color square mark)" },
117
130
  { envVar: "TB_LOGO_ICON_WHITE_FILE_ID", mountPath: "/design/logos/logo-icon-white.png", label: "logo-icon-white.png (reversed square mark)" },
118
131
  { envVar: "TB_LOGO_ICON_TAGLINE_FILE_ID", mountPath: "/design/logos/logo-icon-tagline.png", label: "logo-icon-tagline.png (icon + tagline lockup)" },
119
- { envVar: "TB_FONT_FILE_ID", mountPath: "/design/fonts/Outfit-VariableFont_wght.ttf", label: "Outfit variable font (weights 300-900)" }
132
+ { envVar: "TB_FONT_FILE_ID", mountPath: "/design/fonts/Outfit-VariableFont_wght.ttf", label: "Outfit variable font (weights 300-900) - headings, numbers, eyebrows" },
133
+ { envVar: "TB_FONT_BODY_FILE_ID", mountPath: "/design/fonts/Figtree-VariableFont_wght.ttf", label: "Figtree variable font (weights 300-900) - body copy and labels" }
120
134
  ];
121
135
  const missingAssetEnvVars = ASSET_RESOURCES.filter((a) => !(process.env[a.envVar] || "").trim());
122
136
  if (missingAssetEnvVars.length > 0) {
@@ -149,11 +163,77 @@ function getClient(apiKey) {
149
163
  ...(API_BASE ? { baseURL: API_BASE } : {})
150
164
  });
151
165
  }
166
+ /** Maps a MIME type (or, failing that, a URL/filename extension) to a file extension. */
167
+ function guessExtension(mimeType, hint) {
168
+ const byMime = {
169
+ "image/png": ".png",
170
+ "image/jpeg": ".jpg",
171
+ "image/jpg": ".jpg",
172
+ "image/webp": ".webp",
173
+ "image/gif": ".gif",
174
+ "image/svg+xml": ".svg",
175
+ "application/pdf": ".pdf",
176
+ "text/csv": ".csv",
177
+ "text/plain": ".txt",
178
+ "text/markdown": ".md",
179
+ "application/json": ".json",
180
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
181
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
182
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx"
183
+ };
184
+ const fromMime = mimeType ? byMime[mimeType.toLowerCase().split(";")[0].trim()] : undefined;
185
+ if (fromMime)
186
+ return fromMime;
187
+ const match = hint.match(/\.[a-zA-Z0-9]+(?=$|[?#])/);
188
+ return match ? match[0].toLowerCase() : ".bin";
189
+ }
190
+ const byExt = {
191
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp",
192
+ ".gif": "image/gif", ".svg": "image/svg+xml", ".pdf": "application/pdf", ".csv": "text/csv",
193
+ ".txt": "text/plain", ".md": "text/markdown", ".json": "application/json",
194
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
195
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
196
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
197
+ };
198
+ /**
199
+ * Optional safety net for the `path` source: a list of folders (separated by the
200
+ * OS path delimiter, ";" on Windows) that local attachments must live inside.
201
+ * Unset = any readable local file is allowed.
202
+ */
203
+ const ATTACHMENT_ROOTS = (process.env.TB_PPT_ATTACHMENT_ROOTS || "")
204
+ .split(path.delimiter)
205
+ .map((p) => p.trim())
206
+ .filter(Boolean)
207
+ .map((p) => path.resolve(p));
208
+ function assertPathAllowed(resolved) {
209
+ if (ATTACHMENT_ROOTS.length === 0)
210
+ return;
211
+ const ok = ATTACHMENT_ROOTS.some((root) => {
212
+ const rel = path.relative(root, resolved);
213
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
214
+ });
215
+ if (!ok)
216
+ throw new Error(`path is outside TB_PPT_ATTACHMENT_ROOTS: ${resolved}`);
217
+ }
218
+ const AttachmentInputSchema = z.object({
219
+ path: z.string().optional().describe("Absolute local file path of a chat attachment (Claude Desktop / Claude Code store attachments on disk)"),
220
+ url: z.string().url("attachment url must be a valid, fetchable URL").optional(),
221
+ data: z.string().optional().describe("base64-encoded file bytes, if neither path nor url is available"),
222
+ fileId: z.string().optional().describe("An already-uploaded Files API file_id - mounted as-is, no re-upload"),
223
+ filename: z.string().optional(),
224
+ mimeType: z.string().optional(),
225
+ placement: z.string().optional().describe("Where/how the file should be used in the deck")
226
+ }).refine((a) => Boolean(a.path || a.url || a.data || a.fileId), {
227
+ message: "one of path, url, data or fileId is required"
228
+ });
152
229
  const GenerateTbPowerpointSchema = z.object({
153
230
  outline: z.string().min(1, "outline is required"),
154
231
  deckType: z.string().optional(),
155
232
  clientName: z.string().optional(),
156
- filename: z.string().optional()
233
+ filename: z.string().optional(),
234
+ attachments: z.array(AttachmentInputSchema).max(10, "at most 10 attachments per deck").optional(),
235
+ /** Deprecated alias of `attachments`, kept for existing callers. */
236
+ images: z.array(AttachmentInputSchema).max(10, "at most 10 images per deck").optional()
157
237
  });
158
238
  const GetTbPowerpointResultSchema = z.object({
159
239
  sessionId: z.string().min(1, "sessionId is required")
@@ -180,7 +260,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
180
260
  "the outline as the first message, and returns the session id right away - it does NOT " +
181
261
  "wait for the deck to finish building. Use this whenever the user has typed or drafted " +
182
262
  "an outline/agenda and wants it turned into a Traffic Builders PowerPoint. Follow up later " +
183
- "with get_tb_powerpoint_result using the returned session id to fetch the finished file.",
263
+ "with get_tb_powerpoint_result using the returned session id to fetch the finished file. " +
264
+ "If the user attached files in this chat (logo, screenshot, PDF, spreadsheet, brief...) and " +
265
+ "wants them used in the deck, pass them via the `attachments` argument: give the local file " +
266
+ "`path` of the attachment (Claude Desktop / Claude Code keep chat attachments on disk), or a " +
267
+ "`url`, or base64 `data`. This tool uploads each file to Anthropic's Files API and mounts it " +
268
+ "in the session for the agent. Add a `placement` saying where/how it should be used.",
184
269
  inputSchema: {
185
270
  type: "object",
186
271
  properties: {
@@ -199,6 +284,50 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
199
284
  filename: {
200
285
  type: "string",
201
286
  description: "Optional filename without extension for the resulting .pptx."
287
+ },
288
+ attachments: {
289
+ type: "array",
290
+ description: "Optional files from the chat to include in the deck (images to embed, or PDFs/spreadsheets/documents as source material). At most 10. Each needs one of path, url, data or fileId.",
291
+ maxItems: 10,
292
+ items: {
293
+ type: "object",
294
+ properties: {
295
+ path: {
296
+ type: "string",
297
+ description: "Absolute local file path of the attachment (preferred in Claude Desktop / Claude Code)."
298
+ },
299
+ url: {
300
+ type: "string",
301
+ description: "A direct, fetchable URL to the file."
302
+ },
303
+ data: {
304
+ type: "string",
305
+ description: "Base64-encoded file bytes, if no path or URL is available."
306
+ },
307
+ fileId: {
308
+ type: "string",
309
+ description: "An already-uploaded Files API file_id; mounted as-is without re-uploading."
310
+ },
311
+ filename: {
312
+ type: "string",
313
+ description: "Original filename, used for the mounted file name."
314
+ },
315
+ mimeType: {
316
+ type: "string",
317
+ description: "MIME type if known (e.g. 'image/png', 'application/pdf'); inferred otherwise."
318
+ },
319
+ placement: {
320
+ type: "string",
321
+ description: "Where/how the file should be used, in the user's own words (e.g. 'titelslide achtergrond', 'bron voor de cijfers op slide 4'). Strongly recommended."
322
+ }
323
+ }
324
+ }
325
+ },
326
+ images: {
327
+ type: "array",
328
+ description: "Deprecated alias of `attachments`.",
329
+ maxItems: 10,
330
+ items: { type: "object" }
202
331
  }
203
332
  },
204
333
  required: ["outline"]
@@ -231,7 +360,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
231
360
  const startedAt = Date.now();
232
361
  try {
233
362
  if (name === "generate_tb_powerpoint") {
234
- const { outline, deckType, clientName, filename } = GenerateTbPowerpointSchema.parse(args);
363
+ const parsed = GenerateTbPowerpointSchema.parse(args);
364
+ const { outline, deckType, clientName, filename } = parsed;
365
+ const attachments = [...(parsed.attachments ?? []), ...(parsed.images ?? [])];
235
366
  logTask({
236
367
  tool: name,
237
368
  status: "started",
@@ -239,7 +370,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
239
370
  outlineLength: outline.length,
240
371
  deckType: deckType ?? null,
241
372
  clientName: clientName ?? null,
242
- filename: filename ?? null
373
+ filename: filename ?? null,
374
+ attachmentCount: attachments.length
243
375
  });
244
376
  const { apiKey, agentId, environmentId } = requireSessionConfig();
245
377
  const client = getClient(apiKey);
@@ -252,16 +384,95 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
252
384
  attached: attachedAssets.map((a) => a.mountPath),
253
385
  missing: missingAssetEnvVars.map((a) => a.envVar)
254
386
  });
387
+ // User-supplied attachments from the chat (logos, screenshots, PDFs, ...) - see
388
+ // the "User-supplied attachments" note in the file header. Each one is read from a
389
+ // local path / fetched from a URL / decoded from base64, uploaded to the Files API,
390
+ // and mounted into the session. One at a time, so a bad source only drops that
391
+ // one file instead of failing the whole call.
392
+ const uploadedImages = [];
393
+ const imageWarnings = [];
394
+ if (attachments.length > 0) {
395
+ logTask({ tool: name, status: "fetching_user_attachments", count: attachments.length });
396
+ for (let i = 0; i < attachments.length; i++) {
397
+ const att = attachments[i];
398
+ const index = i + 1;
399
+ try {
400
+ if (att.fileId) {
401
+ // Already in the Files API - mount as-is.
402
+ const ext = guessExtension(att.mimeType, att.filename || "");
403
+ uploadedImages.push({
404
+ mountPath: `/user-attachments/${index}-${path.basename(att.filename || `attachment${ext}`)}`,
405
+ label: att.filename || att.fileId,
406
+ fileId: att.fileId,
407
+ placement: att.placement
408
+ });
409
+ continue;
410
+ }
411
+ let bytes;
412
+ let contentType = att.mimeType;
413
+ let hint = att.filename || "";
414
+ if (att.path) {
415
+ const resolved = path.resolve(att.path);
416
+ assertPathAllowed(resolved);
417
+ bytes = fs.readFileSync(resolved);
418
+ hint = hint || resolved;
419
+ }
420
+ else if (att.data) {
421
+ bytes = Buffer.from(att.data, "base64");
422
+ }
423
+ else {
424
+ const response = await fetch(att.url);
425
+ if (!response.ok) {
426
+ throw new Error(`fetch returned HTTP ${response.status}`);
427
+ }
428
+ contentType = contentType || response.headers.get("content-type") || undefined;
429
+ bytes = Buffer.from(await response.arrayBuffer());
430
+ hint = hint || att.url;
431
+ }
432
+ const ext = guessExtension(contentType, hint);
433
+ contentType = contentType || byExt[ext];
434
+ const baseName = path.basename(att.filename || (att.path ? att.path : `attachment-${index}${ext}`));
435
+ const uploaded = await client.beta.files.upload({
436
+ file: await toFile(bytes, baseName, contentType ? { type: contentType } : undefined)
437
+ });
438
+ uploadedImages.push({
439
+ mountPath: `/user-attachments/${index}-${baseName}`,
440
+ label: baseName,
441
+ fileId: uploaded.id,
442
+ placement: att.placement
443
+ });
444
+ }
445
+ catch (err) {
446
+ const message = err.message;
447
+ const ref = att.path || att.url || att.filename || `#${index}`;
448
+ imageWarnings.push(`Attachment ${index} (${ref}) could not be attached: ${message}`);
449
+ logTask({ tool: name, status: "attachment_failed", index, ref, message });
450
+ }
451
+ }
452
+ logTask({
453
+ tool: name,
454
+ status: "user_attachments_attached",
455
+ attached: uploadedImages.map((img) => img.mountPath),
456
+ warnings: imageWarnings
457
+ });
458
+ }
255
459
  const session = await client.beta.sessions.create({
256
460
  agent: agentId,
257
461
  environment_id: environmentId,
258
- ...(attachedAssets.length > 0
462
+ ...(attachedAssets.length > 0 || uploadedImages.length > 0
259
463
  ? {
260
- resources: attachedAssets.map((a) => ({
261
- type: "file",
262
- file_id: a.fileId,
263
- mount_path: a.mountPath
264
- }))
464
+ resources: [
465
+ ...attachedAssets.map((a) => ({
466
+ type: "file",
467
+ file_id: a.fileId,
468
+ mount_path: a.mountPath
469
+ })),
470
+ ...uploadedImages.map((img) => ({
471
+ type: "file",
472
+ file_id: img.fileId,
473
+ mount_path: img.mountPath
474
+ }))
475
+ ]
265
476
  }
266
477
  : {})
267
478
  });
@@ -276,9 +487,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
276
487
  const assetsNote = attachedAssets.length > 0
277
488
  ? `## Attached brand assets\nThese files are mounted in your sandbox:\n${attachedAssets
278
489
  .map((a) => `- /mnt/session/uploads${a.mountPath} - ${a.label}`)
279
- .join("\n")}\n\nRead the design brief first and follow it exactly: brand colors, type scale, and the 19 slide archetypes it defines. Follow its logo lockup rule precisely - it is not a simple per-background swap: the wordmark logo appears ONLY on Title and Section-divider slides (top-left), while a small icon mark appears bottom-right on EVERY slide regardless of archetype; use the white logo variants on brand-blue backgrounds and the full-color variants on white backgrounds. Embed/register the attached font file and use it for all text - ExtraBold (800) for headings, Regular (400) for body - instead of a fallback typeface.\n\n---\n\n`
490
+ .join("\n")}\n\nRead the design brief first and follow it exactly: brand colors, type scale, and the 21 slide archetypes it defines. Choose each slide's archetype by the shape of its content using the brief's section 7 table, and obey its variation rules (card-block slides are the exception, never the default; never two in a row). Follow its logo lockup rule precisely - it is not a simple per-background swap: the wordmark logo appears ONLY on Title and Section-divider slides (top-left), while a small icon mark appears bottom-right on EVERY slide regardless of archetype; use the white logo variants on brand-blue backgrounds and the full-color variants on white backgrounds. Embed/register BOTH attached font files: Outfit (ExtraBold 800) for headings, big numbers, eyebrows and tags; Figtree (Regular 400, SemiBold 600 for table headers) for body copy, card copy and labels - instead of a fallback typeface.\n\n---\n\n`
491
+ : "";
492
+ const userImagesNote = uploadedImages.length > 0
493
+ ? `## User-provided attachments\nThese files from the user are mounted in your sandbox. Images MUST be inserted into the deck (not just described) at the placement given; other files (PDF, spreadsheet, document) are source material to read and use for the content as described:\n${uploadedImages
494
+ .map((img) => `- /mnt/session/uploads${img.mountPath} (${img.label}) - placement/use: ${img.placement || "not specified - use your judgement"}`)
495
+ .join("\n")}\n\n---\n\n`
280
496
  : "";
281
- const messageText = `Build a Traffic Builders PowerPoint from the following outline.\n\n${assetsNote}${meta}## Outline\n${outline}`;
497
+ const messageText = `Build a Traffic Builders PowerPoint from the following outline.\n\n${assetsNote}${userImagesNote}${meta}## Outline\n${outline}`;
282
498
  await client.beta.sessions.events.send(session.id, {
283
499
  events: [
284
500
  { type: "user.message", content: [{ type: "text", text: messageText }] }
@@ -289,6 +505,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
289
505
  status: "success",
290
506
  sessionId: session.id,
291
507
  assetsAttached: attachedAssets.length,
508
+ attachmentsAttached: uploadedImages.length,
509
+ imageWarnings,
292
510
  durationMs: Date.now() - startedAt
293
511
  });
294
512
  return {
@@ -297,6 +515,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
297
515
  text: JSON.stringify({
298
516
  success: true,
299
517
  sessionId: session.id,
518
+ attachmentsAttached: uploadedImages.length,
519
+ ...(imageWarnings.length > 0 ? { imageWarnings } : {}),
300
520
  message: "Generation started. Call get_tb_powerpoint_result with this session id later to check on it and fetch the finished file."
301
521
  }, null, 2)
302
522
  }]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboundai/tb-ppt-mgagent",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "MCP server that hands a presentation outline to the Traffic Builders PowerPoint Managed Agent, then retrieves the finished .pptx",
5
5
  "main": "build/index.js",
6
6
  "bin": {