-
Notifications
You must be signed in to change notification settings - Fork 35
feat(host): tool category toggle / --slim mode (#847) #944
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b431cd6
7920585
0dc22c2
518c12e
e0c8ea9
110e9d1
3f1aeac
008c9d8
335d7ad
5cc31c0
b2ad97b
11a6729
d0b91a4
878d6a7
d32f022
3301bb9
48fb64b
17ce74c
4ac0f1c
3102365
2c625b0
73e855b
52926d4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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.') | ||
| .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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Useful? React with 👍 / 👎.
Comment on lines
115
to
116
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new Useful? React with 👍 / 👎. |
||
|
|
@@ -124,9 +129,7 @@ program | |
| return; | ||
| } | ||
|
|
||
|
|
||
| let port = parseInt(options.port, 10); | ||
|
|
||
| let autoLaunch = options.autoLaunch || false; | ||
|
|
||
| // ─── --auto-connect (#849) ────────────────────────────────────────── | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This branch only calls 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 | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The category resolution still mixes in 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When slim mode is active, 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. | ||
| { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This refactor drops
--tools-only/--disable-toolsfromserve, 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 👍 / 👎.