@tday-com/mcp 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +36 -0
  2. package/README.md +105 -2
  3. package/dist/api-client.d.ts +23 -0
  4. package/dist/api-client.js +187 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +78 -0
  7. package/dist/config.d.ts +7 -0
  8. package/dist/config.js +66 -0
  9. package/dist/demo-import.d.ts +6 -0
  10. package/dist/demo-import.js +101 -0
  11. package/dist/download-safety.d.ts +7 -0
  12. package/dist/download-safety.js +76 -0
  13. package/dist/installer-agents.d.ts +17 -0
  14. package/dist/installer-agents.js +283 -0
  15. package/dist/installer-callback.d.ts +13 -0
  16. package/dist/installer-callback.js +91 -0
  17. package/dist/installer-slash-command.d.ts +12 -0
  18. package/dist/installer-slash-command.js +262 -0
  19. package/dist/installer.d.ts +2 -0
  20. package/dist/installer.js +127 -0
  21. package/dist/observability.d.ts +1 -0
  22. package/dist/observability.js +77 -0
  23. package/dist/package-info.d.ts +11 -0
  24. package/dist/package-info.js +17 -0
  25. package/dist/result-format.d.ts +4 -0
  26. package/dist/result-format.js +154 -0
  27. package/dist/server.d.ts +1 -0
  28. package/dist/server.js +37 -0
  29. package/dist/tool-schemas.d.ts +460 -0
  30. package/dist/tool-schemas.js +189 -0
  31. package/dist/tools-account.d.ts +6 -0
  32. package/dist/tools-account.js +34 -0
  33. package/dist/tools-demo.d.ts +11 -0
  34. package/dist/tools-demo.js +57 -0
  35. package/dist/tools-knowledge.d.ts +4 -0
  36. package/dist/tools-knowledge.js +111 -0
  37. package/dist/tools-launch.d.ts +4 -0
  38. package/dist/tools-launch.js +365 -0
  39. package/dist/tools.d.ts +15 -0
  40. package/dist/tools.js +296 -0
  41. package/package.json +58 -4
