@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.
- package/README.md +52 -6
- package/build/index.js +236 -16
- 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
|
|
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
|
|
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
|
-
- `
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
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.
|
|
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": {
|