Skip to content

Commit 058f8f5

Browse files
authored
Adds a tool for reading package uris (#318)
Closes dart-lang/sdk#61080 This allows reading directories or files all with the same tool, which I think is generally fine and fewer tools is better. Results are returned as resource links or embedded resources. I also ended up fixing a few bugs with embedded resources. cc @gaaclarke
1 parent c1f1656 commit 058f8f5

File tree

13 files changed

+515
-10
lines changed

13 files changed

+515
-10
lines changed

pkgs/dart_mcp/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
## 0.4.1-wip
2+
3+
- Fix the `resource` parameter type on `EmbeddedResource` to be
4+
`ResourceContents` instead of `Contents`.
5+
- **Note**: This is technically breaking but the previous API would not have
6+
been possible to use in a functional manner, so it is assumed that it had
7+
no usage previously.
8+
- Fix the `type` getter on `EmbeddedResource` to read the actual type field.
9+
110
## 0.4.0
211

312
- Update the tool calling example to include progress notifications.

pkgs/dart_mcp/lib/src/api/api.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ extension type EmbeddedResource.fromMap(Map<String, Object?> _value)
448448
static const expectedType = 'resource';
449449

450450
factory EmbeddedResource({
451-
required Content resource,
451+
required ResourceContents resource,
452452
Annotations? annotations,
453453
Meta? meta,
454454
}) => EmbeddedResource.fromMap({
@@ -459,14 +459,15 @@ extension type EmbeddedResource.fromMap(Map<String, Object?> _value)
459459
});
460460

461461
String get type {
462-
final type = _value['resource'] as String;
462+
final type = _value['type'] as String;
463463
assert(type == expectedType);
464464
return type;
465465
}
466466

467467
/// Either [TextResourceContents] or [BlobResourceContents].
468468
ResourceContents get resource => _value['resource'] as ResourceContents;
469469

470+
@Deprecated('Use `.resource.mimeType`.')
470471
String? get mimeType => _value['mimeType'] as String?;
471472
}
472473

pkgs/dart_mcp/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: dart_mcp
2-
version: 0.4.0
2+
version: 0.4.1-wip
33
description: A package for making MCP servers and clients.
44
repository: https://github.com/dart-lang/ai/tree/main/pkgs/dart_mcp
55
issue_tracker: https://github.com/dart-lang/ai/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Adart_mcp

pkgs/dart_mcp/test/api/tools_test.dart

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// ignore_for_file: lines_longer_than_80_chars
66

77
import 'dart:async';
8+
import 'dart:convert';
89

910
import 'package:dart_mcp/server.dart';
1011
import 'package:test/test.dart';
@@ -1966,6 +1967,74 @@ void main() {
19661967
final result = await serverConnection.callTool(request);
19671968
expect(result.structuredContent, {'bar': 'baz'});
19681969
});
1970+
1971+
test('can return embedded resources', () async {
1972+
final environment = TestEnvironment(
1973+
TestMCPClient(),
1974+
(channel) => TestMCPServerWithTools(
1975+
channel,
1976+
tools: [Tool(name: 'foo', inputSchema: ObjectSchema())],
1977+
toolHandlers: {
1978+
'foo': (request) {
1979+
return CallToolResult(
1980+
content: [
1981+
EmbeddedResource(
1982+
resource: TextResourceContents(
1983+
uri: 'file:///my_resource',
1984+
text: 'Really awesome text',
1985+
),
1986+
),
1987+
EmbeddedResource(
1988+
resource: BlobResourceContents(
1989+
uri: 'file:///my_resource',
1990+
blob: base64Encode([1, 2, 3, 4, 5, 6, 7, 8]),
1991+
),
1992+
),
1993+
],
1994+
);
1995+
},
1996+
},
1997+
),
1998+
);
1999+
final serverConnection = environment.serverConnection;
2000+
await serverConnection.initialize(
2001+
InitializeRequest(
2002+
protocolVersion: ProtocolVersion.latestSupported,
2003+
capabilities: environment.client.capabilities,
2004+
clientInfo: environment.client.implementation,
2005+
),
2006+
);
2007+
final request = CallToolRequest(name: 'foo', arguments: {});
2008+
final result = await serverConnection.callTool(request);
2009+
expect(result.content, hasLength(2));
2010+
expect(
2011+
result.content,
2012+
containsAll([
2013+
isA<EmbeddedResource>()
2014+
.having((r) => r.type, 'type', EmbeddedResource.expectedType)
2015+
.having(
2016+
(r) => r.resource,
2017+
'resource',
2018+
isA<TextResourceContents>().having(
2019+
(r) => r.text,
2020+
'text',
2021+
'Really awesome text',
2022+
),
2023+
),
2024+
isA<EmbeddedResource>()
2025+
.having((r) => r.type, 'type', 'resource')
2026+
.having(
2027+
(r) => r.resource,
2028+
'resource',
2029+
isA<BlobResourceContents>().having(
2030+
(r) => r.blob,
2031+
'blob',
2032+
base64Encode([1, 2, 3, 4, 5, 6, 7, 8]),
2033+
),
2034+
),
2035+
]),
2036+
);
2037+
});
19692038
});
19702039
}
19712040

