Skip to content

Commit b99334d

Browse files
committed
feat: implement tab management tools
1 parent 3b15052 commit b99334d

File tree

12 files changed

+908
-6
lines changed

12 files changed

+908
-6
lines changed

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,31 @@ Close the current browser session.
142142

143143
</details>
144144

145+
<details>
146+
<summary>Tabs</summary>
147+
148+
### `listTabs`
149+
Get a list of all currently opened browser tabs with their URLs, titles, and active status.
150+
151+
### `switchToTab`
152+
Switch to a specific browser tab by its number (starting from 1).
153+
- **Parameters:**
154+
- `tabNumber` (number, required): The number of the tab to switch to (starting from 1)
155+
156+
### `openNewTab`
157+
Open a new browser tab, optionally navigate to a URL, and automatically switch to it.
158+
- **Parameters:**
159+
- `url` (string, optional): The URL to navigate to in the new tab. If not provided, opens a blank tab
160+
161+
### `closeTab`
162+
Close a specific browser tab by its number (1-based), or close the current tab if no number is provided.
163+
- **Parameters:**
164+
- `tabNumber` (number, optional): The number of the tab to close (starting from 1). If not provided, closes the current tab
165+
166+
**Note:** Cannot close the last remaining tab. `closeBrowser` should be used to close the entire browser session.
167+
168+
</details>
169+
145170
<details>
146171
<summary>Element Interaction</summary>
147172

src/responses/index.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface BrowserResponseOptions {
1414
testplaneCode?: string;
1515
additionalInfo?: string;
1616
snapshotOptions?: CaptureSnapshotOptions;
17+
isSnapshotNeeded?: boolean;
1718
}
1819

1920
export interface ElementResponseOptions {
@@ -59,12 +60,17 @@ export async function createBrowserStateResponse(
5960
const activeIndicator = tab.isActive ? "(current)" : "";
6061
sections.push(` ${index + 1}. Title: ${tab.title}; URL: ${tab.url} ${activeIndicator}`);
6162
});
63+
} else {
64+
sections.push("## Browser Tabs");
65+
sections.push("No opened tabs");
6266
}
6367

