openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
268 lines • 10.1 kB
JavaScript
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { McpResponse } from './McpResponse.js';
import { SlimMcpResponse } from './SlimMcpResponse.js';
import { ClearcutLogger } from './telemetry/ClearcutLogger.js';
import { zod } from './third_party/index.js';
import { labels } from './tools/categories.js';
import { categoryToFlagName } from './config/category-options.js';
import { pageIdSchema } from './tools/ToolDefinition.js';
import { logger } from './utils/logger.js';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { isLocalhost } from './utils/url.js';
function buildDisabledMessage(toolName, flag, categoryLabel) {
const reason = categoryLabel
? `is in category ${categoryLabel} which`
: `requires ${flag.startsWith('--experimental') ? 'experimental feature' : 'flag'} ${flag} and`;
return `Tool ${toolName} ${reason} is currently disabled. Enable it by running chrome-devtools start ${flag}=true. For more information check the README.`;
}
function getToolStatusInfo(tool, serverArgs) {
const category = tool.annotations.category;
if (category) {
const flag = categoryToFlagName(category);
if (!serverArgs[flag]) {
return {
disabled: true,
reason: buildDisabledMessage(tool.name, `--${flag}`, labels[category]),
};
}
}
for (const condition of tool.annotations.conditions || []) {
if (!serverArgs[condition]) {
return {
disabled: true,
reason: buildDisabledMessage(tool.name, `--${condition}`),
};
}
}
return { disabled: false };
}
function isPageScopedTool(tool) {
return 'pageScoped' in tool && tool.pageScoped === true;
}
function formatArgumentNames(names) {
return names.map(name => `"${name}"`).join(', ');
}
function buildUnknownArgumentsMessage(toolName, unknownArgumentNames, expectedArgumentNames) {
const unknownLabel = unknownArgumentNames.length === 1 ? 'argument' : 'arguments';
const expectedArguments = expectedArgumentNames.length
? `Expected arguments: ${formatArgumentNames(expectedArgumentNames)}.`
: 'This tool does not accept any arguments.';
const correction = unknownArgumentNames.length === 1 ? 'Remove it' : 'Remove them';
return `Unknown ${unknownLabel} for tool "${toolName}": ${formatArgumentNames(unknownArgumentNames)}. ${expectedArguments} ${correction} and retry.`;
}
async function validateAndResolvePathOrUrl(filePathOrUrl, context) {
try {
const url = new URL(filePathOrUrl);
if (url.protocol === 'file:') {
return pathToFileURL(await context.validatePath(fileURLToPath(url))).href;
}
else if (['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)) {
return filePathOrUrl;
}
}
catch {
// Suppress parsing errors for regular file paths.
}
return await context.validatePath(filePathOrUrl);
}
function isLocalBrowser(context) {
if (context.browser.process()) {
return true;
}
const wsEndpoint = context.browser.wsEndpoint();
if (wsEndpoint && isLocalhost(wsEndpoint)) {
return true;
}
return false;
}
function shouldValidateFile(option, isLocal) {
if (option === true) {
return true;
}
if (typeof option === 'object' && option !== null) {
if (isLocal) {
return Boolean(option.local);
}
return Boolean(option.remote);
}
return false;
}
async function validateToolFiles(tool, params, context) {
const isLocal = isLocalBrowser(context);
for (const [key, option] of Object.entries(tool.verifyFilesSchema)) {
if (shouldValidateFile(option, isLocal)) {
const val = params[key];
if (typeof val === 'string') {
params[key] = await validateAndResolvePathOrUrl(val, context);
}
else if (Array.isArray(val)) {
const updated = [];
for (const item of val) {
if (typeof item === 'string') {
updated.push(await validateAndResolvePathOrUrl(item, context));
}
else {
throw new Error('Unexpected non-string value as a file path or URL');
}
}
params[key] = updated;
}
}
}
}
export class ToolHandler {
tool;
serverArgs;
getContext;
toolMutex;
inputSchema;
registeredInputSchema;
shouldRegister;
disabledReason;
constructor(tool, serverArgs, getContext, toolMutex) {
this.tool = tool;
this.serverArgs = serverArgs;
this.getContext = getContext;
this.toolMutex = toolMutex;
const { disabled, reason } = getToolStatusInfo(tool, serverArgs);
this.disabledReason = reason;
this.shouldRegister = !(disabled && !serverArgs.viaCli);
this.inputSchema =
'pageScoped' in tool &&
tool.pageScoped &&
serverArgs.pageIdRouting &&
!serverArgs.slim
? { ...pageIdSchema, ...tool.schema }
: tool.schema;
this.registeredInputSchema = zod.object(this.inputSchema).passthrough();
}
unknownArgumentNames(params) {
return Object.keys(params).filter(key => !Object.hasOwn(this.inputSchema, key));
}
async handle(params) {
if (this.disabledReason) {
return {
content: [
{
type: 'text',
text: this.disabledReason,
},
],
isError: true,
};
}
const unknownArgumentNames = this.unknownArgumentNames(params);
if (unknownArgumentNames.length) {
return {
content: [
{
type: 'text',
text: buildUnknownArgumentsMessage(this.tool.name, unknownArgumentNames, Object.keys(this.inputSchema)),
},
],
isError: true,
};
}
const guard = await this.toolMutex.acquire();
const startTime = Date.now();
let success = false;
let devToolsData;
let pageUrl;
try {
logger?.(`${this.tool.name} request: ${JSON.stringify(params, null, ' ')}`);
const context = await this.getContext();
logger?.(`${this.tool.name} context: resolved`);
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;
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 &&
!this.serverArgs.slim
? context.getPageById(pageId)
: context.getSelectedMcpPage();
response.setPage(page);
if (this.tool.blockedByDialog) {
page.throwIfDialogOpen();
}
await this.tool.handler({
params,
page,
}, response, context);
}
else {
await this.tool.handler({
params,
}, response, context);
}
}
catch (err) {
response.setError(err);
}
devToolsData = await context.getDevToolsData(page);
pageUrl = context.getSelectedMcpPageUrl(page);
// Resolve data format: --experimentalDataFormat takes precedence, fall back to legacy --experimentalToonFormat
let dataFormat = 'default';
if (this.serverArgs.experimentalDataFormat) {
dataFormat = this.serverArgs.experimentalDataFormat;
}
else if (this.serverArgs.experimentalToonFormat) {
dataFormat = 'toon';
}
const { content, structuredContent } = await response.handle(context, dataFormat);
const result = {
content,
};
if (response.error) {
result.isError = true;
}
success = true;
if (this.serverArgs.experimentalStructuredContent) {
result.structuredContent = structuredContent;
}
return result;
}
catch (err) {
logger?.(`${this.tool.name} error:`, err, err?.stack);
let errorText = err && 'message' in err ? err.message : String(err);
if ('cause' in err && err.cause) {
errorText += `\nCause: ${err.cause.message}`;
}
return {
content: [
{
type: 'text',
text: errorText,
},
],
isError: true,
};
}
finally {
void ClearcutLogger.get()?.logToolInvocation({
toolName: this.tool.name,
params,
schema: this.inputSchema,
success,
latencyMs: Date.now() - startTime,
devToolsData,
pageUrl,
});
guard[Symbol.dispose]();
}
}
}
//# sourceMappingURL=ToolHandler.js.map