@seldonframe/mcp 1.59.2 → 1.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.md +127 -113
- package/package.json +5 -4
- package/src/security.js +241 -241
- package/src/tools.js +64 -2
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"
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"start": "node src/index.js",
|
|
20
20
|
"check:syntax": "node --check --input-type=module < src/index.js && node --check --input-type=module < src/client.js && node --check --input-type=module < src/tools.js && node --check --input-type=module < src/welcome.js && node --check --input-type=module < src/security.js",
|
|
21
21
|
"test": "node --test tests/*.test.mjs",
|
|
22
|
-
"prepublishOnly": "npm run check:syntax && npm run test"
|
|
22
|
+
"prepublishOnly": "npm run check:syntax && npm run test",
|
|
23
|
+
"build:mcpb": "npm ci --omit=dev && npx @anthropic-ai/mcpb pack . seldonframe.mcpb"
|
|
23
24
|
},
|
|
24
25
|
"dependencies": {
|
|
25
26
|
"@modelcontextprotocol/sdk": "^1.0.4"
|
package/src/security.js
CHANGED
|
@@ -1,241 +1,241 @@
|
|
|
1
|
-
// v1.59.2 — security hardening helpers shared by tools.js.
|
|
2
|
-
//
|
|
3
|
-
// Two independent concerns live here:
|
|
4
|
-
// 1. sniffImageKind — magic-byte detection so upload_workspace_image only
|
|
5
|
-
// ever forwards bytes that are actually image files (not arbitrary
|
|
6
|
-
// local files an agent was pointed at).
|
|
7
|
-
// 2. assertPublicHttpUrl — an SSRF guard for machine-side fetches of
|
|
8
|
-
// operator-supplied URLs (fetch_source_for_soul today). Rejects
|
|
9
|
-
// loopback / private / link-local targets so the MCP process can't be
|
|
10
|
-
// used to probe the operator's own LAN or cloud metadata endpoints.
|
|
11
|
-
//
|
|
12
|
-
// Both are pure-ish (assertPublicHttpUrl takes an injectable `lookup` for
|
|
13
|
-
// tests) and have zero dependency on the rest of tools.js so they can be
|
|
14
|
-
// unit-tested in isolation (see tests/security.test.mjs).
|
|
15
|
-
|
|
16
|
-
const MAX_SVG_BYTES = 1 * 1024 * 1024; // 1MB
|
|
17
|
-
|
|
18
|
-
// Loose but sufficient: optional UTF-8 BOM, optional XML prolog, optional
|
|
19
|
-
// DOCTYPE, then an <svg ...> or <svg> root tag. Case-insensitive.
|
|
20
|
-
const SVG_PATTERN =
|
|
21
|
-
/^?\s*(<\?xml[^>]*>\s*)?(<!DOCTYPE[^>]*>\s*)?<svg[\s>]/i;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Sniff the image kind of a buffer from its magic bytes. Returns one of
|
|
25
|
-
* "png" | "jpeg" | "gif" | "webp" | "svg", or null if the buffer doesn't
|
|
26
|
-
* look like a recognized image format.
|
|
27
|
-
*
|
|
28
|
-
* @param {Buffer} buffer
|
|
29
|
-
* @returns {"png"|"jpeg"|"gif"|"webp"|"svg"|null}
|
|
30
|
-
*/
|
|
31
|
-
export function sniffImageKind(buffer) {
|
|
32
|
-
if (!Buffer.isBuffer(buffer) || buffer.length === 0) return null;
|
|
33
|
-
|
|
34
|
-
// PNG: 89 50 4E 47 (plus the usual 0D 0A 1A 0A trailer, but the first
|
|
35
|
-
// four bytes are sufficient to identify it unambiguously).
|
|
36
|
-
if (
|
|
37
|
-
buffer.length >= 4 &&
|
|
38
|
-
buffer[0] === 0x89 &&
|
|
39
|
-
buffer[1] === 0x50 &&
|
|
40
|
-
buffer[2] === 0x4e &&
|
|
41
|
-
buffer[3] === 0x47
|
|
42
|
-
) {
|
|
43
|
-
return "png";
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// JPEG: FF D8 FF
|
|
47
|
-
if (
|
|
48
|
-
buffer.length >= 3 &&
|
|
49
|
-
buffer[0] === 0xff &&
|
|
50
|
-
buffer[1] === 0xd8 &&
|
|
51
|
-
buffer[2] === 0xff
|
|
52
|
-
) {
|
|
53
|
-
return "jpeg";
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// GIF: ASCII "GIF8" (covers both GIF87a and GIF89a).
|
|
57
|
-
if (buffer.length >= 4 && buffer.toString("ascii", 0, 4) === "GIF8") {
|
|
58
|
-
return "gif";
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// WebP: "RIFF" at offset 0, "WEBP" at offset 8.
|
|
62
|
-
if (
|
|
63
|
-
buffer.length >= 12 &&
|
|
64
|
-
buffer.toString("ascii", 0, 4) === "RIFF" &&
|
|
65
|
-
buffer.toString("ascii", 8, 12) === "WEBP"
|
|
66
|
-
) {
|
|
67
|
-
return "webp";
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// SVG: text-based, so magic bytes don't apply — sniff the text instead.
|
|
71
|
-
// Bound the size before decoding so a huge non-image file doesn't get
|
|
72
|
-
// fully UTF-8-decoded just to fail this check.
|
|
73
|
-
if (buffer.length <= MAX_SVG_BYTES) {
|
|
74
|
-
const text = buffer.toString("utf8");
|
|
75
|
-
if (SVG_PATTERN.test(text)) return "svg";
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// ── SSRF guard ──────────────────────────────────────────────────────────
|
|
82
|
-
|
|
83
|
-
const BLOCKED_HOSTNAME_SUFFIXES = [".localhost", ".local", ".internal"];
|
|
84
|
-
|
|
85
|
-
function isBlockedHostname(hostname) {
|
|
86
|
-
const host = hostname.toLowerCase();
|
|
87
|
-
if (host === "localhost") return true;
|
|
88
|
-
return BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => host.endsWith(suffix));
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Parse an IPv4 dotted-quad string into four octet numbers, or null if it
|
|
93
|
-
* isn't a valid literal IPv4 address.
|
|
94
|
-
* @param {string} host
|
|
95
|
-
* @returns {[number,number,number,number]|null}
|
|
96
|
-
*/
|
|
97
|
-
function parseIPv4(host) {
|
|
98
|
-
const parts = host.split(".");
|
|
99
|
-
if (parts.length !== 4) return null;
|
|
100
|
-
const octets = [];
|
|
101
|
-
for (const part of parts) {
|
|
102
|
-
if (!/^\d{1,3}$/.test(part)) return null;
|
|
103
|
-
const n = Number(part);
|
|
104
|
-
if (n < 0 || n > 255) return null;
|
|
105
|
-
octets.push(n);
|
|
106
|
-
}
|
|
107
|
-
return octets;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Is this IPv4 (as an octet quad) inside a non-public range?
|
|
112
|
-
* @param {[number,number,number,number]} o
|
|
113
|
-
*/
|
|
114
|
-
function isNonPublicIPv4(o) {
|
|
115
|
-
const [a, b] = o;
|
|
116
|
-
if (a === 0) return true; // 0.0.0.0/8
|
|
117
|
-
if (a === 10) return true; // 10/8
|
|
118
|
-
if (a === 127) return true; // 127/8 (loopback)
|
|
119
|
-
if (a === 169 && b === 254) return true; // 169.254/16 (link-local)
|
|
120
|
-
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12
|
|
121
|
-
if (a === 192 && b === 168) return true; // 192.168/16
|
|
122
|
-
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64/10 (CGNAT)
|
|
123
|
-
return false;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Is this literal address (v4 or v6 string form) non-public? Handles
|
|
128
|
-
* IPv4-mapped IPv6 (::ffff:x.x.x.x) by unwrapping to the embedded IPv4.
|
|
129
|
-
* @param {string} address
|
|
130
|
-
*/
|
|
131
|
-
function isNonPublicIpLiteral(address) {
|
|
132
|
-
const addr = address.toLowerCase();
|
|
133
|
-
|
|
134
|
-
// IPv4-mapped IPv6: ::ffff:x.x.x.x (or the rarer ::ffff:0:x.x.x.x form).
|
|
135
|
-
const mappedMatch = addr.match(/^::ffff:(?:0:)?(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
136
|
-
if (mappedMatch) {
|
|
137
|
-
const v4 = parseIPv4(mappedMatch[1]);
|
|
138
|
-
return v4 ? isNonPublicIPv4(v4) : true; // malformed embedded v4 = reject
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const v4 = parseIPv4(addr);
|
|
142
|
-
if (v4) return isNonPublicIPv4(v4);
|
|
143
|
-
|
|
144
|
-
// IPv6 literal checks.
|
|
145
|
-
if (addr === "::1") return true; // loopback
|
|
146
|
-
if (addr === "::") return true; // unspecified
|
|
147
|
-
if (/^fc[0-9a-f]{2}:/.test(addr) || /^fd[0-9a-f]{2}:/.test(addr)) {
|
|
148
|
-
return true; // fc00::/7 (unique local) — first byte 0xfc or 0xfd
|
|
149
|
-
}
|
|
150
|
-
if (/^fe[89ab][0-9a-f]:/.test(addr)) return true; // fe80::/10 (link-local)
|
|
151
|
-
|
|
152
|
-
// Not a recognized literal at all (shouldn't happen given callers only
|
|
153
|
-
// pass here after confirming it parses as an IP) — fail closed.
|
|
154
|
-
return false;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Does this string parse as a literal IP address (v4 or v6)? Deliberately
|
|
159
|
-
* simple — good enough to distinguish "the caller already resolved this to
|
|
160
|
-
* an IP" from "this is a hostname that still needs DNS resolution."
|
|
161
|
-
* @param {string} host
|
|
162
|
-
*/
|
|
163
|
-
function isIpLiteral(host) {
|
|
164
|
-
if (parseIPv4(host)) return true;
|
|
165
|
-
// Very small heuristic for IPv6 literal form: contains a colon and only
|
|
166
|
-
// hex digits / colons (optionally with an embedded dotted-quad tail for
|
|
167
|
-
// the IPv4-mapped form).
|
|
168
|
-
return /^[0-9a-f:]+(:\d{1,3}(?:\.\d{1,3}){3})?$/i.test(host) && host.includes(":");
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Assert that a URL is safe for the MCP server (running on the operator's
|
|
173
|
-
* machine) to fetch directly: http(s) only, and resolves exclusively to
|
|
174
|
-
* public IP addresses. Throws with a clear message otherwise.
|
|
175
|
-
*
|
|
176
|
-
* Guards against SSRF-style abuse where an operator-supplied "scrape this
|
|
177
|
-
* URL" argument actually points at localhost, a LAN device, or a cloud
|
|
178
|
-
* metadata endpoint (169.254.169.254) reachable from wherever the MCP
|
|
179
|
-
* process happens to run.
|
|
180
|
-
*
|
|
181
|
-
* @param {string} urlString
|
|
182
|
-
* @param {{ lookup?: (hostname: string, opts: { all: true }) => Promise<{address:string,family:number}[]> }} [opts]
|
|
183
|
-
*/
|
|
184
|
-
export async function assertPublicHttpUrl(urlString, opts = {}) {
|
|
185
|
-
let parsed;
|
|
186
|
-
try {
|
|
187
|
-
parsed = new URL(urlString);
|
|
188
|
-
} catch {
|
|
189
|
-
throw new Error(`assertPublicHttpUrl: "${urlString}" is not a valid URL.`);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
193
|
-
throw new Error(
|
|
194
|
-
`assertPublicHttpUrl: protocol "${parsed.protocol}" is not allowed for "${urlString}" — only http: and https: are permitted.`,
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// URL.hostname strips brackets from IPv6 literals (e.g. "[::1]" -> "::1").
|
|
199
|
-
const hostname = parsed.hostname;
|
|
200
|
-
|
|
201
|
-
if (isBlockedHostname(hostname)) {
|
|
202
|
-
throw new Error(
|
|
203
|
-
`assertPublicHttpUrl: hostname "${hostname}" is blocked (localhost / .localhost / .local / .internal).`,
|
|
204
|
-
);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (isIpLiteral(hostname)) {
|
|
208
|
-
if (isNonPublicIpLiteral(hostname)) {
|
|
209
|
-
throw new Error(
|
|
210
|
-
`assertPublicHttpUrl: "${hostname}" is not a public IP address.`,
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const lookup =
|
|
217
|
-
opts.lookup ?? (await import("node:dns/promises")).lookup;
|
|
218
|
-
let records;
|
|
219
|
-
try {
|
|
220
|
-
records = await lookup(hostname, { all: true });
|
|
221
|
-
} catch (err) {
|
|
222
|
-
throw new Error(
|
|
223
|
-
`assertPublicHttpUrl: DNS lookup for "${hostname}" failed — ${err?.message ?? err}`,
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (!Array.isArray(records) || records.length === 0) {
|
|
228
|
-
throw new Error(
|
|
229
|
-
`assertPublicHttpUrl: DNS lookup for "${hostname}" returned no addresses.`,
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
for (const record of records) {
|
|
234
|
-
const address = record?.address ?? record;
|
|
235
|
-
if (typeof address !== "string" || isNonPublicIpLiteral(address)) {
|
|
236
|
-
throw new Error(
|
|
237
|
-
`assertPublicHttpUrl: "${hostname}" resolves to a non-public address (${address}) — refusing to fetch.`,
|
|
238
|
-
);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
1
|
+
// v1.59.2 — security hardening helpers shared by tools.js.
|
|
2
|
+
//
|
|
3
|
+
// Two independent concerns live here:
|
|
4
|
+
// 1. sniffImageKind — magic-byte detection so upload_workspace_image only
|
|
5
|
+
// ever forwards bytes that are actually image files (not arbitrary
|
|
6
|
+
// local files an agent was pointed at).
|
|
7
|
+
// 2. assertPublicHttpUrl — an SSRF guard for machine-side fetches of
|
|
8
|
+
// operator-supplied URLs (fetch_source_for_soul today). Rejects
|
|
9
|
+
// loopback / private / link-local targets so the MCP process can't be
|
|
10
|
+
// used to probe the operator's own LAN or cloud metadata endpoints.
|
|
11
|
+
//
|
|
12
|
+
// Both are pure-ish (assertPublicHttpUrl takes an injectable `lookup` for
|
|
13
|
+
// tests) and have zero dependency on the rest of tools.js so they can be
|
|
14
|
+
// unit-tested in isolation (see tests/security.test.mjs).
|
|
15
|
+
|
|
16
|
+
const MAX_SVG_BYTES = 1 * 1024 * 1024; // 1MB
|
|
17
|
+
|
|
18
|
+
// Loose but sufficient: optional UTF-8 BOM, optional XML prolog, optional
|
|
19
|
+
// DOCTYPE, then an <svg ...> or <svg> root tag. Case-insensitive.
|
|
20
|
+
const SVG_PATTERN =
|
|
21
|
+
/^?\s*(<\?xml[^>]*>\s*)?(<!DOCTYPE[^>]*>\s*)?<svg[\s>]/i;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Sniff the image kind of a buffer from its magic bytes. Returns one of
|
|
25
|
+
* "png" | "jpeg" | "gif" | "webp" | "svg", or null if the buffer doesn't
|
|
26
|
+
* look like a recognized image format.
|
|
27
|
+
*
|
|
28
|
+
* @param {Buffer} buffer
|
|
29
|
+
* @returns {"png"|"jpeg"|"gif"|"webp"|"svg"|null}
|
|
30
|
+
*/
|
|
31
|
+
export function sniffImageKind(buffer) {
|
|
32
|
+
if (!Buffer.isBuffer(buffer) || buffer.length === 0) return null;
|
|
33
|
+
|
|
34
|
+
// PNG: 89 50 4E 47 (plus the usual 0D 0A 1A 0A trailer, but the first
|
|
35
|
+
// four bytes are sufficient to identify it unambiguously).
|
|
36
|
+
if (
|
|
37
|
+
buffer.length >= 4 &&
|
|
38
|
+
buffer[0] === 0x89 &&
|
|
39
|
+
buffer[1] === 0x50 &&
|
|
40
|
+
buffer[2] === 0x4e &&
|
|
41
|
+
buffer[3] === 0x47
|
|
42
|
+
) {
|
|
43
|
+
return "png";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// JPEG: FF D8 FF
|
|
47
|
+
if (
|
|
48
|
+
buffer.length >= 3 &&
|
|
49
|
+
buffer[0] === 0xff &&
|
|
50
|
+
buffer[1] === 0xd8 &&
|
|
51
|
+
buffer[2] === 0xff
|
|
52
|
+
) {
|
|
53
|
+
return "jpeg";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// GIF: ASCII "GIF8" (covers both GIF87a and GIF89a).
|
|
57
|
+
if (buffer.length >= 4 && buffer.toString("ascii", 0, 4) === "GIF8") {
|
|
58
|
+
return "gif";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// WebP: "RIFF" at offset 0, "WEBP" at offset 8.
|
|
62
|
+
if (
|
|
63
|
+
buffer.length >= 12 &&
|
|
64
|
+
buffer.toString("ascii", 0, 4) === "RIFF" &&
|
|
65
|
+
buffer.toString("ascii", 8, 12) === "WEBP"
|
|
66
|
+
) {
|
|
67
|
+
return "webp";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// SVG: text-based, so magic bytes don't apply — sniff the text instead.
|
|
71
|
+
// Bound the size before decoding so a huge non-image file doesn't get
|
|
72
|
+
// fully UTF-8-decoded just to fail this check.
|
|
73
|
+
if (buffer.length <= MAX_SVG_BYTES) {
|
|
74
|
+
const text = buffer.toString("utf8");
|
|
75
|
+
if (SVG_PATTERN.test(text)) return "svg";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── SSRF guard ──────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const BLOCKED_HOSTNAME_SUFFIXES = [".localhost", ".local", ".internal"];
|
|
84
|
+
|
|
85
|
+
function isBlockedHostname(hostname) {
|
|
86
|
+
const host = hostname.toLowerCase();
|
|
87
|
+
if (host === "localhost") return true;
|
|
88
|
+
return BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => host.endsWith(suffix));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parse an IPv4 dotted-quad string into four octet numbers, or null if it
|
|
93
|
+
* isn't a valid literal IPv4 address.
|
|
94
|
+
* @param {string} host
|
|
95
|
+
* @returns {[number,number,number,number]|null}
|
|
96
|
+
*/
|
|
97
|
+
function parseIPv4(host) {
|
|
98
|
+
const parts = host.split(".");
|
|
99
|
+
if (parts.length !== 4) return null;
|
|
100
|
+
const octets = [];
|
|
101
|
+
for (const part of parts) {
|
|
102
|
+
if (!/^\d{1,3}$/.test(part)) return null;
|
|
103
|
+
const n = Number(part);
|
|
104
|
+
if (n < 0 || n > 255) return null;
|
|
105
|
+
octets.push(n);
|
|
106
|
+
}
|
|
107
|
+
return octets;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Is this IPv4 (as an octet quad) inside a non-public range?
|
|
112
|
+
* @param {[number,number,number,number]} o
|
|
113
|
+
*/
|
|
114
|
+
function isNonPublicIPv4(o) {
|
|
115
|
+
const [a, b] = o;
|
|
116
|
+
if (a === 0) return true; // 0.0.0.0/8
|
|
117
|
+
if (a === 10) return true; // 10/8
|
|
118
|
+
if (a === 127) return true; // 127/8 (loopback)
|
|
119
|
+
if (a === 169 && b === 254) return true; // 169.254/16 (link-local)
|
|
120
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12
|
|
121
|
+
if (a === 192 && b === 168) return true; // 192.168/16
|
|
122
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64/10 (CGNAT)
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Is this literal address (v4 or v6 string form) non-public? Handles
|
|
128
|
+
* IPv4-mapped IPv6 (::ffff:x.x.x.x) by unwrapping to the embedded IPv4.
|
|
129
|
+
* @param {string} address
|
|
130
|
+
*/
|
|
131
|
+
function isNonPublicIpLiteral(address) {
|
|
132
|
+
const addr = address.toLowerCase();
|
|
133
|
+
|
|
134
|
+
// IPv4-mapped IPv6: ::ffff:x.x.x.x (or the rarer ::ffff:0:x.x.x.x form).
|
|
135
|
+
const mappedMatch = addr.match(/^::ffff:(?:0:)?(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
136
|
+
if (mappedMatch) {
|
|
137
|
+
const v4 = parseIPv4(mappedMatch[1]);
|
|
138
|
+
return v4 ? isNonPublicIPv4(v4) : true; // malformed embedded v4 = reject
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const v4 = parseIPv4(addr);
|
|
142
|
+
if (v4) return isNonPublicIPv4(v4);
|
|
143
|
+
|
|
144
|
+
// IPv6 literal checks.
|
|
145
|
+
if (addr === "::1") return true; // loopback
|
|
146
|
+
if (addr === "::") return true; // unspecified
|
|
147
|
+
if (/^fc[0-9a-f]{2}:/.test(addr) || /^fd[0-9a-f]{2}:/.test(addr)) {
|
|
148
|
+
return true; // fc00::/7 (unique local) — first byte 0xfc or 0xfd
|
|
149
|
+
}
|
|
150
|
+
if (/^fe[89ab][0-9a-f]:/.test(addr)) return true; // fe80::/10 (link-local)
|
|
151
|
+
|
|
152
|
+
// Not a recognized literal at all (shouldn't happen given callers only
|
|
153
|
+
// pass here after confirming it parses as an IP) — fail closed.
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Does this string parse as a literal IP address (v4 or v6)? Deliberately
|
|
159
|
+
* simple — good enough to distinguish "the caller already resolved this to
|
|
160
|
+
* an IP" from "this is a hostname that still needs DNS resolution."
|
|
161
|
+
* @param {string} host
|
|
162
|
+
*/
|
|
163
|
+
function isIpLiteral(host) {
|
|
164
|
+
if (parseIPv4(host)) return true;
|
|
165
|
+
// Very small heuristic for IPv6 literal form: contains a colon and only
|
|
166
|
+
// hex digits / colons (optionally with an embedded dotted-quad tail for
|
|
167
|
+
// the IPv4-mapped form).
|
|
168
|
+
return /^[0-9a-f:]+(:\d{1,3}(?:\.\d{1,3}){3})?$/i.test(host) && host.includes(":");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Assert that a URL is safe for the MCP server (running on the operator's
|
|
173
|
+
* machine) to fetch directly: http(s) only, and resolves exclusively to
|
|
174
|
+
* public IP addresses. Throws with a clear message otherwise.
|
|
175
|
+
*
|
|
176
|
+
* Guards against SSRF-style abuse where an operator-supplied "scrape this
|
|
177
|
+
* URL" argument actually points at localhost, a LAN device, or a cloud
|
|
178
|
+
* metadata endpoint (169.254.169.254) reachable from wherever the MCP
|
|
179
|
+
* process happens to run.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} urlString
|
|
182
|
+
* @param {{ lookup?: (hostname: string, opts: { all: true }) => Promise<{address:string,family:number}[]> }} [opts]
|
|
183
|
+
*/
|
|
184
|
+
export async function assertPublicHttpUrl(urlString, opts = {}) {
|
|
185
|
+
let parsed;
|
|
186
|
+
try {
|
|
187
|
+
parsed = new URL(urlString);
|
|
188
|
+
} catch {
|
|
189
|
+
throw new Error(`assertPublicHttpUrl: "${urlString}" is not a valid URL.`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`assertPublicHttpUrl: protocol "${parsed.protocol}" is not allowed for "${urlString}" — only http: and https: are permitted.`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// URL.hostname strips brackets from IPv6 literals (e.g. "[::1]" -> "::1").
|
|
199
|
+
const hostname = parsed.hostname;
|
|
200
|
+
|
|
201
|
+
if (isBlockedHostname(hostname)) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`assertPublicHttpUrl: hostname "${hostname}" is blocked (localhost / .localhost / .local / .internal).`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (isIpLiteral(hostname)) {
|
|
208
|
+
if (isNonPublicIpLiteral(hostname)) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`assertPublicHttpUrl: "${hostname}" is not a public IP address.`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const lookup =
|
|
217
|
+
opts.lookup ?? (await import("node:dns/promises")).lookup;
|
|
218
|
+
let records;
|
|
219
|
+
try {
|
|
220
|
+
records = await lookup(hostname, { all: true });
|
|
221
|
+
} catch (err) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`assertPublicHttpUrl: DNS lookup for "${hostname}" failed — ${err?.message ?? err}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!Array.isArray(records) || records.length === 0) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`assertPublicHttpUrl: DNS lookup for "${hostname}" returned no addresses.`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for (const record of records) {
|
|
234
|
+
const address = record?.address ?? record;
|
|
235
|
+
if (typeof address !== "string" || isNonPublicIpLiteral(address)) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`assertPublicHttpUrl: "${hostname}" resolves to a non-public address (${address}) — refusing to fetch.`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
package/src/tools.js
CHANGED
|
@@ -633,12 +633,17 @@ export const TOOLS = [
|
|
|
633
633
|
{
|
|
634
634
|
name: "send_welcome_email",
|
|
635
635
|
description:
|
|
636
|
-
"Email the active workspace's four key URLs (landing, booking, intake, admin dashboard) to a user. Use this AFTER create_workspace, only when the user has explicitly given their email — never auto-send. The admin URL is bearer-token-scoped and expires in 7 days.
|
|
636
|
+
"Email the active workspace's four key URLs (landing, booking, intake, admin dashboard) to a user. Use this AFTER create_workspace, only when the user has explicitly given their email — never auto-send. The admin URL is bearer-token-scoped and expires in 7 days. " +
|
|
637
|
+
"If the workspace has an AI chatbot (create_full_workspace's response includes chatbot_embed_snippet + chatbot_agent_id), pass chatbot_url + chatbot_embed_snippet + chatbot_status so the welcome email also shows the chatbot card with its embed snippet — operators otherwise never learn the workspace shipped with a chatbot. All three are optional; omit them to send the classic 4-URL email. " +
|
|
638
|
+
"Example: send_welcome_email({ email: 'alice@example.com', name: 'Alice', chatbot_url: 'https://acme.app.seldonframe.com/chat', chatbot_embed_snippet: '<script src=\"...\" async></script>', chatbot_status: 'test' }).",
|
|
637
639
|
inputSchema: obj(
|
|
638
640
|
{
|
|
639
641
|
email: str("Recipient email address."),
|
|
640
642
|
name: str("Optional recipient name (used in the greeting)."),
|
|
641
643
|
workspace_id: str("Optional workspace override. Defaults to active workspace."),
|
|
644
|
+
chatbot_url: str("Optional. Public chatbot URL — include when the workspace has an AI chatbot (see chatbot_embed_snippet + chatbot_agent_id from create_full_workspace's response)."),
|
|
645
|
+
chatbot_embed_snippet: str("Optional. The <script> embed snippet for the chatbot (from create_full_workspace's chatbot_embed_snippet). Required alongside chatbot_url/chatbot_status to render the chatbot card."),
|
|
646
|
+
chatbot_status: str("Optional. \"live\" or \"test\" — reflects the chatbot's current publish status. Defaults the card copy accordingly."),
|
|
642
647
|
},
|
|
643
648
|
["email"],
|
|
644
649
|
),
|
|
@@ -673,6 +678,18 @@ export const TOOLS = [
|
|
|
673
678
|
const appHost = API_INFO.base.replace(/\/api\/v1\/?$/, "");
|
|
674
679
|
const adminUrl = `${appHost}/admin/${encodeURIComponent(workspaceId)}?token=${encodeURIComponent(bearer)}`;
|
|
675
680
|
|
|
681
|
+
// Optional chatbot passthrough — only include the card when the
|
|
682
|
+
// caller supplied enough to render it (url + embed_snippet).
|
|
683
|
+
// Omitted entirely otherwise so this stays backward-compatible.
|
|
684
|
+
const chatbot =
|
|
685
|
+
a.chatbot_url && a.chatbot_embed_snippet
|
|
686
|
+
? {
|
|
687
|
+
url: a.chatbot_url,
|
|
688
|
+
embed_snippet: a.chatbot_embed_snippet,
|
|
689
|
+
status: a.chatbot_status === "live" ? "live" : "test",
|
|
690
|
+
}
|
|
691
|
+
: undefined;
|
|
692
|
+
|
|
676
693
|
await api("POST", "/email/send-welcome", {
|
|
677
694
|
body: {
|
|
678
695
|
email: a.email,
|
|
@@ -682,6 +699,7 @@ export const TOOLS = [
|
|
|
682
699
|
booking_url: publicUrls.book,
|
|
683
700
|
intake_url: publicUrls.intake,
|
|
684
701
|
admin_url: adminUrl,
|
|
702
|
+
...(chatbot ? { chatbot } : {}),
|
|
685
703
|
},
|
|
686
704
|
},
|
|
687
705
|
workspace_id: workspaceId,
|
|
@@ -4728,7 +4746,7 @@ export const TOOLS = [
|
|
|
4728
4746
|
name: "create_agent",
|
|
4729
4747
|
description:
|
|
4730
4748
|
"USE WHEN USER SAYS: 'add a chatbot to my website', 'add an AI assistant to my landing page', 'put a chat widget on my site', 'create a website chatbot', 'add an AI agent that answers customer questions', 'I want chat on my homepage', 'build me a chatbot for [business]'. " +
|
|
4731
|
-
"DON'T confuse with: list_blocks (chat widgets are NOT a block type — agents are a separate primitive); send_conversation_turn (that's for inbound SMS/email auto-reply, NOT a website widget). If the operator wants chat on their website, THIS is the tool. " +
|
|
4749
|
+
"DON'T confuse with: list_blocks (chat widgets are NOT a block type — agents are a separate primitive); send_conversation_turn (that's for inbound SMS/email auto-reply, NOT a website widget); generate_agent (for an agent that ACTS on bookings/leads/a schedule — review requester, speed-to-lead, follow-up — use generate_agent instead; THIS tool only builds a website chat widget with no trigger). If the operator wants chat on their website, THIS is the tool. " +
|
|
4732
4750
|
"Creates a new agent for this workspace. Agents are conversational interfaces (web chat, voice, SMS) that answer FAQs, book appointments, and escalate to humans — composed from typed primitives + the workspace's Soul (industry, voice, services). " +
|
|
4733
4751
|
"WHAT GETS COMPOSED AUTOMATICALLY: persona derived from soul.industry + soul.voice; FAQ knowledge from your `faq` array; pricing facts from `pricing_facts` (validators block any $-amount the agent invents that's not in this list); typed tools (look_up_availability, book_appointment, find_my_existing_appointment, escalate_to_human, provide_faq_answer). " +
|
|
4734
4752
|
"WHAT YOU PROVIDE: name, archetype (website-chatbot for v1.26.x+; voice-receptionist + sms-followup-bot queued), channel (web_chat / voice / sms / email), inline FAQ pairs, allowed pricing facts, optional greeting. " +
|
|
@@ -4928,6 +4946,50 @@ export const TOOLS = [
|
|
|
4928
4946
|
},
|
|
4929
4947
|
},
|
|
4930
4948
|
|
|
4949
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
4950
|
+
// v1.60.0 — GENERATE. Build an agent from ONE plain-English sentence. This
|
|
4951
|
+
// is the front door for anything that DOES something on an event/schedule
|
|
4952
|
+
// (review requester, speed-to-lead, after-hours receptionist) — a real,
|
|
4953
|
+
// event-firing agent (trigger + skill + guardrails), not a website widget.
|
|
4954
|
+
// Wires POST /api/v1/agents/generate → the same deterministic generator
|
|
4955
|
+
// (parseAgentIntent → assembleAgentBundle, LLM-classify optional and
|
|
4956
|
+
// fail-soft) the Studio dashboard uses. Returns a template_id; the next
|
|
4957
|
+
// step is ALWAYS deploy_agent to make it live and firing.
|
|
4958
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
4959
|
+
|
|
4960
|
+
{
|
|
4961
|
+
name: "generate_agent",
|
|
4962
|
+
description:
|
|
4963
|
+
"USE WHEN USER SAYS: 'create a google review requester for my dentist office', 'text new leads instantly', 'build a speed-to-lead texter', 'set up an after-hours receptionist', 'make an agent that follows up after every booking'. " +
|
|
4964
|
+
"Build a working AI agent from a plain-English description. It classifies the intent and assembles a deployable agent — trigger (e.g. fires on booking.completed or lead.created, or answers inbound calls/chats) + skill (the playbook it follows) + guardrails (quiet hours, rate caps, safety rubric) — all wired automatically, no manual configuration. " +
|
|
4965
|
+
"Returns a template_id — then call deploy_agent with { source: { template_id } } to make it live and firing on its trigger (e.g. after every booking, or the instant a new lead arrives). " +
|
|
4966
|
+
"USE THIS (not create_agent) for ANY agent that DOES something on an event or a schedule — a review request after a job, an instant reply to a new lead, a scheduled recap. create_agent is ONLY for a website chat widget with no trigger. " +
|
|
4967
|
+
"Delivery note: a freshly generated event-outbound agent (review-requester / speed-to-lead) defaults to EMAIL so it can send a real message today without the workspace connecting a phone number first (see `warnings` in the response); mention texting/SMS explicitly in your description if you want it to try SMS instead — full delivery still needs the workspace's own Twilio connected, surfaced by deploy_agent as a 'telephony' requirement.",
|
|
4968
|
+
inputSchema: obj(
|
|
4969
|
+
{
|
|
4970
|
+
workspace_id: str("Workspace id (bearer workspace)."),
|
|
4971
|
+
description: str(
|
|
4972
|
+
"One plain-English sentence describing the agent to build, e.g. 'a Google review requester for a dentist' or 'a speed-to-lead texter for new website leads'.",
|
|
4973
|
+
),
|
|
4974
|
+
review_url: str(
|
|
4975
|
+
"Optional: the business's Google review link. Wins over any URL found in the description. Only relevant for a review-requester agent; omit otherwise.",
|
|
4976
|
+
),
|
|
4977
|
+
},
|
|
4978
|
+
["workspace_id", "description"],
|
|
4979
|
+
),
|
|
4980
|
+
handler: async (args) => {
|
|
4981
|
+
const ws = args.workspace_id;
|
|
4982
|
+
const result = await api("POST", "/agents/generate", {
|
|
4983
|
+
body: {
|
|
4984
|
+
description: args.description,
|
|
4985
|
+
review_url: args.review_url ?? undefined,
|
|
4986
|
+
},
|
|
4987
|
+
workspace_id: ws,
|
|
4988
|
+
});
|
|
4989
|
+
return result;
|
|
4990
|
+
},
|
|
4991
|
+
},
|
|
4992
|
+
|
|
4931
4993
|
// ───────────────────────────────────────────────────────────────────────
|
|
4932
4994
|
// v1.59.0 — IMPROVE. The 5th verb of the builder ladder: build → test →
|
|
4933
4995
|
// deploy → sell → improve. `improve_agent` replays an agent's own recent
|