| // Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| import 'package:dart_mcp/client.dart'; |
| import 'package:test/test.dart'; |
| |
| void main() { |
| // A getter reads the same null whether the key was omitted or written as |
| // null, so these assert on the map. |
| test('metadata and meta leave out what they are not given', () { |
| expect(BaseMetadata(name: 'n') as Map<String, Object?>, {'name': 'n'}); |
| expect(BaseMetadata(name: 'n', title: 't') as Map<String, Object?>, { |
| 'name': 'n', |
| 'title': 't', |
| }); |
| expect(MetaWithProgressToken() as Map<String, Object?>, isEmpty); |
| expect( |
| MetaWithProgressToken(progressToken: ProgressToken(1)) |
| as Map<String, Object?>, |
| {'progressToken': 1}, |
| ); |
| }); |
| |
| test('protocol versions can be compared', () { |
| expect( |
| ProtocolVersion.latestSupported > ProtocolVersion.oldestSupported, |
| true, |
| ); |
| expect( |
| ProtocolVersion.latestSupported >= ProtocolVersion.oldestSupported, |
| true, |
| ); |
| expect( |
| ProtocolVersion.latestSupported < ProtocolVersion.oldestSupported, |
| false, |
| ); |
| expect( |
| ProtocolVersion.latestSupported <= ProtocolVersion.oldestSupported, |
| false, |
| ); |
| |
| expect( |
| ProtocolVersion.oldestSupported > ProtocolVersion.latestSupported, |
| false, |
| ); |
| expect( |
| ProtocolVersion.oldestSupported >= ProtocolVersion.latestSupported, |
| false, |
| ); |
| expect( |
| ProtocolVersion.oldestSupported < ProtocolVersion.latestSupported, |
| true, |
| ); |
| expect( |
| ProtocolVersion.oldestSupported <= ProtocolVersion.latestSupported, |
| true, |
| ); |
| |
| expect( |
| ProtocolVersion.latestSupported <= ProtocolVersion.latestSupported, |
| true, |
| ); |
| expect( |
| ProtocolVersion.latestSupported >= ProtocolVersion.latestSupported, |
| true, |
| ); |
| expect( |
| ProtocolVersion.latestSupported < ProtocolVersion.latestSupported, |
| false, |
| ); |
| expect( |
| ProtocolVersion.latestSupported > ProtocolVersion.latestSupported, |
| false, |
| ); |
| }); |
| |
| test('the legacy handshake does not support the request-scoped era', () { |
| // The 2026-07-28 revision is spoken by its own transport; the legacy |
| // handshake refusing it is what downgrades a modern server talking to a |
| // legacy client, so this must stay false until that handshake learns |
| // the revision. |
| expect(ProtocolVersion.v2026_07_28.isSupported, false); |
| expect(ProtocolVersion.v2026_07_28 > ProtocolVersion.latestSupported, true); |
| }); |
| |
| test('protocol versions declare Streamable HTTP support', () { |
| expect( |
| { |
| for (final version in ProtocolVersion.values) |
| version.versionString: version.supportsStreamableHttp, |
| }, |
| { |
| '2024-11-05': false, |
| '2025-03-26': false, |
| '2025-06-18': false, |
| '2025-11-25': false, |
| '2026-07-28': true, |
| }, |
| ); |
| }); |
| |
| test('a method holds from the revision which added it', () { |
| expect(ProtocolVersion.v2024_11_05.methodIsValid('tools/call'), true); |
| expect(ProtocolVersion.v2026_07_28.methodIsValid('tools/call'), true); |
| expect( |
| ProtocolVersion.v2025_03_26.methodIsValid('elicitation/create'), |
| false, |
| ); |
| expect( |
| ProtocolVersion.v2025_06_18.methodIsValid('elicitation/create'), |
| true, |
| ); |
| expect(ProtocolVersion.v2025_11_25.methodIsValid('server/discover'), false); |
| expect(ProtocolVersion.v2026_07_28.methodIsValid('server/discover'), true); |
| }); |
| |
| test('the 2026-07-28 removal set is pinned', () { |
| // Pinned so an entry cannot fall out of the set unnoticed. The walk test |
| // below reads the set and would follow it. |
| expect(ProtocolVersion.v2026_07_28.removedMethods, { |
| 'elicitation/create', |
| 'initialize', |
| 'logging/setLevel', |
| 'notifications/elicitation/complete', |
| 'notifications/initialized', |
| 'notifications/roots/list_changed', |
| 'notifications/tasks/status', |
| 'ping', |
| 'resources/subscribe', |
| 'resources/unsubscribe', |
| 'roots/list', |
| 'sampling/createMessage', |
| 'tasks/cancel', |
| 'tasks/get', |
| 'tasks/list', |
| 'tasks/result', |
| }); |
| }); |
| |
| test('a method ends at the revision which removed it', () { |
| for (var method in ProtocolVersion.v2026_07_28.removedMethods) { |
| expect( |
| ProtocolVersion.v2025_11_25.methodIsValid(method), |
| true, |
| reason: '$method on 2025-11-25', |
| ); |
| expect( |
| ProtocolVersion.v2026_07_28.methodIsValid(method), |
| false, |
| reason: '$method on 2026-07-28', |
| ); |
| } |
| }); |
| |
| test('a method no revision defines is not valid on any of them', () { |
| for (var version in ProtocolVersion.values) { |
| expect(version.methodIsValid('no/such/method'), false); |
| } |
| }); |
| |
| group('API object validation', () { |
| test('throws when required fields are missing', () { |
| expect(() => Root.fromMap({}).uri, throwsA(isA<ArgumentError>())); |
| expect( |
| () => Implementation.fromMap({'name': 'test'}).version, |
| throwsA(isA<ArgumentError>()), |
| ); |
| expect( |
| () => BaseMetadata.fromMap({}).name, |
| throwsA(isA<ArgumentError>()), |
| ); |
| |
| final empty = <String, Object?>{}; |
| |
| // Initialization |
| expect( |
| () => (empty as InitializeRequest).capabilities, |
| throwsArgumentError, |
| ); |
| expect( |
| () => (empty as InitializeRequest).clientInfo, |
| throwsArgumentError, |
| ); |
| expect( |
| () => (empty as DiscoverResult).supportedVersions, |
| throwsArgumentError, |
| ); |
| expect(() => (empty as DiscoverResult).capabilities, throwsArgumentError); |
| |
| // Tools |
| expect(() => (empty as CallToolRequest).name, throwsArgumentError); |
| |
| // Resources |
| expect(() => (empty as ReadResourceRequest).uri, throwsArgumentError); |
| expect(() => (empty as SubscribeRequest).uri, throwsArgumentError); |
| expect(() => (empty as UnsubscribeRequest).uri, throwsArgumentError); |
| |
| // Roots |
| expect(() => (empty as ListRootsResult).roots, throwsArgumentError); |
| |
| // Prompts |
| expect(() => (empty as GetPromptRequest).name, throwsArgumentError); |
| |
| // Completions |
| expect(() => (empty as CompleteRequest).ref, throwsArgumentError); |
| expect(() => (empty as CompleteRequest).argument, throwsArgumentError); |
| |
| // Logging |
| expect(() => (empty as SetLevelRequest).level, throwsArgumentError); |
| |
| // Sampling |
| expect( |
| () => (empty as CreateMessageRequest).messages, |
| throwsArgumentError, |
| ); |
| expect( |
| () => (empty as CreateMessageRequest).maxTokens, |
| throwsArgumentError, |
| ); |
| }); |
| test('meta field is parsed correctly', () { |
| final root = Root.fromMap({ |
| 'uri': 'file:///foo/bar', |
| '_meta': {'foo': 'bar'}, |
| }); |
| expect(root.meta, isNotNull); |
| final metaMap = root.meta as Map; |
| expect(metaMap['foo'], 'bar'); |
| }); |
| test('resultType defaults to complete when absent and passes strings ' |
| 'through', () { |
| expect((<String, Object?>{} as Result).resultType, 'complete'); |
| expect( |
| (<String, Object?>{'resultType': 'input_required'} as Result) |
| .resultType, |
| 'input_required', |
| ); |
| expect( |
| (<String, Object?>{'resultType': 'streaming'} as Result).resultType, |
| 'streaming', |
| ); |
| }); |
| test('isInputRequired is true only for input_required results', () { |
| expect((<String, Object?>{} as Result).isInputRequired, false); |
| expect( |
| (<String, Object?>{'resultType': 'input_required'} as Result) |
| .isInputRequired, |
| true, |
| ); |
| expect( |
| (<String, Object?>{'resultType': 'streaming'} as Result) |
| .isInputRequired, |
| false, |
| ); |
| }); |
| test('cacheable result fields default when absent and parse known ' |
| 'values', () { |
| final empty = <String, Object?>{} as CacheableResult; |
| expect(empty.ttlMs, 0); |
| expect(empty.cacheScope, null); |
| expect((<String, Object?>{'ttlMs': -5} as CacheableResult).ttlMs, 0); |
| final cacheable = |
| <String, Object?>{'ttlMs': 5000, 'cacheScope': 'private'} |
| as CacheableResult; |
| expect(cacheable.ttlMs, 5000); |
| expect(cacheable.cacheScope, CacheScope.private); |
| expect( |
| (<String, Object?>{'cacheScope': 'public'} as CacheableResult) |
| .cacheScope, |
| CacheScope.public, |
| ); |
| expect( |
| (<String, Object?>{'cacheScope': 'session'} as CacheableResult) |
| .cacheScope, |
| null, |
| ); |
| final listTools = |
| <String, Object?>{ |
| 'tools': <Object?>[], |
| 'ttlMs': 5000, |
| 'cacheScope': 'public', |
| } |
| as ListToolsResult; |
| expect(listTools.ttlMs, 5000); |
| expect(listTools.cacheScope, CacheScope.public); |
| }); |
| test('request ids round trip both JSON-RPC id types', () { |
| for (var id in <Object>[7, 'abc']) { |
| final cancelled = CancelledNotification(requestId: RequestId(id)); |
| expect( |
| cancelled as Map<String, Object?>, |
| containsPair('requestId', id), |
| ); |
| expect(cancelled.requestId, id); |
| } |
| }); |
| test('an annotation reads an integer priority as a double', () { |
| expect(Annotations.fromMap({'priority': 1}).priority, 1.0); |
| }); |
| |
| test('an annotation reads a fractional priority', () { |
| expect(Annotations.fromMap({'priority': 0.5}).priority, 0.5); |
| }); |
| |
| test('an annotation leaves an absent priority null', () { |
| expect(Annotations.fromMap({}).priority, isNull); |
| }); |
| }); |
| |
| group('cacheable result factory', () { |
| // Returns an instance of every cacheable result type with the given |
| // settings. The tests assert on the map rather than the getters: an absent |
| // `ttlMs` and a written `ttlMs: 0` both read as `0`. |
| List<Map<String, Object?>> cacheable({ |
| int? ttlMs, |
| CacheScope? cacheScope, |
| }) => |
| [ |
| ListToolsResult(tools: [], ttlMs: ttlMs, cacheScope: cacheScope), |
| ListPromptsResult(prompts: [], ttlMs: ttlMs, cacheScope: cacheScope), |
| ListResourcesResult( |
| resources: [], |
| ttlMs: ttlMs, |
| cacheScope: cacheScope, |
| ), |
| ListResourceTemplatesResult( |
| resourceTemplates: [], |
| ttlMs: ttlMs, |
| cacheScope: cacheScope, |
| ), |
| ReadResourceResult( |
| contents: [], |
| ttlMs: ttlMs, |
| cacheScope: cacheScope, |
| ), |
| DiscoverResult( |
| supportedVersions: [], |
| capabilities: ServerCapabilities(), |
| ttlMs: ttlMs, |
| cacheScope: cacheScope, |
| ), |
| ].cast<Map<String, Object?>>(); |
| |
| test('writes the hints it is given', () { |
| for (var result in cacheable( |
| ttlMs: 5000, |
| cacheScope: CacheScope.public, |
| )) { |
| expect(result, containsPair('ttlMs', 5000)); |
| expect(result, containsPair('cacheScope', 'public')); |
| } |
| for (var result in cacheable(ttlMs: 0, cacheScope: CacheScope.private)) { |
| expect(result, containsPair('ttlMs', 0)); |
| expect(result, containsPair('cacheScope', 'private')); |
| } |
| }); |
| |
| test('writes only the hint it is given', () { |
| for (var result in cacheable(ttlMs: 5000)) { |
| expect(result, containsPair('ttlMs', 5000)); |
| expect(result, isNot(contains('cacheScope'))); |
| } |
| for (var result in cacheable(cacheScope: CacheScope.public)) { |
| expect(result, containsPair('cacheScope', 'public')); |
| expect(result, isNot(contains('ttlMs'))); |
| } |
| }); |
| |
| test('leaves out the hints it is not given', () { |
| for (var result in cacheable()) { |
| expect(result, isNot(contains('ttlMs'))); |
| expect(result, isNot(contains('cacheScope'))); |
| } |
| }); |
| }); |
| |
| group('ResourceLink', () { |
| test('reads the icons a server sent', () { |
| final link = ResourceLink.fromMap({ |
| 'type': 'resource_link', |
| 'uri': 'file:///a', |
| 'name': 'a', |
| 'icons': <Object?>[ |
| { |
| 'src': 'https://example.com/a.png', |
| 'sizes': <Object?>['48x48'], |
| }, |
| ], |
| }); |
| final icon = link.icons!.single; |
| expect(icon.src, 'https://example.com/a.png'); |
| expect(icon.sizes, ['48x48']); |
| }); |
| |
| test('writes the icons it is given', () { |
| final link = ResourceLink( |
| uri: 'file:///a', |
| name: 'a', |
| icons: [Icon(src: 'https://example.com/a.png')], |
| ); |
| expect((link as Map<String, Object?>)['icons'], [ |
| {'src': 'https://example.com/a.png'}, |
| ]); |
| expect(link.icons!.single.src, 'https://example.com/a.png'); |
| }); |
| |
| test('reads null icons when the server left them out', () { |
| final link = ResourceLink(uri: 'file:///a', name: 'a'); |
| expect(link.icons, null); |
| expect((link as Map<String, Object?>).containsKey('icons'), isFalse); |
| }); |
| }); |
| } |