@seldonframe/mcp 1.59.1 → 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.1",
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"
@@ -17,8 +17,10 @@
17
17
  },
18
18
  "scripts": {
19
19
  "start": "node src/index.js",
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",
21
- "prepublishOnly": "npm run check:syntax"
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
+ "test": "node --test tests/*.test.mjs",
22
+ "prepublishOnly": "npm run check:syntax && npm run test",
23
+ "build:mcpb": "npm ci --omit=dev && npx @anthropic-ai/mcpb pack . seldonframe.mcpb"
22
24
  },
23
25
  "dependencies": {
24
26
  "@modelcontextprotocol/sdk": "^1.0.4"
package/src/client.js CHANGED
@@ -2,6 +2,10 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
2
2
  import { homedir, hostname } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { VERSION } from "./welcome.js";
5
+ // v1.59.2 — SSRF guard for fetchText, which fetches operator-supplied URLs
6
+ // directly from the MCP server's machine (fetch_source_for_soul's
7
+ // underlying implementation). See src/security.js for the guard + tests.
8
+ import { assertPublicHttpUrl } from "./security.js";
5
9
 
6
10
  const API_BASE =
7
11
  process.env.SELDONFRAME_API_BASE ?? "https://app.seldonframe.com/api/v1";
@@ -170,18 +174,48 @@ export async function api(method, path, opts = {}) {
170
174
  return data;
171
175
  }
172
176
 
177
+ // v1.59.2 — this fetches a URL the OPERATOR supplies (fetch_source_for_soul's
178
+ // "scrape my existing website" feature), directly from the machine the MCP
179
+ // server runs on. Without a guard, an operator (or a compromised/malicious
180
+ // upstream prompt) could point this at http://localhost:PORT, a LAN device,
181
+ // or a cloud metadata endpoint and have the MCP process fetch it on their
182
+ // behalf. assertPublicHttpUrl rejects non-public targets; redirects are
183
+ // followed MANUALLY (max 3 hops) so a public URL that 30x's to a private
184
+ // one can't slip past the initial check.
185
+ const MAX_REDIRECTS = 3;
186
+
173
187
  export async function fetchText(url, { maxBytes = 256 * 1024 } = {}) {
174
- const res = await fetch(url, {
175
- headers: { "User-Agent": `seldonframe-mcp/${VERSION} (+soul-compiler)` },
176
- redirect: "follow",
177
- });
188
+ let currentUrl = url;
189
+ let res;
190
+ for (let hop = 0; ; hop++) {
191
+ await assertPublicHttpUrl(currentUrl);
192
+ res = await fetch(currentUrl, {
193
+ headers: { "User-Agent": `seldonframe-mcp/${VERSION} (+soul-compiler)` },
194
+ redirect: "manual",
195
+ });
196
+ const isRedirect = res.status >= 300 && res.status < 400;
197
+ if (!isRedirect) break;
198
+ if (hop >= MAX_REDIRECTS) {
199
+ throw new Error(
200
+ `Fetch ${url} failed: exceeded ${MAX_REDIRECTS} redirects (stopped at ${currentUrl}).`,
201
+ );
202
+ }
203
+ const location = res.headers.get("location");
204
+ if (!location) {
205
+ throw new Error(
206
+ `Fetch ${currentUrl} returned redirect status ${res.status} with no Location header.`,
207
+ );
208
+ }
209
+ // Location may be relative — resolve against the URL that produced it.
210
+ currentUrl = new URL(location, currentUrl).toString();
211
+ }
178
212
  if (!res.ok) {
179
213
  throw new Error(`Fetch ${url} failed: ${res.status} ${res.statusText}`);
180
214
  }
181
215
  const raw = await res.text();
182
216
  const truncated = raw.length > maxBytes;
183
217
  const html = truncated ? raw.slice(0, maxBytes) : raw;
184
- return { html, truncated, status: res.status, final_url: res.url };
218
+ return { html, truncated, status: res.status, final_url: res.url || currentUrl };
185
219
  }
186
220
 
187
221
  export function htmlToText(html) {
@@ -0,0 +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
+ }
package/src/tools.js CHANGED
@@ -31,6 +31,10 @@ import { buildFinalizeSummary } from "./finalize-summary.js";
31
31
  // the agent token budget that v1.10.0's image_data_b64 path was bound by.
32
32
  import { readFileSync } from "node:fs";
33
33
  import path from "node:path";
34
+ // v1.59.2 — security hardening: image-content sniffing (upload_workspace_image)
35
+ // and the SSRF guard for machine-side fetches of operator-supplied URLs
36
+ // (fetch_source_for_soul). See src/security.js for details + tests.
37
+ import { sniffImageKind, assertPublicHttpUrl } from "./security.js";
34
38
 
35
39
  const str = (description, extra = {}) => ({ type: "string", description, ...extra });
36
40
  const obj = (properties, required = []) => ({
@@ -629,12 +633,17 @@ export const TOOLS = [
629
633
  {
630
634
  name: "send_welcome_email",
631
635
  description:
632
- "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' }).",
633
639
  inputSchema: obj(
634
640
  {
635
641
  email: str("Recipient email address."),
636
642
  name: str("Optional recipient name (used in the greeting)."),
637
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."),
638
647
  },
639
648
  ["email"],
640
649
  ),
@@ -669,6 +678,18 @@ export const TOOLS = [
669
678
  const appHost = API_INFO.base.replace(/\/api\/v1\/?$/, "");
670
679
  const adminUrl = `${appHost}/admin/${encodeURIComponent(workspaceId)}?token=${encodeURIComponent(bearer)}`;
671
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
+
672
693
  await api("POST", "/email/send-welcome", {
673
694
  body: {
674
695
  email: a.email,
@@ -678,6 +699,7 @@ export const TOOLS = [
678
699
  booking_url: publicUrls.book,
679
700
  intake_url: publicUrls.intake,
680
701
  admin_url: adminUrl,
702
+ ...(chatbot ? { chatbot } : {}),
681
703
  },
682
704
  },
683
705
  workspace_id: workspaceId,
@@ -1399,10 +1421,13 @@ export const TOOLS = [
1399
1421
  {
1400
1422
  name: "fetch_source_for_soul",
1401
1423
  description:
1402
- "Fetch a URL and return normalized text (headings + body, up to 256KB). Use this to gather raw content from the operator's existing website; then extract a structured business profile and save it with submit_soul. Zero LLM cost to SeldonFrame — extraction runs in this session.",
1424
+ "Fetch a URL and return normalized text (headings + body, up to 256KB). Use this to gather raw content from the operator's existing website; then extract a structured business profile and save it with submit_soul. Zero LLM cost to SeldonFrame — extraction runs in this session. " +
1425
+ "v1.59.2 — only public http(s) URLs are fetched: the MCP server rejects localhost / private-network / link-local targets (and any redirect that lands on one) before making the request. Use this for the operator's real, public-facing website only.",
1403
1426
  inputSchema: obj(
1404
1427
  {
1405
- url: str("Absolute URL to fetch."),
1428
+ url: str(
1429
+ "Absolute URL to fetch. Must be a public http(s) URL — localhost, LAN, and other private-network addresses are rejected.",
1430
+ ),
1406
1431
  },
1407
1432
  ["url"],
1408
1433
  ),
@@ -4088,6 +4113,7 @@ export const TOOLS = [
4088
4113
  "(b) `local_file_path` (v1.10.1+) — absolute path on the operator's machine. The MCP server (running locally) reads the file and forwards bytes to the backend. Best path for files on the operator's desktop. file_name + content_type derived from the path. " +
4089
4114
  "(c) `image_data_b64` (legacy v1.10.0) — image bytes base64-encoded. Use only when you've generated bytes yourself (e.g. dynamic image gen) and there's no URL or path. Be aware: the encoded string consumes your tool-call token budget; for files >~12 KB raw, prefer (a) or (b). " +
4090
4115
  "Max 5 MB across all paths. Allowed types: image/png, image/jpeg, image/webp, image/svg+xml, image/gif. " +
4116
+ "v1.59.2 — for local_file_path and image_data_b64, the MCP server sniffs the actual bytes (magic numbers / SVG root tag) before uploading anything; a file that isn't a recognized image format is rejected locally and never reaches the network. This tool cannot be used to exfiltrate arbitrary local files under an image_url. " +
4091
4117
  "Returns the public Blob URL on success; that URL is now live on the workspace's public surface within seconds. " +
4092
4118
  "Antifragile design: server only validates file shape + applies URL to the right column. Your LLM picks which slot ('they said logo, that maps to slot=logo'). As you get better at intent-mapping, the harness doesn't change.",
4093
4119
  inputSchema: obj(
@@ -4162,6 +4188,26 @@ export const TOOLS = [
4162
4188
  `upload_workspace_image: failed to read local_file_path "${filePath}" — ${err?.message ?? err}`,
4163
4189
  );
4164
4190
  }
4191
+ // v1.59.2 — this is the local-file-read path (the MCP server reads
4192
+ // whatever absolute path it's given). Enforce the size cap and
4193
+ // sniff the actual bytes BEFORE they're base64'd and sent anywhere,
4194
+ // so this tool can only ever upload real images — not an
4195
+ // agent-mediated read of an arbitrary local file.
4196
+ if (buf.length > 5 * 1024 * 1024) {
4197
+ return {
4198
+ ok: false,
4199
+ error: "file_too_large",
4200
+ message: `upload_workspace_image: "${filePath}" is ${buf.length} bytes, over the 5MB max.`,
4201
+ };
4202
+ }
4203
+ if (sniffImageKind(buf) === null) {
4204
+ return {
4205
+ ok: false,
4206
+ error: "not_an_image",
4207
+ message:
4208
+ "Not a recognized image file (png, jpeg, gif, webp, svg). This tool only uploads images.",
4209
+ };
4210
+ }
4165
4211
  // Derive file_name + content_type from the path extension, mirroring
4166
4212
  // the server-side image_url logic so the two paths feel identical
4167
4213
  // to the operator.
@@ -4186,12 +4232,40 @@ export const TOOLS = [
4186
4232
  }
4187
4233
  } else {
4188
4234
  // image_data_b64 — caller supplied bytes directly.
4189
- body.image_data_b64 = args.image_data_b64;
4190
4235
  if (!args.file_name || !args.content_type) {
4191
4236
  throw new Error(
4192
4237
  "upload_workspace_image: file_name and content_type are required with image_data_b64. (image_url and local_file_path auto-derive them.)",
4193
4238
  );
4194
4239
  }
4240
+ // v1.59.2 — same bytes-in, sniff-before-send discipline as the
4241
+ // local_file_path branch above (this tool only uploads images,
4242
+ // regardless of which of the three sources supplied the bytes).
4243
+ let decoded;
4244
+ try {
4245
+ decoded = Buffer.from(args.image_data_b64, "base64");
4246
+ } catch (err) {
4247
+ return {
4248
+ ok: false,
4249
+ error: "invalid_base64",
4250
+ message: `upload_workspace_image: image_data_b64 could not be decoded — ${err?.message ?? err}`,
4251
+ };
4252
+ }
4253
+ if (decoded.length > 5 * 1024 * 1024) {
4254
+ return {
4255
+ ok: false,
4256
+ error: "file_too_large",
4257
+ message: `upload_workspace_image: decoded image_data_b64 is ${decoded.length} bytes, over the 5MB max.`,
4258
+ };
4259
+ }
4260
+ if (sniffImageKind(decoded) === null) {
4261
+ return {
4262
+ ok: false,
4263
+ error: "not_an_image",
4264
+ message:
4265
+ "Not a recognized image file (png, jpeg, gif, webp, svg). This tool only uploads images.",
4266
+ };
4267
+ }
4268
+ body.image_data_b64 = args.image_data_b64;
4195
4269
  body.file_name = args.file_name;
4196
4270
  body.content_type = args.content_type;
4197
4271
  }
@@ -4588,7 +4662,8 @@ export const TOOLS = [
4588
4662
  "The OPERATOR pays the LLM provider directly (Anthropic / OpenAI / etc.); SF charges separately for agent platform usage. " +
4589
4663
  "Stored encrypted at rest using the deployment's ENCRYPTION_KEY. " +
4590
4664
  "Operators get keys from console.anthropic.com (recommended for v1.26.x — best tool-use support) or platform.openai.com. " +
4591
- "v1.28+ AUTO-DETECT: pass api_key='env' (or omit api_key entirely) and the MCP server will read process.env.ANTHROPIC_API_KEY / OPENAI_API_KEY from its own environment. Most Claude Code users already have this set (it's how Claude Code works), so this lets a solo SF client onboard with zero key-paste step. Returns { ok: false, error: 'no_env_key' } if the env var isn't set; in that case the user must paste the key explicitly. " +
4665
+ "v1.28+ AUTO-DETECT: pass api_key='env' (or omit api_key entirely) to read process.env.ANTHROPIC_API_KEY / OPENAI_API_KEY from the MCP server's own environment. " +
4666
+ "v1.59.2 — CONSENT REQUIRED before storing an env-detected key: ask the user first ('I found an Anthropic key in your environment — OK to store it in your SeldonFrame workspace?'), then re-call with confirm_store_env_key: true. Without that flag, this tool reports the env var it found and stops — it does NOT transmit the value. A key passed explicitly via api_key is consent by definition and is never gated. Returns { ok: false, error: 'no_env_key' } if the env var isn't set; in that case the user must paste the key explicitly. " +
4592
4667
  "Skip if the workspace already has a key — agents fail-graceful with 'I'm not set up yet' if no key configured, so a 'not configured' chatbot response means CALL THIS TOOL.",
4593
4668
  inputSchema: obj(
4594
4669
  {
@@ -4600,8 +4675,13 @@ export const TOOLS = [
4600
4675
  "LLM provider. v1.26 ships full Anthropic support (tool use, streaming-ready). OpenAI support for chat is partial — recommend Anthropic for production agents.",
4601
4676
  },
4602
4677
  api_key: str(
4603
- "API key. Anthropic keys start with 'sk-ant-...'. Stored encrypted; never echoed back. v1.28+ AUTO-DETECT: pass 'env' (literal string) or omit entirely to read process.env.{ANTHROPIC,OPENAI}_API_KEY from the MCP server's local environment.",
4678
+ "API key. Anthropic keys start with 'sk-ant-...'. Stored encrypted; never echoed back. v1.28+ AUTO-DETECT: pass 'env' (literal string) or omit entirely to read process.env.{ANTHROPIC,OPENAI}_API_KEY from the MCP server's local environment (subject to the confirm_store_env_key consent gate below). Passing the key explicitly here IS consent — it's never gated.",
4604
4679
  ),
4680
+ confirm_store_env_key: {
4681
+ type: "boolean",
4682
+ description:
4683
+ "Set true ONLY after the user has explicitly confirmed storing the API key found in this machine's environment variables in their SeldonFrame workspace.",
4684
+ },
4605
4685
  },
4606
4686
  ["workspace_id", "provider"],
4607
4687
  ),
@@ -4628,6 +4708,20 @@ export const TOOLS = [
4628
4708
  envName,
4629
4709
  };
4630
4710
  }
4711
+ // v1.59.2 — CONSENT GATE. Finding a key in the environment is not
4712
+ // the same as the operator agreeing to store it server-side.
4713
+ // Without explicit confirm_store_env_key: true, stop here and
4714
+ // tell the agent to go get consent — never transmit the value.
4715
+ if (args.confirm_store_env_key !== true) {
4716
+ return {
4717
+ ok: true,
4718
+ needs_consent: true,
4719
+ env_var_found: envName,
4720
+ message:
4721
+ `Found ${envName} in the environment. SeldonFrame can store this key in the workspace (server-side, used only to power the hosted chatbot/agent). ` +
4722
+ `Ask the user for permission first, then re-call this tool with confirm_store_env_key: true.`,
4723
+ };
4724
+ }
4631
4725
  }
4632
4726
 
4633
4727
  const result = await api("POST", "/agents", {
@@ -4652,7 +4746,7 @@ export const TOOLS = [
4652
4746
  name: "create_agent",
4653
4747
  description:
4654
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]'. " +
4655
- "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. " +
4656
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). " +
4657
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). " +
4658
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. " +
@@ -4852,6 +4946,50 @@ export const TOOLS = [
4852
4946
  },
4853
4947
  },
4854
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
+
4855
4993
  // ───────────────────────────────────────────────────────────────────────
4856
4994
  // v1.59.0 — IMPROVE. The 5th verb of the builder ladder: build → test →
4857
4995
  // deploy → sell → improve. `improve_agent` replays an agent's own recent
@@ -5009,7 +5147,8 @@ export const TOOLS = [
5009
5147
  "(2) creates a website-chatbot agent with the FAQ + pricing facts + greeting you provide; " +
5010
5148
  "(3) publishes to status='test' so the operator can sandbox-test before going live (the eval gate runs only on 'live'); " +
5011
5149
  "(4) returns the embed snippet, dashboard URL, and clear next-steps. " +
5012
- "USE THIS as the default for natural-language 'create a chatbot' requests. Fall back to the primitive tools (configure_llm_provider + create_agent + publish_agent) only when you need a custom flow (e.g. agency managing multiple operators with separate Anthropic billing — pass anthropic_api_key explicitly per workspace).",
5150
+ "USE THIS as the default for natural-language 'create a chatbot' requests. Fall back to the primitive tools (configure_llm_provider + create_agent + publish_agent) only when you need a custom flow (e.g. agency managing multiple operators with separate Anthropic billing — pass anthropic_api_key explicitly per workspace). " +
5151
+ "v1.59.2 — CONSENT REQUIRED before storing an env-detected key: if no anthropic_api_key is passed and process.env.ANTHROPIC_API_KEY is found, this tool does NOT store it on the first call. It reports what it found and stops (nothing else in the bundle runs yet). Ask the user for permission, then re-call with confirm_store_env_key: true to proceed with the full bundle. If neither an explicit key nor an env key is present, the bundle proceeds anyway on the SeldonFrame platform key — no consent needed because nothing of the operator's is transmitted.",
5013
5152
  inputSchema: obj(
5014
5153
  {
5015
5154
  workspace_id: str("Workspace id (bearer workspace)."),
@@ -5045,8 +5184,13 @@ export const TOOLS = [
5045
5184
  "First message shown when chat opens (~120 chars). E.g. 'Hi! Asking about HVAC service in Phoenix? I can book you in or answer common questions.' Default if omitted: 'Hi! How can I help you today?'",
5046
5185
  ),
5047
5186
  anthropic_api_key: str(
5048
- "Optional explicit Anthropic API key (sk-ant-...). If omitted, reads from process.env.ANTHROPIC_API_KEY in the MCP server's environment. Pass explicitly for white-label scenarios (different operator = different Anthropic billing). Skipped entirely if the workspace already has a key configured.",
5187
+ "Optional explicit Anthropic API key (sk-ant-...). If omitted, reads from process.env.ANTHROPIC_API_KEY in the MCP server's environment (subject to the confirm_store_env_key consent gate below). Pass explicitly for white-label scenarios (different operator = different Anthropic billing) — passing it here IS consent, never gated. Skipped entirely if the workspace already has a key configured.",
5049
5188
  ),
5189
+ confirm_store_env_key: {
5190
+ type: "boolean",
5191
+ description:
5192
+ "Set true ONLY after the user has explicitly confirmed storing the API key found in this machine's environment variables in their SeldonFrame workspace.",
5193
+ },
5050
5194
  },
5051
5195
  ["workspace_id", "name"],
5052
5196
  ),
@@ -5072,6 +5216,24 @@ export const TOOLS = [
5072
5216
  // that v1.40.9 Sunset Plumbing test exposed.
5073
5217
  const explicitKey = args.anthropic_api_key;
5074
5218
  const envKey = process.env.ANTHROPIC_API_KEY;
5219
+
5220
+ // v1.59.2 — CONSENT GATE. An env-inherited key is about to be
5221
+ // transmitted to the SF API and stored on the workspace; the
5222
+ // operator hasn't agreed to that yet just by virtue of having the
5223
+ // var set in their shell. Stop the ENTIRE bundle here (before
5224
+ // create_agent / publish_agent run) and ask first. An explicit
5225
+ // anthropic_api_key arg is consent by definition and skips this.
5226
+ if (!explicitKey && envKey && args.confirm_store_env_key !== true) {
5227
+ return {
5228
+ ok: true,
5229
+ needs_consent: true,
5230
+ env_var_found: "ANTHROPIC_API_KEY",
5231
+ message:
5232
+ "Found ANTHROPIC_API_KEY in the environment. SeldonFrame can store this key in the workspace (server-side, used only to power the hosted chatbot/agent). " +
5233
+ "Ask the user for permission first, then re-call this tool with confirm_store_env_key: true.",
5234
+ };
5235
+ }
5236
+
5075
5237
  const keyToUse = explicitKey || envKey;
5076
5238
  let llmMode = "platform";
5077
5239
  if (keyToUse) {
package/src/welcome.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // stripped. `create_full_workspace` is the only workspace-creation
9
9
  // path mentioned anywhere in this briefing.
10
10
 
11
- export const VERSION = "1.59.1";
11
+ export const VERSION = "1.59.2";
12
12
 
13
13
  export const WELCOME_MARKDOWN = `# SeldonFrame — create a real Business OS in one conversation
14
14
 
@@ -108,8 +108,17 @@ of v1.28.0 this is **ONE tool call**:
108
108
  // platform key automatically. Operator can BYOK later via
109
109
  // /settings/integrations/llm. Pass explicitly only when you need
110
110
  // per-workspace billing isolation (white-label / agency).
111
+ //
112
+ // v1.59.2 — if you omit anthropic_api_key AND the MCP server's
113
+ // environment has ANTHROPIC_API_KEY set, this call returns
114
+ // needs_consent: true instead of building anything. ASK THE USER
115
+ // before storing a key found in the environment ("I found an
116
+ // Anthropic key in your environment — OK to store it in your
117
+ // SeldonFrame workspace?"), then re-call with
118
+ // confirm_store_env_key: true.
111
119
  })
112
120
  // → returns { agent, embed_url, turn_api_url, turn_api_method: "POST", dashboard_url, llm_mode, next_steps }
121
+ // or, if an env key needs consent first: { ok: true, needs_consent: true, env_var_found, message }.
113
122
  // Internally: creates agent, publishes to test. Skips set_llm_key when
114
123
  // no key supplied (platform fallback in lib/ai/client.ts handles inference).
115
124
 
@@ -125,18 +134,25 @@ billing (e.g. Acme AI agency managing Cypress Pine HVAC + Sunset
125
134
  Dental), pass anthropic_api_key explicitly per workspace instead of
126
135
  relying on platform fallback.
127
136
 
128
- ### v1.40.10 — LLM key handling
137
+ ### v1.40.10 — LLM key handling (v1.59.2 — consent gate on path 2)
129
138
 
130
139
  build_website_chatbot has THREE possible LLM-key paths, in priority
131
140
  order:
132
141
 
133
142
  1. **Explicit** — \`anthropic_api_key: 'sk-ant-...'\` arg → workspace
134
- stored as BYOK, operator pays Anthropic directly.
143
+ stored as BYOK, operator pays Anthropic directly. Passing the key
144
+ explicitly IS consent — this path is never gated.
135
145
  2. **Env-inherited** — process.env.ANTHROPIC_API_KEY in the MCP
136
- server's environment → workspace stored as BYOK with that key.
146
+ server's environment → the DEFAULT is to ASK FIRST, not store
147
+ silently. The first call (no confirm_store_env_key) returns
148
+ \`{ needs_consent: true, env_var_found, message }\` and does nothing
149
+ else. Tell the operator what was found and ask permission; only
150
+ after they agree, re-call with \`confirm_store_env_key: true\` to
151
+ store the key as BYOK and continue the bundle.
137
152
  3. **Platform fallback** — neither explicit nor env → no BYOK
138
- stored. The workspace uses SeldonFrame's platform Anthropic key
139
- automatically (via lib/ai/client.ts -> getAIClient). Operator
153
+ stored, no consent needed (nothing of the operator's is
154
+ transmitted). The workspace uses SeldonFrame's platform Anthropic
155
+ key automatically (via lib/ai/client.ts -> getAIClient). Operator
140
156
  sees no setup friction; they can BYOK later in
141
157
  /settings/integrations/llm.
142
158
 
@@ -145,7 +161,8 @@ the operator: "Your chatbot is using SeldonFrame's shared Anthropic
145
161
  key. You can switch to your own anytime in Settings → Integrations
146
162
  → LLM, no code changes needed." This removes the "where do I get an
147
163
  Anthropic key" question from the critical path of a first-time
148
- chatbot setup.
164
+ chatbot setup — and it means path 2's consent step only ever comes up
165
+ when an env key actually exists, not as a blocking default.
149
166
 
150
167
  For custom flows (different archetype, custom capability allowlist,
151
168
  multi-step blueprint construction), drop down to the primitives: