@tday-com/mcp 0.0.0-stage → 0.3.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/LICENSE +36 -0
- package/README.md +124 -2
- package/dist/api-client.js +170 -0
- package/dist/cli.js +75 -0
- package/dist/config.js +62 -0
- package/dist/download-safety.js +73 -0
- package/dist/installer-agents.js +199 -0
- package/dist/installer-callback.js +84 -0
- package/dist/installer-slash-command.js +321 -0
- package/dist/installer.js +150 -0
- package/dist/package-info.js +23 -0
- package/dist/prompts.js +36 -0
- package/dist/result-format.js +130 -0
- package/dist/server.js +43 -0
- package/dist/tool-schemas.js +175 -0
- package/dist/tools-account.js +30 -0
- package/dist/tools-features.js +39 -0
- package/dist/tools-launch.js +358 -0
- package/dist/tools.js +275 -0
- package/package.json +48 -4
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open, rm } from "node:fs/promises";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { api } from "./api-client.js";
|
|
5
|
+
import { prepareOutputFilePath, validateImageUrl } from "./download-safety.js";
|
|
6
|
+
import { argsObject, readOnly, safeIdSchema, safePath, writeLocal, writeRemote, } from "./tool-schemas.js";
|
|
7
|
+
const MAX_TITLE_CHARS = 160;
|
|
8
|
+
const MAX_DESCRIPTION_CHARS = 4000;
|
|
9
|
+
const MAX_DIRECTION_CHARS = 600;
|
|
10
|
+
export const MAX_VIDEO_BYTES = 250 * 1024 * 1024;
|
|
11
|
+
const MUSIC_MOODS = ["Cinematic", "Upbeat", "Minimal", "Warm", "Driving"];
|
|
12
|
+
const NARRATION_STYLES = ["talk", "thread", "story", "duo", "team", "launch", "doc"];
|
|
13
|
+
const STYLES = "talk (one person talking to one viewer who has the problem, the default); thread (says how many updates there are, then goes through each by name, the default for a product update); " +
|
|
14
|
+
"story (a day in the life of one person in your audience); duo (two voices: the viewer's inner voice and a narrator); team (from the team who built it); launch (quick and bright); doc (a calm, third-person documentary)";
|
|
15
|
+
const VOICE_NAMES = ["Reece", "Ivy", "Janet", "Sarah", "Craig"];
|
|
16
|
+
const VOICES = "Reece, warm conversational narrator; Ivy, bright friendly presenter; Janet, warm expressive explainer; " +
|
|
17
|
+
"Sarah, upbeat lively presenter; Craig, deep polished broadcast voice";
|
|
18
|
+
const POLL_GUIDANCE = "Poll tday_poll_launch_video with this videoId every 20-30 seconds until it is ready.";
|
|
19
|
+
const VOICEOVER_NOT_INCLUDED = "Voiceover is not included on this organisation's plan, so the film is silent and no voice was charged.";
|
|
20
|
+
const videoIdSchema = safeIdSchema.describe("videoId returned by tday_create_launch_video");
|
|
21
|
+
const outputSchemas = {
|
|
22
|
+
create: z
|
|
23
|
+
.object({ videoId: z.string(), status: z.string(), narration: z.string(), reviewUrl: z.string() })
|
|
24
|
+
.passthrough(),
|
|
25
|
+
poll: z
|
|
26
|
+
.object({ videoId: z.string(), status: z.string(), state: z.string(), terminal: z.boolean(), reviewUrl: z.string() })
|
|
27
|
+
.passthrough(),
|
|
28
|
+
mp4: z
|
|
29
|
+
.object({
|
|
30
|
+
videoId: z.string(),
|
|
31
|
+
versionId: z.string(),
|
|
32
|
+
fps: z.number(),
|
|
33
|
+
state: z.enum(["ready", "rendering", "failed"]),
|
|
34
|
+
mp4Url: z.string().optional(),
|
|
35
|
+
progress: z.number().nullable().optional(),
|
|
36
|
+
saved: z.boolean().optional(),
|
|
37
|
+
path: z.string().optional(),
|
|
38
|
+
size: z.number().optional(),
|
|
39
|
+
})
|
|
40
|
+
.passthrough(),
|
|
41
|
+
revise: z.object({ videoId: z.string(), status: z.string(), reviewUrl: z.string() }).passthrough(),
|
|
42
|
+
};
|
|
43
|
+
const FIRST_EXPORT = "The first request for a cut at a frame rate renders its file, which usually takes a minute or two";
|
|
44
|
+
export const launchTools = [
|
|
45
|
+
{
|
|
46
|
+
name: "tday_create_launch_video",
|
|
47
|
+
title: "Create launch video",
|
|
48
|
+
description: "Make a launch video: a short animated film announcing a feature or product, in the brand's own look. tday's pipeline writes the brief, storyboard, motion, music and narration from the title and description; describe the subject well and pick the options. " +
|
|
49
|
+
"For a feature tday already has (tday_list_features), pass its featureId instead of brandId, title and description: the film is made of that feature, with what tday knows about it. " +
|
|
50
|
+
"It spends credits from the brand's organisation: a standard (20s), scored, silent film is 40 credits, and a voiceover or another length changes that (tday_get_credit_prices has the full list). " +
|
|
51
|
+
"A 402 means the balance cannot cover it: nothing was started, so do not retry. Returns a videoId and a reviewUrl at once; the film takes about 2-5 minutes, so poll tday_poll_launch_video.",
|
|
52
|
+
inputSchema: argsObject({
|
|
53
|
+
featureId: safeIdSchema
|
|
54
|
+
.optional()
|
|
55
|
+
.describe("A feature's id from tday_list_features: the film is made of it, and brandId, title and description are not needed."),
|
|
56
|
+
brandId: safeIdSchema
|
|
57
|
+
.optional()
|
|
58
|
+
.describe("Brand ID from tday_list_brands, for a new subject (without featureId). The film uses this brand's colours, fonts and logo."),
|
|
59
|
+
title: z
|
|
60
|
+
.string()
|
|
61
|
+
.trim()
|
|
62
|
+
.min(1)
|
|
63
|
+
.max(MAX_TITLE_CHARS)
|
|
64
|
+
.optional()
|
|
65
|
+
.describe("The feature or product the film launches, as a short name (without featureId)."),
|
|
66
|
+
description: z
|
|
67
|
+
.string()
|
|
68
|
+
.trim()
|
|
69
|
+
.min(1)
|
|
70
|
+
.max(MAX_DESCRIPTION_CHARS)
|
|
71
|
+
.optional()
|
|
72
|
+
.describe("What the feature or product does and who it is for (without featureId). The film's brief is written from this, so be specific."),
|
|
73
|
+
length: z
|
|
74
|
+
.enum(["short", "standard", "long"])
|
|
75
|
+
.default("standard")
|
|
76
|
+
.describe("short (about 10s), standard (about 20s, the default) or long (about 50s)."),
|
|
77
|
+
narration: z
|
|
78
|
+
.enum(["silent", "voiceover"])
|
|
79
|
+
.default("silent")
|
|
80
|
+
.describe("silent (music and motion carry it, the default) or voiceover (narrated, with captions; costs more)."),
|
|
81
|
+
music: z.enum(["score", "none"]).default("score").describe("score (a bed written to the film's length, the default) or none."),
|
|
82
|
+
musicMood: z.enum(MUSIC_MOODS).optional().describe("A mood for the score. Omit to let the planner choose."),
|
|
83
|
+
voice: z.enum(VOICE_NAMES).optional().describe(`The narrator of a voiceover film. Omit for the default. One of: ${VOICES}.`),
|
|
84
|
+
narrationStyle: z.enum(NARRATION_STYLES).optional().describe(`How a voiceover film tells its story. Omit for the default. One of: ${STYLES}.`),
|
|
85
|
+
direction: z
|
|
86
|
+
.string()
|
|
87
|
+
.trim()
|
|
88
|
+
.min(1)
|
|
89
|
+
.max(MAX_DIRECTION_CHARS)
|
|
90
|
+
.optional()
|
|
91
|
+
.describe("Optional creative notes in plain words, for example 'lead on the scrubbing, end on the call to action'."),
|
|
92
|
+
}),
|
|
93
|
+
outputSchema: outputSchemas.create,
|
|
94
|
+
annotations: writeRemote,
|
|
95
|
+
handler: async (args, config) => {
|
|
96
|
+
const existing = typeof args.featureId === "string" ? args.featureId : null;
|
|
97
|
+
const featureId = existing ?? (await saveSubject(args, config));
|
|
98
|
+
const created = await api(config, safePath("/api/showcase/features/:id/create-launch-video", { id: featureId }), { method: "POST", body: JSON.stringify(launchAxes(args)) }).catch((error) => {
|
|
99
|
+
if (existing)
|
|
100
|
+
throw error;
|
|
101
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)} ${subjectSaved(args.title)}`);
|
|
102
|
+
});
|
|
103
|
+
const videoId = created?.runFeatureId;
|
|
104
|
+
if (typeof videoId !== "string")
|
|
105
|
+
throw new Error("tday could not start that film. Try again in a moment.");
|
|
106
|
+
const narration = created?.narration === "voiceover" || created?.narration === "silent" ? created.narration : args.narration;
|
|
107
|
+
return {
|
|
108
|
+
videoId,
|
|
109
|
+
status: "queued",
|
|
110
|
+
length: args.length,
|
|
111
|
+
narration,
|
|
112
|
+
music: args.music,
|
|
113
|
+
reviewUrl: reviewUrl(config, videoId),
|
|
114
|
+
...(args.narration === "voiceover" && narration === "silent" ? { note: VOICEOVER_NOT_INCLUDED } : {}),
|
|
115
|
+
next: `A film takes about 2-5 minutes. ${POLL_GUIDANCE}`,
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "tday_poll_launch_video",
|
|
121
|
+
title: "Poll launch video",
|
|
122
|
+
description: "Check a launch video made with tday_create_launch_video or revised with tday_revise_launch_video. state is in_progress, ready or failed; terminal is true once it is ready or failed. " +
|
|
123
|
+
"Poll every 20-30 seconds while it is in progress. When it is ready, watch it at reviewUrl or call tday_get_launch_video_mp4 for the MP4 file. Spends no credits.",
|
|
124
|
+
inputSchema: argsObject({ videoId: videoIdSchema }),
|
|
125
|
+
outputSchema: outputSchemas.poll,
|
|
126
|
+
annotations: readOnly,
|
|
127
|
+
handler: async (args, config) => {
|
|
128
|
+
const videoId = String(args.videoId);
|
|
129
|
+
const payload = await api(config, safePath("/api/showcase/videos/:videoId", args));
|
|
130
|
+
return summarizeLaunchVideo(videoId, payload ?? {}, reviewUrl(config, videoId));
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "tday_get_launch_video_mp4",
|
|
135
|
+
title: "Get launch video MP4",
|
|
136
|
+
description: "Get a ready launch video as an MP4 file URL. A launch video is ready before its file: " + FIRST_EXPORT + ", so this answers state rendering " +
|
|
137
|
+
"(with progress) until the file exists - call it again every 20-30 seconds until state is ready. Later requests return the stored file at once. Spends no credits. " +
|
|
138
|
+
"If state is failed, call it again with retry: true. versionId defaults to the newest ready cut (readyVersionId from tday_poll_launch_video). " +
|
|
139
|
+
`Pass download: true to also save it inside the current working directory once it is ready (at most ${MAX_VIDEO_BYTES / 1024 / 1024}MB).`,
|
|
140
|
+
inputSchema: argsObject({
|
|
141
|
+
videoId: videoIdSchema,
|
|
142
|
+
versionId: safeIdSchema.optional().describe("A cut's versionId from tday_poll_launch_video. Omit for the newest ready cut."),
|
|
143
|
+
fps: z.union([z.literal(60), z.literal(30)]).default(60).describe("The file's frame rate: 60 (the default, smoothest) or 30 (a smaller file). Older cuts that cannot be rendered again offer only 60 and answer 409 for 30."),
|
|
144
|
+
retry: z.boolean().default(false).describe("Ask again after state failed. Defaults to false."),
|
|
145
|
+
download: z.boolean().default(false).describe("Also save the MP4 into the current working directory. Defaults to false."),
|
|
146
|
+
filePath: z
|
|
147
|
+
.string()
|
|
148
|
+
.trim()
|
|
149
|
+
.min(1)
|
|
150
|
+
.max(500)
|
|
151
|
+
.optional()
|
|
152
|
+
.describe("Where to save it, relative to cwd. Defaults to ./tday-launch-videos/<videoId>-<versionId>.mp4 (<videoId>-<versionId>-30fps.mp4 at 30 fps, so both rates can sit side by side). Only used with download."),
|
|
153
|
+
overwrite: z.boolean().default(false).describe("Whether to overwrite an existing regular file. Defaults to false for safety."),
|
|
154
|
+
}),
|
|
155
|
+
outputSchema: outputSchemas.mp4,
|
|
156
|
+
annotations: writeLocal,
|
|
157
|
+
handler: async (args, config) => {
|
|
158
|
+
const videoId = String(args.videoId);
|
|
159
|
+
const versionId = typeof args.versionId === "string" ? args.versionId : await newestReadyVersion(config, videoId);
|
|
160
|
+
const fps = args.fps === 30 ? 30 : 60;
|
|
161
|
+
const answer = await api(config, safePath("/api/showcase/videos/:videoId/render", args), {
|
|
162
|
+
method: "POST",
|
|
163
|
+
body: JSON.stringify({ versionId, ...(fps === 30 ? { fps } : {}), ...(args.retry ? { retry: true } : {}) }),
|
|
164
|
+
});
|
|
165
|
+
const waiting = exportWaiting(answer, { videoId, versionId, fps, reviewUrl: reviewUrl(config, videoId) });
|
|
166
|
+
if (waiting)
|
|
167
|
+
return waiting;
|
|
168
|
+
const mp4Url = answer?.mp4Url;
|
|
169
|
+
if (typeof mp4Url !== "string")
|
|
170
|
+
throw new Error("tday could not prepare the video. Try again in a moment.");
|
|
171
|
+
const film = { videoId, versionId, fps, state: "ready", mp4Url, reviewUrl: reviewUrl(config, videoId) };
|
|
172
|
+
if (!args.download)
|
|
173
|
+
return film;
|
|
174
|
+
const filePath = typeof args.filePath === "string" ? args.filePath : `./tday-launch-videos/${videoId}-${versionId}${fps === 30 ? "-30fps" : ""}.mp4`;
|
|
175
|
+
return { ...film, saved: true, ...(await saveVideo(mp4Url, filePath, Boolean(args.overwrite))) };
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: "tday_revise_launch_video",
|
|
180
|
+
title: "Revise launch video",
|
|
181
|
+
description: "Make a new cut of a ready launch video from notes, for example 'slow the ending down' or 'lead with the search box'. The kept film is edited to the notes, so the rest stays as it was. " +
|
|
182
|
+
"It spends tweak credits from the brand's organisation (13 credits for a standard film; tday_get_credit_prices has the full list), and a 402 means the balance cannot cover it. " +
|
|
183
|
+
"A 409 means a cut is already being made: poll until it finishes. The previous cut stays available while the new one is made. " + POLL_GUIDANCE,
|
|
184
|
+
inputSchema: argsObject({
|
|
185
|
+
videoId: videoIdSchema,
|
|
186
|
+
notes: z.string().trim().min(1).max(MAX_DIRECTION_CHARS).describe("What to change, in plain words."),
|
|
187
|
+
}),
|
|
188
|
+
outputSchema: outputSchemas.revise,
|
|
189
|
+
annotations: writeRemote,
|
|
190
|
+
handler: async (args, config) => {
|
|
191
|
+
const videoId = String(args.videoId);
|
|
192
|
+
const cut = await api(config, safePath("/api/showcase/videos/:videoId/regenerate", args), {
|
|
193
|
+
method: "POST",
|
|
194
|
+
body: JSON.stringify({ instructions: args.notes }),
|
|
195
|
+
});
|
|
196
|
+
return {
|
|
197
|
+
videoId,
|
|
198
|
+
...(typeof cut?.versionId === "string" ? { versionId: cut.versionId } : {}),
|
|
199
|
+
status: "queued",
|
|
200
|
+
reviewUrl: reviewUrl(config, videoId),
|
|
201
|
+
next: `A new cut takes a few minutes. ${POLL_GUIDANCE}`,
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
];
|
|
206
|
+
async function saveSubject(args, config) {
|
|
207
|
+
if (typeof args.brandId !== "string" || typeof args.title !== "string" || typeof args.description !== "string") {
|
|
208
|
+
throw new Error("Pass featureId (from tday_list_features), or brandId with a title and description for a new subject.");
|
|
209
|
+
}
|
|
210
|
+
const saved = await api(config, "/api/showcase/features", {
|
|
211
|
+
method: "POST",
|
|
212
|
+
body: JSON.stringify({ brandId: args.brandId, title: args.title, description: args.description, typeId: null }),
|
|
213
|
+
});
|
|
214
|
+
const featureId = saved?.feature?.id;
|
|
215
|
+
if (typeof featureId !== "string")
|
|
216
|
+
throw new Error("tday could not start that film. Try again in a moment.");
|
|
217
|
+
return featureId;
|
|
218
|
+
}
|
|
219
|
+
function subjectSaved(title) {
|
|
220
|
+
return `The subject "${String(title)}" was saved on the brand's Launch page, where a film can be made from it later.`;
|
|
221
|
+
}
|
|
222
|
+
function launchAxes(args) {
|
|
223
|
+
return {
|
|
224
|
+
length: args.length,
|
|
225
|
+
narration: args.narration,
|
|
226
|
+
music: args.music,
|
|
227
|
+
...(args.musicMood ? { musicMood: args.musicMood } : {}),
|
|
228
|
+
...(args.voice ? { voiceId: args.voice } : {}),
|
|
229
|
+
...(args.narrationStyle ? { narrationStyle: args.narrationStyle } : {}),
|
|
230
|
+
...(args.direction ? { direction: args.direction } : {}),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function reviewUrl(config, videoId) {
|
|
234
|
+
return `${config.webBaseUrl}/showcase/review/${encodeURIComponent(videoId)}`;
|
|
235
|
+
}
|
|
236
|
+
async function newestReadyVersion(config, videoId) {
|
|
237
|
+
const payload = await api(config, safePath("/api/showcase/videos/:videoId", { videoId }));
|
|
238
|
+
const versionId = payload?.video?.versionId;
|
|
239
|
+
if (typeof versionId !== "string") {
|
|
240
|
+
throw new Error("This launch video has no finished cut yet. Poll tday_poll_launch_video until it is ready, then try again.");
|
|
241
|
+
}
|
|
242
|
+
return versionId;
|
|
243
|
+
}
|
|
244
|
+
function summarizeLaunchVideo(videoId, payload, url) {
|
|
245
|
+
const status = stringOr(payload.feature?.status, "unknown");
|
|
246
|
+
const first = Array.isArray(payload.versions) ? payload.versions[0] : undefined;
|
|
247
|
+
const latest = typeof first === "object" && first !== null ? first : null;
|
|
248
|
+
const latestStatus = stringOr(latest?.status, null);
|
|
249
|
+
const readyVersionId = stringOr(payload.video?.versionId, null);
|
|
250
|
+
const state = launchState(status, latestStatus);
|
|
251
|
+
const failureMessage = state === "failed" ? stringOr(latest?.failureMessage, null) : null;
|
|
252
|
+
const durationMs = typeof payload.video?.durationMs === "number" ? payload.video.durationMs : null;
|
|
253
|
+
return {
|
|
254
|
+
videoId,
|
|
255
|
+
...(typeof payload.feature?.title === "string" ? { title: payload.feature.title } : {}),
|
|
256
|
+
status,
|
|
257
|
+
state,
|
|
258
|
+
terminal: state !== "in_progress",
|
|
259
|
+
latestVersion: latest
|
|
260
|
+
? { versionId: stringOr(latest.versionId, null), versionNumber: latest.versionNumber ?? null, status: latestStatus }
|
|
261
|
+
: null,
|
|
262
|
+
...(readyVersionId ? { readyVersionId } : {}),
|
|
263
|
+
...(durationMs !== null ? { durationMs } : {}),
|
|
264
|
+
...(failureMessage ? { failureMessage } : {}),
|
|
265
|
+
reviewUrl: url,
|
|
266
|
+
next: nextStep(state, readyVersionId),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function exportWaiting(answer, film) {
|
|
270
|
+
if (answer?.status === "rendering" || answer?.status === "none") {
|
|
271
|
+
const progress = typeof answer.progress === "number" ? answer.progress : null;
|
|
272
|
+
return {
|
|
273
|
+
...film,
|
|
274
|
+
state: "rendering",
|
|
275
|
+
progress,
|
|
276
|
+
next: `The file is being rendered${progress === null ? "" : ` (${Math.round(progress * 100)}% done)`}. ${FIRST_EXPORT}. Call tday_get_launch_video_mp4 again in 20-30 seconds.`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
if (answer?.status === "failed") {
|
|
280
|
+
return {
|
|
281
|
+
...film,
|
|
282
|
+
state: "failed",
|
|
283
|
+
error: typeof answer.error === "string" ? answer.error : "The file could not be rendered.",
|
|
284
|
+
next: "The file could not be rendered and spent no credits. Call tday_get_launch_video_mp4 again with retry: true to try again.",
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
function launchState(featureStatus, latestStatus) {
|
|
290
|
+
if (latestStatus === "ready")
|
|
291
|
+
return "ready";
|
|
292
|
+
if (latestStatus === "failed" || featureStatus === "failed")
|
|
293
|
+
return "failed";
|
|
294
|
+
if (!latestStatus && (featureStatus === "review" || featureStatus === "published"))
|
|
295
|
+
return "ready";
|
|
296
|
+
return "in_progress";
|
|
297
|
+
}
|
|
298
|
+
function nextStep(state, readyVersionId) {
|
|
299
|
+
if (state === "ready") {
|
|
300
|
+
return "Ready. Watch it at reviewUrl, call tday_get_launch_video_mp4 for the MP4 file (its first request renders the file, usually a minute or two), or tday_revise_launch_video to change it.";
|
|
301
|
+
}
|
|
302
|
+
if (state === "in_progress")
|
|
303
|
+
return "Still being made. Poll again in 20-30 seconds.";
|
|
304
|
+
return readyVersionId
|
|
305
|
+
? "This cut failed and was not charged. The previous cut is still available: tday_get_launch_video_mp4 returns it, and tday_revise_launch_video can try the change again."
|
|
306
|
+
: "This film failed and was not charged. Start a new one with tday_create_launch_video.";
|
|
307
|
+
}
|
|
308
|
+
function stringOr(value, fallback) {
|
|
309
|
+
return typeof value === "string" && value ? value : fallback;
|
|
310
|
+
}
|
|
311
|
+
async function saveVideo(mp4Url, rawPath, overwrite) {
|
|
312
|
+
const url = validateImageUrl(mp4Url);
|
|
313
|
+
const filePath = await prepareOutputFilePath(rawPath, { overwrite });
|
|
314
|
+
const res = await fetch(url.href);
|
|
315
|
+
if (res.url)
|
|
316
|
+
validateImageUrl(res.url);
|
|
317
|
+
if (!res.ok)
|
|
318
|
+
throw new Error(`Failed to download the video: ${res.status} ${res.statusText}`);
|
|
319
|
+
const declared = Number(res.headers.get("content-length") ?? 0);
|
|
320
|
+
if (Number.isFinite(declared) && declared > MAX_VIDEO_BYTES) {
|
|
321
|
+
throw new Error(`The video exceeds the ${MAX_VIDEO_BYTES / 1024 / 1024}MB download limit. Use mp4Url instead.`);
|
|
322
|
+
}
|
|
323
|
+
const contentType = res.headers.get("content-type")?.toLowerCase();
|
|
324
|
+
if (contentType && !/^(video\/|application\/octet-stream|binary\/octet-stream)/.test(contentType)) {
|
|
325
|
+
throw new Error(`The download is not a video (content-type: ${contentType}).`);
|
|
326
|
+
}
|
|
327
|
+
const reader = res.body?.getReader();
|
|
328
|
+
if (!reader)
|
|
329
|
+
throw new Error("No response body from the video URL.");
|
|
330
|
+
const flags = constants.O_WRONLY |
|
|
331
|
+
constants.O_CREAT |
|
|
332
|
+
(overwrite ? constants.O_TRUNC : constants.O_EXCL) |
|
|
333
|
+
(constants.O_NOFOLLOW ?? 0);
|
|
334
|
+
const handle = await open(filePath, flags, 0o600);
|
|
335
|
+
let size = 0;
|
|
336
|
+
try {
|
|
337
|
+
for (;;) {
|
|
338
|
+
const { done, value } = await reader.read();
|
|
339
|
+
if (done)
|
|
340
|
+
break;
|
|
341
|
+
size += value.byteLength;
|
|
342
|
+
if (size > MAX_VIDEO_BYTES) {
|
|
343
|
+
throw new Error(`The video exceeds the ${MAX_VIDEO_BYTES / 1024 / 1024}MB download limit. Use mp4Url instead.`);
|
|
344
|
+
}
|
|
345
|
+
await handle.write(value);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
await handle.close();
|
|
350
|
+
await rm(filePath, { force: true });
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
reader.releaseLock();
|
|
355
|
+
}
|
|
356
|
+
await handle.close();
|
|
357
|
+
return { path: filePath, size };
|
|
358
|
+
}
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { api, firstImageUrl, normalizeWebsite, query, } from "./api-client.js";
|
|
5
|
+
import { MAX_IMAGE_BYTES, prepareOutputFilePath, validateImageUrl, } from "./download-safety.js";
|
|
6
|
+
import { argsObject, emptyArgsSchema, listArgsSchema, outputSchemas, readOnly, safeIdSchema, safePath, stripPresentationArgs, writeLocal, writeRemote, } from "./tool-schemas.js";
|
|
7
|
+
import { accountTools, imageFormatSchema } from "./tools-account.js";
|
|
8
|
+
import { launchTools } from "./tools-launch.js";
|
|
9
|
+
import { featureTools } from "./tools-features.js";
|
|
10
|
+
const PLATFORM_BADGE_NOTE = 'This image carries a "Made with tday.com" badge because the organisation is on the Free plan. The owner can remove it by upgrading (tday_whoami has the link).';
|
|
11
|
+
export { responseFormatFromArgs, safePath } from "./tool-schemas.js";
|
|
12
|
+
export { ALLOWED_IMAGE_HOSTS, MAX_IMAGE_BYTES, prepareOutputFilePath, sandboxFilePath, validateImageUrl, } from "./download-safety.js";
|
|
13
|
+
export { formatToolResultText, stringifyResult, toStructuredContent, } from "./result-format.js";
|
|
14
|
+
export const tools = [
|
|
15
|
+
{
|
|
16
|
+
name: "tday_whoami",
|
|
17
|
+
title: "Who am I?",
|
|
18
|
+
description: "Get the linked tday account: every organisation the person belongs to, their role in it (owner, admin or member), its plan, its credit balance and when it refreshes, what the plan's output includes (sizes, the Free badge, voiceover), what upgrading would change, its brands, and links to its usage and billing pages. Each organisation has a `summary` sentence to show the person as it is. A design or launch video spends the credits of its BRAND's organisation, so check the organisation that owns the brand before quoting a balance. Only an owner can change a plan; for an admin or member, say who the owner is instead of telling them to upgrade. Use first to verify MCP is connected to the expected account.",
|
|
19
|
+
inputSchema: emptyArgsSchema,
|
|
20
|
+
outputSchema: outputSchemas.whoami,
|
|
21
|
+
annotations: readOnly,
|
|
22
|
+
handler: (_args, config) => api(config, "/api/v1/account"),
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "tday_list_brands",
|
|
26
|
+
title: "List brands",
|
|
27
|
+
description: "List brands accessible to the authenticated user as lightweight summaries. Supports API-backed limit/offset pagination.",
|
|
28
|
+
inputSchema: listArgsSchema,
|
|
29
|
+
outputSchema: outputSchemas.listBrands,
|
|
30
|
+
annotations: readOnly,
|
|
31
|
+
handler: (args, config) => api(config, `/api/brands/summary${query({ limit: Number(args.limit), offset: Number(args.offset) })}`),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "tday_get_brand",
|
|
35
|
+
title: "Get brand",
|
|
36
|
+
description: "Fetch a single brand with its colors, logos, fonts, images, and knowledge base. Use after tday_list_brands when detailed brand context is needed.",
|
|
37
|
+
inputSchema: argsObject({
|
|
38
|
+
brandId: safeIdSchema.describe("Brand ID from tday"),
|
|
39
|
+
}),
|
|
40
|
+
outputSchema: outputSchemas.getBrand,
|
|
41
|
+
annotations: readOnly,
|
|
42
|
+
handler: (args, config) => api(config, safePath("/api/brands/:brandId", args)),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "tday_list_designs",
|
|
46
|
+
title: "List designs",
|
|
47
|
+
description: "List the authenticated user's designs as lightweight summaries. Supports API-backed limit/offset pagination.",
|
|
48
|
+
inputSchema: listArgsSchema,
|
|
49
|
+
outputSchema: outputSchemas.listDesigns,
|
|
50
|
+
annotations: readOnly,
|
|
51
|
+
handler: (args, config) => api(config, `/api/designs/summary${query({ limit: Number(args.limit), offset: Number(args.offset) })}`),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "tday_get_design",
|
|
55
|
+
title: "Get design",
|
|
56
|
+
description: "Fetch a single design by id, including its generations. Use after tday_list_designs when generation IDs or full design details are needed.",
|
|
57
|
+
inputSchema: argsObject({
|
|
58
|
+
designId: safeIdSchema.describe("Design ID from tday"),
|
|
59
|
+
}),
|
|
60
|
+
outputSchema: outputSchemas.getDesign,
|
|
61
|
+
annotations: readOnly,
|
|
62
|
+
handler: (args, config) => api(config, safePath("/api/designs/:designId", args)),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "tday_generate_design",
|
|
66
|
+
title: "Generate design",
|
|
67
|
+
description: "Generate a new tday design from a text prompt against a brand, then poll with tday_poll_generation until it is completed or failed. An image costs 10 credits from the brand's organisation (tday_get_credit_prices has every price; tday_whoami has the balance) and a failed generation gives them back. The response says which organisation paid and what it has left; a refusal for credits means nothing was started, so do not retry. Omit format for square (1080x1080), or landscape (1200x630) where the plan does not include square.",
|
|
68
|
+
inputSchema: argsObject({
|
|
69
|
+
prompt: z
|
|
70
|
+
.string()
|
|
71
|
+
.trim()
|
|
72
|
+
.min(1)
|
|
73
|
+
.max(5000)
|
|
74
|
+
.describe("Design prompt. Be specific about audience, format, copy, and visual direction."),
|
|
75
|
+
brandId: safeIdSchema.describe("Brand ID from tday"),
|
|
76
|
+
name: z.string().trim().min(1).max(200).optional().describe("Short design name"),
|
|
77
|
+
format: imageFormatSchema,
|
|
78
|
+
}),
|
|
79
|
+
outputSchema: outputSchemas.generate,
|
|
80
|
+
annotations: writeRemote,
|
|
81
|
+
handler: (args, config) => api(config, "/api/v1/generate", {
|
|
82
|
+
method: "POST",
|
|
83
|
+
body: JSON.stringify(stripPresentationArgs(args)),
|
|
84
|
+
}),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "tday_poll_generation",
|
|
88
|
+
title: "Poll generation",
|
|
89
|
+
description: "Poll a design generation. Returns status (pending/ideating/generating/visualising/reviewing/refining/completed/failed), editorUrl, imageUrl when completed, and platformBadge when the image carries the Free plan's badge. Call repeatedly until status is completed or failed.",
|
|
90
|
+
inputSchema: argsObject({
|
|
91
|
+
designId: safeIdSchema.describe("Design ID from tday"),
|
|
92
|
+
generationId: safeIdSchema.describe("Generation ID from tday"),
|
|
93
|
+
}),
|
|
94
|
+
outputSchema: outputSchemas.poll,
|
|
95
|
+
annotations: { ...readOnly, idempotentHint: true },
|
|
96
|
+
handler: async (args, config) => {
|
|
97
|
+
const path = safePath("/api/designs/:designId/generate/:generationId/status?poll=1", args);
|
|
98
|
+
const data = await api(config, path);
|
|
99
|
+
const generation = data.complete?.generation;
|
|
100
|
+
const status = data.status ?? generation?.status ?? "unknown";
|
|
101
|
+
const imageUrl = firstImageUrl(generation);
|
|
102
|
+
return {
|
|
103
|
+
designId: args.designId,
|
|
104
|
+
generationId: args.generationId,
|
|
105
|
+
status,
|
|
106
|
+
editorUrl: `${config.webBaseUrl}/designs/${args.designId}`,
|
|
107
|
+
...(imageUrl ? { imageUrl } : {}),
|
|
108
|
+
...(generation?.completedAt ? { completedAt: generation.completedAt } : {}),
|
|
109
|
+
...(data.platformBadge ? { platformBadge: true, badgeNote: PLATFORM_BADGE_NOTE } : {}),
|
|
110
|
+
...(status === "failed"
|
|
111
|
+
? { failureMessage: data.complete?.message ?? "Generation did not complete." }
|
|
112
|
+
: {}),
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "tday_create_brand",
|
|
118
|
+
title: "Create brand",
|
|
119
|
+
description: "Create a new brand from a website URL. Extracts colors, fonts, and logos automatically from the site. You can also pass a name and description.",
|
|
120
|
+
inputSchema: argsObject({
|
|
121
|
+
website: z
|
|
122
|
+
.string()
|
|
123
|
+
.trim()
|
|
124
|
+
.min(1)
|
|
125
|
+
.max(2048)
|
|
126
|
+
.describe("Website URL to extract brand assets from, for example stripe.com or https://stripe.com"),
|
|
127
|
+
name: z.string().trim().min(1).max(200).optional().describe("Brand name, auto-detected from website if omitted"),
|
|
128
|
+
description: z.string().trim().min(1).max(1000).optional().describe("Short brand description"),
|
|
129
|
+
industry: z.string().trim().min(1).max(120).optional().describe("Industry category"),
|
|
130
|
+
}),
|
|
131
|
+
outputSchema: outputSchemas.createBrand,
|
|
132
|
+
annotations: writeRemote,
|
|
133
|
+
handler: (args, config) => {
|
|
134
|
+
const body = { website: normalizeWebsite(String(args.website)) };
|
|
135
|
+
if (args.name)
|
|
136
|
+
body.name = args.name;
|
|
137
|
+
if (args.description)
|
|
138
|
+
body.description = args.description;
|
|
139
|
+
if (args.industry)
|
|
140
|
+
body.industry = args.industry;
|
|
141
|
+
return api(config, "/api/brands", {
|
|
142
|
+
method: "POST",
|
|
143
|
+
body: JSON.stringify(body),
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "tday_download_image",
|
|
149
|
+
title: "Download image",
|
|
150
|
+
description: "Download a completed generated design image to a local file. Pass designId + generationId to save the exported file (what a download from tday gives you), or an imageUrl to save that exact image. Writes only inside the current working directory and caps downloads at 50MB.",
|
|
151
|
+
inputSchema: argsObject({
|
|
152
|
+
imageUrl: z
|
|
153
|
+
.string()
|
|
154
|
+
.trim()
|
|
155
|
+
.url()
|
|
156
|
+
.optional()
|
|
157
|
+
.describe("Completed generation image URL. Optional if designId and generationId are provided."),
|
|
158
|
+
designId: safeIdSchema.optional().describe("Design ID from tday. Optional if imageUrl is provided."),
|
|
159
|
+
generationId: safeIdSchema.optional().describe("Generation ID from tday. Optional if imageUrl is provided."),
|
|
160
|
+
filePath: z
|
|
161
|
+
.string()
|
|
162
|
+
.trim()
|
|
163
|
+
.min(1)
|
|
164
|
+
.max(500)
|
|
165
|
+
.describe("Local path to save the image, relative to cwd, for example ./tday-designs/poster.png"),
|
|
166
|
+
overwrite: z
|
|
167
|
+
.boolean()
|
|
168
|
+
.default(false)
|
|
169
|
+
.describe("Whether to overwrite an existing regular file. Defaults to false for safety."),
|
|
170
|
+
}),
|
|
171
|
+
outputSchema: outputSchemas.download,
|
|
172
|
+
annotations: writeLocal,
|
|
173
|
+
handler: async (args, config) => {
|
|
174
|
+
const hasUrl = Boolean(args.imageUrl);
|
|
175
|
+
const hasIds = Boolean(args.designId && args.generationId);
|
|
176
|
+
if (!hasUrl && !hasIds) {
|
|
177
|
+
throw new Error("Provide either imageUrl, or both designId and generationId.");
|
|
178
|
+
}
|
|
179
|
+
if ((args.designId && !args.generationId) || (!args.designId && args.generationId)) {
|
|
180
|
+
throw new Error("designId and generationId must be provided together.");
|
|
181
|
+
}
|
|
182
|
+
const imageUrl = hasIds
|
|
183
|
+
? await imageUrlForGeneration(args, config)
|
|
184
|
+
: String(args.imageUrl);
|
|
185
|
+
const url = validateImageUrl(imageUrl);
|
|
186
|
+
const filePath = await prepareOutputFilePath(String(args.filePath), {
|
|
187
|
+
overwrite: Boolean(args.overwrite),
|
|
188
|
+
});
|
|
189
|
+
const res = await fetch(url.href);
|
|
190
|
+
if (res.url)
|
|
191
|
+
validateImageUrl(res.url);
|
|
192
|
+
if (!res.ok) {
|
|
193
|
+
throw new Error(`Failed to download image: ${res.status} ${res.statusText}`);
|
|
194
|
+
}
|
|
195
|
+
const contentLength = Number(res.headers.get("content-length") ?? 0);
|
|
196
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_IMAGE_BYTES) {
|
|
197
|
+
throw new Error(`Image exceeds ${MAX_IMAGE_BYTES / 1024 / 1024}MB limit according to content-length. Download aborted.`);
|
|
198
|
+
}
|
|
199
|
+
const contentType = res.headers.get("content-type");
|
|
200
|
+
if (contentType &&
|
|
201
|
+
!contentType.toLowerCase().startsWith("image/") &&
|
|
202
|
+
!contentType.toLowerCase().startsWith("application/octet-stream")) {
|
|
203
|
+
throw new Error(`Download did not return an image (content-type: ${contentType})`);
|
|
204
|
+
}
|
|
205
|
+
const chunks = [];
|
|
206
|
+
let totalBytes = 0;
|
|
207
|
+
const reader = res.body?.getReader();
|
|
208
|
+
if (!reader) {
|
|
209
|
+
throw new Error("No response body from image URL");
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
for (;;) {
|
|
213
|
+
const { done, value } = await reader.read();
|
|
214
|
+
if (done)
|
|
215
|
+
break;
|
|
216
|
+
totalBytes += value.byteLength;
|
|
217
|
+
if (totalBytes > MAX_IMAGE_BYTES) {
|
|
218
|
+
throw new Error(`Image exceeds ${MAX_IMAGE_BYTES / 1024 / 1024}MB limit. Download aborted.`);
|
|
219
|
+
}
|
|
220
|
+
chunks.push(value);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
reader.releaseLock();
|
|
225
|
+
}
|
|
226
|
+
const buffer = Buffer.concat(chunks);
|
|
227
|
+
const flags = constants.O_WRONLY |
|
|
228
|
+
constants.O_CREAT |
|
|
229
|
+
(args.overwrite ? constants.O_TRUNC : constants.O_EXCL) |
|
|
230
|
+
(constants.O_NOFOLLOW ?? 0);
|
|
231
|
+
const handle = await open(filePath, flags, 0o600);
|
|
232
|
+
try {
|
|
233
|
+
await handle.writeFile(buffer);
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
await handle.close();
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
saved: true,
|
|
240
|
+
path: filePath,
|
|
241
|
+
size: buffer.byteLength,
|
|
242
|
+
};
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
...accountTools,
|
|
246
|
+
...launchTools,
|
|
247
|
+
...featureTools,
|
|
248
|
+
];
|
|
249
|
+
async function imageUrlForGeneration(args, config) {
|
|
250
|
+
const data = await api(config, safePath("/api/designs/:designId/generations/:generationId/export", args), { method: "POST", body: JSON.stringify({ scale: 1 }) });
|
|
251
|
+
const imageUrl = typeof data?.image?.url === "string" ? data.image.url : null;
|
|
252
|
+
if (!imageUrl) {
|
|
253
|
+
throw new Error("No image URL found for this generation. Poll tday_poll_generation until status is completed, then try again.");
|
|
254
|
+
}
|
|
255
|
+
return imageUrl;
|
|
256
|
+
}
|
|
257
|
+
function formatZodError(name, error) {
|
|
258
|
+
const details = error.issues
|
|
259
|
+
.map((issue) => {
|
|
260
|
+
const path = issue.path.length ? issue.path.join(".") : "arguments";
|
|
261
|
+
return `${path}: ${issue.message}`;
|
|
262
|
+
})
|
|
263
|
+
.join("; ");
|
|
264
|
+
return `Invalid arguments for ${name}: ${details}`;
|
|
265
|
+
}
|
|
266
|
+
export async function callTool(name, args, config, available = tools) {
|
|
267
|
+
const tool = available.find((t) => t.name === name);
|
|
268
|
+
if (!tool) {
|
|
269
|
+
throw new Error(`Unknown tool: ${name}. Available tools: ${available.map((t) => t.name).join(", ")}`);
|
|
270
|
+
}
|
|
271
|
+
const parsed = tool.inputSchema.safeParse(args);
|
|
272
|
+
if (!parsed.success)
|
|
273
|
+
throw new Error(formatZodError(tool.name, parsed.error));
|
|
274
|
+
return tool.handler(parsed.data, config);
|
|
275
|
+
}
|