@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
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,125 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tday-com/mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Official local MCP server and installer for [tday.com](https://tday.com). Connect Claude Code, Cursor, VS Code, Gemini CLI, Codex, and any MCP-compatible AI agent to your tday.com account so you can generate designs, make launch videos, 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, Gemini CLI and VS Code (stable and Insiders, user `mcp.json`) get MCP config entries; Codex gets a TOML block to paste (the Codex app reads the same file). No manual key copying is needed.
|
|
20
|
+
|
|
21
|
+
One install sets up everything your account can use, with one sign-in:
|
|
22
|
+
|
|
23
|
+
- `tday`: this server (your account, brands, designs, launch videos).
|
|
24
|
+
- `tday-capture`: [`@tday-com/capture`](https://www.npmjs.com/package/@tday-com/capture), which captures real UI from the app running on your machine for tday. Added when your account can capture. It reads the same `~/.tday/config.json`, so it needs no token of its own.
|
|
25
|
+
|
|
26
|
+
Run `install` again whenever you want to update either, or after your account gains access to something new.
|
|
27
|
+
|
|
28
|
+
## Slash commands
|
|
29
|
+
|
|
30
|
+
Claude Code users get:
|
|
31
|
+
|
|
32
|
+
- `/tday` - general tday.com assistant
|
|
33
|
+
- `/tday-design` - generate a design
|
|
34
|
+
- `/tday-brand` - create/list/view brands
|
|
35
|
+
- `/tday-credits` - check your plan and credit balance
|
|
36
|
+
- `/tday-ideas` - brainstorm brand-specific design ideas
|
|
37
|
+
- `/tday-launch-video` - make a launch video of a feature: the one you name, or the newest one without a launch video (when your plan includes launch videos)
|
|
38
|
+
|
|
39
|
+
## MCP tools
|
|
40
|
+
|
|
41
|
+
- `tday_whoami` - current user, plan and credit balance
|
|
42
|
+
- `tday_list_brands` / `tday_get_brand` - lightweight list summaries with API-backed `limit`/`offset`
|
|
43
|
+
- `tday_create_brand` - create a brand from a website
|
|
44
|
+
- `tday_list_designs` / `tday_get_design` - lightweight list summaries with API-backed `limit`/`offset`
|
|
45
|
+
- `tday_generate_design` - prompt + brandId
|
|
46
|
+
- `tday_poll_generation` - poll until completed/failed
|
|
47
|
+
- `tday_download_image` - save a completed image locally
|
|
48
|
+
- `tday_list_features` - a brand's features, newest first, with the launch videos each already has (`without: "launch_video"` lists the ones with none)
|
|
49
|
+
- `tday_create_launch_video` - make a launch video of a feature (`featureId` from `tday_list_features`) or of a new subject (brandId, title, description)
|
|
50
|
+
- `tday_poll_launch_video` / `tday_get_launch_video_mp4` / `tday_revise_launch_video` - follow a film until it is ready, get its MP4, or make a new cut from notes
|
|
51
|
+
|
|
52
|
+
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.
|
|
53
|
+
|
|
54
|
+
Making a launch video spends credits the same way; `tday_get_credit_prices` lists every price.
|
|
55
|
+
|
|
56
|
+
## MCP prompts
|
|
57
|
+
|
|
58
|
+
Clients that list MCP prompts (Claude Code as `/mcp__tday__<name>`, VS Code as `/mcp.tday.<name>`) get:
|
|
59
|
+
|
|
60
|
+
- `launch-video` - a launch video of the feature you name, or of the newest feature without one, quoted before it starts
|
|
61
|
+
- `features` - the newest features and which already have a launch video
|
|
62
|
+
|
|
63
|
+
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.
|
|
64
|
+
|
|
65
|
+
## One-shot CLI calls
|
|
66
|
+
|
|
67
|
+
Non-MCP integrations can call the same tool handlers directly:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npx -y @tday-com/mcp call tday_whoami '{}'
|
|
71
|
+
npx -y @tday-com/mcp call tday_list_brands '{"limit":10,"response_format":"markdown"}'
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
This is useful for smoke tests and scripted integrations.
|
|
75
|
+
|
|
76
|
+
## Claude connector
|
|
77
|
+
|
|
78
|
+
For Claude's remote connector/custom connector flow, use the hosted MCP endpoint:
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
https://tday.com/api/mcp
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
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.
|
|
85
|
+
|
|
86
|
+
## Security and privacy
|
|
87
|
+
|
|
88
|
+
- **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.
|
|
89
|
+
- **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.
|
|
90
|
+
- **Disconnect** any connection from tday Settings - Integrations.
|
|
91
|
+
- **No telemetry.** The server talks only to tday, and to tday's file storage when you ask it to download an image or video.
|
|
92
|
+
|
|
93
|
+
## Manual MCP config
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"mcpServers": {
|
|
98
|
+
"tday": {
|
|
99
|
+
"command": "npx",
|
|
100
|
+
"args": ["-y", "@tday-com/mcp", "serve"]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
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.
|
|
107
|
+
|
|
108
|
+
For Gemini CLI, put the same `mcpServers.tday` object in `~/.gemini/settings.json`. VS Code's user `mcp.json` keys entries by `servers` and names the transport: `{ "servers": { "tday": { "type": "stdio", "command": "npx", "args": ["-y", "@tday-com/mcp", "serve"] } } }`.
|
|
109
|
+
|
|
110
|
+
## Claude Code slash commands
|
|
111
|
+
|
|
112
|
+
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`, `/tday-ideas` and, where your plan includes launch videos, `/tday-launch-video` in the slash-command autocomplete list. A command your account no longer has is removed the next time you run `install`.
|
|
113
|
+
|
|
114
|
+
## Security model
|
|
115
|
+
|
|
116
|
+
- Uses local stdio transport; the MCP server runs on your machine.
|
|
117
|
+
- Browser authorization requires signing in or signing up on tday.com.
|
|
118
|
+
- The installer uses a loopback callback bound to `127.0.0.1` plus a random `state` nonce.
|
|
119
|
+
- The local connection is revocable from tday.com settings.
|
|
120
|
+
- Local config is written to `~/.tday/config.json` with `0600` permissions.
|
|
121
|
+
- 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.
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
Proprietary. See `LICENSE`.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
export async function api(config, path, init = {}) {
|
|
2
|
+
if (!config.token) {
|
|
3
|
+
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.");
|
|
4
|
+
}
|
|
5
|
+
const controller = new AbortController();
|
|
6
|
+
const timeoutMs = init.method === "POST" ? 120_000 : 30_000;
|
|
7
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
8
|
+
if (typeof timeout === "object" && "unref" in timeout)
|
|
9
|
+
timeout.unref();
|
|
10
|
+
const callerSignal = init.signal;
|
|
11
|
+
if (callerSignal?.aborted)
|
|
12
|
+
controller.abort(callerSignal.reason);
|
|
13
|
+
callerSignal?.addEventListener("abort", () => controller.abort(callerSignal.reason), {
|
|
14
|
+
once: true,
|
|
15
|
+
});
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetch(`${config.apiBaseUrl}${path}`, {
|
|
18
|
+
...init,
|
|
19
|
+
signal: controller.signal,
|
|
20
|
+
headers: {
|
|
21
|
+
accept: "application/json",
|
|
22
|
+
"content-type": "application/json",
|
|
23
|
+
authorization: `Bearer ${config.token}`,
|
|
24
|
+
...init.headers,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
const text = await res.text();
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
throw new Error(formatHttpError(res.status, path, text, res.statusText));
|
|
30
|
+
}
|
|
31
|
+
if (!text)
|
|
32
|
+
return null;
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(text);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
42
|
+
if (callerSignal?.aborted)
|
|
43
|
+
throw err;
|
|
44
|
+
throw new Error(`tday request timed out after ${timeoutMs / 1000}s: ${path}`);
|
|
45
|
+
}
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
clearTimeout(timeout);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function formatHttpError(status, path, text, statusText) {
|
|
53
|
+
if (status >= 500) {
|
|
54
|
+
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.`;
|
|
55
|
+
}
|
|
56
|
+
const body = formatErrorBody(text) || statusText;
|
|
57
|
+
const prefix = `tday ${status} ${path}: ${body}`;
|
|
58
|
+
const code = errorCode(text);
|
|
59
|
+
const links = refusalLinks(text);
|
|
60
|
+
const linkLine = [
|
|
61
|
+
links.usage ? `See what the organisation has spent: ${links.usage}` : null,
|
|
62
|
+
links.billing ? `The organisation's owner can change the plan at ${links.billing}` : null,
|
|
63
|
+
]
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.join(" ");
|
|
66
|
+
if (code && PLAN_CODES.has(code) && status !== 402) {
|
|
67
|
+
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.`;
|
|
68
|
+
}
|
|
69
|
+
switch (status) {
|
|
70
|
+
case 400:
|
|
71
|
+
return `${prefix}. Check the tool arguments and retry with values returned by tday list/get tools.`;
|
|
72
|
+
case 401:
|
|
73
|
+
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.`;
|
|
74
|
+
case 402:
|
|
75
|
+
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.`;
|
|
76
|
+
case 403:
|
|
77
|
+
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.`;
|
|
78
|
+
case 404:
|
|
79
|
+
return `${prefix}. Check that the ID came from a tday_* list/get tool and belongs to the linked account.`;
|
|
80
|
+
case 429:
|
|
81
|
+
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.`;
|
|
82
|
+
default:
|
|
83
|
+
return prefix;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const PLAN_CODES = new Set(["insufficient_credits", "plan_locked", "brand_plan_locked", "format_locked", "brand_limit_reached"]);
|
|
87
|
+
const PLAN_REFUSAL_LEADS = {
|
|
88
|
+
plan_locked: "This brand is locked by the organisation's plan.",
|
|
89
|
+
brand_plan_locked: "This brand is locked by the organisation's plan.",
|
|
90
|
+
format_locked: "The organisation's plan does not include this size; name another or omit it.",
|
|
91
|
+
brand_limit_reached: "The organisation's plan does not include another brand.",
|
|
92
|
+
};
|
|
93
|
+
function refusalLinks(text) {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(text);
|
|
96
|
+
return { usage: tdayUrl(parsed.links?.usage), billing: tdayUrl(parsed.links?.billing) };
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return { usage: null, billing: null };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function tdayUrl(value) {
|
|
103
|
+
if (typeof value !== "string" || value.length > 500)
|
|
104
|
+
return null;
|
|
105
|
+
try {
|
|
106
|
+
const url = new URL(value);
|
|
107
|
+
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
108
|
+
const tday = url.protocol === "https:" && (url.hostname === "tday.com" || url.hostname.endsWith(".tday.com"));
|
|
109
|
+
return tday || local ? url.toString() : null;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function errorCode(text) {
|
|
116
|
+
if (!text)
|
|
117
|
+
return null;
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(text);
|
|
120
|
+
return typeof parsed.code === "string" ? parsed.code : null;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function formatErrorBody(text) {
|
|
127
|
+
if (!text)
|
|
128
|
+
return "";
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(text);
|
|
131
|
+
const message = parsed.error ?? parsed.message;
|
|
132
|
+
if (typeof message === "string")
|
|
133
|
+
return message.slice(0, 300);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
}
|
|
137
|
+
return "";
|
|
138
|
+
}
|
|
139
|
+
export function normalizeWebsite(raw) {
|
|
140
|
+
const candidate = raw.match(/^https?:\/\//i) ? raw : `https://${raw}`;
|
|
141
|
+
let url;
|
|
142
|
+
try {
|
|
143
|
+
url = new URL(candidate);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
throw new Error(`Invalid website URL: "${raw}"`);
|
|
147
|
+
}
|
|
148
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
149
|
+
throw new Error(`Website URL must use http or https (got ${url.protocol})`);
|
|
150
|
+
}
|
|
151
|
+
if (!url.hostname || !url.hostname.includes(".")) {
|
|
152
|
+
throw new Error("Website must include a valid domain, for example stripe.com");
|
|
153
|
+
}
|
|
154
|
+
return url.href;
|
|
155
|
+
}
|
|
156
|
+
export function firstImageUrl(generation) {
|
|
157
|
+
return (generation?.imageUrl ??
|
|
158
|
+
generation?.masterImageUrl ??
|
|
159
|
+
generation?.panels?.find((panel) => panel.imageUrl)?.imageUrl ??
|
|
160
|
+
null);
|
|
161
|
+
}
|
|
162
|
+
export function query(params) {
|
|
163
|
+
const search = new URLSearchParams();
|
|
164
|
+
for (const [key, value] of Object.entries(params)) {
|
|
165
|
+
if (value !== undefined)
|
|
166
|
+
search.set(key, String(value));
|
|
167
|
+
}
|
|
168
|
+
const encoded = search.toString();
|
|
169
|
+
return encoded ? `?${encoded}` : "";
|
|
170
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { packageVersion } from "./package-info.js";
|
|
3
|
+
import { runServer } from "./server.js";
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
import { callTool, stringifyResult } from "./tools.js";
|
|
6
|
+
const [, , cmd, ...rest] = process.argv;
|
|
7
|
+
async function main() {
|
|
8
|
+
switch (cmd) {
|
|
9
|
+
case "serve":
|
|
10
|
+
case undefined:
|
|
11
|
+
await runServer();
|
|
12
|
+
return;
|
|
13
|
+
case "install":
|
|
14
|
+
const { runInstaller } = await import("./installer.js");
|
|
15
|
+
await runInstaller(rest);
|
|
16
|
+
return;
|
|
17
|
+
case "call":
|
|
18
|
+
await callToolFromCli(rest);
|
|
19
|
+
return;
|
|
20
|
+
case "version":
|
|
21
|
+
case "-v":
|
|
22
|
+
case "--version": {
|
|
23
|
+
console.log(packageVersion());
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
case "help":
|
|
27
|
+
case "-h":
|
|
28
|
+
case "--help":
|
|
29
|
+
printHelp();
|
|
30
|
+
return;
|
|
31
|
+
default:
|
|
32
|
+
console.error(`Unknown command: ${cmd}`);
|
|
33
|
+
printHelp();
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function printHelp() {
|
|
38
|
+
console.log(`tday - MCP server and installer for tday.com
|
|
39
|
+
|
|
40
|
+
Usage:
|
|
41
|
+
tday install Configure your AI agent (Claude Code, Cursor, Gemini CLI, Codex)
|
|
42
|
+
tday serve Run the MCP server over stdio (invoked by agents)
|
|
43
|
+
tday call <tool> [json]
|
|
44
|
+
Call one tday tool once and print JSON
|
|
45
|
+
tday version Print version
|
|
46
|
+
tday help Show this message
|
|
47
|
+
|
|
48
|
+
Install from npm:
|
|
49
|
+
npx -y @tday-com/mcp install
|
|
50
|
+
`);
|
|
51
|
+
}
|
|
52
|
+
async function callToolFromCli(args) {
|
|
53
|
+
const [toolName, ...jsonParts] = args;
|
|
54
|
+
if (!toolName) {
|
|
55
|
+
throw new Error("Usage: tday call <toolName> [jsonArgs]");
|
|
56
|
+
}
|
|
57
|
+
const jsonArgs = jsonParts.join(" ").trim() || "{}";
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(jsonArgs);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
throw new Error(`Invalid JSON arguments for ${toolName}: ${err.message}`);
|
|
64
|
+
}
|
|
65
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
66
|
+
throw new Error("Tool arguments must be a JSON object.");
|
|
67
|
+
}
|
|
68
|
+
const config = await loadConfig();
|
|
69
|
+
const result = await callTool(toolName, parsed, config);
|
|
70
|
+
console.log(stringifyResult(result));
|
|
71
|
+
}
|
|
72
|
+
main().catch((err) => {
|
|
73
|
+
console.error(err);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
});
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
function normalizeBaseUrl(raw) {
|
|
45
|
+
const trimmed = raw.trim().replace(/\/+$/, "");
|
|
46
|
+
return trimmed.replace(/\/api$/i, "");
|
|
47
|
+
}
|
|
48
|
+
function deriveWebBaseUrl(apiBaseUrl) {
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(apiBaseUrl);
|
|
51
|
+
if (url.hostname.startsWith("api.")) {
|
|
52
|
+
url.hostname = url.hostname.slice(4);
|
|
53
|
+
}
|
|
54
|
+
url.pathname = "";
|
|
55
|
+
url.search = "";
|
|
56
|
+
url.hash = "";
|
|
57
|
+
return normalizeBaseUrl(url.toString());
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return DEFAULT_WEB_URL;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { lstat, mkdir, realpath } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, resolve, relative, sep } from "node:path";
|
|
3
|
+
export const ALLOWED_IMAGE_HOSTS = new Set([
|
|
4
|
+
"cdn.tday.com",
|
|
5
|
+
"video-cdn.communities.one",
|
|
6
|
+
"communities.one",
|
|
7
|
+
"media.brand.dev",
|
|
8
|
+
"asset.brandfetch.io",
|
|
9
|
+
"cdn.brandfetch.io",
|
|
10
|
+
"tday.com",
|
|
11
|
+
"www.tday.com",
|
|
12
|
+
"app.tday.com",
|
|
13
|
+
]);
|
|
14
|
+
export const MAX_IMAGE_BYTES = 50 * 1024 * 1024;
|
|
15
|
+
export function validateImageUrl(raw) {
|
|
16
|
+
let url;
|
|
17
|
+
try {
|
|
18
|
+
url = new URL(raw);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new Error(`Invalid image URL: "${raw}"`);
|
|
22
|
+
}
|
|
23
|
+
if (url.protocol !== "https:") {
|
|
24
|
+
throw new Error(`Image URL must use HTTPS (got ${url.protocol})`);
|
|
25
|
+
}
|
|
26
|
+
if (!ALLOWED_IMAGE_HOSTS.has(url.hostname)) {
|
|
27
|
+
throw new Error(`Image host "${url.hostname}" is not allowed. Expected one of: ${[...ALLOWED_IMAGE_HOSTS].join(", ")}`);
|
|
28
|
+
}
|
|
29
|
+
return url;
|
|
30
|
+
}
|
|
31
|
+
export function sandboxFilePath(raw) {
|
|
32
|
+
const cwd = process.cwd();
|
|
33
|
+
const resolved = resolve(cwd, raw);
|
|
34
|
+
const rel = relative(cwd, resolved);
|
|
35
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel) || resolve(cwd, rel) !== resolved) {
|
|
36
|
+
throw new Error(`File path must be inside the working directory. "${raw}" resolves outside of ${cwd}`);
|
|
37
|
+
}
|
|
38
|
+
return resolved;
|
|
39
|
+
}
|
|
40
|
+
export async function prepareOutputFilePath(raw, options = {}) {
|
|
41
|
+
const filePath = sandboxFilePath(raw);
|
|
42
|
+
const parentDir = dirname(filePath);
|
|
43
|
+
await mkdir(parentDir, { recursive: true });
|
|
44
|
+
const cwdReal = await realpath(process.cwd());
|
|
45
|
+
const parentReal = await realpath(parentDir);
|
|
46
|
+
if (!isInsideDirectory(cwdReal, parentReal)) {
|
|
47
|
+
throw new Error(`File path parent directory must stay inside the working directory and cannot traverse symlinks outside of ${process.cwd()}`);
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const existing = await lstat(filePath);
|
|
51
|
+
if (existing.isSymbolicLink()) {
|
|
52
|
+
throw new Error("Refusing to write to a symbolic link.");
|
|
53
|
+
}
|
|
54
|
+
if (!existing.isFile()) {
|
|
55
|
+
throw new Error("Refusing to write because the target path exists and is not a regular file.");
|
|
56
|
+
}
|
|
57
|
+
if (!options.overwrite) {
|
|
58
|
+
throw new Error("File already exists. Choose a new filePath or pass overwrite: true.");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
if (!isNodeError(err) || err.code !== "ENOENT")
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
return filePath;
|
|
66
|
+
}
|
|
67
|
+
function isInsideDirectory(base, target) {
|
|
68
|
+
const rel = relative(base, target);
|
|
69
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
70
|
+
}
|
|
71
|
+
function isNodeError(err) {
|
|
72
|
+
return err instanceof Error && "code" in err;
|
|
73
|
+
}
|