fix(mcp): package standalone adapter dependencies

This commit is contained in:
Pradeep Elankumaran
2026-08-01 18:51:45 -07:00
parent b86f111752
commit f34b04798d
13 changed files with 742 additions and 647 deletions
+7 -119
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+16
View File
@@ -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'),
};
}
+140
View File
@@ -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 };
+491
View File
@@ -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) }];
}
+9 -9
View File
@@ -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
View File
@@ -34,9 +34,10 @@
},
"files": [
"server.mjs",
"lib/",
"README.md"
],
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0"
"@modelcontextprotocol/sdk": "^1.30.0"
}
}
+11 -10
View File
@@ -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);
}
+9 -9
View File
@@ -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
View File
@@ -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",
+47
View File
@@ -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 });
}
+1 -1
View File
@@ -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",