mirror of
https://github.com/jo-inc/camofox-browser.git
synced 2026-10-02 04:14:41 +08:00
fix(mcp): package standalone adapter dependencies
This commit is contained in:
+7
-119
@@ -1,119 +1,7 @@
|
||||
/**
|
||||
* Cookie file reading and parsing for camofox-browser.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Parse a Netscape-format cookie file into structured cookie objects.
|
||||
* @param {string} text - Raw cookie file content
|
||||
* @returns {Array<{name: string, value: string, domain: string, path: string, expires: number, httpOnly?: boolean, secure?: boolean}>}
|
||||
*/
|
||||
function parseNetscapeCookieFile(text) {
|
||||
const cookies = [];
|
||||
const cleaned = text.replace(/^\uFEFF/, '');
|
||||
|
||||
for (const rawLine of cleaned.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith('#') && !line.startsWith('#HttpOnly_')) continue;
|
||||
|
||||
let httpOnly = false;
|
||||
let working = line;
|
||||
if (working.startsWith('#HttpOnly_')) {
|
||||
httpOnly = true;
|
||||
working = working.replace(/^#HttpOnly_/, '');
|
||||
}
|
||||
|
||||
const parts = working.split('\t');
|
||||
if (parts.length < 7) continue;
|
||||
|
||||
const domain = parts[0];
|
||||
const cookiePath = parts[2];
|
||||
const secure = parts[3].toUpperCase() === 'TRUE';
|
||||
const expires = Number(parts[4]);
|
||||
const name = parts[5];
|
||||
const value = parts.slice(6).join('\t');
|
||||
|
||||
cookies.push({ name, value, domain, path: cookiePath, expires, httpOnly, secure });
|
||||
}
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse cookies from a Netscape cookie file.
|
||||
* @param {object} opts
|
||||
* @param {string} opts.cookiesDir - Base directory for cookie files
|
||||
* @param {string} opts.cookiesPath - Relative path to the cookie file within cookiesDir
|
||||
* @param {string} [opts.domainSuffix] - Only include cookies whose domain ends with this suffix
|
||||
* @param {number} [opts.maxBytes=5242880] - Maximum file size in bytes
|
||||
* @returns {Promise<Array<{name: string, value: string, domain: string, path: string, expires: number, httpOnly: boolean, secure: boolean}>>}
|
||||
*/
|
||||
async function readCookieFile({ cookiesDir, cookiesPath, domainSuffix, maxBytes = 5 * 1024 * 1024 }) {
|
||||
const resolved = path.resolve(cookiesDir, cookiesPath);
|
||||
if (!resolved.startsWith(cookiesDir + path.sep)) {
|
||||
throw new Error('cookiesPath must be a relative path within the cookies directory');
|
||||
}
|
||||
|
||||
const stat = await fs.stat(resolved);
|
||||
if (stat.size > maxBytes) {
|
||||
throw new Error('Cookie file too large (max 5MB)');
|
||||
}
|
||||
|
||||
const text = await fs.readFile(resolved, 'utf8');
|
||||
let cookies = parseNetscapeCookieFile(text);
|
||||
if (domainSuffix) {
|
||||
cookies = cookies.filter((c) => c.domain.endsWith(domainSuffix));
|
||||
}
|
||||
|
||||
return cookies.map((c) => ({
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain,
|
||||
path: c.path,
|
||||
expires: c.expires,
|
||||
httpOnly: !!c.httpOnly,
|
||||
secure: !!c.secure,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Import all cookies from the default bootstrap cookie file into a Playwright context.
|
||||
* Intended for first-run session seeding before any persistent storage state exists.
|
||||
* Missing file is treated as a no-op.
|
||||
* @param {object} opts
|
||||
* @param {string} opts.cookiesDir - Base directory for cookie files
|
||||
* @param {object} opts.context - Playwright BrowserContext
|
||||
* @param {string} [opts.cookiesPath='cookies.txt'] - Relative cookie file path within cookiesDir
|
||||
* @param {object} [opts.logger=console] - Logger with warn()
|
||||
* @returns {Promise<{imported: number, source: string|null}>}
|
||||
*/
|
||||
async function importBootstrapCookies({ cookiesDir, context, cookiesPath = 'cookies.txt', logger = console }) {
|
||||
if (!cookiesDir || !context) {
|
||||
return { imported: 0, source: null };
|
||||
}
|
||||
|
||||
const resolved = path.resolve(cookiesDir, cookiesPath);
|
||||
|
||||
try {
|
||||
const cookies = await readCookieFile({ cookiesDir, cookiesPath });
|
||||
if (cookies.length === 0) {
|
||||
return { imported: 0, source: resolved };
|
||||
}
|
||||
await context.addCookies(cookies);
|
||||
return { imported: cookies.length, source: resolved };
|
||||
} catch (err) {
|
||||
if (err?.code === 'ENOENT') {
|
||||
return { imported: 0, source: null };
|
||||
}
|
||||
logger?.warn?.('failed to import bootstrap cookies', {
|
||||
cookiesPath: resolved,
|
||||
error: err?.message || String(err),
|
||||
});
|
||||
return { imported: 0, source: resolved };
|
||||
}
|
||||
}
|
||||
|
||||
export { parseNetscapeCookieFile, readCookieFile, importBootstrapCookies };
|
||||
// Compatibility re-export. Cookie parsing ships with @askjo/camofox-mcp so its
|
||||
// standalone cookie-import tool has the same path-containment behavior as core.
|
||||
export {
|
||||
parseNetscapeCookieFile,
|
||||
readCookieFile,
|
||||
importBootstrapCookies,
|
||||
} from '../mcp/lib/cookies.mjs';
|
||||
|
||||
+3
-491
@@ -1,491 +1,3 @@
|
||||
/**
|
||||
* Canonical tool contracts for the camofox-browser REST API.
|
||||
*
|
||||
* Single source of truth shared by two hosts that expose the same 11 tools:
|
||||
* - mcp/server.mjs (stdio MCP server for Claude Code, Codex, agy, Cursor, opencode)
|
||||
* - plugin.ts (OpenClaw plugin)
|
||||
*
|
||||
* Importing this module from both hosts means tool names, JSON-Schema
|
||||
* parameters, REST routes, request bodies, auth semantics, and response
|
||||
* shaping cannot drift — the REST server sees identical traffic regardless of
|
||||
* which host the agent reached it through.
|
||||
*
|
||||
* Auth model (mirrors lib/auth.js):
|
||||
* - CAMOFOX_ACCESS_KEY (global superkey): when set, every route except
|
||||
* /health, cookie import, /auth-sessions, /stop requires
|
||||
* `Authorization: Bearer <accessKey>`.
|
||||
* - CAMOFOX_API_KEY (cookie-only gate): the cookie-import route requires
|
||||
* `Authorization: Bearer <apiKey>` (or loopback + non-production).
|
||||
*
|
||||
* Each buildRequest() result declares `auth: 'accessKey' | 'apiKey' | 'none'`
|
||||
* and `responseKind: 'json' | 'snapshot' | 'image'` so the host's fetch layer
|
||||
* and response adapter stay declarative.
|
||||
*/
|
||||
|
||||
import { readCookieFile } from './cookies.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ToolDef
|
||||
* @property {string} name
|
||||
* @property {string} description
|
||||
* @property {object} inputSchema - JSON Schema (object) for tool arguments.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RequestSpec
|
||||
* @property {string} method - HTTP method.
|
||||
* @property {string} path - Path (already includes query string when needed).
|
||||
* @property {'accessKey'|'apiKey'|'none'} auth
|
||||
* @property {'json'|'snapshot'|'image'} responseKind
|
||||
* @property {object} [body] - JSON body.
|
||||
* @property {object} [meta] - Extra metadata for response shaping.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CallContext
|
||||
* @property {string} userId - Session owner (scopes cookie/storage partition).
|
||||
* @property {string} [sessionKey] - Tab partition within a user.
|
||||
*/
|
||||
|
||||
const SEARCH_MACROS = [
|
||||
'@google_search',
|
||||
'@youtube_search',
|
||||
'@amazon_search',
|
||||
'@reddit_search',
|
||||
'@wikipedia_search',
|
||||
'@twitter_search',
|
||||
'@yelp_search',
|
||||
'@spotify_search',
|
||||
'@netflix_search',
|
||||
'@linkedin_search',
|
||||
'@instagram_search',
|
||||
'@tiktok_search',
|
||||
'@twitch_search',
|
||||
];
|
||||
|
||||
/**
|
||||
* The 11 tools, identical schema for both hosts. Edit here and both update.
|
||||
* @type {ToolDef[]}
|
||||
*/
|
||||
export const TOOL_DEFS = [
|
||||
{
|
||||
name: 'camofox_create_tab',
|
||||
description:
|
||||
'PREFERRED: Create a new browser tab using Camoufox anti-detection browser. Use camofox tools instead of Chrome/built-in browser - they bypass bot detection on Google, Amazon, LinkedIn, etc. Returns tabId for subsequent operations.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'Initial URL to navigate to' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'camofox_snapshot',
|
||||
description:
|
||||
'Get accessibility snapshot of a Camoufox page with element refs (e1, e2, etc.) for interaction, plus a visual screenshot. ' +
|
||||
'Large pages are truncated with pagination links preserved at the bottom. ' +
|
||||
'If the response includes hasMore=true and nextOffset, call again with that offset to see more content.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: { type: 'string', description: 'Tab identifier' },
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Character offset for paginated snapshots. Use nextOffset from a previous truncated response.',
|
||||
},
|
||||
},
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'camofox_click',
|
||||
description: 'Click an element in a Camoufox tab by ref (e.g., e1) or CSS selector.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: { type: 'string', description: 'Tab identifier' },
|
||||
ref: { type: 'string', description: 'Element ref from snapshot (e.g., e1)' },
|
||||
selector: { type: 'string', description: 'CSS selector (alternative to ref)' },
|
||||
},
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'camofox_type',
|
||||
description: 'Type text into an element in a Camoufox tab.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: { type: 'string', description: 'Tab identifier' },
|
||||
ref: { type: 'string', description: 'Element ref from snapshot (e.g., e2)' },
|
||||
selector: { type: 'string', description: 'CSS selector (alternative to ref)' },
|
||||
text: { type: 'string', description: 'Text to type' },
|
||||
pressEnter: { type: 'boolean', description: 'Press Enter after typing' },
|
||||
},
|
||||
required: ['tabId', 'text'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'camofox_navigate',
|
||||
description:
|
||||
'Navigate a Camoufox tab to a URL or use a search macro (@google_search, @youtube_search, etc.). Preferred over Chrome for sites with bot detection.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: { type: 'string', description: 'Tab identifier' },
|
||||
url: { type: 'string', description: 'URL to navigate to' },
|
||||
macro: {
|
||||
type: 'string',
|
||||
description: 'Search macro (e.g., @google_search, @youtube_search)',
|
||||
enum: SEARCH_MACROS,
|
||||
},
|
||||
query: { type: 'string', description: 'Search query (when using macro)' },
|
||||
},
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'camofox_scroll',
|
||||
description: 'Scroll a Camoufox page.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: { type: 'string', description: 'Tab identifier' },
|
||||
direction: { type: 'string', enum: ['up', 'down', 'left', 'right'] },
|
||||
amount: { type: 'number', description: 'Pixels to scroll' },
|
||||
},
|
||||
required: ['tabId', 'direction'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'camofox_screenshot',
|
||||
description: 'Take a screenshot of a Camoufox page.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: { type: 'string', description: 'Tab identifier' },
|
||||
},
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'camofox_close_tab',
|
||||
description: 'Close a Camoufox browser tab.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: { type: 'string', description: 'Tab identifier' },
|
||||
},
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'camofox_evaluate',
|
||||
description:
|
||||
"Execute JavaScript in a Camoufox tab's page context. Returns the result of the expression. Use for injecting scripts, reading page state, or calling web app APIs.",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: { type: 'string', description: 'Tab identifier' },
|
||||
expression: {
|
||||
type: 'string',
|
||||
description: 'JavaScript expression to evaluate in the page context',
|
||||
},
|
||||
},
|
||||
required: ['tabId', 'expression'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'camofox_list_tabs',
|
||||
description: 'List all open Camofox tabs for the current session.',
|
||||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||||
},
|
||||
{
|
||||
name: 'camofox_import_cookies',
|
||||
description:
|
||||
'Import cookies into the current Camoufox session (Netscape cookie file). Use to authenticate to sites like LinkedIn without interactive login. Requires CAMOFOX_API_KEY on the REST server.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
cookiesPath: {
|
||||
type: 'string',
|
||||
description: 'Relative path to a Netscape-format cookies.txt file within the server cookies directory',
|
||||
},
|
||||
domainSuffix: {
|
||||
type: 'string',
|
||||
description: 'Only import cookies whose domain ends with this suffix',
|
||||
},
|
||||
},
|
||||
required: ['cookiesPath'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Quick name → def lookup. */
|
||||
export const TOOL_BY_NAME = Object.fromEntries(TOOL_DEFS.map((t) => [t.name, t]));
|
||||
|
||||
/** Tool names in canonical order. */
|
||||
export const TOOL_NAMES = TOOL_DEFS.map((t) => t.name);
|
||||
|
||||
/**
|
||||
* Strip the routing key (tabId) from args, returning the REST body fields.
|
||||
* @param {Record<string, unknown>} args
|
||||
* @param {string} [dropKey]
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
function without(args, dropKey = 'tabId') {
|
||||
const { [dropKey]: _omit, ...rest } = args;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a REST request spec for a tool. Pure / synchronous — except cookie
|
||||
* import, which needs async file parsing; use buildCookieRequest() for that.
|
||||
*
|
||||
* @param {string} name - Tool name.
|
||||
* @param {Record<string, unknown>} args - Tool arguments.
|
||||
* @param {CallContext} ctx - { userId, sessionKey }.
|
||||
* @returns {RequestSpec}
|
||||
* @throws {Error} if the tool is unknown.
|
||||
*/
|
||||
export function buildRequest(name, args, ctx) {
|
||||
const userId = ctx.userId;
|
||||
const sessionKey = ctx.sessionKey;
|
||||
switch (name) {
|
||||
case 'camofox_create_tab':
|
||||
return {
|
||||
method: 'POST',
|
||||
path: '/tabs',
|
||||
auth: 'accessKey',
|
||||
responseKind: 'json',
|
||||
body: { url: args.url, userId, sessionKey },
|
||||
};
|
||||
case 'camofox_snapshot': {
|
||||
const params = new URLSearchParams({ userId, includeScreenshot: 'true' });
|
||||
if (args.offset != null && args.offset !== '') params.set('offset', String(args.offset));
|
||||
return {
|
||||
method: 'GET',
|
||||
path: `/tabs/${args.tabId}/snapshot?${params}`,
|
||||
auth: 'accessKey',
|
||||
responseKind: 'snapshot',
|
||||
};
|
||||
}
|
||||
case 'camofox_click':
|
||||
return {
|
||||
method: 'POST',
|
||||
path: `/tabs/${args.tabId}/click`,
|
||||
auth: 'accessKey',
|
||||
responseKind: 'json',
|
||||
body: { ...without(args), userId },
|
||||
};
|
||||
case 'camofox_type':
|
||||
return {
|
||||
method: 'POST',
|
||||
path: `/tabs/${args.tabId}/type`,
|
||||
auth: 'accessKey',
|
||||
responseKind: 'json',
|
||||
body: { ...without(args), userId },
|
||||
};
|
||||
case 'camofox_navigate':
|
||||
return {
|
||||
method: 'POST',
|
||||
path: `/tabs/${args.tabId}/navigate`,
|
||||
auth: 'accessKey',
|
||||
responseKind: 'json',
|
||||
body: { ...without(args), userId },
|
||||
};
|
||||
case 'camofox_scroll':
|
||||
return {
|
||||
method: 'POST',
|
||||
path: `/tabs/${args.tabId}/scroll`,
|
||||
auth: 'accessKey',
|
||||
responseKind: 'json',
|
||||
body: { ...without(args), userId },
|
||||
};
|
||||
case 'camofox_screenshot':
|
||||
return {
|
||||
method: 'GET',
|
||||
path: `/tabs/${args.tabId}/screenshot?${new URLSearchParams({ userId })}`,
|
||||
auth: 'accessKey',
|
||||
responseKind: 'image',
|
||||
};
|
||||
case 'camofox_close_tab':
|
||||
return {
|
||||
method: 'DELETE',
|
||||
path: `/tabs/${args.tabId}?${new URLSearchParams({ userId })}`,
|
||||
auth: 'accessKey',
|
||||
responseKind: 'json',
|
||||
};
|
||||
case 'camofox_evaluate':
|
||||
return {
|
||||
method: 'POST',
|
||||
path: `/tabs/${args.tabId}/evaluate`,
|
||||
auth: 'accessKey',
|
||||
responseKind: 'json',
|
||||
body: { userId, expression: args.expression },
|
||||
};
|
||||
case 'camofox_list_tabs':
|
||||
return {
|
||||
method: 'GET',
|
||||
path: `/tabs?${new URLSearchParams({ userId })}`,
|
||||
auth: 'accessKey',
|
||||
responseKind: 'json',
|
||||
};
|
||||
case 'camofox_import_cookies':
|
||||
// Async (Netscape parse + path check) — caller must use buildCookieRequest().
|
||||
throw new Error('camofox_import_cookies requires buildCookieRequest() (async cookie parsing)');
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie import is special: the Netscape file must be parsed locally (matching
|
||||
* the OpenClaw plugin) and the REST route accepts a parsed `{ cookies: [...] }`
|
||||
* body, never a path. This function does the parse and returns a request spec
|
||||
* that the host fetch layer can dispatch like any other tool.
|
||||
*
|
||||
* @param {{cookiesPath: string, domainSuffix?: string}} args
|
||||
* @param {CallContext} ctx
|
||||
* @param {{apiKey: string, cookiesDir: string}} config - server config (apiKey + cookiesDir).
|
||||
* @returns {Promise<RequestSpec & {meta: {imported: number, userId: string}}>}
|
||||
* @throws {Error} if CAMOFOX_API_KEY is unset.
|
||||
*/
|
||||
export async function buildCookieRequest(args, ctx, config) {
|
||||
if (!config.apiKey) {
|
||||
throw new Error(
|
||||
'CAMOFOX_API_KEY is not set. Cookie import is disabled unless both the server and the host (MCP/OpenClaw) have CAMOFOX_API_KEY.'
|
||||
);
|
||||
}
|
||||
const cookies = await readCookieFile({
|
||||
cookiesDir: config.cookiesDir,
|
||||
cookiesPath: args.cookiesPath,
|
||||
domainSuffix: args.domainSuffix,
|
||||
});
|
||||
return {
|
||||
method: 'POST',
|
||||
path: `/sessions/${encodeURIComponent(ctx.userId)}/cookies`,
|
||||
auth: 'apiKey',
|
||||
responseKind: 'json',
|
||||
body: { cookies },
|
||||
meta: { imported: cookies.length, userId: ctx.userId },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which bearer token a request needs, given server config.
|
||||
* @param {RequestSpec} spec
|
||||
* @param {{accessKey?: string, apiKey?: string}} config
|
||||
* @returns {{header?: {Authorization: string}, missing?: string}}
|
||||
*/
|
||||
export function authHeaders(spec, config) {
|
||||
if (spec.auth === 'accessKey') {
|
||||
if (!config.accessKey) return {}; // server not gated — no header needed
|
||||
return { Authorization: `Bearer ${config.accessKey}` };
|
||||
}
|
||||
if (spec.auth === 'apiKey') {
|
||||
// apiKey presence is enforced by buildCookieRequest(); here we only attach it.
|
||||
return { Authorization: `Bearer ${config.apiKey}` };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a request spec against a REST base URL. Shared by the MCP server and
|
||||
* the OpenClaw plugin so the wire-level behavior (auth headers, image decoding,
|
||||
* error formatting) is identical across hosts.
|
||||
*
|
||||
* @param {string} baseUrl - REST server origin (e.g. http://localhost:9377).
|
||||
* @param {RequestSpec} spec
|
||||
* @param {{accessKey?: string, apiKey?: string}} config
|
||||
* @returns {Promise<unknown>} JSON value, or an image content block for image specs.
|
||||
* @throws {Error} on non-2xx, or when an image route returns non-image bytes.
|
||||
*/
|
||||
export async function fetchSpec(baseUrl, spec, config) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders(spec, config),
|
||||
};
|
||||
const res = await fetch(`${baseUrl}${spec.path}`, {
|
||||
method: spec.method,
|
||||
headers,
|
||||
body: spec.body ? JSON.stringify(spec.body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
if (spec.responseKind === 'image') {
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
// Guard: server may return JSON/text (e.g. error with 200) — don't base64 it.
|
||||
if (!contentType.startsWith('image/')) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Screenshot failed: ${text}`);
|
||||
}
|
||||
const data = Buffer.from(await res.arrayBuffer()).toString('base64');
|
||||
return { type: 'image', data, mimeType: contentType };
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build + dispatch a tool call end-to-end. The single entry point both hosts
|
||||
* call, so a tool's full behavior (request shape + auth + transport + response
|
||||
* shaping) lives in exactly one place. Hosts only differ in how they source
|
||||
* `ctx` (userId/sessionKey) and `config`.
|
||||
*
|
||||
* @param {string} name - Tool name.
|
||||
* @param {Record<string, unknown>} args - Tool arguments.
|
||||
* @param {CallContext} ctx - { userId, sessionKey }.
|
||||
* @param {string} baseUrl - REST server origin.
|
||||
* @param {{apiKey?: string, accessKey?: string, cookiesDir: string}} config - server config.
|
||||
* @returns {Promise<{spec: RequestSpec, payload: unknown}>}
|
||||
*/
|
||||
export async function runTool(name, args, ctx, baseUrl, config) {
|
||||
const spec =
|
||||
name === 'camofox_import_cookies'
|
||||
? await buildCookieRequest(args, ctx, config)
|
||||
: buildRequest(name, args, ctx);
|
||||
const payload = await fetchSpec(baseUrl, spec, config);
|
||||
return { spec, payload };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape a REST JSON payload into MCP/OpenClaw content blocks.
|
||||
*
|
||||
* - snapshot: splits the embedded screenshot out as an image block
|
||||
* - image: already an image block produced by the fetch layer
|
||||
* - json (default): pretty-printed JSON text block
|
||||
*
|
||||
* @param {RequestSpec} spec
|
||||
* @param {unknown} payload - JSON value ('json'/'snapshot') or an image block ('image').
|
||||
* @returns {Array<{type: string, text?: string, data?: string, mimeType?: string}>}
|
||||
*/
|
||||
export function adaptResponse(spec, payload) {
|
||||
if (spec.responseKind === 'image') {
|
||||
return [payload];
|
||||
}
|
||||
if (spec.responseKind === 'snapshot') {
|
||||
const { screenshot, ...rest } = /** @type {any} */ (payload) || {};
|
||||
const content = [{ type: 'text', text: JSON.stringify(rest, null, 2) }];
|
||||
if (screenshot?.data) {
|
||||
content.push({
|
||||
type: 'image',
|
||||
data: screenshot.data,
|
||||
mimeType: screenshot.mimeType || 'image/png',
|
||||
});
|
||||
}
|
||||
return content;
|
||||
}
|
||||
// Cookie import: surface the parsed count alongside the server reply.
|
||||
if (spec.meta && spec.meta.imported != null) {
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ imported: spec.meta.imported, userId: spec.meta.userId, result: payload }, null, 2),
|
||||
},
|
||||
];
|
||||
}
|
||||
return [{ type: 'text', text: JSON.stringify(payload, null, 2) }];
|
||||
}
|
||||
// Compatibility re-export. The canonical contracts ship with @askjo/camofox-mcp
|
||||
// so the standalone MCP package and the OpenClaw plugin use the same source.
|
||||
export * from '../mcp/lib/tool-contracts.mjs';
|
||||
|
||||
+4
-5
@@ -13,7 +13,7 @@ The MCP server is a thin stdio client over the camofox REST server. Two pieces:
|
||||
|
||||
Registering the MCP server does **not** require being inside the camofox-browser checkout. The examples below work from any directory.
|
||||
|
||||
`mcp/` is also an **independently installable package** (`@askjo/camofox-mcp`, its own `package.json`). It depends on nothing but `@modelcontextprotocol/sdk` — no `camoufox-js`, `playwright-core`, `express`, or the ~300MB browser binary download that the core server pulls in. Tool names, JSON-Schema parameters, REST routes, and response shaping are imported from `../lib/mcp-tool-contracts.mjs`, the same canonical module the OpenClaw plugin (`plugin.ts`) uses, so the two hosts cannot drift. See **Option D** below if you only want the MCP adapter (e.g. pointing `CAMOFOX_BASE_URL` at a REST server running elsewhere).
|
||||
`mcp/` is also an **independently installable package** (`@askjo/camofox-mcp`, its own `package.json`). It depends on nothing but `@modelcontextprotocol/sdk` — no `camoufox-js`, `playwright-core`, `express`, or the ~300MB browser binary download that the core server pulls in. Tool names, JSON-Schema parameters, REST routes, and response shaping are defined in `mcp/lib/tool-contracts.mjs`, which ships inside the standalone package. The OpenClaw plugin (`plugin.ts`) imports the same canonical module, so the two hosts cannot drift. See **Option D** below if you only want the MCP adapter (e.g. pointing `CAMOFOX_BASE_URL` at a REST server running elsewhere).
|
||||
|
||||
## 1. Start the REST server
|
||||
|
||||
@@ -220,9 +220,8 @@ Element refs are unambiguous and preferred over CSS selectors — a selector tha
|
||||
Two layers, covering different things:
|
||||
|
||||
```bash
|
||||
# 1. Handshake + schema smoke test — spawns the real stdio server, checks
|
||||
# initialize/tools/list return all 11 tools with the right shape.
|
||||
# No REST server required.
|
||||
# 1. In-repository handshake + schema smoke test, then a packed-tarball
|
||||
# install and handshake test. Neither needs a REST server.
|
||||
npm run test:mcp
|
||||
|
||||
# 2. Mock-HTTP contract tests — verifies the actual REST traffic each tool
|
||||
@@ -234,4 +233,4 @@ npm run test:mcp
|
||||
NODE_OPTIONS='--experimental-vm-modules' npx jest tests/unit/mcp-contracts.test.js
|
||||
```
|
||||
|
||||
Both hosts (this MCP server and the OpenClaw plugin, `plugin.ts`) share one contract module, [`lib/mcp-tool-contracts.mjs`](../lib/mcp-tool-contracts.mjs) — tool schemas, REST routes, auth, and response shaping are defined once and imported by both, so they cannot drift out of sync.
|
||||
The packed-tarball check installs the generated `@askjo/camofox-mcp` tarball in an empty directory before its handshake. It catches imports that reach outside the standalone package.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import os from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Load only the environment settings the standalone MCP adapter requires.
|
||||
* This module intentionally does not import the core server configuration so
|
||||
* @askjo/camofox-mcp can run without the core package installed.
|
||||
*/
|
||||
export function loadMcpConfig() {
|
||||
return {
|
||||
port: parseInt(process.env.CAMOFOX_PORT || process.env.PORT || '9377', 10),
|
||||
apiKey: process.env.CAMOFOX_API_KEY || '',
|
||||
accessKey: (process.env.CAMOFOX_ACCESS_KEY || '').trim(),
|
||||
cookiesDir: process.env.CAMOFOX_COOKIES_DIR || join(os.homedir(), '.camofox', 'cookies'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Cookie file reading and parsing for camofox-browser.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Parse a Netscape-format cookie file into structured cookie objects.
|
||||
* @param {string} text - Raw cookie file content
|
||||
* @returns {Array<{name: string, value: string, domain: string, path: string, expires: number, httpOnly?: boolean, secure?: boolean}>}
|
||||
*/
|
||||
function parseNetscapeCookieFile(text) {
|
||||
const cookies = [];
|
||||
const cleaned = text.replace(/^\uFEFF/, '');
|
||||
|
||||
for (const rawLine of cleaned.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith('#') && !line.startsWith('#HttpOnly_')) continue;
|
||||
|
||||
let httpOnly = false;
|
||||
let working = line;
|
||||
if (working.startsWith('#HttpOnly_')) {
|
||||
httpOnly = true;
|
||||
working = working.replace(/^#HttpOnly_/, '');
|
||||
}
|
||||
|
||||
const parts = working.split('\t');
|
||||
if (parts.length < 7) continue;
|
||||
|
||||
const domain = parts[0];
|
||||
const cookiePath = parts[2];
|
||||
const secure = parts[3].toUpperCase() === 'TRUE';
|
||||
const expires = Number(parts[4]);
|
||||
const name = parts[5];
|
||||
const value = parts.slice(6).join('\t');
|
||||
|
||||
cookies.push({ name, value, domain, path: cookiePath, expires, httpOnly, secure });
|
||||
}
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse cookies from a Netscape cookie file.
|
||||
* @param {object} opts
|
||||
* @param {string} opts.cookiesDir - Base directory for cookie files
|
||||
* @param {string} opts.cookiesPath - Relative path to the cookie file within cookiesDir
|
||||
* @param {string} [opts.domainSuffix] - Only include cookies whose domain ends with this suffix
|
||||
* @param {number} [opts.maxBytes=5242880] - Maximum file size in bytes
|
||||
* @returns {Promise<Array<{name: string, value: string, domain: string, path: string, expires: number, httpOnly: boolean, secure: boolean}>>}
|
||||
*/
|
||||
function isPathInside(basePath, targetPath) {
|
||||
const relative = path.relative(basePath, targetPath);
|
||||
return relative !== ''
|
||||
&& relative !== '..'
|
||||
&& !relative.startsWith(`..${path.sep}`)
|
||||
&& !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
async function readCookieFile({ cookiesDir, cookiesPath, domainSuffix, maxBytes = 5 * 1024 * 1024 }) {
|
||||
if (typeof cookiesPath !== 'string' || cookiesPath.length === 0) {
|
||||
throw new Error('cookiesPath must be a non-empty string');
|
||||
}
|
||||
if (path.isAbsolute(cookiesPath)) {
|
||||
throw new Error('cookiesPath must be a relative path within the cookies directory');
|
||||
}
|
||||
|
||||
const realCookiesDir = await fs.realpath(cookiesDir);
|
||||
const requestedPath = path.resolve(realCookiesDir, cookiesPath);
|
||||
if (!isPathInside(realCookiesDir, requestedPath)) {
|
||||
throw new Error('cookiesPath must be a relative path within the cookies directory');
|
||||
}
|
||||
|
||||
const realCookiePath = await fs.realpath(requestedPath);
|
||||
if (!isPathInside(realCookiesDir, realCookiePath)) {
|
||||
throw new Error('cookiesPath resolves outside the cookies directory');
|
||||
}
|
||||
|
||||
const stat = await fs.stat(realCookiePath);
|
||||
if (stat.size > maxBytes) {
|
||||
throw new Error('Cookie file too large (max 5MB)');
|
||||
}
|
||||
|
||||
const text = await fs.readFile(realCookiePath, 'utf8');
|
||||
let cookies = parseNetscapeCookieFile(text);
|
||||
if (domainSuffix) {
|
||||
cookies = cookies.filter((c) => c.domain.endsWith(domainSuffix));
|
||||
}
|
||||
|
||||
return cookies.map((c) => ({
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain,
|
||||
path: c.path,
|
||||
expires: c.expires,
|
||||
httpOnly: !!c.httpOnly,
|
||||
secure: !!c.secure,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Import all cookies from the default bootstrap cookie file into a Playwright context.
|
||||
* Intended for first-run session seeding before any persistent storage state exists.
|
||||
* Missing file is treated as a no-op.
|
||||
* @param {object} opts
|
||||
* @param {string} opts.cookiesDir - Base directory for cookie files
|
||||
* @param {object} opts.context - Playwright BrowserContext
|
||||
* @param {string} [opts.cookiesPath='cookies.txt'] - Relative cookie file path within cookiesDir
|
||||
* @param {object} [opts.logger=console] - Logger with warn()
|
||||
* @returns {Promise<{imported: number, source: string|null}>}
|
||||
*/
|
||||
async function importBootstrapCookies({ cookiesDir, context, cookiesPath = 'cookies.txt', logger = console }) {
|
||||
if (!cookiesDir || !context) {
|
||||
return { imported: 0, source: null };
|
||||
}
|
||||
|
||||
const resolved = path.resolve(cookiesDir, cookiesPath);
|
||||
|
||||
try {
|
||||
const cookies = await readCookieFile({ cookiesDir, cookiesPath });
|
||||
if (cookies.length === 0) {
|
||||
return { imported: 0, source: resolved };
|
||||
}
|
||||
await context.addCookies(cookies);
|
||||
return { imported: cookies.length, source: resolved };
|
||||
} catch (err) {
|
||||
if (err?.code === 'ENOENT') {
|
||||
return { imported: 0, source: null };
|
||||
}
|
||||
logger?.warn?.('failed to import bootstrap cookies', {
|
||||
cookiesPath: resolved,
|
||||
error: err?.message || String(err),
|
||||
});
|
||||
return { imported: 0, source: resolved };
|
||||
}
|
||||
}
|
||||
|
||||
export { parseNetscapeCookieFile, readCookieFile, importBootstrapCookies };
|
||||
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* Canonical tool contracts for the camofox-browser REST API.
|
||||
*
|
||||
* Single source of truth shared by two hosts that expose the same 11 tools:
|
||||
* - mcp/server.mjs (stdio MCP server for Claude Code, Codex, agy, Cursor, opencode)
|
||||
* - plugin.ts (OpenClaw plugin)
|
||||
*
|
||||
* Importing this module from both hosts means tool names, JSON-Schema
|
||||
* parameters, REST routes, request bodies, auth semantics, and response
|
||||
* shaping cannot drift — the REST server sees identical traffic regardless of
|
||||
* which host the agent reached it through.
|
||||
*
|
||||
* Auth model (mirrors lib/auth.js):
|
||||
* - CAMOFOX_ACCESS_KEY (global superkey): when set, every route except
|
||||
* /health, cookie import, /auth-sessions, /stop requires
|
||||
* `Authorization: Bearer <accessKey>`.
|
||||
* - CAMOFOX_API_KEY (cookie-only gate): the cookie-import route requires
|
||||
* `Authorization: Bearer <apiKey>` (or loopback + non-production).
|
||||
*
|
||||
* Each buildRequest() result declares `auth: 'accessKey' | 'apiKey' | 'none'`
|
||||
* and `responseKind: 'json' | 'snapshot' | 'image'` so the host's fetch layer
|
||||
* and response adapter stay declarative.
|
||||
*/
|
||||
|
||||
import { readCookieFile } from './cookies.mjs';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ToolDef
|
||||
* @property {string} name
|
||||
* @property {string} description
|
||||
* @property {object} inputSchema - JSON Schema (object) for tool arguments.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RequestSpec
|
||||
* @property {string} method - HTTP method.
|
||||
* @property {string} path - Path (already includes query string when needed).
|
||||
* @property {'accessKey'|'apiKey'|'none'} auth
|
||||
* @property {'json'|'snapshot'|'image'} responseKind
|
||||
* @property {object} [body] - JSON body.
|
||||
* @property {object} [meta] - Extra metadata for response shaping.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CallContext
|
||||
* @property {string} userId - Session owner (scopes cookie/storage partition).
|
||||
* @property {string} [sessionKey] - Tab partition within a user.
|
||||
*/
|
||||
|
||||
const SEARCH_MACROS = [
|
||||
'@google_search',
|
||||
'@youtube_search',
|
||||
'@amazon_search',
|
||||
'@reddit_search',
|
||||
'@wikipedia_search',
|
||||
'@twitter_search',
|
||||
'@yelp_search',
|
||||
'@spotify_search',
|
||||
'@netflix_search',
|
||||
'@linkedin_search',
|
||||
'@instagram_search',
|
||||
'@tiktok_search',
|
||||
'@twitch_search',
|
||||
];
|
||||
|
||||
/**
|
||||
* The 11 tools, identical schema for both hosts. Edit here and both update.
|
||||
* @type {ToolDef[]}
|
||||
*/
|
||||
export const TOOL_DEFS = [
|
||||
{
|
||||
name: 'camofox_create_tab',
|
||||
description:
|
||||
'PREFERRED: Create a new browser tab using Camoufox anti-detection browser. Use camofox tools instead of Chrome/built-in browser - they bypass bot detection on Google, Amazon, LinkedIn, etc. Returns tabId for subsequent operations.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'Initial URL to navigate to' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'camofox_snapshot',
|
||||
description:
|
||||
'Get accessibility snapshot of a Camoufox page with element refs (e1, e2, etc.) for interaction, plus a visual screenshot. ' +
|
||||
'Large pages are truncated with pagination links preserved at the bottom. ' +
|
||||
'If the response includes hasMore=true and nextOffset, call again with that offset to see more content.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: { type: 'string', description: 'Tab identifier' },
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Character offset for paginated snapshots. Use nextOffset from a previous truncated response.',
|
||||
},
|
||||
},
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'camofox_click',
|
||||
description: 'Click an element in a Camoufox tab by ref (e.g., e1) or CSS selector.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: { type: 'string', description: 'Tab identifier' },
|
||||
ref: { type: 'string', description: 'Element ref from snapshot (e.g., e1)' },
|
||||
selector: { type: 'string', description: 'CSS selector (alternative to ref)' },
|
||||
},
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'camofox_type',
|
||||
description: 'Type text into an element in a Camoufox tab.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: { type: 'string', description: 'Tab identifier' },
|
||||
ref: { type: 'string', description: 'Element ref from snapshot (e.g., e2)' },
|
||||
selector: { type: 'string', description: 'CSS selector (alternative to ref)' },
|
||||
text: { type: 'string', description: 'Text to type' },
|
||||
pressEnter: { type: 'boolean', description: 'Press Enter after typing' },
|
||||
},
|
||||
required: ['tabId', 'text'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'camofox_navigate',
|
||||
description:
|
||||
'Navigate a Camoufox tab to a URL or use a search macro (@google_search, @youtube_search, etc.). Preferred over Chrome for sites with bot detection.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: { type: 'string', description: 'Tab identifier' },
|
||||
url: { type: 'string', description: 'URL to navigate to' },
|
||||
macro: {
|
||||
type: 'string',
|
||||
description: 'Search macro (e.g., @google_search, @youtube_search)',
|
||||
enum: SEARCH_MACROS,
|
||||
},
|
||||
query: { type: 'string', description: 'Search query (when using macro)' },
|
||||
},
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'camofox_scroll',
|
||||
description: 'Scroll a Camoufox page.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: { type: 'string', description: 'Tab identifier' },
|
||||
direction: { type: 'string', enum: ['up', 'down', 'left', 'right'] },
|
||||
amount: { type: 'number', description: 'Pixels to scroll' },
|
||||
},
|
||||
required: ['tabId', 'direction'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'camofox_screenshot',
|
||||
description: 'Take a screenshot of a Camoufox page.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: { type: 'string', description: 'Tab identifier' },
|
||||
},
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'camofox_close_tab',
|
||||
description: 'Close a Camoufox browser tab.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: { type: 'string', description: 'Tab identifier' },
|
||||
},
|
||||
required: ['tabId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'camofox_evaluate',
|
||||
description:
|
||||
"Execute JavaScript in a Camoufox tab's page context. Returns the result of the expression. Use for injecting scripts, reading page state, or calling web app APIs.",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tabId: { type: 'string', description: 'Tab identifier' },
|
||||
expression: {
|
||||
type: 'string',
|
||||
description: 'JavaScript expression to evaluate in the page context',
|
||||
},
|
||||
},
|
||||
required: ['tabId', 'expression'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'camofox_list_tabs',
|
||||
description: 'List all open Camofox tabs for the current session.',
|
||||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||||
},
|
||||
{
|
||||
name: 'camofox_import_cookies',
|
||||
description:
|
||||
'Import cookies into the current Camoufox session (Netscape cookie file). Use to authenticate to sites like LinkedIn without interactive login. Requires CAMOFOX_API_KEY on the REST server.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
cookiesPath: {
|
||||
type: 'string',
|
||||
description: 'Relative path to a Netscape-format cookies.txt file within the server cookies directory',
|
||||
},
|
||||
domainSuffix: {
|
||||
type: 'string',
|
||||
description: 'Only import cookies whose domain ends with this suffix',
|
||||
},
|
||||
},
|
||||
required: ['cookiesPath'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Quick name → def lookup. */
|
||||
export const TOOL_BY_NAME = Object.fromEntries(TOOL_DEFS.map((t) => [t.name, t]));
|
||||
|
||||
/** Tool names in canonical order. */
|
||||
export const TOOL_NAMES = TOOL_DEFS.map((t) => t.name);
|
||||
|
||||
/**
|
||||
* Strip the routing key (tabId) from args, returning the REST body fields.
|
||||
* @param {Record<string, unknown>} args
|
||||
* @param {string} [dropKey]
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
function without(args, dropKey = 'tabId') {
|
||||
const { [dropKey]: _omit, ...rest } = args;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a REST request spec for a tool. Pure / synchronous — except cookie
|
||||
* import, which needs async file parsing; use buildCookieRequest() for that.
|
||||
*
|
||||
* @param {string} name - Tool name.
|
||||
* @param {Record<string, unknown>} args - Tool arguments.
|
||||
* @param {CallContext} ctx - { userId, sessionKey }.
|
||||
* @returns {RequestSpec}
|
||||
* @throws {Error} if the tool is unknown.
|
||||
*/
|
||||
export function buildRequest(name, args, ctx) {
|
||||
const userId = ctx.userId;
|
||||
const sessionKey = ctx.sessionKey;
|
||||
switch (name) {
|
||||
case 'camofox_create_tab':
|
||||
return {
|
||||
method: 'POST',
|
||||
path: '/tabs',
|
||||
auth: 'accessKey',
|
||||
responseKind: 'json',
|
||||
body: { url: args.url, userId, sessionKey },
|
||||
};
|
||||
case 'camofox_snapshot': {
|
||||
const params = new URLSearchParams({ userId, includeScreenshot: 'true' });
|
||||
if (args.offset != null && args.offset !== '') params.set('offset', String(args.offset));
|
||||
return {
|
||||
method: 'GET',
|
||||
path: `/tabs/${args.tabId}/snapshot?${params}`,
|
||||
auth: 'accessKey',
|
||||
responseKind: 'snapshot',
|
||||
};
|
||||
}
|
||||
case 'camofox_click':
|
||||
return {
|
||||
method: 'POST',
|
||||
path: `/tabs/${args.tabId}/click`,
|
||||
auth: 'accessKey',
|
||||
responseKind: 'json',
|
||||
body: { ...without(args), userId },
|
||||
};
|
||||
case 'camofox_type':
|
||||
return {
|
||||
method: 'POST',
|
||||
path: `/tabs/${args.tabId}/type`,
|
||||
auth: 'accessKey',
|
||||
responseKind: 'json',
|
||||
body: { ...without(args), userId },
|
||||
};
|
||||
case 'camofox_navigate':
|
||||
return {
|
||||
method: 'POST',
|
||||
path: `/tabs/${args.tabId}/navigate`,
|
||||
auth: 'accessKey',
|
||||
responseKind: 'json',
|
||||
body: { ...without(args), userId },
|
||||
};
|
||||
case 'camofox_scroll':
|
||||
return {
|
||||
method: 'POST',
|
||||
path: `/tabs/${args.tabId}/scroll`,
|
||||
auth: 'accessKey',
|
||||
responseKind: 'json',
|
||||
body: { ...without(args), userId },
|
||||
};
|
||||
case 'camofox_screenshot':
|
||||
return {
|
||||
method: 'GET',
|
||||
path: `/tabs/${args.tabId}/screenshot?${new URLSearchParams({ userId })}`,
|
||||
auth: 'accessKey',
|
||||
responseKind: 'image',
|
||||
};
|
||||
case 'camofox_close_tab':
|
||||
return {
|
||||
method: 'DELETE',
|
||||
path: `/tabs/${args.tabId}?${new URLSearchParams({ userId })}`,
|
||||
auth: 'accessKey',
|
||||
responseKind: 'json',
|
||||
};
|
||||
case 'camofox_evaluate':
|
||||
return {
|
||||
method: 'POST',
|
||||
path: `/tabs/${args.tabId}/evaluate`,
|
||||
auth: 'accessKey',
|
||||
responseKind: 'json',
|
||||
body: { userId, expression: args.expression },
|
||||
};
|
||||
case 'camofox_list_tabs':
|
||||
return {
|
||||
method: 'GET',
|
||||
path: `/tabs?${new URLSearchParams({ userId })}`,
|
||||
auth: 'accessKey',
|
||||
responseKind: 'json',
|
||||
};
|
||||
case 'camofox_import_cookies':
|
||||
// Async (Netscape parse + path check) — caller must use buildCookieRequest().
|
||||
throw new Error('camofox_import_cookies requires buildCookieRequest() (async cookie parsing)');
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie import is special: the Netscape file must be parsed locally (matching
|
||||
* the OpenClaw plugin) and the REST route accepts a parsed `{ cookies: [...] }`
|
||||
* body, never a path. This function does the parse and returns a request spec
|
||||
* that the host fetch layer can dispatch like any other tool.
|
||||
*
|
||||
* @param {{cookiesPath: string, domainSuffix?: string}} args
|
||||
* @param {CallContext} ctx
|
||||
* @param {{apiKey: string, cookiesDir: string}} config - server config (apiKey + cookiesDir).
|
||||
* @returns {Promise<RequestSpec & {meta: {imported: number, userId: string}}>}
|
||||
* @throws {Error} if CAMOFOX_API_KEY is unset.
|
||||
*/
|
||||
export async function buildCookieRequest(args, ctx, config) {
|
||||
if (!config.apiKey) {
|
||||
throw new Error(
|
||||
'CAMOFOX_API_KEY is not set. Cookie import is disabled unless both the server and the host (MCP/OpenClaw) have CAMOFOX_API_KEY.'
|
||||
);
|
||||
}
|
||||
const cookies = await readCookieFile({
|
||||
cookiesDir: config.cookiesDir,
|
||||
cookiesPath: args.cookiesPath,
|
||||
domainSuffix: args.domainSuffix,
|
||||
});
|
||||
return {
|
||||
method: 'POST',
|
||||
path: `/sessions/${encodeURIComponent(ctx.userId)}/cookies`,
|
||||
auth: 'apiKey',
|
||||
responseKind: 'json',
|
||||
body: { cookies },
|
||||
meta: { imported: cookies.length, userId: ctx.userId },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which bearer token a request needs, given server config.
|
||||
* @param {RequestSpec} spec
|
||||
* @param {{accessKey?: string, apiKey?: string}} config
|
||||
* @returns {{header?: {Authorization: string}, missing?: string}}
|
||||
*/
|
||||
export function authHeaders(spec, config) {
|
||||
if (spec.auth === 'accessKey') {
|
||||
if (!config.accessKey) return {}; // server not gated — no header needed
|
||||
return { Authorization: `Bearer ${config.accessKey}` };
|
||||
}
|
||||
if (spec.auth === 'apiKey') {
|
||||
// apiKey presence is enforced by buildCookieRequest(); here we only attach it.
|
||||
return { Authorization: `Bearer ${config.apiKey}` };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a request spec against a REST base URL. Shared by the MCP server and
|
||||
* the OpenClaw plugin so the wire-level behavior (auth headers, image decoding,
|
||||
* error formatting) is identical across hosts.
|
||||
*
|
||||
* @param {string} baseUrl - REST server origin (e.g. http://localhost:9377).
|
||||
* @param {RequestSpec} spec
|
||||
* @param {{accessKey?: string, apiKey?: string}} config
|
||||
* @returns {Promise<unknown>} JSON value, or an image content block for image specs.
|
||||
* @throws {Error} on non-2xx, or when an image route returns non-image bytes.
|
||||
*/
|
||||
export async function fetchSpec(baseUrl, spec, config) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders(spec, config),
|
||||
};
|
||||
const res = await fetch(`${baseUrl}${spec.path}`, {
|
||||
method: spec.method,
|
||||
headers,
|
||||
body: spec.body ? JSON.stringify(spec.body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
if (spec.responseKind === 'image') {
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
// Guard: server may return JSON/text (e.g. error with 200) — don't base64 it.
|
||||
if (!contentType.startsWith('image/')) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Screenshot failed: ${text}`);
|
||||
}
|
||||
const data = Buffer.from(await res.arrayBuffer()).toString('base64');
|
||||
return { type: 'image', data, mimeType: contentType };
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build + dispatch a tool call end-to-end. The single entry point both hosts
|
||||
* call, so a tool's full behavior (request shape + auth + transport + response
|
||||
* shaping) lives in exactly one place. Hosts only differ in how they source
|
||||
* `ctx` (userId/sessionKey) and `config`.
|
||||
*
|
||||
* @param {string} name - Tool name.
|
||||
* @param {Record<string, unknown>} args - Tool arguments.
|
||||
* @param {CallContext} ctx - { userId, sessionKey }.
|
||||
* @param {string} baseUrl - REST server origin.
|
||||
* @param {{apiKey?: string, accessKey?: string, cookiesDir: string}} config - server config.
|
||||
* @returns {Promise<{spec: RequestSpec, payload: unknown}>}
|
||||
*/
|
||||
export async function runTool(name, args, ctx, baseUrl, config) {
|
||||
const spec =
|
||||
name === 'camofox_import_cookies'
|
||||
? await buildCookieRequest(args, ctx, config)
|
||||
: buildRequest(name, args, ctx);
|
||||
const payload = await fetchSpec(baseUrl, spec, config);
|
||||
return { spec, payload };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape a REST JSON payload into MCP/OpenClaw content blocks.
|
||||
*
|
||||
* - snapshot: splits the embedded screenshot out as an image block
|
||||
* - image: already an image block produced by the fetch layer
|
||||
* - json (default): pretty-printed JSON text block
|
||||
*
|
||||
* @param {RequestSpec} spec
|
||||
* @param {unknown} payload - JSON value ('json'/'snapshot') or an image block ('image').
|
||||
* @returns {Array<{type: string, text?: string, data?: string, mimeType?: string}>}
|
||||
*/
|
||||
export function adaptResponse(spec, payload) {
|
||||
if (spec.responseKind === 'image') {
|
||||
return [payload];
|
||||
}
|
||||
if (spec.responseKind === 'snapshot') {
|
||||
const { screenshot, ...rest } = /** @type {any} */ (payload) || {};
|
||||
const content = [{ type: 'text', text: JSON.stringify(rest, null, 2) }];
|
||||
if (screenshot?.data) {
|
||||
content.push({
|
||||
type: 'image',
|
||||
data: screenshot.data,
|
||||
mimeType: screenshot.mimeType || 'image/png',
|
||||
});
|
||||
}
|
||||
return content;
|
||||
}
|
||||
// Cookie import: surface the parsed count alongside the server reply.
|
||||
if (spec.meta && spec.meta.imported != null) {
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ imported: spec.meta.imported, userId: spec.meta.userId, result: payload }, null, 2),
|
||||
},
|
||||
];
|
||||
}
|
||||
return [{ type: 'text', text: JSON.stringify(payload, null, 2) }];
|
||||
}
|
||||
Generated
+9
-9
@@ -9,7 +9,7 @@
|
||||
"version": "1.12.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0"
|
||||
"@modelcontextprotocol/sdk": "^1.30.0"
|
||||
},
|
||||
"bin": {
|
||||
"camofox-mcp": "server.mjs"
|
||||
@@ -19,24 +19,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"version": "2.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz",
|
||||
"integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
|
||||
"integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"@hono/node-server": "^1.19.9 || ^2.0.5",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"content-type": "^1.0.5",
|
||||
|
||||
+2
-1
@@ -34,9 +34,10 @@
|
||||
},
|
||||
"files": [
|
||||
"server.mjs",
|
||||
"lib/",
|
||||
"README.md"
|
||||
],
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0"
|
||||
"@modelcontextprotocol/sdk": "^1.30.0"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-10
@@ -4,7 +4,7 @@
|
||||
// Standalone Model Context Protocol server that exposes the camofox-browser
|
||||
// REST API (default http://localhost:9377) as MCP tools. Tool names, schemas,
|
||||
// REST routes, request bodies, auth, and response shaping are imported from
|
||||
// lib/mcp-tool-contracts.mjs — the SAME source of truth the OpenClaw plugin
|
||||
// mcp/lib/tool-contracts.mjs — the SAME source of truth the OpenClaw plugin
|
||||
// (plugin.ts) uses — so behavior is identical whether an agent reaches camofox
|
||||
// via OpenClaw or MCP. Drift is structurally impossible.
|
||||
//
|
||||
@@ -23,12 +23,12 @@ import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { loadConfig } from "../lib/config.js";
|
||||
import { loadMcpConfig } from "./lib/config.mjs";
|
||||
import {
|
||||
TOOL_DEFS,
|
||||
runTool,
|
||||
adaptResponse,
|
||||
} from "../lib/mcp-tool-contracts.mjs";
|
||||
} from "./lib/tool-contracts.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -39,9 +39,10 @@ const VERSION = JSON.parse(
|
||||
readFileSync(join(__dirname, "package.json"), "utf8")
|
||||
).version;
|
||||
|
||||
// Server config (apiKey / accessKey / cookiesDir / port). Loaded once; env wins
|
||||
// over loadConfig() for MCP-specific overrides via CAMOFOX_BASE_URL.
|
||||
const CONFIG = loadConfig();
|
||||
// Server config (apiKey / accessKey / cookiesDir / port). The standalone
|
||||
// package reads only its own environment settings; CAMOFOX_BASE_URL overrides
|
||||
// the derived local URL.
|
||||
const CONFIG = loadMcpConfig();
|
||||
const BASE_URL = process.env.CAMOFOX_BASE_URL || `http://localhost:${CONFIG.port}`;
|
||||
|
||||
// Per-MCP-server userId so each host session gets an isolated camofox session
|
||||
@@ -50,8 +51,8 @@ const USER_ID = process.env.CAMOFOX_USER_ID || `mcp-${randomUUID()}`;
|
||||
// sessionKey partitions tabs within a user (matches plugin.ts fallback "default").
|
||||
const SESSION_KEY = process.env.CAMOFOX_SESSION_KEY || "default";
|
||||
|
||||
// MCP SDK is an optional dependency so the core camofox install stays light.
|
||||
// Surface a clear, actionable error if it is missing instead of a stack trace.
|
||||
// The standalone package declares the SDK directly. Surface a clear,
|
||||
// actionable error if an incomplete installation is missing it.
|
||||
let Server, StdioServerTransport, CallToolRequestSchema, ListToolsRequestSchema;
|
||||
try {
|
||||
const serverMod = await import("@modelcontextprotocol/sdk/server/index.js");
|
||||
@@ -64,8 +65,8 @@ try {
|
||||
} catch {
|
||||
console.error(
|
||||
"[camofox-mcp] @modelcontextprotocol/sdk is not installed.\n" +
|
||||
"Install it with: npm install @modelcontextprotocol/sdk\n" +
|
||||
"(It is an optionalDependency of this package — add it where you run the MCP server.)"
|
||||
"Install the adapter dependencies with: npm install\n" +
|
||||
"from the @askjo/camofox-mcp package directory."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
Generated
+9
-9
@@ -30,7 +30,7 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0"
|
||||
"@modelcontextprotocol/sdk": "^1.30.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/json-schema-ref-parser": {
|
||||
@@ -642,13 +642,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"version": "2.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz",
|
||||
"integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
@@ -1083,13 +1083,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
|
||||
"integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"@hono/node-server": "^1.19.9 || ^2.0.5",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"content-type": "^1.0.5",
|
||||
|
||||
+2
-2
@@ -121,7 +121,7 @@
|
||||
"prepublishOnly": "npm run build",
|
||||
"start": "node server.js",
|
||||
"mcp": "node mcp/server.mjs",
|
||||
"test:mcp": "node scripts/test-mcp.mjs",
|
||||
"test:mcp": "node scripts/test-mcp.mjs && node scripts/test-mcp-package.mjs",
|
||||
"test": "NODE_OPTIONS='--experimental-vm-modules' jest --runInBand --forceExit",
|
||||
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config jest.config.e2e.cjs --runInBand --forceExit",
|
||||
"test:plugins": "NODE_OPTIONS='--experimental-vm-modules' jest --forceExit plugins/",
|
||||
@@ -142,7 +142,7 @@
|
||||
"swagger-jsdoc": "^6.2.8"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0"
|
||||
"@modelcontextprotocol/sdk": "^1.30.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
// Package-level regression test for @askjo/camofox-mcp.
|
||||
// Packs mcp/, installs that tarball in an empty directory, then runs the same
|
||||
// MCP handshake smoke test against the installed server. This catches imports
|
||||
// that accidentally reach outside the published package.
|
||||
|
||||
import { execFile as execFileCallback } from 'node:child_process';
|
||||
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const MCP_DIR = join(ROOT, 'mcp');
|
||||
const testDir = await mkdtemp(join(tmpdir(), 'camofox-mcp-package-'));
|
||||
let tarball;
|
||||
|
||||
try {
|
||||
const { stdout } = await execFile('npm', ['pack', '--json'], { cwd: MCP_DIR });
|
||||
const [{ filename }] = JSON.parse(stdout);
|
||||
tarball = join(MCP_DIR, filename);
|
||||
|
||||
const installDir = join(testDir, 'install');
|
||||
await mkdir(installDir);
|
||||
await execFile('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', tarball], {
|
||||
cwd: installDir,
|
||||
});
|
||||
|
||||
const server = join(installDir, 'node_modules', '@askjo', 'camofox-mcp', 'server.mjs');
|
||||
await execFile(process.execPath, [join(ROOT, 'scripts', 'test-mcp.mjs')], {
|
||||
cwd: ROOT,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HOME: process.env.HOME,
|
||||
USER: process.env.USER,
|
||||
NODE_OPTIONS: process.env.NODE_OPTIONS || '',
|
||||
CAMOFOX_BASE_URL: 'http://localhost:1',
|
||||
CAMOFOX_MCP_SERVER: server,
|
||||
},
|
||||
});
|
||||
console.log('packed @askjo/camofox-mcp smoke test passed');
|
||||
} finally {
|
||||
if (tarball) await rm(tarball, { force: true });
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = resolve(fileURLToPath(import.meta.url), "..", "..");
|
||||
const SERVER = resolve(ROOT, "mcp", "server.mjs");
|
||||
const SERVER = process.env.CAMOFOX_MCP_SERVER || resolve(ROOT, "mcp", "server.mjs");
|
||||
|
||||
const EXPECTED_TOOLS = [
|
||||
"camofox_create_tab",
|
||||
|
||||
Reference in New Issue
Block a user