@solarisdk/mcp 0.4.3 → 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.
@@ -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,2 @@
1
+ import { type BrowserToolCtx, type Tool } from "./context.js";
2
+ export declare function makeNavigationTools(ctx: BrowserToolCtx): Record<string, Tool>;
@@ -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,2 @@
1
+ import { type BrowserToolCtx, type Tool } from "./context.js";
2
+ export declare function makeSessionTools(ctx: BrowserToolCtx): Record<string, Tool>;
@@ -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 { type Browser, type Page } from "puppeteer-core";
2
- import { type ZodTypeAny } from "zod";
3
- export interface McpResult {
4
- content: Array<{
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 {};