64-
const snapshot = await getCurrentTabSnapshot(browser, options.snapshotOptions);
65-
if (snapshot) {
66-
sections.push("## Current Tab Snapshot");
67-
sections.push(snapshot);
68+
if (options.isSnapshotNeeded !== false) {
69+
const snapshot = await getCurrentTabSnapshot(browser, options.snapshotOptions);
70+
if (snapshot) {
71+
sections.push("## Current Tab Snapshot");
72+
sections.push(snapshot);
73+
}
6874
}
6975

7076
if (options.additionalInfo) {

src/tools/close-tab.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { z } from "zod";
2+
import { ToolDefinition } from "../types.js";
3+
import { ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
4+
import { contextProvider } from "../context-provider.js";
5+
import { createBrowserStateResponse, createErrorResponse } from "../responses/index.js";
6+
import { getBrowserTabs } from "../responses/browser-helpers.js";
7+
8+
export const closeTabSchema = {
9+
tabNumber: z
10+
.number()
11+
.int()
12+
.min(1)
13+
.optional()
14+
.describe("The number of the tab to close (starting from 1). If not provided, closes the current tab"),
15+
};
16+
17+
const closeTabCb: ToolCallback<typeof closeTabSchema> = async args => {
18+
try {
19+
const { tabNumber } = args;
20+
const context = contextProvider.getContext();
21+
22+
if (!context.browser.isActive()) {
23+
return createErrorResponse(
24+
"Cannot close tab — browser is not launched yet. Try opening a tab or navigating to URL.",
25+
);
26+
}
27+
28+
const browser = await context.browser.get();
29+
const windowHandles = await browser.getWindowHandles();
30+
31+
if (windowHandles.length === 0) {
32+
return createErrorResponse("Cannot close tab — no tabs are currently open");
33+
}
34+
35+
if (windowHandles.length === 1) {
36+
return createErrorResponse(
37+
"Cannot close tab — this is the last remaining tab. Use closeBrowser to close the entire browser session.",
38+
);
39+
}
40+
41+
let targetTabNumber: number;
42+
const currentHandle = await browser.getWindowHandle();
43+
const currentIndex = windowHandles.indexOf(currentHandle);
44+
45+
if (tabNumber) {
46+
if (tabNumber > windowHandles.length) {
47+
return createErrorResponse(
48+
`Cannot close tab — tab number ${tabNumber} is out of range. Available range: 1-${windowHandles.length}`,
49+
);
50+
}
51+
targetTabNumber = tabNumber;
52+
} else {
53+
targetTabNumber = currentIndex + 1;
54+
}
55+
56+
const tabs = await getBrowserTabs(browser);
57+
const tabToClose = tabs[targetTabNumber - 1];
58+
59+
await browser.switchToWindow(windowHandles[targetTabNumber - 1]);
60+
await browser.closeWindow();
61+
62+
if (tabNumber && tabNumber !== currentIndex + 1) {
63+
await browser.switchToWindow(currentHandle);
64+
} else {
65+
const remainingHandles = await browser.getWindowHandles();
66+
await browser.switchToWindow(remainingHandles[0]);
67+
}
68+
69+
const actionMessage = `Closed tab ${targetTabNumber}: ${tabToClose.title} (URL: ${tabToClose.url})`;
70+
const testplaneCode = tabNumber
71+
? `// Close specific tab by number\nconst windowHandles = await browser.getWindowHandles();\nawait browser.switchToWindow(windowHandles[${targetTabNumber - 1}]);\nawait browser.closeWindow();`
72+
: `// Close current tab\nawait browser.closeWindow();`;
73+
74+
return await createBrowserStateResponse(browser, {
75+
action: actionMessage,
76+
testplaneCode,
77+
isSnapshotNeeded: false,
78+
});
79+
} catch (error) {
80+
console.error("Error closing tab:", error);
81+
return createErrorResponse("Error closing tab", error instanceof Error ? error : undefined);
82+
}
83+
};
84+
85+
export const closeTab: ToolDefinition<typeof closeTabSchema> = {
86+
name: "closeTab",
87+
description:
88+
"Close a specific browser tab by its number (1-based), or close the current tab if no number is provided",
89+
schema: closeTabSchema,
90+
cb: closeTabCb,
91+
};

src/tools/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,19 @@ import { closeBrowser } from "./close-browser.js";
44
import { clickOnElement } from "./click-on-element.js";
55
import { typeIntoElement } from "./type-into-element.js";
66
import { takePageSnapshot } from "./take-page-snapshot.js";
7+
import { listTabs } from "./list-tabs.js";
8+
import { switchToTab } from "./switch-to-tab.js";
9+
import { openNewTab } from "./open-new-tab.js";
10+
import { closeTab } from "./close-tab.js";
711

812
export const tools = [
913
navigate,
1014
closeBrowser,
1115
clickOnElement,
1216
typeIntoElement,
1317
takePageSnapshot,
18+
listTabs,
19+
switchToTab,
20+
openNewTab,
21+
closeTab,
1422
] as const satisfies ToolDefinition<any>[]; // eslint-disable-line @typescript-eslint/no-explicit-any

src/tools/list-tabs.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { ToolDefinition } from "../types.js";
2+
import { ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
3+
import { contextProvider } from "../context-provider.js";
4+
import { createBrowserStateResponse, createErrorResponse } from "../responses/index.js";
5+
6+
export const listTabsSchema = {};
7+
8+
const listTabsCb: ToolCallback<typeof listTabsSchema> = async () => {
9+
try {
10+
const context = contextProvider.getContext();
11+
12+
if (!context.browser.isActive()) {
13+
return createErrorResponse(
14+
"No opened tabs — browser is not launched yet. Try opening a tab or navigating to URL.",
15+
);
16+
}
17+
18+
const browser = await context.browser.get();
19+
20+
return await createBrowserStateResponse(browser, {
21+
action: "Retrieved list of browser tabs",
22+
isSnapshotNeeded: false,
23+
});
24+
} catch (error) {
25+
console.error("Error listing browser tabs:", error);
26+
return createErrorResponse("Error listing browser tabs", error instanceof Error ? error : undefined);
27+
}
28+
};
29+
30+
export const listTabs: ToolDefinition<typeof listTabsSchema> = {
31+
name: "listTabs",
32+
description: "Get a list of all currently opened browser tabs with their URLs, titles, and active status",
33+
schema: listTabsSchema,
34+
cb: listTabsCb,
35+
};

src/tools/open-new-tab.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { z } from "zod";
2+
import { ToolDefinition } from "../types.js";
3+
import { ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
4+
import { contextProvider } from "../context-provider.js";
5+
import { createBrowserStateResponse, createErrorResponse } from "../responses/index.js";
6+
7+
export const openNewTabSchema = {
8+
url: z.string().url("Invalid URL format").optional().describe("Optional URL to navigate to in the new tab"),
9+
};
10+
11+
const openNewTabCb: ToolCallback<typeof openNewTabSchema> = async args => {
12+
try {
13+
const { url } = args;
14+
const context = contextProvider.getContext();
15+
16+
const browserWasActive = context.browser.isActive();
17+
const browser = await context.browser.get();
18+
19+
let actionMessage = "Opened new tab";
20+
let testplaneCode = "// Open new tab\nawait browser.newWindow('about:blank');";
21+
22+
if (!browserWasActive) {
23+
// Browser wasn't active, so browser.get() already created the first tab
24+
if (url) {
25+
await browser.url(url);
26+
}
27+
} else {
28+
await browser.newWindow(url ?? "about:blank");
29+
}
30+
if (url) {
31+
actionMessage = `Opened new tab and navigated to ${url}`;
32+
testplaneCode = `// Open new tab and navigate to URL\nawait browser.newWindow('${url}');`;
33+
}
34+
35+
return await createBrowserStateResponse(browser, {
36+
action: actionMessage,
37+
testplaneCode,
38+
});
39+
} catch (error) {
40+
console.error("Error opening new tab:", error);
41+
return createErrorResponse("Error opening new tab", error instanceof Error ? error : undefined);
42+
}
43+
};
44+
45+
export const openNewTab: ToolDefinition<typeof openNewTabSchema> = {
46+
name: "openNewTab",
47+
description: "Open a new browser tab, optionally navigate to a URL, and automatically switch to it",
48+
schema: openNewTabSchema,
49+
cb: openNewTabCb,
50+
};

src/tools/switch-to-tab.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { z } from "zod";
2+
import { ToolDefinition } from "../types.js";
3+
import { ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
4+
import { contextProvider } from "../context-provider.js";
5+
import { createBrowserStateResponse, createErrorResponse } from "../responses/index.js";
6+
import { getBrowserTabs } from "../responses/browser-helpers.js";
7+
8+
export const switchToTabSchema = {
9+
tabNumber: z.number().int().min(1).describe("The number of the tab to switch to (starting from 1)"),
10+
};
11+
12+
const switchToTabCb: ToolCallback<typeof switchToTabSchema> = async args => {
13+
try {
14+
const { tabNumber } = args;
15+
const context = contextProvider.getContext();
16+
17+
if (!context.browser.isActive()) {
18+
return createErrorResponse(
19+
"Cannot switch to tab — browser is not launched yet. Try opening a tab or navigating to URL.",
20+
);
21+
}
22+
23+
const browser = await context.browser.get();
24+
const windowHandles = await browser.getWindowHandles();
25+
26+
if (windowHandles.length === 0) {
27+
return createErrorResponse("Cannot switch to tab — no tabs are currently open");
28+
}
29+
30+
if (tabNumber > windowHandles.length) {
31+
return createErrorResponse(
32+
`Cannot switch to tab — tab number ${tabNumber} is out of range. Available range: 1-${windowHandles.length}`,
33+
);
34+
}
35+
36+
const arrayIndex = tabNumber - 1;
37+
const targetHandle = windowHandles[arrayIndex];
38+
const currentHandle = await browser.getWindowHandle();
39+
40+
if (targetHandle === currentHandle) {
41+
return createBrowserStateResponse(browser, {
42+
action: `Already on tab ${tabNumber}`,
43+
isSnapshotNeeded: false,
44+
});
45+
}
46+
47+
await browser.switchToWindow(targetHandle);
48+
49+
const tabs = await getBrowserTabs(browser);
50+
const switchedTab = tabs[arrayIndex];
51+
52+
return await createBrowserStateResponse(browser, {
53+
action: `Switched to tab ${tabNumber}: ${switchedTab.title}, URL: ${switchedTab.url}`,
54+
testplaneCode: `// In actual test code, you may want to search for the tab by its name or URL
55+
// Switch to tab by index
56+
const windowHandles = await browser.getWindowHandles();
57+
await browser.switchToWindow(windowHandles[${arrayIndex}]);`,
58+
isSnapshotNeeded: false,
59+
});
60+
} catch (error) {
61+
console.error("Error switching to tab:", error);
62+
return createErrorResponse("Error switching to tab", error instanceof Error ? error : undefined);
63+
}
64+
};
65+
66+
export const switchToTab: ToolDefinition<typeof switchToTabSchema> = {
67+
name: "switchToTab",
68+
description: "Switch to a specific browser tab by its number (starting from 1)",
69+
schema: switchToTabSchema,
70+
cb: switchToTabCb,
71+
};

test/responses/index.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,8 @@ describe("responses/index", () => {
188188
const result = await createBrowserStateResponse(mockBrowser, options);
189189
const responseText = result.content[0].text;
190190

191-
expect(responseText).not.toContain("## Browser Tabs");
192-
expect(responseText).toContain("No tabs test");
191+
expect(responseText).toContain("## Browser Tabs");
192+
expect(responseText).toContain("No opened tabs");
193193
});
194194

195195
it("should handle null snapshot", async () => {

0 commit comments

Comments
 (0)