diff --git a/lib/src/mcp/mcp_server.dart b/lib/src/mcp/mcp_server.dart index 544ade81e..88eb82dae 100644 --- a/lib/src/mcp/mcp_server.dart +++ b/lib/src/mcp/mcp_server.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:io' show stderr; +import 'dart:io' show Directory, stderr; import 'package:args/command_runner.dart'; import 'package:dart_mcp/server.dart'; @@ -322,11 +322,10 @@ Only one value can be selected. } List _parseTest(Map args) { + // NOTE: 'directory' is intentionally not added here. It is applied as the + // working directory in [_runToolCommand], not as a positional test target. final cliArgs = [if (args['dart'] == true) 'dart', 'test']; - if (args['directory'] != null) { - cliArgs.add(args['directory']! as String); - } if (args['coverage'] == true) { cliArgs.add('--coverage'); } @@ -389,11 +388,10 @@ Only one value can be selected. } List _parsePackagesGet(Map args) { + // NOTE: 'directory' is applied as the working directory in + // [_runToolCommand], not as a positional argument. final cliArgs = ['packages', 'get']; - if (args['directory'] != null) { - cliArgs.add(args['directory']! as String); - } if (args['recursive'] == true) { cliArgs.add('--recursive'); } @@ -408,12 +406,10 @@ Only one value can be selected. } List _parsePackagesCheck(Map args) { + // NOTE: 'directory' is applied as the working directory in + // [_runToolCommand], not as a positional argument. final cliArgs = ['packages', 'check', 'licenses']; - if (args['directory'] != null) { - cliArgs.add(args['directory']! as String); - } - return cliArgs; } @@ -426,13 +422,21 @@ Only one value can be selected. Future _handleTest(CallToolRequest request) async { final args = request.arguments ?? {}; final cliArgs = _parseTest(args); - return _runToolCommand(cliArgs, toolName: 'test'); + return _runToolCommand( + cliArgs, + toolName: 'test', + workingDirectory: args['directory'] as String?, + ); } Future _handlePackagesGet(CallToolRequest request) async { final args = request.arguments ?? {}; final cliArgs = _parsePackagesGet(args); - return _runToolCommand(cliArgs, toolName: 'packages get'); + return _runToolCommand( + cliArgs, + toolName: 'packages get', + workingDirectory: args['directory'] as String?, + ); } Future _handlePackagesCheck(CallToolRequest request) async { @@ -456,7 +460,11 @@ Only one value can be selected. } final cliArgs = _parsePackagesCheck(args); - return _runToolCommand(cliArgs, toolName: 'packages check licenses'); + return _runToolCommand( + cliArgs, + toolName: 'packages check licenses', + workingDirectory: args['directory'] as String?, + ); } /// Runs a CLI command and returns a [CallToolResult] with descriptive @@ -464,10 +472,21 @@ Only one value can be selected. Future _runToolCommand( List args, { required String toolName, + String? workingDirectory, }) async { final commandString = 'very_good ${args.join(' ')}'; + // The underlying CLI commands resolve their target package from + // `Directory.current` (and child processes inherit the process cwd), so a + // requested [workingDirectory] is applied by switching the current + // directory for the duration of the run and restoring it afterwards. + // Relative paths are resolved against the server's current directory. + final previousDirectory = Directory.current; + try { + if (workingDirectory != null) { + Directory.current = workingDirectory; + } final exitCode = await _commandRunner.run(args); if (exitCode == ExitCode.success.code) { @@ -505,6 +524,10 @@ Only one value can be selected. content: [TextContent(text: message)], isError: true, ); + } finally { + if (workingDirectory != null) { + Directory.current = previousDirectory; + } } } } diff --git a/test/src/mcp/mcp_server_test.dart b/test/src/mcp/mcp_server_test.dart index a8630348a..9068d5809 100644 --- a/test/src/mcp/mcp_server_test.dart +++ b/test/src/mcp/mcp_server_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io' as io; import 'package:args/command_runner.dart'; import 'package:dart_mcp/server.dart'; @@ -370,27 +371,36 @@ void main() { ]); }); - test('passes directory as positional argument', () async { + test('does not pass directory as a positional argument', () async { + final tempDir = io.Directory.systemTemp.createTempSync('vgmcp_'); + addTearDown(() => tempDir.deleteSync(recursive: true)); + await sendRequest( CallToolRequest.methodName, _params( - CallToolRequest(name: 'test', arguments: {'directory': 'my_dir'}), + CallToolRequest( + name: 'test', + arguments: {'directory': tempDir.path}, + ), ), ); final capturedArgs = verify(() => mockCommandRunner.run(captureAny())).captured.first as List; - expect(capturedArgs, ['test', 'my_dir']); + expect(capturedArgs, ['test']); }); - test('passes directory as positional argument with dart flag', () async { + test('does not pass directory positionally with dart flag', () async { + final tempDir = io.Directory.systemTemp.createTempSync('vgmcp_'); + addTearDown(() => tempDir.deleteSync(recursive: true)); + await sendRequest( CallToolRequest.methodName, _params( CallToolRequest( name: 'test', - arguments: {'directory': 'my_dir', 'dart': true}, + arguments: {'directory': tempDir.path, 'dart': true}, ), ), ); @@ -398,7 +408,7 @@ void main() { final capturedArgs = verify(() => mockCommandRunner.run(captureAny())).captured.first as List; - expect(capturedArgs, ['dart', 'test', 'my_dir']); + expect(capturedArgs, ['dart', 'test']); }); test('handles command failure', () async { @@ -436,13 +446,16 @@ void main() { }); test('handles all arguments (with split "ignore")', () async { + final tempDir = io.Directory.systemTemp.createTempSync('vgmcp_'); + addTearDown(() => tempDir.deleteSync(recursive: true)); + await sendRequest( CallToolRequest.methodName, _params( CallToolRequest( name: 'packages_get', arguments: { - 'directory': 'my_dir', + 'directory': tempDir.path, 'recursive': true, 'ignore': 'pkg1, pkg2', }, @@ -456,7 +469,6 @@ void main() { expect(capturedArgs, [ 'packages', 'get', - 'my_dir', '--recursive', '--ignore', 'pkg1', @@ -468,12 +480,15 @@ void main() { group('Tool: packages_check_licenses', () { test('handles basic case (licenses=true)', () async { + final tempDir = io.Directory.systemTemp.createTempSync('vgmcp_'); + addTearDown(() => tempDir.deleteSync(recursive: true)); + await sendRequest( CallToolRequest.methodName, _params( CallToolRequest( name: 'packages_check_licenses', - arguments: {'licenses': true, 'directory': 'my_dir'}, + arguments: {'licenses': true, 'directory': tempDir.path}, ), ), ); @@ -481,16 +496,19 @@ void main() { final capturedArgs = verify(() => mockCommandRunner.run(captureAny())).captured.first as List; - expect(capturedArgs, ['packages', 'check', 'licenses', 'my_dir']); + expect(capturedArgs, ['packages', 'check', 'licenses']); }); test('defaults to licenses=true if not provided', () async { + final tempDir = io.Directory.systemTemp.createTempSync('vgmcp_'); + addTearDown(() => tempDir.deleteSync(recursive: true)); + await sendRequest( CallToolRequest.methodName, _params( CallToolRequest( name: 'packages_check_licenses', - arguments: {'directory': 'my_dir'}, + arguments: {'directory': tempDir.path}, ), ), ); @@ -498,7 +516,7 @@ void main() { final capturedArgs = verify(() => mockCommandRunner.run(captureAny())).captured.first as List; - expect(capturedArgs, ['packages', 'check', 'licenses', 'my_dir']); + expect(capturedArgs, ['packages', 'check', 'licenses']); }); test('returns error if licenses=false', () async { @@ -577,5 +595,70 @@ void main() { expect(text, contains('Command: very_good')); }); }); + + group('directory (working directory)', () { + late io.Directory tempDir; + late String originalCwd; + + setUp(() { + originalCwd = io.Directory.current.path; + tempDir = io.Directory.systemTemp.createTempSync('vgmcp_cwd_'); + addTearDown(() { + // Always restore the cwd so a failure cannot leak into other tests. + io.Directory.current = originalCwd; + if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); + }); + }); + + for (final toolName in const [ + 'test', + 'packages_get', + 'packages_check_licenses', + ]) { + test('"$toolName" runs in the requested directory', () async { + String? cwdDuringRun; + when(() => mockCommandRunner.run(any())).thenAnswer((_) async { + cwdDuringRun = io.Directory.current.resolveSymbolicLinksSync(); + return ExitCode.success.code; + }); + + final response = await sendRequest( + CallToolRequest.methodName, + _params( + CallToolRequest( + name: toolName, + arguments: {'directory': tempDir.path}, + ), + ), + ); + + final result = CallToolResult.fromMap( + response['result'] as Map, + ); + expect(result.isError, isFalse); + expect(cwdDuringRun, tempDir.resolveSymbolicLinksSync()); + // The working directory is restored after the command completes. + expect(io.Directory.current.path, originalCwd); + }); + } + + test('returns an error when the directory does not exist', () async { + final missing = '${tempDir.path}/does-not-exist'; + + final response = await sendRequest( + CallToolRequest.methodName, + _params( + CallToolRequest(name: 'test', arguments: {'directory': missing}), + ), + ); + + final result = CallToolResult.fromMap( + response['result'] as Map, + ); + expect(result.isError, isTrue); + // The cwd is left unchanged when switching to it fails. + expect(io.Directory.current.path, originalCwd); + }); + }); }); }