pkgs/dart_mcp_server/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
- Fix analyze tool handling of invalid roots.
77
- Fix erroneous SDK version error messages when connecting to a VM Service
88
instead of DTD URI.
9+
- Add a tool for reading package: URIs. Results are returned as embedded
10+
resources or resource links (when reading a directory).
911

1012
# 0.1.1 (Dart SDK 3.10.0)
1113

pkgs/dart_mcp_server/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ For more information, see the official VS Code documentation for
155155
| `list_running_apps` | | Returns the list of running app process IDs and associated DTD URIs for apps started by the launch_app tool. |
156156
| `pub` | pub | Runs a pub command for the given project roots, like `dart pub get` or `flutter pub add`. |
157157
| `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. |
158+
| `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. |
158159
| `remove_roots` | Remove roots | Removes one or more project roots previously added via the add_roots tool. |
159160
| `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. |
160161
| `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. |
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:async';
6+
import 'dart:convert';
7+
8+
import 'package:collection/collection.dart';
9+
import 'package:dart_mcp/server.dart';
10+
import 'package:file/file.dart';
11+
import 'package:meta/meta.dart';
12+
import 'package:mime/mime.dart';
13+
import 'package:package_config/package_config.dart';
14+
import 'package:path/path.dart' as p;
15+
16+
import '../utils/analytics.dart';
17+
import '../utils/cli_utils.dart';
18+
import '../utils/constants.dart';
19+
import '../utils/file_system.dart';
20+
21+
/// Adds a tool for reading package URIs to an MCP server.
22+
base mixin PackageUriSupport on ToolsSupport, RootsTrackingSupport
23+
implements FileSystemSupport {
24+
@override
25+
FutureOr<InitializeResult> initialize(InitializeRequest request) {
26+
registerTool(readPackageUris, _readPackageUris);
27+
return super.initialize(request);
28+
}
29+
30+
Future<CallToolResult> _readPackageUris(CallToolRequest request) async {
31+
final args = request.arguments!;
32+
final validated = validateRootConfig(
33+
args,
34+
fileSystem: fileSystem,
35+
knownRoots: await roots,
36+
);
37+
if (validated.errorResult case final error?) {
38+
return error;
39+
}
40+
// The root is always non-null if there is no error present.
41+
final root = validated.root!;
42+
43+
// Note that we intentionally do not cache this, because the work to deal
44+
// with invalidating it would likely be more expensive than just
45+
// re-discovering it.
46+
final packageConfig = await findPackageConfig(
47+
fileSystem.directory(Uri.parse(root.uri)),
48+
);
49+
if (packageConfig == null) {
50+
return _noPackageConfigFound(root);
51+
}
52+
53+
final resultContent = <Content>[];
54+
for (final uri in (args[ParameterNames.uris] as List).cast<String>()) {
55+
await for (final content in _readPackageUri(
56+
Uri.parse(uri),
57+
packageConfig,
58+
)) {
59+
resultContent.add(content);
60+
}
61+
}
62+
63+
return CallToolResult(content: resultContent);
64+
}
65+
66+
Stream<Content> _readPackageUri(Uri uri, PackageConfig packageConfig) async* {
67+
if (uri.scheme != 'package') {
68+
yield TextContent(text: 'The URI "$uri" was not a "package:" URI.');
69+
return;
70+
}
71+
final packageName = uri.pathSegments.first;
72+
final path = p.url.joinAll(uri.pathSegments.skip(1));
73+
final package = packageConfig.packages.firstWhereOrNull(
74+
(package) => package.name == packageName,
75+
);
76+
if (package == null) {
77+
yield TextContent(
78+
text:
79+
'The package "$packageName" was not found in your package config, '
80+
'make sure it is listed in your dependencies, or use `pub add` to '
81+
'add it.',
82+
);
83+
return;
84+
}
85+
86+
if (package.root.scheme != 'file') {
87+
// We expect all package roots to be file URIs.
88+
yield Content.text(
89+
text:
90+
'Unexpected root URI for package $packageName '
91+
'"${package.root.scheme}", only "file" schemes are supported',
92+
);
93+
return;
94+
}
95+
96+
final packageRoot = Root(uri: package.root.toString());
97+
final resolvedUri = package.packageUriRoot.resolve(path);
98+
if (!isUnderRoot(packageRoot, resolvedUri.toString(), fileSystem)) {
99+
yield TextContent(
100+
text: 'The uri "$uri" attempted to escape it\'s package root.',
101+
);
102+
return;
103+
}
104+
105+
final osFriendlyPath = p.fromUri(resolvedUri);
106+
final entityType = await fileSystem.type(
107+
osFriendlyPath,
108+
followLinks: false,
109+
);
110+
switch (entityType) {
111+
case FileSystemEntityType.directory:
112+
final dir = fileSystem.directory(osFriendlyPath);
113+
yield Content.text(text: '## Directory "$uri":');
114+
await for (final entry in dir.list(followLinks: false)) {
115+
yield ResourceLink(
116+
name: p.basename(osFriendlyPath),
117+
description: entry is Directory ? 'A directory' : 'A file',
118+
uri: packageConfig.toPackageUri(entry.uri)!.toString(),
119+
mimeType: lookupMimeType(entry.path) ?? '',
120+
);
121+
}
122+
case FileSystemEntityType.link:
123+
// We are only returning a reference to the target, so it is ok to not
124+
// check the path. The agent may have the permissions to read the linked
125+
// path on its own, even if it is outside of the package root.
126+
var targetUri = resolvedUri.resolve(
127+
await fileSystem.link(osFriendlyPath).target(),
128+
);
129+
// If we can represent it as a package URI, do so.
130+
final asPackageUri = packageConfig.toPackageUri(targetUri);
131+
if (asPackageUri != null) {
132+
targetUri = asPackageUri;
133+
}
134+
yield ResourceLink(
135+
name: p.basename(targetUri.path),
136+
description: 'Target of symlink at $uri',
137+
uri: targetUri.toString(),
138+
mimeType: lookupMimeType(targetUri.path) ?? '',
139+
);
140+
case FileSystemEntityType.file:
141+
final file = fileSystem.file(osFriendlyPath);
142+
final mimeType = lookupMimeType(resolvedUri.path) ?? '';
143+
final resourceUri = packageConfig.toPackageUri(resolvedUri)!.toString();
144+
// Attempt to treat it as a utf8 String first, if that fails then just
145+
// return it as bytes.
146+
try {
147+
yield Content.embeddedResource(
148+
resource: TextResourceContents(
149+
uri: resourceUri,
150+
text: await file.readAsString(),
151+
mimeType: mimeType,
152+
),
153+
);
154+
} catch (_) {
155+
yield Content.embeddedResource(
156+
resource: BlobResourceContents(
157+
uri: resourceUri,
158+
mimeType: mimeType,
159+
blob: base64Encode(await file.readAsBytes()),
160+
),
161+
);
162+
}
163+
case FileSystemEntityType.notFound:
164+
yield TextContent(text: 'File not found: $uri');
165+
default:
166+
yield TextContent(
167+
text: 'Unsupported file system entity type $entityType',
168+
);
169+
}
170+
}
171+
172+
CallToolResult _noPackageConfigFound(Root root) => CallToolResult(
173+
isError: true,
174+
content: [
175+
TextContent(
176+
text:
177+
'No package config found for root ${root.uri}. Have you ran `pub '
178+
'get` in this project?',
179+
),
180+
],
181+
)..failureReason = CallToolFailureReason.noPackageConfigFound;
182+
183+
@visibleForTesting
184+
static final readPackageUris = Tool(
185+
name: 'read_package_uris',
186+
description:
187+
'Reads "package" scheme URIs which represent paths under the lib '
188+
'directory of Dart package dependencies. Package uris are always '
189+
'relative, and the first segment is the package name. For example, the '
190+
'URI "package:test/test.dart" represents the path "lib/test.dart" under '
191+
'the "test" package. This API supports both reading files and listing '
192+
'directories.',
193+
inputSchema: Schema.object(
194+
properties: {
195+
ParameterNames.uris: Schema.list(
196+
description: 'All the package URIs to read.',
197+
items: Schema.string(),
198+
),
199+
ParameterNames.root: rootSchema,
200+
},
201+
required: [ParameterNames.uris, ParameterNames.root],
202+
),
203+
);
204+
}

pkgs/dart_mcp_server/lib/src/server.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import 'mixins/analyzer.dart';
2020
import 'mixins/dash_cli.dart';
2121
import 'mixins/dtd.dart';
2222
import 'mixins/flutter_launcher.dart';
23+
import 'mixins/package_uri_reader.dart';
2324
import 'mixins/prompts.dart';
2425
import 'mixins/pub.dart';
2526
import 'mixins/pub_dev_search.dart';
@@ -45,7 +46,8 @@ final class DartMCPServer extends MCPServer
4546
DartToolingDaemonSupport,
4647
FlutterLauncherSupport,
4748
PromptsSupport,
48-
DashPrompts
49+
DashPrompts,
50+
PackageUriSupport
4951
implements
5052
AnalyticsSupport,
5153
ProcessManagerSupport,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ enum CallToolFailureReason {
127127
invalidRootPath,
128128
invalidRootScheme,
129129
noActiveDebugSession,
130+
noPackageConfigFound,
130131
noRootGiven,
131132
noRootsSet,
132133
noSuchCommand,

0 commit comments

Comments
 (0)