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.
diff --git a/pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart b/pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart
index bfacc6c..7ae8ae0 100644
--- a/pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart
+++ b/pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart
@@ -4,6 +4,7 @@
 
 import 'dart:async';
 
+import 'package:collection/collection.dart';
 import 'package:dart_mcp/server.dart';
 import 'package:file/file.dart';
 import 'package:path/path.dart' as p;
@@ -159,7 +160,9 @@
     );
   }
 
-  final root = _findRoot(rootUriString, knownRoots);
+  final root = knownRoots.firstWhereOrNull(
+    (root) => _isUnderRoot(root, rootUriString),
+  );
   if (root == null) {
     return CallToolResult(
       content: [
@@ -195,11 +198,7 @@
   final paths =
       (rootConfig?[ParameterNames.paths] as List?)?.cast<String>() ??
       defaultPaths;
-  final invalidPaths = paths.where((path) {
-    final resolvedPath = rootUri.resolve(path).toString();
-    return rootUriString != resolvedPath &&
-        !p.isWithin(rootUriString, resolvedPath);
-  });
+  final invalidPaths = paths.where((path) => !_isUnderRoot(root, path));
   if (invalidPaths.isNotEmpty) {
     return CallToolResult(
       content: [
@@ -263,17 +262,24 @@
     ),
 };
 
-/// Returns whether or not [rootUri] is an allowed root, either exactly matching
-/// or under on of the [knownRoots].
-Root? _findRoot(String rootUri, List<Root> knownRoots) {
-  for (final root in knownRoots) {
-    final knownRootUri = Uri.parse(root.uri);
-    final resolvedRoot = knownRootUri.resolve(rootUri).toString();
-    if (root.uri == resolvedRoot || p.isWithin(root.uri, resolvedRoot)) {
-      return root;
-    }
+/// Returns whether [uri] is under or exactly equal to [root].
+///
+/// Relative uris will always be under [root] unless they escape it with `../`.
+bool _isUnderRoot(Root root, String uri) {
+  final rootUri = Uri.parse(root.uri);
+  final resolvedUri = rootUri.resolve(uri);
+  // We don't care about queries or fragments, but the scheme/authority must
+  // match.
+  if (rootUri.scheme != resolvedUri.scheme ||
+      rootUri.authority != resolvedUri.authority) {
+    return false;
   }
-  return null;
+  // Canonicalizing the paths handles any `../` segments and also deals with
+  // trailing slashes versus no trailing slashes.
+  final canonicalRootPath = p.canonicalize(rootUri.path);
+  final canonicalUriPath = p.canonicalize(resolvedUri.path);
+  return canonicalRootPath == canonicalUriPath ||
+      canonicalUriPath.startsWith(canonicalRootPath);
 }
 
 /// The schema for the `roots` parameter for any tool that accepts it.
diff --git a/pkgs/dart_mcp_server/pubspec.yaml b/pkgs/dart_mcp_server/pubspec.yaml
index 5b257e4..d27c0e1 100644
--- a/pkgs/dart_mcp_server/pubspec.yaml
+++ b/pkgs/dart_mcp_server/pubspec.yaml
@@ -15,6 +15,7 @@
 dependencies:
   args: ^2.7.0
   async: ^2.13.0
+  collection: ^1.19.1
   dart_mcp: ^0.2.1
   dds_service_extensions: ^2.0.1
   devtools_shared: ^11.2.0
diff --git a/pkgs/dart_mcp_server/test/utils/cli_utils_test.dart b/pkgs/dart_mcp_server/test/utils/cli_utils_test.dart
index ab93e26..075f1d8 100644
--- a/pkgs/dart_mcp_server/test/utils/cli_utils_test.dart
+++ b/pkgs/dart_mcp_server/test/utils/cli_utils_test.dart
@@ -83,6 +83,102 @@
         expect(fileSystem.directory('/bar/baz').existsSync(), true);
       },
     );
+
+    test('can run commands with missing trailing slashes for roots', () async {
+      final result = await runCommandInRoots(
+        CallToolRequest(
+          name: 'foo',
+          arguments: {
+            ParameterNames.roots: [
+              {ParameterNames.root: 'file:///bar'},
+            ],
+          },
+        ),
+        commandForRoot: (_, _, _) => 'testCommand',
+        arguments: ['a', 'b'],
+        commandDescription: '',
+        processManager: processManager,
+        knownRoots: [Root(uri: 'file:///bar/')],
+        fileSystem: fileSystem,
+        sdk: Sdk(),
+      );
+      expect(result.isError, isNot(true));
+      expect(processManager.commandsRan, [
+        equalsCommand((
+          command: ['testCommand', 'a', 'b'],
+          workingDirectory: '/bar',
+        )),
+      ]);
+    });
+
+    test('can run commands with extra trailing slashes for roots', () async {
+      final result = await runCommandInRoots(
+        CallToolRequest(
+          name: 'foo',
+          arguments: {
+            ParameterNames.roots: [
+              {ParameterNames.root: 'file:///bar/'},
+            ],
+          },
+        ),
+        commandForRoot: (_, _, _) => 'testCommand',
+        arguments: ['a', 'b'],
+        commandDescription: '',
+        processManager: processManager,
+        knownRoots: [Root(uri: 'file:///bar')],
+        fileSystem: fileSystem,
+        sdk: Sdk(),
+      );
+      expect(result.isError, isNot(true));
+      expect(processManager.commandsRan, [
+        equalsCommand((
+          command: ['testCommand', 'a', 'b'],
+          workingDirectory: '/bar/',
+        )),
+      ]);
+    });
+
+    test('with paths inside of known roots', () async {
+      final paths = ['file:///foo/', 'file:///foo', './', '.'];
+      final result = await runCommandInRoots(
+        CallToolRequest(
+          name: 'foo',
+          arguments: {
+            ParameterNames.roots: [
+              {ParameterNames.root: 'file:///foo', ParameterNames.paths: paths},
+              {
+                ParameterNames.root: 'file:///foo/',
+                ParameterNames.paths: paths,
+              },
+            ],
+          },
+        ),
+        commandForRoot: (_, _, _) => 'fake',
+        commandDescription: '',
+        processManager: processManager,
+        knownRoots: [Root(uri: 'file:///foo/')],
+        fileSystem: fileSystem,
+        sdk: Sdk(),
+      );
+      expect(
+        result.isError,
+        isNot(true),
+        reason: result.content.map((c) => (c as TextContent).text).join('\n'),
+      );
+      expect(
+        processManager.commandsRan,
+        unorderedEquals([
+          equalsCommand((
+            command: ['fake', ...paths],
+            workingDirectory: '/foo/',
+          )),
+          equalsCommand((
+            command: ['fake', ...paths],
+            workingDirectory: '/foo',
+          )),
+        ]),
+      );
+    });
   });
 
   group('cannot run commands', () {