Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b431cd6
feat(host): tool category toggle / --slim mode (#847)
shaun0927 May 12, 2026
7920585
fix(host): per-tool filtering in registrars with mixed categories (#847)
shaun0927 May 12, 2026
0dc22c2
Honor explicit category flags over env slim mode
shaun0927 May 12, 2026
518c12e
Keep category snapshots aligned after develop rebase
shaun0927 May 12, 2026
e0c8ea9
Align Cursor tier-one count with current tool surface
shaun0927 May 12, 2026
110e9d1
Stabilize CI baselines for current tool surface
shaun0927 May 12, 2026
3f1aeac
Preserve performance-insights gate shape after rebase
shaun0927 May 12, 2026
008c9d8
Harden admin key stdout assertion against worker noise
shaun0927 May 12, 2026
335d7ad
Refresh shared CI fixtures after s2c merge
shaun0927 May 12, 2026
5cc31c0
Resolve category filtering conflicts without pilot side effects
shaun0927 May 13, 2026
b2ad97b
Keep category filtering current with latest tool surface
shaun0927 May 13, 2026
11a6729
Keep tool-schema introspection flush-safe
shaun0927 May 13, 2026
d0b91a4
Keep category baseline aligned with crawl tools
shaun0927 May 13, 2026
878d6a7
Keep PR branch mergeable with develop
shaun0927 May 13, 2026
d32f022
Merge develop into feat/847-tool-category-toggle
shaun0927 May 13, 2026
3301bb9
Merge develop into feat/847-tool-category-toggle (auto-resolve, take-…
shaun0927 May 13, 2026
48fb64b
fix(944): resolve package.json conflict marker
shaun0927 May 13, 2026
17ce74c
Merge develop into feat/847-tool-category-toggle
shaun0927 May 13, 2026
4ac0f1c
Merge develop into feat/847-tool-category-toggle
shaun0927 May 13, 2026
3102365
fix(944): add TOOL_CAPABILITIES + capability + optional annotations
shaun0927 May 13, 2026
2c625b0
Merge develop into feat/847-tool-category-toggle
shaun0927 May 13, 2026
73e855b
fix(944): add oc_recording_status to TOOL_TO_CATEGORY
shaun0927 May 13, 2026
52926d4
Merge remote-tracking branch 'origin/develop' into HEAD
May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"clean": "rimraf dist",
"prepare": "npm run build",
"lint:changed": "node scripts/lint-changed-src.js",
"lint:categories": "node scripts/lint-tool-categories.mjs",
"harness:parallel-smoke": "ts-node tests/harness/parallel-smoke.ts"
},
"keywords": [
Expand Down
136 changes: 136 additions & 0 deletions scripts/lint-tool-categories.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/usr/bin/env node
/**
* lint-tool-categories.mjs (#847)
*
* CI guard: every tool name registered via REGISTRATION_ENTRIES in
* src/tools/index.ts must be present in TOOL_TO_CATEGORY in
* src/tools/_shared/category.ts. A missing entry would either fail loud at
* runtime (registerAllTools throws) or — worse — silently default into the
* full surface even when the operator passed --slim. This script catches
* both classes of regression at PR time.
*
* Strategy:
* 1. Read src/tools/index.ts and extract every entry of the form
* `tools: ['name', ...]` from REGISTRATION_ENTRIES.
* 2. Read src/tools/_shared/category.ts and extract every key of
* TOOL_TO_CATEGORY.
* 3. Diff. Exit non-zero on any mismatch (missing assignment OR stale
* entry no longer used by any registrar).
*
* Why a regex parser instead of importing the modules:
* - Keeps the script dependency-free and runnable in pre-build CI stages
* (no need to compile TypeScript first).
* - The `tools:` and `TOOL_TO_CATEGORY` shapes are deliberately simple
* literal arrays/objects with no interpolation — see the comments in
* src/tools/_shared/category.ts and src/tools/index.ts.
*/

import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, '..');

const indexPath = join(repoRoot, 'src', 'tools', 'index.ts');
const categoryPath = join(repoRoot, 'src', 'tools', '_shared', 'category.ts');

function readSource(filePath) {
try {
return readFileSync(filePath, 'utf8');
} catch (err) {
console.error(
`[lint-tool-categories] Could not read ${filePath}: ${err.message}`,
);
process.exit(2);
}
}

/**
* Extract every `tools: ['a', 'b', ...]` array from REGISTRATION_ENTRIES.
* Multi-line arrays are supported — the regex spans newlines.
*/
function extractRegisteredNames(source) {
const names = new Set();
// Match `tools: [ ... ]` — the array body may span multiple lines.
const re = /tools:\s*\[([\s\S]*?)\]/g;
let m;
while ((m = re.exec(source)) !== null) {
const body = m[1];
const stringRe = /['"]([A-Za-z0-9_]+)['"]/g;
let s;
while ((s = stringRe.exec(body)) !== null) {
names.add(s[1]);
}
}
return names;
}

/**
* Extract every key from `TOOL_TO_CATEGORY = { ... }` — both bare-identifier
* keys (`navigate: 'navigation',`) and quoted-string keys.
*/
function extractCategorizedNames(source) {
const names = new Set();
const objMatch = source.match(
/TOOL_TO_CATEGORY[^=]*=\s*{([\s\S]*?)\n};?/,
);
if (!objMatch) {
console.error(
'[lint-tool-categories] Could not locate TOOL_TO_CATEGORY object literal in category.ts',
);
process.exit(2);
}
const body = objMatch[1];
// Match a property line: leading whitespace, an identifier or quoted name,
// a colon, then a quoted category. Comments are skipped because they don't
// contain `:` followed by a quoted token at the start of a line.
const propRe = /(?:^|\n)\s*(?:['"]([A-Za-z0-9_]+)['"]|([A-Za-z_][A-Za-z0-9_]*))\s*:\s*['"][a-z]+['"]/g;
let m;
while ((m = propRe.exec(body)) !== null) {
names.add(m[1] ?? m[2]);
}
return names;
}

const indexSource = readSource(indexPath);
const categorySource = readSource(categoryPath);

const registered = extractRegisteredNames(indexSource);
const categorized = extractCategorizedNames(categorySource);

const missing = [...registered].filter((n) => !categorized.has(n)).sort();
const stale = [...categorized].filter((n) => !registered.has(n)).sort();

if (missing.length === 0 && stale.length === 0) {
console.error(
`[lint-tool-categories] OK — ${registered.size} registered tools all have a category assignment.`,
);
process.exit(0);
}

if (missing.length > 0) {
console.error(
`[lint-tool-categories] FAIL — ${missing.length} tool(s) registered in src/tools/index.ts have no entry in TOOL_TO_CATEGORY:`,
);
for (const name of missing) {
console.error(` - ${name}`);
}
console.error(
' Fix: add each name to src/tools/_shared/category.ts under the appropriate category.',
);
}

if (stale.length > 0) {
console.error(
`[lint-tool-categories] FAIL — ${stale.length} stale entry/entries in TOOL_TO_CATEGORY no longer correspond to any registered tool:`,
);
for (const name of stale) {
console.error(` - ${name}`);
}
console.error(
' Fix: remove from src/tools/_shared/category.ts (or re-register the tool in src/tools/index.ts).',
);
}

process.exit(1);
99 changes: 59 additions & 40 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@

import { Command } from 'commander';
import { getMCPServer, setMCPServerOptions } from './mcp-server';
import { TOOL_CAPABILITIES, type ToolCapability } from './types/mcp';
import { registerAllTools } from './tools';
import {
CategorySelection,
parseCategoryCsv,
categoryHelpText,
} from './tools/_shared/category';
import { createTransport } from './transports/index';
import { getGlobalConfig, setGlobalConfig } from './config/global';
import { resolveHeadlessMode } from './config/headless-resolver';
Expand Down Expand Up @@ -99,13 +103,14 @@ program
.option('--transport <mode>', 'Transport mode: stdio, http, or both (default: stdio)')
.option('--idle-timeout <duration>', 'Self-exit (code 0) after idle window with zero sessions. Format: <number>(ms|s|m|h), e.g. 30m, 90s, 500ms. Bare numbers are rejected. Also: OPENCHROME_IDLE_TIMEOUT_MS env var (integer ms). Default: disabled.')
.option('--pilot', 'Enable experimental pilot tier (see docs/roadmap/portability-harness-contract.md). Off by default; lazy-loads src/pilot/ modules when set. Also: OPENCHROME_PILOT=1 env var.')
.option('--tools-only <csv>', 'Expose only tools belonging to the specified capability groups (comma-separated). Valid values: core,crawl,recording,workflow,storage,profile,totp,pilot. Default: all groups exposed.')
.option('--disable-tools <csv>', 'Remove tools belonging to the specified capability groups (comma-separated). Valid values: core,crawl,recording,workflow,storage,profile,totp,pilot.')
.option('--introspect-tools-list', 'Print tools/list as compact JSON to stdout and exit (no Chrome/CDP startup). Used by lint-tool-schemas.mjs.')
.option('--auto-connect [userDataDir]', 'Attach to a Chrome you started yourself by reading <userDataDir>/DevToolsActivePort (#849). When omitted, uses the platform-default Chrome user-data dir. Also: OPENCHROME_AUTO_CONNECT=<dir> env var. Implies --launch-mode=attach.')
.option('--launch-mode <mode>', 'Chrome launch mode: auto | attach | isolated (#659). Also: OPENCHROME_LAUNCH_MODE env var.')
.option('--secrets <path>', 'Load a dotenv-format secrets file (KEY=value per line). Tokens "${SECRET:NAME}" in tool arguments are substituted to the real value at MCP request deserialization; the same values are redacted from every LLM-visible artifact (responses, trace, skill records, journal). Default: no secrets loaded. P3: no OS keychain integration.')
.action(async (options: { port: string; autoLaunch?: boolean; userDataDir?: string; profileDirectory?: string; chromeBinary?: string; headlessShell?: boolean; headless?: boolean; visible?: boolean; windowSize?: string; windowPosition?: string; windowBounds?: string; startMaximized?: boolean; restartChrome?: boolean; hybrid?: boolean; lpPort?: string; blockedDomains?: string; auditLog?: boolean; sanitizeContent?: boolean; allTools?: boolean; serverMode?: boolean; http?: string | boolean; authToken?: string; transport?: string; idleTimeout?: string; allowUnauthenticatedHttp?: boolean; pilot?: boolean; toolsOnly?: string; disableTools?: string; introspectToolsList?: boolean; autoConnect?: string | boolean; launchMode?: string; secrets?: string }) => {
.option('--slim', `Register only the slim-mode tool categories (chrome-devtools-mcp parity). Always-on categories (reliability, observe) are kept. Also: OPENCHROME_SLIM=1 env var.\n${categoryHelpText()}`)
.option('--enable-categories <csv>', 'Comma-separated allow-list of tool categories to register. Mutually exclusive with --slim (slim wins). Also: OPENCHROME_ENABLE_CATEGORIES env var.')
.option('--disable-categories <csv>', 'Comma-separated deny-list of tool categories to skip. Always-on categories cannot be disabled. Also: OPENCHROME_DISABLE_CATEGORIES env var.')
Comment on lines +110 to +112
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep legacy capability-filter CLI flags available

This refactor drops --tools-only/--disable-tools from serve, which makes existing startup commands using those flags fail at argument parsing. The capability-filter mechanism is still present in the server/types layer, so removing only the CLI entry points introduces a backward-incompatible regression for operators who rely on those flags in launch scripts.

Useful? React with 👍 / 👎.

.action(async (options: { port: string; autoLaunch?: boolean; userDataDir?: string; profileDirectory?: string; chromeBinary?: string; headlessShell?: boolean; headless?: boolean; visible?: boolean; windowSize?: string; windowPosition?: string; windowBounds?: string; startMaximized?: boolean; restartChrome?: boolean; hybrid?: boolean; lpPort?: string; blockedDomains?: string; auditLog?: boolean; sanitizeContent?: boolean; allTools?: boolean; serverMode?: boolean; http?: string | boolean; authToken?: string; transport?: string; idleTimeout?: string; allowUnauthenticatedHttp?: boolean; pilot?: boolean; introspectToolsList?: boolean; autoConnect?: string | boolean; launchMode?: string; secrets?: string; slim?: boolean; enableCategories?: string; disableCategories?: string }) => {
// --introspect-tools-list: print tools/list JSON and exit, NO Chrome/CDP/transport startup.
if (options.introspectToolsList) {
const { MCPServer } = await import('./mcp-server');
Comment on lines 115 to 116
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor category flags in introspect-tools-list mode

The --introspect-tools-list early-return path registers tools with registerAllTools(server) before any category-selection parsing runs, so --slim / --enable-categories / --disable-categories (and their env mirrors) are ignored in introspection mode. This makes commands like openchrome serve --introspect-tools-list --slim report the full default tool surface instead of the actual filtered one, which can mislead rollout checks and any automation that relies on introspection output to verify startup configuration.

Useful? React with 👍 / 👎.

Comment on lines 115 to 116
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor category flags in introspection path

The new --slim / --enable-categories / --disable-categories options are advertised on serve, but the --introspect-tools-list fast path still calls registerAllTools(server) with no selection. In practice, openchrome serve --introspect-tools-list --slim (or with category env vars) returns the full tool manifest instead of the filtered one, which makes introspection inconsistent with normal startup and can mislead CI/operators validating slim mode.

Useful? React with 👍 / 👎.

Expand All @@ -124,9 +129,7 @@ program
return;
}


let port = parseInt(options.port, 10);

let autoLaunch = options.autoLaunch || false;

// ─── --auto-connect (#849) ──────────────────────────────────────────
Expand Down Expand Up @@ -378,47 +381,16 @@ program
console.error('[openchrome] Content sanitization: disabled');
}

const mcpOptions: Parameters<typeof setMCPServerOptions>[0] = {};

// Tool tier configuration
const envTier = parseInt(process.env.OPENCHROME_TOOL_TIER || '', 10);
if (options.allTools || envTier >= 3) {
mcpOptions.initialToolTier = 3 as ToolTier;
setMCPServerOptions({ initialToolTier: 3 as ToolTier });
console.error('[openchrome] All tools exposed from startup');
} else if (envTier === 2) {
mcpOptions.initialToolTier = 2 as ToolTier;
setMCPServerOptions({ initialToolTier: 2 as ToolTier });
Comment on lines 386 to +390
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset MCP server options when no tool tier override is set

This branch only calls setMCPServerOptions(...) when --all-tools or OPENCHROME_TOOL_TIER is 2/3, but setMCPServerOptions is a replace-only global setter; if serve is invoked again in the same process (e.g., integration harnesses or embedded usage), a previous non-default tier persists and silently changes the next startup’s tool exposure. Before this commit the code always called the setter with an explicit object (including {}), so this introduces stale cross-invocation state.

Useful? React with 👍 / 👎.

console.error('[openchrome] Tier 2 tools exposed from startup');
}

// Capability filter configuration (#829)
const allCapabilities: readonly ToolCapability[] = TOOL_CAPABILITIES;
if (options.toolsOnly && options.disableTools) {
console.error('[openchrome] Error: --tools-only and --disable-tools are mutually exclusive');
process.exit(2);
}
if (options.toolsOnly) {
const requested = options.toolsOnly.split(',').map(s => s.trim()).filter(Boolean) as ToolCapability[];
const invalid = requested.filter(c => !allCapabilities.includes(c));
if (invalid.length > 0) {
console.error(`[openchrome] Error: unknown capability group(s): ${invalid.join(', ')}. Valid: ${allCapabilities.join(', ')}`);
process.exit(2);
}
mcpOptions.capabilityFilter = new Set(requested);
console.error(`[openchrome] Capability filter (tools-only): ${requested.join(', ')}`);
} else if (options.disableTools) {
const disabled = options.disableTools.split(',').map(s => s.trim()).filter(Boolean) as ToolCapability[];
const invalid = disabled.filter(c => !allCapabilities.includes(c));
if (invalid.length > 0) {
console.error(`[openchrome] Error: unknown capability group(s): ${invalid.join(', ')}. Valid: ${allCapabilities.join(', ')}`);
process.exit(2);
}
const allowed = allCapabilities.filter(c => !disabled.includes(c));
mcpOptions.capabilityFilter = new Set(allowed);
console.error(`[openchrome] Capability filter (disable-tools): disabled=${disabled.join(', ')}`);
}

setMCPServerOptions(mcpOptions);

// Set infinite reconnection for HTTP daemon mode BEFORE creating CDPClient singleton.
// getMCPServer() → SessionManager → getCDPClient() reads this env var at construction.
// Resolve transport mode: --transport flag takes precedence over --http flag
Expand All @@ -437,7 +409,54 @@ program
resetReadinessMachine();

const server = getMCPServer();
await registerAllTools(server);

// Tool category selection (#847). Flags win over env vars; env vars exist
// so MCP host configs that cannot pass argv (Claude Desktop config blocks)
// can still trim the surface. CSV parse errors are fatal — a typo in
// --enable-categories should not silently degrade to "all tools".
let categorySelection: CategorySelection;
try {
const cliCategoryOverride =
options.enableCategories !== undefined || options.disableCategories !== undefined;
const slim =
options.slim === true ||
(!cliCategoryOverride && process.env.OPENCHROME_SLIM === '1');
const enabledCsv = slim
? ''
: options.enableCategories ??
process.env.OPENCHROME_ENABLE_CATEGORIES ??
'';
const disabledCsv =
options.disableCategories ??
Comment on lines +426 to +430
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat category CLI flags as full override of env selection

The category resolution still mixes in OPENCHROME_ENABLE_CATEGORIES/OPENCHROME_DISABLE_CATEGORIES whenever the corresponding CLI flag is omitted, so a one-off flag can be silently constrained by stale environment state and produce the wrong tool surface. For example, with OPENCHROME_DISABLE_CATEGORIES=vision, running --enable-categories=vision still removes vision and leaves only always-on categories. That contradicts the stated "flags win over env vars" behavior and can break operator expectations in production deployments that rely on env defaults.

Useful? React with 👍 / 👎.

process.env.OPENCHROME_DISABLE_CATEGORIES ??
'';
categorySelection = {
slim,
enabled:
enabledCsv.length > 0
? parseCategoryCsv(
enabledCsv,
options.enableCategories
? '--enable-categories'
: 'OPENCHROME_ENABLE_CATEGORIES',
)
Comment on lines +436 to +442
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore enabled-category parse errors when slim is active

When slim mode is active, enabled is not used by resolveEnabledCategories(), but the code still parses and validates enabledCsv unconditionally. This causes startup to fail on irrelevant data (for example, OPENCHROME_ENABLE_CATEGORIES=bogus openchrome serve --slim exits with code 2), even though slim is documented to win over --enable-categories; the ignored branch should not be able to block server startup.

Useful? React with 👍 / 👎.

: undefined,
disabled:
disabledCsv.length > 0
? parseCategoryCsv(
disabledCsv,
options.disableCategories
? '--disable-categories'
: 'OPENCHROME_DISABLE_CATEGORIES',
)
: undefined,
};
} catch (err) {
console.error(`[openchrome] ${(err as Error).message}`);
process.exit(2);
}
registerAllTools(server, categorySelection);


// Pilot dynamic-skills (#889): lazy attach only when explicitly enabled.
{
Expand Down
10 changes: 10 additions & 0 deletions src/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ import {
parseDomainFromUri,
readSkillGraphResource,
} from './resources/skill-graph';
import {
disabledToolsResource,
DISABLED_TOOLS_RESOURCE_URI,
getDisabledToolsContent,
} from './resources/tools-disabled';
import { HintEngine } from './hints';
import { buildAutomationInsight, formatAutomationFallback, shouldInjectAutomationFallback } from './hints/result-guidance';
import { validateToolSchema } from './utils/schema-validator';
Expand Down Expand Up @@ -466,6 +471,9 @@ export class MCPServer {
// Register built-in resources
this.registerResource(usageGuideResource);
this.registerResource(skillGraphResourceTemplate);
// Sidecar discovery surface for tools filtered out by category selection
// (#847). The snapshot is populated by registerAllTools() at startup.
this.registerResource(disabledToolsResource);

// Initialize dashboard if enabled
if (options.dashboard) {
Expand Down Expand Up @@ -1363,6 +1371,8 @@ export class MCPServer {
let content: string;
if (uri === 'openchrome://usage-guide') {
content = getUsageGuideContent();
} else if (uri === DISABLED_TOOLS_RESOURCE_URI) {
content = getDisabledToolsContent();
} else {
throw new Error(`No content handler for resource: ${uri}`);
}
Expand Down
Loading
Loading