@solarisdk/mcp 0.4.2 → 0.4.4
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/README.md +1 -1
- package/dist/browser-tools/auth.d.ts +2 -0
- package/dist/browser-tools/auth.js +403 -0
- package/dist/browser-tools/capture.d.ts +2 -0
- package/dist/browser-tools/capture.js +67 -0
- package/dist/browser-tools/context.d.ts +79 -0
- package/dist/browser-tools/context.js +74 -0
- package/dist/browser-tools/interaction.d.ts +2 -0
- package/dist/browser-tools/interaction.js +91 -0
- package/dist/browser-tools/navigation.d.ts +2 -0
- package/dist/browser-tools/navigation.js +30 -0
- package/dist/browser-tools/session.d.ts +2 -0
- package/dist/browser-tools/session.js +182 -0
- package/dist/browser.d.ts +4 -49
- package/dist/browser.js +21 -783
- package/dist/server.js +14 -3
- package/dist/solari-mcp-http.bundle.cjs +1465 -1397
- package/dist/solari-mcp.bundle.cjs +1462 -1394
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Clicking, typing, key presses and arbitrary page-context JS.
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { text } from "./context.js";
|
|
4
|
+
export function makeInteractionTools(ctx) {
|
|
5
|
+
const { need, activePage } = ctx;
|
|
6
|
+
return {
|
|
7
|
+
solari_browser_click: {
|
|
8
|
+
description: "Click on the page: give a CSS selector, or x/y viewport coordinates (e.g. from a screenshot).",
|
|
9
|
+
inputSchema: {
|
|
10
|
+
sessionId: z.string(),
|
|
11
|
+
selector: z.string().optional(),
|
|
12
|
+
x: z.number().optional(),
|
|
13
|
+
y: z.number().optional(),
|
|
14
|
+
},
|
|
15
|
+
handler: async (a) => {
|
|
16
|
+
const page = await activePage(need(a.sessionId));
|
|
17
|
+
if (a.selector) {
|
|
18
|
+
await page.click(a.selector);
|
|
19
|
+
}
|
|
20
|
+
else if (typeof a.x === "number" && typeof a.y === "number") {
|
|
21
|
+
await page.mouse.click(a.x, a.y);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
throw new Error("provide selector or x+y");
|
|
25
|
+
}
|
|
26
|
+
return text({ ok: true, url: page.url() });
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
solari_browser_type: {
|
|
30
|
+
description: "Type text into the page. Optionally focus a CSS selector first. clear:true replaces the " +
|
|
31
|
+
"field's existing value (otherwise text is appended at the caret). pressEnter submits.",
|
|
32
|
+
inputSchema: {
|
|
33
|
+
sessionId: z.string(),
|
|
34
|
+
text: z.string(),
|
|
35
|
+
selector: z.string().optional(),
|
|
36
|
+
clear: z.boolean().optional(),
|
|
37
|
+
pressEnter: z.boolean().optional(),
|
|
38
|
+
},
|
|
39
|
+
handler: async (a) => {
|
|
40
|
+
const page = await activePage(need(a.sessionId));
|
|
41
|
+
if (a.selector) {
|
|
42
|
+
const sel = a.selector;
|
|
43
|
+
await page.focus(sel);
|
|
44
|
+
if (a.clear) {
|
|
45
|
+
await page.$eval(sel, (el) => {
|
|
46
|
+
const f = el;
|
|
47
|
+
f.value = "";
|
|
48
|
+
f.dispatchEvent(new Event("input", { bubbles: true }));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
await page.keyboard.type(a.text, { delay: 20 });
|
|
53
|
+
if (a.pressEnter)
|
|
54
|
+
await page.keyboard.press("Enter");
|
|
55
|
+
return text({ ok: true });
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
solari_browser_key: {
|
|
59
|
+
description: "Press a key in the browser session (e.g. 'Enter', 'Escape', 'ArrowDown', 'PageDown'). " +
|
|
60
|
+
"Chords use '+' (e.g. 'Control+a').",
|
|
61
|
+
inputSchema: { sessionId: z.string(), key: z.string() },
|
|
62
|
+
handler: async (a) => {
|
|
63
|
+
const page = await activePage(need(a.sessionId));
|
|
64
|
+
const parts = a.key.split("+").filter(Boolean);
|
|
65
|
+
const key = parts.pop();
|
|
66
|
+
for (const m of parts)
|
|
67
|
+
await page.keyboard.down(m);
|
|
68
|
+
try {
|
|
69
|
+
await page.keyboard.press(key);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
for (const m of parts.reverse())
|
|
73
|
+
await page.keyboard.up(m);
|
|
74
|
+
}
|
|
75
|
+
return text({ ok: true });
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
solari_browser_evaluate: {
|
|
79
|
+
description: "Evaluate a JavaScript expression on the page and return its JSON-serialized result.",
|
|
80
|
+
inputSchema: { sessionId: z.string(), expression: z.string() },
|
|
81
|
+
handler: async (a) => {
|
|
82
|
+
const page = await activePage(need(a.sessionId));
|
|
83
|
+
// Pass the expression as a string: puppeteer sends it as a
|
|
84
|
+
// debugger-originated Runtime.evaluate, which is exempt from the
|
|
85
|
+
// page's CSP. Wrapping it in eval() inside page context is not.
|
|
86
|
+
const result = await page.evaluate(a.expression);
|
|
87
|
+
return text(result === undefined ? "undefined" : result);
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Page navigation.
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { text } from "./context.js";
|
|
4
|
+
export function makeNavigationTools(ctx) {
|
|
5
|
+
const { need, activePage } = ctx;
|
|
6
|
+
return {
|
|
7
|
+
solari_browser_navigate: {
|
|
8
|
+
description: "Navigate the browser session to a URL. Returns final url, title and HTTP status.",
|
|
9
|
+
inputSchema: { sessionId: z.string(), url: z.string() },
|
|
10
|
+
handler: async (a) => {
|
|
11
|
+
const page = await activePage(need(a.sessionId));
|
|
12
|
+
let url = a.url.trim();
|
|
13
|
+
if (!/^[a-z][a-z0-9+.-]*:/i.test(url))
|
|
14
|
+
url = `https://${url}`;
|
|
15
|
+
const scheme = url.slice(0, url.indexOf(":")).toLowerCase();
|
|
16
|
+
if (scheme !== "http" && scheme !== "https") {
|
|
17
|
+
throw new Error(`refusing to navigate to a non-http(s) URL (${scheme}:)`);
|
|
18
|
+
}
|
|
19
|
+
// Block link-local / cloud metadata so page content can't steer the
|
|
20
|
+
// agent into reading the browser pool's instance credentials.
|
|
21
|
+
const host = new URL(url).hostname;
|
|
22
|
+
if (/^(169\.254\.|127\.|\[?::1\]?$|localhost$|metadata\.google)/i.test(host)) {
|
|
23
|
+
throw new Error(`refusing to navigate to internal host ${host}`);
|
|
24
|
+
}
|
|
25
|
+
const resp = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
|
26
|
+
return text({ url: page.url(), title: await page.title(), status: resp?.status() ?? null });
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { apiError, CDP_DIAL_ATTEMPTS, redact, releaseBrowserSession, text, } from "./context.js";
|
|
3
|
+
export function makeSessionTools(ctx) {
|
|
4
|
+
const { cfg, reg, deps, api } = ctx;
|
|
5
|
+
return {
|
|
6
|
+
solari_browser_create: {
|
|
7
|
+
description: "Start a Solari cloud browser session. Use this whenever you need to browse the web. " +
|
|
8
|
+
"Returns a sessionId for the other solari_browser_* tools.\n" +
|
|
9
|
+
"mode defaults to 'stealth' — the anti-bot hardened pool. Just call this with no " +
|
|
10
|
+
"arguments and you get it; you do NOT need to ask for stealth explicitly. Stealth is " +
|
|
11
|
+
"the right default for the open web: ordinary sites work fine on it, and it is what " +
|
|
12
|
+
"keeps bot-detection from blocking the session.\n" +
|
|
13
|
+
"Pass mode:'fast' ONLY for a site you already know does not fingerprint or block " +
|
|
14
|
+
"automation (internal tools, localhost, your own app, plain docs/API pages) and you " +
|
|
15
|
+
"want the lower-latency pool. If a page in fast mode returns a block/captcha/challenge, " +
|
|
16
|
+
"close the session and retry with mode:'stealth'.\n" +
|
|
17
|
+
"captcha auto-solving is ALSO on by default (it follows the pool: on for stealth, off " +
|
|
18
|
+
"for fast). Pass captcha:false to turn it off — solving a challenge costs money and " +
|
|
19
|
+
"adds latency, so switch it off for sites you know never challenge.\n" +
|
|
20
|
+
"proxy ('smart' | country code like 'us') REQUIRES stealth, as does captcha; both are " +
|
|
21
|
+
"rejected if you ask for them explicitly in fast mode. recording gives an rrweb replay.",
|
|
22
|
+
inputSchema: {
|
|
23
|
+
mode: z.enum(["stealth", "fast"]).optional(),
|
|
24
|
+
/** @deprecated use mode. Kept so existing callers keep working. */
|
|
25
|
+
stealth: z.boolean().optional(),
|
|
26
|
+
proxy: z.string().optional(),
|
|
27
|
+
/** Defaults to ON (following the pool). Pass false to opt out. */
|
|
28
|
+
captcha: z.boolean().optional(),
|
|
29
|
+
recording: z.boolean().optional(),
|
|
30
|
+
profileId: z.string().optional(),
|
|
31
|
+
/** Defaults to ON. Pass false to never auto-sign-in on this session. */
|
|
32
|
+
autoLogin: z.boolean().optional(),
|
|
33
|
+
},
|
|
34
|
+
handler: async (a) => {
|
|
35
|
+
// STEALTH IS THE DEFAULT. Models consistently declined to opt in when it
|
|
36
|
+
// was an optional boolean, so sessions silently landed on the fast pool
|
|
37
|
+
// and got blocked by bot detection — the failure looked like "the site
|
|
38
|
+
// is broken" rather than "we picked the wrong pool". Defaulting on, with
|
|
39
|
+
// an explicit escape hatch, removes that whole class of confusion.
|
|
40
|
+
//
|
|
41
|
+
// Precedence: explicit mode wins; then the deprecated stealth boolean
|
|
42
|
+
// (so `stealth:false` still means fast for old callers); then stealth.
|
|
43
|
+
const stealth = a.mode !== undefined ? a.mode === "stealth" : (a.stealth ?? true);
|
|
44
|
+
// CAPTCHA IS ALSO ON BY DEFAULT — same reasoning as stealth: a model
|
|
45
|
+
// that has to opt in does not, and the resulting failure ("the page is
|
|
46
|
+
// a challenge screen") reads as a broken site rather than a missing
|
|
47
|
+
// option.
|
|
48
|
+
//
|
|
49
|
+
// The default FOLLOWS the pool rather than being a flat `true`, because
|
|
50
|
+
// captcha is stealth-only upstream. A flat true would make plain
|
|
51
|
+
// mode:'fast' throw on an option the caller never asked for, turning the
|
|
52
|
+
// escape hatch into a dead end. So: stealth ⇒ on, fast ⇒ off, and an
|
|
53
|
+
// explicit value always wins (captcha:false is the opt-out).
|
|
54
|
+
const captcha = a.captcha ?? stealth;
|
|
55
|
+
// proxy/captcha are stealth-only upstream. Previously a fast-pool
|
|
56
|
+
// session with proxy came back with the proxy silently dropped and only
|
|
57
|
+
// a note in the response; fail loudly instead — a silently unproxied
|
|
58
|
+
// request can leak the origin IP, which is the one thing the caller was
|
|
59
|
+
// trying to avoid. Only an EXPLICIT ask conflicts; the captcha default
|
|
60
|
+
// simply turns itself off in fast mode.
|
|
61
|
+
if (!stealth) {
|
|
62
|
+
const needsStealth = [a.proxy ? "proxy" : "", a.captcha === true ? "captcha" : ""].filter(Boolean);
|
|
63
|
+
if (needsStealth.length) {
|
|
64
|
+
throw new Error(`${needsStealth.join(" and ")} ${needsStealth.length > 1 ? "require" : "requires"} ` +
|
|
65
|
+
`stealth, but mode is 'fast'. ` +
|
|
66
|
+
`Drop ${needsStealth.length > 1 ? "them" : "it"} or use mode:'stealth'.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const body = {};
|
|
70
|
+
if (stealth)
|
|
71
|
+
body.stealth = true;
|
|
72
|
+
if (captcha)
|
|
73
|
+
body.captcha = true;
|
|
74
|
+
if (a.recording)
|
|
75
|
+
body.recording = true;
|
|
76
|
+
if (a.proxy)
|
|
77
|
+
body.proxy = a.proxy;
|
|
78
|
+
if (a.profileId)
|
|
79
|
+
body.profileId = a.profileId;
|
|
80
|
+
// Auto-login is ON by default. If the account has connected a password
|
|
81
|
+
// manager and configured this site, the gateway signs the session in on
|
|
82
|
+
// its own — the agent never sees or handles the credential. Costs
|
|
83
|
+
// nothing when nothing is configured: the gateway finds no connection
|
|
84
|
+
// and the flow is exactly as before.
|
|
85
|
+
if (a.autoLogin !== false)
|
|
86
|
+
body.autoLogin = true;
|
|
87
|
+
const res = await api(cfg, "POST", "/sessions", body);
|
|
88
|
+
if (res.status !== 201)
|
|
89
|
+
await apiError(res, "session create");
|
|
90
|
+
const s = (await res.json());
|
|
91
|
+
// cdpEndpoint is optional on the wire; derive it from wsEndpoint the
|
|
92
|
+
// way the official SDK does.
|
|
93
|
+
const cdp = s.cdpEndpoint ?? s.wsEndpoint?.replace("/ws/", "/cdp/");
|
|
94
|
+
if (!cdp)
|
|
95
|
+
throw new Error("gateway returned no cdpEndpoint or wsEndpoint");
|
|
96
|
+
// From here on the session is BILLABLE. Any failure before it is
|
|
97
|
+
// registered must release it, or it leaks until TTL with an id the
|
|
98
|
+
// model never saw.
|
|
99
|
+
try {
|
|
100
|
+
let browser;
|
|
101
|
+
let lastErr;
|
|
102
|
+
for (let i = 0; i < CDP_DIAL_ATTEMPTS; i++) {
|
|
103
|
+
try {
|
|
104
|
+
browser = await deps.connect(cdp);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
// A freshly-started session has a transient upstream window.
|
|
109
|
+
lastErr = err;
|
|
110
|
+
await new Promise((r) => setTimeout(r, 500 * (i + 1)));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!browser)
|
|
114
|
+
throw lastErr ?? new Error("could not connect to the browser");
|
|
115
|
+
const pages = await browser.pages();
|
|
116
|
+
const page = pages.find((p) => !p.isClosed()) ?? (await browser.newPage());
|
|
117
|
+
reg.sessions.set(s.sessionId, {
|
|
118
|
+
sessionId: s.sessionId,
|
|
119
|
+
browser,
|
|
120
|
+
page,
|
|
121
|
+
expiresAt: s.expiresAt,
|
|
122
|
+
recording: Boolean(a.recording),
|
|
123
|
+
cdpEndpoint: cdp,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
await releaseBrowserSession(cfg, s.sessionId, api).catch(() => { });
|
|
128
|
+
throw new Error(`browser session started but could not be attached (released it): ${redact(err instanceof Error ? err.message : String(err))}`);
|
|
129
|
+
}
|
|
130
|
+
// Proxy silently degrades rather than erroring — surface what we got.
|
|
131
|
+
// Echo the resolved mode back: the caller did not necessarily pick it
|
|
132
|
+
// (stealth is the default), and knowing which pool it landed on is what
|
|
133
|
+
// makes a later block actionable — "retry on stealth" vs "this site
|
|
134
|
+
// blocks us even hardened".
|
|
135
|
+
return text({
|
|
136
|
+
sessionId: s.sessionId,
|
|
137
|
+
mode: stealth ? "stealth" : "fast",
|
|
138
|
+
captcha,
|
|
139
|
+
expiresAt: s.expiresAt,
|
|
140
|
+
proxy: s.proxy ?? (a.proxy ? "NOT APPLIED (check plan)" : undefined),
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
solari_browser_replay_url: {
|
|
145
|
+
description: "Get the session-replay URL for a browser session created with recording: true. " +
|
|
146
|
+
"May 404 for a few seconds right after close — retry.",
|
|
147
|
+
inputSchema: { sessionId: z.string() },
|
|
148
|
+
handler: async (a) => {
|
|
149
|
+
const id = a.sessionId;
|
|
150
|
+
const known = reg.sessions.get(id);
|
|
151
|
+
if (known && !known.recording) {
|
|
152
|
+
throw new Error(`session ${id} was not created with recording: true, so it has no replay`);
|
|
153
|
+
}
|
|
154
|
+
const res = await api(cfg, "GET", `/sessions/${encodeURIComponent(id)}/replay-url`);
|
|
155
|
+
if (!res.ok)
|
|
156
|
+
await apiError(res, "replay-url");
|
|
157
|
+
return text(await res.json());
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
solari_browser_close: {
|
|
161
|
+
description: "Close a browser session and release it.",
|
|
162
|
+
inputSchema: { sessionId: z.string() },
|
|
163
|
+
handler: async (a) => {
|
|
164
|
+
const id = a.sessionId;
|
|
165
|
+
// Release FIRST: if it fails, keep the registry entry so the model can
|
|
166
|
+
// retry rather than losing the handle to a still-billing session.
|
|
167
|
+
await releaseBrowserSession(cfg, id, api);
|
|
168
|
+
const e = reg.sessions.get(id);
|
|
169
|
+
if (e) {
|
|
170
|
+
try {
|
|
171
|
+
await e.browser.disconnect();
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
/* already gone */
|
|
175
|
+
}
|
|
176
|
+
reg.sessions.delete(id);
|
|
177
|
+
}
|
|
178
|
+
return text({ ok: true });
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
package/dist/browser.d.ts
CHANGED
|
@@ -1,52 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
type: "text";
|
|
6
|
-
text: string;
|
|
7
|
-
} | {
|
|
8
|
-
type: "image";
|
|
9
|
-
data: string;
|
|
10
|
-
mimeType: string;
|
|
11
|
-
}>;
|
|
12
|
-
isError?: boolean;
|
|
13
|
-
}
|
|
14
|
-
export interface Tool {
|
|
15
|
-
description: string;
|
|
16
|
-
inputSchema: Record<string, ZodTypeAny>;
|
|
17
|
-
handler: (args: Record<string, unknown>) => Promise<McpResult>;
|
|
18
|
-
}
|
|
19
|
-
export interface BrowserConfig {
|
|
20
|
-
apiKey: string;
|
|
21
|
-
baseUrl: string;
|
|
22
|
-
}
|
|
23
|
-
interface BrowserEntry {
|
|
24
|
-
sessionId: string;
|
|
25
|
-
browser: Browser;
|
|
26
|
-
page: Page;
|
|
27
|
-
expiresAt: string;
|
|
28
|
-
recording: boolean;
|
|
29
|
-
/**
|
|
30
|
-
* The session's raw CDP endpoint, kept so the handoff flow can RECONNECT.
|
|
31
|
-
*
|
|
32
|
-
* A login handoff severs the agent's socket gateway-side — deliberately, so
|
|
33
|
-
* the agent cannot watch the human type. That kills this entry's puppeteer
|
|
34
|
-
* handle, so resuming afterwards needs a fresh connect rather than the dead
|
|
35
|
-
* one. Without this the agent "resumes" into a Target-closed error.
|
|
36
|
-
*/
|
|
37
|
-
cdpEndpoint: string;
|
|
38
|
-
}
|
|
39
|
-
export interface BrowserRegistry {
|
|
40
|
-
sessions: Map<string, BrowserEntry>;
|
|
41
|
-
}
|
|
42
|
-
declare function api(cfg: BrowserConfig, method: string, path: string, body?: unknown): Promise<Response>;
|
|
43
|
-
/** Release a browser session gateway-side. Safe to call for unknown ids. */
|
|
44
|
-
export declare function releaseBrowserSession(cfg: BrowserConfig, id: string, fetchApi?: typeof api): Promise<void>;
|
|
45
|
-
export interface BrowserDeps {
|
|
46
|
-
connect: (cdpEndpoint: string) => Promise<Browser>;
|
|
47
|
-
fetchApi: typeof api;
|
|
48
|
-
}
|
|
1
|
+
import { api, type BrowserDeps } from "./browser-tools/context.js";
|
|
2
|
+
export type { BrowserConfig, BrowserDeps, BrowserRegistry, McpResult, Tool, } from "./browser-tools/context.js";
|
|
3
|
+
export { releaseBrowserSession } from "./browser-tools/context.js";
|
|
4
|
+
import type { BrowserConfig, BrowserRegistry, Tool } from "./browser-tools/context.js";
|
|
49
5
|
export declare function makeBrowserToolset(cfg: BrowserConfig, reg: BrowserRegistry, deps?: BrowserDeps): Record<string, Tool>;
|
|
50
6
|
/** Release every browser session in a registry (used on eviction/shutdown). */
|
|
51
7
|
export declare function releaseAllBrowserSessions(cfg: BrowserConfig, reg: BrowserRegistry, fetchApi?: typeof api): Promise<void>;
|
|
52
|
-
export {};
|