@sdods/mcp 0.3.2 → 0.5.1
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/dist/.tsbuildinfo +1 -1
- package/dist/browser/config.d.ts +64 -0
- package/dist/browser/config.js +100 -0
- package/dist/browser/index.d.ts +8 -0
- package/dist/browser/index.js +8 -0
- package/dist/browser/manifest.d.ts +35 -0
- package/dist/browser/manifest.js +105 -0
- package/dist/browser/playwright-driver.d.ts +18 -0
- package/dist/browser/playwright-driver.js +69 -0
- package/dist/browser/policy.d.ts +23 -0
- package/dist/browser/policy.js +30 -0
- package/dist/browser/session.d.ts +57 -0
- package/dist/browser/session.js +145 -0
- package/dist/browser/shapes.d.ts +254 -0
- package/dist/browser/shapes.js +416 -0
- package/dist/browser/types.d.ts +61 -0
- package/dist/browser/types.js +2 -0
- package/dist/install/index.d.ts +9 -0
- package/dist/install/index.js +11 -2
- package/dist/prompts/index.js +1 -1
- package/dist/registry/registry.d.ts +18 -1
- package/dist/registry/registry.js +14 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.js +24 -31
- package/dist/tools/browser.d.ts +3 -0
- package/dist/tools/browser.js +191 -0
- package/dist/tools/index.d.ts +2 -1
- package/dist/tools/index.js +3 -1
- package/dist/tools/run.d.ts +1 -0
- package/dist/tools/run.js +2 -4
- package/package.json +4 -2
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* SDODS browser name -> the child's `--browser` value, plus a device for the mobile profiles.
|
|
4
|
+
*
|
|
5
|
+
* `--browser` is always passed. The child defaults to chromium on the `chrome` channel — real
|
|
6
|
+
* Google Chrome — which is not on a CI runner and is not what `-b chromium` means anywhere else
|
|
7
|
+
* in SDODS.
|
|
8
|
+
*/
|
|
9
|
+
const CHILD_BROWSER = {
|
|
10
|
+
chromium: { browser: 'chromium' },
|
|
11
|
+
edge: { browser: 'msedge' },
|
|
12
|
+
firefox: { browser: 'firefox' },
|
|
13
|
+
webkit: { browser: 'webkit' },
|
|
14
|
+
'mobile-chrome': { browser: 'chromium', device: 'Pixel 7' },
|
|
15
|
+
'mobile-safari': { browser: 'webkit', device: 'iPhone 15' },
|
|
16
|
+
};
|
|
17
|
+
function definedOnly(obj) {
|
|
18
|
+
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
|
19
|
+
}
|
|
20
|
+
function originOf(url) {
|
|
21
|
+
if (!url)
|
|
22
|
+
return undefined;
|
|
23
|
+
try {
|
|
24
|
+
return new URL(url).origin;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function buildSessionLaunch(resolved, spec, opts = {}) {
|
|
31
|
+
const browser = spec.browser ?? 'chromium';
|
|
32
|
+
const child = CHILD_BROWSER[browser];
|
|
33
|
+
const safeId = spec.sessionId.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
34
|
+
const runsDir = resolved.runtime.artifactsDir ?? join(resolved.runtime.repoRoot, '.sdods/runs');
|
|
35
|
+
// Inside a run, artifacts belong with it. Outside one they must NOT go under .sdods/runs:
|
|
36
|
+
// `listRuns` treats every directory there as a run, and `runtime.runId` from `config show` is a
|
|
37
|
+
// freshly minted id on every call — so a session would invent a run with no results, once per
|
|
38
|
+
// open, and `sdods report --last` would start pointing at browsing rather than testing.
|
|
39
|
+
const outputDir = opts.runId
|
|
40
|
+
? join(runsDir, opts.runId, 'browser', safeId)
|
|
41
|
+
: join(resolved.runtime.repoRoot, '.sdods', 'browser', safeId);
|
|
42
|
+
const configPath = join(outputDir, 'mcp.config.json');
|
|
43
|
+
const use = resolved.env.use ?? {};
|
|
44
|
+
const contextOptions = definedOnly({
|
|
45
|
+
storageState: opts.storageStateFile,
|
|
46
|
+
viewport: spec.viewport ?? resolved.project.screenshots?.viewport,
|
|
47
|
+
locale: use.locale,
|
|
48
|
+
timezoneId: use.timezoneId,
|
|
49
|
+
colorScheme: use.colorScheme,
|
|
50
|
+
geolocation: use.geolocation,
|
|
51
|
+
permissions: use.permissions,
|
|
52
|
+
ignoreHTTPSErrors: use.ignoreHTTPSErrors,
|
|
53
|
+
extraHTTPHeaders: use.extraHTTPHeaders,
|
|
54
|
+
httpCredentials: use.httpCredentials,
|
|
55
|
+
});
|
|
56
|
+
// Origins are opt-in. Deriving an allowlist from the app's own baseUrl would block fonts, CDNs,
|
|
57
|
+
// analytics and SSO redirects on any real application — and upstream states the flag is not a
|
|
58
|
+
// security boundary and does not follow redirects, so it must not be sold as one.
|
|
59
|
+
const allowedOrigins = resolved.env.ui?.allowedOrigins
|
|
60
|
+
?.map((o) => originOf(o) ?? o)
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
const blockedOrigins = resolved.env.ui?.blockedOrigins;
|
|
63
|
+
const configFile = definedOnly({
|
|
64
|
+
browser: {
|
|
65
|
+
// Without `isolated`, the child reuses the browser's existing context and every value in
|
|
66
|
+
// contextOptions above is silently discarded — the session would ignore its own storage
|
|
67
|
+
// state, locale, viewport and headers while appearing to work.
|
|
68
|
+
isolated: true,
|
|
69
|
+
contextOptions,
|
|
70
|
+
launchOptions: { headless: !spec.headed },
|
|
71
|
+
},
|
|
72
|
+
testIdAttribute: resolved.project.testIdAttribute,
|
|
73
|
+
outputDir,
|
|
74
|
+
network: allowedOrigins?.length || blockedOrigins?.length
|
|
75
|
+
? definedOnly({ allowedOrigins, blockedOrigins })
|
|
76
|
+
: undefined,
|
|
77
|
+
timeouts: definedOnly({
|
|
78
|
+
action: resolved.project.timeouts?.action,
|
|
79
|
+
navigation: resolved.project.timeouts?.navigation,
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
const argv = ['mcp', '--isolated', '--config', configPath, '--browser', child.browser];
|
|
83
|
+
const device = spec.device ?? child.device;
|
|
84
|
+
if (device)
|
|
85
|
+
argv.push('--device', device);
|
|
86
|
+
if (!spec.headed)
|
|
87
|
+
argv.push('--headless');
|
|
88
|
+
if (spec.caps?.length)
|
|
89
|
+
argv.push('--caps', [...new Set(spec.caps)].join(','));
|
|
90
|
+
if (spec.snapshotMode)
|
|
91
|
+
argv.push('--snapshot-mode', spec.snapshotMode);
|
|
92
|
+
if (spec.images === 'omit')
|
|
93
|
+
argv.push('--image-responses', 'omit');
|
|
94
|
+
// A parsed dotenv file, not a path, once the child has it: keeping it on the command line stops
|
|
95
|
+
// secret VALUES being written into the config file under .sdods/runs.
|
|
96
|
+
if (opts.secretsFile)
|
|
97
|
+
argv.push('--secrets', opts.secretsFile);
|
|
98
|
+
return { configFile, argv, outputDir, configPath };
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './config.js';
|
|
3
|
+
export * from './manifest.js';
|
|
4
|
+
export * from './policy.js';
|
|
5
|
+
export * from './session.js';
|
|
6
|
+
export { UPSTREAM_SHAPES, UPSTREAM_DEFAULTS, type UpstreamToolName } from './shapes.js';
|
|
7
|
+
export { resolvePlaywrightBin, createPlaywrightDriver } from './playwright-driver.js';
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './config.js';
|
|
3
|
+
export * from './manifest.js';
|
|
4
|
+
export * from './policy.js';
|
|
5
|
+
export * from './session.js';
|
|
6
|
+
export { UPSTREAM_SHAPES, UPSTREAM_DEFAULTS } from './shapes.js';
|
|
7
|
+
export { resolvePlaywrightBin, createPlaywrightDriver } from './playwright-driver.js';
|
|
8
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { UpstreamToolName } from './shapes.js';
|
|
2
|
+
/**
|
|
3
|
+
* The SDODS view of each Playwright MCP tool: what it costs a caller, and who may call it.
|
|
4
|
+
*
|
|
5
|
+
* Kept separate from `shapes.ts` (generated) because these are judgements, not transcription. They
|
|
6
|
+
* are also the only place the wrapper differs from upstream on purpose, so the differences are
|
|
7
|
+
* reviewable in one screen rather than spread across 42 definitions.
|
|
8
|
+
*/
|
|
9
|
+
export type BrowserAccess = 'read' | 'write';
|
|
10
|
+
/** Which `--caps` pack the child needs before the tool exists at all. */
|
|
11
|
+
export type CapsPack = 'default' | 'vision' | 'pdf' | 'devtools';
|
|
12
|
+
export interface BrowserToolSpec {
|
|
13
|
+
pack: CapsPack;
|
|
14
|
+
access: BrowserAccess;
|
|
15
|
+
/** Overrides the access/domain scope derivation. Used to keep the two RCE-shaped tools apart. */
|
|
16
|
+
scope?: 'browser:admin';
|
|
17
|
+
/** Appended to the upstream description; says what SDODS adds or restricts. */
|
|
18
|
+
note?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* `read` vs `write` rather than `run`: `run` derives `runs:write` through `scopeForToolAccess`,
|
|
22
|
+
* which is the wrong scope for a browser and would hand browser control to anything that may
|
|
23
|
+
* start a test.
|
|
24
|
+
*
|
|
25
|
+
* Two deliberate divergences from upstream's own annotations:
|
|
26
|
+
* - `browser_take_screenshot` and `browser_pdf_save` write files, and upstream still marks them
|
|
27
|
+
* read-only. They stay `read` here because they only write into the session's own output
|
|
28
|
+
* directory, but the note says so rather than leaving it implied.
|
|
29
|
+
* - Everything that changes page state is `write`, including `browser_navigate`, which upstream
|
|
30
|
+
* does not flag. Navigating away is not an observation.
|
|
31
|
+
*/
|
|
32
|
+
export declare const BROWSER_TOOLS: Record<UpstreamToolName, BrowserToolSpec>;
|
|
33
|
+
/** Tools gated behind the unsafe opt-in. */
|
|
34
|
+
export declare const UNSAFE_TOOLS: ("browser_annotate" | "browser_click" | "browser_close" | "browser_console_messages" | "browser_drag" | "browser_drop" | "browser_evaluate" | "browser_file_upload" | "browser_fill_form" | "browser_find" | "browser_handle_dialog" | "browser_hide_highlight" | "browser_highlight" | "browser_hover" | "browser_mouse_click_xy" | "browser_mouse_down" | "browser_mouse_drag_xy" | "browser_mouse_move_xy" | "browser_mouse_up" | "browser_mouse_wheel" | "browser_navigate" | "browser_navigate_back" | "browser_network_request" | "browser_network_requests" | "browser_pdf_save" | "browser_press_key" | "browser_resize" | "browser_resume" | "browser_run_code_unsafe" | "browser_select_option" | "browser_snapshot" | "browser_start_recording" | "browser_start_tracing" | "browser_start_video" | "browser_stop_recording" | "browser_stop_tracing" | "browser_stop_video" | "browser_tabs" | "browser_take_screenshot" | "browser_type" | "browser_video_chapter" | "browser_video_hide_actions" | "browser_video_show_actions" | "browser_wait_for")[];
|
|
35
|
+
//# sourceMappingURL=manifest.d.ts.map
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `read` vs `write` rather than `run`: `run` derives `runs:write` through `scopeForToolAccess`,
|
|
3
|
+
* which is the wrong scope for a browser and would hand browser control to anything that may
|
|
4
|
+
* start a test.
|
|
5
|
+
*
|
|
6
|
+
* Two deliberate divergences from upstream's own annotations:
|
|
7
|
+
* - `browser_take_screenshot` and `browser_pdf_save` write files, and upstream still marks them
|
|
8
|
+
* read-only. They stay `read` here because they only write into the session's own output
|
|
9
|
+
* directory, but the note says so rather than leaving it implied.
|
|
10
|
+
* - Everything that changes page state is `write`, including `browser_navigate`, which upstream
|
|
11
|
+
* does not flag. Navigating away is not an observation.
|
|
12
|
+
*/
|
|
13
|
+
export const BROWSER_TOOLS = {
|
|
14
|
+
// ---- observe -----------------------------------------------------------------------------
|
|
15
|
+
browser_snapshot: { pack: 'default', access: 'read' },
|
|
16
|
+
browser_find: { pack: 'default', access: 'read' },
|
|
17
|
+
browser_console_messages: { pack: 'default', access: 'read' },
|
|
18
|
+
browser_network_requests: { pack: 'default', access: 'read' },
|
|
19
|
+
browser_network_request: {
|
|
20
|
+
pack: 'default',
|
|
21
|
+
access: 'read',
|
|
22
|
+
note: 'Authorization, cookie and API-key headers are redacted before the result is returned.',
|
|
23
|
+
},
|
|
24
|
+
browser_take_screenshot: {
|
|
25
|
+
pack: 'default',
|
|
26
|
+
access: 'read',
|
|
27
|
+
note: 'Returns the image inline; a `filename` writes into the session output directory only.',
|
|
28
|
+
},
|
|
29
|
+
browser_wait_for: { pack: 'default', access: 'read' },
|
|
30
|
+
// ---- act ---------------------------------------------------------------------------------
|
|
31
|
+
browser_navigate: { pack: 'default', access: 'write' },
|
|
32
|
+
browser_navigate_back: { pack: 'default', access: 'write' },
|
|
33
|
+
browser_click: { pack: 'default', access: 'write' },
|
|
34
|
+
browser_type: { pack: 'default', access: 'write' },
|
|
35
|
+
browser_fill_form: { pack: 'default', access: 'write' },
|
|
36
|
+
browser_press_key: { pack: 'default', access: 'write' },
|
|
37
|
+
browser_hover: { pack: 'default', access: 'write' },
|
|
38
|
+
browser_drag: { pack: 'default', access: 'write' },
|
|
39
|
+
browser_drop: { pack: 'default', access: 'write' },
|
|
40
|
+
browser_select_option: { pack: 'default', access: 'write' },
|
|
41
|
+
browser_file_upload: {
|
|
42
|
+
pack: 'default',
|
|
43
|
+
access: 'write',
|
|
44
|
+
note: 'Paths resolve inside the repository root the server was started in.',
|
|
45
|
+
},
|
|
46
|
+
browser_resize: { pack: 'default', access: 'write' },
|
|
47
|
+
browser_tabs: { pack: 'default', access: 'write' },
|
|
48
|
+
browser_handle_dialog: { pack: 'default', access: 'write' },
|
|
49
|
+
browser_close: {
|
|
50
|
+
pack: 'default',
|
|
51
|
+
access: 'write',
|
|
52
|
+
note: 'Closes the page. The session and its browser stay up — use browser_session_close to end the session.',
|
|
53
|
+
},
|
|
54
|
+
// ---- arbitrary code: the two that are RCE-shaped ------------------------------------------
|
|
55
|
+
browser_evaluate: {
|
|
56
|
+
pack: 'default',
|
|
57
|
+
access: 'write',
|
|
58
|
+
scope: 'browser:admin',
|
|
59
|
+
note: 'Runs caller-supplied JavaScript in the page. Off unless SDODS_BROWSER_ALLOW_UNSAFE is set.',
|
|
60
|
+
},
|
|
61
|
+
browser_run_code_unsafe: {
|
|
62
|
+
pack: 'default',
|
|
63
|
+
access: 'write',
|
|
64
|
+
scope: 'browser:admin',
|
|
65
|
+
note: 'Executes arbitrary JavaScript in the Playwright server process — upstream calls this RCE-equivalent. Off unless SDODS_BROWSER_ALLOW_UNSAFE is set.',
|
|
66
|
+
},
|
|
67
|
+
// ---- vision: coordinate input, no accessibility tree --------------------------------------
|
|
68
|
+
browser_mouse_move_xy: { pack: 'vision', access: 'write' },
|
|
69
|
+
browser_mouse_click_xy: { pack: 'vision', access: 'write' },
|
|
70
|
+
browser_mouse_drag_xy: { pack: 'vision', access: 'write' },
|
|
71
|
+
browser_mouse_down: { pack: 'vision', access: 'write' },
|
|
72
|
+
browser_mouse_up: { pack: 'vision', access: 'write' },
|
|
73
|
+
browser_mouse_wheel: { pack: 'vision', access: 'write' },
|
|
74
|
+
// ---- pdf ----------------------------------------------------------------------------------
|
|
75
|
+
browser_pdf_save: {
|
|
76
|
+
pack: 'pdf',
|
|
77
|
+
access: 'read',
|
|
78
|
+
note: 'Writes into the session output directory only.',
|
|
79
|
+
},
|
|
80
|
+
// ---- devtools: tracing, video, annotation -------------------------------------------------
|
|
81
|
+
browser_start_tracing: { pack: 'devtools', access: 'read' },
|
|
82
|
+
browser_stop_tracing: { pack: 'devtools', access: 'read' },
|
|
83
|
+
browser_start_video: { pack: 'devtools', access: 'read' },
|
|
84
|
+
browser_stop_video: { pack: 'devtools', access: 'read' },
|
|
85
|
+
browser_video_chapter: { pack: 'devtools', access: 'read' },
|
|
86
|
+
browser_video_show_actions: { pack: 'devtools', access: 'read' },
|
|
87
|
+
browser_video_hide_actions: { pack: 'devtools', access: 'read' },
|
|
88
|
+
browser_start_recording: {
|
|
89
|
+
pack: 'devtools',
|
|
90
|
+
access: 'read',
|
|
91
|
+
note: 'Records what a person does in the browser; pair with browser_stop_recording. Not for unattended runs.',
|
|
92
|
+
},
|
|
93
|
+
browser_stop_recording: { pack: 'devtools', access: 'read' },
|
|
94
|
+
browser_highlight: { pack: 'devtools', access: 'write' },
|
|
95
|
+
browser_hide_highlight: { pack: 'devtools', access: 'write' },
|
|
96
|
+
browser_annotate: {
|
|
97
|
+
pack: 'devtools',
|
|
98
|
+
access: 'write',
|
|
99
|
+
note: 'Waits for a person to draw in the browser, so it blocks until they finish. Not for unattended runs.',
|
|
100
|
+
},
|
|
101
|
+
browser_resume: { pack: 'devtools', access: 'write' },
|
|
102
|
+
};
|
|
103
|
+
/** Tools gated behind the unsafe opt-in. */
|
|
104
|
+
export const UNSAFE_TOOLS = Object.keys(BROWSER_TOOLS).filter((n) => BROWSER_TOOLS[n].scope === 'browser:admin');
|
|
105
|
+
//# sourceMappingURL=manifest.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { BrowserDriver } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Drives the Playwright MCP server that ships inside the pinned `playwright`.
|
|
4
|
+
*
|
|
5
|
+
* Not `npx playwright mcp`: npx resolves at run time, which is how this repo ended up with
|
|
6
|
+
* `.mcp.json` pointing at a server nobody had pinned. The binary is resolved from node_modules so
|
|
7
|
+
* the tools the wrapper declares and the tools the child offers come from the same install — the
|
|
8
|
+
* drift test asserts exactly that.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolvePlaywrightBin(repoRoot: string): string | null;
|
|
11
|
+
export interface PlaywrightDriverOptions {
|
|
12
|
+
repoRoot: string;
|
|
13
|
+
argv: string[];
|
|
14
|
+
/** Merged into the child's args map before each call; see UPSTREAM_DEFAULTS. */
|
|
15
|
+
defaultsFor?: (tool: string) => Record<string, unknown> | undefined;
|
|
16
|
+
}
|
|
17
|
+
export declare function createPlaywrightDriver(opts: PlaywrightDriverOptions): Promise<BrowserDriver>;
|
|
18
|
+
//# sourceMappingURL=playwright-driver.d.ts.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { redactHeaders } from './policy.js';
|
|
4
|
+
/**
|
|
5
|
+
* Drives the Playwright MCP server that ships inside the pinned `playwright`.
|
|
6
|
+
*
|
|
7
|
+
* Not `npx playwright mcp`: npx resolves at run time, which is how this repo ended up with
|
|
8
|
+
* `.mcp.json` pointing at a server nobody had pinned. The binary is resolved from node_modules so
|
|
9
|
+
* the tools the wrapper declares and the tools the child offers come from the same install — the
|
|
10
|
+
* drift test asserts exactly that.
|
|
11
|
+
*/
|
|
12
|
+
export function resolvePlaywrightBin(repoRoot) {
|
|
13
|
+
const candidates = [
|
|
14
|
+
join(repoRoot, 'node_modules', '.bin', 'playwright'),
|
|
15
|
+
join(repoRoot, 'node_modules', '@playwright', 'test', 'cli.js'),
|
|
16
|
+
];
|
|
17
|
+
return candidates.find((c) => existsSync(c)) ?? null;
|
|
18
|
+
}
|
|
19
|
+
export async function createPlaywrightDriver(opts) {
|
|
20
|
+
const bin = resolvePlaywrightBin(opts.repoRoot);
|
|
21
|
+
if (!bin) {
|
|
22
|
+
throw Object.assign(new Error('Playwright is not installed in this workspace.'), {
|
|
23
|
+
error: { code: 'BROWSER_UNAVAILABLE', hint: 'Run `bun install`.' },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
const { Client } = await import('@modelcontextprotocol/client');
|
|
27
|
+
const { StdioClientTransport } = await import('@modelcontextprotocol/client/stdio');
|
|
28
|
+
const client = new Client({ name: 'sdods-browser', version: '0.1.0' });
|
|
29
|
+
const transport = new StdioClientTransport({
|
|
30
|
+
command: bin.endsWith('.js') ? process.execPath : bin,
|
|
31
|
+
args: bin.endsWith('.js') ? [bin, ...opts.argv] : opts.argv,
|
|
32
|
+
env: { ...process.env },
|
|
33
|
+
// The child restricts file access to its workspace roots, so the repo root is what makes
|
|
34
|
+
// browser_file_upload of a repo path and the .sdods output directory reachable.
|
|
35
|
+
cwd: opts.repoRoot,
|
|
36
|
+
stderr: 'ignore',
|
|
37
|
+
});
|
|
38
|
+
await client.connect(transport);
|
|
39
|
+
const server = client.getServerVersion() ?? { name: 'unknown', version: 'unknown' };
|
|
40
|
+
return {
|
|
41
|
+
info: () => ({ server: String(server.name), version: String(server.version) }),
|
|
42
|
+
async listTools() {
|
|
43
|
+
return (await client.listTools()).tools.map((t) => t.name);
|
|
44
|
+
},
|
|
45
|
+
async call(tool, args) {
|
|
46
|
+
// Upstream schemas are additionalProperties:false, so SDODS-only arguments must not be
|
|
47
|
+
// forwarded, and the properties upstream marks required-with-default must be filled back in.
|
|
48
|
+
const { sessionId: _drop, ...rest } = args;
|
|
49
|
+
const merged = { ...(opts.defaultsFor?.(tool) ?? {}), ...rest };
|
|
50
|
+
const result = (await client.callTool({ name: tool, arguments: merged }));
|
|
51
|
+
const out = { text: '', images: [], isError: Boolean(result.isError) };
|
|
52
|
+
const parts = [];
|
|
53
|
+
for (const c of result.content ?? []) {
|
|
54
|
+
if (c.type === 'text')
|
|
55
|
+
parts.push(String(c.text ?? ''));
|
|
56
|
+
else if (c.type === 'image')
|
|
57
|
+
out.images.push({ data: String(c.data), mimeType: String(c.mimeType ?? 'image/png') });
|
|
58
|
+
else
|
|
59
|
+
parts.push(`[${String(c.type)}]`);
|
|
60
|
+
}
|
|
61
|
+
out.text = redactHeaders(parts.join('\n'));
|
|
62
|
+
return out;
|
|
63
|
+
},
|
|
64
|
+
async close() {
|
|
65
|
+
await client.close().catch(() => undefined);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=playwright-driver.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the wrapper refuses, and what it never lets back out.
|
|
3
|
+
*
|
|
4
|
+
* Applied in the driver rather than in each tool, so a tool cannot forget to call it.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Network results come back as TEXT, so the keyed-object redactor used elsewhere in SDODS does not
|
|
8
|
+
* apply — headers arrive as `authorization: Bearer ey...` inside a formatted block.
|
|
9
|
+
*
|
|
10
|
+
* Bodies are deliberately not regex-scrubbed: Playwright's own `--secrets` masks known values at
|
|
11
|
+
* capture time, which a post-hoc guess cannot do without also mangling legitimate content.
|
|
12
|
+
*/
|
|
13
|
+
export declare function redactHeaders(text: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Arbitrary-code tools are off unless explicitly enabled.
|
|
16
|
+
*
|
|
17
|
+
* A scope alone is not enough: the stdio principal is LOCAL_ADMIN and holds every scope, so on a
|
|
18
|
+
* developer machine `browser_run_code_unsafe` would otherwise always be available. This is the
|
|
19
|
+
* switch an operator can set for a deployment; the scope is what governs HTTP principals.
|
|
20
|
+
*/
|
|
21
|
+
export declare function unsafeToolsEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
22
|
+
export declare const UNSAFE_DISABLED_HINT = "Set SDODS_BROWSER_ALLOW_UNSAFE=1 to enable it. It runs caller-supplied code, which upstream documents as RCE-equivalent.";
|
|
23
|
+
//# sourceMappingURL=policy.d.ts.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the wrapper refuses, and what it never lets back out.
|
|
3
|
+
*
|
|
4
|
+
* Applied in the driver rather than in each tool, so a tool cannot forget to call it.
|
|
5
|
+
*/
|
|
6
|
+
/** Header lines whose value must never reach a transcript. */
|
|
7
|
+
const SECRET_HEADER = /^(\s*[-*]?\s*)(authorization|proxy-authorization|cookie|set-cookie|x-api-key|x-auth-token|x-csrf-token)(\s*:\s*)(.+)$/gim;
|
|
8
|
+
/**
|
|
9
|
+
* Network results come back as TEXT, so the keyed-object redactor used elsewhere in SDODS does not
|
|
10
|
+
* apply — headers arrive as `authorization: Bearer ey...` inside a formatted block.
|
|
11
|
+
*
|
|
12
|
+
* Bodies are deliberately not regex-scrubbed: Playwright's own `--secrets` masks known values at
|
|
13
|
+
* capture time, which a post-hoc guess cannot do without also mangling legitimate content.
|
|
14
|
+
*/
|
|
15
|
+
export function redactHeaders(text) {
|
|
16
|
+
return text.replace(SECRET_HEADER, (_m, lead, name, sep) => `${lead}${name}${sep}***`);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Arbitrary-code tools are off unless explicitly enabled.
|
|
20
|
+
*
|
|
21
|
+
* A scope alone is not enough: the stdio principal is LOCAL_ADMIN and holds every scope, so on a
|
|
22
|
+
* developer machine `browser_run_code_unsafe` would otherwise always be available. This is the
|
|
23
|
+
* switch an operator can set for a deployment; the scope is what governs HTTP principals.
|
|
24
|
+
*/
|
|
25
|
+
export function unsafeToolsEnabled(env = process.env) {
|
|
26
|
+
const raw = env.SDODS_BROWSER_ALLOW_UNSAFE;
|
|
27
|
+
return raw === '1' || raw === 'true';
|
|
28
|
+
}
|
|
29
|
+
export const UNSAFE_DISABLED_HINT = 'Set SDODS_BROWSER_ALLOW_UNSAFE=1 to enable it. It runs caller-supplied code, which upstream documents as RCE-equivalent.';
|
|
30
|
+
//# sourceMappingURL=policy.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type ResolvedForBrowser } from './config.js';
|
|
2
|
+
import type { BrowserDriver, BrowserSessionSpec, SessionInfo } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Live browser sessions for ONE MCP connection.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately not a module-level map keyed by a caller-supplied id. The HTTP transport builds one
|
|
7
|
+
* server and one ToolContext per mcp-session-id and already refuses a session whose principal
|
|
8
|
+
* differs; a process-wide map would let a second principal name the first one's session id and
|
|
9
|
+
* drive their logged-in browser. Ownership by context makes that unreachable.
|
|
10
|
+
*/
|
|
11
|
+
export interface SessionManagerOptions {
|
|
12
|
+
rootDir: string;
|
|
13
|
+
/** Reaps a session that nobody has used. Browsers are expensive to leave running. */
|
|
14
|
+
idleMs?: number;
|
|
15
|
+
maxSessions?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Seams. The default implementations spawn the pinned Playwright MCP child and shell out to
|
|
18
|
+
* `sdods config show`; overriding them lets the lifecycle be tested without either, and is where
|
|
19
|
+
* a second BrowserDriver would be plugged in.
|
|
20
|
+
*/
|
|
21
|
+
createDriver?: (args: {
|
|
22
|
+
repoRoot: string;
|
|
23
|
+
argv: string[];
|
|
24
|
+
}) => Promise<BrowserDriver>;
|
|
25
|
+
resolveConfig?: (project: string, env?: string) => Promise<ResolvedForBrowser>;
|
|
26
|
+
}
|
|
27
|
+
interface Entry {
|
|
28
|
+
driver: BrowserDriver;
|
|
29
|
+
info: SessionInfo;
|
|
30
|
+
timer?: NodeJS.Timeout;
|
|
31
|
+
}
|
|
32
|
+
export declare const DEFAULT_IDLE_MS: number;
|
|
33
|
+
export declare const DEFAULT_MAX_SESSIONS = 4;
|
|
34
|
+
export declare class BrowserSessionManager {
|
|
35
|
+
private readonly opts;
|
|
36
|
+
private readonly sessions;
|
|
37
|
+
private readonly idleMs;
|
|
38
|
+
private readonly maxSessions;
|
|
39
|
+
constructor(opts: SessionManagerOptions);
|
|
40
|
+
list(): SessionInfo[];
|
|
41
|
+
has(id: string): boolean;
|
|
42
|
+
open(spec: BrowserSessionSpec): Promise<SessionInfo>;
|
|
43
|
+
/** The session for a call, opening a default one from the connection's project when needed. */
|
|
44
|
+
ensure(id: string, fallback: Omit<BrowserSessionSpec, 'sessionId'>): Promise<Entry>;
|
|
45
|
+
get(id: string): Entry | undefined;
|
|
46
|
+
close(id: string): Promise<boolean>;
|
|
47
|
+
closeAll(): Promise<void>;
|
|
48
|
+
private touch;
|
|
49
|
+
/**
|
|
50
|
+
* Config comes through the CLI, like every other SDODS MCP tool, rather than by importing
|
|
51
|
+
* @sdods/core — so a browser session sees exactly what `sdods config show` would print.
|
|
52
|
+
*/
|
|
53
|
+
private resolve;
|
|
54
|
+
private storageStateFor;
|
|
55
|
+
}
|
|
56
|
+
export {};
|
|
57
|
+
//# sourceMappingURL=session.d.ts.map
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { sdodsCli } from '../cli.js';
|
|
4
|
+
import { buildSessionLaunch } from './config.js';
|
|
5
|
+
import { createPlaywrightDriver } from './playwright-driver.js';
|
|
6
|
+
import { UPSTREAM_DEFAULTS } from './shapes.js';
|
|
7
|
+
export const DEFAULT_IDLE_MS = 10 * 60_000;
|
|
8
|
+
export const DEFAULT_MAX_SESSIONS = 4;
|
|
9
|
+
export class BrowserSessionManager {
|
|
10
|
+
opts;
|
|
11
|
+
sessions = new Map();
|
|
12
|
+
idleMs;
|
|
13
|
+
maxSessions;
|
|
14
|
+
constructor(opts) {
|
|
15
|
+
this.opts = opts;
|
|
16
|
+
this.idleMs = opts.idleMs ?? DEFAULT_IDLE_MS;
|
|
17
|
+
this.maxSessions = opts.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
18
|
+
}
|
|
19
|
+
list() {
|
|
20
|
+
return [...this.sessions.values()].map((e) => e.info);
|
|
21
|
+
}
|
|
22
|
+
has(id) {
|
|
23
|
+
return this.sessions.has(id);
|
|
24
|
+
}
|
|
25
|
+
async open(spec) {
|
|
26
|
+
await this.close(spec.sessionId);
|
|
27
|
+
if (this.sessions.size >= this.maxSessions) {
|
|
28
|
+
throw Object.assign(new Error(`At most ${this.maxSessions} browser sessions at a time.`), {
|
|
29
|
+
error: {
|
|
30
|
+
code: 'BROWSER_TOO_MANY_SESSIONS',
|
|
31
|
+
hint: 'Close one with browser_session_close, or list them with browser_session_list.',
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const resolved = await this.resolve(spec.project, spec.env);
|
|
36
|
+
const storageStateFile = spec.role
|
|
37
|
+
? await this.storageStateFor(spec.project, spec.env, spec.role)
|
|
38
|
+
: undefined;
|
|
39
|
+
// Only correlate with a run when one is actually in progress; see buildSessionLaunch.
|
|
40
|
+
const launch = buildSessionLaunch(resolved, spec, {
|
|
41
|
+
storageStateFile,
|
|
42
|
+
runId: process.env.SDODS_RUN_ID || undefined,
|
|
43
|
+
});
|
|
44
|
+
mkdirSync(dirname(launch.configPath), { recursive: true });
|
|
45
|
+
writeFileSync(launch.configPath, `${JSON.stringify(launch.configFile, null, 2)}\n`);
|
|
46
|
+
const driver = this.opts.createDriver
|
|
47
|
+
? await this.opts.createDriver({ repoRoot: this.opts.rootDir, argv: launch.argv })
|
|
48
|
+
: await createPlaywrightDriver({
|
|
49
|
+
repoRoot: this.opts.rootDir,
|
|
50
|
+
argv: launch.argv,
|
|
51
|
+
defaultsFor: (tool) => UPSTREAM_DEFAULTS[tool],
|
|
52
|
+
});
|
|
53
|
+
const now = new Date().toISOString();
|
|
54
|
+
const info = {
|
|
55
|
+
sessionId: spec.sessionId,
|
|
56
|
+
project: spec.project,
|
|
57
|
+
env: spec.env ?? resolved.env.name,
|
|
58
|
+
role: spec.role,
|
|
59
|
+
browser: (spec.browser ?? 'chromium'),
|
|
60
|
+
headed: Boolean(spec.headed),
|
|
61
|
+
outputDir: launch.outputDir,
|
|
62
|
+
openedAt: now,
|
|
63
|
+
lastUsed: now,
|
|
64
|
+
server: `${driver.info().server} ${driver.info().version}`,
|
|
65
|
+
};
|
|
66
|
+
const entry = { driver, info };
|
|
67
|
+
this.sessions.set(spec.sessionId, entry);
|
|
68
|
+
this.touch(spec.sessionId);
|
|
69
|
+
return info;
|
|
70
|
+
}
|
|
71
|
+
/** The session for a call, opening a default one from the connection's project when needed. */
|
|
72
|
+
async ensure(id, fallback) {
|
|
73
|
+
const existing = this.sessions.get(id);
|
|
74
|
+
if (existing) {
|
|
75
|
+
this.touch(id);
|
|
76
|
+
return existing;
|
|
77
|
+
}
|
|
78
|
+
await this.open({ ...fallback, sessionId: id });
|
|
79
|
+
return this.sessions.get(id);
|
|
80
|
+
}
|
|
81
|
+
get(id) {
|
|
82
|
+
const e = this.sessions.get(id);
|
|
83
|
+
if (e)
|
|
84
|
+
this.touch(id);
|
|
85
|
+
return e;
|
|
86
|
+
}
|
|
87
|
+
async close(id) {
|
|
88
|
+
const entry = this.sessions.get(id);
|
|
89
|
+
if (!entry)
|
|
90
|
+
return false;
|
|
91
|
+
if (entry.timer)
|
|
92
|
+
clearTimeout(entry.timer);
|
|
93
|
+
this.sessions.delete(id);
|
|
94
|
+
await entry.driver.close();
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
async closeAll() {
|
|
98
|
+
await Promise.all([...this.sessions.keys()].map((id) => this.close(id)));
|
|
99
|
+
}
|
|
100
|
+
touch(id) {
|
|
101
|
+
const entry = this.sessions.get(id);
|
|
102
|
+
if (!entry)
|
|
103
|
+
return;
|
|
104
|
+
entry.info.lastUsed = new Date().toISOString();
|
|
105
|
+
if (entry.timer)
|
|
106
|
+
clearTimeout(entry.timer);
|
|
107
|
+
entry.timer = setTimeout(() => void this.close(id), this.idleMs);
|
|
108
|
+
// A pending reap must never hold the process open.
|
|
109
|
+
entry.timer.unref?.();
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Config comes through the CLI, like every other SDODS MCP tool, rather than by importing
|
|
113
|
+
* @sdods/core — so a browser session sees exactly what `sdods config show` would print.
|
|
114
|
+
*/
|
|
115
|
+
async resolve(project, env) {
|
|
116
|
+
if (this.opts.resolveConfig)
|
|
117
|
+
return this.opts.resolveConfig(project, env);
|
|
118
|
+
const args = ['config', 'show', '-p', project];
|
|
119
|
+
if (env)
|
|
120
|
+
args.push('-e', env);
|
|
121
|
+
const r = await sdodsCli(args, { cwd: this.opts.rootDir });
|
|
122
|
+
return r.json;
|
|
123
|
+
}
|
|
124
|
+
async storageStateFor(project, env, role) {
|
|
125
|
+
const args = ['auth', 'list', '-p', project];
|
|
126
|
+
if (env)
|
|
127
|
+
args.push('-e', env);
|
|
128
|
+
const rows = (await sdodsCli(args, { cwd: this.opts.rootDir }))
|
|
129
|
+
.json;
|
|
130
|
+
const hit = (Array.isArray(rows) ? rows : []).find((r) => String(r.role ?? r.user) === role && String(r.fresh ?? 'yes') !== 'no');
|
|
131
|
+
const file = hit?.file ? String(hit.file) : undefined;
|
|
132
|
+
if (!file) {
|
|
133
|
+
// Capturing would drive a real login flow, which is not something a read of "open a browser
|
|
134
|
+
// as this role" should trigger on its own.
|
|
135
|
+
throw Object.assign(new Error(`No fresh login state for role "${role}".`), {
|
|
136
|
+
error: {
|
|
137
|
+
code: 'BROWSER_NO_AUTH_STATE',
|
|
138
|
+
hint: `Run \`sdods auth capture -p ${project}${env ? ` -e ${env}` : ''} -u ${role}\` first.`,
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return file;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=session.js.map
|