Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 48 additions & 14 deletions src/lib/mcp/tools/browsers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1551,25 +1551,17 @@ describe("manage_browsers save profile", () => {
expect(profileName?.description).toContain(
"create when no matching profile exists",
);
expect(browser?.description).toContain("leased_at");
expect(browser?.description).toContain(
"active cdp, playwright, and webdriver connections close and must reconnect",
);
expect(browser?.description).toContain(
"call save_profile at a stable point, such as right after a login completes",
);
expect(browser?.description).toContain(
"a browser_busy conflict is temporary; retry the call",
expect(profileName?.description).toContain(
"saving does not attach it or change save_profile_changes",
);
expect(browser?.description).toContain(
"browser_not_active (the browser is no longer running) are not retryable",
"save_profile restarts the browser: cdp, playwright, and webdriver connections close",
);
expect(browser?.description).toContain(
"caches, service worker storage, and google drive indexeddb data are omitted",
"call it at a stable point, such as right after a login completes",
);
expect(browser?.description).toContain("the most recently started wins");
expect(browser?.description).toContain(
"a successful save restarts timeout_seconds",
"mint a read or control live-view url",
);
} finally {
await close();
Expand Down Expand Up @@ -1636,11 +1628,53 @@ describe("manage_browsers save profile", () => {
expect(byId).toEqual({
id: "prf_existing",
name: "existing-profile",
note: expect.any(String),
});
expect(byName).toEqual({
expect(byName).toMatchObject({
id: "prf_created_from_name",
name: "checkout.snapshot-1",
});
for (const text of [
"start a new webdriver session",
"caches, service worker storage, and google drive indexeddb data are omitted",
"a successful save restarts timeout_seconds",
"the most recently started wins",
]) {
expect(byId.note).toContain(text);
}
} finally {
await close();
}
});

test("returns retry guidance when a save fails", async () => {
const { client, close } = await connectTestMcp(
registerBrowserCapabilities,
{
browsers: {
saveProfile: async () => {
throw new Error("browser_busy");
},
},
},
);

try {
const result = await client.callTool({
name: "manage_browsers",
arguments: {
action: "save_profile",
session_id: "brr_123",
profile_name: "profile",
},
});
expect(result.isError).toBe(true);
const text = toolResultText(result);
expect(text).toContain("a browser_busy conflict is temporary");
expect(text).toContain(
"browser_not_active (the browser is no longer running) are not retryable",
);
expect(text).toContain("check the profile before calling again");
} finally {
await close();
}
Expand Down
19 changes: 14 additions & 5 deletions src/lib/mcp/tools/browsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,10 @@ const maxRawTelemetryEvents = 5;
// the page envelope while ensuring one response cannot carry multiple max-size records.
const maxRawTelemetryResponseBytes = 1024 * 1024;
const saveProfileTimeoutMs = 190_000;
const saveProfileNote =
"the browser restarted: reconnect cdp and playwright clients, start a new webdriver session, and expect restored tabs to have reloaded without in-memory page state. caches, service worker storage, and google drive indexeddb data are omitted from the saved profile but remain in the running browser. a successful save restarts timeout_seconds. browsers already using this profile keep their in-memory state; pools with refresh_on_profile_update replace idle sessions from the new snapshot, and other browsers use it when they next load the profile. among overlapping save_profile calls to the same profile, the most recently started wins even if an earlier call finishes later; an automatic session-end save with save_profile_changes is ordered against them by finish time.";
const saveProfileErrorNote =
"a browser_busy conflict is temporary; retry the call. browser_lease_changed (a pooled browser was released or reacquired after the call began) and browser_not_active (the browser is no longer running) are not retryable against that browser. save_profile waits up to 190 seconds and is not retried automatically; after a timeout the save may still finish, so check the profile before calling again. a failed save leaves timeout_seconds unchanged.";

function rawTelemetryPageError(items: TelemetryEnvelope[]) {
for (const { event } of items) {
Expand Down Expand Up @@ -499,7 +503,7 @@ export function registerBrowserCapabilities(
"manage_browsers",
{
description:
'manage the browser sessions that every web task runs in, and their archived telemetry. once a session exists, drive it with webmcp, browser_repl, execute_playwright_code, or computer_action, preferring the first that can carry the current step. use "list" to choose an existing session, "create" before browser control (optionally with start_replay), "update" to change supported session settings, "get" for full details, "get_telemetry" to diagnose active or deleted sessions, "create_live_view" to mint an unrevokable read-only or control-only live-view bearer url for a headful browser, "save_profile" to snapshot a running session to an existing or new profile without ending it, and "delete" when finished. live sessions can be addressed by id or by the name given at creation or set on update; deleted sessions only by id. browser records include leased_at only for leased pooled sessions; it is the time the session was last leased. get_telemetry compacts events by default; set compact=false with explicit categories and a limit of at most 5 when raw headers, request data, response bodies, or other omitted fields are needed. save_profile accepts exactly one destination: profile_id must identify an existing profile, while profile_name overwrites the matching profile in the browser project or creates it. saving restarts the browser: active cdp, playwright, and webdriver connections close and must reconnect, webdriver clients need a new session, restored tabs reload, and in-memory page state is lost, so call save_profile at a stable point, such as right after a login completes, not in the middle of a page action. the browser is stopped only while its user data is copied, and the action returns after the restarted browser accepts cdp connections and the profile upload completes. caches, service worker storage, and google drive indexeddb data are omitted from the saved profile but remain in the running browser. saving neither attaches the destination profile nor changes save_profile_changes. active browsers already using that profile keep their in-memory state; pools with refresh_on_profile_update enabled replace idle sessions from the new snapshot, while other browsers use it when they next load the profile. among overlapping save_profile calls to the same profile, the most recently started wins even if an earlier call finishes later; an automatic session-end save with save_profile_changes is ordered against them by finish time. a successful save restarts timeout_seconds, while a failed save leaves it unchanged. a browser_busy conflict is temporary; retry the call. browser_lease_changed (a pooled browser was released or reacquired after the call began) and browser_not_active (the browser is no longer running) are not retryable against that browser. save_profile waits up to 190 seconds and is not retried automatically; after a timeout the save may still finish, so check the profile before calling again.',
'manage the browser sessions that every web task runs in, and their archived telemetry. once a session exists, drive it with webmcp, browser_repl, execute_playwright_code, or computer_action, preferring the first that can carry the current step. use "list" to choose an existing session, "create" before browser control, "update" to change supported session settings, "get" for full details, "get_telemetry" to diagnose active or deleted sessions, "create_live_view" to mint a read or control live-view url for a headful browser, "save_profile" to snapshot a running session to a profile without ending it, and "delete" when finished. live view urls are bearer credentials that cannot be revoked before they expire. save_profile restarts the browser: cdp, playwright, and webdriver connections close and tabs reload, so call it at a stable point, such as right after a login completes. live sessions can be addressed by id or by the name given at creation or set on update; deleted sessions only by id. get_telemetry compacts events by default; set compact=false with explicit categories and a limit of at most 5 when raw headers, request data, response bodies, or other omitted fields are needed.',
inputSchema: z.strictObject(
{
...projectSelectionInputSchema(),
Expand Down Expand Up @@ -648,14 +652,14 @@ export function registerBrowserCapabilities(
.max(255)
.regex(/^[a-zA-Z0-9._-]{1,255}$/)
.describe(
"(create, update) profile name to load saved cookies/logins. (save_profile) destination profile name to overwrite, or create when no matching profile exists in the browser project. must be 1-255 letters, digits, '.', '_' or '-'; cannot use with profile_id.",
"(create, update) profile name to load saved cookies/logins. (save_profile) destination profile name to overwrite, or create when no matching profile exists in the browser project; saving does not attach it or change save_profile_changes. must be 1-255 letters, digits, '.', '_' or '-'; cannot use with profile_id.",
)
.optional(),
profile_id: z
.string()
.min(1)
.describe(
"(create, update) existing profile id to load. (save_profile) existing destination profile id to overwrite. cannot use with profile_name.",
"(create, update) existing profile id to load. (save_profile) existing destination profile id to overwrite; saving does not attach it or change save_profile_changes. cannot use with profile_name.",
)
.optional(),
save_profile_changes: z
Expand Down Expand Up @@ -1281,7 +1285,7 @@ export function registerBrowserCapabilities(
{ name: params.profile_name },
options,
);
return jsonResponse(profile);
return jsonResponse({ ...profile, note: saveProfileNote });
}
case "delete": {
if (!params.session_id)
Expand All @@ -1293,7 +1297,12 @@ export function registerBrowserCapabilities(
}
}
} catch (error) {
throwToolError("manage_browsers", params.action, error);
throwToolError(
"manage_browsers",
params.action,
error,
params.action === "save_profile" ? saveProfileErrorNote : undefined,
);
}
},
);
Expand Down
69 changes: 38 additions & 31 deletions src/lib/mcp/tools/config-registry.test.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,66 @@
/// <reference types="bun-types" />

import { describe, expect, test } from 'bun:test';
import { connectTestMcp, toolResultJSON } from '@/lib/mcp/mcp-test-fixtures';
import { registerConfigRegistryTools } from '@/lib/mcp/tools/config-registry';
import { describe, expect, test } from "bun:test";
import { connectTestMcp, toolResultJSON } from "@/lib/mcp/mcp-test-fixtures";
import { registerConfigRegistryTools } from "@/lib/mcp/tools/config-registry";

describe('manage_config_registry SDK parity', () => {
test('preserves a recommended reserved ISP IP in the nested create recipe', async () => {
describe("manage_config_registry SDK parity", () => {
test("preserves a recommended reserved ISP IP in the nested create recipe", async () => {
const recommendation = {
type: 'recommendation',
type: "recommendation",
browser: { stealth: true },
evidence: {},
match_scope: 'exact',
matched_target: 'https://example.com',
match_scope: "exact",
matched_target: "https://example.com",
proxy: {
mode: 'managed',
mode: "managed",
create: {
type: 'isp',
type: "isp",
config: {
country: 'US',
ip_address: '203.0.113.10',
country: "US",
ip_address: "203.0.113.10",
},
},
},
};
let lookupInput: unknown;
const { client, close } = await connectTestMcp(registerConfigRegistryTools, {
configRegistry: {
lookup: async (input: unknown) => {
lookupInput = input;
return {
target: { url: 'https://example.com' },
recommendation,
working_configurations: [recommendation],
};
const { client, close } = await connectTestMcp(
registerConfigRegistryTools,
{
configRegistry: {
lookup: async (input: unknown) => {
lookupInput = input;
return {
target: { url: "https://example.com" },
recommendation,
working_configurations: [recommendation],
};
},
},
},
});
);

try {
const tool = (await client.listTools()).tools.find(({ name }) => name === 'manage_config_registry');
expect(tool?.description).toContain('config.ip_address');
const tool = (await client.listTools()).tools.find(
({ name }) => name === "manage_config_registry",
);
expect(tool?.description).toContain("config.ip_address");

const result = await client.callTool({
name: 'manage_config_registry',
name: "manage_config_registry",
arguments: {
action: 'lookup',
url: 'https://example.com',
allowed_proxy_countries: ['US'],
action: "lookup",
url: "https://example.com",
allowed_proxy_countries: ["US"],
},
});
expect(lookupInput).toEqual({
url: 'https://example.com',
allowed_proxy_countries: ['US'],
url: "https://example.com",
allowed_proxy_countries: ["US"],
});
expect(toolResultJSON(result).recommendation.proxy.create.config.ip_address).toBe('203.0.113.10');
expect(
toolResultJSON(result).recommendation.proxy.create.config.ip_address,
).toBe("203.0.113.10");
} finally {
await close();
}
Expand Down
5 changes: 1 addition & 4 deletions src/lib/mcp/tools/proxies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,10 +452,7 @@ describe("manage_proxies", () => {
proxy_count: 1,
});
expect(calls).toEqual([
[
"list",
{ country: "GB", status: "active", limit: 5, offset: 0 },
],
["list", { country: "GB", status: "active", limit: 5, offset: 0 }],
["get", "203.0.113.10"],
]);
} finally {
Expand Down
4 changes: 1 addition & 3 deletions src/lib/mcp/tools/proxies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,7 @@ export function registerProxyTools(
}
if (
params.ip_address !== undefined &&
!["create", "list", "move", "get_reserved_ip"].includes(
params.action,
)
!["create", "list", "move", "get_reserved_ip"].includes(params.action)
) {
return errorResponse(
"error: ip_address applies only to list, move, and get_reserved_ip; for create use config.ip_address.",
Expand Down
4 changes: 1 addition & 3 deletions src/lib/mcp/tools/vault-managed-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,7 @@ describe("managed auth vault credentials", () => {
expect(credentialTool?.inputSchema.properties?.provider).toMatchObject({
enum: ["kernel", "1password", "managed_auth"],
});
expect(credentialTool?.description).toContain(
'provider:"managed_auth"',
);
expect(credentialTool?.description).toContain('provider:"managed_auth"');
expect(credentialTool?.description).toContain(
"follows future saved-credential changes",
);
Expand Down
8 changes: 4 additions & 4 deletions src/lib/mcp/tools/vault-onepassword.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,9 @@ describe("1Password vault credentials", () => {
expect(guidance).toContain("it needs no browser");
expect(guidance).toContain("every 2 seconds");
expect(guidance).toContain("after 3 minutes");
expect(guidance).toContain("mcp intentionally omits access_request_reference");
expect(guidance).toContain(
"mcp intentionally omits access_request_reference",
);
expect(guidance).toContain(
"create a browser with this vault attached (KERNEL loads the 1password extension into it on demand)",
);
Expand Down Expand Up @@ -933,9 +935,7 @@ describe("1Password vault credentials", () => {
expect(body.guidance.join(" ")).toContain(
"may import an access request already approved",
);
expect(body.guidance.join(" ")).toContain(
"mcp intentionally omits them",
);
expect(body.guidance.join(" ")).toContain("mcp intentionally omits them");
expect(body.item.state.access_request).not.toHaveProperty("resolved");
expectNoReferences(read);
expect(
Expand Down
10 changes: 4 additions & 6 deletions src/lib/mcp/tools/vault-provider-configs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@ import {
organizationWideAuthInfo,
projectScopedAuthInfo,
} from "@/lib/mcp/auth-context.test-fixtures";
import {
toolResultJSON,
toolResultText,
} from "@/lib/mcp/mcp-test-fixtures";
import { toolResultJSON, toolResultText } from "@/lib/mcp/mcp-test-fixtures";
import { connectVaultTest, item, linkSpec } from "./vaults.test-fixtures";

const tool = "manage_vault_provider_configs";
Expand Down Expand Up @@ -64,8 +61,9 @@ describe("vault provider config SDK routing", () => {
"manage_vault_wallets",
"manage_vault_cards",
]) {
expect(tools.find((candidate) => candidate.name === name)?.description)
.toContain("vgs");
expect(
tools.find((candidate) => candidate.name === name)?.description,
).toContain("vgs");
}

const results = [
Expand Down
Loading