Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
50 changes: 50 additions & 0 deletions .github/workflows/interop_2026_07_28.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**'
Expand All @@ -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/**'
Expand Down Expand Up @@ -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

Expand Down
151 changes: 150 additions & 1 deletion packages/mcp_dart_cli/test/e2e/cli_interop_e2e_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -249,6 +288,111 @@ void main() {
(content.first as Map<String, dynamic>)['text'],
contains('hello from filesystem server'),
);

final searchArguments = <String, dynamic>{
'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<String, dynamic>;
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<String, dynamic>;
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);
}
Expand Down Expand Up @@ -580,6 +724,11 @@ Future<File> _writeTypeScriptProbeConfig(
return probeConfig;
}

String _toolText(Map<String, dynamic> result) {
final content = result['content'] as List<dynamic>;
return (content.single as Map<String, dynamic>)['text'] as String;
}

Directory _findRepoRoot(Directory start) {
var current = start.absolute;
while (current.path != current.parent.path) {
Expand Down
51 changes: 51 additions & 0 deletions test/interop/ts/README.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 26 additions & 18 deletions test/interop/ts/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading