diff --git a/.github/workflows/interop_2026_07_28.yml b/.github/workflows/interop_2026_07_28.yml index 473b5fb2..a9f7ac15 100644 --- a/.github/workflows/interop_2026_07_28.yml +++ b/.github/workflows/interop_2026_07_28.yml @@ -11,6 +11,7 @@ on: branches: - main paths: + - 'packages/mcp_dart_cli/test/e2e/cli_interop_e2e_test.dart' - 'lib/**' - 'example/server_sse.dart' - 'test/conformance/**' @@ -32,6 +33,7 @@ on: - 'pubspec.lock' pull_request: paths: + - 'packages/mcp_dart_cli/test/e2e/cli_interop_e2e_test.dart' - 'lib/**' - 'example/server_sse.dart' - 'test/conformance/**' @@ -86,6 +88,54 @@ jobs: --tags interop --name "Legacy SSE" + legacy-dependency-compatibility: + name: legacy dependency compatibility (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Dart + uses: dart-lang/setup-dart@6afc89df92d6eb3834022f73cd65adc8cdfcb92d # v1.8.1 + with: + sdk: stable + + - name: Set up Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + + - name: Install TypeScript legacy fixture dependencies + working-directory: test/interop/ts + run: npm ci + + - name: Build TypeScript legacy fixtures + working-directory: test/interop/ts + run: npm run build + + - name: Verify legacy dependency compatibility + working-directory: test/interop/ts + run: npm run test:dependency-compat + + - name: Install CLI dependencies + working-directory: packages/mcp_dart_cli + run: dart pub get + + - name: Verify public CLI filesystem compatibility + working-directory: packages/mcp_dart_cli + env: + CI: 'true' + run: dart test test/e2e/cli_interop_e2e_test.dart --name filesystem + ts-2026-07-28-interop: runs-on: ubuntu-latest diff --git a/packages/mcp_dart_cli/test/e2e/cli_interop_e2e_test.dart b/packages/mcp_dart_cli/test/e2e/cli_interop_e2e_test.dart index 784b94dd..c44e7167 100644 --- a/packages/mcp_dart_cli/test/e2e/cli_interop_e2e_test.dart +++ b/packages/mcp_dart_cli/test/e2e/cli_interop_e2e_test.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:mcp_dart/mcp_dart.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; @@ -205,10 +206,48 @@ void main() { } final root = await Directory.systemTemp.createTemp('mcp_fs_interop_'); - final canonicalRoot = Directory(root.resolveSymbolicLinksSync()); + final parent = Directory(root.resolveSymbolicLinksSync()); + final canonicalRoot = await Directory( + p.join(parent.path, 'allowed'), + ).create(); + final sibling = await Directory( + p.join(parent.path, 'allowed-sibling'), + ).create(); + final outside = await Directory( + p.join(parent.path, 'outside'), + ).create(); final file = File(p.join(canonicalRoot.path, 'hello.txt')); await file.writeAsString('hello from filesystem server\n'); try { + final nested = File( + p.join(canonicalRoot.path, 'nested', 'child.txt'), + ); + await nested.parent.create(); + await nested.writeAsString('nested fixture'); + final hidden = File(p.join(canonicalRoot.path, '.hidden.txt')); + await hidden.writeAsString('hidden fixture'); + final excluded = File( + p.join(canonicalRoot.path, 'excluded', 'secret.txt'), + ); + await excluded.parent.create(); + await excluded.writeAsString('excluded fixture'); + const outsideFileName = 'outside-secret.txt'; + await File( + p.join(sibling.path, outsideFileName), + ).writeAsString('must remain outside the allowed root'); + final deniedPaths = [ + outside.path, + sibling.path, + '${canonicalRoot.path}${p.separator}..${p.separator}outside', + ]; + // Windows symlinks need elevated privileges; the other path-boundary + // checks run on every platform. + if (!Platform.isWindows) { + final escape = Link(p.join(canonicalRoot.path, 'escape')); + await escape.create(sibling.path); + deniedPaths.add(escape.path); + } + final result = await _runCli([ 'inspect-server', '--json', @@ -249,6 +288,111 @@ void main() { (content.first as Map)['text'], contains('hello from filesystem server'), ); + + final searchArguments = { + 'path': canonicalRoot.path, + 'pattern': '**/*.txt', + 'excludePatterns': ['excluded/**'], + }; + final search = await _runCli([ + 'call-tool', + 'search_files', + '--json', + '--json-args', + jsonEncode(searchArguments), + '--', + 'node', + filesystemServer.path, + canonicalRoot.path, + ], workingDirectory: cliDir); + _expectSuccess(search); + final searchJson = + jsonDecode(search.stdout as String) as Map; + expect(searchJson['isError'], isNot(true)); + expect( + _toolText(searchJson).split('\n'), + unorderedEquals([file.path, nested.path, hidden.path]), + ); + + final deniedCall = await _runCli([ + 'call-tool', + 'search_files', + '--json', + '--json-args', + jsonEncode({...searchArguments, 'path': sibling.path}), + '--', + 'node', + filesystemServer.path, + canonicalRoot.path, + ], workingDirectory: cliDir); + expect(deniedCall.exitCode, 70); + final deniedJson = + jsonDecode(deniedCall.stdout as String) as Map; + expect(deniedJson['isError'], isTrue); + expect(_toolText(deniedJson), contains('Access denied')); + expect(_toolText(deniedJson), isNot(contains(outsideFileName))); + + // Reuse one public SDK connection to prove errors do not poison the + // session. The published peer reports these as tool errors, not + // JSON-RPC errors, including malformed tool arguments. + final client = McpClient( + const Implementation(name: 'filesystem-compat', version: '1.0.0'), + options: const McpClientOptions(protocol: McpProtocol.legacy), + ); + try { + await client.connect( + StdioClientTransport( + StdioServerParameters( + command: 'node', + args: [filesystemServer.path, canonicalRoot.path], + stderrMode: ProcessStartMode.normal, + ), + ), + ); + for (final deniedPath in deniedPaths) { + final denied = await client.callTool( + CallToolRequest( + name: 'search_files', + arguments: {...searchArguments, 'path': deniedPath}, + ), + ); + expect(denied.isError, isTrue, reason: deniedPath); + expect( + _toolText(denied.toJson()), + contains('Access denied'), + reason: deniedPath, + ); + expect( + _toolText(denied.toJson()), + isNot(contains(outsideFileName)), + reason: deniedPath, + ); + } + final malformed = await client.callTool( + CallToolRequest( + name: 'search_files', + arguments: {...searchArguments, 'pattern': 42}, + ), + ); + expect(malformed.isError, isTrue); + expect( + _toolText(malformed.toJson()), + contains('Invalid arguments'), + ); + final followUp = await client.callTool( + CallToolRequest( + name: 'search_files', + arguments: searchArguments, + ), + ); + expect(followUp.isError, isFalse); + expect( + _toolText(followUp.toJson()).split('\n'), + unorderedEquals([file.path, nested.path, hidden.path]), + ); + } finally { + await client.close(); + } } finally { await root.delete(recursive: true); } @@ -580,6 +724,11 @@ Future _writeTypeScriptProbeConfig( return probeConfig; } +String _toolText(Map result) { + final content = result['content'] as List; + return (content.single as Map)['text'] as String; +} + Directory _findRepoRoot(Directory start) { var current = start.absolute; while (current.path != current.parent.path) { diff --git a/test/interop/ts/README.md b/test/interop/ts/README.md new file mode 100644 index 00000000..c2d602d5 --- /dev/null +++ b/test/interop/ts/README.md @@ -0,0 +1,51 @@ +# Legacy TypeScript interoperability fixtures + +These private fixtures exercise the initialization-era MCP SDK and published +filesystem server. They are test inputs, not dependencies of the published +Dart SDK or CLI. Keep their versions separate from the modern TypeScript +fixtures in `../ts_2026_07_28/`. + +## Security overrides + +The manifest pins these transitive security fixes: + +- `@hono/node-server 2.1.3` addresses + [GHSA-rmxm-3fg6-px4f](https://github.com/advisories/GHSA-rmxm-3fg6-px4f). +- `glob 14.0.2` addresses + [GHSA-3g3m-2fjr-6pw4](https://github.com/advisories/GHSA-3g3m-2fjr-6pw4). +- `minimatch 11.0.1` addresses + [GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249). + +The glob and minimatch overrides cross their parents' declared major ranges. +They were selected explicitly for this Node 24 fixture; the matching behavior, +filesystem tool calls, and lint file selection must remain covered by tests. +The minimatch upgrade adds the BlueOak-1.0.0-licensed transitive package +`@isaacs/nonrecursive-glob-match`. + +Remove an override only after all affected parents resolve a fixed version +without it and the same compatibility checks pass. Do not silence audit +findings or weaken interoperability expectations to accommodate an update. + +## Verification + +From this directory: + +```bash +npm ci +npm run build +npm run lint +npm run format:check +npm run test:dependency-compat +npm audit +``` + +From `packages/mcp_dart_cli/`, run the public CLI filesystem regressions with +missing fixtures treated as failures: + +```bash +CI=true dart test test/e2e/cli_interop_e2e_test.dart --name filesystem +``` + +Also run the root Dart tests and the tagged legacy interoperability suites. +`npm audit` covers the resolved dependency graph; it is not proof that bundled +upstream source contains no vulnerable third-party code. diff --git a/test/interop/ts/package-lock.json b/test/interop/ts/package-lock.json index 4c409b96..d3727512 100644 --- a/test/interop/ts/package-lock.json +++ b/test/interop/ts/package-lock.json @@ -192,9 +192,9 @@ } }, "node_modules/@hono/node-server": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.11.tgz", - "integrity": "sha512-bjD221KPLoJTWUwso1J6fGKiTXEUFedG/s0visavY4zakFPkeGURMRNly+FhBHs7T8Dz4qHaZIMX9ZoJHSJtKA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz", + "integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==", "license": "MIT", "engines": { "node": ">=20" @@ -269,6 +269,13 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/nonrecursive-glob-match": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz", + "integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/@keyv/bigmap": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", @@ -1603,15 +1610,15 @@ } }, "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-14.0.2.tgz", + "integrity": "sha512-vfMgTR2djMHPUchsJMLpZlYQvtjVL+mHpEr5wPpoCbQs2vr/FBNK+3Gnl9Ara1DSs4Aeg4eG9HiADb5/rkY/ZA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.2.2", + "minimatch": "^11.0.0", "minipass": "^7.1.3", - "path-scurry": "^2.0.2" + "path-scurry": "^3.0.0" }, "engines": { "node": "18 || 20 || >=22" @@ -1882,9 +1889,9 @@ } }, "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -1947,13 +1954,14 @@ } }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.1.tgz", + "integrity": "sha512-t+Vn6DX4wPCxS23y03C0uhF7S1jpi1GfqPrnlEipCJaA2ykiNbMkVlDutZtmvLfRcUN6R8AYVlzTsQrT4RZoLQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "@isaacs/nonrecursive-glob-match": "^1.0.1", + "brace-expansion": "^5.0.12" }, "engines": { "node": "18 || 20 || >=22" @@ -2115,9 +2123,9 @@ } }, "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-3.0.0.tgz", + "integrity": "sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { diff --git a/test/interop/ts/package.json b/test/interop/ts/package.json index 50015e5c..c07c1f97 100644 --- a/test/interop/ts/package.json +++ b/test/interop/ts/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "build": "tsc", + "test:dependency-compat": "node --test dist/dependency_compatibility_test.js", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", "format": "prettier --write src/", @@ -29,8 +30,9 @@ "typescript-eslint": "^8.72.0" }, "overrides": { - "@hono/node-server": "2.0.11", - "glob": "13.0.6" + "@hono/node-server": "2.1.3", + "glob": "14.0.2", + "minimatch": "11.0.1" }, "engines": { "node": ">=24" diff --git a/test/interop/ts/src/dependency_compatibility_test.ts b/test/interop/ts/src/dependency_compatibility_test.ts new file mode 100644 index 00000000..da9df4c3 --- /dev/null +++ b/test/interop/ts/src/dependency_compatibility_test.ts @@ -0,0 +1,178 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readdir } from 'node:fs'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, relative, sep } from 'node:path'; +import { setImmediate } from 'node:timers/promises'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { Glob, glob, globSync } from 'glob'; +import type { FSOption } from 'glob'; +import { Minimatch, minimatch } from 'minimatch'; + +const fixtureRoot = fileURLToPath(new URL('../', import.meta.url)); + +test('glob and minimatch retain file selection and exclusion semantics', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'mcp-glob-compat-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(join(root, 'nested')); + await mkdir(join(root, 'excluded')); + for (const path of [ + 'keep.txt', + '.hidden.txt', + 'nested/child.txt', + 'excluded/no.txt', + ]) { + await writeFile(join(root, path), 'fixture'); + } + const options = { + cwd: root, + dot: true, + nodir: true, + ignore: ['excluded/**'], + }; + const expected = ['.hidden.txt', 'keep.txt', 'nested/child.txt']; + const normalized = (paths: string[]): string[] => + paths.map((path) => path.split(sep).join('/')).sort(); + assert.deepEqual(normalized(await glob('**/*.txt', options)), expected); + assert.deepEqual(normalized(globSync('**/*.txt', options)), expected); + assert.deepEqual( + normalized(await new Glob('**/*.txt', options).walk()), + expected + ); + assert.equal(minimatch('nested/child.txt', '**/*.txt', { dot: true }), true); + assert.equal(minimatch('.hidden.txt', '**/*.txt', { dot: true }), true); + assert.equal(minimatch('.hidden.txt', '**/*.txt'), false); + assert.equal(minimatch('nested/child.md', '**/*.txt', { dot: true }), false); + const matcher = new Minimatch('nested/**/*.txt', { dot: true }); + assert.equal(matcher.match('nested/child.txt'), true); + assert.equal(matcher.match('excluded/child.txt'), false); +}); + +test( + 'glob abort stops traversal after the first directory read', + { timeout: 5000 }, + async (t) => { + const root = await mkdtemp(join(tmpdir(), 'mcp-glob-abort-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(join(root, 'nested')); + await writeFile(join(root, 'nested', 'file.txt'), 'fixture'); + const controller = new AbortController(); + const reason = new Error('dependency compatibility abort'); + let reads = 0; + const fs: FSOption = { + readdir(path, options, callback) { + reads += 1; + if (controller.signal.aborted) { + // Count forbidden work, but never perform filesystem I/O after abort. + queueMicrotask(() => callback(null, [])); + return; + } + readdir(path, options, (error, entries) => { + controller.abort(reason); + callback(error, entries); + }); + }, + }; + await assert.rejects( + glob('**/*.txt', { cwd: root, fs, signal: controller.signal }), + (error: unknown) => error === reason + ); + await setImmediate(); + assert.equal( + reads, + 1, + 'an aborted walk must not request child directories' + ); + } +); + +test( + 'minimatch bounds optimization-level-2 pattern amplification', + { timeout: 10000 }, + () => { + // A separate process provides a hard limit even if synchronous preprocessing + // regresses. Eight transforms are enough to detect missing bounds safely. + const probe = spawnSync( + process.execPath, + [ + '--input-type=module', + '--eval', + `import { Minimatch } from 'minimatch'; +const matcher = new Minimatch('**/../a/b/'.repeat(8) + 'target.txt', { + optimizationLevel: 2, braceExpandMax: 16, +}); +console.log(JSON.stringify({ parts: matcher.globParts.length }));`, + ], + { cwd: fixtureRoot, encoding: 'utf8', timeout: 5000, maxBuffer: 16384 } + ); + assert.ifError(probe.error); + assert.equal(probe.status, 0, probe.stderr); + const result = JSON.parse(probe.stdout) as { parts: number }; + assert.equal(Number.isInteger(result.parts), true); + assert.ok( + result.parts > 0 && result.parts <= 16, + `expanded ${result.parts} patterns` + ); + } +); + +test( + 'ESLint selects TypeScript files and preserves ignored paths', + { timeout: 30000 }, + async (t) => { + const sourceRoot = await mkdtemp( + join(fixtureRoot, 'src', 'dependency-compat-') + ); + const ignoredRoot = await mkdtemp( + join(fixtureRoot, 'dist', '.dependency-compat-') + ); + t.after(async () => { + await rm(sourceRoot, { recursive: true, force: true }); + await rm(ignoredRoot, { recursive: true, force: true }); + }); + const positive = join(sourceRoot, 'positive.ts'); + const negative = join(sourceRoot, 'negative.ts'); + const ignored = join(ignoredRoot, 'ignored.ts'); + await writeFile(positive, 'export const compatibilityValue: number = 1;\n'); + await writeFile( + negative, + 'export function compare(value: number): boolean {\n return value == 1;\n}\n' + ); + await writeFile(ignored, 'export const ignored = ;\n'); + const result = spawnSync( + process.execPath, + [ + join(fixtureRoot, 'node_modules', 'eslint', 'bin', 'eslint.js'), + '--format=json', + '--no-warn-ignored', + '--no-error-on-unmatched-pattern', + `${relative(fixtureRoot, sourceRoot).split(sep).join('/')}/**/*.ts`, + `${relative(fixtureRoot, ignoredRoot).split(sep).join('/')}/**/*.ts`, + ], + { cwd: fixtureRoot, encoding: 'utf8', timeout: 20000, maxBuffer: 65536 } + ); + assert.ifError(result.error); + assert.equal(result.status, 1, result.stderr); + const reports = JSON.parse(result.stdout) as Array<{ + filePath: string; + messages: Array<{ ruleId: string | null }>; + }>; + assert.deepEqual( + reports.map((report) => report.filePath).sort(), + [negative, positive].sort() + ); + assert.deepEqual( + reports.find((report) => report.filePath === positive)?.messages, + [] + ); + assert.ok( + reports + .find((report) => report.filePath === negative) + ?.messages.some((message) => message.ruleId === 'eqeqeq'), + 'the selected negative control must fail the checked-in equality rule' + ); + } +);