From 6cca9683fe03f4c19efc6708df76b56f5e0fea1e Mon Sep 17 00:00:00 2001 From: Jake Macdonald Date: Thu, 13 Nov 2025 21:38:19 +0000 Subject: [PATCH 1/6] add support for reading package URIs to the MCP server --- pkgs/dart_mcp/CHANGELOG.md | 9 + pkgs/dart_mcp/lib/src/api/api.dart | 5 +- pkgs/dart_mcp/pubspec.yaml | 2 +- pkgs/dart_mcp/test/api/tools_test.dart | 69 ++++++ pkgs/dart_mcp_server/CHANGELOG.md | 2 + .../lib/src/mixins/package_uri_reader.dart | 200 ++++++++++++++++++ pkgs/dart_mcp_server/lib/src/server.dart | 4 +- .../lib/src/utils/analytics.dart | 1 + .../lib/src/utils/cli_utils.dart | 10 +- pkgs/dart_mcp_server/pubspec.yaml | 4 +- .../test/tools/package_uri_reader_test.dart | 125 +++++++++++ .../counter_app/lib/images/add_to_vs_code.png | Bin 0 -> 3135 bytes 12 files changed, 421 insertions(+), 10 deletions(-) create mode 100644 pkgs/dart_mcp_server/lib/src/mixins/package_uri_reader.dart create mode 100644 pkgs/dart_mcp_server/test/tools/package_uri_reader_test.dart create mode 100644 pkgs/dart_mcp_server/test_fixtures/counter_app/lib/images/add_to_vs_code.png diff --git a/pkgs/dart_mcp/CHANGELOG.md b/pkgs/dart_mcp/CHANGELOG.md index d806baf2..2ab9f92e 100644 --- a/pkgs/dart_mcp/CHANGELOG.md +++ b/pkgs/dart_mcp/CHANGELOG.md @@ -1,3 +1,12 @@ +## 0.4.1-wip + +- Fix the `resource` parameter type on `EmbeddedResource` to be + `ResourceContents` instead of `Contents`. + - **Note**: This is technically breaking but the previous API would not have + been possible to use in a functional manner, so it is assumed that it had + no usage previously. +- Fix the `type` getter on `EmbeddedResource` to read the actual type field. + ## 0.4.0 - Update the tool calling example to include progress notifications. diff --git a/pkgs/dart_mcp/lib/src/api/api.dart b/pkgs/dart_mcp/lib/src/api/api.dart index 8e0b6e2a..52fbadd9 100644 --- a/pkgs/dart_mcp/lib/src/api/api.dart +++ b/pkgs/dart_mcp/lib/src/api/api.dart @@ -448,7 +448,7 @@ extension type EmbeddedResource.fromMap(Map _value) static const expectedType = 'resource'; factory EmbeddedResource({ - required Content resource, + required ResourceContents resource, Annotations? annotations, Meta? meta, }) => EmbeddedResource.fromMap({ @@ -459,7 +459,7 @@ extension type EmbeddedResource.fromMap(Map _value) }); String get type { - final type = _value['resource'] as String; + final type = _value['type'] as String; assert(type == expectedType); return type; } @@ -467,6 +467,7 @@ extension type EmbeddedResource.fromMap(Map _value) /// Either [TextResourceContents] or [BlobResourceContents]. ResourceContents get resource => _value['resource'] as ResourceContents; + @Deprecated('Use `.resource.mimeType`.') String? get mimeType => _value['mimeType'] as String?; } diff --git a/pkgs/dart_mcp/pubspec.yaml b/pkgs/dart_mcp/pubspec.yaml index f85b6c69..44d5a32c 100644 --- a/pkgs/dart_mcp/pubspec.yaml +++ b/pkgs/dart_mcp/pubspec.yaml @@ -1,5 +1,5 @@ name: dart_mcp -version: 0.4.0 +version: 0.4.1-wip description: A package for making MCP servers and clients. repository: https://github.com/dart-lang/ai/tree/main/pkgs/dart_mcp issue_tracker: https://github.com/dart-lang/ai/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Adart_mcp diff --git a/pkgs/dart_mcp/test/api/tools_test.dart b/pkgs/dart_mcp/test/api/tools_test.dart index e40f2d94..729c2b2f 100644 --- a/pkgs/dart_mcp/test/api/tools_test.dart +++ b/pkgs/dart_mcp/test/api/tools_test.dart @@ -5,6 +5,7 @@ // ignore_for_file: lines_longer_than_80_chars import 'dart:async'; +import 'dart:convert'; import 'package:dart_mcp/server.dart'; import 'package:test/test.dart'; @@ -1966,6 +1967,74 @@ void main() { final result = await serverConnection.callTool(request); expect(result.structuredContent, {'bar': 'baz'}); }); + + test('can return embedded resources', () async { + final environment = TestEnvironment( + TestMCPClient(), + (channel) => TestMCPServerWithTools( + channel, + tools: [Tool(name: 'foo', inputSchema: ObjectSchema())], + toolHandlers: { + 'foo': (request) { + return CallToolResult( + content: [ + EmbeddedResource( + resource: TextResourceContents( + uri: 'file:///my_resource', + text: 'Really awesome text', + ), + ), + EmbeddedResource( + resource: BlobResourceContents( + uri: 'file:///my_resource', + blob: base64Encode([1, 2, 3, 4, 5, 6, 7, 8]), + ), + ), + ], + ); + }, + }, + ), + ); + final serverConnection = environment.serverConnection; + await serverConnection.initialize( + InitializeRequest( + protocolVersion: ProtocolVersion.latestSupported, + capabilities: environment.client.capabilities, + clientInfo: environment.client.implementation, + ), + ); + final request = CallToolRequest(name: 'foo', arguments: {}); + final result = await serverConnection.callTool(request); + expect(result.content, hasLength(2)); + expect( + result.content, + containsAll([ + isA() + .having((r) => r.type, 'type', EmbeddedResource.expectedType) + .having( + (r) => r.resource, + 'resource', + isA().having( + (r) => r.text, + 'text', + 'Really awesome text', + ), + ), + isA() + .having((r) => r.type, 'type', 'resource') + .having( + (r) => r.resource, + 'resource', + isA().having( + (r) => r.blob, + 'blob', + base64Encode([1, 2, 3, 4, 5, 6, 7, 8]), + ), + ), + ]), + ); + }); }); } diff --git a/pkgs/dart_mcp_server/CHANGELOG.md b/pkgs/dart_mcp_server/CHANGELOG.md index 794d99ff..918c25d6 100644 --- a/pkgs/dart_mcp_server/CHANGELOG.md +++ b/pkgs/dart_mcp_server/CHANGELOG.md @@ -6,6 +6,8 @@ - Fix analyze tool handling of invalid roots. - Fix erroneous SDK version error messages when connecting to a VM Service instead of DTD URI. +- Add a tool for reading package: URIs. Results are returned as embedded + resources or resource links (when reading a directory). # 0.1.1 (Dart SDK 3.10.0) diff --git a/pkgs/dart_mcp_server/lib/src/mixins/package_uri_reader.dart b/pkgs/dart_mcp_server/lib/src/mixins/package_uri_reader.dart new file mode 100644 index 00000000..fca903f4 --- /dev/null +++ b/pkgs/dart_mcp_server/lib/src/mixins/package_uri_reader.dart @@ -0,0 +1,200 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:dart_mcp/server.dart'; +import 'package:file/file.dart'; +import 'package:meta/meta.dart'; +import 'package:mime/mime.dart'; +import 'package:package_config/package_config.dart'; +import 'package:path/path.dart' as p; + +import '../utils/analytics.dart'; +import '../utils/cli_utils.dart'; +import '../utils/constants.dart'; +import '../utils/file_system.dart'; + +/// Adds a tool for reading package URIs to an MCP server. +base mixin PackageUriSupport on ToolsSupport, RootsTrackingSupport + implements FileSystemSupport { + @override + FutureOr initialize(InitializeRequest request) { + registerTool(readPackageUris, _readPackageUris); + return super.initialize(request); + } + + Future _readPackageUris(CallToolRequest request) async { + // Note that these are already validated based on the inputSchema, so these + // casts are safe. + final args = request.arguments!; + final validated = validateRootConfig( + args, + fileSystem: fileSystem, + knownRoots: await roots, + ); + if (validated.errorResult case final error?) { + return error; + } + // The root is always non-null if there is no error present. + final root = validated.root!; + + // Note that we intentionally do not cache this, because the work to deal + // with invalidating it would likely be more expensive than just + // re-discovering it. + final packageConfig = await findPackageConfig( + fileSystem.directory(Uri.parse(root.uri)), + ); + if (packageConfig == null) { + return _noPackageConfigFound(root); + } + + final resultContent = []; + for (final uri in (args[ParameterNames.uris] as List).cast()) { + await for (final content in _readPackageUri( + Uri.parse(uri), + packageConfig, + )) { + resultContent.add(content); + } + } + + return CallToolResult(content: resultContent); + } + + Stream _readPackageUri(Uri uri, PackageConfig packageConfig) async* { + if (uri.scheme != 'package') { + yield TextContent(text: 'The URI "$uri" was not a "package:" URI.'); + return; + } + final packageName = uri.pathSegments.first; + final path = p.url.joinAll(uri.pathSegments.skip(1)); + final package = packageConfig.packages.firstWhereOrNull( + (package) => package.name == packageName, + ); + if (package == null) { + yield TextContent( + text: + 'The package "$packageName" was not found in your package config, ' + 'make sure it is listed in your dependencies, or use `pub add` to ' + 'add it.', + ); + return; + } + + if (package.root.scheme != 'file') { + // We expect all package roots to be file URIs. + yield Content.text( + text: + 'Unexpected root URI for package $packageName ' + '"${package.root.scheme}", only "file" schemes are supported', + ); + return; + } + + final packageRoot = Root(uri: package.root.toString()); + final resolvedUri = package.packageUriRoot.resolve(path); + if (!isUnderRoot(packageRoot, resolvedUri.toString(), fileSystem)) { + yield TextContent( + text: 'The uri "$uri" attempted to escape it\'s package root.', + ); + return; + } + + // Checks complete, actually read the file. + if (await fileSystem.isDirectory(resolvedUri.path)) { + final dir = fileSystem.directory(resolvedUri.path); + yield Content.text(text: '## Directory "$uri":'); + await for (final entry in dir.list(followLinks: false)) { + yield ResourceLink( + name: p.basename(resolvedUri.path), + description: entry is Directory ? 'A directory' : 'A file', + uri: packageConfig.toPackageUri(entry.uri)!.toString(), + mimeType: lookupMimeType(entry.path) ?? '', + ); + } + } else if (await fileSystem.isLink(resolvedUri.path)) { + // We are only returning a reference to the target, so it is ok to not + // check the path. The agent may have the permissions to read the linked + // path on its own, even if it is outside of the package root. + var targetUri = resolvedUri.resolve( + await fileSystem.link(resolvedUri.path).target(), + ); + // If we can represent it as a package URI, do so. + final asPackageUri = packageConfig.toPackageUri(targetUri); + if (asPackageUri != null) { + targetUri = asPackageUri; + } + yield ResourceLink( + name: p.basename(targetUri.path), + description: 'Target of symlink at $uri', + uri: targetUri.toString(), + mimeType: lookupMimeType(targetUri.path) ?? '', + ); + } else { + final file = fileSystem.file(resolvedUri.path); + if (!(await file.exists())) { + yield TextContent(text: 'File not found: ${file.uri}'); + return; + } + + final mimeType = lookupMimeType(resolvedUri.path) ?? ''; + final resourceUri = packageConfig.toPackageUri(resolvedUri)!.toString(); + // Attempt to treat it as a utf8 String first, if that fails then just + // return it as bytes. + try { + yield Content.embeddedResource( + resource: TextResourceContents( + uri: resourceUri, + text: await file.readAsString(), + mimeType: mimeType, + ), + ); + } catch (_) { + yield Content.embeddedResource( + resource: BlobResourceContents( + uri: resourceUri, + mimeType: mimeType, + blob: base64Encode(await file.readAsBytes()), + ), + ); + } + } + } + + CallToolResult _noPackageConfigFound(Root root) => CallToolResult( + isError: true, + content: [ + TextContent( + text: + 'No package config found for root ${root.uri}. Have you ran `pub ' + 'get` in this project?', + ), + ], + )..failureReason = CallToolFailureReason.noPackageConfigFound; + + @visibleForTesting + static final readPackageUris = Tool( + name: 'read_package_uris', + description: + 'Reads "package" scheme URIs which represent paths under the lib ' + 'directory of Dart package dependencies. Package uris are always ' + 'relative, and the first segment is the package name. For example, the ' + 'URI "package:test/test.dart" represents the path "lib/test.dart" under ' + 'the "test" package. This API supports both reading files and listing ' + 'directories.', + inputSchema: Schema.object( + properties: { + ParameterNames.uris: Schema.list( + description: 'All the package URIs to read.', + items: Schema.string(), + ), + ParameterNames.root: rootSchema, + }, + required: [ParameterNames.uris, ParameterNames.root], + ), + ); +} diff --git a/pkgs/dart_mcp_server/lib/src/server.dart b/pkgs/dart_mcp_server/lib/src/server.dart index 4b1df0ae..d9e0220c 100644 --- a/pkgs/dart_mcp_server/lib/src/server.dart +++ b/pkgs/dart_mcp_server/lib/src/server.dart @@ -20,6 +20,7 @@ import 'mixins/analyzer.dart'; import 'mixins/dash_cli.dart'; import 'mixins/dtd.dart'; import 'mixins/flutter_launcher.dart'; +import 'mixins/package_uri_reader.dart'; import 'mixins/prompts.dart'; import 'mixins/pub.dart'; import 'mixins/pub_dev_search.dart'; @@ -45,7 +46,8 @@ final class DartMCPServer extends MCPServer DartToolingDaemonSupport, FlutterLauncherSupport, PromptsSupport, - DashPrompts + DashPrompts, + PackageUriSupport implements AnalyticsSupport, ProcessManagerSupport, diff --git a/pkgs/dart_mcp_server/lib/src/utils/analytics.dart b/pkgs/dart_mcp_server/lib/src/utils/analytics.dart index 1bc74a38..f5f3071a 100644 --- a/pkgs/dart_mcp_server/lib/src/utils/analytics.dart +++ b/pkgs/dart_mcp_server/lib/src/utils/analytics.dart @@ -127,6 +127,7 @@ enum CallToolFailureReason { invalidRootPath, invalidRootScheme, noActiveDebugSession, + noPackageConfigFound, noRootGiven, noRootsSet, noSuchCommand, 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 2410fc74..7f9a7e8d 100644 --- a/pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart +++ b/pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart @@ -165,7 +165,7 @@ Future runCommandInRoot( } final root = knownRoots.firstWhereOrNull( - (root) => _isUnderRoot(root, rootUriString, fileSystem), + (root) => isUnderRoot(root, rootUriString, fileSystem), ); if (root == null) { return CallToolResult( @@ -203,7 +203,7 @@ Future runCommandInRoot( (rootConfig?[ParameterNames.paths] as List?)?.cast() ?? defaultPaths; final invalidPaths = paths.where( - (path) => !_isUnderRoot(root, path, fileSystem), + (path) => !isUnderRoot(root, path, fileSystem), ); if (invalidPaths.isNotEmpty) { return CallToolResult( @@ -306,7 +306,7 @@ validateRootConfig( } final knownRoot = knownRoots.firstWhereOrNull( - (root) => _isUnderRoot(root, rootUriString, fileSystem), + (root) => isUnderRoot(root, rootUriString, fileSystem), ); if (knownRoot == null) { return ( @@ -331,7 +331,7 @@ validateRootConfig( defaultPaths; if (paths != null) { final invalidPaths = paths.where( - (path) => !_isUnderRoot(root, path, fileSystem), + (path) => !isUnderRoot(root, path, fileSystem), ); if (invalidPaths.isNotEmpty) { return ( @@ -374,7 +374,7 @@ Future defaultCommandForRoot( /// 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, FileSystem fileSystem) { +bool isUnderRoot(Root root, String uri, FileSystem fileSystem) { // This normalizes the URI to ensure it is treated as a directory (for example // ensures it ends with a trailing slash). final rootUri = fileSystem.directory(Uri.parse(root.uri)).uri; diff --git a/pkgs/dart_mcp_server/pubspec.yaml b/pkgs/dart_mcp_server/pubspec.yaml index 34782245..7d6d828f 100644 --- a/pkgs/dart_mcp_server/pubspec.yaml +++ b/pkgs/dart_mcp_server/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: args: ^2.7.0 async: ^2.13.0 collection: ^1.19.1 - dart_mcp: ^0.4.0 + dart_mcp: ^0.4.1 dds_service_extensions: ^2.0.1 devtools_shared: ^12.0.0 dtd: ^4.0.0 @@ -29,6 +29,8 @@ dependencies: # to cache the correct directory. ref: b0838eac58308fc4e6654ca99eda75b30649c08f meta: ^1.16.0 + mime: ^2.0.0 + package_config: ^2.2.0 path: ^1.9.1 pool: ^1.5.1 process: ^5.0.3 diff --git a/pkgs/dart_mcp_server/test/tools/package_uri_reader_test.dart b/pkgs/dart_mcp_server/test/tools/package_uri_reader_test.dart new file mode 100644 index 00000000..f13799b0 --- /dev/null +++ b/pkgs/dart_mcp_server/test/tools/package_uri_reader_test.dart @@ -0,0 +1,125 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:dart_mcp/client.dart'; +import 'package:dart_mcp_server/src/mixins/package_uri_reader.dart'; +import 'package:dart_mcp_server/src/utils/constants.dart'; +import 'package:test/test.dart'; + +import '../test_harness.dart'; + +void main() { + late TestHarness testHarness; + late Root counterAppRoot; + + Future readUris(List uris) => testHarness.callTool( + CallToolRequest( + name: PackageUriSupport.readPackageUris.name, + arguments: { + ParameterNames.root: counterAppRoot.uri, + ParameterNames.uris: uris, + }, + ), + ); + + setUpAll(() async { + testHarness = await TestHarness.start(inProcess: true); + counterAppRoot = testHarness.rootForPath(counterAppPath); + testHarness.mcpClient.addRoot(counterAppRoot); + }); + + group('$PackageUriSupport', () { + test('can read package: uris for the root package', () async { + final result = await readUris([ + 'package:counter_app/images/add_to_vs_code.png', + 'package:counter_app/main.dart', + 'package:counter_app/', + ]); + expect( + result.content, + containsAll([ + matchesEmbeddedTextResource( + contains('void main('), + 'package:counter_app/main.dart', + ), + matchesEmbeddedBlobResource( + isA(), + 'package:counter_app/images/add_to_vs_code.png', + 'image/png', + ), + matchesResourceLink('package:counter_app/driver_main.dart'), + matchesResourceLink('package:counter_app/main.dart'), + matchesResourceLink('package:counter_app/images/'), + ]), + ); + }); + + test('can read package: uris for other packages', () async { + final result = await readUris(['package:flutter/material.dart']); + expect( + result.content, + containsAll([ + matchesEmbeddedTextResource( + contains('library material;'), + 'package:flutter/material.dart', + ), + ]), + ); + }); + + + }); +} + +TypeMatcher isEmbeddedResource() => + isA().having( + (content) => content.type, + 'type', + equals(EmbeddedResource.expectedType), + ); + +TypeMatcher isTextResource() => + isA().having( + (resource) => resource.isText, + 'isText', + isTrue, + ); + +Matcher matchesEmbeddedTextResource(dynamic contentMatcher, dynamic uri) => + isEmbeddedResource().having( + (content) => content.resource, + 'resource', + isTextResource() + .having((resource) => resource.text, 'text', contentMatcher) + .having((resource) => resource.uri, 'uri', uri), + ); + +TypeMatcher isBlobResource() => + isA().having( + (resource) => resource.isBlob, + 'isBlob', + isTrue, + ); + +Matcher matchesEmbeddedBlobResource( + dynamic contentMatcher, + dynamic uri, + dynamic mimeType, +) => isEmbeddedResource().having( + (content) => content.resource, + 'resource', + isBlobResource() + .having((resource) => resource.blob, 'blob', contentMatcher) + .having((resource) => resource.uri, 'uri', uri) + .having((resource) => resource.mimeType, 'mimeType', mimeType), +); + +TypeMatcher isResourceLink() => isA().having( + (content) => content.type, + 'type', + equals(ResourceLink.expectedType), +); + +Matcher matchesResourceLink(String uri) => + isResourceLink().having((link) => link.uri, 'uri', uri); diff --git a/pkgs/dart_mcp_server/test_fixtures/counter_app/lib/images/add_to_vs_code.png b/pkgs/dart_mcp_server/test_fixtures/counter_app/lib/images/add_to_vs_code.png new file mode 100644 index 0000000000000000000000000000000000000000..d26ab0cea6f593f52c1115ec7eb9527ab63212e3 GIT binary patch literal 3135 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 Date: Thu, 13 Nov 2025 21:53:07 +0000 Subject: [PATCH 2/6] add more tests for error cases --- .../lib/src/mixins/package_uri_reader.dart | 4 +- .../test/tools/package_uri_reader_test.dart | 91 ++++++++++++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/pkgs/dart_mcp_server/lib/src/mixins/package_uri_reader.dart b/pkgs/dart_mcp_server/lib/src/mixins/package_uri_reader.dart index fca903f4..4e6bd913 100644 --- a/pkgs/dart_mcp_server/lib/src/mixins/package_uri_reader.dart +++ b/pkgs/dart_mcp_server/lib/src/mixins/package_uri_reader.dart @@ -28,8 +28,6 @@ base mixin PackageUriSupport on ToolsSupport, RootsTrackingSupport } Future _readPackageUris(CallToolRequest request) async { - // Note that these are already validated based on the inputSchema, so these - // casts are safe. final args = request.arguments!; final validated = validateRootConfig( args, @@ -137,7 +135,7 @@ base mixin PackageUriSupport on ToolsSupport, RootsTrackingSupport } else { final file = fileSystem.file(resolvedUri.path); if (!(await file.exists())) { - yield TextContent(text: 'File not found: ${file.uri}'); + yield TextContent(text: 'File not found: $uri'); return; } diff --git a/pkgs/dart_mcp_server/test/tools/package_uri_reader_test.dart b/pkgs/dart_mcp_server/test/tools/package_uri_reader_test.dart index f13799b0..5265a75c 100644 --- a/pkgs/dart_mcp_server/test/tools/package_uri_reader_test.dart +++ b/pkgs/dart_mcp_server/test/tools/package_uri_reader_test.dart @@ -6,6 +6,7 @@ import 'package:dart_mcp/client.dart'; import 'package:dart_mcp_server/src/mixins/package_uri_reader.dart'; import 'package:dart_mcp_server/src/utils/constants.dart'; import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; import '../test_harness.dart'; @@ -68,10 +69,98 @@ void main() { ); }); - + test('returns an error if no package_config.json is found', () async { + final emptyPackage = d.dir('empty_dir'); + await emptyPackage.create(); + final noPackageConfigAppRoot = testHarness.rootForPath( + emptyPackage.io.path, + ); + testHarness.mcpClient.addRoot(noPackageConfigAppRoot); + final result = await testHarness.callTool( + CallToolRequest( + name: PackageUriSupport.readPackageUris.name, + arguments: { + ParameterNames.root: noPackageConfigAppRoot.uri, + ParameterNames.uris: ['package:foo/bar.dart'], + }, + ), + expectError: true, + ); + expect( + result.content, + contains( + isTextContent( + 'No package config found for root ${noPackageConfigAppRoot.uri}. ' + 'Have you ran `pub get` in this project?', + ), + ), + ); + }); + + test('returns an error for non-package uris', () async { + final result = await readUris(['file:///foo/bar.dart']); + expect( + result.content, + contains( + isTextContent( + 'The URI "file:///foo/bar.dart" was not a "package:" URI.', + ), + ), + ); + }); + + test('returns an error for unknown packages', () async { + final result = await readUris(['package:not_a_real_package/foo.dart']); + expect( + result.content, + contains( + isTextContent( + 'The package "not_a_real_package" was not found in your package config, ' + 'make sure it is listed in your dependencies, or use `pub add` to ' + 'add it.', + ), + ), + ); + }); + + test( + 'returns an error for uris that try to escape the package root', + () async { + final result = await readUris(['package:counter_app/../main.dart']); + expect( + result.content, + contains( + isTextContent( + // This actually comes from package:package_config, so it doesn't + // match the error we would throw. + contains( + 'The package "main.dart" was not found in your package config', + ), + ), + ), + ); + }, + ); + + test('returns an error for files that are not found', () async { + final result = await readUris([ + 'package:counter_app/not_a_real_file.dart', + ]); + expect( + result.content, + contains( + isTextContent( + 'File not found: package:counter_app/not_a_real_file.dart', + ), + ), + ); + }); }); } +TypeMatcher isTextContent(dynamic textMatcher) => + isA().having((c) => c.text, 'text', textMatcher); + TypeMatcher isEmbeddedResource() => isA().having( (content) => content.type, From 3692d9971f23c5047090cc31c5ff85d43fb9635e Mon Sep 17 00:00:00 2001 From: Jake Macdonald Date: Thu, 13 Nov 2025 22:02:52 +0000 Subject: [PATCH 3/6] fix lint issue --- .../dart_mcp_server/test/tools/package_uri_reader_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkgs/dart_mcp_server/test/tools/package_uri_reader_test.dart b/pkgs/dart_mcp_server/test/tools/package_uri_reader_test.dart index 5265a75c..bb3caacc 100644 --- a/pkgs/dart_mcp_server/test/tools/package_uri_reader_test.dart +++ b/pkgs/dart_mcp_server/test/tools/package_uri_reader_test.dart @@ -115,9 +115,9 @@ void main() { result.content, contains( isTextContent( - 'The package "not_a_real_package" was not found in your package config, ' - 'make sure it is listed in your dependencies, or use `pub add` to ' - 'add it.', + 'The package "not_a_real_package" was not found in your package ' + 'config, make sure it is listed in your dependencies, or use ' + '`pub add` to add it.', ), ), ); From 2203d3a4fa27f30bb35988579248f2a4007799ad Mon Sep 17 00:00:00 2001 From: Jake Macdonald Date: Thu, 13 Nov 2025 22:06:20 +0000 Subject: [PATCH 4/6] update README.md --- pkgs/dart_mcp_server/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/pkgs/dart_mcp_server/README.md b/pkgs/dart_mcp_server/README.md index 8431c3e0..6ea2dc97 100644 --- a/pkgs/dart_mcp_server/README.md +++ b/pkgs/dart_mcp_server/README.md @@ -155,6 +155,7 @@ For more information, see the official VS Code documentation for | `list_running_apps` | | Returns the list of running app process IDs and associated DTD URIs for apps started by the launch_app tool. | | `pub` | pub | Runs a pub command for the given project roots, like `dart pub get` or `flutter pub add`. | | `pub_dev_search` | pub.dev search | Searches pub.dev for packages relevant to a given search query. The response will describe each result with its download count, package description, topics, license, and publisher. | +| `read_package_uris` | | Reads "package" scheme URIs which represent paths under the lib directory of Dart package dependencies. Package uris are always relative, and the first segment is the package name. For example, the URI "package:test/test.dart" represents the path "lib/test.dart" under the "test" package. This API supports both reading files and listing directories. | | `remove_roots` | Remove roots | Removes one or more project roots previously added via the add_roots tool. | | `resolve_workspace_symbol` | Project search | Look up a symbol or symbols in all workspaces by name. Can be used to validate that a symbol exists or discover small spelling mistakes, since the search is fuzzy. | | `run_tests` | Run tests | Run Dart or Flutter tests with an agent centric UX. ALWAYS use instead of `dart test` or `flutter test` shell commands. | From 6e70217b30e5a5aaac6ea4a981cc3ace6df13759 Mon Sep 17 00:00:00 2001 From: Jake MacDonald Date: Thu, 13 Nov 2025 14:41:53 -0800 Subject: [PATCH 5/6] Fix windows issues --- .../lib/src/mixins/package_uri_reader.dart | 113 +++++++++--------- 1 file changed, 59 insertions(+), 54 deletions(-) diff --git a/pkgs/dart_mcp_server/lib/src/mixins/package_uri_reader.dart b/pkgs/dart_mcp_server/lib/src/mixins/package_uri_reader.dart index 4e6bd913..21948df7 100644 --- a/pkgs/dart_mcp_server/lib/src/mixins/package_uri_reader.dart +++ b/pkgs/dart_mcp_server/lib/src/mixins/package_uri_reader.dart @@ -102,64 +102,69 @@ base mixin PackageUriSupport on ToolsSupport, RootsTrackingSupport return; } - // Checks complete, actually read the file. - if (await fileSystem.isDirectory(resolvedUri.path)) { - final dir = fileSystem.directory(resolvedUri.path); - yield Content.text(text: '## Directory "$uri":'); - await for (final entry in dir.list(followLinks: false)) { + final osFriendlyPath = p.fromUri(resolvedUri); + final entityType = await fileSystem.type(osFriendlyPath, followLinks: false); + switch (entityType) { + case FileSystemEntityType.directory: + final dir = fileSystem.directory(osFriendlyPath); + yield Content.text(text: '## Directory "$uri":'); + await for (final entry in dir.list(followLinks: false)) { + yield ResourceLink( + name: p.basename(osFriendlyPath), + description: entry is Directory ? 'A directory' : 'A file', + uri: packageConfig.toPackageUri(entry.uri)!.toString(), + mimeType: lookupMimeType(entry.path) ?? '', + ); + } + case FileSystemEntityType.link: + // We are only returning a reference to the target, so it is ok to not + // check the path. The agent may have the permissions to read the linked + // path on its own, even if it is outside of the package root. + var targetUri = resolvedUri.resolve( + await fileSystem.link(osFriendlyPath).target(), + ); + // If we can represent it as a package URI, do so. + final asPackageUri = packageConfig.toPackageUri(targetUri); + if (asPackageUri != null) { + targetUri = asPackageUri; + } yield ResourceLink( - name: p.basename(resolvedUri.path), - description: entry is Directory ? 'A directory' : 'A file', - uri: packageConfig.toPackageUri(entry.uri)!.toString(), - mimeType: lookupMimeType(entry.path) ?? '', + name: p.basename(targetUri.path), + description: 'Target of symlink at $uri', + uri: targetUri.toString(), + mimeType: lookupMimeType(targetUri.path) ?? '', ); - } - } else if (await fileSystem.isLink(resolvedUri.path)) { - // We are only returning a reference to the target, so it is ok to not - // check the path. The agent may have the permissions to read the linked - // path on its own, even if it is outside of the package root. - var targetUri = resolvedUri.resolve( - await fileSystem.link(resolvedUri.path).target(), - ); - // If we can represent it as a package URI, do so. - final asPackageUri = packageConfig.toPackageUri(targetUri); - if (asPackageUri != null) { - targetUri = asPackageUri; - } - yield ResourceLink( - name: p.basename(targetUri.path), - description: 'Target of symlink at $uri', - uri: targetUri.toString(), - mimeType: lookupMimeType(targetUri.path) ?? '', - ); - } else { - final file = fileSystem.file(resolvedUri.path); - if (!(await file.exists())) { - yield TextContent(text: 'File not found: $uri'); - return; - } - - final mimeType = lookupMimeType(resolvedUri.path) ?? ''; - final resourceUri = packageConfig.toPackageUri(resolvedUri)!.toString(); - // Attempt to treat it as a utf8 String first, if that fails then just - // return it as bytes. - try { - yield Content.embeddedResource( - resource: TextResourceContents( - uri: resourceUri, - text: await file.readAsString(), - mimeType: mimeType, - ), + case FileSystemEntityType.file: + final file = fileSystem.file(osFriendlyPath); + final mimeType = lookupMimeType(resolvedUri.path) ?? ''; + final resourceUri = packageConfig.toPackageUri(resolvedUri)!.toString(); + // Attempt to treat it as a utf8 String first, if that fails then just + // return it as bytes. + try { + yield Content.embeddedResource( + resource: TextResourceContents( + uri: resourceUri, + text: await file.readAsString(), + mimeType: mimeType, + ), + ); + } catch (_) { + yield Content.embeddedResource( + resource: BlobResourceContents( + uri: resourceUri, + mimeType: mimeType, + blob: base64Encode(await file.readAsBytes()), + ), + ); + } + case FileSystemEntityType.notFound: + yield TextContent( + text: 'File not found: $uri', ); - } catch (_) { - yield Content.embeddedResource( - resource: BlobResourceContents( - uri: resourceUri, - mimeType: mimeType, - blob: base64Encode(await file.readAsBytes()), - ), + default: + yield TextContent( + text: 'Unsupported file system entity type $entityType', ); - } } } From 51ae5e25ec3074fe8adb7615418a03a86cd187a0 Mon Sep 17 00:00:00 2001 From: Jake Macdonald Date: Thu, 13 Nov 2025 22:44:35 +0000 Subject: [PATCH 6/6] format --- .../lib/src/mixins/package_uri_reader.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pkgs/dart_mcp_server/lib/src/mixins/package_uri_reader.dart b/pkgs/dart_mcp_server/lib/src/mixins/package_uri_reader.dart index 21948df7..0e0fcdc3 100644 --- a/pkgs/dart_mcp_server/lib/src/mixins/package_uri_reader.dart +++ b/pkgs/dart_mcp_server/lib/src/mixins/package_uri_reader.dart @@ -103,7 +103,10 @@ base mixin PackageUriSupport on ToolsSupport, RootsTrackingSupport } final osFriendlyPath = p.fromUri(resolvedUri); - final entityType = await fileSystem.type(osFriendlyPath, followLinks: false); + final entityType = await fileSystem.type( + osFriendlyPath, + followLinks: false, + ); switch (entityType) { case FileSystemEntityType.directory: final dir = fileSystem.directory(osFriendlyPath); @@ -158,9 +161,7 @@ base mixin PackageUriSupport on ToolsSupport, RootsTrackingSupport ); } case FileSystemEntityType.notFound: - yield TextContent( - text: 'File not found: $uri', - ); + yield TextContent(text: 'File not found: $uri'); default: yield TextContent( text: 'Unsupported file system entity type $entityType',