Skip to content

Commit 2a83a89

Browse files
committed
add support for package: uris
1 parent 19e9eac commit 2a83a89

File tree

5 files changed

+252
-89
lines changed

5 files changed

+252
-89
lines changed

.github/workflows/dart_mcp_server.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
fail-fast: false
2929
matrix:
3030
flutterSdk:
31-
- beta
31+
# - beta
3232
- master
3333
os:
3434
- ubuntu-latest

pkgs/dart_mcp_server/lib/src/mixins/analyzer.dart

Lines changed: 79 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ import 'package:dart_mcp/server.dart';
1111
import 'package:json_rpc_2/json_rpc_2.dart';
1212
import 'package:language_server_protocol/protocol_generated.dart' as lsp;
1313
import 'package:meta/meta.dart';
14+
import 'package:package_config/package_config.dart';
1415

1516
import '../lsp/wire_format.dart';
1617
import '../utils/analytics.dart';
18+
import '../utils/cli_utils.dart';
1719
import '../utils/constants.dart';
20+
import '../utils/file_system.dart';
1821
import '../utils/sdk.dart';
1922

2023
/// Mix this in to any MCPServer to add support for analyzing Dart projects.
@@ -23,7 +26,7 @@ import '../utils/sdk.dart';
2326
/// mixins applied.
2427
base mixin DartAnalyzerSupport
2528
on ToolsSupport, LoggingSupport, RootsTrackingSupport
26-
implements SdkSupport {
29+
implements FileSystemSupport, SdkSupport {
2730
/// The LSP server connection for the analysis server.
2831
Peer? _lspConnection;
2932

@@ -325,10 +328,42 @@ base mixin DartAnalyzerSupport
325328
/// Implementation of the [readSummaryTool], gets summary information for a
326329
/// given library.
327330
Future<CallToolResult> _readSummary(CallToolRequest request) async {
328-
final errorResult = await _ensurePrerequisites(request);
331+
if (await _ensurePrerequisites(request) case final errorResult?) {
332+
return errorResult;
333+
}
334+
335+
final uriString = request.arguments![ParameterNames.uri] as String;
336+
Uri? uri = Uri.parse(uriString);
337+
final (
338+
root: Root? root,
339+
// We do our own path handling because we have to handle package URIs
340+
// after getting a root.
341+
paths: _,
342+
errorResult: CallToolResult? errorResult,
343+
) = validateRootConfig(
344+
request.arguments,
345+
fileSystem: fileSystem,
346+
knownRoots: await roots,
347+
// Validates that the non-package: uris are valid under the provided root.
348+
defaultPaths: [if (uri.scheme != 'package') uriString],
349+
);
329350
if (errorResult != null) return errorResult;
330351

331-
final uri = Uri.parse(request.arguments![ParameterNames.uri] as String);
352+
// This normalizes the URI to ensure it is treated as a directory (for
353+
// example ensures it ends with a trailing slash).
354+
final rootDir = fileSystem.directory(Uri.parse(root!.uri));
355+
356+
// Resolve package URIs using the package config.
357+
if (uri.scheme == 'package') {
358+
final packageConfig = await findPackageConfig(rootDir);
359+
if (packageConfig == null) return noPackageConfig(root);
360+
361+
uri = packageConfig.resolve(uri);
362+
if (uri == null) return unableToResolveUri(uriString);
363+
} else if (uri.scheme == '' && !uri.isAbsolute) {
364+
uri = rootDir.uri.resolve('$uri');
365+
}
366+
332367
final result =
333368
(await _lspConnection!.sendRequest(
334369
'dart/textDocument/summary',
@@ -486,20 +521,30 @@ base mixin DartAnalyzerSupport
486521
static final readSummaryTool = Tool(
487522
name: 'read_summary',
488523
description:
489-
'Gets a summary of a given dart library by URI. This should be '
490-
'preferred over reading files when the only goal is to understand '
491-
'how to use a given library. Supports `package:` URIs and `file:` '
492-
'URIs. The library must be available to one of the currently active '
493-
'projects.',
524+
'Read a summary of a given Dart library by URI. It is absolutely '
525+
'critical that you read the summary of any library before using it to '
526+
'ensure you know the correct API - failure to do so may result in '
527+
'catastrophe. A project root is required in order to use this tool, '
528+
'because `package:` URIs are specific to a given root. \n\n'
529+
'Note that summary files omit implementation details for brevity, but '
530+
'you must assume real implementations exist unless explicitly told '
531+
'otherwise. DO NOT provide your own implementation. Failure to follow '
532+
'this instruction will have dire consequences.',
494533
inputSchema: Schema.object(
495534
properties: {
535+
ParameterNames.root: rootSchema,
496536
ParameterNames.uri: Schema.string(
497537
description:
498-
'The URI of the library, either a `package:` or `file:` URI.',
538+
'The URI of the library, either a `package:` or `file:` URI. '
539+
'All `file:` URIs must resolve to a path under the given root.',
499540
),
500541
},
542+
required: [ParameterNames.root, ParameterNames.uri],
543+
),
544+
annotations: ToolAnnotations(
545+
title: 'Dart library summary',
546+
readOnlyHint: true,
501547
),
502-
annotations: ToolAnnotations(title: 'Dart summary', readOnlyHint: true),
503548
);
504549

505550
@visibleForTesting
@@ -513,6 +558,30 @@ base mixin DartAnalyzerSupport
513558
),
514559
],
515560
)..failureReason = CallToolFailureReason.noRootsSet;
561+
562+
@visibleForTesting
563+
static CallToolResult noPackageConfig(Root root) => CallToolResult(
564+
isError: true,
565+
content: [
566+
TextContent(
567+
text:
568+
'Unable to find a package config for root ${root.uri}. Please '
569+
'make sure you have ran `flutter pub get` or `dart pub get`.',
570+
),
571+
],
572+
);
573+
574+
@visibleForTesting
575+
static CallToolResult unableToResolveUri(String uri) => CallToolResult(
576+
isError: true,
577+
content: [
578+
TextContent(
579+
text:
580+
'Unable to resolve URI $uri. Have you added that package as a '
581+
'dependency?',
582+
),
583+
],
584+
);
516585
}
517586

518587
/// Common schema for tools that require a file URI, line, and column.

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

Lines changed: 115 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -153,71 +153,24 @@ Future<CallToolResult> runCommandInRoot(
153153
required Sdk sdk,
154154
}) async {
155155
rootConfig ??= request.arguments;
156-
final rootUriString = rootConfig?[ParameterNames.root] as String?;
157-
if (rootUriString == null) {
158-
// This shouldn't happen based on the schema, but handle defensively.
159-
return CallToolResult(
160-
content: [
161-
TextContent(text: 'Invalid root configuration: missing `root` key.'),
162-
],
163-
isError: true,
164-
)..failureReason ??= CallToolFailureReason.noRootGiven;
165-
}
166-
167-
final root = knownRoots.firstWhereOrNull(
168-
(root) => _isUnderRoot(root, rootUriString, fileSystem),
156+
final (
157+
root: Root? root,
158+
paths: List<String>? paths,
159+
errorResult: CallToolResult? errorResult,
160+
) = validateRootConfig(
161+
rootConfig,
162+
defaultPaths: defaultPaths,
163+
fileSystem: fileSystem,
164+
knownRoots: knownRoots,
169165
);
170-
if (root == null) {
171-
return CallToolResult(
172-
content: [
173-
TextContent(
174-
text:
175-
'Invalid root $rootUriString, must be under one of the '
176-
'registered project roots:\n\n${knownRoots.join('\n')}',
177-
),
178-
],
179-
isError: true,
180-
)..failureReason ??= CallToolFailureReason.invalidRootPath;
181-
}
182-
183-
final rootUri = Uri.parse(rootUriString);
184-
if (rootUri.scheme != 'file') {
185-
return CallToolResult(
186-
content: [
187-
TextContent(
188-
text:
189-
'Only file scheme uris are allowed for roots, but got '
190-
'$rootUri',
191-
),
192-
],
193-
isError: true,
194-
)..failureReason ??= CallToolFailureReason.invalidRootScheme;
195-
}
196-
final projectRoot = fileSystem.directory(rootUri);
166+
if (errorResult != null) return errorResult;
197167

168+
final projectRoot = Uri.parse(root!.uri);
198169
final commandWithPaths = <String>[
199-
await commandForRoot(rootUriString, fileSystem, sdk),
170+
await commandForRoot(root.uri, fileSystem, sdk),
200171
...arguments,
201172
];
202-
final paths =
203-
(rootConfig?[ParameterNames.paths] as List?)?.cast<String>() ??
204-
defaultPaths;
205-
final invalidPaths = paths.where(
206-
(path) => !_isUnderRoot(root, path, fileSystem),
207-
);
208-
if (invalidPaths.isNotEmpty) {
209-
return CallToolResult(
210-
content: [
211-
TextContent(
212-
text:
213-
'Paths are not allowed to escape their project root:\n'
214-
'${invalidPaths.join('\n')}',
215-
),
216-
],
217-
isError: true,
218-
)..failureReason ??= CallToolFailureReason.invalidPath;
219-
}
220-
commandWithPaths.addAll(paths);
173+
if (paths != null) commandWithPaths.addAll(paths);
221174

222175
final workingDir = fileSystem.directory(projectRoot.path);
223176
await workingDir.create(recursive: true);
@@ -252,6 +205,106 @@ Future<CallToolResult> runCommandInRoot(
252205
);
253206
}
254207

208+
/// Validates a root argument given via [rootConfig], ensuring that it falls
209+
/// under one of the [knownRoots], and that all `paths` arguments are also under
210+
/// the given root.
211+
///
212+
/// Returns a root on success, equal to the given root (but this could be a
213+
/// subdirectory of one of the [knownRoots]), as well as any paths that were
214+
/// validated.
215+
///
216+
/// If no [ParameterNames.paths] are provided, then the [defaultPaths] will be
217+
/// used, if present. Otherwise no paths are validated or will be returned.
218+
///
219+
/// On failure, returns a [CallToolResult].
220+
({Root? root, List<String>? paths, CallToolResult? errorResult})
221+
validateRootConfig(
222+
Map<String, Object?>? rootConfig, {
223+
List<String>? defaultPaths,
224+
required FileSystem fileSystem,
225+
required List<Root> knownRoots,
226+
}) {
227+
final rootUriString = rootConfig?[ParameterNames.root] as String?;
228+
if (rootUriString == null) {
229+
// This shouldn't happen based on the schema, but handle defensively.
230+
return (
231+
root: null,
232+
paths: null,
233+
errorResult: CallToolResult(
234+
content: [
235+
TextContent(text: 'Invalid root configuration: missing `root` key.'),
236+
],
237+
isError: true,
238+
)..failureReason ??= CallToolFailureReason.noRootGiven,
239+
);
240+
}
241+
242+
final knownRoot = knownRoots.firstWhereOrNull(
243+
(root) => _isUnderRoot(root, rootUriString, fileSystem),
244+
);
245+
if (knownRoot == null) {
246+
return (
247+
root: null,
248+
paths: null,
249+
errorResult: CallToolResult(
250+
content: [
251+
TextContent(
252+
text:
253+
'Invalid root $rootUriString, must be under one of the '
254+
'registered project roots:\n\n${knownRoots.join('\n')}',
255+
),
256+
],
257+
isError: true,
258+
)..failureReason ??= CallToolFailureReason.invalidRootPath,
259+
);
260+
}
261+
final root = Root(uri: rootUriString);
262+
263+
final rootUri = Uri.parse(rootUriString);
264+
if (rootUri.scheme != 'file') {
265+
return (
266+
root: null,
267+
paths: null,
268+
errorResult: CallToolResult(
269+
content: [
270+
TextContent(
271+
text:
272+
'Only file scheme uris are allowed for roots, but got '
273+
'$rootUri',
274+
),
275+
],
276+
isError: true,
277+
)..failureReason ??= CallToolFailureReason.invalidRootScheme,
278+
);
279+
}
280+
281+
final paths =
282+
(rootConfig?[ParameterNames.paths] as List?)?.cast<String>() ??
283+
defaultPaths;
284+
if (paths != null) {
285+
final invalidPaths = paths.where(
286+
(path) => !_isUnderRoot(root, path, fileSystem),
287+
);
288+
if (invalidPaths.isNotEmpty) {
289+
return (
290+
root: null,
291+
paths: null,
292+
errorResult: CallToolResult(
293+
content: [
294+
TextContent(
295+
text:
296+
'Paths are not allowed to escape their project root:\n'
297+
'${invalidPaths.join('\n')}',
298+
),
299+
],
300+
isError: true,
301+
)..failureReason ??= CallToolFailureReason.invalidPath,
302+
);
303+
}
304+
}
305+
return (root: root, paths: paths, errorResult: null);
306+
}
307+
255308
/// Returns 'dart' or 'flutter' based on the pubspec contents.
256309
///
257310
/// Throws an [ArgumentError] if there is no pubspec.
@@ -316,7 +369,8 @@ final rootSchema = Schema.string(
316369
description:
317370
'This must be equal to or a subdirectory of one of the roots '
318371
'allowed by the client. Must be a URI with a `file:` '
319-
'scheme (e.g. file:///absolute/path/to/root).',
372+
'scheme (e.g. file:///absolute/path/to/root). Dart and Flutter project '
373+
'roots can be identified by the existence of a `pubspec.yaml` file.',
320374
);
321375

322376
/// Very thin extension type for a pubspec just containing what we need.

pkgs/dart_mcp_server/pubspec.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ description: >-
44
models.
55
publish_to: none
66
environment:
7-
sdk: ^3.9.0-163.0.dev
7+
sdk: ^3.10.0-18.0.dev
88

99
executables:
1010
dart_mcp_server: main
@@ -27,8 +27,9 @@ dependencies:
2727
path: third_party/pkg/language_server_protocol
2828
# When changing this, also update .github/workflows/dart_mcp_server.yaml
2929
# to cache the correct directory.
30-
ref: b0838eac58308fc4e6654ca99eda75b30649c08f
30+
ref: 82a01bb69cbee93663f3891d481c96f0ada4437c
3131
meta: ^1.16.0
32+
package_config: ^2.2.0
3233
path: ^1.9.1
3334
pool: ^1.5.1
3435
process: ^5.0.3

0 commit comments

Comments
 (0)