Skip to content

Commit 9842031

Browse files
author
rocketraccoon
committed
feat(mcp-tools): add attach to existing browser
1 parent 06790c8 commit 9842031

File tree

5 files changed

+234
-10
lines changed

5 files changed

+234
-10
lines changed

src/browser-context.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { WdioBrowser } from "testplane";
2-
import { launchBrowser } from "testplane/unstable";
1+
import { WdioBrowser, SessionOptions } from "testplane";
2+
import { launchBrowser, attachToBrowser } from "testplane/unstable";
33

44
export interface BrowserOptions {
55
headless?: boolean;
@@ -8,24 +8,32 @@ export interface BrowserOptions {
88
export class BrowserContext {
99
protected _browser: WdioBrowser | null = null;
1010
protected _options: BrowserOptions;
11+
protected _session: SessionOptions | undefined;
1112

12-
constructor(options: BrowserOptions = {}) {
13+
constructor(options: BrowserOptions = {}, session?: SessionOptions) {
1314
this._options = options;
15+
this._session = session;
1416
}
1517

1618
async get(): Promise<WdioBrowser> {
1719
if (this._browser) {
1820
return this._browser;
1921
}
2022

21-
this._browser = await launchBrowser({
22-
headless: this._options.headless ? "new" : false,
23-
desiredCapabilities: {
24-
"goog:chromeOptions": {
25-
args: process.env.DISABLE_BROWSER_SANDBOX ? ["--no-sandbox", "--disable-dev-shm-usage"] : [],
23+
if (this._session) {
24+
console.error("Attach to browser");
25+
this._browser = await attachToBrowser(this._session);
26+
} else {
27+
console.error("Launch browser");
28+
this._browser = await launchBrowser({
29+
headless: this._options.headless ? "new" : false,
30+
desiredCapabilities: {
31+
"goog:chromeOptions": {
32+
args: process.env.DISABLE_BROWSER_SANDBOX ? ["--no-sandbox", "--disable-dev-shm-usage"] : [],
33+
},
2634
},
27-
},
28-
});
35+
});
36+
}
2937

3038
return this._browser;
3139
}

src/tools/attach-to-browser.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Context, ToolDefinition } from "../types.js";
2+
import { ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
3+
import { createSimpleResponse, createErrorResponse } from "../responses/index.js";
4+
import { BrowserContext } from "../browser-context.js";
5+
import type { SessionOptions } from "testplane";
6+
import { contextProvider } from "../context-provider.js";
7+
import { attachToBrowserSchema } from "./utils/attach-to-browser-schema.js";
8+
9+
const attachToBrowserCb: ToolCallback<typeof attachToBrowserSchema> = async args => {
10+
try {
11+
const { session } = args;
12+
13+
const browserContext = new BrowserContext({}, session as SessionOptions);
14+
await browserContext.get();
15+
16+
contextProvider.setContext({
17+
browser: browserContext,
18+
} as Context);
19+
20+
const context = contextProvider.getContext();
21+
22+
if (!context.browser.isActive()) {
23+
return createSimpleResponse("No active browser session");
24+
}
25+
26+
return createSimpleResponse("Successfully attached to existing browser session");
27+
} catch (error) {
28+
console.error("Error attach to browser:", error);
29+
return createErrorResponse("Error attach to browser", error instanceof Error ? error : undefined);
30+
}
31+
};
32+
33+
export const attachToBrowser: ToolDefinition<typeof attachToBrowserSchema> = {
34+
name: "attachToBrowser",
35+
description: "Attach to existing browser session",
36+
schema: attachToBrowserSchema,
37+
cb: attachToBrowserCb,
38+
};

src/tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { switchToTab } from "./switch-to-tab.js";
1010
import { openNewTab } from "./open-new-tab.js";
1111
import { closeTab } from "./close-tab.js";
1212
import { waitForElement } from "./wait-for-element.js";
13+
import { attachToBrowser } from "./attach-to-browser.js";
1314

1415
export const tools = [
1516
navigate,
@@ -23,4 +24,5 @@ export const tools = [
2324
openNewTab,
2425
closeTab,
2526
waitForElement,
27+
attachToBrowser,
2628
] as const satisfies ToolDefinition<any>[]; // eslint-disable-line @typescript-eslint/no-explicit-any
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { z } from "zod";
2+
3+
export const attachToBrowserSchema = {
4+
session: z
5+
.object({
6+
sessionId: z.string().describe("Unique identifier for the session"),
7+
sessionCaps: z
8+
.object({
9+
acceptInsecureCerts: z
10+
.boolean()
11+
.describe("Whether the session accepts insecure certificates")
12+
.optional(),
13+
browserName: z.string().describe("Name of the browser being automated"),
14+
browserVersion: z.string().describe("Version of the browser"),
15+
chrome: z
16+
.object({
17+
chromedriverVersion: z.string().describe("Version of ChromeDriver being used"),
18+
userDataDir: z.string().describe("Path to Chrome's user data directory"),
19+
})
20+
.describe("Chrome-specific capabilities")
21+
.optional(),
22+
"fedcm:accounts": z.boolean().describe("FedCM accounts API support flag").optional(),
23+
"goog:chromeOptions": z
24+
.object({
25+
debuggerAddress: z.string().describe("Address for Chrome debugger"),
26+
})
27+
.describe("Chrome options")
28+
.optional(),
29+
networkConnectionEnabled: z.boolean().describe("Network connection capability flag").optional(),
30+
pageLoadStrategy: z.string().describe("Strategy for page loading").optional(),
31+
platformName: z.string().describe("Name of the platform").optional(),
32+
proxy: z.object({}).describe("Proxy configuration").optional(),
33+
setWindowRect: z.boolean().describe("Window resizing capability flag"),
34+
strictFileInteractability: z.boolean().describe("Strict file interaction flag").optional(),
35+
timeouts: z
36+
.object({
37+
implicit: z.number().describe("Implicit wait timeout in ms"),
38+
pageLoad: z.number().describe("Page load timeout in ms"),
39+
script: z.number().describe("Script execution timeout in ms"),
40+
})
41+
.describe("Timeout configurations")
42+
.optional(),
43+
unhandledPromptBehavior: z.string().describe("Behavior for unhandled prompts").optional(),
44+
"webauthn:extension:credBlob": z
45+
.boolean()
46+
.describe("WebAuthn credBlob extension support")
47+
.optional(),
48+
"webauthn:extension:largeBlob": z
49+
.boolean()
50+
.describe("WebAuthn largeBlob extension support")
51+
.optional(),
52+
"webauthn:extension:minPinLength": z
53+
.boolean()
54+
.describe("WebAuthn minPinLength extension support")
55+
.optional(),
56+
"webauthn:extension:prf": z.boolean().describe("WebAuthn prf extension support").optional(),
57+
"webauthn:virtualAuthenticators": z.boolean().describe("Virtual authenticators support").optional(),
58+
})
59+
.describe("Session capabilities"),
60+
sessionOpts: z
61+
.object({
62+
protocol: z.string().describe("Protocol used for connection"),
63+
hostname: z.string().describe("Hostname for WebDriver server"),
64+
port: z.number().describe("Port for WebDriver server"),
65+
path: z.string().describe("Base path for WebDriver endpoints"),
66+
queryParams: z.object({}).describe("Additional query parameters").optional(),
67+
capabilities: z
68+
.object({
69+
browserName: z.string().describe("Requested browser name"),
70+
"wdio:enforceWebDriverClassic": z
71+
.boolean()
72+
.describe("Flag to enforce classic WebDriver protocol"),
73+
"goog:chromeOptions": z
74+
.object({
75+
binary: z.string().describe("Path to Chrome binary"),
76+
})
77+
.describe("Chrome-specific options"),
78+
})
79+
.describe("Requested capabilities")
80+
.optional(),
81+
logLevel: z.string().describe("Logging level").optional(),
82+
connectionRetryTimeout: z.number().describe("Connection retry timeout in ms").optional(),
83+
connectionRetryCount: z.number().describe("Maximum connection retry attempts").optional(),
84+
enableDirectConnect: z.boolean().describe("Flag for direct connection to browser").optional(),
85+
strictSSL: z.boolean().describe("Strict SSL verification flag"),
86+
requestedCapabilities: z
87+
.object({
88+
browserName: z.string().describe("Originally requested browser name"),
89+
"wdio:enforceWebDriverClassic": z
90+
.boolean()
91+
.describe("Originally requested protocol enforcement"),
92+
"goog:chromeOptions": z.object({
93+
binary: z.string().describe("Originally requested Chrome binary path"),
94+
}),
95+
})
96+
.describe("Originally requested capabilities")
97+
.optional(),
98+
automationProtocol: z.string().describe("Automation protocol being used").optional(),
99+
baseUrl: z.string().describe("Base URL for tests").optional(),
100+
waitforInterval: z.number().describe("Wait interval in ms").optional(),
101+
waitforTimeout: z.number().describe("Wait timeout in ms").optional(),
102+
})
103+
.describe("Session options")
104+
.optional(),
105+
})
106+
.describe("Attach to browser json object from console after --keep-browser testplane run"),
107+
};
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
3+
import { startClient } from "../utils";
4+
import { INTEGRATION_TEST_TIMEOUT } from "../constants";
5+
6+
const sessionMinimalMock = {
7+
sessionId: "ffe4129f99be1125e304242500121efa",
8+
sessionCaps: {
9+
browserName: "chrome",
10+
browserVersion: "137.0.7151.119",
11+
setWindowRect: true,
12+
},
13+
sessionOpts: {
14+
protocol: "http",
15+
hostname: "127.0.0.1",
16+
port: 49426,
17+
path: "/",
18+
strictSSL: true,
19+
},
20+
};
21+
describe.only(
22+
"tools/attachToBrowser",
23+
() => {
24+
let client: Client;
25+
26+
beforeEach(async () => {
27+
client = await startClient();
28+
});
29+
30+
afterEach(async () => {
31+
if (client) {
32+
await client.close();
33+
}
34+
});
35+
36+
describe("attachToBrowser tool availability", () => {
37+
it("should list attachToBrowser tool in available tools", async () => {
38+
const tools = await client.listTools();
39+
40+
const attachToBrowserTool = tools.tools.find(tool => tool.name === "attachToBrowser");
41+
42+
expect(attachToBrowserTool).toBeDefined();
43+
expect(attachToBrowserTool?.description).toBe("Attach to existing browser session");
44+
});
45+
});
46+
47+
describe("attachToBrowser tool execution", () => {
48+
it("should attach to existing browser session", async () => {
49+
const result = await client.callTool({
50+
name: "attachToBrowser",
51+
arguments: {
52+
session: sessionMinimalMock,
53+
},
54+
});
55+
56+
expect(true).toBe(true);
57+
58+
expect(result.isError).toBe(false);
59+
expect(result.content).toBeDefined();
60+
61+
const content = result.content as Array<{ type: string; text: string }>;
62+
expect(content).toHaveLength(1);
63+
expect(content[0].type).toBe("text");
64+
expect(content[0].text).toBe("Successfully attached to existing browser session");
65+
});
66+
});
67+
},
68+
INTEGRATION_TEST_TIMEOUT,
69+
);

0 commit comments

Comments
 (0)