mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 15:00:11 +00:00
feat: Analyze heap snapshots without a browser connection (#3156)
The heap snapshot tools only read local files but every tool call used to launch or connect to a browser first. This PR changes this such that we only connect to the browser for MCP tools that need a browser. McpContext now lives as long as the server and connects lazily to the browser. When BrowserManager returns a different browser (e.g. after a reconnect), McpContext resets and rebuilds its browser-dependent state instead of being re-created. onnect), McpContext resets and rebuilds its browser-dependent state instead of being re-created. The background roots refresh on reconnect is dropped: the context, and its roots, now survive reconnects, and clients notify changes through `roots/list_changed`. Tools that do not need a browser are defined with defineBrowserlessTool. Their handlers only receive a BrowserlessContext with the heap snapshot methods, so using browser APIs from them is a compile-time error. McpContext implements both Context and BrowserlessContext. All heap snapshot analysis tools except take_heapsnapshot are browserless now. Browserless tools also skip the timeout.
This commit is contained in:
1 parent
8604365faa
commit
78c8320c50
13 files changed
+688
-264
No files matched your search
@@ -17,10 +17,7 @@ import type {ParsedArguments} from '../build/src/config/ConfigParser.js';
|
||||
import {zod} from '../build/src/third_party/index.js';
|
||||
import {ToolCategory, labels} from '../build/src/tools/categories.js';
|
||||
import {isSlimTool} from '../build/src/tools/ToolDefinition.js';
|
||||
import type {
|
||||
DefinedPageTool,
|
||||
ToolDefinition,
|
||||
} from '../src/tools/ToolDefinition.js';
|
||||
import type {AnyToolDefinition} from '../src/tools/ToolDefinition.js';
|
||||
import {createTools, requiresHiddenFlag} from '../build/src/tools/tools.js';
|
||||
|
||||
const OUTPUT_PATH = './docs/tool-reference.md';
|
||||
@@ -334,9 +331,7 @@ async function generateReference(
|
||||
);
|
||||
}
|
||||
|
||||
function getToolsAndCategories(
|
||||
tools: Array<ToolDefinition<zod.ZodRawShape> | DefinedPageTool>,
|
||||
) {
|
||||
function getToolsAndCategories(tools: AnyToolDefinition[]) {
|
||||
// Convert ToolDefinitions to ToolWithAnnotations
|
||||
const toolsWithAnnotations: ToolWithAnnotations[] = tools
|
||||
.filter(tool => {
|
||||
|
||||
+103
-35
@@ -42,6 +42,7 @@ import {
|
||||
} from './third_party/index.js';
|
||||
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
|
||||
import type {
|
||||
BrowserlessContext,
|
||||
Context,
|
||||
DevToolsData,
|
||||
SupportedExtensions,
|
||||
@@ -52,7 +53,7 @@ import {McpWorker} from './McpWorker.js';
|
||||
import {getTempFilePath, resolveCanonicalPath} from './utils/files.js';
|
||||
import {escapeForLog} from './utils/logger.js';
|
||||
import {isAllowedUrl} from './utils/url.js';
|
||||
interface McpContextOptions {
|
||||
export interface McpContextOptions {
|
||||
// Whether the DevTools windows are exposed as pages for debugging of DevTools.
|
||||
experimentalDevToolsDebugging: boolean;
|
||||
// Whether all page-like targets are exposed as pages.
|
||||
@@ -70,9 +71,6 @@ interface McpContextOptions {
|
||||
// restricted to the OS temp directory. When true, the previous permissive
|
||||
// behavior is restored.
|
||||
allowUnrestrictedPaths?: boolean;
|
||||
// Whether this context replaces a previous one after a browser reconnect.
|
||||
// Surfaces a one-time note in the next response.
|
||||
reconnected?: boolean;
|
||||
// Custom navigation timeout in milliseconds to override default.
|
||||
navigationTimeout?: number;
|
||||
// Whether extension tools and targets are enabled.
|
||||
@@ -81,6 +79,9 @@ interface McpContextOptions {
|
||||
onNotification?: (message: string) => void;
|
||||
// Shared heap snapshot manager to preserve loaded snapshots across browser reconnects.
|
||||
heapSnapshotManager: HeapSnapshotManager;
|
||||
// Returns the browser to use. Called by ensureBrowser(), so the browser
|
||||
// connection is only established once a tool needs it.
|
||||
getBrowser: () => Promise<Browser>;
|
||||
}
|
||||
|
||||
// Page ids are handed out from a process-wide counter so they stay unique
|
||||
@@ -89,10 +90,17 @@ interface McpContextOptions {
|
||||
// page of the reconnected browser.
|
||||
let nextPageId = 1;
|
||||
|
||||
export class McpContext implements Context {
|
||||
browser: Browser;
|
||||
export class McpContext implements Context, BrowserlessContext {
|
||||
logger: Logger;
|
||||
|
||||
#browser?: Browser;
|
||||
// Initialization of #browser, settles once its browser-dependent state is
|
||||
// ready.
|
||||
#browserReady?: Promise<void>;
|
||||
// Whether a browser connection was established before, so that switching to
|
||||
// a new browser surfaces a reconnect notice.
|
||||
#hasConnected = false;
|
||||
|
||||
// Maps LLM-provided isolatedContext name → Puppeteer BrowserContext.
|
||||
#isolatedContexts = new Map<string, BrowserContext>();
|
||||
// Auto-generated name counter for when no name is provided.
|
||||
@@ -107,7 +115,7 @@ export class McpContext implements Context {
|
||||
#selectedPageFallback?: {wasClosed: boolean};
|
||||
#selectedPageFallbackRead = false;
|
||||
|
||||
#serviceWorkerConsoleCollector: ServiceWorkerConsoleCollector;
|
||||
#serviceWorkerConsoleCollector?: ServiceWorkerConsoleCollector;
|
||||
|
||||
#isRunningTrace = false;
|
||||
|
||||
@@ -122,7 +130,6 @@ export class McpContext implements Context {
|
||||
#allowUnrestrictedPaths: boolean;
|
||||
|
||||
private constructor(
|
||||
browser: Browser,
|
||||
logger: Logger,
|
||||
options: McpContextOptions,
|
||||
locatorClass: typeof Locator,
|
||||
@@ -133,26 +140,22 @@ export class McpContext implements Context {
|
||||
},
|
||||
});
|
||||
|
||||
this.browser = browser;
|
||||
this.logger = logger;
|
||||
this.#locatorClass = locatorClass;
|
||||
this.#options = options;
|
||||
this.#heapSnapshotManager = options.heapSnapshotManager;
|
||||
this.#allowUnrestrictedPaths = options.allowUnrestrictedPaths ?? false;
|
||||
this.#reconnectNotice = options.reconnected ?? false;
|
||||
|
||||
this.#serviceWorkerConsoleCollector = new ServiceWorkerConsoleCollector(
|
||||
this.browser,
|
||||
);
|
||||
}
|
||||
|
||||
async #init(): Promise<void> {
|
||||
await this.createPagesSnapshot();
|
||||
const workers = this.createWorkersSnapshot();
|
||||
|
||||
await this.#serviceWorkerConsoleCollector.init(workers);
|
||||
this.browser.on('targetcreated', this.#onTargetCreated);
|
||||
this.browser.on('targetdestroyed', this.#onTargetDestroyed);
|
||||
/**
|
||||
* The browser of the current connection. Throws if no browser connection was
|
||||
* established yet, see {@link McpContext.ensureBrowser}.
|
||||
*/
|
||||
get browser(): Browser {
|
||||
if (!this.#browser) {
|
||||
throw new Error('Not connected to a browser.');
|
||||
}
|
||||
return this.#browser;
|
||||
}
|
||||
|
||||
#allMcpPages(): Set<McpPage> {
|
||||
@@ -163,11 +166,66 @@ export class McpContext implements Context {
|
||||
return pages;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.browser.off('targetcreated', this.#onTargetCreated);
|
||||
this.browser.off('targetdestroyed', this.#onTargetDestroyed);
|
||||
/**
|
||||
* Returns the browser, establishing the connection if needed. If the browser
|
||||
* changed since the last call (e.g. after a reconnect), all
|
||||
* browser-dependent state is rebuilt for the new browser.
|
||||
*/
|
||||
async ensureBrowser(): Promise<Browser> {
|
||||
const browser = await this.#options.getBrowser();
|
||||
if (browser === this.#browser && this.#browserReady) {
|
||||
// Joins a still pending initialization instead of handing out
|
||||
// half-initialized state.
|
||||
await this.#browserReady;
|
||||
return browser;
|
||||
}
|
||||
|
||||
this.#serviceWorkerConsoleCollector.dispose();
|
||||
this.#resetBrowserState();
|
||||
this.#browser = browser;
|
||||
const ready = this.#initBrowserState(browser);
|
||||
this.#browserReady = ready;
|
||||
try {
|
||||
await ready;
|
||||
return browser;
|
||||
} catch (err) {
|
||||
// Leave no half-initialized state behind, the next call starts over. If
|
||||
// a later call already installed another browser, its state is left
|
||||
// intact.
|
||||
if (this.#browserReady === ready) {
|
||||
this.#resetBrowserState();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async #initBrowserState(browser: Browser): Promise<void> {
|
||||
const collector = new ServiceWorkerConsoleCollector(browser);
|
||||
this.#serviceWorkerConsoleCollector = collector;
|
||||
// All listeners are registered synchronously, before the first await. If
|
||||
// this call hangs and is abandoned by the tool-call timeout, a later call
|
||||
// that installs a different browser removes them in #resetBrowserState().
|
||||
browser.on('targetcreated', this.#onTargetCreated);
|
||||
browser.on('targetdestroyed', this.#onTargetDestroyed);
|
||||
await Promise.all([
|
||||
collector.init(this.createWorkersSnapshot()),
|
||||
this.createPagesSnapshot(),
|
||||
]);
|
||||
// The state now belongs to the browser of a later call, leave it intact.
|
||||
if (this.#browser !== browser) {
|
||||
throw new Error('Browser was replaced while initializing.');
|
||||
}
|
||||
|
||||
if (this.#hasConnected) {
|
||||
this.#reconnectNotice = true;
|
||||
}
|
||||
this.#hasConnected = true;
|
||||
}
|
||||
|
||||
#resetBrowserState(): void {
|
||||
this.#browser?.off('targetcreated', this.#onTargetCreated);
|
||||
this.#browser?.off('targetdestroyed', this.#onTargetDestroyed);
|
||||
|
||||
this.#serviceWorkerConsoleCollector?.dispose();
|
||||
for (const mcpPage of this.#allMcpPages()) {
|
||||
mcpPage.dispose();
|
||||
}
|
||||
@@ -177,6 +235,19 @@ export class McpContext implements Context {
|
||||
// Either the entire browser will be closed or we disconnect
|
||||
// without destroying browser state.
|
||||
this.#isolatedContexts.clear();
|
||||
this.#nextIsolatedContextId = 1;
|
||||
this.#selectedPage = undefined;
|
||||
this.#selectedPageFallback = undefined;
|
||||
this.#selectedPageFallbackRead = false;
|
||||
this.#isRunningTrace = false;
|
||||
this.#traceResults = [];
|
||||
this.#serviceWorkerConsoleCollector = undefined;
|
||||
this.#browser = undefined;
|
||||
this.#browserReady = undefined;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.#resetBrowserState();
|
||||
}
|
||||
|
||||
#onTargetCreated = (target: Target): void => {
|
||||
@@ -203,16 +274,13 @@ export class McpContext implements Context {
|
||||
}
|
||||
};
|
||||
|
||||
static async from(
|
||||
browser: Browser,
|
||||
static create(
|
||||
logger: Logger,
|
||||
opts: McpContextOptions,
|
||||
/* Let tests use unbundled Locator class to avoid overly strict checks within puppeteer that fail when mixing bundled and unbundled class instances */
|
||||
locatorClass: typeof Locator = Locator,
|
||||
): Promise<McpContext> {
|
||||
const context = new McpContext(browser, logger, opts, locatorClass);
|
||||
await context.#init();
|
||||
return context;
|
||||
): McpContext {
|
||||
return new McpContext(logger, opts, locatorClass);
|
||||
}
|
||||
|
||||
static resetPageIdsForTesting(): void {
|
||||
@@ -485,9 +553,9 @@ export class McpContext implements Context {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true once if this context was created by reconnecting after the
|
||||
* previous browser connection was lost, so the next response can surface a
|
||||
* note. Cleared on first call.
|
||||
* Returns true once after the browser connection was re-established with a
|
||||
* new browser, so the next response can surface a note. Cleared on first
|
||||
* call.
|
||||
*/
|
||||
consumeReconnectNotice(): boolean {
|
||||
const notice = this.#reconnectNotice;
|
||||
@@ -572,7 +640,7 @@ export class McpContext implements Context {
|
||||
getServiceWorkerConsoleData(
|
||||
extensionId: string,
|
||||
): Array<ConsoleMessage | UncaughtError> {
|
||||
return this.#serviceWorkerConsoleCollector.getData(extensionId);
|
||||
return this.#serviceWorkerConsoleCollector?.getData(extensionId) ?? [];
|
||||
}
|
||||
|
||||
#getBrowserContextToNameMap(): Map<BrowserContext, string> {
|
||||
|
||||
+125
-74
@@ -7,20 +7,27 @@
|
||||
import type {ParsedArguments} from './config/ConfigParser.js';
|
||||
import type {McpContext} from './McpContext.js';
|
||||
import type {McpPage} from './McpPage.js';
|
||||
import {McpResponse} from './McpResponse.js';
|
||||
import {McpResponse, type DataFormat} from './McpResponse.js';
|
||||
import {SlimMcpResponse} from './SlimMcpResponse.js';
|
||||
import {ClearcutLogger} from './telemetry/ClearcutLogger.js';
|
||||
import type {CallToolResult, Mutex} from './third_party/index.js';
|
||||
import type {Browser, CallToolResult, Mutex} from './third_party/index.js';
|
||||
import {zod} from './third_party/index.js';
|
||||
import {labels} from './tools/categories.js';
|
||||
import {categoryToFlagName} from './config/category-options.js';
|
||||
import type {
|
||||
AnyToolDefinition,
|
||||
BrowserlessToolDefinition,
|
||||
DefinedPageTool,
|
||||
DevToolsData,
|
||||
FileVerificationOption,
|
||||
ToolDefinition,
|
||||
} from './tools/ToolDefinition.js';
|
||||
import {isAvailableInMode, isSlimTool} from './tools/ToolDefinition.js';
|
||||
import {
|
||||
isAvailableInMode,
|
||||
isBrowserlessTool,
|
||||
isPageScopedTool,
|
||||
isSlimTool,
|
||||
} from './tools/ToolDefinition.js';
|
||||
import {logger} from './utils/logger.js';
|
||||
import {fileURLToPath, pathToFileURL} from 'node:url';
|
||||
import {isLocalhost} from './utils/url.js';
|
||||
@@ -55,7 +62,7 @@ function buildDisabledMessage(
|
||||
}
|
||||
|
||||
function getToolStatusInfo(
|
||||
tool: ToolDefinition | DefinedPageTool,
|
||||
tool: AnyToolDefinition,
|
||||
serverArgs: ParsedArguments,
|
||||
): {disabled: boolean; reason?: string; unavailableInMode?: boolean} {
|
||||
if (!isAvailableInMode(tool, serverArgs)) {
|
||||
@@ -91,12 +98,6 @@ function getToolStatusInfo(
|
||||
return {disabled: false};
|
||||
}
|
||||
|
||||
function isPageScopedTool(
|
||||
tool: ToolDefinition | DefinedPageTool,
|
||||
): tool is DefinedPageTool {
|
||||
return 'pageScoped' in tool && tool.pageScoped === true;
|
||||
}
|
||||
|
||||
async function validateAndResolvePathOrUrl(
|
||||
filePathOrUrl: string,
|
||||
context: McpContext,
|
||||
@@ -118,11 +119,11 @@ async function validateAndResolvePathOrUrl(
|
||||
return await context.validatePath(filePathOrUrl);
|
||||
}
|
||||
|
||||
function isLocalBrowser(context: McpContext): boolean {
|
||||
if (context.browser.process()) {
|
||||
function isLocalBrowser(browser: Browser): boolean {
|
||||
if (browser.process()) {
|
||||
return true;
|
||||
}
|
||||
const wsEndpoint = context.browser.wsEndpoint();
|
||||
const wsEndpoint = browser.wsEndpoint();
|
||||
if (wsEndpoint && isLocalhost(wsEndpoint)) {
|
||||
return true;
|
||||
}
|
||||
@@ -145,12 +146,16 @@ function shouldValidateFile(
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* `isLocal` tells whether files are accessed on the machine the server runs on,
|
||||
* which is always the case for browserless tools.
|
||||
*/
|
||||
async function validateToolFiles(
|
||||
tool: ToolDefinition | DefinedPageTool,
|
||||
tool: AnyToolDefinition,
|
||||
params: Record<string, unknown>,
|
||||
context: McpContext,
|
||||
isLocal: boolean,
|
||||
): Promise<void> {
|
||||
const isLocal = isLocalBrowser(context);
|
||||
for (const [key, option] of Object.entries(tool.verifyFilesSchema)) {
|
||||
if (shouldValidateFile(option, isLocal)) {
|
||||
const val = params[key];
|
||||
@@ -186,7 +191,7 @@ export class ToolHandler {
|
||||
private readonly disabledReason?: string;
|
||||
|
||||
constructor(
|
||||
private readonly tool: ToolDefinition | DefinedPageTool,
|
||||
private readonly tool: AnyToolDefinition,
|
||||
private readonly serverArgs: ParsedArguments,
|
||||
private readonly getContext: () => Promise<McpContext>,
|
||||
private readonly toolMutex: Mutex,
|
||||
@@ -234,6 +239,101 @@ export class ToolHandler {
|
||||
}
|
||||
}
|
||||
|
||||
#dataFormat(): DataFormat {
|
||||
// --experimentalDataFormat takes precedence over the legacy
|
||||
// --experimentalToonFormat.
|
||||
return (
|
||||
this.serverArgs.experimentalDataFormat ??
|
||||
(this.serverArgs.experimentalToonFormat ? 'toon' : 'default')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browserless tools never establish a browser connection, so they also never
|
||||
* wait for one under TOOL_CALL_TIMEOUT_MS.
|
||||
*/
|
||||
async #runBrowserlessTool(
|
||||
tool: BrowserlessToolDefinition,
|
||||
params: Record<string, unknown>,
|
||||
response: McpResponse,
|
||||
context: McpContext,
|
||||
): ReturnType<McpResponse['handle']> {
|
||||
try {
|
||||
await validateToolFiles(tool, params, context, true);
|
||||
await tool.handler({params}, response, context);
|
||||
} catch (err) {
|
||||
response.setError(err);
|
||||
}
|
||||
return await response.handle(context, this.#dataFormat());
|
||||
}
|
||||
|
||||
async #runBrowserTool(
|
||||
tool: ToolDefinition | DefinedPageTool,
|
||||
params: Record<string, unknown>,
|
||||
response: McpResponse,
|
||||
context: McpContext,
|
||||
onTelemetryData: (
|
||||
devToolsData: DevToolsData | undefined,
|
||||
pageUrl: string | undefined,
|
||||
) => void,
|
||||
): ReturnType<McpResponse['handle']> {
|
||||
// ensureBrowser() has no cancellation mechanism, so this timeout only
|
||||
// stops us from waiting — the attempt itself keeps running abandoned.
|
||||
// abandonPendingBrowserAttemptOnTimeout() tells BrowserManager to
|
||||
// discard that attempt if it succeeds later instead of handing it to a
|
||||
// subsequent caller — see BrowserManager#abandonPendingAttempt()'s doc
|
||||
// comment for the full mechanism.
|
||||
const browser = await this.#raceWithTimeout(context.ensureBrowser(), () =>
|
||||
this.abandonPendingBrowserAttemptOnTimeout(),
|
||||
);
|
||||
logger?.(`${tool.name} browser: resolved`);
|
||||
if (context.consumeReconnectNotice()) {
|
||||
response.setReconnectNotice();
|
||||
}
|
||||
let page: McpPage | undefined;
|
||||
try {
|
||||
await validateToolFiles(tool, params, context, isLocalBrowser(browser));
|
||||
if (isPageScopedTool(tool)) {
|
||||
const pageId =
|
||||
typeof params.pageId === 'number' ? params.pageId : undefined;
|
||||
page =
|
||||
this.serverArgs.pageIdRouting &&
|
||||
pageId !== undefined &&
|
||||
!isSlimTool(tool)
|
||||
? context.getPageById(pageId)
|
||||
: context.getSelectedMcpPage();
|
||||
await page?.init();
|
||||
response.setPage(page);
|
||||
if (tool.blockedByDialog) {
|
||||
page.throwIfDialogOpen();
|
||||
}
|
||||
await tool.handler(
|
||||
{
|
||||
params,
|
||||
page,
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
} else {
|
||||
await tool.handler(
|
||||
{
|
||||
params,
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
response.setError(err);
|
||||
}
|
||||
onTelemetryData(
|
||||
await context.getDevToolsData(page),
|
||||
context.getSelectedMcpPageUrl(page),
|
||||
);
|
||||
return await response.handle(context, this.#dataFormat());
|
||||
}
|
||||
|
||||
handle = async (params: Record<string, unknown>): Promise<CallToolResult> => {
|
||||
using guard = await this.toolMutex.acquire();
|
||||
|
||||
@@ -257,72 +357,23 @@ export class ToolHandler {
|
||||
logger?.(
|
||||
`${this.tool.name} request: ${JSON.stringify(params, null, ' ')}`,
|
||||
);
|
||||
// ensureBrowser() has no cancellation mechanism, so this timeout only
|
||||
// stops us from waiting — the attempt itself keeps running abandoned.
|
||||
// abandonPendingBrowserAttemptOnTimeout() tells BrowserManager to
|
||||
// discard that attempt if it succeeds later instead of handing it to a
|
||||
// subsequent caller — see BrowserManager#abandonPendingAttempt()'s doc
|
||||
// comment for the full mechanism.
|
||||
const context = await this.#raceWithTimeout(this.getContext(), () =>
|
||||
this.abandonPendingBrowserAttemptOnTimeout(),
|
||||
);
|
||||
logger?.(`${this.tool.name} context: resolved`);
|
||||
const context = await this.getContext();
|
||||
const response = this.serverArgs.slim
|
||||
? new SlimMcpResponse(this.serverArgs)
|
||||
: new McpResponse(this.serverArgs);
|
||||
|
||||
response.setRedactNetworkHeaders(this.serverArgs.redactNetworkHeaders);
|
||||
if (context.consumeReconnectNotice()) {
|
||||
response.setReconnectNotice();
|
||||
}
|
||||
let page: McpPage | undefined;
|
||||
try {
|
||||
await validateToolFiles(this.tool, params, context);
|
||||
if (isPageScopedTool(this.tool)) {
|
||||
const pageId =
|
||||
typeof params.pageId === 'number' ? params.pageId : undefined;
|
||||
page =
|
||||
this.serverArgs.pageIdRouting &&
|
||||
pageId !== undefined &&
|
||||
!isSlimTool(this.tool)
|
||||
? context.getPageById(pageId)
|
||||
: context.getSelectedMcpPage();
|
||||
await page?.init();
|
||||
response.setPage(page);
|
||||
if (this.tool.blockedByDialog) {
|
||||
page.throwIfDialogOpen();
|
||||
}
|
||||
await this.tool.handler(
|
||||
{
|
||||
params,
|
||||
page,
|
||||
},
|
||||
const {content, structuredContent} = isBrowserlessTool(this.tool)
|
||||
? await this.#runBrowserlessTool(this.tool, params, response, context)
|
||||
: await this.#runBrowserTool(
|
||||
this.tool,
|
||||
params,
|
||||
response,
|
||||
context,
|
||||
);
|
||||
} else {
|
||||
await this.tool.handler(
|
||||
{
|
||||
params,
|
||||
(data, url) => {
|
||||
devToolsData = data;
|
||||
pageUrl = url;
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
response.setError(err);
|
||||
}
|
||||
devToolsData = await context.getDevToolsData(page);
|
||||
pageUrl = context.getSelectedMcpPageUrl(page);
|
||||
// --experimentalDataFormat takes precedence over the legacy
|
||||
// --experimentalToonFormat.
|
||||
const dataFormat =
|
||||
this.serverArgs.experimentalDataFormat ??
|
||||
(this.serverArgs.experimentalToonFormat ? 'toon' : 'default');
|
||||
const {content, structuredContent} = await response.handle(
|
||||
context,
|
||||
dataFormat,
|
||||
);
|
||||
const result: CallToolResult & {
|
||||
structuredContent?: Record<string, unknown>;
|
||||
} = {
|
||||
|
||||
+44
-52
@@ -25,10 +25,7 @@ import {
|
||||
puppeteer,
|
||||
} from './third_party/index.js';
|
||||
import {ToolHandler} from './ToolHandler.js';
|
||||
import {
|
||||
type DefinedPageTool,
|
||||
type ToolDefinition,
|
||||
} from './tools/ToolDefinition.js';
|
||||
import type {AnyToolDefinition} from './tools/ToolDefinition.js';
|
||||
import {createTools} from './tools/tools.js';
|
||||
import {logger} from './utils/logger.js';
|
||||
import {VERSION} from './version.js';
|
||||
@@ -59,7 +56,7 @@ export class McpServer {
|
||||
readonly server: SdkMcpServer;
|
||||
#serverArgs: ParsedArguments;
|
||||
#browserManager: BrowserManager;
|
||||
#context?: McpContext;
|
||||
#context: McpContext;
|
||||
#heapSnapshotManager = new HeapSnapshotManager();
|
||||
|
||||
/**
|
||||
@@ -68,6 +65,11 @@ export class McpServer {
|
||||
* roots are read from `#serverArgs` when combining roots.
|
||||
*/
|
||||
#lastClientRoots?: Root[];
|
||||
/**
|
||||
* Whether a tool call already waited for the initial `roots/list`, so a
|
||||
* client that negotiates roots but never answers only delays the first call.
|
||||
*/
|
||||
#awaitedInitialRoots = false;
|
||||
#toolMutex = new Mutex();
|
||||
#tools = new Map<string, ToolEntry>();
|
||||
|
||||
@@ -100,6 +102,31 @@ export class McpServer {
|
||||
},
|
||||
);
|
||||
|
||||
this.#context = McpContext.create(logger, {
|
||||
experimentalDevToolsDebugging:
|
||||
this.#serverArgs.experimentalDevtools ?? false,
|
||||
experimentalIncludeAllPages: this.#serverArgs.experimentalIncludeAllPages,
|
||||
performanceCrux: this.#serverArgs.performanceCrux,
|
||||
sourceMaps: this.#serverArgs.sourceMaps,
|
||||
allowlist: this.#serverArgs.allowedUrlPattern,
|
||||
blocklist: this.#serverArgs.blockedUrlPattern,
|
||||
allowUnrestrictedPaths: this.#serverArgs.allowUnrestrictedPaths,
|
||||
categoryExtensions: this.#serverArgs.categoryExtensions,
|
||||
heapSnapshotManager: this.#heapSnapshotManager,
|
||||
getBrowser: () => this.#browserManager.ensureBrowser(),
|
||||
onNotification: (message: string) => {
|
||||
void this.server
|
||||
.sendLoggingMessage({
|
||||
level: 'info',
|
||||
data: message,
|
||||
})
|
||||
.catch(e => {
|
||||
logger?.('Failed to send MCP notification', e);
|
||||
});
|
||||
},
|
||||
});
|
||||
this.#context.setRoots(this.#combinedRoots());
|
||||
|
||||
this.server.server.setRequestHandler('logging/setLevel', () => {
|
||||
return {};
|
||||
});
|
||||
@@ -177,11 +204,9 @@ export class McpServer {
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
this.#context?.dispose();
|
||||
this.#context.dispose();
|
||||
} catch (err) {
|
||||
logger?.('Failed to dispose context', err);
|
||||
} finally {
|
||||
this.#context = undefined;
|
||||
}
|
||||
this.#heapSnapshotManager.dispose();
|
||||
await Promise.allSettled([
|
||||
@@ -236,8 +261,9 @@ export class McpServer {
|
||||
|
||||
/**
|
||||
* `timeout` is only passed where a tool call is waiting on the result – the
|
||||
* background refreshes below block nobody, so bounding them would just discard
|
||||
* roots a slow client was about to send
|
||||
* background refreshes (on initialization and `roots/list_changed`) block
|
||||
* nobody, so bounding them would just discard roots a slow client was about
|
||||
* to send
|
||||
*/
|
||||
async #updateRoots(timeout?: number): Promise<void> {
|
||||
if (!this.server.server.getClientCapabilities()?.roots) {
|
||||
@@ -249,57 +275,23 @@ export class McpServer {
|
||||
timeout === undefined ? undefined : {timeout},
|
||||
);
|
||||
this.#lastClientRoots = result.roots;
|
||||
this.#context?.setRoots(this.#combinedRoots());
|
||||
this.#context.setRoots(this.#combinedRoots());
|
||||
} catch (e) {
|
||||
logger?.('Failed to list roots', e);
|
||||
}
|
||||
}
|
||||
|
||||
async #getContext(): Promise<McpContext> {
|
||||
const browser = await this.#browserManager.ensureBrowser();
|
||||
|
||||
if (this.#context?.browser !== browser) {
|
||||
this.#context?.dispose();
|
||||
this.#context = await McpContext.from(browser, logger, {
|
||||
experimentalDevToolsDebugging:
|
||||
this.#serverArgs.experimentalDevtools ?? false,
|
||||
experimentalIncludeAllPages:
|
||||
this.#serverArgs.experimentalIncludeAllPages,
|
||||
performanceCrux: this.#serverArgs.performanceCrux,
|
||||
sourceMaps: this.#serverArgs.sourceMaps,
|
||||
allowlist: this.#serverArgs.allowedUrlPattern,
|
||||
blocklist: this.#serverArgs.blockedUrlPattern,
|
||||
allowUnrestrictedPaths: this.#serverArgs.allowUnrestrictedPaths,
|
||||
// Surfaces a one-time note in the next response after a reconnect.
|
||||
reconnected: this.#context !== undefined,
|
||||
categoryExtensions: this.#serverArgs.categoryExtensions,
|
||||
heapSnapshotManager: this.#heapSnapshotManager,
|
||||
onNotification: (message: string) => {
|
||||
void this.server
|
||||
.sendLoggingMessage({
|
||||
level: 'info',
|
||||
data: message,
|
||||
})
|
||||
.catch(e => {
|
||||
logger?.('Failed to send MCP notification', e);
|
||||
});
|
||||
},
|
||||
});
|
||||
this.#context.setRoots(this.#combinedRoots());
|
||||
if (this.#lastClientRoots === undefined) {
|
||||
// Nothing listed yet, so this call has to wait – bounded, since it is
|
||||
// holding the tool mutex, and a later background refresh still lands
|
||||
await this.#updateRoots(ROOTS_REQUEST_TIMEOUT);
|
||||
} else {
|
||||
// Carry the known roots over and refresh out of band, so a reconnect
|
||||
// never pays for a client round-trip
|
||||
void this.#updateRoots();
|
||||
}
|
||||
if (this.#lastClientRoots === undefined && !this.#awaitedInitialRoots) {
|
||||
this.#awaitedInitialRoots = true;
|
||||
// Nothing listed yet, so this call has to wait – bounded, since it is
|
||||
// holding the tool mutex, and a later background refresh still lands
|
||||
await this.#updateRoots(ROOTS_REQUEST_TIMEOUT);
|
||||
}
|
||||
return this.#context;
|
||||
}
|
||||
|
||||
#createToolHandler(tool: ToolDefinition | DefinedPageTool): ToolHandler {
|
||||
#createToolHandler(tool: AnyToolDefinition): ToolHandler {
|
||||
return new ToolHandler(
|
||||
tool,
|
||||
this.#serverArgs,
|
||||
@@ -309,7 +301,7 @@ export class McpServer {
|
||||
);
|
||||
}
|
||||
|
||||
#registerTool(tool: ToolDefinition | DefinedPageTool): void {
|
||||
#registerTool(tool: AnyToolDefinition): void {
|
||||
const handler = this.#createToolHandler(tool);
|
||||
|
||||
const registeredTool = this.server.registerTool(
|
||||
|
||||
@@ -343,6 +343,14 @@ export type Context = Readonly<{
|
||||
getSelectedMcpPage(): McpPage;
|
||||
getWorkers(): McpWorker[];
|
||||
getWorkerById(id: string): McpWorker | undefined;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The context available to tools that do not need a browser connection, see
|
||||
* {@link defineBrowserlessTool}. Using it never triggers a browser launch or
|
||||
* connection.
|
||||
*/
|
||||
export type BrowserlessContext = Readonly<{
|
||||
getHeapSnapshotAggregates(
|
||||
filePath: string,
|
||||
filterName?: string,
|
||||
@@ -522,6 +530,49 @@ export function definePageTool<Schema extends zod.ZodRawShape>(
|
||||
};
|
||||
}
|
||||
|
||||
export interface BrowserlessToolDefinition<
|
||||
Schema extends zod.ZodRawShape = zod.ZodRawShape,
|
||||
> extends BaseToolDefinition<Schema> {
|
||||
browserless: true;
|
||||
handler(
|
||||
request: Request<Schema>,
|
||||
response: Response,
|
||||
context: BrowserlessContext,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a tool that does not need a browser connection, e.g. a tool that only
|
||||
* analyzes local files. Its handler only receives a {@link BrowserlessContext}.
|
||||
*/
|
||||
export function defineBrowserlessTool<Schema extends zod.ZodRawShape>(
|
||||
definition: (
|
||||
args: ParsedArguments,
|
||||
) => Omit<BrowserlessToolDefinition<Schema>, 'browserless'>,
|
||||
): (args: ParsedArguments) => BrowserlessToolDefinition<Schema> {
|
||||
return (args: ParsedArguments): BrowserlessToolDefinition<Schema> => {
|
||||
return {
|
||||
...definition(args),
|
||||
browserless: true,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type AnyToolDefinition =
|
||||
ToolDefinition | DefinedPageTool | BrowserlessToolDefinition;
|
||||
|
||||
export function isPageScopedTool(
|
||||
tool: AnyToolDefinition,
|
||||
): tool is DefinedPageTool {
|
||||
return 'pageScoped' in tool && tool.pageScoped === true;
|
||||
}
|
||||
|
||||
export function isBrowserlessTool(
|
||||
tool: AnyToolDefinition,
|
||||
): tool is BrowserlessToolDefinition {
|
||||
return 'browserless' in tool && tool.browserless === true;
|
||||
}
|
||||
|
||||
export const CLOSE_PAGE_ERROR =
|
||||
'The last open page cannot be closed. It is fine to keep it open.';
|
||||
|
||||
|
||||
+14
-14
@@ -9,7 +9,7 @@ import {zod} from '../third_party/index.js';
|
||||
import {byteSizeRangeSchema} from '../utils/bytes.js';
|
||||
|
||||
import {ToolCategory} from './categories.js';
|
||||
import {definePageTool, defineTool} from './ToolDefinition.js';
|
||||
import {defineBrowserlessTool, definePageTool} from './ToolDefinition.js';
|
||||
|
||||
const HEAP_SNAPSHOT_FILTERS: readonly [string, ...string[]] = [
|
||||
'objectsRetainedByDetachedDomNodes',
|
||||
@@ -75,7 +75,7 @@ export const takeHeapSnapshot = definePageTool(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const getHeapSnapshotSummary = defineTool(() => ({
|
||||
export const getHeapSnapshotSummary = defineBrowserlessTool(() => ({
|
||||
name: 'get_heapsnapshot_summary',
|
||||
description:
|
||||
'Loads and caches a memory heapsnapshot and returns snapshot summary stats, including native contexts and their sizes, and retained by context summary.',
|
||||
@@ -117,7 +117,7 @@ export const getHeapSnapshotSummary = defineTool(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const getHeapSnapshotDetails = defineTool(() => ({
|
||||
export const getHeapSnapshotDetails = defineBrowserlessTool(() => ({
|
||||
name: 'get_heapsnapshot_details',
|
||||
description:
|
||||
'Loads and caches a memory heapsnapshot and returns all available information including statistics, static data, and aggregated node information. Supports pagination for aggregates.',
|
||||
@@ -171,7 +171,7 @@ export const getHeapSnapshotDetails = defineTool(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const getHeapSnapshotClassNodes = defineTool(() => ({
|
||||
export const getHeapSnapshotClassNodes = defineBrowserlessTool(() => ({
|
||||
name: 'get_heapsnapshot_class_nodes',
|
||||
description:
|
||||
'Loads and caches a memory heapsnapshot and returns instances of a specific class with their IDs.',
|
||||
@@ -217,7 +217,7 @@ export const getHeapSnapshotClassNodes = defineTool(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const getHeapSnapshotRetainers = defineTool(() => ({
|
||||
export const getHeapSnapshotRetainers = defineBrowserlessTool(() => ({
|
||||
name: 'get_heapsnapshot_retainers',
|
||||
description:
|
||||
'Loads and caches a memory heapsnapshot and returns retainers for a specific node ID.',
|
||||
@@ -251,7 +251,7 @@ export const getHeapSnapshotRetainers = defineTool(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const closeHeapSnapshot = defineTool(() => ({
|
||||
export const closeHeapSnapshot = defineBrowserlessTool(() => ({
|
||||
name: 'close_heapsnapshot',
|
||||
description:
|
||||
'Closes a previously loaded and cached memory heapsnapshot, freeing its memory.',
|
||||
@@ -282,7 +282,7 @@ export const closeHeapSnapshot = defineTool(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const getHeapSnapshotRetainingPaths = defineTool(() => ({
|
||||
export const getHeapSnapshotRetainingPaths = defineBrowserlessTool(() => ({
|
||||
name: 'get_heapsnapshot_retaining_paths',
|
||||
description:
|
||||
'Loads and caches a memory heapsnapshot and returns retaining paths for a specific node ID. This helps to understand why a node is not being garbage collected.',
|
||||
@@ -327,7 +327,7 @@ export const getHeapSnapshotRetainingPaths = defineTool(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const getHeapSnapshotEdges = defineTool(() => ({
|
||||
export const getHeapSnapshotEdges = defineBrowserlessTool(() => ({
|
||||
name: 'get_heapsnapshot_edges',
|
||||
description:
|
||||
'Loads and caches a memory heapsnapshot and returns outgoing edges (references) for a specific node ID.',
|
||||
@@ -378,7 +378,7 @@ export const getHeapSnapshotEdges = defineTool(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const getHeapSnapshotDominators = defineTool(() => ({
|
||||
export const getHeapSnapshotDominators = defineBrowserlessTool(() => ({
|
||||
name: 'get_heapsnapshot_dominators',
|
||||
description:
|
||||
'Loads and caches a memory heapsnapshot and returns the dominator chain for a specific node ID. This helps to identify which objects are keeping the target node alive.',
|
||||
@@ -410,7 +410,7 @@ export const getHeapSnapshotDominators = defineTool(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const compareHeapSnapshots = defineTool(() => ({
|
||||
export const compareHeapSnapshots = defineBrowserlessTool(() => ({
|
||||
name: 'compare_heapsnapshots',
|
||||
description:
|
||||
'Loads and caches two memory heapsnapshots and returns the comparison. If classIndex is provided, returns detailed diff for that class, otherwise returns summary diff.',
|
||||
@@ -465,7 +465,7 @@ export const compareHeapSnapshots = defineTool(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const getHeapSnapshotDuplicateStrings = defineTool(() => ({
|
||||
export const getHeapSnapshotDuplicateStrings = defineBrowserlessTool(() => ({
|
||||
name: 'get_heapsnapshot_duplicate_strings',
|
||||
description:
|
||||
'Loads and caches a memory heapsnapshot and returns duplicate strings grouped by their value.',
|
||||
@@ -497,7 +497,7 @@ export const getHeapSnapshotDuplicateStrings = defineTool(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const getHeapSnapshotObjectDetails = defineTool(() => ({
|
||||
export const getHeapSnapshotObjectDetails = defineBrowserlessTool(() => ({
|
||||
name: 'get_heapsnapshot_object_details',
|
||||
description:
|
||||
'Loads and caches a memory heapsnapshot and returns detailed information about a specific object by its node ID, including size, type, distance, and DOM detachedness.',
|
||||
@@ -527,7 +527,7 @@ export const getHeapSnapshotObjectDetails = defineTool(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const queryHeapSnapshotObjects = defineTool(() => ({
|
||||
export const queryHeapSnapshotObjects = defineBrowserlessTool(() => ({
|
||||
name: 'query_heapsnapshot_objects',
|
||||
description:
|
||||
'Loads and caches a memory heapsnapshot and queries objects matching specific filters (className, propertyName, nodeType, retainedSize, selfSize, isDetached, sortBy).',
|
||||
@@ -596,7 +596,7 @@ export const queryHeapSnapshotObjects = defineTool(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const analyzeHeapSnapshotContexts = defineTool(() => ({
|
||||
export const analyzeHeapSnapshotContexts = defineBrowserlessTool(() => ({
|
||||
name: 'analyze_heapsnapshot_contexts',
|
||||
description:
|
||||
'Loads and caches a memory heapsnapshot to identify and rank closure contexts holding dead captured fields—variables no remaining live closure can read. Scopes are ranked globally by the retained size of these dead values to provide a prioritizing heuristic, rather than an exact measure of reclaimable bytes.',
|
||||
|
||||
+4
-8
@@ -26,12 +26,10 @@ import * as scriptTools from './script.js';
|
||||
import * as slimTools from './slim/tools.js';
|
||||
import * as snapshotTools from './snapshot.js';
|
||||
import * as thirdPartyDeveloperTools from './thirdPartyDeveloper.js';
|
||||
import type {DefinedPageTool, ToolDefinition} from './ToolDefinition.js';
|
||||
import type {AnyToolDefinition} from './ToolDefinition.js';
|
||||
import * as webmcpTools from './webmcp.js';
|
||||
|
||||
export const createTools = (
|
||||
args: ParsedArguments,
|
||||
): Array<ToolDefinition | DefinedPageTool> => {
|
||||
export const createTools = (args: ParsedArguments): AnyToolDefinition[] => {
|
||||
const rawTools = [
|
||||
...Object.values(commentsTools),
|
||||
...Object.values(consoleTools),
|
||||
@@ -54,7 +52,7 @@ export const createTools = (
|
||||
...Object.values(webmcpTools),
|
||||
];
|
||||
|
||||
const tools: Array<ToolDefinition | DefinedPageTool> = [];
|
||||
const tools: AnyToolDefinition[] = [];
|
||||
for (const tool of rawTools) {
|
||||
tools.push(tool(args));
|
||||
}
|
||||
@@ -68,9 +66,7 @@ export const createTools = (
|
||||
* Returns true if the tool is gated behind a hidden (internal) flag. Such
|
||||
* tools are excluded from the generated documentation and CLI.
|
||||
*/
|
||||
export function requiresHiddenFlag(
|
||||
tool: ToolDefinition | DefinedPageTool,
|
||||
): boolean {
|
||||
export function requiresHiddenFlag(tool: AnyToolDefinition): boolean {
|
||||
for (const condition of tool.annotations.conditions ?? []) {
|
||||
const option: YargsOptions = mcpOptions[condition];
|
||||
if (option.hidden) {
|
||||
|
||||
+173
-24
@@ -36,6 +36,7 @@ import {
|
||||
import {serverHooks, TestServer} from './server.js';
|
||||
import {
|
||||
assertNoServiceWorkerReported,
|
||||
createConnectedMcpContext,
|
||||
createTempDir,
|
||||
createTempFile,
|
||||
getMockBrowser,
|
||||
@@ -76,9 +77,8 @@ describe('McpContext', () => {
|
||||
const {target: defaultTarget} = createPageTarget();
|
||||
browser.targets.returns(options?.targets ?? [defaultTarget]);
|
||||
browser.browserContexts.returns([]);
|
||||
return await McpContext.from(
|
||||
return await createConnectedMcpContext(
|
||||
browser,
|
||||
undefined,
|
||||
{
|
||||
experimentalDevToolsDebugging: false,
|
||||
performanceCrux: true,
|
||||
@@ -524,19 +524,14 @@ describe('McpContext', () => {
|
||||
performanceCrux: false,
|
||||
heapSnapshotManager: new HeapSnapshotManager(),
|
||||
};
|
||||
const first = await McpContext.from(browser, undefined, options, Locator);
|
||||
const first = await createConnectedMcpContext(browser, options, Locator);
|
||||
const idBeforeReconnect = (await first.newPage()).id;
|
||||
first.dispose();
|
||||
|
||||
// A new context (as created after a browser reconnect) continues the
|
||||
// shared id counter, so an id handed out before no longer resolves and
|
||||
// the next page keeps counting up rather than colliding with it.
|
||||
const second = await McpContext.from(
|
||||
browser,
|
||||
undefined,
|
||||
options,
|
||||
Locator,
|
||||
);
|
||||
const second = await createConnectedMcpContext(browser, options, Locator);
|
||||
try {
|
||||
assert.throws(
|
||||
() => second.getPageById(idBeforeReconnect),
|
||||
@@ -565,27 +560,182 @@ describe('McpContext', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the reconnect notice once', async () => {
|
||||
await withBrowser(async browser => {
|
||||
const heapSnapshotManager = new HeapSnapshotManager();
|
||||
const context = await McpContext.from(
|
||||
browser,
|
||||
describe('lazy browser connection', () => {
|
||||
function createLazyContext(getBrowser: () => Promise<Browser>) {
|
||||
return McpContext.create(
|
||||
undefined,
|
||||
{
|
||||
experimentalDevToolsDebugging: false,
|
||||
performanceCrux: false,
|
||||
reconnected: true,
|
||||
heapSnapshotManager,
|
||||
heapSnapshotManager: new HeapSnapshotManager(),
|
||||
getBrowser,
|
||||
},
|
||||
Locator,
|
||||
);
|
||||
try {
|
||||
assert.ok(context.consumeReconnectNotice(), 'notice available once');
|
||||
assert.ok(!context.consumeReconnectNotice(), 'notice does not repeat');
|
||||
} finally {
|
||||
context.dispose();
|
||||
heapSnapshotManager.dispose();
|
||||
}
|
||||
|
||||
function createBrowser() {
|
||||
const browser = createMockPuppeteerBrowser();
|
||||
browser.targets.returns([]);
|
||||
return browser;
|
||||
}
|
||||
|
||||
it('does not connect until ensureBrowser() is called', async () => {
|
||||
const browser = createBrowser();
|
||||
const getBrowser = sinon.stub<[], Promise<Browser>>().resolves(browser);
|
||||
const context = createLazyContext(getBrowser);
|
||||
|
||||
sinon.assert.notCalled(getBrowser);
|
||||
assert.throws(() => context.browser, /Not connected to a browser/);
|
||||
|
||||
assert.strictEqual(await context.ensureBrowser(), browser);
|
||||
assert.strictEqual(context.browser, browser);
|
||||
sinon.assert.calledOnce(getBrowser);
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
it('resets the browser state and reports a reconnect notice once when the browser changes', async () => {
|
||||
const firstBrowser = createBrowser();
|
||||
const secondBrowser = createBrowser();
|
||||
const getBrowser = sinon.stub<[], Promise<Browser>>();
|
||||
getBrowser.onFirstCall().resolves(firstBrowser);
|
||||
getBrowser.onSecondCall().resolves(secondBrowser);
|
||||
const context = createLazyContext(getBrowser);
|
||||
|
||||
await context.ensureBrowser();
|
||||
assert.ok(!context.consumeReconnectNotice());
|
||||
context.setIsRunningPerformanceTrace(true);
|
||||
|
||||
await context.ensureBrowser();
|
||||
|
||||
assert.strictEqual(context.browser, secondBrowser);
|
||||
assert.ok(!context.isRunningPerformanceTrace());
|
||||
sinon.assert.calledWith(
|
||||
firstBrowser.off,
|
||||
'targetcreated',
|
||||
sinon.match.func,
|
||||
);
|
||||
assert.ok(context.consumeReconnectNotice(), 'notice available once');
|
||||
assert.ok(!context.consumeReconnectNotice(), 'notice does not repeat');
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
it('starts over if initializing the browser state fails', async () => {
|
||||
const browser = createBrowser();
|
||||
browser.targets.onFirstCall().throws(new Error('init failed'));
|
||||
const context = createLazyContext(async () => browser);
|
||||
|
||||
await assert.rejects(context.ensureBrowser(), /init failed/);
|
||||
assert.throws(() => context.browser, /Not connected to a browser/);
|
||||
|
||||
await context.ensureBrowser();
|
||||
assert.strictEqual(context.browser, browser);
|
||||
assert.ok(!context.consumeReconnectNotice());
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
describe('when the browser is replaced during a hanging initialization', () => {
|
||||
function setUpHangingInit() {
|
||||
const firstBrowser = createBrowser();
|
||||
const secondBrowser = createBrowser();
|
||||
const getBrowser = sinon.stub<[], Promise<Browser>>();
|
||||
getBrowser.onFirstCall().resolves(firstBrowser);
|
||||
getBrowser.onSecondCall().resolves(secondBrowser);
|
||||
const context = createLazyContext(getBrowser);
|
||||
const hangingSnapshot = Promise.withResolvers<McpPage[]>();
|
||||
sinon
|
||||
.stub(context, 'createPagesSnapshot')
|
||||
.callThrough()
|
||||
.onFirstCall()
|
||||
.returns(hangingSnapshot.promise);
|
||||
return {context, firstBrowser, secondBrowser, hangingSnapshot};
|
||||
}
|
||||
|
||||
it('does not touch the new browser state once the stale call resumes', async () => {
|
||||
const {context, firstBrowser, secondBrowser, hangingSnapshot} =
|
||||
setUpHangingInit();
|
||||
|
||||
const staleCall = context.ensureBrowser();
|
||||
await context.ensureBrowser();
|
||||
for (const call of firstBrowser.on.getCalls()) {
|
||||
sinon.assert.calledWithExactly(firstBrowser.off, ...call.args);
|
||||
}
|
||||
const firstBrowserOnCalls = firstBrowser.on.callCount;
|
||||
const secondBrowserOnCalls = secondBrowser.on.callCount;
|
||||
|
||||
hangingSnapshot.resolve([]);
|
||||
await assert.rejects(staleCall, /Browser was replaced/);
|
||||
|
||||
assert.strictEqual(context.browser, secondBrowser);
|
||||
sinon.assert.callCount(firstBrowser.on, firstBrowserOnCalls);
|
||||
sinon.assert.callCount(secondBrowser.on, secondBrowserOnCalls);
|
||||
assert.ok(!context.consumeReconnectNotice());
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
it('does not reset the new browser state when the stale call fails', async () => {
|
||||
const {context, secondBrowser, hangingSnapshot} = setUpHangingInit();
|
||||
|
||||
const staleCall = context.ensureBrowser();
|
||||
await context.ensureBrowser();
|
||||
|
||||
hangingSnapshot.reject(new Error('init failed'));
|
||||
await assert.rejects(staleCall, /init failed/);
|
||||
|
||||
assert.strictEqual(context.browser, secondBrowser);
|
||||
sinon.assert.notCalled(secondBrowser.off);
|
||||
context.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the same browser is returned during a hanging initialization', () => {
|
||||
function setUpHangingInit() {
|
||||
const browser = createBrowser();
|
||||
const context = createLazyContext(async () => browser);
|
||||
const hangingSnapshot = Promise.withResolvers<McpPage[]>();
|
||||
const createPagesSnapshot = sinon
|
||||
.stub(context, 'createPagesSnapshot')
|
||||
.callThrough()
|
||||
.onFirstCall()
|
||||
.returns(hangingSnapshot.promise);
|
||||
return {context, browser, hangingSnapshot, createPagesSnapshot};
|
||||
}
|
||||
|
||||
it('waits for the pending initialization', async () => {
|
||||
const {context, browser, hangingSnapshot, createPagesSnapshot} =
|
||||
setUpHangingInit();
|
||||
|
||||
const firstCall = context.ensureBrowser();
|
||||
let joinedCallSettled = false;
|
||||
const joinedCall = context.ensureBrowser().finally(() => {
|
||||
joinedCallSettled = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.ok(!joinedCallSettled);
|
||||
|
||||
hangingSnapshot.resolve([]);
|
||||
assert.strictEqual(await firstCall, browser);
|
||||
assert.strictEqual(await joinedCall, browser);
|
||||
sinon.assert.calledOnce(createPagesSnapshot);
|
||||
sinon.assert.notCalled(browser.off);
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
it('fails the joined call too and starts over on the next call', async () => {
|
||||
const {context, browser, hangingSnapshot, createPagesSnapshot} =
|
||||
setUpHangingInit();
|
||||
|
||||
const firstCall = context.ensureBrowser();
|
||||
const joinedCall = context.ensureBrowser();
|
||||
hangingSnapshot.reject(new Error('init failed'));
|
||||
await assert.rejects(firstCall, /init failed/);
|
||||
await assert.rejects(joinedCall, /init failed/);
|
||||
assert.throws(() => context.browser, /Not connected to a browser/);
|
||||
|
||||
assert.strictEqual(await context.ensureBrowser(), browser);
|
||||
sinon.assert.calledTwice(createPagesSnapshot);
|
||||
context.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -933,9 +1083,8 @@ describe('McpContext', () => {
|
||||
const browser = getMockBrowser();
|
||||
browser.pages = async () => [];
|
||||
browser.targets = () => [];
|
||||
return await McpContext.from(
|
||||
return await createConnectedMcpContext(
|
||||
browser,
|
||||
undefined,
|
||||
{
|
||||
experimentalDevToolsDebugging: false,
|
||||
performanceCrux: false,
|
||||
|
||||
+28
-5
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
import path from 'node:path';
|
||||
import {afterEach, describe, it} from 'node:test';
|
||||
|
||||
import sinon from 'sinon';
|
||||
@@ -23,15 +24,20 @@ describe('McpServer', () => {
|
||||
ClearcutLogger.resetForTesting();
|
||||
});
|
||||
|
||||
async function createTestServer(extraArgs: string[] = []) {
|
||||
async function createTestServer(
|
||||
extraArgs: string[] = [],
|
||||
{mockContext = true}: {mockContext?: boolean} = {},
|
||||
) {
|
||||
const browserManager = sinon.createStubInstance(BrowserManager);
|
||||
const browser = createMockPuppeteerBrowser();
|
||||
browserManager.ensureBrowser.resolves(browser);
|
||||
|
||||
const context = createMockMcpContext();
|
||||
context.browser = browser;
|
||||
context.ensureBrowser.resolves(browser);
|
||||
context.getPages.returns([]);
|
||||
sinon.stub(McpContext, 'from').resolves(context);
|
||||
if (mockContext) {
|
||||
sinon.stub(McpContext, 'create').returns(context);
|
||||
}
|
||||
|
||||
const serverArgs = new ConfigParser(
|
||||
'1.0.0',
|
||||
@@ -77,14 +83,31 @@ describe('McpServer', () => {
|
||||
});
|
||||
|
||||
it('executes the tool handler when arguments are valid', async () => {
|
||||
const {server, browserManager, context} = await createTestServer();
|
||||
const {server, context} = await createTestServer();
|
||||
|
||||
const result = await server.callTool('list_pages', {});
|
||||
|
||||
assert.strictEqual(result.isError, undefined);
|
||||
sinon.assert.calledOnce(browserManager.ensureBrowser);
|
||||
sinon.assert.calledOnce(context.ensureBrowser);
|
||||
sinon.assert.calledOnce(context.createPagesSnapshot);
|
||||
});
|
||||
|
||||
it('does not launch or connect a browser for browserless tools', async () => {
|
||||
const {server, browserManager} = await createTestServer(
|
||||
['--memoryDebugging', '--allowUnrestrictedPaths'],
|
||||
{mockContext: false},
|
||||
);
|
||||
|
||||
const result = await server.callTool('get_heapsnapshot_summary', {
|
||||
filePath: path.resolve('tests/fixtures/heap-1.heapsnapshot'),
|
||||
});
|
||||
|
||||
assert.strictEqual(result.isError, undefined);
|
||||
assert.strictEqual(result.content[0].type, 'text');
|
||||
assert.match(result.content[0].text, /## Heap Snapshot Data/);
|
||||
sinon.assert.notCalled(browserManager.ensureBrowser);
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('slim mode', () => {
|
||||
|
||||
+117
-38
@@ -21,7 +21,9 @@ import {zod, Mutex} from '../src/third_party/index.js';
|
||||
import {TOOL_CALL_TIMEOUT_MS, ToolHandler} from '../src/ToolHandler.js';
|
||||
import {ToolCategory} from '../src/tools/categories.js';
|
||||
import {
|
||||
defineBrowserlessTool,
|
||||
definePageTool,
|
||||
type BrowserlessToolDefinition,
|
||||
type DefinedPageTool,
|
||||
type DevToolsData,
|
||||
type ToolDefinition,
|
||||
@@ -59,7 +61,7 @@ describe('ToolHandler', () => {
|
||||
|
||||
const mockContext = sinon.createStubInstance(McpContext);
|
||||
const mockProcess = sinon.createStubInstance(ChildProcess);
|
||||
mockContext.browser = getMockBrowser({process: mockProcess});
|
||||
mockContext.ensureBrowser.resolves(getMockBrowser({process: mockProcess}));
|
||||
const mockPage = sinon.createStubInstance(McpPage);
|
||||
mockContext.getPageById.returns(mockPage);
|
||||
|
||||
@@ -105,7 +107,7 @@ describe('ToolHandler', () => {
|
||||
|
||||
const mockContext = sinon.createStubInstance(McpContext);
|
||||
const mockProcess = sinon.createStubInstance(ChildProcess);
|
||||
mockContext.browser = getMockBrowser({process: mockProcess});
|
||||
mockContext.ensureBrowser.resolves(getMockBrowser({process: mockProcess}));
|
||||
const mockPage = sinon.createStubInstance(McpPage);
|
||||
mockContext.getSelectedMcpPage.returns(mockPage);
|
||||
|
||||
@@ -146,7 +148,7 @@ describe('ToolHandler', () => {
|
||||
|
||||
const mockContext = sinon.createStubInstance(McpContext);
|
||||
const mockProcess = sinon.createStubInstance(ChildProcess);
|
||||
mockContext.browser = getMockBrowser({process: mockProcess});
|
||||
mockContext.ensureBrowser.resolves(getMockBrowser({process: mockProcess}));
|
||||
|
||||
const toolMutex = new Mutex();
|
||||
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
|
||||
@@ -199,9 +201,11 @@ describe('ToolHandler', () => {
|
||||
handler: async () => undefined,
|
||||
};
|
||||
const mockContext = sinon.createStubInstance(McpContext);
|
||||
mockContext.browser = getMockBrowser({
|
||||
process: sinon.createStubInstance(ChildProcess),
|
||||
});
|
||||
mockContext.ensureBrowser.resolves(
|
||||
getMockBrowser({
|
||||
process: sinon.createStubInstance(ChildProcess),
|
||||
}),
|
||||
);
|
||||
const handleStub = sinon
|
||||
.stub(McpResponse.prototype, 'handle')
|
||||
.resolves({content: [], structuredContent: {}});
|
||||
@@ -274,7 +278,9 @@ describe('ToolHandler', () => {
|
||||
|
||||
const mockContext = sinon.createStubInstance(McpContext);
|
||||
const mockProcess = sinon.createStubInstance(ChildProcess);
|
||||
mockContext.browser = getMockBrowser({process: mockProcess});
|
||||
mockContext.ensureBrowser.resolves(
|
||||
getMockBrowser({process: mockProcess}),
|
||||
);
|
||||
mockContext.getDevToolsData.resolves(testCase.devToolsData);
|
||||
if (testCase.pageUrl) {
|
||||
mockContext.getSelectedMcpPageUrl.returns(testCase.pageUrl);
|
||||
@@ -616,7 +622,7 @@ describe('ToolHandler', () => {
|
||||
|
||||
const mockContext = sinon.createStubInstance(McpContext);
|
||||
const mockProcess = sinon.createStubInstance(ChildProcess);
|
||||
mockContext.browser = getMockBrowser({process: mockProcess});
|
||||
mockContext.ensureBrowser.resolves(getMockBrowser({process: mockProcess}));
|
||||
mockContext.validatePath.callsFake(async p => {
|
||||
if (!p) {
|
||||
return undefined;
|
||||
@@ -699,7 +705,7 @@ describe('ToolHandler', () => {
|
||||
|
||||
const mockContext = sinon.createStubInstance(McpContext);
|
||||
const mockProcess = sinon.createStubInstance(ChildProcess);
|
||||
mockContext.browser = getMockBrowser({process: mockProcess});
|
||||
mockContext.ensureBrowser.resolves(getMockBrowser({process: mockProcess}));
|
||||
mockContext.validatePath.rejects(
|
||||
new Error('Access denied: path is outside roots'),
|
||||
);
|
||||
@@ -736,7 +742,7 @@ describe('ToolHandler', () => {
|
||||
const tool = evaluateScript(serverArgs);
|
||||
const mockContext = sinon.createStubInstance(McpContext);
|
||||
const mockProcess = sinon.createStubInstance(ChildProcess);
|
||||
mockContext.browser = getMockBrowser({process: mockProcess});
|
||||
mockContext.ensureBrowser.resolves(getMockBrowser({process: mockProcess}));
|
||||
mockContext.validatePath.rejects(
|
||||
new Error('Access denied: path is outside roots'),
|
||||
);
|
||||
@@ -789,7 +795,7 @@ describe('ToolHandler', () => {
|
||||
|
||||
const mockContext = sinon.createStubInstance(McpContext);
|
||||
const mockProcess = sinon.createStubInstance(ChildProcess);
|
||||
mockContext.browser = getMockBrowser({process: mockProcess});
|
||||
mockContext.ensureBrowser.resolves(getMockBrowser({process: mockProcess}));
|
||||
const canonicalPath = path.resolve('/canonical/workspace/upload.png');
|
||||
mockContext.validatePath.resolves(canonicalPath);
|
||||
|
||||
@@ -845,9 +851,11 @@ describe('ToolHandler', () => {
|
||||
};
|
||||
|
||||
const mockContext = sinon.createStubInstance(McpContext);
|
||||
mockContext.browser = getMockBrowser({
|
||||
wsEndpoint: 'ws://127.0.0.1:9222/devtools/browser/test',
|
||||
});
|
||||
mockContext.ensureBrowser.resolves(
|
||||
getMockBrowser({
|
||||
wsEndpoint: 'ws://127.0.0.1:9222/devtools/browser/test',
|
||||
}),
|
||||
);
|
||||
const canonicalBundlePath = path.resolve('/canonical/workspace/app.swbn');
|
||||
mockContext.validatePath.resolves(canonicalBundlePath);
|
||||
|
||||
@@ -908,9 +916,11 @@ describe('ToolHandler', () => {
|
||||
};
|
||||
|
||||
const mockContext = sinon.createStubInstance(McpContext);
|
||||
mockContext.browser = getMockBrowser({
|
||||
wsEndpoint: 'ws://remote-host.com:9222/devtools/browser/test',
|
||||
});
|
||||
mockContext.ensureBrowser.resolves(
|
||||
getMockBrowser({
|
||||
wsEndpoint: 'ws://remote-host.com:9222/devtools/browser/test',
|
||||
}),
|
||||
);
|
||||
|
||||
const toolMutex = new Mutex();
|
||||
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
|
||||
@@ -961,7 +971,7 @@ describe('ToolHandler', () => {
|
||||
};
|
||||
|
||||
const mockContext = sinon.createStubInstance(McpContext);
|
||||
mockContext.browser = getMockBrowser();
|
||||
mockContext.ensureBrowser.resolves(getMockBrowser());
|
||||
|
||||
const toolMutex = new Mutex();
|
||||
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
|
||||
@@ -1010,7 +1020,7 @@ describe('ToolHandler', () => {
|
||||
|
||||
const mockContext = sinon.createStubInstance(McpContext);
|
||||
const mockProcess = sinon.createStubInstance(ChildProcess);
|
||||
mockContext.browser = getMockBrowser({process: mockProcess});
|
||||
mockContext.ensureBrowser.resolves(getMockBrowser({process: mockProcess}));
|
||||
|
||||
const toolMutex = new Mutex();
|
||||
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
|
||||
@@ -1063,9 +1073,11 @@ describe('ToolHandler', () => {
|
||||
};
|
||||
|
||||
const mockContext = sinon.createStubInstance(McpContext);
|
||||
mockContext.browser = getMockBrowser({
|
||||
wsEndpoint: 'ws://remote-host.com:9222/devtools/browser/test',
|
||||
});
|
||||
mockContext.ensureBrowser.resolves(
|
||||
getMockBrowser({
|
||||
wsEndpoint: 'ws://remote-host.com:9222/devtools/browser/test',
|
||||
}),
|
||||
);
|
||||
const canonicalOutputPath = path.resolve('/canonical/output.json');
|
||||
mockContext.validatePath.resolves(canonicalOutputPath);
|
||||
|
||||
@@ -1126,7 +1138,7 @@ describe('ToolHandler', () => {
|
||||
|
||||
const mockContext = sinon.createStubInstance(McpContext);
|
||||
const mockProcess = sinon.createStubInstance(ChildProcess);
|
||||
mockContext.browser = getMockBrowser({process: mockProcess});
|
||||
mockContext.ensureBrowser.resolves(getMockBrowser({process: mockProcess}));
|
||||
mockContext.validatePath.rejects(
|
||||
new Error('Path is outside configured roots'),
|
||||
);
|
||||
@@ -1186,9 +1198,11 @@ describe('ToolHandler', () => {
|
||||
|
||||
// Remote browser: should validate
|
||||
const mockRemoteContext = sinon.createStubInstance(McpContext);
|
||||
mockRemoteContext.browser = getMockBrowser({
|
||||
wsEndpoint: 'ws://remote-host.com:9222/devtools/browser/test',
|
||||
});
|
||||
mockRemoteContext.ensureBrowser.resolves(
|
||||
getMockBrowser({
|
||||
wsEndpoint: 'ws://remote-host.com:9222/devtools/browser/test',
|
||||
}),
|
||||
);
|
||||
mockRemoteContext.validatePath.resolves();
|
||||
|
||||
const remoteToolHandler = new ToolHandler(
|
||||
@@ -1209,7 +1223,9 @@ describe('ToolHandler', () => {
|
||||
// Local browser: should skip
|
||||
const mockLocalContext = sinon.createStubInstance(McpContext);
|
||||
const mockProcess = sinon.createStubInstance(ChildProcess);
|
||||
mockLocalContext.browser = getMockBrowser({process: mockProcess});
|
||||
mockLocalContext.ensureBrowser.resolves(
|
||||
getMockBrowser({process: mockProcess}),
|
||||
);
|
||||
|
||||
const localToolHandler = new ToolHandler(
|
||||
tool,
|
||||
@@ -1249,7 +1265,7 @@ describe('ToolHandler', () => {
|
||||
|
||||
const mockContext = sinon.createStubInstance(McpContext);
|
||||
const mockProcess = sinon.createStubInstance(ChildProcess);
|
||||
mockContext.browser = getMockBrowser({process: mockProcess});
|
||||
mockContext.ensureBrowser.resolves(getMockBrowser({process: mockProcess}));
|
||||
mockContext.getDevToolsData.resolves({});
|
||||
const mockPage = sinon.createStubInstance(McpPage);
|
||||
mockPage.getDialog.returns(undefined);
|
||||
@@ -1315,7 +1331,7 @@ describe('ToolHandler', () => {
|
||||
};
|
||||
|
||||
const mockContext = createMockMcpContext();
|
||||
mockContext.browser = getMockBrowser();
|
||||
mockContext.ensureBrowser.resolves(getMockBrowser());
|
||||
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
|
||||
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
|
||||
}).parse();
|
||||
@@ -1364,7 +1380,7 @@ describe('ToolHandler', () => {
|
||||
|
||||
const mockContext = sinon.createStubInstance(McpContext);
|
||||
const mockProcess = sinon.createStubInstance(ChildProcess);
|
||||
mockContext.browser = getMockBrowser({process: mockProcess});
|
||||
mockContext.ensureBrowser.resolves(getMockBrowser({process: mockProcess}));
|
||||
|
||||
const toolMutex = new Mutex();
|
||||
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
|
||||
@@ -1395,10 +1411,10 @@ describe('ToolHandler', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('times out when getContext() hangs, and abandons the pending connect', async () => {
|
||||
it('times out when ensureBrowser() hangs, and abandons the pending connect', async () => {
|
||||
const tool: ToolDefinition = {
|
||||
name: 'hanging_context_tool',
|
||||
description: 'A tool whose getContext() call never resolves',
|
||||
description: 'A tool whose browser connection never resolves',
|
||||
annotations: {
|
||||
category: ToolCategory.NAVIGATION,
|
||||
readOnlyHint: true,
|
||||
@@ -1407,10 +1423,17 @@ describe('ToolHandler', () => {
|
||||
blockedByDialog: false,
|
||||
verifyFilesSchema: {},
|
||||
handler: async () => {
|
||||
// Never reached: the timeout fires while still awaiting getContext().
|
||||
// Never reached: the timeout fires while still awaiting ensureBrowser().
|
||||
},
|
||||
};
|
||||
|
||||
const mockContext = createMockMcpContext();
|
||||
mockContext.ensureBrowser.returns(
|
||||
new Promise(() => {
|
||||
// Simulates BrowserManager#ensureBrowser() hanging on a half-open
|
||||
// socket: it never resolves or rejects on its own.
|
||||
}),
|
||||
);
|
||||
const abandonPendingBrowserAttemptSpy = sinon.spy();
|
||||
|
||||
const toolMutex = new Mutex();
|
||||
@@ -1421,11 +1444,7 @@ describe('ToolHandler', () => {
|
||||
const toolHandler = new ToolHandler(
|
||||
tool,
|
||||
serverArgs,
|
||||
() =>
|
||||
new Promise(() => {
|
||||
// Simulates BrowserManager#ensureBrowser() hanging on a half-open
|
||||
// socket: it never resolves or rejects on its own.
|
||||
}),
|
||||
async () => mockContext,
|
||||
toolMutex,
|
||||
abandonPendingBrowserAttemptSpy,
|
||||
);
|
||||
@@ -1446,4 +1465,64 @@ describe('ToolHandler', () => {
|
||||
clock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
describe('browserless tools', () => {
|
||||
function createBrowserlessTool(
|
||||
handler: BrowserlessToolDefinition['handler'],
|
||||
) {
|
||||
return defineBrowserlessTool(() => ({
|
||||
name: 'browserless_tool',
|
||||
description: 'A tool that does not need a browser',
|
||||
annotations: {
|
||||
category: ToolCategory.MEMORY,
|
||||
readOnlyHint: true,
|
||||
},
|
||||
schema: {
|
||||
filePath: zod.string(),
|
||||
},
|
||||
blockedByDialog: false,
|
||||
verifyFilesSchema: {filePath: true},
|
||||
handler,
|
||||
}))(createServerArgs());
|
||||
}
|
||||
|
||||
function createServerArgs() {
|
||||
return new ConfigParser('1.0.0', ['node', 'script.js'], {
|
||||
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
|
||||
}).parse();
|
||||
}
|
||||
|
||||
it('runs without establishing a browser connection', async () => {
|
||||
const handler = sinon.stub().resolves();
|
||||
const mockContext = createMockMcpContext();
|
||||
mockContext.validatePath.resolves('/canonical/file.heapsnapshot');
|
||||
const handleStub = sinon
|
||||
.stub(McpResponse.prototype, 'handle')
|
||||
.resolves({content: [], structuredContent: {}});
|
||||
|
||||
const result = await new ToolHandler(
|
||||
createBrowserlessTool(handler),
|
||||
createServerArgs(),
|
||||
async () => mockContext,
|
||||
new Mutex(),
|
||||
sinon.spy(),
|
||||
).handle({filePath: 'file.heapsnapshot'});
|
||||
|
||||
assert.strictEqual(result.isError, undefined);
|
||||
sinon.assert.notCalled(mockContext.ensureBrowser);
|
||||
sinon.assert.notCalled(mockContext.consumeReconnectNotice);
|
||||
sinon.assert.notCalled(mockContext.getDevToolsData);
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
mockContext.validatePath,
|
||||
'file.heapsnapshot',
|
||||
);
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
handler,
|
||||
{params: {filePath: '/canonical/file.heapsnapshot'}},
|
||||
sinon.match.instanceOf(McpResponse),
|
||||
mockContext,
|
||||
);
|
||||
sinon.assert.calledOnceWithExactly(handleStub, mockContext, 'default');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -576,7 +576,7 @@ describe('browser', () => {
|
||||
assert.strictEqual(first, firstBrowser);
|
||||
|
||||
// Simulates a getContext()-level timeout firing after ensureBrowser()
|
||||
// had already resolved — e.g. McpContext.from()'s own CDP
|
||||
// had already resolved — e.g. McpContext.ensureBrowser()'s own CDP
|
||||
// initialization hanging on a transport that died silently. Token
|
||||
// rotation alone wouldn't help here: there's no in-flight
|
||||
// connect()/launch() left to discard, #browser is already cached.
|
||||
|
||||
+7
-3
@@ -13,13 +13,17 @@ import {pathToFileURL} from 'node:url';
|
||||
|
||||
import sinon from 'sinon';
|
||||
|
||||
import {McpContext} from '../src/McpContext.js';
|
||||
import type {McpContext} from '../src/McpContext.js';
|
||||
import {HeapSnapshotManager} from '../src/processors/HeapSnapshotManager.js';
|
||||
import {resolveCanonicalPath} from '../src/utils/files.js';
|
||||
import {escapeForLog} from '../src/utils/logger.js';
|
||||
|
||||
import {createMockPuppeteerBrowser} from './mocks.js';
|
||||
import {createTempDir, withMcpContext} from './utils.js';
|
||||
import {
|
||||
createConnectedMcpContext,
|
||||
createTempDir,
|
||||
withMcpContext,
|
||||
} from './utils.js';
|
||||
|
||||
describe('McpContext Roots', () => {
|
||||
it('should allow access to os.tmpdir() even if roots are empty', async () => {
|
||||
@@ -170,7 +174,7 @@ describe('McpContext path validation escaping', () => {
|
||||
async function createContext(): Promise<McpContext> {
|
||||
const browser = createMockPuppeteerBrowser();
|
||||
browser.targets.returns([]);
|
||||
return await McpContext.from(browser, undefined, {
|
||||
return await createConnectedMcpContext(browser, {
|
||||
experimentalDevToolsDebugging: false,
|
||||
performanceCrux: false,
|
||||
heapSnapshotManager: new HeapSnapshotManager(),
|
||||
|
||||
+19
-3
@@ -29,7 +29,7 @@ import sinon from 'sinon';
|
||||
import type {ParsedArguments} from '../src/config/ConfigParser.js';
|
||||
import {stopDaemon} from '../src/daemon/client.js';
|
||||
import {isDaemonRunning} from '../src/daemon/utils.js';
|
||||
import {McpContext} from '../src/McpContext.js';
|
||||
import {McpContext, type McpContextOptions} from '../src/McpContext.js';
|
||||
import {McpResponse} from '../src/McpResponse.js';
|
||||
import {HeapSnapshotManager} from '../src/processors/HeapSnapshotManager.js';
|
||||
import {TextSnapshot} from '../src/TextSnapshot.js';
|
||||
@@ -168,6 +168,23 @@ export async function withBrowser(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an McpContext that is already connected to `browser`.
|
||||
*/
|
||||
export async function createConnectedMcpContext(
|
||||
browser: Browser,
|
||||
options: Omit<McpContextOptions, 'getBrowser'>,
|
||||
locatorClass?: typeof Locator,
|
||||
): Promise<McpContext> {
|
||||
const context = McpContext.create(
|
||||
undefined,
|
||||
{...options, getBrowser: async () => browser},
|
||||
locatorClass,
|
||||
);
|
||||
await context.ensureBrowser();
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function withMcpContext(
|
||||
cb: (
|
||||
response: McpResponse,
|
||||
@@ -198,9 +215,8 @@ export async function withMcpContext(
|
||||
if (context) {
|
||||
context.dispose();
|
||||
}
|
||||
context = await McpContext.from(
|
||||
context = await createConnectedMcpContext(
|
||||
browser,
|
||||
undefined,
|
||||
{
|
||||
experimentalDevToolsDebugging: false,
|
||||
performanceCrux: options.performanceCrux ?? true,
|
||||
|
||||
Reference in new issue
Block a user