@seldonframe/mcp 1.59.1 → 1.59.2
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/package.json +4 -3
- package/src/client.js +39 -5
- package/src/security.js +241 -0
- package/src/tools.js +107 -7
- package/src/welcome.js +24 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seldonframe/mcp",
|
|
3
|
-
"version": "1.59.
|
|
3
|
+
"version": "1.59.2",
|
|
4
4
|
"mcpName": "io.github.seldonframe/mcp",
|
|
5
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
6
|
"license": "AGPL-3.0-or-later",
|
|
@@ -17,8 +17,9 @@
|
|
|
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"
|
|
22
23
|
},
|
|
23
24
|
"dependencies": {
|
|
24
25
|
"@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 = []) => ({
|
|
@@ -1399,10 +1403,13 @@ export const TOOLS = [
|
|
|
1399
1403
|
{
|
|
1400
1404
|
name: "fetch_source_for_soul",
|
|
1401
1405
|
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."
|
|
1406
|
+
"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. " +
|
|
1407
|
+
"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
1408
|
inputSchema: obj(
|
|
1404
1409
|
{
|
|
1405
|
-
url: str(
|
|
1410
|
+
url: str(
|
|
1411
|
+
"Absolute URL to fetch. Must be a public http(s) URL — localhost, LAN, and other private-network addresses are rejected.",
|
|
1412
|
+
),
|
|
1406
1413
|
},
|
|
1407
1414
|
["url"],
|
|
1408
1415
|
),
|
|
@@ -4088,6 +4095,7 @@ export const TOOLS = [
|
|
|
4088
4095
|
"(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
4096
|
"(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
4097
|
"Max 5 MB across all paths. Allowed types: image/png, image/jpeg, image/webp, image/svg+xml, image/gif. " +
|
|
4098
|
+
"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
4099
|
"Returns the public Blob URL on success; that URL is now live on the workspace's public surface within seconds. " +
|
|
4092
4100
|
"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
4101
|
inputSchema: obj(
|
|
@@ -4162,6 +4170,26 @@ export const TOOLS = [
|
|
|
4162
4170
|
`upload_workspace_image: failed to read local_file_path "${filePath}" — ${err?.message ?? err}`,
|
|
4163
4171
|
);
|
|
4164
4172
|
}
|
|
4173
|
+
// v1.59.2 — this is the local-file-read path (the MCP server reads
|
|
4174
|
+
// whatever absolute path it's given). Enforce the size cap and
|
|
4175
|
+
// sniff the actual bytes BEFORE they're base64'd and sent anywhere,
|
|
4176
|
+
// so this tool can only ever upload real images — not an
|
|
4177
|
+
// agent-mediated read of an arbitrary local file.
|
|
4178
|
+
if (buf.length > 5 * 1024 * 1024) {
|
|
4179
|
+
return {
|
|
4180
|
+
ok: false,
|
|
4181
|
+
error: "file_too_large",
|
|
4182
|
+
message: `upload_workspace_image: "${filePath}" is ${buf.length} bytes, over the 5MB max.`,
|
|
4183
|
+
};
|
|
4184
|
+
}
|
|
4185
|
+
if (sniffImageKind(buf) === null) {
|
|
4186
|
+
return {
|
|
4187
|
+
ok: false,
|
|
4188
|
+
error: "not_an_image",
|
|
4189
|
+
message:
|
|
4190
|
+
"Not a recognized image file (png, jpeg, gif, webp, svg). This tool only uploads images.",
|
|
4191
|
+
};
|
|
4192
|
+
}
|
|
4165
4193
|
// Derive file_name + content_type from the path extension, mirroring
|
|
4166
4194
|
// the server-side image_url logic so the two paths feel identical
|
|
4167
4195
|
// to the operator.
|
|
@@ -4186,12 +4214,40 @@ export const TOOLS = [
|
|
|
4186
4214
|
}
|
|
4187
4215
|
} else {
|
|
4188
4216
|
// image_data_b64 — caller supplied bytes directly.
|
|
4189
|
-
body.image_data_b64 = args.image_data_b64;
|
|
4190
4217
|
if (!args.file_name || !args.content_type) {
|
|
4191
4218
|
throw new Error(
|
|
4192
4219
|
"upload_workspace_image: file_name and content_type are required with image_data_b64. (image_url and local_file_path auto-derive them.)",
|
|
4193
4220
|
);
|
|
4194
4221
|
}
|
|
4222
|
+
// v1.59.2 — same bytes-in, sniff-before-send discipline as the
|
|
4223
|
+
// local_file_path branch above (this tool only uploads images,
|
|
4224
|
+
// regardless of which of the three sources supplied the bytes).
|
|
4225
|
+
let decoded;
|
|
4226
|
+
try {
|
|
4227
|
+
decoded = Buffer.from(args.image_data_b64, "base64");
|
|
4228
|
+
} catch (err) {
|
|
4229
|
+
return {
|
|
4230
|
+
ok: false,
|
|
4231
|
+
error: "invalid_base64",
|
|
4232
|
+
message: `upload_workspace_image: image_data_b64 could not be decoded — ${err?.message ?? err}`,
|
|
4233
|
+
};
|
|
4234
|
+
}
|
|
4235
|
+
if (decoded.length > 5 * 1024 * 1024) {
|
|
4236
|
+
return {
|
|
4237
|
+
ok: false,
|
|
4238
|
+
error: "file_too_large",
|
|
4239
|
+
message: `upload_workspace_image: decoded image_data_b64 is ${decoded.length} bytes, over the 5MB max.`,
|
|
4240
|
+
};
|
|
4241
|
+
}
|
|
4242
|
+
if (sniffImageKind(decoded) === null) {
|
|
4243
|
+
return {
|
|
4244
|
+
ok: false,
|
|
4245
|
+
error: "not_an_image",
|
|
4246
|
+
message:
|
|
4247
|
+
"Not a recognized image file (png, jpeg, gif, webp, svg). This tool only uploads images.",
|
|
4248
|
+
};
|
|
4249
|
+
}
|
|
4250
|
+
body.image_data_b64 = args.image_data_b64;
|
|
4195
4251
|
body.file_name = args.file_name;
|
|
4196
4252
|
body.content_type = args.content_type;
|
|
4197
4253
|
}
|
|
@@ -4588,7 +4644,8 @@ export const TOOLS = [
|
|
|
4588
4644
|
"The OPERATOR pays the LLM provider directly (Anthropic / OpenAI / etc.); SF charges separately for agent platform usage. " +
|
|
4589
4645
|
"Stored encrypted at rest using the deployment's ENCRYPTION_KEY. " +
|
|
4590
4646
|
"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)
|
|
4647
|
+
"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. " +
|
|
4648
|
+
"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
4649
|
"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
4650
|
inputSchema: obj(
|
|
4594
4651
|
{
|
|
@@ -4600,8 +4657,13 @@ export const TOOLS = [
|
|
|
4600
4657
|
"LLM provider. v1.26 ships full Anthropic support (tool use, streaming-ready). OpenAI support for chat is partial — recommend Anthropic for production agents.",
|
|
4601
4658
|
},
|
|
4602
4659
|
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.",
|
|
4660
|
+
"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
4661
|
),
|
|
4662
|
+
confirm_store_env_key: {
|
|
4663
|
+
type: "boolean",
|
|
4664
|
+
description:
|
|
4665
|
+
"Set true ONLY after the user has explicitly confirmed storing the API key found in this machine's environment variables in their SeldonFrame workspace.",
|
|
4666
|
+
},
|
|
4605
4667
|
},
|
|
4606
4668
|
["workspace_id", "provider"],
|
|
4607
4669
|
),
|
|
@@ -4628,6 +4690,20 @@ export const TOOLS = [
|
|
|
4628
4690
|
envName,
|
|
4629
4691
|
};
|
|
4630
4692
|
}
|
|
4693
|
+
// v1.59.2 — CONSENT GATE. Finding a key in the environment is not
|
|
4694
|
+
// the same as the operator agreeing to store it server-side.
|
|
4695
|
+
// Without explicit confirm_store_env_key: true, stop here and
|
|
4696
|
+
// tell the agent to go get consent — never transmit the value.
|
|
4697
|
+
if (args.confirm_store_env_key !== true) {
|
|
4698
|
+
return {
|
|
4699
|
+
ok: true,
|
|
4700
|
+
needs_consent: true,
|
|
4701
|
+
env_var_found: envName,
|
|
4702
|
+
message:
|
|
4703
|
+
`Found ${envName} in the environment. SeldonFrame can store this key in the workspace (server-side, used only to power the hosted chatbot/agent). ` +
|
|
4704
|
+
`Ask the user for permission first, then re-call this tool with confirm_store_env_key: true.`,
|
|
4705
|
+
};
|
|
4706
|
+
}
|
|
4631
4707
|
}
|
|
4632
4708
|
|
|
4633
4709
|
const result = await api("POST", "/agents", {
|
|
@@ -5009,7 +5085,8 @@ export const TOOLS = [
|
|
|
5009
5085
|
"(2) creates a website-chatbot agent with the FAQ + pricing facts + greeting you provide; " +
|
|
5010
5086
|
"(3) publishes to status='test' so the operator can sandbox-test before going live (the eval gate runs only on 'live'); " +
|
|
5011
5087
|
"(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)."
|
|
5088
|
+
"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). " +
|
|
5089
|
+
"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
5090
|
inputSchema: obj(
|
|
5014
5091
|
{
|
|
5015
5092
|
workspace_id: str("Workspace id (bearer workspace)."),
|
|
@@ -5045,8 +5122,13 @@ export const TOOLS = [
|
|
|
5045
5122
|
"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
5123
|
),
|
|
5047
5124
|
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.",
|
|
5125
|
+
"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
5126
|
),
|
|
5127
|
+
confirm_store_env_key: {
|
|
5128
|
+
type: "boolean",
|
|
5129
|
+
description:
|
|
5130
|
+
"Set true ONLY after the user has explicitly confirmed storing the API key found in this machine's environment variables in their SeldonFrame workspace.",
|
|
5131
|
+
},
|
|
5050
5132
|
},
|
|
5051
5133
|
["workspace_id", "name"],
|
|
5052
5134
|
),
|
|
@@ -5072,6 +5154,24 @@ export const TOOLS = [
|
|
|
5072
5154
|
// that v1.40.9 Sunset Plumbing test exposed.
|
|
5073
5155
|
const explicitKey = args.anthropic_api_key;
|
|
5074
5156
|
const envKey = process.env.ANTHROPIC_API_KEY;
|
|
5157
|
+
|
|
5158
|
+
// v1.59.2 — CONSENT GATE. An env-inherited key is about to be
|
|
5159
|
+
// transmitted to the SF API and stored on the workspace; the
|
|
5160
|
+
// operator hasn't agreed to that yet just by virtue of having the
|
|
5161
|
+
// var set in their shell. Stop the ENTIRE bundle here (before
|
|
5162
|
+
// create_agent / publish_agent run) and ask first. An explicit
|
|
5163
|
+
// anthropic_api_key arg is consent by definition and skips this.
|
|
5164
|
+
if (!explicitKey && envKey && args.confirm_store_env_key !== true) {
|
|
5165
|
+
return {
|
|
5166
|
+
ok: true,
|
|
5167
|
+
needs_consent: true,
|
|
5168
|
+
env_var_found: "ANTHROPIC_API_KEY",
|
|
5169
|
+
message:
|
|
5170
|
+
"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). " +
|
|
5171
|
+
"Ask the user for permission first, then re-call this tool with confirm_store_env_key: true.",
|
|
5172
|
+
};
|
|
5173
|
+
}
|
|
5174
|
+
|
|
5075
5175
|
const keyToUse = explicitKey || envKey;
|
|
5076
5176
|
let llmMode = "platform";
|
|
5077
5177
|
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:
|