@unboundai/tb-ppt-mgagent 0.5.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.
- package/README.md +186 -141
- package/build/index.js +231 -12
- 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
|
-
##
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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:
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
});
|
|
@@ -278,7 +488,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
278
488
|
.map((a) => `- /mnt/session/uploads${a.mountPath} - ${a.label}`)
|
|
279
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`
|
|
280
490
|
: "";
|
|
281
|
-
const
|
|
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`
|
|
495
|
+
: "";
|
|
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.
|
|
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": {
|