@unboundai/tb-ppt-mgagent 0.4.0 → 0.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.
Files changed (3) hide show
  1. package/README.md +186 -141
  2. package/build/index.js +232 -13
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,141 +1,186 @@
1
- # Traffic Builders PowerPoint MCP Server
2
-
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
-
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.
6
-
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
-
9
- ## Available tools
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.
12
-
13
- - **`get_tb_powerpoint_result(sessionId)`** — checks on a generation started above:
14
- 1. Retrieves the session. If its `status` isn't `idle`, returns `{ ready: false, status }` — still running, try again later.
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
- 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
-
18
- ## What's still an assumption
19
-
20
- 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
-
22
- 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
-
24
- ## Setup
25
-
26
- 1. **Install dependencies**:
27
- ```
28
- npm install
29
- ```
30
-
31
- 2. **Build the project**:
32
- ```
33
- npm run build
34
- ```
35
-
36
- 3. **Upload the brand assets** (one time, or again whenever an asset changes):
37
- ```
38
- .\uploadAssets.ps1 -ApiKey "sk-ant-..."
39
- ```
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.
41
-
42
- 4. **Configure environment variables**:
43
- - `ANTHROPIC_API_KEY` — Anthropic API key with Managed Agents access.
44
- - `ANTHROPIC_AGENT_ID` — e.g. `agent_01H8osDSMeEtGcEHT1ajzuin`.
45
- - `ANTHROPIC_ENVIRONMENT_ID` — e.g. `env_01VHzZmqBababsghFjQoAGFx`.
46
- - `ANTHROPIC_API_BASE` — optional, overrides the SDK's default API base URL.
47
- - `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
- - `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
- - `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.
51
-
52
- 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
-
54
- ```json
55
- "tb-ppt-mgagent": {
56
- "command": "npx",
57
- "args": [
58
- "-y",
59
- "@unboundai/tb-ppt-mgagent"
60
- ],
61
- "env": {
62
- "ANTHROPIC_API_KEY": "sk-ant-...",
63
- "ANTHROPIC_AGENT_ID": "agent_...",
64
- "ANTHROPIC_ENVIRONMENT_ID": "env_...",
65
- "TB_PPT_OUTPUT_DIR": "/path/to/gcs-synced-folder",
66
- "TB_PPT_PUBLIC_URL_BASE": "https://storage.googleapis.com/your-bucket/your-prefix",
67
- "TB_DESIGN_BRIEF_FILE_ID": "file_...",
68
- "TB_LOGO_HORIZONTAL_FILE_ID": "file_...",
69
- "TB_LOGO_HORIZONTAL_WHITE_FILE_ID": "file_...",
70
- "TB_LOGO_ICON_FILE_ID": "file_...",
71
- "TB_LOGO_ICON_WHITE_FILE_ID": "file_...",
72
- "TB_LOGO_ICON_TAGLINE_FILE_ID": "file_...",
73
- "TB_FONT_FILE_ID": "file_..."
74
- }
75
- }
76
- ```
77
-
78
- Or, from a local checkout of this repo:
79
-
80
- ```json
81
- "tb-ppt-mgagent": {
82
- "command": "node",
83
- "args": [
84
- "/path/to/tb-ppt-mgAgent/build/index.js"
85
- ],
86
- "env": { "...": "same as above" }
87
- }
88
- ```
89
-
90
- ### Publishing a new version
91
-
92
- ```bash
93
- npm version patch # or minor/major
94
- npm publish # prepublishOnly rebuilds automatically; will prompt for a 2FA OTP
95
- ```
96
-
97
- ## Task logging
98
-
99
- Every tool call writes one JSON-lines entry to `TB_PPT_LOG_FILE` (default `logs/tasks.log`) and mirrors it to stderr (which the MCP host's own log viewer already shows, as seen in the `[powerpoint-creator]` logs). Each line has a `timestamp`, `tool`, and `status`, plus whatever's relevant to that outcome:
100
-
101
- | status | tool | meaning |
102
- |---|---|---|
103
- | `started` | either | call received, with its key arguments (outline preview/length, or sessionId) |
104
- | `attaching_assets` | `generate_tb_powerpoint` | which brand asset `resources` are being attached (`attached`, a list of mount paths) and which env vars are unset (`missing`) |
105
- | `success` | `generate_tb_powerpoint` | session created; includes `sessionId` and `assetsAttached` count |
106
- | `not_ready` | `get_tb_powerpoint_result` | session isn't `idle` yet; includes `sessionStatus` |
107
- | `agent_message` | `get_tb_powerpoint_result` | the agent's own most recent `agent.message` text, up to 500 chars, in `agentMessagePreview` — this is what the agent actually said, not our derived status. Logged once the session is idle, before the stop-reason check, so it's there even on `not_end_turn` |
108
- | `not_end_turn` | `get_tb_powerpoint_result` | idle, but `stop_reason` wasn't `end_turn`; includes `stopReason` |
109
- | `listing_files` | `get_tb_powerpoint_result` | about to call Files API `list` for this session |
110
- | `files_listed` | `get_tb_powerpoint_result` | includes `fileCount`, every `.pptx` candidate found (`pptxCandidates`, each with `id`/`filename`/`sizeBytes`), and which one was `chosenFileId` |
111
- | `no_output_file` | `get_tb_powerpoint_result` | finished cleanly but no `.pptx` found; includes `fileCount` |
112
- | `download_started` | `get_tb_powerpoint_result` | about to call Files API `download`; includes `fileId`, `filename`, `expectedSizeBytes` |
113
- | `download_completed` | `get_tb_powerpoint_result` | bytes received; includes `downloadedBytes` |
114
- | `saving_file` | `get_tb_powerpoint_result` | about to write to `destPath` inside `outputDir` |
115
- | `file_saved` | `get_tb_powerpoint_result` | write confirmed via `fs.statSync`; includes `savedBytes` and `bytesMatch` (should be `true`) |
116
- | `success` | `get_tb_powerpoint_result` | fully done; includes `filename`, `destPath`, `downloadUrl` |
117
- | `invalid_arguments` | either | zod validation failed; includes `details` |
118
- | `error` | either | anything else threw; includes `message` |
119
-
120
- The agent's actual final message is also returned to the caller as `agentMessage` on every `get_tb_powerpoint_result` response (not just logged), so a not-quite-right run can be diagnosed from the tool's own output, not just the log file.
121
-
122
- All entries include `durationMs` except the intermediate progress lines (`attaching_assets`, `agent_message`, `listing_files`, `files_listed`, `download_started`, `download_completed`, `saving_file`) — those are pure checkpoints, timed by their surrounding `started`/`success`/`error` lines instead. To see what's been run:
123
-
124
- ```bash
125
- tail -f logs/tasks.log
126
- # or, for just failures:
127
- grep -E '"status":"(error|not_end_turn|no_output_file)"' logs/tasks.log
128
- ```
129
-
130
- ## Development
131
-
132
- ```bash
133
- # Watch mode
134
- npm run dev
135
- ```
136
-
137
- Both tools were smoke-tested end-to-end over the real MCP protocol (`tools/list`, and calling each with a dummy API key to confirm the SDK calls actually reach `api.anthropic.com` and get a real `401 authentication_error` back rather than failing on a wrong method/shape before the network call) — including with asset env vars set, to confirm the `resources` array builds correctly.
138
-
139
- ## Design brief reference
140
-
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 six 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.
1
+ # Traffic Builders PowerPoint MCP Server
2
+
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
+
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.
6
+
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
+
9
+ ## Available tools
10
+
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
+
13
+ - **`get_tb_powerpoint_result(sessionId)`** — checks on a generation started above:
14
+ 1. Retrieves the session. If its `status` isn't `idle`, returns `{ ready: false, status }` — still running, try again later.
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
+ 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
+
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
+
60
+ ## What's still an assumption
61
+
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.
63
+
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`.
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
+
68
+ ## Setup
69
+
70
+ 1. **Install dependencies**:
71
+ ```
72
+ npm install
73
+ ```
74
+
75
+ 2. **Build the project**:
76
+ ```
77
+ npm run build
78
+ ```
79
+
80
+ 3. **Upload the brand assets** (one time, or again whenever an asset changes):
81
+ ```
82
+ .\uploadAssets.ps1 -ApiKey "sk-ant-..."
83
+ ```
84
+ 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.
85
+
86
+ 4. **Configure environment variables**:
87
+ - `ANTHROPIC_API_KEY` — Anthropic API key with Managed Agents access.
88
+ - `ANTHROPIC_AGENT_ID` — e.g. `agent_01H8osDSMeEtGcEHT1ajzuin`.
89
+ - `ANTHROPIC_ENVIRONMENT_ID` — e.g. `env_01VHzZmqBababsghFjQoAGFx`.
90
+ - `ANTHROPIC_API_BASE` — optional, overrides the SDK's default API base URL.
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.
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.
93
+ - `TB_PPT_LOG_FILE` — optional. Path to the task log file. Defaults to `logs/tasks.log` next to this package.
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` — 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.
96
+
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:
98
+
99
+ ```json
100
+ "tb-ppt-mgagent": {
101
+ "command": "npx",
102
+ "args": [
103
+ "-y",
104
+ "@unboundai/tb-ppt-mgagent"
105
+ ],
106
+ "env": {
107
+ "ANTHROPIC_API_KEY": "sk-ant-...",
108
+ "ANTHROPIC_AGENT_ID": "agent_...",
109
+ "ANTHROPIC_ENVIRONMENT_ID": "env_...",
110
+ "TB_PPT_OUTPUT_DIR": "/path/to/gcs-synced-folder",
111
+ "TB_PPT_PUBLIC_URL_BASE": "https://storage.googleapis.com/your-bucket/your-prefix",
112
+ "TB_DESIGN_BRIEF_FILE_ID": "file_...",
113
+ "TB_LOGO_HORIZONTAL_FILE_ID": "file_...",
114
+ "TB_LOGO_HORIZONTAL_WHITE_FILE_ID": "file_...",
115
+ "TB_LOGO_ICON_FILE_ID": "file_...",
116
+ "TB_LOGO_ICON_WHITE_FILE_ID": "file_...",
117
+ "TB_LOGO_ICON_TAGLINE_FILE_ID": "file_...",
118
+ "TB_FONT_FILE_ID": "file_..."
119
+ }
120
+ }
121
+ ```
122
+
123
+ Or, from a local checkout of this repo:
124
+
125
+ ```json
126
+ "tb-ppt-mgagent": {
127
+ "command": "node",
128
+ "args": [
129
+ "/path/to/tb-ppt-mgAgent/build/index.js"
130
+ ],
131
+ "env": { "...": "same as above" }
132
+ }
133
+ ```
134
+
135
+ ### Publishing a new version
136
+
137
+ ```bash
138
+ npm version patch # or minor/major
139
+ npm publish # prepublishOnly rebuilds automatically; will prompt for a 2FA OTP
140
+ ```
141
+
142
+ ## Task logging
143
+
144
+ Every tool call writes one JSON-lines entry to `TB_PPT_LOG_FILE` (default `logs/tasks.log`) and mirrors it to stderr (which the MCP host's own log viewer already shows, as seen in the `[powerpoint-creator]` logs). Each line has a `timestamp`, `tool`, and `status`, plus whatever's relevant to that outcome:
145
+
146
+ | status | tool | meaning |
147
+ |---|---|---|
148
+ | `started` | either | call received, with its key arguments (outline preview/length, or sessionId) |
149
+ | `attaching_assets` | `generate_tb_powerpoint` | which brand asset `resources` are being attached (`attached`, a list of mount paths) and which env vars are unset (`missing`) |
150
+ | `success` | `generate_tb_powerpoint` | session created; includes `sessionId` and `assetsAttached` count |
151
+ | `not_ready` | `get_tb_powerpoint_result` | session isn't `idle` yet; includes `sessionStatus` |
152
+ | `agent_message` | `get_tb_powerpoint_result` | the agent's own most recent `agent.message` text, up to 500 chars, in `agentMessagePreview` — this is what the agent actually said, not our derived status. Logged once the session is idle, before the stop-reason check, so it's there even on `not_end_turn` |
153
+ | `not_end_turn` | `get_tb_powerpoint_result` | idle, but `stop_reason` wasn't `end_turn`; includes `stopReason` |
154
+ | `listing_files` | `get_tb_powerpoint_result` | about to call Files API `list` for this session |
155
+ | `files_listed` | `get_tb_powerpoint_result` | includes `fileCount`, every `.pptx` candidate found (`pptxCandidates`, each with `id`/`filename`/`sizeBytes`), and which one was `chosenFileId` |
156
+ | `no_output_file` | `get_tb_powerpoint_result` | finished cleanly but no `.pptx` found; includes `fileCount` |
157
+ | `download_started` | `get_tb_powerpoint_result` | about to call Files API `download`; includes `fileId`, `filename`, `expectedSizeBytes` |
158
+ | `download_completed` | `get_tb_powerpoint_result` | bytes received; includes `downloadedBytes` |
159
+ | `saving_file` | `get_tb_powerpoint_result` | about to write to `destPath` inside `outputDir` |
160
+ | `file_saved` | `get_tb_powerpoint_result` | write confirmed via `fs.statSync`; includes `savedBytes` and `bytesMatch` (should be `true`) |
161
+ | `success` | `get_tb_powerpoint_result` | fully done; includes `filename`, `destPath`, `downloadUrl` |
162
+ | `invalid_arguments` | either | zod validation failed; includes `details` |
163
+ | `error` | either | anything else threw; includes `message` |
164
+
165
+ The agent's actual final message is also returned to the caller as `agentMessage` on every `get_tb_powerpoint_result` response (not just logged), so a not-quite-right run can be diagnosed from the tool's own output, not just the log file.
166
+
167
+ All entries include `durationMs` except the intermediate progress lines (`attaching_assets`, `agent_message`, `listing_files`, `files_listed`, `download_started`, `download_completed`, `saving_file`) — those are pure checkpoints, timed by their surrounding `started`/`success`/`error` lines instead. To see what's been run:
168
+
169
+ ```bash
170
+ tail -f logs/tasks.log
171
+ # or, for just failures:
172
+ grep -E '"status":"(error|not_end_turn|no_output_file)"' logs/tasks.log
173
+ ```
174
+
175
+ ## Development
176
+
177
+ ```bash
178
+ # Watch mode
179
+ npm run dev
180
+ ```
181
+
182
+ Both tools were smoke-tested end-to-end over the real MCP protocol (`tools/list`, and calling each with a dummy API key to confirm the SDK calls actually reach `api.anthropic.com` and get a real `401 authentication_error` back rather than failing on a wrong method/shape before the network call) — including with asset env vars set, to confirm the `resources` array builds correctly.
183
+
184
+ ## Design brief reference
185
+
186
+ `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).
package/build/index.js CHANGED
@@ -57,12 +57,25 @@
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";
@@ -149,11 +162,77 @@ function getClient(apiKey) {
149
162
  ...(API_BASE ? { baseURL: API_BASE } : {})
150
163
  });
