diff --git a/.gitignore b/.gitignore index fe375305..9081d553 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ pubspec.lock .vscode .idea *.iml +.agents # Gemini specific ignores **/.gemini/* diff --git a/pkgs/dart_mcp/lib/api.dart b/pkgs/dart_mcp/lib/api.dart new file mode 100644 index 00000000..98a9db52 --- /dev/null +++ b/pkgs/dart_mcp/lib/api.dart @@ -0,0 +1,5 @@ +// Copyright (c) 2026, 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. + +export 'src/api/api.dart'; diff --git a/pkgs/dart_mcp/lib/client.dart b/pkgs/dart_mcp/lib/client.dart index 38e86cca..472fe30f 100644 --- a/pkgs/dart_mcp/lib/client.dart +++ b/pkgs/dart_mcp/lib/client.dart @@ -2,5 +2,5 @@ // 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. -export 'src/api/api.dart'; +export 'api.dart'; export 'src/client/client.dart'; diff --git a/pkgs/dart_mcp/lib/server.dart b/pkgs/dart_mcp/lib/server.dart index 1b6cb10c..a59549bd 100644 --- a/pkgs/dart_mcp/lib/server.dart +++ b/pkgs/dart_mcp/lib/server.dart @@ -2,5 +2,5 @@ // 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. -export 'src/api/api.dart'; +export 'api.dart'; export 'src/server/server.dart'; diff --git a/pkgs/dart_mcp/lib/src/api/api.dart b/pkgs/dart_mcp/lib/src/api/api.dart index 82fa6001..bc96f772 100644 --- a/pkgs/dart_mcp/lib/src/api/api.dart +++ b/pkgs/dart_mcp/lib/src/api/api.dart @@ -61,10 +61,10 @@ enum ProtocolVersion { /// A progress token, used to associate progress notifications with the original /// request. -extension type ProgressToken(/*String|int*/ Object _) {} +extension type ProgressToken(/*String|int*/ Object _) implements Object {} /// An opaque token used to represent a cursor for pagination. -extension type Cursor(String _) {} +extension type Cursor(String _) implements Object {} /// Generic metadata passed with most requests. /// @@ -92,7 +92,7 @@ extension type Cursor(String _) {} /// - Name: Unless empty, MUST begin and end with an alphanumeric character /// (`[a-z0-9A-Z]`). MAY contain hyphens (`-`), underscores (`_`), dots /// (`.`), and alphanumerics in between. -extension type Meta.fromMap(Map _value) { +extension type Meta.fromMap(Map _value) implements Object { Object? operator [](String key) => _value[key]; } @@ -100,7 +100,8 @@ extension type Meta.fromMap(Map _value) { /// /// Not to be confused with the `_meta` property in the spec, which has a /// different purpose. -extension type BaseMetadata.fromMap(Map _value) { +extension type BaseMetadata.fromMap(Map _value) + implements Object { factory BaseMetadata({required String name, String? title}) => BaseMetadata.fromMap({Keys.name: name, Keys.title: title}); @@ -130,7 +131,8 @@ extension type BaseMetadata.fromMap(Map _value) { /// [ProgressToken] at the key "progressToken". /// /// Should be "mixed in" by implementing this type from other extension types. -extension type WithProgressToken.fromMap(Map _value) { +extension type WithProgressToken.fromMap(Map _value) + implements Object { ProgressToken? get progressToken => _value[Keys.progressToken] as ProgressToken?; } @@ -147,7 +149,8 @@ extension type MetaWithProgressToken.fromMap(Map _value) /// Base interface for all types that can have arbitrary metadata attached. /// /// Should not be constructed directly, and has no public constructor. -extension type WithMetadata._fromMap(Map _value) { +extension type WithMetadata._fromMap(Map _value) + implements Object { /// The `_meta` property/parameter is reserved by MCP to allow clients and /// servers to attach additional metadata to their interactions. /// @@ -171,14 +174,14 @@ extension type Request._fromMap(Map _value) } /// Base interface for all notifications. -extension type Notification(Map _value) { +extension type Notification(Map _value) implements Object { /// This parameter name is reserved by MCP to allow clients and servers to /// attach additional metadata to their notifications. Meta? get meta => _value[Keys.meta] as Meta?; } /// Base interface for all responses to requests. -extension type Result._(Map _value) { +extension type Result._(Map _value) implements Object { Meta? get meta => _value[Keys.meta] as Meta?; } @@ -226,7 +229,7 @@ extension type CancelledNotification.fromMap(Map _value) } /// An opaque request ID. -extension type RequestId(/*String|int*/ Parameter _) {} +extension type RequestId(/*String|int*/ Parameter _) implements Object {} /// A ping, issued by either the server or the client, to check that the other /// party is still alive. @@ -315,7 +318,7 @@ extension type PaginatedResult._fromMap(Map _value) /// /// Doing `is` checks does not work because these are just extension types, they /// all have the same runtime type (`Map`). -extension type Content._(Map _value) { +extension type Content._(Map _value) implements Object { factory Content.fromMap(Map value) { assert(value.containsKey(Keys.type)); return Content._(value); @@ -538,13 +541,15 @@ extension type ResourceLink.fromMap(Map _value) /// Base type for objects that include optional annotations for the client. /// /// The client can use annotations to inform how objects are used or displayed. -extension type Annotated._fromMap(Map _value) { +extension type Annotated._fromMap(Map _value) + implements Object { /// Annotations for this object. Annotations? get annotations => _value[Keys.annotations] as Annotations?; } /// The annotations for an [Annotated] object. -extension type Annotations.fromMap(Map _value) { +extension type Annotations.fromMap(Map _value) + implements Object { factory Annotations({ List? audience, DateTime? lastModified, diff --git a/pkgs/dart_mcp/lib/src/api/sampling.dart b/pkgs/dart_mcp/lib/src/api/sampling.dart index 20a722ed..b1d7e014 100644 --- a/pkgs/dart_mcp/lib/src/api/sampling.dart +++ b/pkgs/dart_mcp/lib/src/api/sampling.dart @@ -138,7 +138,8 @@ extension type CreateMessageResult.fromMap(Map _value) } /// Describes a message issued to or received from an LLM API. -extension type SamplingMessage.fromMap(Map _value) { +extension type SamplingMessage.fromMap(Map _value) + implements Object { factory SamplingMessage({required Role role, required Content content}) => SamplingMessage.fromMap({Keys.role: role.name, Keys.content: content}); diff --git a/pkgs/dart_mcp/pubspec.yaml b/pkgs/dart_mcp/pubspec.yaml index 4086ba25..2f4f6e3a 100644 --- a/pkgs/dart_mcp/pubspec.yaml +++ b/pkgs/dart_mcp/pubspec.yaml @@ -16,5 +16,6 @@ dependencies: stream_transform: ^2.1.1 dev_dependencies: + checks: ^0.3.1 dart_flutter_team_lints: ^3.2.1 test: ^1.25.15 diff --git a/pkgs/dart_mcp/test/api/api_test.dart b/pkgs/dart_mcp/test/api/api_test.dart index e26a2370..2b540d78 100644 --- a/pkgs/dart_mcp/test/api/api_test.dart +++ b/pkgs/dart_mcp/test/api/api_test.dart @@ -2,126 +2,107 @@ // 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:checks/checks.dart'; import 'package:dart_mcp/client.dart'; import 'package:test/test.dart'; void main() { test('protocol versions can be compared', () { - expect( + check( ProtocolVersion.latestSupported > ProtocolVersion.oldestSupported, - true, - ); - expect( + ).isTrue(); + check( ProtocolVersion.latestSupported >= ProtocolVersion.oldestSupported, - true, - ); - expect( + ).isTrue(); + check( ProtocolVersion.latestSupported < ProtocolVersion.oldestSupported, - false, - ); - expect( + ).isFalse(); + check( ProtocolVersion.latestSupported <= ProtocolVersion.oldestSupported, - false, - ); + ).isFalse(); - expect( + check( ProtocolVersion.oldestSupported > ProtocolVersion.latestSupported, - false, - ); - expect( + ).isFalse(); + check( ProtocolVersion.oldestSupported >= ProtocolVersion.latestSupported, - false, - ); - expect( + ).isFalse(); + check( ProtocolVersion.oldestSupported < ProtocolVersion.latestSupported, - true, - ); - expect( + ).isTrue(); + check( ProtocolVersion.oldestSupported <= ProtocolVersion.latestSupported, - true, - ); + ).isTrue(); - expect( + check( ProtocolVersion.latestSupported <= ProtocolVersion.latestSupported, - true, - ); - expect( + ).isTrue(); + check( ProtocolVersion.latestSupported >= ProtocolVersion.latestSupported, - true, - ); - expect( + ).isTrue(); + check( ProtocolVersion.latestSupported < ProtocolVersion.latestSupported, - false, - ); - expect( + ).isFalse(); + check( ProtocolVersion.latestSupported > ProtocolVersion.latestSupported, - false, - ); + ).isFalse(); }); group('API object validation', () { test('throws when required fields are missing', () { - expect(() => Root.fromMap({}).uri, throwsA(isA())); - expect( + check(() => Root.fromMap({}).uri).throws(); + check( () => Implementation.fromMap({'name': 'test'}).version, - throwsA(isA()), - ); - expect( - () => BaseMetadata.fromMap({}).name, - throwsA(isA()), - ); + ).throws(); + check(() => BaseMetadata.fromMap({}).name).throws(); final empty = {}; // Initialization - expect( + check( () => (empty as InitializeRequest).capabilities, - throwsArgumentError, - ); - expect( + ).throws(); + check( () => (empty as InitializeRequest).clientInfo, - throwsArgumentError, - ); + ).throws(); // Tools - expect(() => (empty as CallToolRequest).name, throwsArgumentError); + check(() => (empty as CallToolRequest).name).throws(); // Resources - expect(() => (empty as ReadResourceRequest).uri, throwsArgumentError); - expect(() => (empty as SubscribeRequest).uri, throwsArgumentError); - expect(() => (empty as UnsubscribeRequest).uri, throwsArgumentError); + check(() => (empty as ReadResourceRequest).uri).throws(); + check(() => (empty as SubscribeRequest).uri).throws(); + check(() => (empty as UnsubscribeRequest).uri).throws(); // Roots - expect(() => (empty as ListRootsResult).roots, throwsArgumentError); + check(() => (empty as ListRootsResult).roots).throws(); // Prompts - expect(() => (empty as GetPromptRequest).name, throwsArgumentError); + check(() => (empty as GetPromptRequest).name).throws(); // Completions - expect(() => (empty as CompleteRequest).ref, throwsArgumentError); - expect(() => (empty as CompleteRequest).argument, throwsArgumentError); + check(() => (empty as CompleteRequest).ref).throws(); + check(() => (empty as CompleteRequest).argument).throws(); // Logging - expect(() => (empty as SetLevelRequest).level, throwsArgumentError); + check(() => (empty as SetLevelRequest).level).throws(); // Sampling - expect( + check( () => (empty as CreateMessageRequest).messages, - throwsArgumentError, - ); - expect( + ).throws(); + check( () => (empty as CreateMessageRequest).maxTokens, - throwsArgumentError, - ); + ).throws(); }); test('meta field is parsed correctly', () { final root = Root.fromMap({ 'uri': 'file:///foo/bar', '_meta': {'foo': 'bar'}, }); - expect(root.meta, isNotNull); + check(root.meta).isNotNull(); final metaMap = root.meta as Map; - expect(metaMap['foo'], 'bar'); + check(metaMap['foo']).equals('bar'); }); }); } diff --git a/pkgs/dart_mcp/test/api/completions_test.dart b/pkgs/dart_mcp/test/api/completions_test.dart index 7a9730fa..5ed2326e 100644 --- a/pkgs/dart_mcp/test/api/completions_test.dart +++ b/pkgs/dart_mcp/test/api/completions_test.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:checks/checks.dart'; import 'package:dart_mcp/server.dart'; import 'package:test/test.dart'; @@ -16,26 +17,28 @@ void main() { TestMCPServerWithCompletions.new, ); final initializeResult = await environment.initializeServer(); - expect(initializeResult.capabilities.completions, Completions()); + check( + initializeResult.capabilities.completions as Map?, + ).isNotNull().deepEquals(Completions() as Map); final serverConnection = environment.serverConnection; - expect( - (await serverConnection.requestCompletions( - CompleteRequest( - ref: TestMCPServerWithCompletions.languagePromptRef, - argument: CompletionArgument( - name: - TestMCPServerWithCompletions - .languagePrompt - .arguments! - .single - .name, - value: 'c', - ), + final result = await serverConnection.requestCompletions( + CompleteRequest( + ref: TestMCPServerWithCompletions.languagePromptRef, + argument: CompletionArgument( + name: + TestMCPServerWithCompletions + .languagePrompt + .arguments! + .single + .name, + value: 'c', ), - )).completion.values, - TestMCPServerWithCompletions.cLanguages, + ), ); + check( + result.completion.values, + ).deepEquals(TestMCPServerWithCompletions.cLanguages); }); test('client can request resource completions', () async { @@ -44,27 +47,30 @@ void main() { TestMCPServerWithCompletions.new, ); final initializeResult = await environment.initializeServer(); - expect(initializeResult.capabilities.completions, Completions()); + check( + initializeResult.capabilities.completions as Map?, + ).isNotNull().deepEquals(Completions() as Map); final serverConnection = environment.serverConnection; - expect( - (await serverConnection.requestCompletions( - CompleteRequest( - ref: TestMCPServerWithCompletions.packageUriTemplateRef, - argument: CompletionArgument(name: 'package_name', value: 'a'), - ), - )).completion.values, - TestMCPServerWithCompletions.aPackages, + final result1 = await serverConnection.requestCompletions( + CompleteRequest( + ref: TestMCPServerWithCompletions.packageUriTemplateRef, + argument: CompletionArgument(name: 'package_name', value: 'a'), + ), ); - expect( - (await serverConnection.requestCompletions( - CompleteRequest( - ref: TestMCPServerWithCompletions.packageUriTemplateRef, - argument: CompletionArgument(name: 'path', value: 'a'), - ), - )).completion.values, - TestMCPServerWithCompletions.packagePaths, + check( + result1.completion.values, + ).deepEquals(TestMCPServerWithCompletions.aPackages); + + final result2 = await serverConnection.requestCompletions( + CompleteRequest( + ref: TestMCPServerWithCompletions.packageUriTemplateRef, + argument: CompletionArgument(name: 'path', value: 'a'), + ), ); + check( + result2.completion.values, + ).deepEquals(TestMCPServerWithCompletions.packagePaths); }); test('client can request resource completions with context', () async { @@ -73,19 +79,19 @@ void main() { TestMCPServerWithCompletions.new, ); final initializeResult = await environment.initializeServer(); - expect(initializeResult.capabilities.completions, Completions()); + check( + initializeResult.capabilities.completions as Map?, + ).isNotNull().deepEquals(Completions() as Map); final serverConnection = environment.serverConnection; - expect( - (await serverConnection.requestCompletions( - CompleteRequest( - ref: TestMCPServerWithCompletions.packageUriTemplateRef, - argument: CompletionArgument(name: 'path', value: 'a'), - context: CompletionContext(arguments: {'package_name': 'async'}), - ), - )).completion.values, - ['async.dart'], + final result = await serverConnection.requestCompletions( + CompleteRequest( + ref: TestMCPServerWithCompletions.packageUriTemplateRef, + argument: CompletionArgument(name: 'path', value: 'a'), + context: CompletionContext(arguments: {'package_name': 'async'}), + ), ); + check(result.completion.values).deepEquals(['async.dart']); }); } diff --git a/pkgs/dart_mcp/test/api/elicitation_test.dart b/pkgs/dart_mcp/test/api/elicitation_test.dart index bdcc3c32..1c2fdec7 100644 --- a/pkgs/dart_mcp/test/api/elicitation_test.dart +++ b/pkgs/dart_mcp/test/api/elicitation_test.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:checks/checks.dart'; import 'package:dart_mcp/client.dart'; import 'package:dart_mcp/server.dart'; import 'package:test/test.dart'; @@ -50,8 +51,8 @@ void main() { ); final result = await elicitationRequest; - expect(result.action, ElicitationAction.accept); - expect(result.content, {'name': 'John Doe'}); + check(result.action).equals(ElicitationAction.accept); + check(result.content).isNotNull().deepEquals({'name': 'John Doe'}); }); }); } diff --git a/pkgs/dart_mcp/test/api/initialization_test.dart b/pkgs/dart_mcp/test/api/initialization_test.dart index b271ce14..bd449102 100644 --- a/pkgs/dart_mcp/test/api/initialization_test.dart +++ b/pkgs/dart_mcp/test/api/initialization_test.dart @@ -2,6 +2,7 @@ // 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:checks/checks.dart'; import 'package:dart_mcp/server.dart'; import 'package:test/test.dart'; @@ -14,7 +15,7 @@ void main() { ); final map = result as Map; - expect(map.containsKey('instructions'), isFalse); + check(map).not((m) => m.containsKey('instructions')); }); test('nonnull instructions', () async { @@ -26,6 +27,6 @@ void main() { ); final map = result as Map; - expect(map['instructions'], equals('foo')); + check(map)['instructions'].equals('foo'); }); } diff --git a/pkgs/dart_mcp/test/api/logging_test.dart b/pkgs/dart_mcp/test/api/logging_test.dart index 65e46a01..db6baae8 100644 --- a/pkgs/dart_mcp/test/api/logging_test.dart +++ b/pkgs/dart_mcp/test/api/logging_test.dart @@ -2,6 +2,10 @@ // 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 'package:async/async.dart'; +import 'package:checks/checks.dart'; import 'package:dart_mcp/server.dart'; import 'package:test/test.dart'; @@ -15,26 +19,27 @@ void main() { ); final initializeResult = await environment.initializeServer(); - expect(initializeResult.capabilities.logging, Logging()); + check( + initializeResult.capabilities.logging as Map?, + ).isNotNull().deepEquals(Logging() as Map); final serverConnection = environment.serverConnection; final server = environment.server; - expect( + check( server.loggingLevel, - LoggingLevel.warning, - reason: 'The default level is warning', - ); + because: 'The default level is warning', + ).equals(LoggingLevel.warning); await serverConnection.setLogLevel( SetLevelRequest(level: LoggingLevel.debug), ); - expect(server.loggingLevel, LoggingLevel.debug); + check(server.loggingLevel).equals(LoggingLevel.debug); await serverConnection.setLogLevel( SetLevelRequest(level: LoggingLevel.error), ); - expect(server.loggingLevel, LoggingLevel.error); + check(server.loggingLevel).equals(LoggingLevel.error); }); test('client can receive log messages', () async { @@ -61,20 +66,29 @@ void main() { ), ]; - expect( - serverConnection.onLog, - emitsInOrder([ - for (var notification in notifications) - if (notification.level >= LoggingLevel.warning) notification, - ]), - ); + final expectedNotifications = [ + for (var notification in notifications) + if (notification.level >= LoggingLevel.warning) notification, + ]; - expect( - serverConnection.onLog, - neverEmits([ - for (var notification in notifications) - if (notification.level < LoggingLevel.warning) notification, - ]), + final queue1 = StreamQueue(serverConnection.onLog); + final queue2 = StreamQueue(serverConnection.onLog); + + final inOrderFuture = check(queue1).inOrder([ + for (var expected in expectedNotifications) + (Subject> s) => s.emits( + (Subject e) => e + .has((x) => x as Map, 'as Map') + .deepEquals(expected as Map), + ), + ]); + + final neverEmitsFuture = check(queue2).neverEmits( + (Subject e) => + e + .has((x) => x.level, 'level') + .has((l) => l < LoggingLevel.warning, 'is less than warning') + .isTrue(), ); for (var notification in notifications) { @@ -88,8 +102,11 @@ void main() { /// Allow the notifications to propagate. await pumpEventQueue(); - /// Closes the log stream so that `neverEmits` can complete above. - await environment.shutdown(); + await inOrderFuture; + await queue1.cancel(); + + unawaited(environment.shutdown()); + await neverEmitsFuture; }); test('server can log functions for lazy evaluation', () async { @@ -111,7 +128,16 @@ void main() { LoggingMessageNotification(level: LoggingLevel.warning, data: i), ]; - expect(serverConnection.onLog, emitsInOrder(notifications)); + final queue = StreamQueue(serverConnection.onLog); + + final inOrderFuture = check(queue).inOrder([ + for (var expected in notifications) + (Subject> s) => s.emits( + (Subject e) => e + .has((x) => x as Map, 'as Map') + .deepEquals(expected as Map), + ), + ]); // A function with no arguments server.log(notifications[0].level, () => notifications[0].data); @@ -122,28 +148,29 @@ void main() { // A function with an optional named argument server.log(notifications[2].level, ({int? x}) => notifications[2].data); - expect( + check( () => server.log(LoggingLevel.warning, () => null), - throwsA(isA()), - reason: 'Lazy message functions should not have a nullable return type', - ); + because: 'Lazy message functions should not have a nullable return type', + ).throws(); - expect( + check( () => server.log(LoggingLevel.warning, (int x) => 'hello'), - throwsA(isA()), - reason: + because: 'Lazy message functions should not have required positional ' 'arguments', - ); + ).throws(); - expect( + check( () => server.log(LoggingLevel.warning, ({required int x}) => 'hello'), - throwsA(isA()), - reason: 'Lazy message functions should not have required named arguments', - ); + because: + 'Lazy message functions should not have required named arguments', + ).throws(); // Below logging level, never gets evaluated. server.log(LoggingLevel.info, () => throw StateError('Unreachable')); + + await inOrderFuture; + await queue.cancel(); }); } diff --git a/pkgs/dart_mcp/test/api/prompts_test.dart b/pkgs/dart_mcp/test/api/prompts_test.dart index 7853f3ca..40d6234d 100644 --- a/pkgs/dart_mcp/test/api/prompts_test.dart +++ b/pkgs/dart_mcp/test/api/prompts_test.dart @@ -4,6 +4,8 @@ import 'dart:async'; +import 'package:async/async.dart'; +import 'package:checks/checks.dart'; import 'package:dart_mcp/server.dart'; import 'package:test/test.dart'; @@ -17,15 +19,17 @@ void main() { ); final initializeResult = await environment.initializeServer(); - expect( - initializeResult.capabilities, - ServerCapabilities(prompts: Prompts(listChanged: true)), + check(initializeResult.capabilities as Map).deepEquals( + ServerCapabilities(prompts: Prompts(listChanged: true)) + as Map, ); final serverConnection = environment.serverConnection; final promptsResult = await serverConnection.listPrompts(); - expect(promptsResult.prompts, [TestMCPServerWithPrompts.greeting]); + check( + promptsResult.prompts as List, + ).deepEquals([TestMCPServerWithPrompts.greeting as Map]); final greetingResult = await serverConnection.getPrompt( GetPromptRequest( @@ -34,12 +38,12 @@ void main() { ), ); - expect( - greetingResult.messages.single, + check(greetingResult.messages.single as Map).deepEquals( PromptMessage( - role: Role.user, - content: TextContent(text: 'Please greet me joyously'), - ), + role: Role.user, + content: TextContent(text: 'Please greet me joyously'), + ) + as Map, ); }); @@ -51,15 +55,25 @@ void main() { await environment.initializeServer(); final serverConnection = environment.serverConnection; - expect( - serverConnection.promptListChanged, - emitsInOrder([ - PromptListChangedNotification(), - PromptListChangedNotification(), - null, - ]), - reason: 'We should get a notification for new and removed prompts', - ); + final queue = StreamQueue(serverConnection.promptListChanged); + + final inOrderFuture = check(queue).inOrder([ + (s) => s.emits( + (e) => e + .has((x) => x as Map, 'as Map') + .deepEquals( + PromptListChangedNotification() as Map, + ), + ), + (s) => s.emits( + (e) => e + .has((x) => x as Map, 'as Map') + .deepEquals( + PromptListChangedNotification() as Map, + ), + ), + (s) => s.emits((e) => e.isNull()), + ]); final server = environment.server; server.addPrompt( @@ -71,6 +85,9 @@ void main() { // Give the notifications a chance to propagate. await pumpEventQueue(); + await inOrderFuture; + await queue.cancel(); + // We need to manually shut down so that the queue of prompt changes doesn't // keep the test active. await environment.shutdown(); diff --git a/pkgs/dart_mcp/test/api/roots_test.dart b/pkgs/dart_mcp/test/api/roots_test.dart index 9e3dc586..573a9e6a 100644 --- a/pkgs/dart_mcp/test/api/roots_test.dart +++ b/pkgs/dart_mcp/test/api/roots_test.dart @@ -3,6 +3,7 @@ // BSD-style license that can be found in the LICENSE file. import 'package:async/async.dart'; +import 'package:checks/checks.dart'; import 'package:dart_mcp/client.dart'; import 'package:test/test.dart'; @@ -17,53 +18,58 @@ void main() { await environment.initializeServer(); final client = environment.client; - expect( - environment.client.capabilities.roots, - RootsCapabilities(listChanged: true), + check( + environment.client.capabilities.roots as Map?, + ).isNotNull().deepEquals( + RootsCapabilities(listChanged: true) as Map, ); final server = environment.server; final events = StreamQueue(server.rootsListChanged!); - expect((await server.listRoots()).roots, isEmpty); + check((await server.listRoots()).roots).isEmpty(); final a = Root(uri: 'test://a', name: 'a'); final a2 = Root(uri: 'test://a', name: 'a2'); final b = Root(uri: 'test://b', name: 'b'); - expect(client.addRoot(a), isTrue); - expect( + check(client.addRoot(a)).isTrue(); + check( client.addRoot(a2), - isFalse, - reason: 'Roots are compared only by URI', - ); - expect(client.addRoot(b), isTrue); + because: 'Roots are compared only by URI', + ).isFalse(); + check(client.addRoot(b)).isTrue(); - expect(await events.take(2), hasLength(2)); + check(await events.take(2)).length.equals(2); environment.serverConnection.sendNotification( RootsListChangedNotification.methodName, ); - expect(await events.next, isNull); + check(await events.next).isNull(); - expect( - (await server.listRoots(ListRootsRequest())).roots, - unorderedEquals([a, b]), - ); + final rootsResult = await server.listRoots(ListRootsRequest()); + check(rootsResult.roots as List).unorderedMatches([ + (it) => + it.isA>().deepEquals(a as Map), + (it) => + it.isA>().deepEquals(b as Map), + ]); - expect(client.removeRoot(a2), true); - expect(client.removeRoot(a), false); - expect(client.removeRoot(b), true); + check(client.removeRoot(a2)).isTrue(); + check(client.removeRoot(a)).isFalse(); + check(client.removeRoot(b)).isTrue(); - expect(await events.take(2), hasLength(2)); + check(await events.take(2)).length.equals(2); - expect((await server.listRoots(ListRootsRequest())).roots, isEmpty); + check((await server.listRoots(ListRootsRequest())).roots).isEmpty(); - expect(events.hasNext, completion(false)); + final hasNextFuture = check(events.hasNext).completes((it) => it.isFalse()); // Manually shutdown so the event stream can close and `hasNext` will // complete. await environment.shutdown(); + + await hasNextFuture; }); } diff --git a/pkgs/dart_mcp/test/api/sampling_test.dart b/pkgs/dart_mcp/test/api/sampling_test.dart index af1d486d..2188d504 100644 --- a/pkgs/dart_mcp/test/api/sampling_test.dart +++ b/pkgs/dart_mcp/test/api/sampling_test.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:checks/checks.dart'; import 'package:dart_mcp/server.dart'; import 'package:dart_mcp/src/client/client.dart'; import 'package:test/test.dart'; @@ -18,7 +19,7 @@ void main() { ); await environment.initializeServer(); final server = environment.server; - expect(server.clientCapabilities.sampling, isNotNull); + check(server.clientCapabilities.sampling).isNotNull(); final client = environment.client; final expectedResult = @@ -28,12 +29,12 @@ void main() { model: 'fakeModel', ); - expect( - await server.createMessage( - CreateMessageRequest(messages: [], maxTokens: 100), - ), - expectedResult, + final result = await server.createMessage( + CreateMessageRequest(messages: [], maxTokens: 100), ); + check( + result as Map, + ).deepEquals(expectedResult as Map); }); } diff --git a/pkgs/dart_mcp/test/api/tools_test.dart b/pkgs/dart_mcp/test/api/tools_test.dart index 6fad7fc9..10d0d7da 100644 --- a/pkgs/dart_mcp/test/api/tools_test.dart +++ b/pkgs/dart_mcp/test/api/tools_test.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:checks/checks.dart'; import 'package:dart_mcp/server.dart'; import 'package:test/test.dart'; @@ -39,14 +40,18 @@ void main() { final expectedErrorsSet = expectedErrorsWithoutPaths.map(onlyKeepError).toSet(); - expect( - actualErrorsStrippedSet, - equals(expectedErrorsSet), - reason: + check( + actualErrorsStrippedSet as Iterable, + because: reason ?? 'Data: $data. Expected (paths ignored): $expectedErrorsSet. ' 'Actual (paths ignored): $actualErrorsStrippedSet', - ); + ).unorderedMatches([ + for (final expected in expectedErrorsSet) + (it) => it.isA>().deepEquals( + expected as Map, + ), + ]); } /// Asserts that schema validation produces the exact expected errors, including error paths. @@ -61,13 +66,14 @@ void main() { // Compare sets of full ValidationError objects. // This relies on ValidationError's equality being based on its // underlying map (including the path if present). - expect( + check( actualErrors.map((e) => e.toString()).toList()..sort(), - equals(expectedErrorsWithPaths.map((e) => e.toString()).toList()..sort()), - reason: + because: reason ?? 'Data: $data. Expected (exact): ${expectedErrorsWithPaths.map((e) => e.toString()).toSet()}. ' 'Actual (exact): ${actualErrors.map((e) => e.toString()).toSet()}', + ).deepEquals( + expectedErrorsWithPaths.map((e) => e.toString()).toList()..sort(), ); } @@ -86,7 +92,7 @@ void main() { minProperties: 1, maxProperties: 2, ); - expect(schema, { + check(schema as Map).deepEquals({ 'type': 'object', 'title': 'Foo', 'description': 'Bar', @@ -108,7 +114,9 @@ void main() { test('ObjectSchema includes empty properties by default', () { final schema = ObjectSchema(); - expect(schema, {'type': 'object', 'properties': {}}); + check( + schema as Map, + ).deepEquals({'type': 'object', 'properties': {}}); }); test('StringSchema', () { @@ -121,7 +129,7 @@ void main() { defaultValue: 'test', format: StringFormat.dateTime, ); - expect(schema, { + check(schema as Map).deepEquals({ 'type': 'string', 'title': 'Foo', 'description': 'Bar', @@ -132,7 +140,7 @@ void main() { 'format': 'date-time', }); // Ensure enum value uses the custom name. - expect(schema.format, StringFormat.dateTime); + check(schema.format).equals(StringFormat.dateTime); }); test('NumberSchema', () { @@ -145,7 +153,7 @@ void main() { exclusiveMaximum: 11, multipleOf: 2, ); - expect(schema, { + check(schema as Map).deepEquals({ 'type': 'number', 'title': 'Foo', 'description': 'Bar', @@ -167,7 +175,7 @@ void main() { exclusiveMaximum: 11, multipleOf: 2, ); - expect(schema, { + check(schema as Map).deepEquals({ 'type': 'integer', 'title': 'Foo', 'description': 'Bar', @@ -181,12 +189,16 @@ void main() { test('BooleanSchema', () { final schema = BooleanSchema(title: 'Foo', description: 'Bar'); - expect(schema, {'type': 'boolean', 'title': 'Foo', 'description': 'Bar'}); + check( + schema as Map, + ).deepEquals({'type': 'boolean', 'title': 'Foo', 'description': 'Bar'}); }); test('NullSchema', () { final schema = NullSchema(title: 'Foo', description: 'Bar'); - expect(schema, {'type': 'null', 'title': 'Foo', 'description': 'Bar'}); + check( + schema as Map, + ).deepEquals({'type': 'null', 'title': 'Foo', 'description': 'Bar'}); }); test('ListSchema', () { @@ -200,7 +212,7 @@ void main() { maxItems: 10, uniqueItems: true, ); - expect(schema, { + check(schema as Map).deepEquals({ 'type': 'array', 'title': 'Foo', 'description': 'Bar', @@ -223,7 +235,7 @@ void main() { description: 'Bar', values: {'a', 'b', 'c'}, ); - expect(schema, { + check(schema as Map).deepEquals({ 'type': 'string', 'title': 'Foo', 'description': 'Bar', @@ -241,7 +253,7 @@ void main() { EnumValueWithTitle(constValue: 'c', title: 'C'), ], ); - expect(schema, { + check(schema as Map).deepEquals({ 'type': 'string', 'title': 'Foo', 'description': 'Bar', @@ -259,7 +271,7 @@ void main() { description: 'Bar', values: ['a', 'b', 'c'], ); - expect(schema, { + check(schema as Map).deepEquals({ 'type': 'array', 'title': 'Foo', 'description': 'Bar', @@ -280,7 +292,7 @@ void main() { EnumValueWithTitle(constValue: 'c', title: 'C'), ], ); - expect(schema, { + check(schema as Map).deepEquals({ 'type': 'array', 'title': 'Foo', 'description': 'Bar', @@ -305,7 +317,7 @@ void main() { oneOf: [StringSchema(), IntegerSchema()], not: [StringSchema()], ); - expect(schema, { + check(schema as Map).deepEquals({ 'type': 'boolean', 'title': 'Foo', 'description': 'Bar', @@ -333,14 +345,14 @@ void main() { title: 'twelve', description: 'its the number 12!', ); - expect(schema, { + check(schema as Map).deepEquals({ 'const': 12, 'title': 'twelve', 'description': 'its the number 12!', }); - expect(schema.constValue, 12); - expect(schema.title, 'twelve'); - expect(schema.description, 'its the number 12!'); + check(schema.constValue).equals(12); + check(schema.title).equals('twelve'); + check(schema.description).equals('its the number 12!'); }); }); @@ -2080,10 +2092,10 @@ void main() { ); final request = CallToolRequest(name: 'foo', arguments: {'bar': 'baz'}); final result = await serverConnection.callTool(request); - expect(result.content, hasLength(1)); - expect(result.content.first, isA()); - final textContent = result.content.first as TextContent; - expect(textContent.text, 'baz'); + check(result.content).length.equals(1); + check( + result.content.first, + ).isA().has((t) => t.text, 'text').equals('baz'); }); test('can return a resource link', () async { @@ -2118,13 +2130,12 @@ void main() { ); final request = CallToolRequest(name: 'foo', arguments: {}); final result = await serverConnection.callTool(request); - expect(result.content, hasLength(1)); - expect(result.content.first, isA()); - final resourceLink = result.content.first as ResourceLink; - expect(resourceLink.name, 'foo'); - expect(resourceLink.description, 'a description'); - expect(resourceLink.uri, 'https://google.com'); - expect(resourceLink.mimeType, 'text/html'); + check(result.content).length.equals(1); + check(result.content.first).isA() + ..has((r) => r.name, 'name').equals('foo') + ..has((r) => r.description, 'description').equals('a description') + ..has((r) => r.uri, 'uri').equals('https://google.com') + ..has((r) => r.mimeType, 'mimeType').equals('text/html'); }); test('can return structured content', () async { @@ -2159,7 +2170,7 @@ void main() { ); final request = CallToolRequest(name: 'foo', arguments: {}); final result = await serverConnection.callTool(request); - expect(result.structuredContent, {'bar': 'baz'}); + check(result.structuredContent).isNotNull().deepEquals({'bar': 'baz'}); }); test('can return embedded resources', () async { @@ -2200,34 +2211,33 @@ void main() { ); 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]), - ), - ), - ]), - ); + check(result.content).length.equals(2); + check(result.content as List).unorderedMatches([ + (it) => it + .isA() + .has((r) => r as Map, 'as Map') + .deepEquals( + EmbeddedResource( + resource: TextResourceContents( + uri: 'file:///my_resource', + text: 'Really awesome text', + ), + ) + as Map, + ), + (it) => it + .isA() + .has((r) => r as Map, 'as Map') + .deepEquals( + EmbeddedResource( + resource: BlobResourceContents( + uri: 'file:///my_resource', + blob: base64Encode([1, 2, 3, 4, 5, 6, 7, 8]), + ), + ) + as Map, + ), + ]); }); }); } diff --git a/pkgs/dart_mcp/test/checks/checks.dart b/pkgs/dart_mcp/test/checks/checks.dart new file mode 100644 index 00000000..98a9db52 --- /dev/null +++ b/pkgs/dart_mcp/test/checks/checks.dart @@ -0,0 +1,5 @@ +// Copyright (c) 2026, 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. + +export 'src/api/api.dart'; diff --git a/pkgs/dart_mcp/test/checks/src/api/api.dart b/pkgs/dart_mcp/test/checks/src/api/api.dart new file mode 100644 index 00000000..e8733514 --- /dev/null +++ b/pkgs/dart_mcp/test/checks/src/api/api.dart @@ -0,0 +1,153 @@ +// Copyright (c) 2026, 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. + +library; + +import 'package:checks/checks.dart'; +import 'package:checks/context.dart'; +import 'package:dart_mcp/api.dart'; + +part 'completions.dart'; +part 'elicitation.dart'; +part 'icons.dart'; +part 'initialization.dart'; +part 'logging.dart'; +part 'prompts.dart'; +part 'resources.dart'; +part 'roots.dart'; +part 'sampling.dart'; +part 'tools.dart'; + +extension BaseMetadataChecks on Subject { + Subject get name => has((x) => x.name, 'name'); + Subject get title => has((x) => x.title, 'title'); +} + +extension AnnotatedChecks on Subject { + Subject get annotations => + has((x) => x.annotations, 'annotations'); +} + +extension AnnotationsChecks on Subject { + Subject?> get audience => has((x) => x.audience, 'audience'); + Subject get lastModified => + has((x) => x.lastModified, 'lastModified'); + Subject get priority => has((x) => x.priority, 'priority'); +} + +extension WithMetadataChecks on Subject { + Subject get meta => has((x) => x.meta, 'meta'); +} + +extension RequestChecks on Subject { + Subject get meta => has((x) => x.meta, 'meta'); +} + +extension NotificationChecks on Subject { + Subject get meta => has((x) => x.meta, 'meta'); +} + +extension ResultChecks on Subject { + Subject get meta => has((x) => x.meta, 'meta'); +} + +extension CancelledNotificationChecks on Subject { + Subject get requestId => has((x) => x.requestId, 'requestId'); + Subject get reason => has((x) => x.reason, 'reason'); +} + +extension ProgressNotificationChecks on Subject { + Subject get progressToken => + has((x) => x.progressToken, 'progressToken'); + Subject get progress => has((x) => x.progress, 'progress'); + Subject get total => has((x) => x.total, 'total'); + Subject get message => has((x) => x.message, 'message'); +} + +extension PaginatedRequestChecks on Subject { + Subject get cursor => has((x) => x.cursor, 'cursor'); +} + +extension PaginatedResultChecks on Subject { + Subject get nextCursor => has((x) => x.nextCursor, 'nextCursor'); +} + +extension ContentChecks on Subject { + Subject get type => has((x) => x.type, 'type'); + Subject get isText => has((x) => x.isText, 'isText'); + Subject get isImage => has((x) => x.isImage, 'isImage'); + Subject get isAudio => has((x) => x.isAudio, 'isAudio'); + Subject get isEmbeddedResource => + has((x) => x.isEmbeddedResource, 'isEmbeddedResource'); + + Subject get asText { + return context.nest(() => ['as TextContent'], (actual) { + if (actual.isText) { + return Extracted.value(actual as TextContent); + } + return Extracted.rejection( + which: ['is not a TextContent (type is ${actual.type})'], + ); + }); + } + + Subject get asImage { + return context.nest(() => ['as ImageContent'], (actual) { + if (actual.isImage) { + return Extracted.value(actual as ImageContent); + } + return Extracted.rejection( + which: ['is not an ImageContent (type is ${actual.type})'], + ); + }); + } + + Subject get asAudio { + return context.nest(() => ['as AudioContent'], (actual) { + if (actual.isAudio) { + return Extracted.value(actual as AudioContent); + } + return Extracted.rejection( + which: ['is not an AudioContent (type is ${actual.type})'], + ); + }); + } + + Subject get asEmbeddedResource { + return context.nest(() => ['as EmbeddedResource'], (actual) { + if (actual.isEmbeddedResource) { + return Extracted.value(actual as EmbeddedResource); + } + return Extracted.rejection( + which: ['is not an EmbeddedResource (type is ${actual.type})'], + ); + }); + } +} + +extension TextContentChecks on Subject { + Subject get text => has((x) => x.text, 'text'); +} + +extension ImageContentChecks on Subject { + Subject get data => has((x) => x.data, 'data'); + Subject get mimeType => has((x) => x.mimeType, 'mimeType'); +} + +extension AudioContentChecks on Subject { + Subject get data => has((x) => x.data, 'data'); + Subject get mimeType => has((x) => x.mimeType, 'mimeType'); +} + +extension EmbeddedResourceChecks on Subject { + Subject get resource => has((x) => x.resource, 'resource'); +} + +extension ResourceLinkChecks on Subject { + Subject get description => has((x) => x.description, 'description'); + Subject get uri => has((x) => x.uri, 'uri'); + Subject get mimeType => has((x) => x.mimeType, 'mimeType'); + Subject get size => has((x) => x.size, 'size'); + Subject?> get icons => has((x) => x.icons, 'icons'); +} diff --git a/pkgs/dart_mcp/test/checks/src/api/completions.dart b/pkgs/dart_mcp/test/checks/src/api/completions.dart new file mode 100644 index 00000000..b6dd679c --- /dev/null +++ b/pkgs/dart_mcp/test/checks/src/api/completions.dart @@ -0,0 +1,67 @@ +// Copyright (c) 2026, 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. + +part of 'api.dart'; + +extension CompleteRequestChecks on Subject { + Subject get ref => has((x) => x.ref, 'ref'); + Subject get argument => + has((x) => x.argument, 'argument'); + Subject get context => has((x) => x.context, 'context'); +} + +extension CompleteResultChecks on Subject { + Subject get completion => has((x) => x.completion, 'completion'); +} + +extension CompletionChecks on Subject { + Subject> get values => has((x) => x.values, 'values'); + Subject get total => has((x) => x.total, 'total'); + Subject get hasMore => has((x) => x.hasMore, 'hasMore'); +} + +extension CompletionArgumentChecks on Subject { + Subject get name => has((x) => x.name, 'name'); + Subject get value => has((x) => x.value, 'value'); +} + +extension CompletionContextChecks on Subject { + Subject?> get arguments => + has((x) => x.arguments, 'arguments'); +} + +extension ReferenceChecks on Subject { + Subject get type => has((x) => x.type, 'type'); + Subject get isPrompt => has((x) => x.isPrompt, 'isPrompt'); + Subject get isResource => has((x) => x.isResource, 'isResource'); + + Subject get asPrompt { + return context.nest(() => ['as PromptReference'], (actual) { + if (actual.isPrompt) { + return Extracted.value(actual as PromptReference); + } + return Extracted.rejection( + which: ['is not a PromptReference (type is ${actual.type})'], + ); + }); + } + + Subject get asResource { + return context.nest(() => ['as ResourceTemplateReference'], (actual) { + if (actual.isResource) { + return Extracted.value(actual as ResourceTemplateReference); + } + return Extracted.rejection( + which: [ + 'is not a ResourceTemplateReference (type is ${actual.type})', + ], + ); + }); + } +} + +extension ResourceTemplateReferenceChecks + on Subject { + Subject get uri => has((x) => x.uri, 'uri'); +} diff --git a/pkgs/dart_mcp/test/checks/src/api/elicitation.dart b/pkgs/dart_mcp/test/checks/src/api/elicitation.dart new file mode 100644 index 00000000..1b4c1e06 --- /dev/null +++ b/pkgs/dart_mcp/test/checks/src/api/elicitation.dart @@ -0,0 +1,27 @@ +// Copyright (c) 2026, 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. + +part of 'api.dart'; + +extension ElicitRequestChecks on Subject { + Subject get mode => has((x) => x.mode, 'mode'); + Subject get message => has((x) => x.message, 'message'); + Subject get elicitationId => + has((x) => x.elicitationId, 'elicitationId'); + Subject get url => has((x) => x.url, 'url'); + Subject get requestedSchema => + has((x) => x.requestedSchema, 'requestedSchema'); +} + +extension ElicitResultChecks on Subject { + Subject get action => has((x) => x.action, 'action'); + Subject?> get content => + has((x) => x.content, 'content'); +} + +extension ElicitationCompleteNotificationChecks + on Subject { + Subject get elicitationId => + has((x) => x.elicitationId, 'elicitationId'); +} diff --git a/pkgs/dart_mcp/test/checks/src/api/icons.dart b/pkgs/dart_mcp/test/checks/src/api/icons.dart new file mode 100644 index 00000000..04474c67 --- /dev/null +++ b/pkgs/dart_mcp/test/checks/src/api/icons.dart @@ -0,0 +1,12 @@ +// Copyright (c) 2026, 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. + +part of 'api.dart'; + +extension IconChecks on Subject { + Subject get src => has((x) => x.src, 'src'); + Subject get mimeType => has((x) => x.mimeType, 'mimeType'); + Subject?> get sizes => has((x) => x.sizes, 'sizes'); + Subject get theme => has((x) => x.theme, 'theme'); +} diff --git a/pkgs/dart_mcp/test/checks/src/api/initialization.dart b/pkgs/dart_mcp/test/checks/src/api/initialization.dart new file mode 100644 index 00000000..ba46ec78 --- /dev/null +++ b/pkgs/dart_mcp/test/checks/src/api/initialization.dart @@ -0,0 +1,75 @@ +// Copyright (c) 2026, 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. + +part of 'api.dart'; + +extension InitializeRequestChecks on Subject { + Subject get protocolVersion => + has((x) => x.protocolVersion, 'protocolVersion'); + Subject get capabilities => + has((x) => x.capabilities, 'capabilities'); + Subject get clientInfo => + has((x) => x.clientInfo, 'clientInfo'); +} + +extension InitializeResultChecks on Subject { + Subject get protocolVersion => + has((x) => x.protocolVersion, 'protocolVersion'); + Subject get capabilities => + has((x) => x.capabilities, 'capabilities'); + Subject get serverInfo => + has((x) => x.serverInfo, 'serverInfo'); + Subject get instructions => + has((x) => x.instructions, 'instructions'); +} + +extension ClientCapabilitiesChecks on Subject { + Subject?> get experimental => + has((x) => x.experimental, 'experimental'); + Subject get roots => has((x) => x.roots, 'roots'); + Subject?> get sampling => + has((x) => x.sampling, 'sampling'); + Subject get elicitation => + has((x) => x.elicitation, 'elicitation'); +} + +extension RootsCapabilitiesChecks on Subject { + Subject get listChanged => has((x) => x.listChanged, 'listChanged'); +} + +extension ElicitationCapabilityChecks on Subject { + Subject?> get form => has((x) => x.form, 'form'); + Subject?> get url => has((x) => x.url, 'url'); +} + +extension ServerCapabilitiesChecks on Subject { + Subject?> get experimental => + has((x) => x.experimental, 'experimental'); + Subject get completions => + has((x) => x.completions, 'completions'); + Subject get logging => has((x) => x.logging, 'logging'); + Subject get prompts => has((x) => x.prompts, 'prompts'); + Subject get resources => has((x) => x.resources, 'resources'); + Subject get tools => has((x) => x.tools, 'tools'); +} + +extension PromptsChecks on Subject { + Subject get listChanged => has((x) => x.listChanged, 'listChanged'); +} + +extension ResourcesChecks on Subject { + Subject get listChanged => has((x) => x.listChanged, 'listChanged'); + Subject get subscribe => has((x) => x.subscribe, 'subscribe'); +} + +extension ToolsChecks on Subject { + Subject get listChanged => has((x) => x.listChanged, 'listChanged'); +} + +extension ImplementationChecks on Subject { + Subject get version => has((x) => x.version, 'version'); + Subject get description => has((x) => x.description, 'description'); + Subject?> get icons => has((x) => x.icons, 'icons'); + Subject get websiteUrl => has((x) => x.websiteUrl, 'websiteUrl'); +} diff --git a/pkgs/dart_mcp/test/checks/src/api/logging.dart b/pkgs/dart_mcp/test/checks/src/api/logging.dart new file mode 100644 index 00000000..624416a4 --- /dev/null +++ b/pkgs/dart_mcp/test/checks/src/api/logging.dart @@ -0,0 +1,16 @@ +// Copyright (c) 2026, 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. + +part of 'api.dart'; + +extension SetLevelRequestChecks on Subject { + Subject get level => has((x) => x.level, 'level'); +} + +extension LoggingMessageNotificationChecks + on Subject { + Subject get level => has((x) => x.level, 'level'); + Subject get logger => has((x) => x.logger, 'logger'); + Subject get data => has((x) => x.data, 'data'); +} diff --git a/pkgs/dart_mcp/test/checks/src/api/prompts.dart b/pkgs/dart_mcp/test/checks/src/api/prompts.dart new file mode 100644 index 00000000..26bd3d2c --- /dev/null +++ b/pkgs/dart_mcp/test/checks/src/api/prompts.dart @@ -0,0 +1,43 @@ +// Copyright (c) 2026, 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. + +part of 'api.dart'; + +extension ListPromptsRequestChecks on Subject {} + +extension ListPromptsResultChecks on Subject { + Subject> get prompts => has((x) => x.prompts, 'prompts'); +} + +extension GetPromptRequestChecks on Subject { + Subject get name => has((x) => x.name, 'name'); + Subject?> get arguments => + has((x) => x.arguments, 'arguments'); +} + +extension GetPromptResultChecks on Subject { + Subject get description => has((x) => x.description, 'description'); + Subject> get messages => + has((x) => x.messages, 'messages'); +} + +extension PromptChecks on Subject { + Subject get description => has((x) => x.description, 'description'); + Subject?> get arguments => + has((x) => x.arguments, 'arguments'); + Subject?> get icons => has((x) => x.icons, 'icons'); +} + +extension PromptArgumentChecks on Subject { + Subject get description => has((x) => x.description, 'description'); + Subject get required => has((x) => x.required, 'required'); +} + +extension PromptMessageChecks on Subject { + Subject get role => has((x) => x.role, 'role'); + Subject get content => has((x) => x.content, 'content'); +} + +extension PromptListChangedNotificationChecks + on Subject {} diff --git a/pkgs/dart_mcp/test/checks/src/api/resources.dart b/pkgs/dart_mcp/test/checks/src/api/resources.dart new file mode 100644 index 00000000..1fb249b4 --- /dev/null +++ b/pkgs/dart_mcp/test/checks/src/api/resources.dart @@ -0,0 +1,93 @@ +// Copyright (c) 2026, 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. + +part of 'api.dart'; + +extension ListResourcesRequestChecks on Subject {} + +extension ListResourcesResultChecks on Subject { + Subject> get resources => has((x) => x.resources, 'resources'); +} + +extension ListResourceTemplatesRequestChecks + on Subject {} + +extension ListResourceTemplatesResultChecks + on Subject { + Subject> get resourceTemplates => + has((x) => x.resourceTemplates, 'resourceTemplates'); +} + +extension ReadResourceRequestChecks on Subject { + Subject get uri => has((x) => x.uri, 'uri'); +} + +extension ReadResourceResultChecks on Subject { + Subject> get contents => + has((x) => x.contents, 'contents'); +} + +extension ResourceListChangedNotificationChecks + on Subject {} + +extension SubscribeRequestChecks on Subject { + Subject get uri => has((x) => x.uri, 'uri'); +} + +extension UnsubscribeRequestChecks on Subject { + Subject get uri => has((x) => x.uri, 'uri'); +} + +extension ResourceUpdatedNotificationChecks + on Subject { + Subject get uri => has((x) => x.uri, 'uri'); +} + +extension ResourceChecks on Subject { + Subject get uri => has((x) => x.uri, 'uri'); + Subject get description => has((x) => x.description, 'description'); + Subject get mimeType => has((x) => x.mimeType, 'mimeType'); + Subject get size => has((x) => x.size, 'size'); + Subject?> get icons => has((x) => x.icons, 'icons'); +} + +extension ResourceTemplateChecks on Subject { + Subject get uriTemplate => has((x) => x.uriTemplate, 'uriTemplate'); + Subject get description => has((x) => x.description, 'description'); + Subject get mimeType => has((x) => x.mimeType, 'mimeType'); + Subject?> get icons => has((x) => x.icons, 'icons'); +} + +extension ResourceContentsChecks on Subject { + Subject get uri => has((x) => x.uri, 'uri'); + Subject get mimeType => has((x) => x.mimeType, 'mimeType'); + Subject get isText => has((x) => x.isText, 'isText'); + Subject get isBlob => has((x) => x.isBlob, 'isBlob'); + + Subject get asText { + return context.nest(() => ['as TextResourceContents'], (resource) { + if (resource.isText) { + return Extracted.value(resource as TextResourceContents); + } + return Extracted.rejection(which: ['is not a TextResourceContents']); + }); + } + + Subject get asBlob { + return context.nest(() => ['as BlobResourceContents'], (resource) { + if (resource.isBlob) { + return Extracted.value(resource as BlobResourceContents); + } + return Extracted.rejection(which: ['is not a BlobResourceContents']); + }); + } +} + +extension TextResourceContentsChecks on Subject { + Subject get text => has((x) => x.text, 'text'); +} + +extension BlobResourceContentsChecks on Subject { + Subject get blob => has((x) => x.blob, 'blob'); +} diff --git a/pkgs/dart_mcp/test/checks/src/api/roots.dart b/pkgs/dart_mcp/test/checks/src/api/roots.dart new file mode 100644 index 00000000..bd1efd10 --- /dev/null +++ b/pkgs/dart_mcp/test/checks/src/api/roots.dart @@ -0,0 +1,19 @@ +// Copyright (c) 2026, 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. + +part of 'api.dart'; + +extension ListRootsRequestChecks on Subject {} + +extension ListRootsResultChecks on Subject { + Subject> get roots => has((x) => x.roots, 'roots'); +} + +extension RootChecks on Subject { + Subject get uri => has((x) => x.uri, 'uri'); + Subject get name => has((x) => x.name, 'name'); +} + +extension RootsListChangedNotificationChecks + on Subject {} diff --git a/pkgs/dart_mcp/test/checks/src/api/sampling.dart b/pkgs/dart_mcp/test/checks/src/api/sampling.dart new file mode 100644 index 00000000..e1e3922c --- /dev/null +++ b/pkgs/dart_mcp/test/checks/src/api/sampling.dart @@ -0,0 +1,52 @@ +// Copyright (c) 2026, 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. + +part of 'api.dart'; + +extension CreateMessageRequestChecks on Subject { + Subject> get messages => + has((x) => x.messages, 'messages'); + Subject get modelPreferences => + has((x) => x.modelPreferences, 'modelPreferences'); + Subject get systemPrompt => + has((x) => x.systemPrompt, 'systemPrompt'); + Subject get includeContext => + has((x) => x.includeContext, 'includeContext'); + Subject get temperature => has((x) => x.temperature, 'temperature'); + Subject get maxTokens => has((x) => x.maxTokens, 'maxTokens'); + Subject?> get stopSequences => + has((x) => x.stopSequences, 'stopSequences'); + Subject get toolChoice => has((x) => x.toolChoice, 'toolChoice'); + Subject?> get metadata => + has((x) => x.metadata, 'metadata'); +} + +extension CreateMessageResultChecks on Subject { + Subject get model => has((x) => x.model, 'model'); + Subject get stopReason => has((x) => x.stopReason, 'stopReason'); +} + +extension SamplingMessageChecks on Subject { + Subject get role => has((x) => x.role, 'role'); + Subject get content => + has((x) => x.content, 'content'); +} + +extension ModelPreferencesChecks on Subject { + Subject?> get hints => has((x) => x.hints, 'hints'); + Subject get costPriority => + has((x) => x.costPriority, 'costPriority'); + Subject get speedPriority => + has((x) => x.speedPriority, 'speedPriority'); + Subject get intelligencePriority => + has((x) => x.intelligencePriority, 'intelligencePriority'); +} + +extension ModelHintChecks on Subject { + Subject get name => has((x) => x.name, 'name'); +} + +extension ToolChoiceChecks on Subject { + Subject get mode => has((x) => x.mode, 'mode'); +} diff --git a/pkgs/dart_mcp/test/checks/src/api/tools.dart b/pkgs/dart_mcp/test/checks/src/api/tools.dart new file mode 100644 index 00000000..5b4fe61e --- /dev/null +++ b/pkgs/dart_mcp/test/checks/src/api/tools.dart @@ -0,0 +1,39 @@ +// Copyright (c) 2026, 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. + +part of 'api.dart'; + +extension ListToolsRequestChecks on Subject {} + +extension ListToolsResultChecks on Subject { + Subject> get tools => has((x) => x.tools, 'tools'); +} + +extension CallToolRequestChecks on Subject { + Subject get name => has((x) => x.name, 'name'); + Subject?> get arguments => + has((x) => x.arguments, 'arguments'); +} + +extension CallToolResultChecks on Subject { + Subject> get content => has((x) => x.content, 'content'); + Subject?> get structuredContent => + has((x) => x.structuredContent, 'structuredContent'); + Subject get isError => has((x) => x.isError, 'isError'); +} + +extension ToolChecks on Subject { + Subject get description => has((x) => x.description, 'description'); + Subject get inputSchema => + has((x) => x.inputSchema, 'inputSchema'); + Subject get outputSchema => + has((x) => x.outputSchema, 'outputSchema'); + Subject?> get icons => has((x) => x.icons, 'icons'); +} + +extension ValidationErrorChecks on Subject { + Subject get error => has((x) => x.error, 'error'); + Subject> get path => has((x) => x.path, 'path'); + Subject get details => has((x) => x.details, 'details'); +} diff --git a/pkgs/dart_mcp/test/client/elicitation_test.dart b/pkgs/dart_mcp/test/client/elicitation_test.dart index 72b8a690..219705d8 100644 --- a/pkgs/dart_mcp/test/client/elicitation_test.dart +++ b/pkgs/dart_mcp/test/client/elicitation_test.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:checks/checks.dart'; import 'package:dart_mcp/client.dart'; import 'package:dart_mcp/server.dart'; import 'package:json_rpc_2/json_rpc_2.dart'; @@ -38,8 +39,8 @@ void main() { ), ), ); - expect(result.action, ElicitationAction.accept); - expect(result.content, {'name': 'John Doe'}); + check(result.action).equals(ElicitationAction.accept); + check(result.content).isNotNull().deepEquals({'name': 'John Doe'}); }); }); @@ -91,13 +92,15 @@ void main() { }, ); - expect(server.userHasCompletedUrlElicitation, false); + check(server.userHasCompletedUrlElicitation).isFalse(); final result = await environment.serverConnection.callTool( CallToolRequest(name: 'test_tool'), ); - expect(server.userHasCompletedUrlElicitation, true); - expect((result.content.first as TextContent).text, 'success'); + check(server.userHasCompletedUrlElicitation).isTrue(); + check( + result.content.first, + ).isA().has((t) => t.text, 'text').equals('success'); }, ); @@ -132,20 +135,17 @@ void main() { }, ); - await expectLater( - () => environment.serverConnection.callTool( + await check( + environment.serverConnection.callTool( CallToolRequest(name: 'test_tool'), ), - throwsA( - isA().having( - (e) => e.code, - 'code', - McpErrorCodes.urlElicitationRequired, - ), - ), + ).throws( + (it) => it + .has((e) => e.code, 'code') + .equals(McpErrorCodes.urlElicitationRequired), ); - expect(toolCallCount, 1); + check(toolCallCount).equals(1); }); }); } diff --git a/pkgs/dart_mcp/test/client_and_server_test.dart b/pkgs/dart_mcp/test/client_and_server_test.dart index 78180be9..df3931f9 100644 --- a/pkgs/dart_mcp/test/client_and_server_test.dart +++ b/pkgs/dart_mcp/test/client_and_server_test.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:async/async.dart'; +import 'package:checks/checks.dart'; import 'package:dart_mcp/client.dart'; import 'package:dart_mcp/server.dart'; import 'package:json_rpc_2/error_code.dart'; @@ -19,68 +20,75 @@ void main() { final environment = TestEnvironment(TestMCPClient(), TestMCPServer.new); final initializeResult = await environment.initializeServer(); - expect(initializeResult.capabilities, isEmpty); - expect(initializeResult.instructions, environment.server.instructions); - expect(initializeResult.protocolVersion, ProtocolVersion.latestSupported); - - expect(environment.server.clientInfo, environment.client.implementation); - expect( - environment.serverConnection.serverInfo, - environment.server.implementation, - ); - - expect( + check(initializeResult.capabilities as Map).isEmpty(); + check( + initializeResult.instructions, + ).equals(environment.server.instructions); + check( + initializeResult.protocolVersion, + ).equals(ProtocolVersion.latestSupported); + + check(environment.server.clientInfo as Map?) + .isNotNull() + .deepEquals(environment.client.implementation as Map); + check(environment.serverConnection.serverInfo as Map?) + .isNotNull() + .deepEquals(environment.server.implementation as Map); + + await check( environment.serverConnection.listTools(ListToolsRequest()), - throwsA( - isA().having((e) => e.code, 'code', METHOD_NOT_FOUND), - ), - reason: 'The client calling unsupported methods should throw', + ).throws( + (it) => it.has((e) => e.code, 'code').equals(METHOD_NOT_FOUND), ); - expect( + await check( environment.server.createMessage( CreateMessageRequest(messages: [], maxTokens: 1), ), - throwsA( - isA().having((e) => e.code, 'code', METHOD_NOT_FOUND), - ), - reason: 'The server calling unsupported methods should throw', + ).throws( + (it) => it.has((e) => e.code, 'code').equals(METHOD_NOT_FOUND), ); }); test('client and server can capture protocol messages', () async { final clientLog = StreamController(); final serverLog = StreamController(); + final clientLogQueue = StreamQueue(clientLog.stream); + final serverLogQueue = StreamQueue(serverLog.stream); final environment = TestEnvironment( TestMCPClient(), (c) => TestMCPServer(c, protocolLogSink: serverLog.sink), protocolLogSink: clientLog.sink, ); await environment.initializeServer(); - expect( - clientLog.stream, - emitsInOrder([ - allOf(startsWith('>>>'), contains('initialize')), - allOf(startsWith('<<<'), contains('serverInfo')), - allOf(startsWith('>>>'), contains('notifications/initialized')), - ]), - ); - expect( - serverLog.stream, - emitsInOrder([ - allOf(startsWith('<<<'), contains('initialize')), - allOf(startsWith('>>>'), contains('serverInfo')), - allOf(startsWith('<<<'), contains('notifications/initialized')), - ]), - ); + + check(await clientLogQueue.next) + ..startsWith('>>>') + ..contains('initialize'); + check(await clientLogQueue.next) + ..startsWith('<<<') + ..contains('serverInfo'); + check(await clientLogQueue.next) + ..startsWith('>>>') + ..contains('notifications/initialized'); + + check(await serverLogQueue.next) + ..startsWith('<<<') + ..contains('initialize'); + check(await serverLogQueue.next) + ..startsWith('>>>') + ..contains('serverInfo'); + check(await serverLogQueue.next) + ..startsWith('<<<') + ..contains('notifications/initialized'); }); test('client and server can ping each other', () async { final environment = TestEnvironment(TestMCPClient(), TestMCPServer.new); await environment.initializeServer(); - expect(await environment.serverConnection.ping(), true); - expect(await environment.server.ping(), true); + check(await environment.serverConnection.ping()).isTrue(); + check(await environment.server.ping()).isTrue(); }); test('client can handle ping timeouts', () async { @@ -98,12 +106,11 @@ void main() { }); await environment.initializeServer(); - expect( + check( await environment.serverConnection.ping( timeout: const Duration(milliseconds: 1), ), - false, - ); + ).isFalse(); }); test('server can handle ping timeouts', () async { @@ -121,10 +128,9 @@ void main() { }); await environment.initializeServer(); - expect( + check( await environment.server.ping(timeout: const Duration(milliseconds: 1)), - false, - ); + ).isFalse(); }); // Regression test for https://github.com/dart-lang/ai/issues/238. @@ -132,14 +138,10 @@ void main() { final environment = TestEnvironment(TestMCPClient(), TestMCPServer.new); await environment.initializeServer(); - await expectLater( + await check( environment.serverConnection.ping(request: PingRequest()), - completes, - ); - await expectLater( - environment.server.ping(request: PingRequest()), - completes, - ); + ).completes(); + await check(environment.server.ping(request: PingRequest())).completes(); }); test( @@ -147,6 +149,7 @@ void main() { () async { for (final initializedMessage in [null, InitializedNotification()]) { final serverLog = StreamController(); + final serverLogQueue = StreamQueue(serverLog.stream); final environment = TestEnvironment( TestMCPClient(), (c) => TestMCPServer(c, protocolLogSink: serverLog.sink), @@ -161,15 +164,24 @@ void main() { // Send a notification that doesn't have any parameters. environment.serverConnection.notifyInitialized(initializedMessage); final result = await environment.server.initialized; - expect(result, initializedMessage); - expect( - serverLog.stream, - emitsInOrder([ - allOf(startsWith('<<<'), contains('initialize')), - allOf(startsWith('>>>'), contains('serverInfo')), - allOf(startsWith('<<<'), contains('notifications/initialized')), - ]), - ); + if (initializedMessage == null) { + check(result).isNull(); + } else { + check( + result as Map?, + ).isNotNull().deepEquals(initializedMessage as Map); + } + + check(await serverLogQueue.next) + ..startsWith('<<<') + ..contains('initialize'); + check(await serverLogQueue.next) + ..startsWith('>>>') + ..contains('serverInfo'); + check(await serverLogQueue.next) + ..startsWith('<<<') + ..contains('notifications/initialized'); + await environment.client.shutdown(); } }, @@ -188,26 +200,8 @@ void main() { meta: MetaWithProgressToken(progressToken: ProgressToken(1337)), ); - expect( - serverConnection.onProgress(request), - emits( - ProgressNotification( - progressToken: request.meta!.progressToken!, - progress: 50, - ), - ), - ); - - expect( - serverConnection.onProgress(request), - neverEmits( - ProgressNotification( - progressToken: request.meta!.progressToken!, - progress: 100, - ), - ), - reason: 'Should not receive progress events for completed requests', - ); + final events = []; + final sub = serverConnection.onProgress(request).listen(events.add); // Ensure the subscription is set up before calling the tool. await pumpEventQueue(); @@ -218,6 +212,16 @@ void main() { // Give the bad notification time to hit our stream. await pumpEventQueue(); + + check(events as List).deepEquals([ + ProgressNotification( + progressToken: request.meta!.progressToken!, + progress: 50, + ) + as Map, + ]); + + await sub.cancel(); }); test('servers can handle progress notifications', () async { @@ -245,6 +249,9 @@ void main() { meta: MetaWithProgressToken(progressToken: ProgressToken(1337)), ); + final events = []; + final sub = server.onProgress(request).listen(events.add); + // Ensure the subscription is set up before calling the tool. await pumpEventQueue(); @@ -253,30 +260,30 @@ void main() { progressToken: request.meta!.progressToken!, progress: 50, ); - expect(server.onProgress(request), emits(expectedNotification)); + + environment.serverConnection.notifyProgress(expectedNotification); + await onDone; final lateNotification = ProgressNotification( progressToken: request.meta!.progressToken!, progress: 100, ); - expect( - server.onProgress(request), - neverEmits(lateNotification), - reason: 'Should not receive progress events for completed requests', - ); - - environment.serverConnection.notifyProgress(expectedNotification); - await onDone; environment.serverConnection.notifyProgress(lateNotification); // Give the bad notification time to hit our stream. await pumpEventQueue(); + + check( + events as List, + ).deepEquals([expectedNotification as Map]); + + await sub.cancel(); }); test('closing a server removes the connection', () async { final environment = TestEnvironment(TestMCPClient(), TestMCPServer.new); await environment.serverConnection.shutdown(); - expect(environment.client.connections, isEmpty); + check(environment.client.connections).isEmpty(); }); group('version negotiation', () { @@ -290,8 +297,12 @@ void main() { clientInfo: environment.client.implementation, ), ); - expect(initializeResult.protocolVersion, ProtocolVersion.oldestSupported); - expect(serverConnection.protocolVersion, ProtocolVersion.oldestSupported); + check( + initializeResult.protocolVersion, + ).equals(ProtocolVersion.oldestSupported); + check( + serverConnection.protocolVersion, + ).equals(ProtocolVersion.oldestSupported); }); test('server can downgrade the version', () async { final environment = TestEnvironment( @@ -300,7 +311,9 @@ void main() { ); final initializeResult = await environment.initializeServer(); - expect(initializeResult.protocolVersion, ProtocolVersion.oldestSupported); + check( + initializeResult.protocolVersion, + ).equals(ProtocolVersion.oldestSupported); }); test('server can accept a lower version', () async { @@ -308,7 +321,9 @@ void main() { final initializeResult = await environment.initializeServer( protocolVersion: ProtocolVersion.oldestSupported, ); - expect(initializeResult.protocolVersion, ProtocolVersion.oldestSupported); + check( + initializeResult.protocolVersion, + ).equals(ProtocolVersion.oldestSupported); }); test( @@ -319,8 +334,8 @@ void main() { TestUnrecognizedVersionMcpServer.new, ); await environment.initializeServer(); - expect(environment.client.connections, isEmpty); - expect(environment.serverConnection.isActive, false); + check(environment.client.connections).isEmpty(); + check(environment.serverConnection.isActive).isFalse(); }, ); }); @@ -328,35 +343,49 @@ void main() { group('error handling', () { test('client can handle invalid protocol messages', () async { final protocolController = StreamController(); + final logEvents = []; + final sub = protocolController.stream.listen(logEvents.add); final environment = TestEnvironment( TestMCPClient(), TestMCPServer.new, protocolLogSink: protocolController.sink, ); environment.serverChannel.sink.add('Just some random text'); - expect( - protocolController.stream, - emitsThrough(allOf(startsWith('>>>'), contains('Invalid JSON'))), + + await check(environment.initializeServer()).completes(); + + await sub.cancel(); + check(logEvents).any( + (it) => + it.isA() + ..startsWith('>>>') + ..contains('Invalid JSON'), ); - expect(environment.initializeServer(), completes); }); test('server can handle invalid protocol messages', () async { final protocolController = StreamController(); + final logEvents = []; + final sub = protocolController.stream.listen(logEvents.add); final environment = TestEnvironment( TestMCPClient(), TestMCPServer.new, protocolLogSink: protocolController.sink, ); environment.clientChannel.sink.add('Just some random text'); - expect( - protocolController.stream, - emitsThrough(allOf(startsWith('<<<'), contains('Invalid JSON'))), + + await check(environment.initializeServer()).completes(); + + await sub.cancel(); + check(logEvents).any( + (it) => + it.isA() + ..startsWith('<<<') + ..contains('Invalid JSON'), ); - expect(environment.initializeServer(), completes); }); - test('server exits before initialization', () { + test('server exits before initialization', () async { final client = TestMCPClient(); final clientController = StreamController(); final serverController = StreamController(); @@ -370,7 +399,7 @@ void main() { ); final connection = client.connectServer(clientChannel); - expect( + final initFuture = check( connection.initialize( InitializeRequest( protocolVersion: ProtocolVersion.latestSupported, @@ -378,23 +407,22 @@ void main() { clientInfo: Implementation(name: '', version: ''), ), ), - throwsA( - isA().having( - (e) => e.message, - 'message', - 'The client closed with pending request "initialize".', - ), - ), + ).throws( + (it) => it + .has((e) => e.message, 'message') + .equals('The client closed with pending request "initialize".'), ); // This shuts down the channel between the client and server, so it // happens during the initialization request (which the server never) // responds to. - serverChannel.sink.close(); + unawaited(serverChannel.sink.close()); + + await initFuture; addTearDown(() { - expect(connection.isActive, false); - expect(client.connections, isEmpty); + check(connection.isActive).isFalse(); + check(client.connections).isEmpty(); }); }); }); diff --git a/pkgs/dart_mcp/test/server/resources_support_test.dart b/pkgs/dart_mcp/test/server/resources_support_test.dart index e15465d1..6997407f 100644 --- a/pkgs/dart_mcp/test/server/resources_support_test.dart +++ b/pkgs/dart_mcp/test/server/resources_support_test.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:async/async.dart'; +import 'package:checks/checks.dart'; import 'package:dart_mcp/server.dart'; import 'package:test/test.dart'; @@ -18,31 +19,25 @@ void main() { ); final initializeResult = await environment.initializeServer(); - expect( - initializeResult.capabilities.resources, - equals(Resources(listChanged: true, subscribe: true)), + check( + initializeResult.capabilities.resources as Map, + ).deepEquals( + Resources(listChanged: true, subscribe: true) as Map, ); final serverConnection = environment.serverConnection; final resourcesResult = await serverConnection.listResources(); - expect(resourcesResult.resources.length, 1); + check(resourcesResult.resources).has((r) => r.length, 'length').equals(1); final resource = resourcesResult.resources.single; final result = await serverConnection.readResource( ReadResourceRequest(uri: resource.uri), ); - expect( - result.contents.single, - isA() - .having((c) => c.isText, 'isText', true) - .having( - (c) => (c as TextResourceContents).text, - 'text', - 'hello world!', - ), - ); + final contents = result.contents.single; + check(contents.isText).isTrue(); + check((contents as TextResourceContents).text).equals('hello world!'); }); test('client can subscribe to resource updates from the server', () async { @@ -74,10 +69,14 @@ void main() { final resources = await serverConnection.listResources( ListResourcesRequest(), ); - expect( - resources.resources, - unorderedEquals([fooResource, TestMCPServerWithResources.helloWorld]), - ); + check(resources.resources as List).unorderedMatches([ + (it) => it.isA>().deepEquals( + fooResource as Map, + ), + (it) => it.isA>().deepEquals( + TestMCPServerWithResources.helloWorld as Map, + ), + ]); final resourceChangedQueue = StreamQueue(serverConnection.resourceUpdated); await serverConnection.subscribeResource( @@ -87,27 +86,17 @@ void main() { fooContents = 'baz'; server.updateResource(fooResource); - expect( - await resourceChangedQueue.next, - isA().having( - (n) => n.uri, - 'uri', - fooResource.uri, - ), - ); + check(await resourceChangedQueue.next) + .isA() + .has((n) => n.uri, 'uri') + .equals(fooResource.uri); - expect( - await serverConnection.readResource( - ReadResourceRequest(uri: fooResource.uri), - ), - isA().having( - (r) => r.contents.single, - 'contents', - isA() - .having((c) => c.text, 'text', 'baz') - .having((c) => c.uri, 'uri', fooResource.uri), - ), + final readResult = await serverConnection.readResource( + ReadResourceRequest(uri: fooResource.uri), ); + check(readResult.contents.single).isA() + ..has((c) => c.text, 'text').equals('baz') + ..has((c) => c.uri, 'uri').equals(fooResource.uri); await serverConnection.unsubscribeResource( UnsubscribeRequest(uri: fooResource.uri), @@ -116,23 +105,29 @@ void main() { fooContents = 'zap'; server.updateResource(fooResource); - expect(resourceChangedQueue.hasNext, completion(false)); + final resourceChangedHasNext = check( + resourceChangedQueue.hasNext, + ).completes((it) => it.isFalse()); server.removeResource(fooResource.uri); - expect( - await resourceListChangedQueue.next, - ResourceListChangedNotification(), - ); + check( + await resourceListChangedQueue.next as Map, + ).deepEquals(ResourceListChangedNotification() as Map); server.sendNotification(ResourceListChangedNotification.methodName); - expect(await resourceListChangedQueue.next, null); + check(await resourceListChangedQueue.next).isNull(); - expect(resourceListChangedQueue.hasNext, completion(false)); + final resourceListChangedHasNext = check( + resourceListChangedQueue.hasNext, + ).completes((it) => it.isFalse()); /// We need to manually shut down to so that the `hasNext` futures can /// complete. await environment.shutdown(); + + await resourceChangedHasNext; + await resourceListChangedHasNext; }); test('resource change notifications are throttled', () async { @@ -166,7 +161,9 @@ void main() { // Should get exactly two notifications even though we have more resources, // one initial notification and one after the throttle delay. await resourceListChangedQueue.take(2); - expect(resourceListChangedQueue.hasNext, completion(false)); + final resourceListChangedHasNext = check( + resourceListChangedQueue.hasNext, + ).completes((it) => it.isFalse()); await pumpEventQueue(); final resourceChangedQueue = StreamQueue(serverConnection.resourceUpdated); @@ -185,19 +182,20 @@ void main() { // Only two should make it through, one at the start and one after the // timeout. for (var i = 0; i < 2; i++) { - expect( - await resourceChangedQueue.next, - isA().having( - (n) => n.uri, - 'uri', - resource.uri, - ), - ); + check(await resourceChangedQueue.next) + .isA() + .has((n) => n.uri, 'uri') + .equals(resource.uri); } - expect(resourceChangedQueue.hasNext, completion(false)); + final resourceChangedHasNext = check( + resourceChangedQueue.hasNext, + ).completes((it) => it.isFalse()); await pumpEventQueue(); await environment.shutdown(); + + await resourceListChangedHasNext; + await resourceChangedHasNext; }); test( @@ -216,18 +214,18 @@ void main() { final templatesResponse = await serverConnection.listResourceTemplates(); - expect( - templatesResponse.resourceTemplates.single, - TestMCPServerWithResources.packageUriTemplate, + check( + templatesResponse.resourceTemplates.single as Map, + ).deepEquals( + TestMCPServerWithResources.packageUriTemplate as Map, ); final readResourceResponse = await serverConnection.readResource( ReadResourceRequest(uri: 'package:foo/foo.dart'), ); - expect( + check( (readResourceResponse.contents.single as TextResourceContents).text, - 'hello world!', - ); + ).equals('hello world!'); }, ); } diff --git a/pkgs/dart_mcp/test/server/roots_tracking_support_test.dart b/pkgs/dart_mcp/test/server/roots_tracking_support_test.dart index 9716c6e3..2df45ac7 100644 --- a/pkgs/dart_mcp/test/server/roots_tracking_support_test.dart +++ b/pkgs/dart_mcp/test/server/roots_tracking_support_test.dart @@ -3,6 +3,7 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:async'; +import 'package:checks/checks.dart'; import 'package:dart_mcp/client.dart'; import 'package:dart_mcp/server.dart'; import 'package:test/test.dart'; @@ -24,37 +25,57 @@ void main() { final b = Root(uri: 'test://b', name: 'b'); /// Basic interactions, add and remove some roots. - expect(await server.roots, isEmpty); - expect(client.addRoot(a), isTrue); + check(await server.roots).isEmpty(); + check(client.addRoot(a)).isTrue(); await pumpEventQueue(); - expect(await server.roots, [a]); - expect(client.addRoot(b), isTrue); + check( + (await server.roots) as List, + ).deepEquals([a as Map]); + check(client.addRoot(b)).isTrue(); await pumpEventQueue(); - expect(await server.roots, unorderedEquals([a, b])); + check((await server.roots) as List).unorderedMatches([ + (it) => + it.isA>().deepEquals(a as Map), + (it) => + it.isA>().deepEquals(b as Map), + ]); final completer = Completer(); client.waitToRespond = completer.future; final c = Root(uri: 'test://c', name: 'c'); final d = Root(uri: 'test://d', name: 'd'); - expect(client.addRoot(c), isTrue); + check(client.addRoot(c)).isTrue(); await pumpEventQueue(); - expect( - server.roots, - isA(), - reason: 'Server is waiting to fetch new roots', - ); - expect( - server.roots, - completion(unorderedEquals([b, c, d])), - reason: 'Should not see intermediate states', + check(server.roots).isA>(); + + final rootsFuture = check(server.roots).isA>>().completes( + (it) => it.isA>().unorderedMatches([ + (it) => it.isA>().deepEquals( + b as Map, + ), + (it) => it.isA>().deepEquals( + c as Map, + ), + (it) => it.isA>().deepEquals( + d as Map, + ), + ]), ); - expect(client.addRoot(d), isTrue); + check(client.addRoot(d)).isTrue(); await pumpEventQueue(); - expect(client.removeRoot(a), isTrue); + check(client.removeRoot(a)).isTrue(); await pumpEventQueue(); completer.complete(); client.waitToRespond = null; - expect(await server.roots, unorderedEquals([b, c, d])); + await rootsFuture; + check((await server.roots) as List).unorderedMatches([ + (it) => + it.isA>().deepEquals(b as Map), + (it) => + it.isA>().deepEquals(c as Map), + (it) => + it.isA>().deepEquals(d as Map), + ]); }); } diff --git a/pkgs/dart_mcp/test/server/tools_support_test.dart b/pkgs/dart_mcp/test/server/tools_support_test.dart index 84f9825d..ec091eee 100644 --- a/pkgs/dart_mcp/test/server/tools_support_test.dart +++ b/pkgs/dart_mcp/test/server/tools_support_test.dart @@ -4,6 +4,8 @@ import 'dart:async'; +import 'package:async/async.dart'; +import 'package:checks/checks.dart'; import 'package:dart_mcp/server.dart'; import 'package:test/test.dart'; @@ -16,15 +18,14 @@ void main() { TestMCPServerWithTools.new, ); final initializeResult = await environment.initializeServer(); - expect( - initializeResult.capabilities.tools, - equals(Tools(listChanged: true)), - ); + check( + initializeResult.capabilities.tools as Map, + ).deepEquals(Tools(listChanged: true) as Map); final serverConnection = environment.serverConnection; final toolsResult = await serverConnection.listTools(); - expect(toolsResult.tools.length, 2); + check(toolsResult.tools).has((t) => t.length, 'length').equals(2); final tool = toolsResult.tools.firstWhere( (tool) => tool.name == TestMCPServerWithTools.helloWorld.name, @@ -33,14 +34,15 @@ void main() { final result = await serverConnection.callTool( CallToolRequest(name: tool.name), ); - expect(result.isError, isNot(true)); - expect(result.content.single, TestMCPServerWithTools.helloWorldContent); - - expect( - await serverConnection.listTools(ListToolsRequest()), - toolsResult, - reason: 'can list tools with a non-null request object', + check(result.isError).not((it) => it.equals(true)); + check(result.content.single as Map).deepEquals( + TestMCPServerWithTools.helloWorldContent as Map, ); + + check( + (await serverConnection.listTools(ListToolsRequest())) + as Map, + ).deepEquals(toolsResult as Map); }); test('client can subscribe to tool list updates from the server', () async { @@ -53,14 +55,7 @@ void main() { final serverConnection = environment.serverConnection; final server = environment.server; - expect( - serverConnection.toolListChanged, - emitsInOrder([ - ToolListChangedNotification(), - ToolListChangedNotification(), - null, - ]), - ); + final toolListChangedQueue = StreamQueue(serverConnection.toolListChanged); server.registerTool( Tool(name: 'foo', inputSchema: ObjectSchema()), @@ -73,8 +68,22 @@ void main() { // Give the notifications time to be received. await pumpEventQueue(); + check( + await toolListChangedQueue.next as Map, + ).deepEquals(ToolListChangedNotification() as Map); + check( + await toolListChangedQueue.next as Map, + ).deepEquals(ToolListChangedNotification() as Map); + check(await toolListChangedQueue.next).isNull(); + + final hasNextFuture = check( + toolListChangedQueue.hasNext, + ).completes((it) => it.isFalse()); + // Need to manually close so the stream matchers can complete. await environment.shutdown(); + + await hasNextFuture; }); test('schema validation failure returns an error', () async { @@ -93,13 +102,12 @@ void main() { arguments: const {}, ), ); - expect(result.isError, isTrue); - expect(result.content.single, isA()); + check(result.isError).isNotNull().isTrue(); + check(result.content.single).isA(); final textContent = result.content.single as TextContent; - expect( + check( textContent.text, - contains('Required property "message" is missing at path #root'), - ); + ).contains('Required property "message" is missing at path #root'); // Call with wrong type for 'message'. result = await serverConnection.callTool( @@ -108,13 +116,12 @@ void main() { arguments: {'message': 123}, ), ); - expect(result.isError, isTrue); - expect(result.content.single, isA()); + check(result.isError).isNotNull().isTrue(); + check(result.content.single).isA(); final textContent2 = result.content.single as TextContent; - expect( + check( textContent2.text, - contains('Value `123` is not of type `String` at path #root["message"]'), - ); + ).contains('Value `123` is not of type `String` at path #root["message"]'); }); }