Skip to content

Commit 4b9db06

Browse files
Merge branch 'main' into vgv-ai-bot/issue-1606
2 parents 029c207 + e7ee560 commit 4b9db06

4 files changed

Lines changed: 150 additions & 41 deletions

File tree

lib/src/mcp/mcp_server.dart

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import 'dart:async';
2-
import 'dart:io' show stderr;
2+
import 'dart:io' show Directory, stderr;
33

44
import 'package:args/command_runner.dart';
55
import 'package:dart_mcp/server.dart';
@@ -329,11 +329,10 @@ Only one value can be selected.
329329
}
330330

331331
List<String> _parseTest(Map<String, Object?> args) {
332+
// NOTE: 'directory' is intentionally not added here. It is applied as the
333+
// working directory in [_runToolCommand], not as a positional test target.
332334
final cliArgs = <String>[if (args['dart'] == true) 'dart', 'test'];
333335

334-
if (args['directory'] != null) {
335-
cliArgs.add(args['directory']! as String);
336-
}
337336
if (args['coverage'] == true) {
338337
cliArgs.add('--coverage');
339338
}
@@ -402,11 +401,10 @@ Only one value can be selected.
402401
}
403402

404403
List<String> _parsePackagesGet(Map<String, Object?> args) {
404+
// NOTE: 'directory' is applied as the working directory in
405+
// [_runToolCommand], not as a positional argument.
405406
final cliArgs = <String>['packages', 'get'];
406407

407-
if (args['directory'] != null) {
408-
cliArgs.add(args['directory']! as String);
409-
}
410408
if (args['recursive'] == true) {
411409
cliArgs.add('--recursive');
412410
}
@@ -421,12 +419,10 @@ Only one value can be selected.
421419
}
422420

423421
List<String> _parsePackagesCheck(Map<String, Object?> args) {
422+
// NOTE: 'directory' is applied as the working directory in
423+
// [_runToolCommand], not as a positional argument.
424424
final cliArgs = <String>['packages', 'check', 'licenses'];
425425

426-
if (args['directory'] != null) {
427-
cliArgs.add(args['directory']! as String);
428-
}
429-
430426
return cliArgs;
431427
}
432428

@@ -439,13 +435,21 @@ Only one value can be selected.
439435
Future<CallToolResult> _handleTest(CallToolRequest request) async {
440436
final args = request.arguments ?? {};
441437
final cliArgs = _parseTest(args);
442-
return _runToolCommand(cliArgs, toolName: 'test');
438+
return _runToolCommand(
439+
cliArgs,
440+
toolName: 'test',
441+
workingDirectory: args['directory'] as String?,
442+
);
443443
}
444444

445445
Future<CallToolResult> _handlePackagesGet(CallToolRequest request) async {
446446
final args = request.arguments ?? {};
447447
final cliArgs = _parsePackagesGet(args);
448-
return _runToolCommand(cliArgs, toolName: 'packages get');
448+
return _runToolCommand(
449+
cliArgs,
450+
toolName: 'packages get',
451+
workingDirectory: args['directory'] as String?,
452+
);
449453
}
450454

451455
Future<CallToolResult> _handlePackagesCheck(CallToolRequest request) async {
@@ -469,18 +473,33 @@ Only one value can be selected.
469473
}
470474

471475
final cliArgs = _parsePackagesCheck(args);
472-
return _runToolCommand(cliArgs, toolName: 'packages check licenses');
476+
return _runToolCommand(
477+
cliArgs,
478+
toolName: 'packages check licenses',
479+
workingDirectory: args['directory'] as String?,
480+
);
473481
}
474482

475483
/// Runs a CLI command and returns a [CallToolResult] with descriptive
476484
/// error messages including the command that was run and the exit code.
477485
Future<CallToolResult> _runToolCommand(
478486
List<String> args, {
479487
required String toolName,
488+
String? workingDirectory,
480489
}) async {
481490
final commandString = 'very_good ${args.join(' ')}';
482491

492+
// The underlying CLI commands resolve their target package from
493+
// `Directory.current` (and child processes inherit the process cwd), so a
494+
// requested [workingDirectory] is applied by switching the current
495+
// directory for the duration of the run and restoring it afterwards.
496+
// Relative paths are resolved against the server's current directory.
497+
final previousDirectory = Directory.current;
498+
483499
try {
500+
if (workingDirectory != null) {
501+
Directory.current = workingDirectory;
502+
}
484503
final exitCode = await _commandRunner.run(args);
485504

486505
if (exitCode == ExitCode.success.code) {
@@ -518,6 +537,10 @@ Only one value can be selected.
518537
content: [TextContent(text: message)],
519538
isError: true,
520539
);
540+
} finally {
541+
if (workingDirectory != null) {
542+
Directory.current = previousDirectory;
543+
}
521544
}
522545
}
523546
}