151
164
  }
165
+ /** Maps a MIME type (or, failing that, a URL/filename extension) to a file extension. */
166
+ function guessExtension(mimeType, hint) {
167
+ const byMime = {
168
+ "image/png": ".png",
169
+ "image/jpeg": ".jpg",
170
+ "image/jpg": ".jpg",
171
+ "image/webp": ".webp",
172
+ "image/gif": ".gif",
173
+ "image/svg+xml": ".svg",
174
+ "application/pdf": ".pdf",
175
+ "text/csv": ".csv",
176
+ "text/plain": ".txt",
177
+ "text/markdown": ".md",
178
+ "application/json": ".json",
179
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
180
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
181
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx"
182
+ };
183
+ const fromMime = mimeType ? byMime[mimeType.toLowerCase().split(";")[0].trim()] : undefined;
184
+ if (fromMime)
185
+ return fromMime;
186
+ const match = hint.match(/\.[a-zA-Z0-9]+(?=$|[?#])/);
187
+ return match ? match[0].toLowerCase() : ".bin";
188
+ }
189
+ const byExt = {
190
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp",
191
+ ".gif": "image/gif", ".svg": "image/svg+xml", ".pdf": "application/pdf", ".csv": "text/csv",
192
+ ".txt": "text/plain", ".md": "text/markdown", ".json": "application/json",
193
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
194
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
195
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
196
+ };
197
+ /**
198
+ * Optional safety net for the `path` source: a list of folders (separated by the
199
+ * OS path delimiter, ";" on Windows) that local attachments must live inside.
200
+ * Unset = any readable local file is allowed.
201
+ */
202
+ const ATTACHMENT_ROOTS = (process.env.TB_PPT_ATTACHMENT_ROOTS || "")
203
+ .split(path.delimiter)
204
+ .map((p) => p.trim())
205
+ .filter(Boolean)
206
+ .map((p) => path.resolve(p));
207
+ function assertPathAllowed(resolved) {
208
+ if (ATTACHMENT_ROOTS.length === 0)
209
+ return;
210
+ const ok = ATTACHMENT_ROOTS.some((root) => {
211
+ const rel = path.relative(root, resolved);
212
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
213
+ });
214
+ if (!ok)
215
+ throw new Error(`path is outside TB_PPT_ATTACHMENT_ROOTS: ${resolved}`);
216
+ }
217
+ const AttachmentInputSchema = z.object({
218
+ path: z.string().optional().describe("Absolute local file path of a chat attachment (Claude Desktop / Claude Code store attachments on disk)"),
219
+ url: z.string().url("attachment url must be a valid, fetchable URL").optional(),
220
+ data: z.string().optional().describe("base64-encoded file bytes, if neither path nor url is available"),
221
+ fileId: z.string().optional().describe("An already-uploaded Files API file_id - mounted as-is, no re-upload"),
222
+ filename: z.string().optional(),
223
+ mimeType: z.string().optional(),
224
+ placement: z.string().optional().describe("Where/how the file should be used in the deck")
225
+ }).refine((a) => Boolean(a.path || a.url || a.data || a.fileId), {
226
+ message: "one of path, url, data or fileId is required"
227
+ });
152
228
  const GenerateTbPowerpointSchema = z.object({
153
229
  outline: z.string().min(1, "outline is required"),
154
230
  deckType: z.string().optional(),
155
231
  clientName: z.string().optional(),
156
- filename: z.string().optional()
232
+ filename: z.string().optional(),
233
+ attachments: z.array(AttachmentInputSchema).max(10, "at most 10 attachments per deck").optional(),
234
+ /** Deprecated alias of `attachments`, kept for existing callers. */
235
+ images: z.array(AttachmentInputSchema).max(10, "at most 10 images per deck").optional()
157
236
  });
158
237
  const GetTbPowerpointResultSchema = z.object({
159
238
  sessionId: z.string().min(1, "sessionId is required")
@@ -180,7 +259,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
180
259
  "the outline as the first message, and returns the session id right away - it does NOT " +
181
260
  "wait for the deck to finish building. Use this whenever the user has typed or drafted " +
182
261
  "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.",
262
+ "with get_tb_powerpoint_result using the returned session id to fetch the finished file. " +
263
+ "If the user attached files in this chat (logo, screenshot, PDF, spreadsheet, brief...) and " +
264
+ "wants them used in the deck, pass them via the `attachments` argument: give the local file " +
265
+ "`path` of the attachment (Claude Desktop / Claude Code keep chat attachments on disk), or a " +
266
+ "`url`, or base64 `data`. This tool uploads each file to Anthropic's Files API and mounts it " +
267
+ "in the session for the agent. Add a `placement` saying where/how it should be used.",
184
268
  inputSchema: {
185
269
  type: "object",
186
270
  properties: {
@@ -199,6 +283,50 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
199
283
  filename: {
200
284
  type: "string",
201
285
  description: "Optional filename without extension for the resulting .pptx."
286
+ },
287
+ attachments: {
288
+ type: "array",
289
+ 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.",
290
+ maxItems: 10,
291
+ items: {
292
+ type: "object",
293
+ properties: {
294
+ path: {
295
+ type: "string",
296
+ description: "Absolute local file path of the attachment (preferred in Claude Desktop / Claude Code)."
297
+ },
298
+ url: {
299
+ type: "string",
300
+ description: "A direct, fetchable URL to the file."
301
+ },
302
+ data: {
303
+ type: "string",
304
+ description: "Base64-encoded file bytes, if no path or URL is available."
305
+ },
306
+ fileId: {
307
+ type: "string",
308
+ description: "An already-uploaded Files API file_id; mounted as-is without re-uploading."
309
+ },
310
+ filename: {
311
+ type: "string",
312
+ description: "Original filename, used for the mounted file name."
313
+ },
314
+ mimeType: {
315
+ type: "string",
316
+ description: "MIME type if known (e.g. 'image/png', 'application/pdf'); inferred otherwise."
317
+ },
318
+ placement: {
319
+ type: "string",
320
+ 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."
321
+ }
322
+ }
323
+ }
324
+ },
325
+ images: {
326
+ type: "array",
327
+ description: "Deprecated alias of `attachments`.",
328
+ maxItems: 10,
329
+ items: { type: "object" }
202
330
  }
203
331
  },
204
332
  required: ["outline"]
@@ -231,7 +359,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
231
359
  const startedAt = Date.now();
232
360
  try {
233
361
  if (name === "generate_tb_powerpoint") {
234
- const { outline, deckType, clientName, filename } = GenerateTbPowerpointSchema.parse(args);
362
+ const parsed = GenerateTbPowerpointSchema.parse(args);
363
+ const { outline, deckType, clientName, filename } = parsed;
364
+ const attachments = [...(parsed.attachments ?? []), ...(parsed.images ?? [])];
235
365
  logTask({
236
366
  tool: name,
237
367
  status: "started",
@@ -239,7 +369,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
239
369
  outlineLength: outline.length,
240
370
  deckType: deckType ?? null,
241
371
  clientName: clientName ?? null,
242
- filename: filename ?? null
372
+ filename: filename ?? null,
373
+ attachmentCount: attachments.length
243
374
  });
244
375
  const { apiKey, agentId, environmentId } = requireSessionConfig();
245
376
  const client = getClient(apiKey);
@@ -252,16 +383,95 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
252
383
  attached: attachedAssets.map((a) => a.mountPath),
253
384
  missing: missingAssetEnvVars.map((a) => a.envVar)
254
385
  });
386
+ // User-supplied attachments from the chat (logos, screenshots, PDFs, ...) - see
387
+ // the "User-supplied attachments" note in the file header. Each one is read from a
388
+ // local path / fetched from a URL / decoded from base64, uploaded to the Files API,
389
+ // and mounted into the session. One at a time, so a bad source only drops that
390
+ // one file instead of failing the whole call.
391
+ const uploadedImages = [];
392
+ const imageWarnings = [];
393
+ if (attachments.length > 0) {
394
+ logTask({ tool: name, status: "fetching_user_attachments", count: attachments.length });
395
+ for (let i = 0; i < attachments.length; i++) {
396
+ const att = attachments[i];
397
+ const index = i + 1;
398
+ try {
399
+ if (att.fileId) {
400
+ // Already in the Files API - mount as-is.
401
+ const ext = guessExtension(att.mimeType, att.filename || "");
402
+ uploadedImages.push({
403
+ mountPath: `/user-attachments/${index}-${path.basename(att.filename || `attachment${ext}`)}`,
404
+ label: att.filename || att.fileId,
405
+ fileId: att.fileId,
406
+ placement: att.placement
407
+ });
408
+ continue;
409
+ }
410
+ let bytes;
411
+ let contentType = att.mimeType;
412
+ let hint = att.filename || "";
413
+ if (att.path) {
414
+ const resolved = path.resolve(att.path);
415
+ assertPathAllowed(resolved);
416
+ bytes = fs.readFileSync(resolved);
417
+ hint = hint || resolved;
418
+ }
419
+ else if (att.data) {
420
+ bytes = Buffer.from(att.data, "base64");
421
+ }
422
+ else {
423
+ const response = await fetch(att.url);
424
+ if (!response.ok) {
425
+ throw new Error(`fetch returned HTTP ${response.status}`);
426
+ }
427
+ contentType = contentType || response.headers.get("content-type") || undefined;
428
+ bytes = Buffer.from(await response.arrayBuffer());
429
+ hint = hint || att.url;
430
+ }
431
+ const ext = guessExtension(contentType, hint);
432
+ contentType = contentType || byExt[ext];
433
+ const baseName = path.basename(att.filename || (att.path ? att.path : `attachment-${index}${ext}`));
434
+ const uploaded = await client.beta.files.upload({
435
+ file: await toFile(bytes, baseName, contentType ? { type: contentType } : undefined)
436
+ });
437
+ uploadedImages.push({
438
+ mountPath: `/user-attachments/${index}-${baseName}`,
439
+ label: baseName,
440
+ fileId: uploaded.id,
441
+ placement: att.placement
442
+ });
443
+ }
444
+ catch (err) {
445
+ const message = err.message;
446
+ const ref = att.path || att.url || att.filename || `#${index}`;
447
+ imageWarnings.push(`Attachment ${index} (${ref}) could not be attached: ${message}`);
448
+ logTask({ tool: name, status: "attachment_failed", index, ref, message });
449
+ }
450
+ }
451
+ logTask({
452
+ tool: name,
453
+ status: "user_attachments_attached",
454
+ attached: uploadedImages.map((img) => img.mountPath),
455
+ warnings: imageWarnings
456
+ });
457
+ }
255
458
  const session = await client.beta.sessions.create({
256
459
  agent: agentId,
257
460
  environment_id: environmentId,
258
- ...(attachedAssets.length > 0
461
+ ...(attachedAssets.length > 0 || uploadedImages.length > 0
259
462
  ? {
260
- resources: attachedAssets.map((a) => ({
261
- type: "file",
262
- file_id: a.fileId,
263
- mount_path: a.mountPath
264
- }))
463
+ resources: [
464
+ ...attachedAssets.map((a) => ({
465
+ type: "file",
466
+ file_id: a.fileId,
467
+ mount_path: a.mountPath
468
+ })),
469
+ ...uploadedImages.map((img) => ({
470
+ type: "file",
471
+ file_id: img.fileId,
472
+ mount_path: img.mountPath
473
+ }))
474
+ ]
265
475
  }
266
476
  : {})
267
477
  });
@@ -276,9 +486,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
276
486
  const assetsNote = attachedAssets.length > 0
277
487
  ? `## Attached brand assets\nThese files are mounted in your sandbox:\n${attachedAssets
278
488
  .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 six slide archetypes it defines. Place the correct logo variant per slide background (full-color logo on white/light backgrounds, the reversed white logo on brand-blue 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`
489
+ .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
+ : "";
491
+ const userImagesNote = uploadedImages.length > 0
492
+ ? `## 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
493
+ .map((img) => `- /mnt/session/uploads${img.mountPath} (${img.label}) - placement/use: ${img.placement || "not specified - use your judgement"}`)
494
+ .join("\n")}\n\n---\n\n`
280
495
  : "";
281
- const messageText = `Build a Traffic Builders PowerPoint from the following outline.\n\n${assetsNote}${meta}## Outline\n${outline}`;
496
+ const messageText = `Build a Traffic Builders PowerPoint from the following outline.\n\n${assetsNote}${userImagesNote}${meta}## Outline\n${outline}`;
282
497
  await client.beta.sessions.events.send(session.id, {
283
498
  events: [
284
499
  { type: "user.message", content: [{ type: "text", text: messageText }] }
@@ -289,6 +504,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
289
504
  status: "success",
290
505
  sessionId: session.id,
291
506
  assetsAttached: attachedAssets.length,
507
+ attachmentsAttached: uploadedImages.length,
508
+ imageWarnings,
292
509
  durationMs: Date.now() - startedAt
293
510
  });
294
511
  return {
@@ -297,6 +514,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
297
514
  text: JSON.stringify({
298
515
  success: true,
299
516
  sessionId: session.id,
517
+ attachmentsAttached: uploadedImages.length,
518
+ ...(imageWarnings.length > 0 ? { imageWarnings } : {}),
300
519
  message: "Generation started. Call get_tb_powerpoint_result with this session id later to check on it and fetch the finished file."
301
520
  }, null, 2)
302
521
  }]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboundai/tb-ppt-mgagent",
3
- "version": "0.4.0",
3
+ "version": "0.6.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": {