Skip to content

Commit 16799ee

Browse files
authored
Be more flexible about roots by comparing canonicalized paths (#148)
Instead of using isWithin and also checking for exact equality, we compare the scheme/authority, and then compare the canonicalized paths.
1 parent cf7d67f commit 16799ee

File tree

3 files changed

+119
-16
lines changed

3 files changed

+119
-16
lines changed

pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import 'dart:async';
66

7+
import 'package:collection/collection.dart';
78
import 'package:dart_mcp/server.dart';
89
import 'package:file/file.dart';
910
import 'package:path/path.dart' as p;
@@ -159,7 +160,9 @@ Future<CallToolResult> runCommandInRoot(
159160
);
160161
}
161162

162-
final root = _findRoot(rootUriString, knownRoots);
163+
final root = knownRoots.firstWhereOrNull(
164+
(root) => _isUnderRoot(root, rootUriString),
165+
);
163166
if (root == null) {
164167
return CallToolResult(
165168
content: [
@@ -195,11 +198,7 @@ Future<CallToolResult> runCommandInRoot(
195198
final paths =
196199
(rootConfig?[ParameterNames.paths] as List?)?.cast<String>() ??
197200
defaultPaths;
198-
final invalidPaths = paths.where((path) {
199-
final resolvedPath = rootUri.resolve(path).toString();
200-
return rootUriString != resolvedPath &&
201-
!p.isWithin(rootUriString, resolvedPath);
202-
});
201+
final invalidPaths = paths.where((path) => !_isUnderRoot(root, path));
203202
if (invalidPaths.isNotEmpty) {
204203
return CallToolResult(
205204
content: [
@@ -263,17 +262,24 @@ Future<String> defaultCommandForRoot(
263262
),
264263
};
265264

266-
/// Returns whether or not [rootUri] is an allowed root, either exactly matching
267-
/// or under on of the [knownRoots].
268-
Root? _findRoot(String rootUri, List<Root> knownRoots) {
269-
for (final root in knownRoots) {
270-
final knownRootUri = Uri.parse(root.uri);
271-
final resolvedRoot = knownRootUri.resolve(rootUri).toString();
272-
if (root.uri == resolvedRoot || p.isWithin(root.uri, resolvedRoot)) {
273-
return root;
274-
}
265+
/// Returns whether [uri] is under or exactly equal to [root].
266+
///
267+
/// Relative uris will always be under [root] unless they escape it with `../`.
268+
bool _isUnderRoot(Root root, String uri) {
269+
final rootUri = Uri.parse(root.uri);
270+
final resolvedUri = rootUri.resolve(uri);
271+
// We don't care about queries or fragments, but the scheme/authority must
272+
// match.
273+
if (rootUri.scheme != resolvedUri.scheme ||
274+
rootUri.authority != resolvedUri.authority) {
275+
return false;
275276
}
276-
return null;
277+
// Canonicalizing the paths handles any `../` segments and also deals with
278+
// trailing slashes versus no trailing slashes.
279+
final canonicalRootPath = p.canonicalize(rootUri.path);
280+
final canonicalUriPath = p.canonicalize(resolvedUri.path);
281+
return canonicalRootPath == canonicalUriPath ||
282+
canonicalUriPath.startsWith(canonicalRootPath);
277283
}
278284

279285
/// The schema for the `roots` parameter for any tool that accepts it.

pkgs/dart_mcp_server/pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ executables:
1515
dependencies:
1616
args: ^2.7.0
1717
async: ^2.13.0
18+
collection: ^1.19.1
1819
dart_mcp: ^0.2.1
1920
dds_service_extensions: ^2.0.1
2021
devtools_shared: ^11.2.0

pkgs/dart_mcp_server/test/utils/cli_utils_test.dart

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,102 @@ void main() {
8383
expect(fileSystem.directory('/bar/baz').existsSync(), true);
8484
},
8585
);
86+
87+
test('can run commands with missing trailing slashes for roots', () async {
88+
final result = await runCommandInRoots(
89+
CallToolRequest(
90+
name: 'foo',
91+
arguments: {
92+
ParameterNames.roots: [
93+
{ParameterNames.root: 'file:///bar'},
94+
],
95+
},
96+
),
97+
commandForRoot: (_, _, _) => 'testCommand',
98+
arguments: ['a', 'b'],
99+
commandDescription: '',
100+
processManager: processManager,
101+
knownRoots: [Root(uri: 'file:///bar/')],
102+
fileSystem: fileSystem,
103+
sdk: Sdk(),
104+
);
105+
expect(result.isError, isNot(true));
106+
expect(processManager.commandsRan, [
107+
equalsCommand((
108+
command: ['testCommand', 'a', 'b'],
109+
workingDirectory: '/bar',
110+
)),
111+
]);
112+
});
113+
114+
test('can run commands with extra trailing slashes for roots', () async {
115+
final result = await runCommandInRoots(
116+
CallToolRequest(
117+
name: 'foo',
118+
arguments: {
119+
ParameterNames.roots: [
120+
{ParameterNames.root: 'file:///bar/'},
121+
],
122+
},
123+
),
124+
commandForRoot: (_, _, _) => 'testCommand',
125+
arguments: ['a', 'b'],
126+
commandDescription: '',
127+
processManager: processManager,
128+
knownRoots: [Root(uri: 'file:///bar')],
129+
fileSystem: fileSystem,
130+
sdk: Sdk(),
131+
);
132+
expect(result.isError, isNot(true));
133+
expect(processManager.commandsRan, [
134+
equalsCommand((
135+
command: ['testCommand', 'a', 'b'],
136+
workingDirectory: '/bar/',
137+
)),
138+
]);
139+
});
140+
141+
test('with paths inside of known roots', () async {
142+
final paths = ['file:///foo/', 'file:///foo', './', '.'];
143+
final result = await runCommandInRoots(
144+
CallToolRequest(
145+
name: 'foo',
146+
arguments: {
147+
ParameterNames.roots: [
148+
{ParameterNames.root: 'file:///foo', ParameterNames.paths: paths},
149+
{
150+
ParameterNames.root: 'file:///foo/',
151+
ParameterNames.paths: paths,
152+
},
153+
],
154+
},
155+
),
156+
commandForRoot: (_, _, _) => 'fake',
157+
commandDescription: '',
158+
processManager: processManager,
159+
knownRoots: [Root(uri: 'file:///foo/')],
160+
fileSystem: fileSystem,
161+
sdk: Sdk(),
162+
);
163+
expect(
164+
result.isError,
165+
isNot(true),
166+
reason: result.content.map((c) => (c as TextContent).text).join('\n'),
167+
);
168+
expect(
169+
processManager.commandsRan,
170+
unorderedEquals([
171+
equalsCommand((
172+
command: ['fake', ...paths],
173+
workingDirectory: '/foo/',
174+
)),
175+
equalsCommand((
176+
command: ['fake', ...paths],
177+
workingDirectory: '/foo',
178+
)),
179+
]),
180+
);
181+
});
86182
});
87183

88184
group('cannot run commands', () {

0 commit comments

Comments
 (0)