From 3e43e9f2f6e99127dc6771ca276a43fdb4ac012e Mon Sep 17 00:00:00 2001 From: Conal <33135619+Conalh@users.noreply.github.com> Date: Thu, 21 May 2026 18:47:31 -0700 Subject: [PATCH] Detect prefixed MCP config examples --- README.md | 5 +- dist/detectors/mcp.js | 10 +++- docs/PILOT.md | 4 +- docs/TRUST.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/detectors/mcp.ts | 11 +++- test/action-metadata.test.mjs | 2 +- .../new/examples/cursor_mcp_config.json | 7 +++ .../new/examples/example_mcp_config.json | 11 ++++ .../mcp-prefixed-sample-drift/old/.gitkeep | 1 + test/git-diff.test.mjs | 50 +++++++++++++++++++ test/mcp-drift.test.mjs | 29 +++++++++++ test/public-docs.test.mjs | 3 ++ 14 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 test/fixtures/mcp-prefixed-sample-drift/new/examples/cursor_mcp_config.json create mode 100644 test/fixtures/mcp-prefixed-sample-drift/new/examples/example_mcp_config.json create mode 100644 test/fixtures/mcp-prefixed-sample-drift/old/.gitkeep diff --git a/README.md b/README.md index 8721127..341695f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ ScopeTrail is a free OSS CLI and GitHub Action that reviews pull requests for ri - `.vscode/mcp.json` - `.codeium/windsurf/mcp_config.json` - `mcp_config.json.sample`, `mcp_config.json.template`, `mcp_config.json.disabled`, and `mcp_config.json.example` +- Prefixed sample MCP configs such as `example_mcp_config.json`, `claude_mcp_config.json`, `cursor_mcp_config.json`, and `vscode_mcp_config.json` - `.claude/settings.json` - `.codex/config.toml` - Terminal, Markdown, JSON, and line-level GitHub annotation output @@ -92,7 +93,7 @@ jobs: with: fetch-depth: 0 - - uses: Conalh/ScopeTrail@v0.1.10 + - uses: Conalh/ScopeTrail@v0.1.11 with: fail-on: none ``` @@ -122,7 +123,7 @@ ScopeTrail v0 detects: - Cursor, VS Code, and Windsurf MCP config files using `mcpServers` or `servers` where supported. - Windsurf remote MCP endpoint changes through `serverUrl`. - Sample/template/disabled MCP config drift as a separate advisory category, not active server drift. -- Risky copied MCP examples such as `.mcp.json.sample`, `.mcp.json.template`, `.mcp.json.disabled`, `.mcp.json.windows.example`, `.mcp.json.example.mac`, and nested `mcp_config.json.example` files with unpinned commands or remote endpoints. +- Risky copied MCP examples such as `.mcp.json.sample`, `.mcp.json.template`, `.mcp.json.disabled`, `.mcp.json.windows.example`, `.mcp.json.example.mac`, nested `mcp_config.json.example`, and prefixed files such as `example_mcp_config.json` with unpinned commands or remote endpoints. - Broad Claude Code allow rules such as `Bash(npm *)` and `Read(~/**)`. Scoped grants (`WebFetch(domain:example.com)`, `mcp__github__get_issue`) are recognized as narrow and not flagged. - Removed Claude Code deny rules for sensitive files such as `.env`. - Claude Code hook changes: **removed**, **added**, and **command-changed** (a strict `PreToolUse` swapped for a no-op script is the same risk as a removal — both are now caught). diff --git a/dist/detectors/mcp.js b/dist/detectors/mcp.js index 38e5bf1..0be9761 100644 --- a/dist/detectors/mcp.js +++ b/dist/detectors/mcp.js @@ -16,6 +16,12 @@ const MCP_SAMPLE_CONFIG_FILENAMES = new Set([ 'mcp_config.json.template', 'mcp_config.json.example' ]); +const MCP_PREFIXED_SAMPLE_CONFIG_FILENAMES = new Set([ + 'example_mcp_config.json', + 'claude_mcp_config.json', + 'cursor_mcp_config.json', + 'vscode_mcp_config.json' +]); const MCP_EXAMPLE_BASE_FILENAMES = ['.mcp.json', 'mcp_config.json']; const MCP_PLATFORM_EXAMPLE_QUALIFIERS = new Set([ 'darwin', @@ -169,7 +175,9 @@ export function isMcpSampleConfigPath(relativePath) { } const fileName = segments.at(-1); return fileName - ? MCP_SAMPLE_CONFIG_FILENAMES.has(fileName) || isPlatformSuffixedMcpExampleFileName(fileName) + ? MCP_SAMPLE_CONFIG_FILENAMES.has(fileName) + || MCP_PREFIXED_SAMPLE_CONFIG_FILENAMES.has(fileName) + || isPlatformSuffixedMcpExampleFileName(fileName) : false; } function isPlatformSuffixedMcpExampleFileName(fileName) { diff --git a/docs/PILOT.md b/docs/PILOT.md index c1ab2db..c0a3ab7 100644 --- a/docs/PILOT.md +++ b/docs/PILOT.md @@ -23,7 +23,7 @@ jobs: with: fetch-depth: 0 - - uses: Conalh/ScopeTrail@v0.1.10 + - uses: Conalh/ScopeTrail@v0.1.11 with: fail-on: none ``` @@ -36,7 +36,7 @@ Useful checks during the trial: - Did ScopeTrail catch real permission drift? - Did any warning feel noisy or too broad? -- Did sample/template/disabled MCP config findings, including platform-suffixed examples, correctly stay separate from active MCP server drift? +- Did sample/template/disabled MCP config findings, including platform-suffixed and prefixed MCP config examples, correctly stay separate from active MCP server drift? - Did it miss an agent config surface your repository uses? - Would a team workflow need cross-repo visibility, policy ownership, exception workflow, or reporting? diff --git a/docs/TRUST.md b/docs/TRUST.md index 03165a5..4dc21f5 100644 --- a/docs/TRUST.md +++ b/docs/TRUST.md @@ -6,7 +6,7 @@ ScopeTrail is a local-only GitHub Action and CLI for reviewing AI-agent permissi ScopeTrail reads the checked-out repository and compares supported agent configuration files between the pull request base and head refs. Supported active files include `.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`, `.codeium/windsurf/mcp_config.json`, `.claude/settings.json`, and `.codex/config.toml`. -ScopeTrail also reviews sample/template/disabled MCP config files such as `.mcp.json.sample`, `.mcp.json.template`, `.mcp.json.disabled`, `.mcp.json.example`, platform-suffixed MCP example files such as `.mcp.json.windows.example` and `.mcp.json.example.mac`, and nested `mcp_config.json.example` variants. Those findings are reported separately from active MCP server drift so copied examples can be reviewed without implying they are live configuration. +ScopeTrail also reviews sample/template/disabled MCP config files such as `.mcp.json.sample`, `.mcp.json.template`, `.mcp.json.disabled`, `.mcp.json.example`, platform-suffixed MCP example files such as `.mcp.json.windows.example` and `.mcp.json.example.mac`, nested `mcp_config.json.example` variants, and prefixed MCP config example files such as `example_mcp_config.json`, `claude_mcp_config.json`, `cursor_mcp_config.json`, and `vscode_mcp_config.json`. Those findings are reported separately from active MCP server drift so copied examples can be reviewed without implying they are live configuration. In GitHub Actions, `fetch-depth: 0` is required so ScopeTrail can compare the pull request base and head commits instead of only seeing the latest checkout. diff --git a/package-lock.json b/package-lock.json index fbc3123..3f71c31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scopetrail", - "version": "0.1.10", + "version": "0.1.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scopetrail", - "version": "0.1.10", + "version": "0.1.11", "license": "MIT", "bin": { "scopetrail": "dist/index.js" diff --git a/package.json b/package.json index 4c7d283..f844346 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scopetrail", - "version": "0.1.10", + "version": "0.1.11", "description": "Code review for AI agent permission drift.", "type": "module", "bin": { diff --git a/src/detectors/mcp.ts b/src/detectors/mcp.ts index 66e75d5..3100c81 100644 --- a/src/detectors/mcp.ts +++ b/src/detectors/mcp.ts @@ -20,6 +20,13 @@ const MCP_SAMPLE_CONFIG_FILENAMES = new Set([ 'mcp_config.json.example' ]); +const MCP_PREFIXED_SAMPLE_CONFIG_FILENAMES = new Set([ + 'example_mcp_config.json', + 'claude_mcp_config.json', + 'cursor_mcp_config.json', + 'vscode_mcp_config.json' +]); + const MCP_EXAMPLE_BASE_FILENAMES = ['.mcp.json', 'mcp_config.json'] as const; const MCP_PLATFORM_EXAMPLE_QUALIFIERS = new Set([ @@ -201,7 +208,9 @@ export function isMcpSampleConfigPath(relativePath: string): boolean { const fileName = segments.at(-1); return fileName - ? MCP_SAMPLE_CONFIG_FILENAMES.has(fileName) || isPlatformSuffixedMcpExampleFileName(fileName) + ? MCP_SAMPLE_CONFIG_FILENAMES.has(fileName) + || MCP_PREFIXED_SAMPLE_CONFIG_FILENAMES.has(fileName) + || isPlatformSuffixedMcpExampleFileName(fileName) : false; } diff --git a/test/action-metadata.test.mjs b/test/action-metadata.test.mjs index df7fd0b..ed205ae 100644 --- a/test/action-metadata.test.mjs +++ b/test/action-metadata.test.mjs @@ -41,7 +41,7 @@ test('public Action install tags match package version', async () => { const version = packageJson.version; const installTagPattern = new RegExp(`Conalh/ScopeTrail@v${version.replaceAll('.', '\\.')}`); - assert.equal(version, '0.1.10'); + assert.equal(version, '0.1.11'); assert.match(readme, installTagPattern); assert.match(pilotGuide, installTagPattern); }); diff --git a/test/fixtures/mcp-prefixed-sample-drift/new/examples/cursor_mcp_config.json b/test/fixtures/mcp-prefixed-sample-drift/new/examples/cursor_mcp_config.json new file mode 100644 index 0000000..a7e0105 --- /dev/null +++ b/test/fixtures/mcp-prefixed-sample-drift/new/examples/cursor_mcp_config.json @@ -0,0 +1,7 @@ +{ + "servers": { + "cursor-docs": { + "url": "https://mcp.example.com/cursor" + } + } +} diff --git a/test/fixtures/mcp-prefixed-sample-drift/new/examples/example_mcp_config.json b/test/fixtures/mcp-prefixed-sample-drift/new/examples/example_mcp_config.json new file mode 100644 index 0000000..6a9685d --- /dev/null +++ b/test/fixtures/mcp-prefixed-sample-drift/new/examples/example_mcp_config.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "copy-risk": { + "command": "npx", + "args": [ + "-y", + "@acme/copy-risk@latest" + ] + } + } +} diff --git a/test/fixtures/mcp-prefixed-sample-drift/old/.gitkeep b/test/fixtures/mcp-prefixed-sample-drift/old/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/test/fixtures/mcp-prefixed-sample-drift/old/.gitkeep @@ -0,0 +1 @@ + diff --git a/test/git-diff.test.mjs b/test/git-diff.test.mjs index 01e441f..43602d8 100644 --- a/test/git-diff.test.mjs +++ b/test/git-diff.test.mjs @@ -190,6 +190,56 @@ test('CLI git diff snapshots platform-suffixed MCP example paths', async () => { } }); +test('CLI git diff snapshots prefixed MCP config example paths', async () => { + const repo = await mkdtemp(join(tmpdir(), 'scopetrail-git-prefixed-sample-')); + try { + await execGit(repo, 'init', '-b', 'main'); + await execGit(repo, 'config', 'user.name', 'ScopeTrail Test'); + await execGit(repo, 'config', 'user.email', 'scopetrail@example.invalid'); + + await writeFile(join(repo, 'README.md'), 'base\n'); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'base'); + const base = await gitStdout(repo, 'rev-parse', 'HEAD'); + + await mkdir(join(repo, 'examples'), { recursive: true }); + await writeFile( + join(repo, 'examples', 'example_mcp_config.json'), + `${JSON.stringify( + { + mcpServers: { + 'copy-risk': { + command: 'npx', + args: ['-y', '@acme/copy-risk@latest'] + } + } + }, + null, + 2 + )}\n` + ); + await execGit(repo, 'add', '.'); + await execGit(repo, 'commit', '-m', 'add prefixed sample mcp config'); + const head = await gitStdout(repo, 'rev-parse', 'HEAD'); + + const { stdout } = await execFileAsync( + process.execPath, + ['dist/index.js', 'diff', '--repo', repo, '--base', base, '--head', head, '--format', 'json'], + { cwd: packageRoot } + ); + const report = JSON.parse(stdout); + + assert.deepEqual( + report.findings.map((finding) => finding.kind), + ['mcp_sample_server_added', 'mcp_sample_unpinned_command'] + ); + assert.equal(report.findings[0].file, 'examples/example_mcp_config.json'); + assert.equal(report.findings.some((finding) => finding.kind === 'mcp_server_added'), false); + } finally { + await rm(repo, { recursive: true, force: true }); + } +}); + async function writeConfig(repo, { mcp, claude }) { await mkdir(join(repo, '.claude'), { recursive: true }); await writeFile(join(repo, '.mcp.json'), `${JSON.stringify(mcp, null, 2)}\n`); diff --git a/test/mcp-drift.test.mjs b/test/mcp-drift.test.mjs index 9d296d1..bc38a8e 100644 --- a/test/mcp-drift.test.mjs +++ b/test/mcp-drift.test.mjs @@ -83,6 +83,16 @@ test('recognizes platform-suffixed MCP examples while ignoring backup files', () assert.equal(isMcpSampleConfigPath('examples/.mcp.json.bak'), false); }); +test('recognizes prefixed MCP config examples while ignoring broad registry and backup names', () => { + assert.equal(isMcpSampleConfigPath('examples/example_mcp_config.json'), true); + assert.equal(isMcpSampleConfigPath('examples/claude_mcp_config.json'), true); + assert.equal(isMcpSampleConfigPath('examples/cursor_mcp_config.json'), true); + assert.equal(isMcpSampleConfigPath('examples/vscode_mcp_config.json'), true); + assert.equal(isMcpSampleConfigPath('examples/registry_mcp_config.json'), false); + assert.equal(isMcpSampleConfigPath('examples/example_mcp_config.json.bak'), false); + assert.equal(isMcpSampleConfigPath('dist/example_mcp_config.json'), false); +}); + test('detects platform-suffixed MCP example drift without treating it as active server drift', async () => { const oldDir = join(testDir, 'fixtures', 'mcp-platform-sample-drift', 'old'); const newDir = join(testDir, 'fixtures', 'mcp-platform-sample-drift', 'new'); @@ -101,3 +111,22 @@ test('detects platform-suffixed MCP example drift without treating it as active ] ); }); + +test('detects prefixed MCP config example drift without treating it as active server drift', async () => { + const oldDir = join(testDir, 'fixtures', 'mcp-prefixed-sample-drift', 'old'); + const newDir = join(testDir, 'fixtures', 'mcp-prefixed-sample-drift', 'new'); + + const findings = await detectMcpDrift(oldDir, newDir); + + assert.equal(findings.some((finding) => finding.kind === 'mcp_server_added'), false); + assert.equal(findings.some((finding) => finding.kind === 'unpinned_mcp_command'), false); + assert.deepEqual( + findings.map((finding) => [finding.file, finding.kind, finding.subject, finding.severity, finding.line]), + [ + ['examples/cursor_mcp_config.json', 'mcp_sample_server_added', 'cursor-docs', 'low', 3], + ['examples/cursor_mcp_config.json', 'mcp_sample_remote_endpoint', 'cursor-docs', 'medium', 4], + ['examples/example_mcp_config.json', 'mcp_sample_server_added', 'copy-risk', 'low', 3], + ['examples/example_mcp_config.json', 'mcp_sample_unpinned_command', 'copy-risk', 'medium', 7] + ] + ); +}); diff --git a/test/public-docs.test.mjs b/test/public-docs.test.mjs index 4e56d09..094ad28 100644 --- a/test/public-docs.test.mjs +++ b/test/public-docs.test.mjs @@ -44,9 +44,12 @@ test('public docs describe active and sample MCP config coverage', async () => { assert.match(readme, /\.mcp\.json\.sample/); assert.match(readme, /\.mcp\.json\.windows\.example/); assert.match(readme, /mcp_config\.json\.example/); + assert.match(readme, /example_mcp_config\.json/); assert.match(trust, /sample\/template\/disabled MCP config files/i); assert.match(trust, /platform-suffixed MCP example files/i); + assert.match(trust, /prefixed MCP config example files/i); assert.match(pilot, /sample\/template\/disabled MCP config findings/i); + assert.match(pilot, /prefixed MCP config examples/i); }); test('adoption checklist defines advisory-first rollout and feedback path', async () => {