site/package-lock.json

Lines changed: 15 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@
3232
"@docusaurus/tsconfig": "3.10.1",
3333
"@docusaurus/types": "^3.7.0",
3434
"@eslint/js": "^10.0.1",
35-
"@types/react": "^19.2.15",
36-
"eslint": "^10.4.1",
35+
"@types/react": "^19.2.17",
36+
"eslint": "^10.5.0",
3737
"eslint-plugin-jest": "^29.15.2",
3838
"globals": "^17.6.0",
3939
"jest": "^30.4.2",
40-
"prettier": "^3.8.3",
40+
"prettier": "^3.8.4",
4141
"typescript": "^6.0.3"
4242
},
4343
"browserslist": {

test/src/mcp/mcp_server_test.dart

Lines changed: 95 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'dart:async';
22
import 'dart:convert';
3+
import 'dart:io' as io;
34

45
import 'package:args/command_runner.dart';
56
import 'package:dart_mcp/server.dart';
@@ -373,35 +374,44 @@ void main() {
373374
]);
374375
});
375376

376-
test('passes directory as positional argument', () async {
377+
test('does not pass directory as a positional argument', () async {
378+
final tempDir = io.Directory.systemTemp.createTempSync('vgmcp_');
379+
addTearDown(() => tempDir.deleteSync(recursive: true));
380+
377381
await sendRequest(
378382
CallToolRequest.methodName,
379383
_params(
380-
CallToolRequest(name: 'test', arguments: {'directory': 'my_dir'}),
384+
CallToolRequest(
385+
name: 'test',
386+
arguments: {'directory': tempDir.path},
387+
),
381388
),
382389
);
383390

384391
final capturedArgs =
385392
verify(() => mockCommandRunner.run(captureAny())).captured.first
386393
as List<String>;
387-
expect(capturedArgs, ['test', 'my_dir']);
394+
expect(capturedArgs, ['test']);
388395
});
389396

390-
test('passes directory as positional argument with dart flag', () async {
397+
test('does not pass directory positionally with dart flag', () async {
398+
final tempDir = io.Directory.systemTemp.createTempSync('vgmcp_');
399+
addTearDown(() => tempDir.deleteSync(recursive: true));
400+
391401
await sendRequest(
392402
CallToolRequest.methodName,
393403
_params(
394404
CallToolRequest(
395405
name: 'test',
396-
arguments: {'directory': 'my_dir', 'dart': true},
406+
arguments: {'directory': tempDir.path, 'dart': true},
397407
),
398408
),
399409
);
400410

401411
final capturedArgs =
402412
verify(() => mockCommandRunner.run(captureAny())).captured.first
403413
as List<String>;
404-
expect(capturedArgs, ['dart', 'test', 'my_dir']);
414+
expect(capturedArgs, ['dart', 'test']);
405415
});
406416

407417
test('handles command failure', () async {
@@ -456,13 +466,16 @@ void main() {
456466
});
457467

458468
test('handles all arguments (with split "ignore")', () async {
469+
final tempDir = io.Directory.systemTemp.createTempSync('vgmcp_');
470+
addTearDown(() => tempDir.deleteSync(recursive: true));
471+
459472
await sendRequest(
460473
CallToolRequest.methodName,
461474
_params(
462475
CallToolRequest(
463476
name: 'packages_get',
464477
arguments: {
465-
'directory': 'my_dir',
478+
'directory': tempDir.path,
466479
'recursive': true,
467480
'ignore': 'pkg1, pkg2',
468481
},
@@ -476,7 +489,6 @@ void main() {
476489
expect(capturedArgs, [
477490
'packages',
478491
'get',
479-
'my_dir',
480492
'--recursive',
481493
'--ignore',
482494
'pkg1',
@@ -488,37 +500,43 @@ void main() {
488500

489501
group('Tool: packages_check_licenses', () {
490502
test('handles basic case (licenses=true)', () async {
503+
final tempDir = io.Directory.systemTemp.createTempSync('vgmcp_');
504+
addTearDown(() => tempDir.deleteSync(recursive: true));
505+
491506
await sendRequest(
492507
CallToolRequest.methodName,
493508
_params(
494509
CallToolRequest(
495510
name: 'packages_check_licenses',
496-
arguments: {'licenses': true, 'directory': 'my_dir'},
511+
arguments: {'licenses': true, 'directory': tempDir.path},
497512
),
498513
),
499514
);
500515

501516
final capturedArgs =
502517
verify(() => mockCommandRunner.run(captureAny())).captured.first
503518
as List<String>;
504-
expect(capturedArgs, ['packages', 'check', 'licenses', 'my_dir']);
519+
expect(capturedArgs, ['packages', 'check', 'licenses']);
505520
});
506521

507522
test('defaults to licenses=true if not provided', () async {
523+
final tempDir = io.Directory.systemTemp.createTempSync('vgmcp_');
524+
addTearDown(() => tempDir.deleteSync(recursive: true));
525+
508526
await sendRequest(
509527
CallToolRequest.methodName,
510528
_params(
511529
CallToolRequest(
512530
name: 'packages_check_licenses',
513-
arguments: {'directory': 'my_dir'},
531+
arguments: {'directory': tempDir.path},
514532
),
515533
),
516534
);
517535

518536
final capturedArgs =
519537
verify(() => mockCommandRunner.run(captureAny())).captured.first
520538
as List<String>;
521-
expect(capturedArgs, ['packages', 'check', 'licenses', 'my_dir']);
539+
expect(capturedArgs, ['packages', 'check', 'licenses']);
522540
});
523541

524542
test('returns error if licenses=false', () async {
@@ -597,5 +615,70 @@ void main() {
597615
expect(text, contains('Command: very_good'));
598616
});
599617
});
618+
619+
group('directory (working directory)', () {
620+
late io.Directory tempDir;
621+
late String originalCwd;
622+
623+
setUp(() {
624+
originalCwd = io.Directory.current.path;
625+
tempDir = io.Directory.systemTemp.createTempSync('vgmcp_cwd_');
626+
addTearDown(() {
627+
// Always restore the cwd so a failure cannot leak into other tests.
628+
io.Directory.current = originalCwd;
629+
if (tempDir.existsSync()) tempDir.deleteSync(recursive: true);
630+
});
631+
});
632+
633+
for (final toolName in const [
634+
'test',
635+
'packages_get',
636+
'packages_check_licenses',
637+
]) {
638+
test('"$toolName" runs in the requested directory', () async {
639+
String? cwdDuringRun;
640+
when(() => mockCommandRunner.run(any())).thenAnswer((_) async {
641+
cwdDuringRun = io.Directory.current.resolveSymbolicLinksSync();
642+
return ExitCode.success.code;
643+
});
644+
645+
final response = await sendRequest(
646+
CallToolRequest.methodName,
647+
_params(
648+
CallToolRequest(
649+
name: toolName,
650+
arguments: {'directory': tempDir.path},
651+
),
652+
),
653+
);
654+
655+
final result = CallToolResult.fromMap(
656+
response['result'] as Map<String, Object?>,
657+
);
658+
expect(result.isError, isFalse);
659+
expect(cwdDuringRun, tempDir.resolveSymbolicLinksSync());
660+
// The working directory is restored after the command completes.
661+
expect(io.Directory.current.path, originalCwd);
662+
});
663+
}
664+
665+
test('returns an error when the directory does not exist', () async {
666+
final missing = '${tempDir.path}/does-not-exist';
667+
668+
final response = await sendRequest(
669+
CallToolRequest.methodName,
670+
_params(
671+
CallToolRequest(name: 'test', arguments: {'directory': missing}),
672+
),
673+
);
674+
675+
final result = CallToolResult.fromMap(
676+
response['result'] as Map<String, Object?>,
677+
);
678+
expect(result.isError, isTrue);
679+
// The cwd is left unchanged when switching to it fails.
680+
expect(io.Directory.current.path, originalCwd);
681+
});
682+
});
600683
});
601684
}

0 commit comments

Comments
 (0)