package/LICENSE ADDED
@@ -0,0 +1,36 @@
1
+ tday MCP Package License
2
+
3
+ Copyright (c) 2026 tday.com. All rights reserved.
4
+
5
+ This package is proprietary software owned by tday.com.
6
+
7
+ Subject to these terms, tday.com grants you a limited, revocable,
8
+ non-exclusive, non-transferable license to download, install, and run the
9
+ unmodified npm package solely for the purpose of connecting supported AI agent
10
+ software to your tday.com account and using tday.com services.
11
+
12
+ You may not, except as expressly permitted by applicable law:
13
+
14
+ 1. copy this package except as strictly necessary to install and run it;
15
+ 2. modify, adapt, translate, or create derivative works from this package;
16
+ 3. redistribute, sublicense, sell, rent, lease, lend, publish, or otherwise
17
+ make this package available to third parties;
18
+ 4. reverse engineer, decompile, disassemble, or attempt to derive source code,
19
+ protocols, internal implementation details, or trade secrets from this
20
+ package;
21
+ 5. remove, alter, or obscure any proprietary notices; or
22
+ 6. use this package to build or assist a competing service.
23
+
24
+ No ownership rights are transferred to you. All rights not expressly granted
25
+ are reserved by tday.com.
26
+
27
+ This package is provided "as is" and without warranties of any kind, whether
28
+ express, implied, statutory, or otherwise, including warranties of
29
+ merchantability, fitness for a particular purpose, title, and non-infringement.
30
+ To the maximum extent permitted by law, tday.com will not be liable for any
31
+ indirect, incidental, special, consequential, exemplary, or punitive damages,
32
+ or for lost profits, data, goodwill, or business interruption arising out of or
33
+ related to this package.
34
+
35
+ Your use of tday.com services remains subject to the applicable tday.com terms
36
+ and policies.
package/README.md CHANGED
@@ -1,3 +1,106 @@
1
- # Temporary Holding Version
1
+ # @tday-com/mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Official local MCP server and installer for [tday.com](https://tday.com). Connect Claude Code, Cursor, Gemini CLI, Codex, and any MCP-compatible AI agent to your tday.com account so you can generate designs, manage brands, and query your workspace directly from chat.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npx -y @tday-com/mcp install
9
+ ```
10
+
11
+ Alternatives:
12
+
13
+ ```bash
14
+ bunx @tday-com/mcp install
15
+ pnpm dlx @tday-com/mcp install
16
+ curl -fsSL https://tday.com/install.sh | bash
17
+ ```
18
+
19
+ The installer opens your browser, lets you sign in or sign up on tday.com, asks you to authorize this machine, then automatically configures supported agents. Claude Code, Cursor, and Gemini CLI get MCP config entries; Codex gets a TOML block to paste. No manual key copying is needed.
20
+
21
+ ## Slash commands
22
+
23
+ Claude Code users get:
24
+
25
+ - `/tday` - general tday.com assistant
26
+ - `/tday-design` - generate a design
27
+ - `/tday-brand` - create/list/view brands
28
+ - `/tday-credits` - check your plan and credit balance
29
+ - `/tday-ideas` - brainstorm brand-specific design ideas
30
+
31
+ ## MCP tools
32
+
33
+ - `tday_whoami` - current user, plan and credit balance
34
+ - `tday_list_brands` / `tday_get_brand` - lightweight list summaries with API-backed `limit`/`offset`
35
+ - `tday_create_brand` - create a brand from a website
36
+ - `tday_list_designs` / `tday_get_design` - lightweight list summaries with API-backed `limit`/`offset`
37
+ - `tday_generate_design` - prompt + brandId
38
+ - `tday_poll_generation` - poll until completed/failed
39
+ - `tday_download_image` - save a completed image locally
40
+ - `tday_search_knowledge_entries` / `tday_create_knowledge_entry` - read and record tday codebase knowledge entries (tday staff accounts only)
41
+
42
+ Generating a design spends credits from your organisation's balance, at the same price as in the app, and a failed generation gives them back. When the balance cannot cover a request, tday answers with a 402 and spends nothing; `tday_whoami` shows the balance and when it refreshes.
43
+
44
+ All tools return structured content for modern MCP clients. They also accept optional `response_format: "json" | "markdown"` for the text payload; JSON remains the default for backward compatibility.
45
+
46
+ ## One-shot CLI calls
47
+
48
+ Non-MCP integrations can call the same tool handlers directly:
49
+
50
+ ```bash
51
+ npx -y @tday-com/mcp call tday_whoami '{}'
52
+ npx -y @tday-com/mcp call tday_list_brands '{"limit":10,"response_format":"markdown"}'
53
+ ```
54
+
55
+ This is useful for smoke tests and scripted integrations.
56
+
57
+ ## Claude connector
58
+
59
+ For Claude's remote connector/custom connector flow, use the hosted MCP endpoint:
60
+
61
+ ```text
62
+ https://tday.com/api/mcp
63
+ ```
64
+
65
+ The hosted connector uses OAuth + PKCE with refresh-token rotation. Users sign in on tday.com, Claude receives a scoped bearer token, and the connection can be revoked from tday Settings - Integrations.
66
+
67
+ ## Security and privacy
68
+
69
+ - **What a connection can do.** The token an MCP connection holds only reaches the routes the tools above use. It cannot delete or transfer a brand, manage members, or see payment details.
70
+ - **Links expire.** A link made by `install` lasts 90 days; run `install` again to renew it. A hosted connector's access token lasts an hour and renews itself for as long as the connection is used; one left unused for 30 days disconnects.
71
+ - **Disconnect** any connection from tday Settings - Integrations.
72
+ - **Telemetry is off by default.** Set `TDAY_MCP_TELEMETRY=1` to send this server's traces, metrics and logs (tool calls and their requests to tday, never your token or file contents) to help us debug.
73
+
74
+ ## Manual MCP config
75
+
76
+ ```json
77
+ {
78
+ "mcpServers": {
79
+ "tday": {
80
+ "command": "npx",
81
+ "args": ["-y", "@tday-com/mcp", "serve"]
82
+ }
83
+ }
84
+ }
85
+ ```
86
+
87
+ If you add this block yourself, run `npx -y @tday-com/mcp install` once afterward so the browser sign-in flow can link the server to your tday account automatically. `install` writes the entry pinned to its own version (`@tday-com/mcp@<version>`), so an agent never picks up a new release on its own; run `install` again to update. Pin the version in a block you write by hand too.
88
+
89
+ For Gemini CLI, put the same `mcpServers.tday` object in `~/.gemini/settings.json`.
90
+
91
+ ## Claude Code slash commands
92
+
93
+ The installer writes user-level Claude Code slash commands to `~/.claude/commands`. After restarting Claude Code, typing `/` shows `/tday`, `/tday-design`, `/tday-brand`, `/tday-credits`, and `/tday-ideas` in the slash-command autocomplete list.
94
+
95
+ ## Security model
96
+
97
+ - Uses local stdio transport; the MCP server runs on your machine.
98
+ - Browser authorization requires signing in or signing up on tday.com.
99
+ - The installer uses a loopback callback bound to `127.0.0.1` plus a random `state` nonce.
100
+ - The local connection is revocable from tday.com settings.
101
+ - Local config is written to `~/.tday/config.json` with `0600` permissions.
102
+ - File downloads are restricted to HTTPS image URLs on an allowlist, re-check redirect targets, cap size at 50MB, and write only inside the current working directory.
103
+
104
+ ## License
105
+
106
+ Proprietary. See `LICENSE`.
@@ -0,0 +1,23 @@
1
+ import type { TdayConfig } from "./config.js";
2
+ export declare function api<T = unknown>(config: TdayConfig, path: string, init?: RequestInit): Promise<T>;
3
+ export declare function normalizeWebsite(raw: string): string;
4
+ export interface GenerationLike {
5
+ id?: string;
6
+ status?: string;
7
+ imageUrl?: string | null;
8
+ masterImageUrl?: string | null;
9
+ completedAt?: string | null;
10
+ panels?: Array<{
11
+ imageUrl?: string | null;
12
+ }>;
13
+ }
14
+ export interface StatusPollResponse {
15
+ status?: string;
16
+ complete?: {
17
+ generation?: GenerationLike;
18
+ message?: string;
19
+ } | null;
20
+ platformBadge?: boolean;
21
+ }
22
+ export declare function firstImageUrl(generation: GenerationLike | undefined | null): string | null;
23
+ export declare function query(params: Record<string, string | number | boolean | undefined>): string;
@@ -0,0 +1,187 @@
1
+ // Authenticated HTTP client for the tday API plus small request/response
2
+ // helpers shared by the tool handlers in tools.ts.
3
+ export async function api(config, path, init = {}) {
4
+ if (!config.token) {
5
+ throw new Error("Not authenticated. Run `npx -y @tday-com/mcp install` to link this MCP server to your tday account in the browser, then restart your AI agent.");
6
+ }
7
+ const controller = new AbortController();
8
+ const timeoutMs = init.method === "POST" ? 120_000 : 30_000;
9
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
10
+ if (typeof timeout === "object" && "unref" in timeout)
11
+ timeout.unref();
12
+ // Forward caller cancellation into the timeout controller so both signals
13
+ // apply (AbortSignal.any needs Node 20.3+; engines allow >=18).
14
+ const callerSignal = init.signal;
15
+ if (callerSignal?.aborted)
16
+ controller.abort(callerSignal.reason);
17
+ callerSignal?.addEventListener("abort", () => controller.abort(callerSignal.reason), {
18
+ once: true,
19
+ });
20
+ try {
21
+ const res = await fetch(`${config.apiBaseUrl}${path}`, {
22
+ ...init,
23
+ signal: controller.signal,
24
+ headers: {
25
+ accept: "application/json",
26
+ "content-type": "application/json",
27
+ authorization: `Bearer ${config.token}`,
28
+ ...init.headers,
29
+ },
30
+ });
31
+ // A failed body read is not an empty acknowledgement. In particular, never
32
+ // repeat a mutation just because its successful response was interrupted.
33
+ const text = await res.text();
34
+ if (!res.ok) {
35
+ throw new Error(formatHttpError(res.status, path, text, res.statusText));
36
+ }
37
+ if (!text)
38
+ return null;
39
+ try {
40
+ return JSON.parse(text);
41
+ }
42
+ catch {
43
+ return text;
44
+ }
45
+ }
46
+ catch (err) {
47
+ if (err instanceof Error && err.name === "AbortError") {
48
+ if (callerSignal?.aborted)
49
+ throw err; // caller cancelled — not a timeout
50
+ throw new Error(`tday request timed out after ${timeoutMs / 1000}s: ${path}`);
51
+ }
52
+ throw err;
53
+ }
54
+ finally {
55
+ clearTimeout(timeout);
56
+ }
57
+ }
58
+ function formatHttpError(status, path, text, statusText) {
59
+ // A 5xx body is where internal detail lives, so it is never shown. A 4xx contributes only its
60
+ // JSON `error` string, which the tday routes write for people to read.
61
+ if (status >= 500) {
62
+ return `tday ${status} ${path}. tday is temporarily unable to complete the request; retry shortly. If a design or launch video was already queued, keep polling it (tday_poll_generation or tday_poll_launch_video) rather than starting another.`;
63
+ }
64
+ const body = formatErrorBody(text) || statusText;
65
+ const prefix = `tday ${status} ${path}: ${body}`;
66
+ const code = errorCode(text);
67
+ const links = refusalLinks(text);
68
+ const linkLine = [
69
+ links.usage ? `See what the organisation has spent: ${links.usage}` : null,
70
+ links.billing ? `The organisation's owner can change the plan at ${links.billing}` : null,
71
+ ]
72
+ .filter(Boolean)
73
+ .join(" ");
74
+ if (code && PLAN_CODES.has(code) && status !== 402) {
75
+ return `${prefix}. ${PLAN_REFUSAL_LEADS[code] ?? "The organisation's plan does not include this."}${linkLine ? ` ${linkLine}` : ""} Run tday_whoami to see what the plan includes and who owns it.`;
76
+ }
77
+ switch (status) {
78
+ case 400:
79
+ return `${prefix}. Check the tool arguments and retry with values returned by tday list/get tools.`;
80
+ case 401:
81
+ return `${prefix}. This MCP server's tday link is missing, expired (links last 90 days) or was disconnected. Run \`npx -y @tday-com/mcp install\` to sign in and relink this machine, then restart your AI agent and retry tday_whoami.`;
82
+ case 402:
83
+ // The body's sentence already says what the request needed and what is
84
+ // left; this only tells the agent what to do next. Nothing was spent.
85
+ return `${prefix}. Nothing was started and no credits were spent. Do not retry until the balance changes.${linkLine ? ` ${linkLine}` : ""} Run tday_whoami for each organisation's balance and who owns it; tday_get_credit_prices has what things cost.`;
86
+ case 403:
87
+ return `${prefix}. The linked tday account, or this connection, is not allowed to do that. Use tday_whoami to verify the account, then list brands/designs again.`;
88
+ case 404:
89
+ return `${prefix}. Check that the ID came from a tday_* list/get tool and belongs to the linked account.`;
90
+ case 429:
91
+ return `${prefix}. MCP/API generation is rate limited per account. Wait for the limit window to pass (see the Retry-After time), then retry. Do not retry in a loop. Run tday_whoami to check the plan and credit balance.`;
92
+ default:
93
+ return prefix;
94
+ }
95
+ }
96
+ /** Refusals a plan or more credits resolves (web/src/lib/credits/refusal-links.ts). */
97
+ const PLAN_CODES = new Set(["insufficient_credits", "plan_locked", "brand_plan_locked", "format_locked", "brand_limit_reached"]);
98
+ const PLAN_REFUSAL_LEADS = {
99
+ plan_locked: "This brand is locked by the organisation's plan.",
100
+ brand_plan_locked: "This brand is locked by the organisation's plan.",
101
+ format_locked: "The organisation's plan does not include this size; name another or omit it.",
102
+ brand_limit_reached: "The organisation's plan does not include another brand.",
103
+ };
104
+ /**
105
+ * The usage and billing links a plan refusal carries. Only https URLs on a tday host (or local
106
+ * development) are repeated to the assistant; anything else in the body is dropped.
107
+ */
108
+ function refusalLinks(text) {
109
+ try {
110
+ const parsed = JSON.parse(text);
111
+ return { usage: tdayUrl(parsed.links?.usage), billing: tdayUrl(parsed.links?.billing) };
112
+ }
113
+ catch {
114
+ return { usage: null, billing: null };
115
+ }
116
+ }
117
+ function tdayUrl(value) {
118
+ if (typeof value !== "string" || value.length > 500)
119
+ return null;
120
+ try {
121
+ const url = new URL(value);
122
+ const local = url.hostname === "localhost" || url.hostname === "127.0.0.1";
123
+ const tday = url.protocol === "https:" && (url.hostname === "tday.com" || url.hostname.endsWith(".tday.com"));
124
+ return tday || local ? url.toString() : null;
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ /** The refusal's machine-readable code (`insufficient_credits`, `plan_locked`, ...), when the body has one. */
131
+ function errorCode(text) {
132
+ if (!text)
133
+ return null;
134
+ try {
135
+ const parsed = JSON.parse(text);
136
+ return typeof parsed.code === "string" ? parsed.code : null;
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ }
142
+ function formatErrorBody(text) {
143
+ if (!text)
144
+ return "";
145
+ try {
146
+ const parsed = JSON.parse(text);
147
+ const message = parsed.error ?? parsed.message;
148
+ if (typeof message === "string")
149
+ return message.slice(0, 300);
150
+ }
151
+ catch {
152
+ // Not JSON: nothing from it is shown.
153
+ }
154
+ return "";
155
+ }
156
+ export function normalizeWebsite(raw) {
157
+ const candidate = raw.match(/^https?:\/\//i) ? raw : `https://${raw}`;
158
+ let url;
159
+ try {
160
+ url = new URL(candidate);
161
+ }
162
+ catch {
163
+ throw new Error(`Invalid website URL: "${raw}"`);
164
+ }
165
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
166
+ throw new Error(`Website URL must use http or https (got ${url.protocol})`);
167
+ }
168
+ if (!url.hostname || !url.hostname.includes(".")) {
169
+ throw new Error("Website must include a valid domain, for example stripe.com");
170
+ }
171
+ return url.href;
172
+ }
173
+ export function firstImageUrl(generation) {
174
+ return (generation?.imageUrl ??
175
+ generation?.masterImageUrl ??
176
+ generation?.panels?.find((panel) => panel.imageUrl)?.imageUrl ??
177
+ null);
178
+ }
179
+ export function query(params) {
180
+ const search = new URLSearchParams();
181
+ for (const [key, value] of Object.entries(params)) {
182
+ if (value !== undefined)
183
+ search.set(key, String(value));
184
+ }
185
+ const encoded = search.toString();
186
+ return encoded ? `?${encoded}` : "";
187
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ import { startObservability } from "./observability.js";
3
+ startObservability();
4
+ import { packageVersion } from "./package-info.js";
5
+ import { runServer } from "./server.js";
6
+ import { loadConfig } from "./config.js";
7
+ import { callTool, stringifyResult } from "./tools.js";
8
+ const [, , cmd, ...rest] = process.argv;
9
+ async function main() {
10
+ switch (cmd) {
11
+ case "serve":
12
+ case undefined:
13
+ await runServer();
14
+ return;
15
+ case "install":
16
+ // Lazy-load installer to keep `serve` cold-start tiny.
17
+ const { runInstaller } = await import("./installer.js");
18
+ await runInstaller(rest);
19
+ return;
20
+ case "call":
21
+ await callToolFromCli(rest);
22
+ return;
23
+ case "version":
24
+ case "-v":
25
+ case "--version": {
26
+ console.log(packageVersion());
27
+ return;
28
+ }
29
+ case "help":
30
+ case "-h":
31
+ case "--help":
32
+ printHelp();
33
+ return;
34
+ default:
35
+ console.error(`Unknown command: ${cmd}`);
36
+ printHelp();
37
+ process.exit(1);
38
+ }
39
+ }
40
+ function printHelp() {
41
+ console.log(`tday - MCP server and installer for tday.com
42
+
43
+ Usage:
44
+ tday install Configure your AI agent (Claude Code, Cursor, Gemini CLI, Codex)
45
+ tday serve Run the MCP server over stdio (invoked by agents)
46
+ tday call <tool> [json]
47
+ Call one tday tool once and print JSON
48
+ tday version Print version
49
+ tday help Show this message
50
+
51
+ Install from npm:
52
+ npx -y @tday-com/mcp install
53
+ `);
54
+ }
55
+ async function callToolFromCli(args) {
56
+ const [toolName, ...jsonParts] = args;
57
+ if (!toolName) {
58
+ throw new Error("Usage: tday call <toolName> [jsonArgs]");
59
+ }
60
+ const jsonArgs = jsonParts.join(" ").trim() || "{}";
61
+ let parsed;
62
+ try {
63
+ parsed = JSON.parse(jsonArgs);
64
+ }
65
+ catch (err) {
66
+ throw new Error(`Invalid JSON arguments for ${toolName}: ${err.message}`);
67
+ }
68
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
69
+ throw new Error("Tool arguments must be a JSON object.");
70
+ }
71
+ const config = await loadConfig();
72
+ const result = await callTool(toolName, parsed, config);
73
+ console.log(stringifyResult(result));
74
+ }
75
+ main().catch((err) => {
76
+ console.error(err);
77
+ process.exit(1);
78
+ });
@@ -0,0 +1,7 @@
1
+ export interface TdayConfig {
2
+ apiBaseUrl: string;
3
+ webBaseUrl: string;
4
+ token: string | null;
5
+ }
6
+ export declare function loadConfig(): Promise<TdayConfig>;
7
+ export declare function configPath(): string;
package/dist/config.js ADDED
@@ -0,0 +1,66 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const CONFIG_PATH = join(homedir(), ".tday", "config.json");
5
+ const DEFAULT_WEB_URL = "https://tday.com";
6
+ export async function loadConfig() {
7
+ const envApiBaseUrl = process.env.TDAY_API_URL;
8
+ const envWebBaseUrl = process.env.TDAY_WEB_URL;
9
+ const envToken = process.env.TDAY_TOKEN ?? null;
10
+ const defaultApiBaseUrl = normalizeBaseUrl(envApiBaseUrl ?? DEFAULT_WEB_URL);
11
+ const defaultWebBaseUrl = normalizeBaseUrl(envWebBaseUrl ?? deriveWebBaseUrl(defaultApiBaseUrl));
12
+ if (envToken) {
13
+ return {
14
+ apiBaseUrl: defaultApiBaseUrl,
15
+ webBaseUrl: defaultWebBaseUrl,
16
+ token: envToken,
17
+ };
18
+ }
19
+ const fileConfig = await readFirstConfig([CONFIG_PATH]);
20
+ const apiBaseUrl = normalizeBaseUrl(fileConfig?.apiBaseUrl ?? defaultApiBaseUrl);
21
+ const webBaseUrl = normalizeBaseUrl(fileConfig?.webBaseUrl ?? envWebBaseUrl ?? deriveWebBaseUrl(apiBaseUrl));
22
+ return {
23
+ apiBaseUrl,
24
+ webBaseUrl,
25
+ token: fileConfig?.token ?? null,
26
+ };
27
+ }
28
+ export function configPath() {
29
+ return CONFIG_PATH;
30
+ }
31
+ async function readFirstConfig(paths) {
32
+ for (const path of paths) {
33
+ try {
34
+ const raw = await readFile(path, "utf8");
35
+ const parsed = JSON.parse(raw);
36
+ if (parsed && typeof parsed === "object")
37
+ return parsed;
38
+ }
39
+ catch {
40
+ // Try the next location. Missing or invalid config should not crash the server;
41
+ // the first tool call will return an actionable authentication error instead.
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+ function normalizeBaseUrl(raw) {
47
+ const trimmed = raw.trim().replace(/\/+$/, "");
48
+ // Many users naturally set TDAY_API_URL=https://tday.com/api. The MCP tools
49
+ // pass full route paths like /api/billing/info, so strip a trailing /api.
50
+ return trimmed.replace(/\/api$/i, "");
51
+ }
52
+ function deriveWebBaseUrl(apiBaseUrl) {
53
+ try {
54
+ const url = new URL(apiBaseUrl);
55
+ if (url.hostname.startsWith("api.")) {
56
+ url.hostname = url.hostname.slice(4);
57
+ }
58
+ url.pathname = "";
59
+ url.search = "";
60
+ url.hash = "";
61
+ return normalizeBaseUrl(url.toString());
62
+ }
63
+ catch {
64
+ return DEFAULT_WEB_URL;
65
+ }
66
+ }
@@ -0,0 +1,6 @@
1
+ import type { TdayConfig } from "./config.js";
2
+ export declare function importDemoBundle(config: TdayConfig, manifestPath: string, brandId: string, title: string): Promise<{
3
+ editorUrl: string;
4
+ projectId: string;
5
+ reused: boolean;
6
+ }>;
@@ -0,0 +1,101 @@
1
+ import { open, realpath } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { resolve, relative, isAbsolute, dirname } from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import { z } from "zod";
6
+ import { api } from "./api-client.js";
7
+ const ASSET_NAME = /^(master-take\.mp4|final\.mp4|poster\.png|(?:voiceoverUrl|musicUrl|mixedAudioUrl|beat-\d+)\.mp3)$/;
8
+ const resultSchema = z.object({ projectId: z.string(), editorUrl: z.string().url(), reused: z.boolean() });
9
+ const prepareSchema = z.object({ reused: z.literal(false), ticket: z.string(), uploads: z.array(z.object({
10
+ name: z.string(), uploadUrl: z.string().url(), headers: z.record(z.string()),
11
+ })) });
12
+ function within(root, path) {
13
+ const rel = relative(root, path);
14
+ if (rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(rel))
15
+ throw new Error("Demo files must remain inside the working directory and bundle");
16
+ }
17
+ async function readBounded(root, path, max) {
18
+ const actual = await realpath(path);
19
+ within(root, actual);
20
+ // Check the opened descriptor, not a preceding stat. O_NOFOLLOW rejects a
21
+ // last-moment symlink substitution. Parent directories are resolved first.
22
+ const file = await open(actual, constants.O_RDONLY | constants.O_NOFOLLOW);
23
+ try {
24
+ const info = await file.stat();
25
+ if (!info.isFile() || info.size <= 0 || info.size > max)
26
+ throw new Error("Demo file is empty or exceeds the size limit");
27
+ const bytes = Buffer.alloc(info.size + 1);
28
+ let size = 0;
29
+ while (size < bytes.length) {
30
+ const read = await file.read(bytes, size, bytes.length - size, null);
31
+ if (!read.bytesRead)
32
+ break;
33
+ size += read.bytesRead;
34
+ }
35
+ if (size !== info.size)
36
+ throw new Error("Demo file changed while reading");
37
+ return bytes.subarray(0, size);
38
+ }
39
+ finally {
40
+ await file.close();
41
+ }
42
+ }
43
+ const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
44
+ export async function importDemoBundle(config, manifestPath, brandId, title) {
45
+ const cwd = await realpath(process.cwd());
46
+ const path = await realpath(resolve(cwd, manifestPath));
47
+ within(cwd, path);
48
+ const root = dirname(path);
49
+ const manifest = JSON.parse((await readBounded(root, path, 1024 * 1024)).toString("utf8"));
50
+ const files = z.record(z.enum(["video/mp4", "audio/mpeg", "image/png"])).parse(manifest.files);
51
+ if (Object.keys(files).length > 200)
52
+ throw new Error("Too many demo assets");
53
+ const assets = [];
54
+ let total = 0;
55
+ for (const name of Object.keys(files).sort()) {
56
+ if (!ASSET_NAME.test(name))
57
+ throw new Error("Unexpected demo asset name");
58
+ const bytes = await readBounded(root, resolve(root, name), 100 * 1024 * 1024);
59
+ total += bytes.length;
60
+ if (total > 500 * 1024 * 1024)
61
+ throw new Error("Demo bundle exceeds 500 MB");
62
+ assets.push({ name, contentType: files[name], sizeBytes: bytes.length, sha256: digest(bytes) });
63
+ }
64
+ const metadata = { brandId, title, manifest, assets };
65
+ if (Buffer.byteLength(JSON.stringify(metadata)) > 1024 * 1024 - 8192)
66
+ throw new Error("Demo metadata exceeds request limit");
67
+ const prepared = await api(config, "/api/studio/demo-import/prepare", { method: "POST", body: JSON.stringify(metadata) });
68
+ const reused = resultSchema.safeParse(prepared);
69
+ if (reused.success && reused.data.reused)
70
+ return reused.data;
71
+ const { ticket, uploads } = prepareSchema.parse(prepared);
72
+ if (uploads.length !== assets.length || new Set(uploads.map((u) => u.name)).size !== assets.length)
73
+ throw new Error("Invalid demo upload response");
74
+ for (const upload of uploads) {
75
+ const asset = assets.find((a) => a.name === upload.name);
76
+ if (!asset)
77
+ throw new Error("Unexpected demo upload asset");
78
+ const url = new URL(upload.uploadUrl);
79
+ if (url.protocol !== "https:" || url.username || url.password || url.port
80
+ || !/^[a-z0-9.-]+\.s3\.[a-z0-9-]+\.amazonaws\.com$/.test(url.hostname))
81
+ throw new Error("Demo upload must use signed S3 storage");
82
+ const bytes = await readBounded(root, resolve(root, asset.name), 100 * 1024 * 1024);
83
+ if (bytes.length !== asset.sizeBytes || digest(bytes) !== asset.sha256)
84
+ throw new Error("Demo asset changed since upload preparation");
85
+ // Never forward tday auth or arbitrary server-provided headers to storage.
86
+ const headers = { "content-type": asset.contentType, "cache-control": "private, no-store",
87
+ "x-amz-checksum-sha256": Buffer.from(asset.sha256, "hex").toString("base64") };
88
+ if (Object.entries(headers).some(([key, value]) => upload.headers[key] !== value))
89
+ throw new Error("Invalid demo upload headers");
90
+ let response;
91
+ try {
92
+ response = await fetch(url, { method: "PUT", body: new Uint8Array(bytes), headers, redirect: "error", signal: AbortSignal.timeout(120000) });
93
+ }
94
+ catch {
95
+ throw new Error("Demo asset upload failed. Prepare again and retry.");
96
+ }
97
+ if (!response.ok)
98
+ throw new Error(`Demo asset upload failed (${response.status})`);
99
+ }
100
+ return resultSchema.parse(await api(config, "/api/studio/demo-import/complete", { method: "POST", body: JSON.stringify({ ...metadata, ticket }) }));
101
+ }
@@ -0,0 +1,7 @@
1
+ export declare const ALLOWED_IMAGE_HOSTS: Set<string>;
2
+ export declare const MAX_IMAGE_BYTES: number;
3
+ export declare function validateImageUrl(raw: string): URL;
4
+ export declare function sandboxFilePath(raw: string): string;
5
+ export declare function prepareOutputFilePath(raw: string, options?: {
6
+ overwrite?: boolean;
7
+ }): Promise<string>;