@seldonframe/mcp 1.59.2 → 1.60.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 ADDED
@@ -0,0 +1,28 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SeldonFrame (Maxime Houle)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ Note: this MIT license applies to the `skills/mcp-server` package (the
26
+ SeldonFrame MCP connector) only. The SeldonFrame platform in the rest of
27
+ this repository remains licensed under AGPL-3.0 — see the LICENSE file at
28
+ the repository root.
package/README.md CHANGED
@@ -1,113 +1,127 @@
1
- <!-- mcp-name: io.github.seldonframe/mcp -->
2
- # @seldonframe/mcp
3
-
4
- Official SeldonFrame MCP server for Claude Code and Claude Desktop.
5
-
6
- **One command, one real workspace.** The first time you run it, no API key is needed. Say `create_workspace({ name: "Dental Clinic Laval" })` and the server mints a real hosted workspace on `dental-clinic-laval.app.seldonframe.com` — with CRM, Cal.diy booking, Formbricks intake, and Brain v2 pre-installed — and returns live URLs. No signup wall, no "guest mode", no claim step.
7
-
8
- ## 60-second quickstart
9
-
10
- ```bash
11
- # 1. From the repo root
12
- cd skills/mcp-server && npm install
13
-
14
- # 2. Register with Claude Code — no env vars needed
15
- claude mcp add seldonframe -s user -- node "$(pwd)/src/index.js"
16
-
17
- # 3. In Claude Code, just ask:
18
- # create_workspace({ name: "My Business OS" })
19
- ```
20
-
21
- That call returns something like:
22
-
23
- ```json
24
- {
25
- "workspace": { "id": "wsp_…", "slug": "my-business-os", "tier": "free" },
26
- "urls": {
27
- "dashboard": "https://my-business-os.app.seldonframe.com",
28
- "book": "https://my-business-os.app.seldonframe.com/book",
29
- "intake": "https://my-business-os.app.seldonframe.com/intake"
30
- },
31
- "installed": ["crm", "caldiy-booking", "formbricks-intake", "brain-v2"],
32
- "next": [ "…" ]
33
- }
34
- ```
35
-
36
- Every subsequent tool response includes a `next:` array — follow the rails.
37
-
38
- ## How auth works
39
-
40
- | Situation | What happens |
41
- |---|---|
42
- | First ever call | `create_workspace` POSTs with no auth. Server mints a workspace + bearer token. MCP stores the token in `~/.seldonframe/device.json`. |
43
- | Subsequent calls | MCP sends `Authorization: Bearer <workspace token>` automatically. |
44
- | `SELDONFRAME_API_KEY` set | Takes precedence over device tokens. Unlocks Pro capabilities. |
45
-
46
- You never have to juggle modes. The first workspace is free forever.
47
-
48
- ## When you need `SELDONFRAME_API_KEY`
49
-
50
- - Adding a **second workspace**
51
- - Connecting a **custom domain**
52
- - **Full Brain v2** intelligence (heuristic Brain is always free)
53
- - Publishing, exporting, org-scoped secret rotation
54
-
55
- Get one at <https://app.seldonframe.com/settings/api>, then:
56
-
57
- ```bash
58
- export SELDONFRAME_API_KEY=sk-…
59
- ```
60
-
61
- Restart the MCP server. Your existing workspaces continue to work untouched.
62
-
63
- ## Install via plugin manifest (one-shot)
64
-
65
- The repo ships a `.claude-plugin/plugin.json` at the root. From inside Claude Code:
66
-
67
- ```
68
- /plugin install <path-to-repo>
69
- ```
70
-
71
- ## Install via npm
72
-
73
- ```bash
74
- claude mcp add seldonframe -- npx -y @seldonframe/mcp
75
- ```
76
-
77
- This is the canonical install command surfaced on the marketing site,
78
- the `/docs/quickstart` page, and the repo README. It uses `npx -y`
79
- so users never have to globally install anything; the latest version
80
- is fetched and run on demand.
81
-
82
- ## Environment
83
-
84
- - `SELDONFRAME_API_KEY` *(optional)* — Enables Pro capabilities (second workspace, custom domains, full Brain v2).
85
- - `SELDONFRAME_API_BASE` *(optional)* — Override the API base URL. Defaults to `https://app.seldonframe.com/api/v1`.
86
-
87
- ## Tools
88
-
89
- **Workspace:** `create_workspace`, `list_workspaces`, `switch_workspace`, `clone_workspace`, `link_workspace_owner`, `get_workspace_snapshot`.
90
- **Blocks:** `install_caldiy_booking`, `install_formbricks_intake`, `install_vertical_pack`.
91
- **Customize (typed, no backend LLM):** `update_landing_content`, `customize_intake_form`, `configure_booking`, `update_theme`.
92
- **Soul:** `fetch_source_for_soul`, `submit_soul`.
93
- **Ops:** `list_automations`, `connect_custom_domain`, `export_agent`, `store_secret`, `list_secrets`, `rotate_secret`.
94
-
95
- ### Architecture: zero backend LLM cost
96
-
97
- Natural-language reasoning happens in the MCP session (Claude Code on the user's side). The backend only accepts **structured** commands and applies them deterministically. There is no `seldon_it` endpoint that parses prompts server-side; the old `query_brain` is replaced by `get_workspace_snapshot`, which returns raw state for Claude to reason over. Seldon spends $0 on LLM for the free tier — the user's Claude Code subscription is the reasoning engine.
98
-
99
- ### Soul compilation (zero cost to Seldon)
100
-
101
- Soul compilation runs in **your** Claude Code session, not on Seldon's servers:
102
-
103
- 1. `fetch_source_for_soul({ url })` — returns up to 256KB of normalized text.
104
- 2. You (the agent) extract a structured Soul object.
105
- 3. `submit_soul({ soul })` — persists it to the workspace.
106
-
107
- ## Troubleshooting
108
-
109
- **`Seldon API 401`** — Your `SELDONFRAME_API_KEY` is invalid or expired. Regenerate at <https://app.seldonframe.com/settings/api>, or unset the env var to fall back to your device token.
110
-
111
- **`Seldon API 402`** — You tried a Pro capability without a key. Set `SELDONFRAME_API_KEY` and restart.
112
-
113
- **Want to reset device state?** Delete `~/.seldonframe/device.json`. Your hosted workspaces stay live at `app.seldonframe.com`; only the local tokens are cleared.
1
+ <!-- mcp-name: io.github.seldonframe/mcp -->
2
+ # @seldonframe/mcp
3
+
4
+ Official SeldonFrame MCP server for Claude Code and Claude Desktop.
5
+
6
+ **One command, one real workspace.** The first time you run it, no API key is needed. Say `create_workspace({ name: "Dental Clinic Laval" })` and the server mints a real hosted workspace on `dental-clinic-laval.app.seldonframe.com` — with CRM, Cal.diy booking, Formbricks intake, and Brain v2 pre-installed — and returns live URLs. No signup wall, no "guest mode", no claim step.
7
+
8
+ ## 60-second quickstart
9
+
10
+ ```bash
11
+ # 1. From the repo root
12
+ cd skills/mcp-server && npm install
13
+
14
+ # 2. Register with Claude Code — no env vars needed
15
+ claude mcp add seldonframe -s user -- node "$(pwd)/src/index.js"
16
+
17
+ # 3. In Claude Code, just ask:
18
+ # create_workspace({ name: "My Business OS" })
19
+ ```
20
+
21
+ That call returns something like:
22
+
23
+ ```json
24
+ {
25
+ "workspace": { "id": "wsp_…", "slug": "my-business-os", "tier": "free" },
26
+ "urls": {
27
+ "dashboard": "https://my-business-os.app.seldonframe.com",
28
+ "book": "https://my-business-os.app.seldonframe.com/book",
29
+ "intake": "https://my-business-os.app.seldonframe.com/intake"
30
+ },
31
+ "installed": ["crm", "caldiy-booking", "formbricks-intake", "brain-v2"],
32
+ "next": [ "…" ]
33
+ }
34
+ ```
35
+
36
+ Every subsequent tool response includes a `next:` array — follow the rails.
37
+
38
+ ## How auth works
39
+
40
+ | Situation | What happens |
41
+ |---|---|
42
+ | First ever call | `create_workspace` POSTs with no auth. Server mints a workspace + bearer token. MCP stores the token in `~/.seldonframe/device.json`. |
43
+ | Subsequent calls | MCP sends `Authorization: Bearer <workspace token>` automatically. |
44
+ | `SELDONFRAME_API_KEY` set | Takes precedence over device tokens. Unlocks Pro capabilities. |
45
+
46
+ You never have to juggle modes. The first workspace is free forever.
47
+
48
+ ## When you need `SELDONFRAME_API_KEY`
49
+
50
+ - Adding a **second workspace**
51
+ - Connecting a **custom domain**
52
+ - **Full Brain v2** intelligence (heuristic Brain is always free)
53
+ - Publishing, exporting, org-scoped secret rotation
54
+
55
+ Get one at <https://app.seldonframe.com/settings/api>, then:
56
+
57
+ ```bash
58
+ export SELDONFRAME_API_KEY=sk-…
59
+ ```
60
+
61
+ Restart the MCP server. Your existing workspaces continue to work untouched.
62
+
63
+ ## Install via plugin manifest (one-shot)
64
+
65
+ The repo ships a `.claude-plugin/plugin.json` at the root. From inside Claude Code:
66
+
67
+ ```
68
+ /plugin install <path-to-repo>
69
+ ```
70
+
71
+ ## Install via npm
72
+
73
+ ```bash
74
+ claude mcp add seldonframe -- npx -y @seldonframe/mcp
75
+ ```
76
+
77
+ This is the canonical install command surfaced on the marketing site,
78
+ the `/docs/quickstart` page, and the repo README. It uses `npx -y`
79
+ so users never have to globally install anything; the latest version
80
+ is fetched and run on demand.
81
+
82
+ ## Environment
83
+
84
+ - `SELDONFRAME_API_KEY` *(optional)* — Enables Pro capabilities (second workspace, custom domains, full Brain v2).
85
+ - `SELDONFRAME_API_BASE` *(optional)* — Override the API base URL. Defaults to `https://app.seldonframe.com/api/v1`.
86
+
87
+ ## Tools
88
+
89
+ **Workspace:** `create_workspace`, `list_workspaces`, `switch_workspace`, `clone_workspace`, `link_workspace_owner`, `get_workspace_snapshot`.
90
+ **Blocks:** `install_caldiy_booking`, `install_formbricks_intake`, `install_vertical_pack`.
91
+ **Customize (typed, no backend LLM):** `update_landing_content`, `customize_intake_form`, `configure_booking`, `update_theme`.
92
+ **Soul:** `fetch_source_for_soul`, `submit_soul`.
93
+ **Ops:** `list_automations`, `connect_custom_domain`, `export_agent`, `store_secret`, `list_secrets`, `rotate_secret`.
94
+
95
+ ### Architecture: zero backend LLM cost
96
+
97
+ Natural-language reasoning happens in the MCP session (Claude Code on the user's side). The backend only accepts **structured** commands and applies them deterministically. There is no `seldon_it` endpoint that parses prompts server-side; the old `query_brain` is replaced by `get_workspace_snapshot`, which returns raw state for Claude to reason over. Seldon spends $0 on LLM for the free tier — the user's Claude Code subscription is the reasoning engine.
98
+
99
+ ### Soul compilation (zero cost to Seldon)
100
+
101
+ Soul compilation runs in **your** Claude Code session, not on Seldon's servers:
102
+
103
+ 1. `fetch_source_for_soul({ url })` — returns up to 256KB of normalized text.
104
+ 2. You (the agent) extract a structured Soul object.
105
+ 3. `submit_soul({ soul })` — persists it to the workspace.
106
+
107
+ ## Troubleshooting
108
+
109
+ **`Seldon API 401`** — Your `SELDONFRAME_API_KEY` is invalid or expired. Regenerate at <https://app.seldonframe.com/settings/api>, or unset the env var to fall back to your device token.
110
+
111
+ **`Seldon API 402`** — You tried a Pro capability without a key. Set `SELDONFRAME_API_KEY` and restart.
112
+
113
+ **Want to reset device state?** Delete `~/.seldonframe/device.json`. Your hosted workspaces stay live at `app.seldonframe.com`; only the local tokens are cleared.
114
+
115
+ ## Privacy Policy
116
+
117
+ Full policy: **<https://www.seldonframe.com/privacy>**
118
+
119
+ - **Data collection.** The connector runs locally and sends only what you ask it to send: the structured tool commands and business content (workspace names, URLs you point it at, contact and booking details you create) go to `app.seldonframe.com` over HTTPS. The only thing it stores on your machine is a workspace bearer token in `~/.seldonframe/device.json`.
120
+ - **Usage and storage.** Data you submit is stored in your hosted SeldonFrame workspace and used to run it (website, CRM, booking, agents). It is not used to train AI models.
121
+ - **Third-party sharing.** We don't sell your data. Workspace features rely on infrastructure subprocessors (hosting, database, email/SMS delivery when you configure them) as described in the policy above.
122
+ - **Retention.** Workspace data is retained while your workspace is active and deleted on account deletion or on request. The local device token can be removed at any time by deleting `~/.seldonframe/device.json`.
123
+ - **Contact.** <hello@seldonframe.com>
124
+
125
+ ## License
126
+
127
+ This package (`skills/mcp-server`, the SeldonFrame MCP connector) is [MIT-licensed](./LICENSE). The SeldonFrame platform in the rest of the repository remains AGPL-3.0 — see the repository root `LICENSE`.
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@seldonframe/mcp",
3
- "version": "1.59.2",
3
+ "version": "1.60.0",
4
4
  "mcpName": "io.github.seldonframe/mcp",
5
- "description": "Open-source GoHighLevel alternative for agencies. 146+ MCP tools that let Claude Code spin up white-labeled client workspaces — CRM, booking, intake forms, landing pages, AI chatbot, and pre-wired agent archetypes (speed-to-lead, missed-call-text-back, review-requester) — in minutes. AGPL-3.0.",
6
- "license": "AGPL-3.0-or-later",
5
+ "description": "Open-source GoHighLevel alternative for agencies. 146+ MCP tools that let Claude Code spin up white-labeled client workspaces — CRM, booking, intake forms, landing pages, AI chatbot, and pre-wired agent archetypes (speed-to-lead, missed-call-text-back, review-requester) — in minutes. MIT-licensed connector (platform: AGPL-3.0).",
6
+ "license": "MIT",
7
7
  "type": "module",
8
8
  "bin": {
9
9
  "seldonframe-mcp": "src/index.js"
@@ -19,7 +19,8 @@
19
19
  "start": "node src/index.js",
20
20
  "check:syntax": "node --check --input-type=module < src/index.js && node --check --input-type=module < src/client.js && node --check --input-type=module < src/tools.js && node --check --input-type=module < src/welcome.js && node --check --input-type=module < src/security.js",
21
21
  "test": "node --test tests/*.test.mjs",
22
- "prepublishOnly": "npm run check:syntax && npm run test"
22
+ "prepublishOnly": "npm run check:syntax && npm run test",
23
+ "build:mcpb": "npm ci --omit=dev && npx @anthropic-ai/mcpb pack . seldonframe.mcpb"
23
24
  },
24
25
  "dependencies": {
25
26
  "@modelcontextprotocol/sdk": "^1.0.4"
package/src/security.js CHANGED
@@ -1,241 +1,241 @@
1
- // v1.59.2 — security hardening helpers shared by tools.js.
2
- //
3
- // Two independent concerns live here:
4
- // 1. sniffImageKind — magic-byte detection so upload_workspace_image only
5
- // ever forwards bytes that are actually image files (not arbitrary
6
- // local files an agent was pointed at).
7
- // 2. assertPublicHttpUrl — an SSRF guard for machine-side fetches of
8
- // operator-supplied URLs (fetch_source_for_soul today). Rejects
9
- // loopback / private / link-local targets so the MCP process can't be
10
- // used to probe the operator's own LAN or cloud metadata endpoints.
11
- //
12
- // Both are pure-ish (assertPublicHttpUrl takes an injectable `lookup` for
13
- // tests) and have zero dependency on the rest of tools.js so they can be
14
- // unit-tested in isolation (see tests/security.test.mjs).
15
-
16
- const MAX_SVG_BYTES = 1 * 1024 * 1024; // 1MB
17
-
18
- // Loose but sufficient: optional UTF-8 BOM, optional XML prolog, optional
19
- // DOCTYPE, then an <svg ...> or <svg> root tag. Case-insensitive.
20
- const SVG_PATTERN =
21
- /^?\s*(<\?xml[^>]*>\s*)?(<!DOCTYPE[^>]*>\s*)?<svg[\s>]/i;
22
-
23
- /**
24
- * Sniff the image kind of a buffer from its magic bytes. Returns one of
25
- * "png" | "jpeg" | "gif" | "webp" | "svg", or null if the buffer doesn't
26
- * look like a recognized image format.
27
- *
28
- * @param {Buffer} buffer
29
- * @returns {"png"|"jpeg"|"gif"|"webp"|"svg"|null}
30
- */
31
- export function sniffImageKind(buffer) {
32
- if (!Buffer.isBuffer(buffer) || buffer.length === 0) return null;
33
-
34
- // PNG: 89 50 4E 47 (plus the usual 0D 0A 1A 0A trailer, but the first
35
- // four bytes are sufficient to identify it unambiguously).
36
- if (
37
- buffer.length >= 4 &&
38
- buffer[0] === 0x89 &&
39
- buffer[1] === 0x50 &&
40
- buffer[2] === 0x4e &&
41
- buffer[3] === 0x47
42
- ) {
43
- return "png";
44
- }
45
-
46
- // JPEG: FF D8 FF
47
- if (
48
- buffer.length >= 3 &&
49
- buffer[0] === 0xff &&
50
- buffer[1] === 0xd8 &&
51
- buffer[2] === 0xff
52
- ) {
53
- return "jpeg";
54
- }
55
-
56
- // GIF: ASCII "GIF8" (covers both GIF87a and GIF89a).
57
- if (buffer.length >= 4 && buffer.toString("ascii", 0, 4) === "GIF8") {
58
- return "gif";
59
- }
60
-
61
- // WebP: "RIFF" at offset 0, "WEBP" at offset 8.
62
- if (
63
- buffer.length >= 12 &&
64
- buffer.toString("ascii", 0, 4) === "RIFF" &&
65
- buffer.toString("ascii", 8, 12) === "WEBP"
66
- ) {
67
- return "webp";
68
- }
69
-
70
- // SVG: text-based, so magic bytes don't apply — sniff the text instead.
71
- // Bound the size before decoding so a huge non-image file doesn't get
72
- // fully UTF-8-decoded just to fail this check.
73
- if (buffer.length <= MAX_SVG_BYTES) {
74
- const text = buffer.toString("utf8");
75
- if (SVG_PATTERN.test(text)) return "svg";
76
- }
77
-
78
- return null;
79
- }
80
-
81
- // ── SSRF guard ──────────────────────────────────────────────────────────
82
-
83
- const BLOCKED_HOSTNAME_SUFFIXES = [".localhost", ".local", ".internal"];
84
-
85
- function isBlockedHostname(hostname) {
86
- const host = hostname.toLowerCase();
87
- if (host === "localhost") return true;
88
- return BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => host.endsWith(suffix));
89
- }
90
-
91
- /**
92
- * Parse an IPv4 dotted-quad string into four octet numbers, or null if it
93
- * isn't a valid literal IPv4 address.
94
- * @param {string} host
95
- * @returns {[number,number,number,number]|null}
96
- */
97
- function parseIPv4(host) {
98
- const parts = host.split(".");
99
- if (parts.length !== 4) return null;
100
- const octets = [];
101
- for (const part of parts) {
102
- if (!/^\d{1,3}$/.test(part)) return null;
103
- const n = Number(part);
104
- if (n < 0 || n > 255) return null;
105
- octets.push(n);
106
- }
107
- return octets;
108
- }
109
-
110
- /**
111
- * Is this IPv4 (as an octet quad) inside a non-public range?
112
- * @param {[number,number,number,number]} o
113
- */
114
- function isNonPublicIPv4(o) {
115
- const [a, b] = o;
116
- if (a === 0) return true; // 0.0.0.0/8
117
- if (a === 10) return true; // 10/8
118
- if (a === 127) return true; // 127/8 (loopback)
119
- if (a === 169 && b === 254) return true; // 169.254/16 (link-local)
120
- if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12
121
- if (a === 192 && b === 168) return true; // 192.168/16
122
- if (a === 100 && b >= 64 && b <= 127) return true; // 100.64/10 (CGNAT)
123
- return false;
124
- }
125
-
126
- /**
127
- * Is this literal address (v4 or v6 string form) non-public? Handles
128
- * IPv4-mapped IPv6 (::ffff:x.x.x.x) by unwrapping to the embedded IPv4.
129
- * @param {string} address
130
- */
131
- function isNonPublicIpLiteral(address) {
132
- const addr = address.toLowerCase();
133
-
134
- // IPv4-mapped IPv6: ::ffff:x.x.x.x (or the rarer ::ffff:0:x.x.x.x form).
135
- const mappedMatch = addr.match(/^::ffff:(?:0:)?(\d{1,3}(?:\.\d{1,3}){3})$/);
136
- if (mappedMatch) {
137
- const v4 = parseIPv4(mappedMatch[1]);
138
- return v4 ? isNonPublicIPv4(v4) : true; // malformed embedded v4 = reject
139
- }
140
-
141
- const v4 = parseIPv4(addr);
142
- if (v4) return isNonPublicIPv4(v4);
143
-
144
- // IPv6 literal checks.
145
- if (addr === "::1") return true; // loopback
146
- if (addr === "::") return true; // unspecified
147
- if (/^fc[0-9a-f]{2}:/.test(addr) || /^fd[0-9a-f]{2}:/.test(addr)) {
148
- return true; // fc00::/7 (unique local) — first byte 0xfc or 0xfd
149
- }
150
- if (/^fe[89ab][0-9a-f]:/.test(addr)) return true; // fe80::/10 (link-local)
151
-
152
- // Not a recognized literal at all (shouldn't happen given callers only
153
- // pass here after confirming it parses as an IP) — fail closed.
154
- return false;
155
- }
156
-
157
- /**
158
- * Does this string parse as a literal IP address (v4 or v6)? Deliberately
159
- * simple — good enough to distinguish "the caller already resolved this to
160
- * an IP" from "this is a hostname that still needs DNS resolution."
161
- * @param {string} host
162
- */
163
- function isIpLiteral(host) {
164
- if (parseIPv4(host)) return true;
165
- // Very small heuristic for IPv6 literal form: contains a colon and only
166
- // hex digits / colons (optionally with an embedded dotted-quad tail for
167
- // the IPv4-mapped form).
168
- return /^[0-9a-f:]+(:\d{1,3}(?:\.\d{1,3}){3})?$/i.test(host) && host.includes(":");
169
- }
170
-
171
- /**
172
- * Assert that a URL is safe for the MCP server (running on the operator's
173
- * machine) to fetch directly: http(s) only, and resolves exclusively to
174
- * public IP addresses. Throws with a clear message otherwise.
175
- *
176
- * Guards against SSRF-style abuse where an operator-supplied "scrape this
177
- * URL" argument actually points at localhost, a LAN device, or a cloud
178
- * metadata endpoint (169.254.169.254) reachable from wherever the MCP
179
- * process happens to run.
180
- *
181
- * @param {string} urlString
182
- * @param {{ lookup?: (hostname: string, opts: { all: true }) => Promise<{address:string,family:number}[]> }} [opts]
183
- */
184
- export async function assertPublicHttpUrl(urlString, opts = {}) {
185
- let parsed;
186
- try {
187
- parsed = new URL(urlString);
188
- } catch {
189
- throw new Error(`assertPublicHttpUrl: "${urlString}" is not a valid URL.`);
190
- }
191
-
192
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
193
- throw new Error(
194
- `assertPublicHttpUrl: protocol "${parsed.protocol}" is not allowed for "${urlString}" — only http: and https: are permitted.`,
195
- );
196
- }
197
-
198
- // URL.hostname strips brackets from IPv6 literals (e.g. "[::1]" -> "::1").
199
- const hostname = parsed.hostname;
200
-
201
- if (isBlockedHostname(hostname)) {
202
- throw new Error(
203
- `assertPublicHttpUrl: hostname "${hostname}" is blocked (localhost / .localhost / .local / .internal).`,
204
- );
205
- }
206
-
207
- if (isIpLiteral(hostname)) {
208
- if (isNonPublicIpLiteral(hostname)) {
209
- throw new Error(
210
- `assertPublicHttpUrl: "${hostname}" is not a public IP address.`,
211
- );
212
- }
213
- return;
214
- }
215
-
216
- const lookup =
217
- opts.lookup ?? (await import("node:dns/promises")).lookup;
218
- let records;
219
- try {
220
- records = await lookup(hostname, { all: true });
221
- } catch (err) {
222
- throw new Error(
223
- `assertPublicHttpUrl: DNS lookup for "${hostname}" failed — ${err?.message ?? err}`,
224
- );
225
- }
226
-
227
- if (!Array.isArray(records) || records.length === 0) {
228
- throw new Error(
229
- `assertPublicHttpUrl: DNS lookup for "${hostname}" returned no addresses.`,
230
- );
231
- }
232
-
233
- for (const record of records) {
234
- const address = record?.address ?? record;
235
- if (typeof address !== "string" || isNonPublicIpLiteral(address)) {
236
- throw new Error(
237
- `assertPublicHttpUrl: "${hostname}" resolves to a non-public address (${address}) — refusing to fetch.`,
238
- );
239
- }
240
- }
241
- }
1
+ // v1.59.2 — security hardening helpers shared by tools.js.
2
+ //
3
+ // Two independent concerns live here:
4
+ // 1. sniffImageKind — magic-byte detection so upload_workspace_image only
5
+ // ever forwards bytes that are actually image files (not arbitrary
6
+ // local files an agent was pointed at).
7
+ // 2. assertPublicHttpUrl — an SSRF guard for machine-side fetches of
8
+ // operator-supplied URLs (fetch_source_for_soul today). Rejects
9
+ // loopback / private / link-local targets so the MCP process can't be
10
+ // used to probe the operator's own LAN or cloud metadata endpoints.
11
+ //
12
+ // Both are pure-ish (assertPublicHttpUrl takes an injectable `lookup` for
13
+ // tests) and have zero dependency on the rest of tools.js so they can be
14
+ // unit-tested in isolation (see tests/security.test.mjs).
15
+
16
+ const MAX_SVG_BYTES = 1 * 1024 * 1024; // 1MB
17
+
18
+ // Loose but sufficient: optional UTF-8 BOM, optional XML prolog, optional
19
+ // DOCTYPE, then an <svg ...> or <svg> root tag. Case-insensitive.
20
+ const SVG_PATTERN =
21
+ /^?\s*(<\?xml[^>]*>\s*)?(<!DOCTYPE[^>]*>\s*)?<svg[\s>]/i;
22
+
23
+ /**
24
+ * Sniff the image kind of a buffer from its magic bytes. Returns one of
25
+ * "png" | "jpeg" | "gif" | "webp" | "svg", or null if the buffer doesn't
26
+ * look like a recognized image format.
27
+ *
28
+ * @param {Buffer} buffer
29
+ * @returns {"png"|"jpeg"|"gif"|"webp"|"svg"|null}
30
+ */
31
+ export function sniffImageKind(buffer) {
32
+ if (!Buffer.isBuffer(buffer) || buffer.length === 0) return null;
33
+
34
+ // PNG: 89 50 4E 47 (plus the usual 0D 0A 1A 0A trailer, but the first
35
+ // four bytes are sufficient to identify it unambiguously).
36
+ if (
37
+ buffer.length >= 4 &&
38
+ buffer[0] === 0x89 &&
39
+ buffer[1] === 0x50 &&
40
+ buffer[2] === 0x4e &&
41
+ buffer[3] === 0x47
42
+ ) {
43
+ return "png";
44
+ }
45
+
46
+ // JPEG: FF D8 FF
47
+ if (
48
+ buffer.length >= 3 &&
49
+ buffer[0] === 0xff &&
50
+ buffer[1] === 0xd8 &&
51
+ buffer[2] === 0xff
52
+ ) {
53
+ return "jpeg";
54
+ }
55
+
56
+ // GIF: ASCII "GIF8" (covers both GIF87a and GIF89a).
57
+ if (buffer.length >= 4 && buffer.toString("ascii", 0, 4) === "GIF8") {
58
+ return "gif";
59
+ }
60
+
61
+ // WebP: "RIFF" at offset 0, "WEBP" at offset 8.
62
+ if (
63
+ buffer.length >= 12 &&
64
+ buffer.toString("ascii", 0, 4) === "RIFF" &&
65
+ buffer.toString("ascii", 8, 12) === "WEBP"
66
+ ) {
67
+ return "webp";
68
+ }
69
+
70
+ // SVG: text-based, so magic bytes don't apply — sniff the text instead.
71
+ // Bound the size before decoding so a huge non-image file doesn't get
72
+ // fully UTF-8-decoded just to fail this check.
73
+ if (buffer.length <= MAX_SVG_BYTES) {
74
+ const text = buffer.toString("utf8");
75
+ if (SVG_PATTERN.test(text)) return "svg";
76
+ }
77
+
78
+ return null;
79
+ }
80
+
81
+ // ── SSRF guard ──────────────────────────────────────────────────────────
82
+
83
+ const BLOCKED_HOSTNAME_SUFFIXES = [".localhost", ".local", ".internal"];
84
+
85
+ function isBlockedHostname(hostname) {
86
+ const host = hostname.toLowerCase();
87
+ if (host === "localhost") return true;
88
+ return BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => host.endsWith(suffix));
89
+ }
90
+
91
+ /**
92
+ * Parse an IPv4 dotted-quad string into four octet numbers, or null if it
93
+ * isn't a valid literal IPv4 address.
94
+ * @param {string} host
95
+ * @returns {[number,number,number,number]|null}
96
+ */
97
+ function parseIPv4(host) {
98
+ const parts = host.split(".");
99
+ if (parts.length !== 4) return null;
100
+ const octets = [];
101
+ for (const part of parts) {
102
+ if (!/^\d{1,3}$/.test(part)) return null;
103
+ const n = Number(part);
104
+ if (n < 0 || n > 255) return null;
105
+ octets.push(n);
106
+ }
107
+ return octets;
108
+ }
109
+
110
+ /**
111
+ * Is this IPv4 (as an octet quad) inside a non-public range?
112
+ * @param {[number,number,number,number]} o
113
+ */
114
+ function isNonPublicIPv4(o) {
115
+ const [a, b] = o;
116
+ if (a === 0) return true; // 0.0.0.0/8
117
+ if (a === 10) return true; // 10/8
118
+ if (a === 127) return true; // 127/8 (loopback)
119
+ if (a === 169 && b === 254) return true; // 169.254/16 (link-local)
120
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12
121
+ if (a === 192 && b === 168) return true; // 192.168/16
122
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64/10 (CGNAT)
123
+ return false;
124
+ }
125
+
126
+ /**
127
+ * Is this literal address (v4 or v6 string form) non-public? Handles
128
+ * IPv4-mapped IPv6 (::ffff:x.x.x.x) by unwrapping to the embedded IPv4.
129
+ * @param {string} address
130
+ */
131
+ function isNonPublicIpLiteral(address) {
132
+ const addr = address.toLowerCase();
133
+
134
+ // IPv4-mapped IPv6: ::ffff:x.x.x.x (or the rarer ::ffff:0:x.x.x.x form).
135
+ const mappedMatch = addr.match(/^::ffff:(?:0:)?(\d{1,3}(?:\.\d{1,3}){3})$/);
136
+ if (mappedMatch) {
137
+ const v4 = parseIPv4(mappedMatch[1]);
138
+ return v4 ? isNonPublicIPv4(v4) : true; // malformed embedded v4 = reject
139
+ }
140
+
141
+ const v4 = parseIPv4(addr);
142
+ if (v4) return isNonPublicIPv4(v4);
143
+
144
+ // IPv6 literal checks.
145
+ if (addr === "::1") return true; // loopback
146
+ if (addr === "::") return true; // unspecified
147
+ if (/^fc[0-9a-f]{2}:/.test(addr) || /^fd[0-9a-f]{2}:/.test(addr)) {
148
+ return true; // fc00::/7 (unique local) — first byte 0xfc or 0xfd
149
+ }
150
+ if (/^fe[89ab][0-9a-f]:/.test(addr)) return true; // fe80::/10 (link-local)
151
+
152
+ // Not a recognized literal at all (shouldn't happen given callers only
153
+ // pass here after confirming it parses as an IP) — fail closed.
154
+ return false;
155
+ }
156
+
157
+ /**
158
+ * Does this string parse as a literal IP address (v4 or v6)? Deliberately
159
+ * simple — good enough to distinguish "the caller already resolved this to
160
+ * an IP" from "this is a hostname that still needs DNS resolution."
161
+ * @param {string} host
162
+ */
163
+ function isIpLiteral(host) {
164
+ if (parseIPv4(host)) return true;
165
+ // Very small heuristic for IPv6 literal form: contains a colon and only
166
+ // hex digits / colons (optionally with an embedded dotted-quad tail for
167
+ // the IPv4-mapped form).
168
+ return /^[0-9a-f:]+(:\d{1,3}(?:\.\d{1,3}){3})?$/i.test(host) && host.includes(":");
169
+ }
170
+
171
+ /**
172
+ * Assert that a URL is safe for the MCP server (running on the operator's
173
+ * machine) to fetch directly: http(s) only, and resolves exclusively to
174
+ * public IP addresses. Throws with a clear message otherwise.
175
+ *
176
+ * Guards against SSRF-style abuse where an operator-supplied "scrape this
177
+ * URL" argument actually points at localhost, a LAN device, or a cloud
178
+ * metadata endpoint (169.254.169.254) reachable from wherever the MCP
179
+ * process happens to run.
180
+ *
181
+ * @param {string} urlString
182
+ * @param {{ lookup?: (hostname: string, opts: { all: true }) => Promise<{address:string,family:number}[]> }} [opts]
183
+ */
184
+ export async function assertPublicHttpUrl(urlString, opts = {}) {
185
+ let parsed;
186
+ try {
187
+ parsed = new URL(urlString);
188
+ } catch {
189
+ throw new Error(`assertPublicHttpUrl: "${urlString}" is not a valid URL.`);
190
+ }
191
+
192
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
193
+ throw new Error(
194
+ `assertPublicHttpUrl: protocol "${parsed.protocol}" is not allowed for "${urlString}" — only http: and https: are permitted.`,
195
+ );
196
+ }
197
+
198
+ // URL.hostname strips brackets from IPv6 literals (e.g. "[::1]" -> "::1").
199
+ const hostname = parsed.hostname;
200
+
201
+ if (isBlockedHostname(hostname)) {
202
+ throw new Error(
203
+ `assertPublicHttpUrl: hostname "${hostname}" is blocked (localhost / .localhost / .local / .internal).`,
204
+ );
205
+ }
206
+
207
+ if (isIpLiteral(hostname)) {
208
+ if (isNonPublicIpLiteral(hostname)) {
209
+ throw new Error(
210
+ `assertPublicHttpUrl: "${hostname}" is not a public IP address.`,
211
+ );
212
+ }
213
+ return;
214
+ }
215
+
216
+ const lookup =
217
+ opts.lookup ?? (await import("node:dns/promises")).lookup;
218
+ let records;
219
+ try {
220
+ records = await lookup(hostname, { all: true });
221
+ } catch (err) {
222
+ throw new Error(
223
+ `assertPublicHttpUrl: DNS lookup for "${hostname}" failed — ${err?.message ?? err}`,
224
+ );
225
+ }
226
+
227
+ if (!Array.isArray(records) || records.length === 0) {
228
+ throw new Error(
229
+ `assertPublicHttpUrl: DNS lookup for "${hostname}" returned no addresses.`,
230
+ );
231
+ }
232
+
233
+ for (const record of records) {
234
+ const address = record?.address ?? record;
235
+ if (typeof address !== "string" || isNonPublicIpLiteral(address)) {
236
+ throw new Error(
237
+ `assertPublicHttpUrl: "${hostname}" resolves to a non-public address (${address}) — refusing to fetch.`,
238
+ );
239
+ }
240
+ }
241
+ }
package/src/tools.js CHANGED
@@ -633,12 +633,17 @@ export const TOOLS = [
633
633
  {
634
634
  name: "send_welcome_email",
635
635
  description:
636
- "Email the active workspace's four key URLs (landing, booking, intake, admin dashboard) to a user. Use this AFTER create_workspace, only when the user has explicitly given their email — never auto-send. The admin URL is bearer-token-scoped and expires in 7 days. Example: send_welcome_email({ email: 'alice@example.com', name: 'Alice' }).",
636
+ "Email the active workspace's four key URLs (landing, booking, intake, admin dashboard) to a user. Use this AFTER create_workspace, only when the user has explicitly given their email — never auto-send. The admin URL is bearer-token-scoped and expires in 7 days. " +
637
+ "If the workspace has an AI chatbot (create_full_workspace's response includes chatbot_embed_snippet + chatbot_agent_id), pass chatbot_url + chatbot_embed_snippet + chatbot_status so the welcome email also shows the chatbot card with its embed snippet — operators otherwise never learn the workspace shipped with a chatbot. All three are optional; omit them to send the classic 4-URL email. " +
638
+ "Example: send_welcome_email({ email: 'alice@example.com', name: 'Alice', chatbot_url: 'https://acme.app.seldonframe.com/chat', chatbot_embed_snippet: '<script src=\"...\" async></script>', chatbot_status: 'test' }).",
637
639
  inputSchema: obj(
638
640
  {
639
641
  email: str("Recipient email address."),
640
642
  name: str("Optional recipient name (used in the greeting)."),
641
643
  workspace_id: str("Optional workspace override. Defaults to active workspace."),
644
+ chatbot_url: str("Optional. Public chatbot URL — include when the workspace has an AI chatbot (see chatbot_embed_snippet + chatbot_agent_id from create_full_workspace's response)."),
645
+ chatbot_embed_snippet: str("Optional. The <script> embed snippet for the chatbot (from create_full_workspace's chatbot_embed_snippet). Required alongside chatbot_url/chatbot_status to render the chatbot card."),
646
+ chatbot_status: str("Optional. \"live\" or \"test\" — reflects the chatbot's current publish status. Defaults the card copy accordingly."),
642
647
  },
643
648
  ["email"],
644
649
  ),
@@ -673,6 +678,18 @@ export const TOOLS = [
673
678
  const appHost = API_INFO.base.replace(/\/api\/v1\/?$/, "");
674
679
  const adminUrl = `${appHost}/admin/${encodeURIComponent(workspaceId)}?token=${encodeURIComponent(bearer)}`;
675
680
 
681
+ // Optional chatbot passthrough — only include the card when the
682
+ // caller supplied enough to render it (url + embed_snippet).
683
+ // Omitted entirely otherwise so this stays backward-compatible.
684
+ const chatbot =
685
+ a.chatbot_url && a.chatbot_embed_snippet
686
+ ? {
687
+ url: a.chatbot_url,
688
+ embed_snippet: a.chatbot_embed_snippet,
689
+ status: a.chatbot_status === "live" ? "live" : "test",
690
+ }
691
+ : undefined;
692
+
676
693
  await api("POST", "/email/send-welcome", {
677
694
  body: {
678
695
  email: a.email,
@@ -682,6 +699,7 @@ export const TOOLS = [
682
699
  booking_url: publicUrls.book,
683
700
  intake_url: publicUrls.intake,
684
701
  admin_url: adminUrl,
702
+ ...(chatbot ? { chatbot } : {}),
685
703
  },
686
704
  },
687
705
  workspace_id: workspaceId,
@@ -4728,7 +4746,7 @@ export const TOOLS = [
4728
4746
  name: "create_agent",
4729
4747
  description:
4730
4748
  "USE WHEN USER SAYS: 'add a chatbot to my website', 'add an AI assistant to my landing page', 'put a chat widget on my site', 'create a website chatbot', 'add an AI agent that answers customer questions', 'I want chat on my homepage', 'build me a chatbot for [business]'. " +
4731
- "DON'T confuse with: list_blocks (chat widgets are NOT a block type — agents are a separate primitive); send_conversation_turn (that's for inbound SMS/email auto-reply, NOT a website widget). If the operator wants chat on their website, THIS is the tool. " +
4749
+ "DON'T confuse with: list_blocks (chat widgets are NOT a block type — agents are a separate primitive); send_conversation_turn (that's for inbound SMS/email auto-reply, NOT a website widget); generate_agent (for an agent that ACTS on bookings/leads/a schedule — review requester, speed-to-lead, follow-up — use generate_agent instead; THIS tool only builds a website chat widget with no trigger). If the operator wants chat on their website, THIS is the tool. " +
4732
4750
  "Creates a new agent for this workspace. Agents are conversational interfaces (web chat, voice, SMS) that answer FAQs, book appointments, and escalate to humans — composed from typed primitives + the workspace's Soul (industry, voice, services). " +
4733
4751
  "WHAT GETS COMPOSED AUTOMATICALLY: persona derived from soul.industry + soul.voice; FAQ knowledge from your `faq` array; pricing facts from `pricing_facts` (validators block any $-amount the agent invents that's not in this list); typed tools (look_up_availability, book_appointment, find_my_existing_appointment, escalate_to_human, provide_faq_answer). " +
4734
4752
  "WHAT YOU PROVIDE: name, archetype (website-chatbot for v1.26.x+; voice-receptionist + sms-followup-bot queued), channel (web_chat / voice / sms / email), inline FAQ pairs, allowed pricing facts, optional greeting. " +
@@ -4928,6 +4946,50 @@ export const TOOLS = [
4928
4946
  },
4929
4947
  },
4930
4948
 
4949
+ // ───────────────────────────────────────────────────────────────────────
4950
+ // v1.60.0 — GENERATE. Build an agent from ONE plain-English sentence. This
4951
+ // is the front door for anything that DOES something on an event/schedule
4952
+ // (review requester, speed-to-lead, after-hours receptionist) — a real,
4953
+ // event-firing agent (trigger + skill + guardrails), not a website widget.
4954
+ // Wires POST /api/v1/agents/generate → the same deterministic generator
4955
+ // (parseAgentIntent → assembleAgentBundle, LLM-classify optional and
4956
+ // fail-soft) the Studio dashboard uses. Returns a template_id; the next
4957
+ // step is ALWAYS deploy_agent to make it live and firing.
4958
+ // ───────────────────────────────────────────────────────────────────────
4959
+
4960
+ {
4961
+ name: "generate_agent",
4962
+ description:
4963
+ "USE WHEN USER SAYS: 'create a google review requester for my dentist office', 'text new leads instantly', 'build a speed-to-lead texter', 'set up an after-hours receptionist', 'make an agent that follows up after every booking'. " +
4964
+ "Build a working AI agent from a plain-English description. It classifies the intent and assembles a deployable agent — trigger (e.g. fires on booking.completed or lead.created, or answers inbound calls/chats) + skill (the playbook it follows) + guardrails (quiet hours, rate caps, safety rubric) — all wired automatically, no manual configuration. " +
4965
+ "Returns a template_id — then call deploy_agent with { source: { template_id } } to make it live and firing on its trigger (e.g. after every booking, or the instant a new lead arrives). " +
4966
+ "USE THIS (not create_agent) for ANY agent that DOES something on an event or a schedule — a review request after a job, an instant reply to a new lead, a scheduled recap. create_agent is ONLY for a website chat widget with no trigger. " +
4967
+ "Delivery note: a freshly generated event-outbound agent (review-requester / speed-to-lead) defaults to EMAIL so it can send a real message today without the workspace connecting a phone number first (see `warnings` in the response); mention texting/SMS explicitly in your description if you want it to try SMS instead — full delivery still needs the workspace's own Twilio connected, surfaced by deploy_agent as a 'telephony' requirement.",
4968
+ inputSchema: obj(
4969
+ {
4970
+ workspace_id: str("Workspace id (bearer workspace)."),
4971
+ description: str(
4972
+ "One plain-English sentence describing the agent to build, e.g. 'a Google review requester for a dentist' or 'a speed-to-lead texter for new website leads'.",
4973
+ ),
4974
+ review_url: str(
4975
+ "Optional: the business's Google review link. Wins over any URL found in the description. Only relevant for a review-requester agent; omit otherwise.",
4976
+ ),
4977
+ },
4978
+ ["workspace_id", "description"],
4979
+ ),
4980
+ handler: async (args) => {
4981
+ const ws = args.workspace_id;
4982
+ const result = await api("POST", "/agents/generate", {
4983
+ body: {
4984
+ description: args.description,
4985
+ review_url: args.review_url ?? undefined,
4986
+ },
4987
+ workspace_id: ws,
4988
+ });
4989
+ return result;
4990
+ },
4991
+ },
4992
+
4931
4993
  // ───────────────────────────────────────────────────────────────────────
4932
4994
  // v1.59.0 — IMPROVE. The 5th verb of the builder ladder: build → test →
4933
4995
  // deploy → sell → improve. `improve_agent` replays an agent's own recent