@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 +28 -0
- package/README.md +127 -113
- package/package.json +7 -5
- package/src/client.js +39 -5
- package/src/security.js +241 -0
- package/src/tools.js +171 -9
- package/src/welcome.js +24 -7
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.
|
|
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": "
|
|
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
|
-
"
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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) {
|
package/src/security.js
ADDED
|
@@ -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.
|
|
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(
|
|
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)
|
|
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.
|
|
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 →
|
|
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
|
|
139
|
-
|
|
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:
|