-
Notifications
You must be signed in to change notification settings - Fork 35
feat(core): capability-gated tool surface (#829) #924
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
c7d2325
df48e08
9397695
0611368
d4ef394
51f1ea6
b8890f8
27ccd34
4eb8baa
a98b26e
c291300
a576942
6455f65
3755d70
42c67a5
d53b70c
3e8088a
321d530
307fa4a
dc352da
78d96bb
ccbfd77
f42ab9f
cbe193d
ba07054
e2800b6
4c28d0a
577b5da
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,157 @@ | ||
| #!/usr/bin/env node | ||
| 'use strict'; | ||
|
|
||
| /** | ||
| * lint:tools-capabilities (#829) | ||
| * | ||
| * Verifies that every tool registered via registerAllTools() has an entry in | ||
| * TOOL_CAPABILITY_MAP (src/tools/index.ts) and therefore gets a `capability` | ||
| * field on its MCPToolDefinition. | ||
| * | ||
| * Fails with exit code 1 if any registered tool name is missing from the map. | ||
| * | ||
| * Usage: node scripts/lint-tools-capabilities.js | ||
| * npm run lint:tools-capabilities | ||
| */ | ||
|
|
||
| const path = require('path'); | ||
| const fs = require('fs'); | ||
|
|
||
| const ROOT = path.join(__dirname, '..'); | ||
| const TOOLS_INDEX = path.join(ROOT, 'src', 'tools', 'index.ts'); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Parse TOOL_CAPABILITY_MAP from the TypeScript source via simple regex. | ||
| // We do not compile the TS here — the map is a plain object literal with | ||
| // string keys so a regex scan is reliable and fast. | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| function parseCapabilityMap(src) { | ||
| // Match the TOOL_CAPABILITY_MAP object literal | ||
| const mapMatch = src.match(/export const TOOL_CAPABILITY_MAP[^=]*=\s*\{([^}]+)\}/s); | ||
| if (!mapMatch) { | ||
| throw new Error('Could not find TOOL_CAPABILITY_MAP in src/tools/index.ts'); | ||
| } | ||
|
|
||
| const body = mapMatch[1]; | ||
| const entries = {}; | ||
| // Match lines like: navigate: 'core', | ||
| const lineRe = /^\s+(\w+):\s*'(\w+)',?/gm; | ||
| let m; | ||
| while ((m = lineRe.exec(body)) !== null) { | ||
| entries[m[1]] = m[2]; | ||
| } | ||
| return entries; | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Parse the list of tools that are actually registered by scanning for | ||
| // server.registerTool( calls in all src/**/*.ts production sources. | ||
| // | ||
| // Two call patterns exist: | ||
| // 1. server.registerTool('literal_name', ...) | ||
| // 2. server.registerTool(someVar.name, ...) — name defined elsewhere as name: 'literal' | ||
| // | ||
| // For pattern 2 we also scan the file for MCPToolDefinition `name:` fields. | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| function collectRegisteredSourceFiles(dir, out = []) { | ||
| for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { | ||
| if (entry.name === 'index.ts') continue; | ||
|
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.
Do not skip every Useful? React with 👍 / 👎. |
||
| const full = path.join(dir, entry.name); | ||
| if (entry.isDirectory()) { | ||
| collectRegisteredSourceFiles(full, out); | ||
| } else if (entry.isFile() && entry.name.endsWith('.ts')) { | ||
| const src = fs.readFileSync(full, 'utf8'); | ||
| if (/\.registerTool\(/.test(src)) { | ||
| out.push({ file: full, src }); | ||
| } | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| function collectRegisteredToolNames() { | ||
| const files = collectRegisteredSourceFiles(path.join(ROOT, 'src')); | ||
|
|
||
| const names = new Set(); | ||
|
|
||
| // Pattern 1: .registerTool('literal_name', ...) | ||
| const literalRe = /\.registerTool\(\s*['"](\w+)['"]/g; | ||
|
|
||
| // Pattern 2: name: 'tool_name' (inside MCPToolDefinition objects) | ||
| // We capture all name: 'value' assignments in files that also call registerTool | ||
| const nameFieldRe = /^\s+name:\s*['"](\w+)['"]/gm; | ||
|
|
||
| for (const { src } of files) { | ||
|
|
||
| // Pattern 1: direct `name: 'value'` inside a registerTool() definition. | ||
| literalRe.lastIndex = 0; | ||
| let m; | ||
| while ((m = literalRe.exec(src)) !== null) { | ||
| names.add(m[1]); | ||
| } | ||
|
|
||
| // Pattern 2: if file uses .registerTool(varExpr.name, ...) scan for name: 'value' | ||
| if (/\.registerTool\(\s*\w+\.\w+/.test(src)) { | ||
| nameFieldRe.lastIndex = 0; | ||
| while ((m = nameFieldRe.exec(src)) !== null) { | ||
| names.add(m[1]); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return names; | ||
| } | ||
|
|
||
| function main() { | ||
| let indexSrc; | ||
| try { | ||
| indexSrc = fs.readFileSync(TOOLS_INDEX, 'utf8'); | ||
| } catch (err) { | ||
| console.error(`lint:tools-capabilities: cannot read ${TOOLS_INDEX}: ${err.message}`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| let capMap; | ||
| try { | ||
| capMap = parseCapabilityMap(indexSrc); | ||
| } catch (err) { | ||
| console.error(`lint:tools-capabilities: ${err.message}`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| const registered = collectRegisteredToolNames(); | ||
|
|
||
| const missing = []; | ||
| for (const name of [...registered].sort()) { | ||
| if (!(name in capMap)) { | ||
| missing.push(name); | ||
| } | ||
| } | ||
|
|
||
| if (missing.length > 0) { | ||
| console.error('lint:tools-capabilities FAILED'); | ||
| console.error(`The following ${missing.length} tool(s) are registered but have no entry in TOOL_CAPABILITY_MAP:`); | ||
| for (const name of missing) { | ||
| console.error(` - ${name}`); | ||
| } | ||
| console.error('\nAdd each tool to TOOL_CAPABILITY_MAP in src/tools/index.ts.'); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| const mapKeys = Object.keys(capMap).sort(); | ||
| const unregistered = mapKeys.filter(k => k !== 'expand_tools' && !registered.has(k)); | ||
| if (unregistered.length > 0) { | ||
| console.warn('lint:tools-capabilities WARNING: TOOL_CAPABILITY_MAP has entries for unregistered tools:'); | ||
| for (const name of unregistered) { | ||
| console.warn(` - ${name}`); | ||
| } | ||
| } | ||
|
|
||
| const total = registered.size; | ||
| console.log(`lint:tools-capabilities OK — ${total} tool(s) all have capability tags (${Object.keys(capMap).length} entries in map).`); | ||
| process.exit(0); | ||
| } | ||
|
|
||
| main(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ | |
|
|
||
| import { Command } from 'commander'; | ||
| import { getMCPServer, setMCPServerOptions } from './mcp-server'; | ||
| import { TOOL_CAPABILITIES, type ToolCapability } from './types/mcp'; | ||
| import { registerAllTools } from './tools'; | ||
| import { createTransport } from './transports/index'; | ||
| import { getGlobalConfig, setGlobalConfig } from './config/global'; | ||
|
|
@@ -98,11 +99,13 @@ 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.') | ||
|
Comment on lines
+102
to
+103
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 👍 / 👎. |
||
| .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; introspectToolsList?: boolean; autoConnect?: string | boolean; launchMode?: string; secrets?: string }) => { | ||
| .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 }) => { | ||
| // --introspect-tools-list: print tools/list JSON and exit, NO Chrome/CDP/transport startup. | ||
| if (options.introspectToolsList) { | ||
| const { MCPServer } = await import('./mcp-server'); | ||
|
|
@@ -121,7 +124,9 @@ program | |
| return; | ||
| } | ||
|
|
||
|
|
||
| let port = parseInt(options.port, 10); | ||
|
|
||
| let autoLaunch = options.autoLaunch || false; | ||
|
|
||
| // ─── --auto-connect (#849) ────────────────────────────────────────── | ||
|
|
@@ -373,16 +378,47 @@ 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) { | ||
| setMCPServerOptions({ initialToolTier: 3 as ToolTier }); | ||
| mcpOptions.initialToolTier = 3 as ToolTier; | ||
| console.error('[openchrome] All tools exposed from startup'); | ||
| } else if (envTier === 2) { | ||
| setMCPServerOptions({ initialToolTier: 2 as ToolTier }); | ||
| mcpOptions.initialToolTier = 2 as ToolTier; | ||
| 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(', ')}`); | ||
|
Comment on lines
+406
to
+407
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 Useful? React with 👍 / 👎. |
||
| } 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 | ||
|
|
||
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.
The linter only scans
src/tools/*.tsforregisterTool(...)calls, so tools registered from outside that directory (notablyoc_proxy_hookfromsrc/pilot/proxy/hook) are invisible to the check. If such a tool is omitted fromTOOL_CAPABILITY_MAP, CI will still pass and the tool will silently default tocore, breaking intended capability-group filtering for pilot deployments.Useful? React with 👍 / 👎.