From f9f9df5c1ff483e03e06f315eefa7fe1f5847b31 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 08:20:57 -0400 Subject: [PATCH 01/42] Consolidate type schema validation --- CHANGELOG.md | 24 +- example/client_stdio.dart | 7 +- example/server_stdio.dart | 6 +- lib/src/client/client.dart | 64 +++- lib/src/server/server.dart | 1 + lib/src/server/streamable_https.dart | 22 +- lib/src/shared/json_schema/json_schema.dart | 71 ++-- .../json_schema/json_schema_validator.dart | 2 +- lib/src/shared/protocol.dart | 7 +- lib/src/shared/uri_template.dart | 31 +- lib/src/types/completion.dart | 30 +- lib/src/types/content.dart | 168 +++++++-- lib/src/types/elicitation.dart | 211 +++++++++-- lib/src/types/initialization.dart | 149 ++++++-- lib/src/types/json_rpc.dart | 30 +- lib/src/types/logging.dart | 12 +- lib/src/types/prompts.dart | 4 +- lib/src/types/resources.dart | 135 ++++--- lib/src/types/roots.dart | 37 +- lib/src/types/sampling.dart | 89 +++-- lib/src/types/tasks.dart | 5 +- lib/src/types/validation.dart | 178 +++++++++ .../lib/src/conformance_runner.dart | 19 + .../client_elicitation_defaults_test.dart | 13 +- test/client/client_test.dart | 22 +- test/client/client_tool_validation_test.dart | 13 +- test/client/streamable_https_test.dart | 7 +- test/elicitation_test.dart | 236 +++++++++++- test/lifecycle_test.dart | 4 + test/mcp_2025_11_25_test.dart | 176 ++++++++- test/mcp_2026_07_28_test.dart | 249 ++++++++++++- test/server/server_test.dart | 4 +- test/server/streamable_https_test.dart | 12 + test/server/streamable_mcp_server_test.dart | 24 ++ test/shared/json_schema_from_json_test.dart | 75 +++- test/shared/json_schema_validator_test.dart | 14 +- test/shared/protocol_test.dart | 37 ++ test/shared/uri_template_test.dart | 43 +++ test/types/logging_types_test.dart | 28 ++ test/types/resources_test.dart | 133 ++++++- test/types/sampling_test.dart | 217 ++++++++++- test/types_test.dart | 346 +++++++++++++++++- 42 files changed, 2636 insertions(+), 319 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee6c6e94..607ddcc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,9 @@ - Retried `server/discover` with an advertised compatible stateless protocol version after `UnsupportedProtocolVersionError` instead of falling back to legacy initialization. +- Accepted whole-number JSON numeric values for integer wire fields such as + resource link sizes, completion totals, sampling `maxTokens`, task TTLs, and + JSON Schema length/item bounds while continuing to reject fractional values. - Added client-side `subscriptions/listen` handles that correlate stream notifications by `io.modelcontextprotocol/subscriptionId`, validate the acknowledgment, and cancel long-lived streams with `notifications/cancelled`. @@ -88,14 +91,28 @@ clamping malformed wire values to zero. - Validated MRTR `inputResponses` as `CreateMessageResult`, `ListRootsResult`, or `ElicitResult` instead of accepting arbitrary result objects. -- Allowed finite numeric `ElicitResult.content` values to match the stable and - MCP 2026 `string | number | boolean | string[]` schema. +- Restricted numeric `ElicitResult.content` values to integers, matching the + stable and MCP 2026 `string | integer | boolean | string[]` schemas while + still accepting whole-number JSON numeric values. +- Made form elicitation number-schema keyword validation protocol-aware: + stable 2025 keeps integer-only `minimum`, `maximum`, and `default` values, + while MCP 2026 accepts fractional number keywords. - Rejected form elicitation schemas that provide legacy `enumNames` without the required string `enum`. - Rejected `ElicitResult.content` when the result action is `decline` or `cancel`. - Rejected URL elicitation values that are not absolute URIs to match the stable and MCP 2026 `format: uri` schemas. +- Rejected non-absolute resource URIs and malformed resource URI templates to + match stable and MCP 2026 `format: uri` and `format: uri-template` schemas. +- Rejected malformed base64 payloads for image, audio, and blob resource + content to match stable and MCP 2026 `format: byte` schemas. +- Rejected malformed shared annotation fields, including non-role audiences, + out-of-range priorities, and non-string `lastModified` values. +- Rejected malformed `Role` values in prompt and sampling messages instead of + allowing raw enum lookup failures. +- Rejected malformed logging level, sampling `includeContext`, and sampling + `toolChoice.mode` enum values with protocol parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. @@ -140,6 +157,9 @@ dispatch finite numeric progress tokens end-to-end. - Widened protocol `relatedRequestId` API parameters to preserve string and finite numeric JSON-RPC request IDs through request and notification routing. +- Accepted numeric `minimum`, `maximum`, `exclusiveMinimum`, + `exclusiveMaximum`, `multipleOf`, and `default` values on JSON Schema + `integer` schemas, matching the stable and MCP 2026 schema definitions. ## 2.2.0 diff --git a/example/client_stdio.dart b/example/client_stdio.dart index e3cfa9d7..aad46b9e 100644 --- a/example/client_stdio.dart +++ b/example/client_stdio.dart @@ -8,7 +8,7 @@ import 'package:mcp_dart/mcp_dart.dart'; /// It demonstrates how to use the MCP client library with standard I/O. /// It runs the server example from `example/server_stdio.dart` /// and communicates with it using the StdioClientTransport. -/// The client sends various requests to the server, including ping, tool calls, +/// The client sends various requests to the server, including tool calls, /// resource reads, and prompt calls. Future main() async { // Define the server executable and arguments @@ -48,11 +48,6 @@ Future main() async { await client.connect(transport); print('Connected to server.'); - // Example: Send a ping request - print('Sending ping...'); - final pingResult = await client.ping(); - print('Ping successful: ${pingResult.toJson()}'); - print('Listing tools...'); final tools = await client.listTools(); print('Resources: ${tools.toJson()}'); diff --git a/example/server_stdio.dart b/example/server_stdio.dart index d2c38f07..1434cd3c 100644 --- a/example/server_stdio.dart +++ b/example/server_stdio.dart @@ -63,7 +63,11 @@ void main() async { final text = 'Sample log content'; return ReadResourceResult( contents: [ - TextResourceContents(uri: uri.path, mimeType: 'text/plain', text: text), + TextResourceContents( + uri: uri.toString(), + mimeType: 'text/plain', + text: text, + ), ], ); }); diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 0c0fdc9a..38249a4c 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -15,24 +15,21 @@ class McpClientOptions extends ProtocolOptions { /// Capabilities to advertise as being supported by this client. final ClientCapabilities? capabilities; - /// Preferred protocol version for opt-in `server/discover` negotiation. - /// - /// The current default keeps existing clients on the stable initialization - /// flow unless [useServerDiscover] is enabled. + /// Preferred protocol version for `server/discover` negotiation. final String protocolVersion; /// Whether [McpClient.connect] should probe with `server/discover` first. final bool useServerDiscover; - /// Whether a `server/discover` method-not-found response should fall back to - /// the legacy `initialize` handshake. + /// Whether a failed `server/discover` probe should fall back to the legacy + /// `initialize` handshake when the peer looks like a pre-discovery server. final bool allowLegacyInitializationFallback; const McpClientOptions({ super.enforceStrictCapabilities, this.capabilities, this.protocolVersion = latestDraftProtocolVersion, - this.useServerDiscover = false, + this.useServerDiscover = true, this.allowLegacyInitializationFallback = true, }); } @@ -122,6 +119,7 @@ const Set _statelessRemovedRequestMethods = { const Set _statelessRemovedNotificationMethods = { Method.notificationsInitialized, Method.notificationsRootsListChanged, + Method.notificationsTasksStatus, }; /// An MCP client implementation built on top of a pluggable [Transport]. @@ -172,7 +170,7 @@ class McpClient extends Protocol { : _capabilities = options?.capabilities ?? const ClientCapabilities(), _preferredProtocolVersion = options?.protocolVersion ?? latestDraftProtocolVersion, - _useServerDiscover = options?.useServerDiscover ?? false, + _useServerDiscover = options?.useServerDiscover ?? true, _allowLegacyInitializationFallback = options?.allowLegacyInitializationFallback ?? true, super(options) { @@ -216,11 +214,18 @@ class McpClient extends Protocol { } return result; }, - (id, params, meta) => JsonRpcElicitRequest( - id: id, - elicitParams: ElicitRequest.fromJson(params ?? {}), - meta: meta, - ), + (id, params, meta) { + final protocolVersion = _protocolVersionForIncomingRequest(meta); + return JsonRpcElicitRequest( + id: id, + elicitParams: ElicitRequest.fromJson( + params ?? {}, + protocolVersion: protocolVersion, + ), + meta: meta, + protocolVersion: protocolVersion, + ); + }, ); } @@ -470,10 +475,7 @@ class McpClient extends Protocol { await discoverServer(); return; } catch (error) { - final canFallback = _allowLegacyInitializationFallback && - error is McpError && - error.code == ErrorCode.methodNotFound.value; - if (!canFallback) { + if (!_isLegacyDiscoveryFallbackError(error)) { rethrow; } _logger.debug( @@ -493,6 +495,26 @@ class McpClient extends Protocol { } } + bool _isLegacyDiscoveryFallbackError(Object error) { + if (!_allowLegacyInitializationFallback || error is! McpError) { + return false; + } + if (error.code == ErrorCode.methodNotFound.value) { + return true; + } + + final message = error.message; + if (error.code == 0 && + message.contains('Error POSTing to endpoint (HTTP 400)')) { + return true; + } + + return (error.code == 0 || + error.code == ErrorCode.connectionClosed.value || + error.code == ErrorCode.invalidRequest.value) && + message.contains('Server not initialized'); + } + @override Future request( JsonRpcRequest requestData, @@ -608,6 +630,14 @@ class McpClient extends Protocol { /// Gets the negotiated protocol version after connection. String? getProtocolVersion() => _negotiatedProtocolVersion; + String? _protocolVersionForIncomingRequest(Map? meta) { + final protocolVersion = meta?[McpMetaKey.protocolVersion]; + if (protocolVersion is String) { + return protocolVersion; + } + return _negotiatedProtocolVersion ?? _preferredProtocolVersion; + } + @override bool isRecognizedResultType(String resultType) { if (super.isRecognizedResultType(resultType)) { diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index e5da1fdb..d5a1f52d 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -76,6 +76,7 @@ class Server extends Protocol { static const Set _statelessRemovedNotificationMethods = { Method.notificationsInitialized, Method.notificationsRootsListChanged, + Method.notificationsTasksStatus, }; static const Set _inputRequiredResultMethods = { diff --git a/lib/src/server/streamable_https.dart b/lib/src/server/streamable_https.dart index 5f540921..b0994a79 100644 --- a/lib/src/server/streamable_https.dart +++ b/lib/src/server/streamable_https.dart @@ -439,6 +439,18 @@ class StreamableHTTPServerTransport return null; } + String? _nestedMetadataProtocolVersion(Map messageJson) { + final params = messageJson['params']; + if (params is Map) { + final meta = params['_meta']; + if (meta is Map) { + final version = meta[McpMetaKey.protocolVersion]; + return version is String ? version : null; + } + } + return null; + } + bool _usesStatelessHttpValidation( HttpRequest req, List messages, @@ -756,6 +768,7 @@ class StreamableHTTPServerTransport Future _validateStatelessHttpHeaders( HttpRequest req, List messages, + List> messageJsons, ) async { if (!_usesStatelessHttpValidation(req, messages)) { return true; @@ -773,6 +786,7 @@ class StreamableHTTPServerTransport } final message = messages.single; + final messageJson = messageJsons.single; final protocolHeader = req.headers.value('mcp-protocol-version')?.trim(); if (protocolHeader == null || protocolHeader.isEmpty) { await _writeHeaderMismatchResponse( @@ -791,12 +805,12 @@ class StreamableHTTPServerTransport return false; } - final metadataVersion = _metadataProtocolVersion(message); + final metadataVersion = _nestedMetadataProtocolVersion(messageJson); if (metadataVersion == null) { await _writeHeaderMismatchResponse( req.response, message, - 'MCP-Protocol-Version header has no matching request _meta protocol version', + 'MCP-Protocol-Version header has no matching request _meta protocol version in params._meta', ); return false; } @@ -1336,6 +1350,7 @@ class StreamableHTTPServerTransport } final List messages = []; + final List> messageJsons = []; if (rawMessages.isEmpty) { await _writeJsonRpcErrorResponse( req.response, @@ -1362,6 +1377,7 @@ class StreamableHTTPServerTransport final messageJson = rawItem is Map ? rawItem : rawItem.cast(); + messageJsons.add(messageJson); messages.add(JsonRpcMessage.fromJson(messageJson)); } catch (e) { await _writeJsonRpcErrorResponse( @@ -1376,7 +1392,7 @@ class StreamableHTTPServerTransport } } - if (!await _validateStatelessHttpHeaders(req, messages)) { + if (!await _validateStatelessHttpHeaders(req, messages, messageJsons)) { return; } diff --git a/lib/src/shared/json_schema/json_schema.dart b/lib/src/shared/json_schema/json_schema.dart index da48f924..c9e0b927 100644 --- a/lib/src/shared/json_schema/json_schema.dart +++ b/lib/src/shared/json_schema/json_schema.dart @@ -1,5 +1,18 @@ const _jsonSchemaAnnotationKeys = {'title', 'description', 'default'}; +int? _readOptionalInteger(Object? value, String field) { + if (value == null) { + return null; + } + if (value is int) { + return value; + } + if (value is double && value.isFinite && value == value.truncateToDouble()) { + return value.toInt(); + } + throw FormatException('$field must be an integer'); +} + /// A builder for creating JSON Schemas in a type-safe way. sealed class JsonSchema { final String? title; @@ -8,7 +21,7 @@ sealed class JsonSchema { /// The default value for this schema. /// /// The type of this value depends on the schema type (e.g., [String] for [JsonString], - /// [int] for [JsonInteger], etc.). + /// [num] for [JsonNumber] and [JsonInteger], etc.). dynamic get defaultValue; const JsonSchema({this.title, this.description}); @@ -255,14 +268,14 @@ sealed class JsonSchema { /// Creates an integer schema. static JsonInteger integer({ - int? minimum, - int? maximum, - int? exclusiveMinimum, - int? exclusiveMaximum, - int? multipleOf, + num? minimum, + num? maximum, + num? exclusiveMinimum, + num? exclusiveMaximum, + num? multipleOf, String? title, String? description, - int? defaultValue, + num? defaultValue, String? mcpHeader, }) { return JsonInteger( @@ -496,8 +509,14 @@ class JsonString extends JsonSchema { factory JsonString.fromJson(Map json) { final rawMcpHeader = json['x-mcp-header']; return JsonString._( - minLength: json['minLength'] as int?, - maxLength: json['maxLength'] as int?, + minLength: _readOptionalInteger( + json['minLength'], + 'JsonString.minLength', + ), + maxLength: _readOptionalInteger( + json['maxLength'], + 'JsonString.maxLength', + ), pattern: json['pattern'] as String?, format: json['format'] as String?, enumValues: (json['enum'] as List?)?.cast() ?? @@ -619,11 +638,11 @@ class JsonInteger extends JsonSchema { final bool _hasDefault; final bool _hasMcpHeader; final Object? _rawMcpHeader; - final int? minimum; - final int? maximum; - final int? exclusiveMinimum; - final int? exclusiveMaximum; - final int? multipleOf; + final num? minimum; + final num? maximum; + final num? exclusiveMinimum; + final num? exclusiveMaximum; + final num? multipleOf; /// MCP `x-mcp-header` extension for mirroring this parameter into HTTP. final String? mcpHeader; @@ -660,19 +679,19 @@ class JsonInteger extends JsonSchema { _rawMcpHeader = rawMcpHeader; @override - final int? defaultValue; + final num? defaultValue; factory JsonInteger.fromJson(Map json) { final rawMcpHeader = json['x-mcp-header']; return JsonInteger._( - minimum: json['minimum'] as int?, - maximum: json['maximum'] as int?, - exclusiveMinimum: json['exclusiveMinimum'] as int?, - exclusiveMaximum: json['exclusiveMaximum'] as int?, - multipleOf: json['multipleOf'] as int?, + minimum: json['minimum'] as num?, + maximum: json['maximum'] as num?, + exclusiveMinimum: json['exclusiveMinimum'] as num?, + exclusiveMaximum: json['exclusiveMaximum'] as num?, + multipleOf: json['multipleOf'] as num?, title: json['title'] as String?, description: json['description'] as String?, - defaultValue: json['default'] as int?, + defaultValue: json['default'] as num?, mcpHeader: rawMcpHeader is String ? rawMcpHeader : null, rawMcpHeader: rawMcpHeader, hasDefault: json.containsKey('default'), @@ -832,8 +851,14 @@ class JsonArray extends JsonSchema { items: json['items'] != null ? JsonSchema.fromJson(json['items'] as Map) : null, - minItems: json['minItems'] as int?, - maxItems: json['maxItems'] as int?, + minItems: _readOptionalInteger( + json['minItems'], + 'JsonArray.minItems', + ), + maxItems: _readOptionalInteger( + json['maxItems'], + 'JsonArray.maxItems', + ), uniqueItems: json['uniqueItems'] as bool?, title: json['title'] as String?, description: json['description'] as String?, diff --git a/lib/src/shared/json_schema/json_schema_validator.dart b/lib/src/shared/json_schema/json_schema_validator.dart index bb92abbe..8264cc04 100644 --- a/lib/src/shared/json_schema/json_schema_validator.dart +++ b/lib/src/shared/json_schema/json_schema_validator.dart @@ -188,7 +188,7 @@ extension JsonSchemaValidation on JsonSchema { } if (schema.multipleOf != null) { - if ((data % schema.multipleOf!).abs() > 0) { + if ((data % schema.multipleOf!).abs() > 1e-10) { throw JsonSchemaValidationException( 'Value must be multiple of ${schema.multipleOf}', path, diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index ae05f7f5..58c08297 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:mcp_dart/src/shared/logging.dart'; import 'package:mcp_dart/src/shared/task_interfaces.dart'; import 'package:mcp_dart/src/types.dart'; +import 'package:mcp_dart/src/types/validation.dart'; import 'package:meta/meta.dart'; import 'transport.dart'; @@ -1275,8 +1276,10 @@ abstract class Protocol { this, ) : null, - taskRequestedTtl: - (request.params?['task'] as Map?)?['ttl'] as int?, + taskRequestedTtl: readOptionalInteger( + (request.params?['task'] as Map?)?['ttl'], + 'RequestOptions.task.ttl', + ), sendNotification: (notification, {relatedTask}) { var outgoingNotification = notification; if (subscriptionState != null) { diff --git a/lib/src/shared/uri_template.dart b/lib/src/shared/uri_template.dart index 77bdc112..368a59f9 100644 --- a/lib/src/shared/uri_template.dart +++ b/lib/src/shared/uri_template.dart @@ -129,6 +129,10 @@ class UriTemplateExpander { parts.add(_ExpressionPart(operator, varSpecs)); i = end + 1; + } else if (template[i] == '}') { + throw ArgumentError( + "Unmatched closing template expression at index $i", + ); } else { currentText.write(template[i]); i++; @@ -153,8 +157,9 @@ class UriTemplateExpander { } static List<_VarSpec> _parseVarSpecs(String specsString) { - return specsString.split(',').map((spec) { - spec = spec.trim(); + return specsString.split(',').map((rawSpec) { + var spec = rawSpec.trim(); + final originalSpec = spec; bool explode = false; int? prefix; @@ -164,16 +169,20 @@ class UriTemplateExpander { } else { final prefixMatch = RegExp(r':(\d+)$').firstMatch(spec); if (prefixMatch != null) { - prefix = int.tryParse(prefixMatch.group(1)!); + final prefixText = prefixMatch.group(1)!; + if (!RegExp(r'^[1-9][0-9]{0,4}$').hasMatch(prefixText)) { + throw ArgumentError( + "Invalid prefix modifier '$prefixText' in variable spec " + "'$originalSpec'", + ); + } + prefix = int.parse(prefixText); spec = spec.substring(0, prefixMatch.start); } } - if (spec.isEmpty || - !RegExp(r'^[a-zA-Z0-9_]|(%[0-9A-Fa-f]{2})').hasMatch(spec[0])) { - if (spec.isNotEmpty) { - // Allow empty string from splitting trailing comma - } + if (!_isValidVariableName(spec)) { + throw ArgumentError("Invalid variable name '$spec'"); } _validateLength(spec, maxVariableLength, "Variable name '$spec'"); @@ -182,6 +191,12 @@ class UriTemplateExpander { }).toList(); } + static bool _isValidVariableName(String spec) { + return RegExp( + r'^(?:[a-zA-Z0-9_]|%[0-9A-Fa-f]{2})(?:\.?(?:[a-zA-Z0-9_]|%[0-9A-Fa-f]{2}))*$', + ).hasMatch(spec); + } + String _encodeValue(String value, String operator) { _validateLength(value, maxVariableLength, "Variable value"); diff --git a/lib/src/types/completion.dart b/lib/src/types/completion.dart index 50513521..57ceb848 100644 --- a/lib/src/types/completion.dart +++ b/lib/src/types/completion.dart @@ -19,16 +19,24 @@ sealed class Reference { }; } - Map toJson() => { - 'type': type, - ...switch (this) { - final ResourceReference r => {'uri': r.uri}, - final PromptReference p => { - 'name': p.name, - if (p.title != null) 'title': p.title, - }, + Map toJson() { + return switch (this) { + final ResourceReference r => _resourceReferenceToJson(r), + final PromptReference p => { + 'type': p.type, + 'name': p.name, + if (p.title != null) 'title': p.title, }, - }; + }; + } +} + +Map _resourceReferenceToJson(ResourceReference reference) { + validateUriTemplateString(reference.uri, 'ResourceReference.uri'); + return { + 'type': reference.type, + 'uri': reference.uri, + }; } /// Reference to a resource or resource template URI. @@ -39,7 +47,7 @@ class ResourceReference extends Reference { factory ResourceReference.fromJson(Map json) { return ResourceReference( - uri: json['uri'] as String, + uri: readRequiredUriTemplateString(json['uri'], 'ResourceReference.uri'), ); } } @@ -204,7 +212,7 @@ class CompletionResultData { } return CompletionResultData( values: values.cast(), - total: json['total'] as int?, + total: readOptionalInteger(json['total'], 'CompletionResultData.total'), hasMore: json['hasMore'] as bool?, ); } diff --git a/lib/src/types/content.dart b/lib/src/types/content.dart index 42c612b5..fd364653 100644 --- a/lib/src/types/content.dart +++ b/lib/src/types/content.dart @@ -21,6 +21,83 @@ Map _asJsonObject( return map; } +String _readRequiredString(Object? value, String field) { + if (value is String) { + return value; + } + throw FormatException('$field must be a string'); +} + +bool _isAbsoluteUri(String value) { + return Uri.tryParse(value)?.hasScheme ?? false; +} + +String _readRequiredAbsoluteUriString(Object? value, String field) { + final result = _readRequiredString(value, field); + if (!_isAbsoluteUri(result)) { + throw FormatException('$field must be an absolute URI'); + } + return result; +} + +void _validateAbsoluteUriString(String value, String field) { + if (!_isAbsoluteUri(value)) { + throw ArgumentError.value(value, field, 'must be an absolute URI'); + } +} + +String _absoluteUriForJson(String value, String field) { + validateAbsoluteUriString(value, field); + return value; +} + +String _base64ForJson(String value, String field) { + validateBase64String(value, field); + return value; +} + +Map _annotationsForJson( + Map value, + String field, +) { + final result = readJsonObject(value, field); + validateAnnotationsObject(result, field); + return result; +} + +String? _readOptionalPresentString( + Map json, + String key, + String field, +) { + if (!json.containsKey(key)) { + return null; + } + return _readRequiredString(json[key], field); +} + +List? _readOptionalPresentStringList( + Map json, + String key, + String field, +) { + if (!json.containsKey(key)) { + return null; + } + final value = json[key]; + if (value is! List) { + throw FormatException('$field must be a list of strings'); + } + + return [ + for (final item in value) + if (item is String) + item + else + throw FormatException('$field items must be strings'), + ]; +} + /// Allowed audience values for content/resource annotations. enum AnnotationAudience { user, assistant } @@ -45,12 +122,19 @@ class Annotations { ); factory Annotations.fromJson(Map json) { + final audience = readOptionalAnnotationAudience( + json['audience'], + 'Annotations.audience', + ); return Annotations( - audience: (json['audience'] as List?) - ?.map((value) => AnnotationAudience.values.byName(value as String)) + audience: audience + ?.map((value) => AnnotationAudience.values.byName(value)) .toList(), priority: readUnitDouble(json['priority'], 'Annotations.priority'), - lastModified: json['lastModified'] as String?, + lastModified: readOptionalString( + json['lastModified'], + 'Annotations.lastModified', + ), ); } @@ -88,7 +172,10 @@ sealed class ResourceContents { /// Creates a specific [ResourceContents] subclass from JSON. factory ResourceContents.fromJson(Map json) { - final uri = json['uri'] as String; + final uri = readRequiredAbsoluteUriString( + json['uri'], + 'ResourceContents.uri', + ); final mimeType = json['mimeType'] as String?; final meta = _asJsonObjectOrNull( json['_meta'], @@ -120,7 +207,10 @@ sealed class ResourceContents { return BlobResourceContents( uri: uri, mimeType: mimeType, - blob: json['blob'] as String, + blob: readRequiredBase64String( + json['blob'], + 'BlobResourceContents.blob', + ), meta: meta, extra: passthrough, ); @@ -135,11 +225,13 @@ sealed class ResourceContents { /// Converts resource contents to JSON. Map toJson() => { - 'uri': uri, + 'uri': _absoluteUriForJson(uri, 'ResourceContents.uri'), if (mimeType != null) 'mimeType': mimeType, ...switch (this) { final TextResourceContents c => {'text': c.text}, - final BlobResourceContents c => {'blob': c.blob}, + final BlobResourceContents c => { + 'blob': _base64ForJson(c.blob, 'BlobResourceContents.blob'), + }, UnknownResourceContents _ => {}, }, if (meta != null) @@ -211,27 +303,42 @@ class McpIcon { }); factory McpIcon.fromJson(Map json) { - final themeString = json['theme'] as String?; + final themeString = _readOptionalPresentString( + json, + 'theme', + 'McpIcon.theme', + ); final iconTheme = switch (themeString) { 'light' => IconTheme.light, 'dark' => IconTheme.dark, - _ => null, + null => null, + _ => throw const FormatException( + 'McpIcon.theme must be either "light" or "dark"', + ), }; return McpIcon( - src: json['src'] as String, - mimeType: json['mimeType'] as String?, - sizes: (json['sizes'] as List?)?.cast(), + src: _readRequiredAbsoluteUriString(json['src'], 'McpIcon.src'), + mimeType: _readOptionalPresentString( + json, + 'mimeType', + 'McpIcon.mimeType', + ), + sizes: _readOptionalPresentStringList(json, 'sizes', 'McpIcon.sizes'), theme: iconTheme, ); } - Map toJson() => { - 'src': src, - if (mimeType != null) 'mimeType': mimeType, - if (sizes != null) 'sizes': sizes, - if (theme != null) 'theme': theme!.name, - }; + Map toJson() { + _validateAbsoluteUriString(src, 'McpIcon.src'); + + return { + 'src': src, + if (mimeType != null) 'mimeType': mimeType, + if (sizes != null) 'sizes': sizes, + if (theme != null) 'theme': theme!.name, + }; + } } /// Base class for content parts within prompts or tool results. @@ -265,22 +372,21 @@ sealed class Content { '_meta': readJsonObject(c.meta, 'TextContent._meta'), }, final ImageContent c => { - 'data': c.data, + 'data': _base64ForJson(c.data, 'ImageContent.data'), 'mimeType': c.mimeType, - if (c.theme != null) 'theme': c.theme, if (c.annotations != null) 'annotations': c.annotations!.toJson(), if (c.meta != null) '_meta': readJsonObject(c.meta, 'ImageContent._meta'), }, final AudioContent c => { - 'data': c.data, + 'data': _base64ForJson(c.data, 'AudioContent.data'), 'mimeType': c.mimeType, if (c.annotations != null) 'annotations': c.annotations!.toJson(), if (c.meta != null) '_meta': readJsonObject(c.meta, 'AudioContent._meta'), }, final ResourceLink c => { - 'uri': c.uri, + 'uri': _absoluteUriForJson(c.uri, 'ResourceLink.uri'), 'name': c.name, if (c.title != null) 'title': c.title, if (c.description != null) 'description': c.description, @@ -289,8 +395,8 @@ sealed class Content { if (c.icons != null) 'icons': c.icons!.map((icon) => icon.toJson()).toList(), if (c.annotations != null) - 'annotations': readJsonObject( - c.annotations, + 'annotations': _annotationsForJson( + c.annotations!, 'ResourceLink.annotations', ), if (c.meta != null) @@ -346,6 +452,10 @@ class ImageContent extends Content { final String mimeType; /// Optional theme hint for legacy icon usage (`light` | `dark`). + /// + /// This field is parsed for backwards compatibility with older icon-shaped + /// payloads. MCP ImageContent content blocks do not serialize `theme`; use + /// [McpIcon.theme] for advertised icons. final String? theme; /// Optional annotations for the content block. @@ -364,7 +474,7 @@ class ImageContent extends Content { factory ImageContent.fromJson(Map json) { return ImageContent( - data: json['data'] as String, + data: readRequiredBase64String(json['data'], 'ImageContent.data'), mimeType: json['mimeType'] as String, theme: json['theme'] as String?, annotations: json['annotations'] == null @@ -399,7 +509,7 @@ class AudioContent extends Content { factory AudioContent.fromJson(Map json) { return AudioContent( - data: json['data'] as String, + data: readRequiredBase64String(json['data'], 'AudioContent.data'), mimeType: json['mimeType'] as String, annotations: json['annotations'] == null ? null @@ -493,16 +603,16 @@ class ResourceLink extends Content { factory ResourceLink.fromJson(Map json) { return ResourceLink( - uri: json['uri'] as String, + uri: readRequiredAbsoluteUriString(json['uri'], 'ResourceLink.uri'), name: json['name'] as String, title: json['title'] as String?, description: json['description'] as String?, mimeType: json['mimeType'] as String?, - size: json['size'] as int?, + size: readOptionalInteger(json['size'], 'ResourceLink.size'), icons: (json['icons'] as List?) ?.map((icon) => McpIcon.fromJson(_asJsonObject(icon))) .toList(), - annotations: _asJsonObjectOrNull( + annotations: readOptionalAnnotationsObject( json['annotations'], 'ResourceLink.annotations', ), diff --git a/lib/src/types/elicitation.dart b/lib/src/types/elicitation.dart index 8b009980..a8cb4932 100644 --- a/lib/src/types/elicitation.dart +++ b/lib/src/types/elicitation.dart @@ -1,5 +1,6 @@ import '../shared/json_schema/json_schema.dart'; import 'json_rpc.dart'; +import 'tasks.dart'; import 'validation.dart'; /// Legacy alias for [JsonSchema] used in elicitation requests. @@ -40,12 +41,16 @@ class ElicitRequest { /// Required for URL mode to correlate with completion notifications. final String? elicitationId; + /// Task metadata for task-augmented execution. + final TaskCreation? task; + const ElicitRequest({ this.mode, required this.message, this.requestedSchema, this.url, this.elicitationId, + this.task, }) : assert( mode != ElicitationMode.url || requestedSchema == null, 'URL elicitation must not include requestedSchema.', @@ -75,6 +80,7 @@ class ElicitRequest { const ElicitRequest.form({ required this.message, required ElicitationInputSchema this.requestedSchema, + this.task, }) : mode = ElicitationMode.form, url = null, elicitationId = null; @@ -84,10 +90,14 @@ class ElicitRequest { required this.message, required String this.url, required String this.elicitationId, + this.task, }) : mode = ElicitationMode.url, requestedSchema = null; - factory ElicitRequest.fromJson(Map json) { + factory ElicitRequest.fromJson( + Map json, { + String? protocolVersion, + }) { final modeValue = json['mode']; if (modeValue != null && modeValue is! String) { throw const FormatException('Elicitation mode must be a string.'); @@ -110,6 +120,7 @@ class ElicitRequest { final requestedSchemaJson = json['requestedSchema']; final url = json['url']; final elicitationId = json['elicitationId']; + final task = readOptionalJsonObject(json['task'], 'ElicitRequest.task'); if (mode == ElicitationMode.url) { if (url is! String) { @@ -128,13 +139,17 @@ class ElicitRequest { message: message, url: url, elicitationId: elicitationId, + task: task == null ? null : TaskCreation.fromJson(task), ); } if (requestedSchemaJson is! Map) { throw const FormatException('Form elicitation requires requestedSchema.'); } - _validateFormRequestedSchemaJson(requestedSchemaJson); + _validateFormRequestedSchemaJson( + requestedSchemaJson, + protocolVersion: protocolVersion, + ); if (url != null) { throw const FormatException('Form elicitation must not include url.'); } @@ -148,10 +163,11 @@ class ElicitRequest { mode: mode, message: message, requestedSchema: JsonSchema.fromJson(requestedSchemaJson), + task: task == null ? null : TaskCreation.fromJson(task), ); } - void _validateShape() { + void _validateShape({String? protocolVersion}) { if (isUrlMode) { if (requestedSchema != null) { throw ArgumentError( @@ -171,7 +187,10 @@ class ElicitRequest { if (requestedSchema == null) { throw ArgumentError('Form elicitation requires requestedSchema.'); } - _validateFormRequestedSchema(requestedSchema!); + _validateFormRequestedSchema( + requestedSchema!, + protocolVersion: protocolVersion, + ); if (url != null) { throw ArgumentError('Form elicitation must not include url.'); } @@ -180,14 +199,15 @@ class ElicitRequest { } } - Map toJson() { - _validateShape(); + Map toJson({String? protocolVersion}) { + _validateShape(protocolVersion: protocolVersion); return { if (mode != null) 'mode': mode!.name, 'message': message, if (requestedSchema != null) 'requestedSchema': requestedSchema!.toJson(), if (url != null) 'url': url, if (elicitationId != null) 'elicitationId': elicitationId, + if (task != null) 'task': task!.toJson(), }; } @@ -207,7 +227,13 @@ class JsonRpcElicitRequest extends JsonRpcRequest { required super.id, required this.elicitParams, super.meta, - }) : super(method: Method.elicitationCreate, params: elicitParams.toJson()); + String? protocolVersion, + }) : super( + method: Method.elicitationCreate, + params: elicitParams.toJson( + protocolVersion: protocolVersion ?? _protocolVersionFromMeta(meta), + ), + ); factory JsonRpcElicitRequest.fromJson(Map json) { final paramsMap = json['params'] as Map?; @@ -215,10 +241,15 @@ class JsonRpcElicitRequest extends JsonRpcRequest { throw const FormatException("Missing params for elicit request"); } final meta = extractRequestMeta(json); + final protocolVersion = _protocolVersionFromMeta(meta); return JsonRpcElicitRequest( id: parseRequestId(json['id']), - elicitParams: ElicitRequest.fromJson(paramsMap), + elicitParams: ElicitRequest.fromJson( + paramsMap, + protocolVersion: protocolVersion, + ), meta: meta, + protocolVersion: protocolVersion, ); } } @@ -290,10 +321,10 @@ class ElicitResult implements BaseResultData { Map toJson() { final resultAction = action; _validateElicitResultContentForAction(resultAction, content); - _validateElicitResultContent(content); + final normalizedContent = _normalizeElicitResultContent(content); return { 'action': resultAction, - if (content != null) 'content': content, + if (normalizedContent != null) 'content': normalizedContent, if (meta != null) '_meta': readJsonObject(meta, 'ElicitResult._meta'), }; } @@ -421,16 +452,30 @@ typedef ElicitRequestParams = ElicitRequest; @Deprecated('Use ElicitationCompleteNotification instead') typedef ElicitationCompleteParams = ElicitationCompleteNotification; -void _validateFormRequestedSchema(ElicitationInputSchema schema) { - _validateFormRequestedSchemaJson(schema.toJson()); +void _validateFormRequestedSchema( + ElicitationInputSchema schema, { + String? protocolVersion, +}) { + _validateFormRequestedSchemaJson( + schema.toJson(), + protocolVersion: protocolVersion, + ); } -void _validateFormRequestedSchemaJson(Map json) { +void _validateFormRequestedSchemaJson( + Map json, { + String? protocolVersion, +}) { _ensureAllowedKeys( json, const {r'$schema', 'type', 'properties', 'required'}, 'ElicitRequest.requestedSchema', ); + _validateOptionalStringKeyword( + json, + r'$schema', + 'ElicitRequest.requestedSchema', + ); if (json['type'] != 'object') { throw const FormatException( 'Form elicitation requestedSchema must have type object.', @@ -451,6 +496,7 @@ void _validateFormRequestedSchemaJson(Map json) { _validatePrimitiveSchema( (entry.value as Map).cast(), 'ElicitRequest.requestedSchema.properties.${entry.key}', + protocolVersion: protocolVersion, ); } final required = json['required']; @@ -462,7 +508,11 @@ void _validateFormRequestedSchemaJson(Map json) { } } -void _validatePrimitiveSchema(Map json, String context) { +void _validatePrimitiveSchema( + Map json, + String context, { + String? protocolVersion, +}) { final type = json['type']; switch (type) { case 'string': @@ -482,6 +532,12 @@ void _validatePrimitiveSchema(Map json, String context) { }, context, ); + _validatePrimitiveBaseKeywords(json, context); + _validateNumberSchemaKeywords( + json, + context, + protocolVersion: protocolVersion, + ); return; case 'boolean': _ensureAllowedKeys( @@ -489,6 +545,10 @@ void _validatePrimitiveSchema(Map json, String context) { const {'type', 'title', 'description', 'default'}, context, ); + _validatePrimitiveBaseKeywords(json, context); + if (json['default'] != null && json['default'] is! bool) { + throw FormatException('$context.default must be a boolean.'); + } return; case 'array': _validateMultiSelectEnumSchema(json, context); @@ -500,6 +560,33 @@ void _validatePrimitiveSchema(Map json, String context) { } } +void _validatePrimitiveBaseKeywords( + Map json, + String context, +) { + _validateOptionalStringKeyword(json, 'title', context); + _validateOptionalStringKeyword(json, 'description', context); +} + +void _validateNumberSchemaKeywords( + Map json, + String context, { + String? protocolVersion, +}) { + if (!_usesDraftNumberSchemaKeywords(protocolVersion)) { + for (final key in const ['default', 'minimum', 'maximum']) { + _validateOptionalIntegerKeyword(json, key, context); + } + return; + } + + for (final key in const ['default', 'minimum', 'maximum']) { + if (json[key] != null) { + readFiniteNumber(json[key], '$context.$key'); + } + } +} + void _validateStringOrSingleEnumSchema( Map json, String context, @@ -510,6 +597,8 @@ void _validateStringOrSingleEnumSchema( const {'type', 'title', 'description', 'oneOf', 'default'}, context, ); + _validatePrimitiveBaseKeywords(json, context); + _validateOptionalStringKeyword(json, 'default', context); final oneOf = json['oneOf']; if (oneOf is! List || oneOf.any( @@ -538,6 +627,10 @@ void _validateStringOrSingleEnumSchema( }, context, ); + _validatePrimitiveBaseKeywords(json, context); + _validateOptionalStringKeyword(json, 'default', context); + _validateOptionalIntegerKeyword(json, 'minLength', context); + _validateOptionalIntegerKeyword(json, 'maxLength', context); final enumValues = json['enum']; if (enumValues != null && (enumValues is! List || enumValues.any((value) => value is! String))) { @@ -575,16 +668,21 @@ void _validateMultiSelectEnumSchema( }, context, ); + _validatePrimitiveBaseKeywords(json, context); + _validateOptionalIntegerKeyword(json, 'minItems', context); + _validateOptionalIntegerKeyword(json, 'maxItems', context); + _validateOptionalStringListKeyword(json, 'default', context); final items = json['items']; if (items is! Map) { throw FormatException('$context.items is required for array schemas.'); } final itemMap = items.cast(); - if (itemMap['type'] == 'string' && itemMap['enum'] is List) { - final enumValues = itemMap['enum'] as List; - if (enumValues.any((value) => value is! String)) { - throw FormatException('$context.items.enum must be a string array.'); + if (itemMap.containsKey('enum')) { + _ensureAllowedKeys(itemMap, const {'type', 'enum'}, '$context.items'); + if (itemMap['type'] != 'string') { + throw FormatException('$context.items.type must be string.'); } + _validateRequiredStringListKeyword(itemMap, 'enum', '$context.items'); return; } final anyOf = itemMap['anyOf']; @@ -600,6 +698,50 @@ void _validateMultiSelectEnumSchema( throw FormatException('$context.items must define a string enum.'); } +void _validateOptionalStringKeyword( + Map json, + String key, + String context, +) { + final value = json[key]; + if (value != null && value is! String) { + throw FormatException('$context.$key must be a string.'); + } +} + +void _validateOptionalIntegerKeyword( + Map json, + String key, + String context, +) { + if (json[key] == null) { + return; + } + readOptionalInteger(json[key], '$context.$key'); +} + +void _validateOptionalStringListKeyword( + Map json, + String key, + String context, +) { + if (json[key] == null) { + return; + } + _validateRequiredStringListKeyword(json, key, context); +} + +void _validateRequiredStringListKeyword( + Map json, + String key, + String context, +) { + final value = json[key]; + if (value is! List || value.any((item) => item is! String)) { + throw FormatException('$context.$key must be a string array.'); + } +} + void _ensureAllowedKeys( Map json, Set allowed, @@ -613,6 +755,15 @@ void _ensureAllowedKeys( } } +String? _protocolVersionFromMeta(Map? meta) { + final protocolVersion = meta?[McpMetaKey.protocolVersion]; + return protocolVersion is String ? protocolVersion : null; +} + +bool _usesDraftNumberSchemaKeywords(String? protocolVersion) { + return protocolVersion != null && isStatelessProtocolVersion(protocolVersion); +} + Map? _parseElicitResultContent(Object? content) { if (content == null) { return null; @@ -621,39 +772,49 @@ Map? _parseElicitResultContent(Object? content) { throw const FormatException('ElicitResult.content must be an object.'); } final result = content.cast(); - _validateElicitResultContent(result, formatException: true); - return result; + return _normalizeElicitResultContent(result, formatException: true); } -void _validateElicitResultContent( +Map? _normalizeElicitResultContent( Map? content, { bool formatException = false, }) { if (content == null) { - return; + return null; } + final normalized = {}; for (final entry in content.entries) { final value = entry.value; if (value is String || value is bool) { + normalized[entry.key] = value; + continue; + } + if (value is int) { + normalized[entry.key] = value; continue; } - if (value is num && value.isFinite) { + if (value is double && + value.isFinite && + value == value.truncateToDouble()) { + normalized[entry.key] = value.toInt(); continue; } if (value is List && value.every((item) => item is String)) { + normalized[entry.key] = List.from(value); continue; } if (formatException) { throw FormatException( - 'ElicitResult.content.${entry.key} must be string, finite number, boolean, or string[]', + 'ElicitResult.content.${entry.key} must be string, integer, boolean, or string[]', ); } throw ArgumentError.value( value, 'content.${entry.key}', - 'ElicitResult content values must be string, finite number, boolean, or string[]', + 'ElicitResult content values must be string, integer, boolean, or string[]', ); } + return normalized; } void _validateElicitResultContentForAction( diff --git a/lib/src/types/initialization.dart b/lib/src/types/initialization.dart index 03d1007a..e743be17 100644 --- a/lib/src/types/initialization.dart +++ b/lib/src/types/initialization.dart @@ -18,6 +18,69 @@ Map? _asJsonObject(dynamic value) { throw FormatException('Expected object capability, got ${value.runtimeType}'); } +String _readRequiredString(Object? value, String field) { + if (value is String) { + return value; + } + throw FormatException('$field must be a string'); +} + +String? _readOptionalPresentString( + Map json, + String key, + String field, +) { + if (!json.containsKey(key)) { + return null; + } + return _readRequiredString(json[key], field); +} + +bool _isAbsoluteUri(String value) { + return Uri.tryParse(value)?.hasScheme ?? false; +} + +String? _readOptionalPresentUriString( + Map json, + String key, + String field, +) { + final value = _readOptionalPresentString(json, key, field); + if (value == null) { + return null; + } + if (!_isAbsoluteUri(value)) { + throw FormatException('$field must be an absolute URI'); + } + return value; +} + +void _validateAbsoluteUriString(String value, String field) { + if (!_isAbsoluteUri(value)) { + throw ArgumentError.value(value, field, 'must be an absolute URI'); + } +} + +List? _readOptionalIconList( + Map json, + String key, + String field, +) { + if (!json.containsKey(key)) { + return null; + } + + final value = json[key]; + if (value is! List) { + throw FormatException('$field must be a list of objects'); + } + + return [ + for (var i = 0; i < value.length; i++) + McpIcon.fromJson(readJsonObject(value[i], '$field[$i]')), + ]; +} + Map? _asStrictJsonObject(Object? value, String field) { if (value == null) { return null; @@ -150,25 +213,42 @@ class Implementation { factory Implementation.fromJson(Map json) { return Implementation( - name: json['name'] as String, - title: json['title'] as String?, - version: json['version'] as String, - description: json['description'] as String?, - icons: (json['icons'] as List?) - ?.map((e) => McpIcon.fromJson(e as Map)) - .toList(), - websiteUrl: json['websiteUrl'] as String?, + name: _readRequiredString(json['name'], 'Implementation.name'), + title: _readOptionalPresentString( + json, + 'title', + 'Implementation.title', + ), + version: _readRequiredString(json['version'], 'Implementation.version'), + description: _readOptionalPresentString( + json, + 'description', + 'Implementation.description', + ), + icons: _readOptionalIconList(json, 'icons', 'Implementation.icons'), + websiteUrl: _readOptionalPresentUriString( + json, + 'websiteUrl', + 'Implementation.websiteUrl', + ), ); } - Map toJson() => { - 'name': name, - if (title != null) 'title': title, - 'version': version, - if (description != null) 'description': description, - if (icons != null) 'icons': icons?.map((e) => e.toJson()).toList(), - if (websiteUrl != null) 'websiteUrl': websiteUrl, - }; + Map toJson() { + final websiteUrl = this.websiteUrl; + if (websiteUrl != null) { + _validateAbsoluteUriString(websiteUrl, 'Implementation.websiteUrl'); + } + + return { + 'name': name, + if (title != null) 'title': title, + 'version': version, + if (description != null) 'description': description, + if (icons != null) 'icons': icons?.map((e) => e.toJson()).toList(), + if (websiteUrl != null) 'websiteUrl': websiteUrl, + }; + } } /// Describes capabilities related to root resources (e.g., workspace folders). @@ -1083,6 +1163,16 @@ class DiscoverResult implements BaseResultData { }); factory DiscoverResult.fromJson(Map json) { + final resultType = readOptionalString( + json['resultType'], + 'DiscoverResult.resultType', + ); + if (resultType != resultTypeComplete) { + throw const FormatException( + 'DiscoverResult.resultType must be complete', + ); + } + final supportedVersions = json['supportedVersions']; if (supportedVersions is! List) { throw const FormatException( @@ -1091,7 +1181,6 @@ class DiscoverResult implements BaseResultData { } return DiscoverResult( - resultType: json['resultType'] as String? ?? 'complete', supportedVersions: supportedVersions.cast(), capabilities: ServerCapabilities.fromJson( json['capabilities'] as Map, @@ -1105,14 +1194,24 @@ class DiscoverResult implements BaseResultData { } @override - Map toJson() => { - 'resultType': resultType, - 'supportedVersions': supportedVersions, - 'capabilities': capabilities.toJson(), - 'serverInfo': serverInfo.toJson(), - if (instructions != null) 'instructions': instructions, - if (meta != null) '_meta': readJsonObject(meta, 'DiscoverResult._meta'), - }; + Map toJson() { + if (resultType != resultTypeComplete) { + throw ArgumentError.value( + resultType, + 'DiscoverResult.resultType', + 'must be complete', + ); + } + + return { + 'resultType': resultType, + 'supportedVersions': supportedVersions, + 'capabilities': capabilities.toJson(), + 'serverInfo': serverInfo.toJson(), + if (instructions != null) 'instructions': instructions, + if (meta != null) '_meta': readJsonObject(meta, 'DiscoverResult._meta'), + }; + } } /// Notification sent from the client to the server after initialization is finished. diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index 6cb55fd9..ddc82f9a 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -711,17 +711,21 @@ class InputRequest { /// Creates an embedded `elicitation/create` input request. factory InputRequest.elicit(ElicitRequest params) { + final inputParams = params.toJson( + protocolVersion: latestDraftProtocolVersion, + )..remove('task'); return InputRequest._( method: Method.elicitationCreate, - params: params.toJson(), + params: inputParams, ); } /// Creates an embedded `sampling/createMessage` input request. factory InputRequest.createMessage(CreateMessageRequest params) { + final inputParams = params.toJson()..remove('task'); return InputRequest._( method: Method.samplingCreateMessage, - params: params.toJson(), + params: inputParams, ); } @@ -745,13 +749,28 @@ class InputRequest { json['params'], 'InputRequest.params', ); - ElicitRequest.fromJson(params); + if (params.containsKey('task')) { + throw const FormatException( + 'InputRequest elicitation/create params must not include ' + 'legacy task metadata', + ); + } + ElicitRequest.fromJson( + params, + protocolVersion: latestDraftProtocolVersion, + ); return InputRequest._(method: method, params: params); case Method.samplingCreateMessage: final params = _readRequiredJsonObject( json['params'], 'InputRequest.params', ); + if (params.containsKey('task')) { + throw const FormatException( + 'InputRequest sampling/createMessage params must not include ' + 'legacy task metadata', + ); + } CreateMessageRequest.fromJson(params); return InputRequest._(method: method, params: params); case Method.rootsList: @@ -797,7 +816,10 @@ class InputRequest { if (method != Method.elicitationCreate || params == null) { throw StateError('InputRequest is not an elicitation/create request'); } - return ElicitRequest.fromJson(params!); + return ElicitRequest.fromJson( + params!, + protocolVersion: latestDraftProtocolVersion, + ); } /// The typed params for an embedded `sampling/createMessage` request. diff --git a/lib/src/types/logging.dart b/lib/src/types/logging.dart index b57aa24c..fe4090ae 100644 --- a/lib/src/types/logging.dart +++ b/lib/src/types/logging.dart @@ -22,7 +22,11 @@ class SetLevelRequest { factory SetLevelRequest.fromJson(Map json) => SetLevelRequest( - level: LoggingLevel.values.byName(json['level'] as String), + level: readRequiredEnumValue( + json['level'], + LoggingLevel.values, + 'SetLevelRequest.level', + ), ); Map toJson() => {'level': level.name}; @@ -74,7 +78,11 @@ class LoggingMessageNotification { Map json, ) => LoggingMessageNotification( - level: LoggingLevel.values.byName(json['level'] as String), + level: readRequiredEnumValue( + json['level'], + LoggingLevel.values, + 'LoggingMessageNotification.level', + ), logger: json['logger'] as String?, data: json['data'], ); diff --git a/lib/src/types/prompts.dart b/lib/src/types/prompts.dart index 71a50939..a74ab569 100644 --- a/lib/src/types/prompts.dart +++ b/lib/src/types/prompts.dart @@ -295,7 +295,9 @@ class PromptMessage { factory PromptMessage.fromJson(Map json) { return PromptMessage( - role: PromptMessageRole.values.byName(json['role'] as String), + role: PromptMessageRole.values.byName( + readRequiredRoleString(json['role'], 'PromptMessage.role'), + ), content: Content.fromJson(json['content'] as Map), ); } diff --git a/lib/src/types/resources.dart b/lib/src/types/resources.dart index 52dc0a77..aa226545 100644 --- a/lib/src/types/resources.dart +++ b/lib/src/types/resources.dart @@ -32,14 +32,24 @@ class ResourceAnnotations { factory ResourceAnnotations.fromJson(Map json) { return ResourceAnnotations( title: json['title'] as String?, - audience: (json['audience'] as List?)?.cast(), + audience: readOptionalAnnotationAudience( + json['audience'], + 'ResourceAnnotations.audience', + ), priority: readUnitDouble(json['priority'], 'ResourceAnnotations.priority'), - lastModified: json['lastModified'] as String?, + lastModified: readOptionalString( + json['lastModified'], + 'ResourceAnnotations.lastModified', + ), ); } Map toJson() { + validateAnnotationAudience( + audience, + 'ResourceAnnotations.audience', + ); validateUnitDouble(priority, 'ResourceAnnotations.priority'); return { if (audience != null) 'audience': audience, @@ -100,7 +110,7 @@ class Resource { /// Creates from JSON. factory Resource.fromJson(Map json) { return Resource( - uri: json['uri'] as String, + uri: readRequiredAbsoluteUriString(json['uri'], 'Resource.uri'), name: json['name'] as String, title: json['title'] as String?, description: json['description'] as String?, @@ -114,7 +124,7 @@ class Resource { size: readOptionalInteger(json['size'], 'Resource.size'), annotations: json['annotations'] != null ? ResourceAnnotations.fromJson( - json['annotations'] as Map, + readJsonObject(json['annotations'], 'Resource.annotations'), ) : null, meta: readOptionalJsonObject(json['_meta'], 'Resource._meta'), @@ -122,18 +132,20 @@ class Resource { } /// Converts to JSON. - Map toJson() => { - 'uri': uri, - 'name': name, - if (title != null) 'title': title, - if (description != null) 'description': description, - if (mimeType != null) 'mimeType': mimeType, - if (icons != null) - 'icons': icons!.map((icon) => icon.toJson()).toList(), - if (size != null) 'size': size, - if (annotations != null) 'annotations': annotations!.toJson(), - if (meta != null) '_meta': readJsonObject(meta, 'Resource._meta'), - }; + Map toJson() { + validateAbsoluteUriString(uri, 'Resource.uri'); + return { + 'uri': uri, + 'name': name, + if (title != null) 'title': title, + if (description != null) 'description': description, + if (mimeType != null) 'mimeType': mimeType, + if (icons != null) 'icons': icons!.map((icon) => icon.toJson()).toList(), + if (size != null) 'size': size, + if (annotations != null) 'annotations': annotations!.toJson(), + if (meta != null) '_meta': readJsonObject(meta, 'Resource._meta'), + }; + } } /// A template description for resources available on the server. @@ -184,7 +196,10 @@ class ResourceTemplate { /// Creates from JSON. factory ResourceTemplate.fromJson(Map json) { return ResourceTemplate( - uriTemplate: json['uriTemplate'] as String, + uriTemplate: readRequiredUriTemplateString( + json['uriTemplate'], + 'ResourceTemplate.uriTemplate', + ), name: json['name'] as String, title: json['title'] as String?, description: json['description'] as String?, @@ -197,7 +212,10 @@ class ResourceTemplate { .toList(), annotations: json['annotations'] != null ? ResourceAnnotations.fromJson( - json['annotations'] as Map, + readJsonObject( + json['annotations'], + 'ResourceTemplate.annotations', + ), ) : null, meta: readOptionalJsonObject(json['_meta'], 'ResourceTemplate._meta'), @@ -205,18 +223,22 @@ class ResourceTemplate { } /// Converts to JSON. - Map toJson() => { - 'uriTemplate': uriTemplate, - 'name': name, - if (title != null) 'title': title, - if (description != null) 'description': description, - if (mimeType != null) 'mimeType': mimeType, - if (icons != null) - 'icons': icons!.map((icon) => icon.toJson()).toList(), - if (annotations != null) 'annotations': annotations!.toJson(), - if (meta != null) - '_meta': readJsonObject(meta, 'ResourceTemplate._meta'), - }; + Map toJson() { + validateUriTemplateString( + uriTemplate, + 'ResourceTemplate.uriTemplate', + ); + return { + 'uriTemplate': uriTemplate, + 'name': name, + if (title != null) 'title': title, + if (description != null) 'description': description, + if (mimeType != null) 'mimeType': mimeType, + if (icons != null) 'icons': icons!.map((icon) => icon.toJson()).toList(), + if (annotations != null) 'annotations': annotations!.toJson(), + if (meta != null) '_meta': readJsonObject(meta, 'ResourceTemplate._meta'), + }; + } } /// Parameters for the `resources/list` request. Includes pagination. @@ -461,7 +483,10 @@ class ReadResourceRequest { factory ReadResourceRequest.fromJson(Map json) => ReadResourceRequest( - uri: json['uri'] as String, + uri: readRequiredAbsoluteUriString( + json['uri'], + 'ReadResourceRequest.uri', + ), inputResponses: InputResponse.mapFromJson( json['inputResponses'], 'ReadResourceRequest.inputResponses', @@ -472,12 +497,15 @@ class ReadResourceRequest { ), ); - Map toJson() => { - 'uri': uri, - if (inputResponses != null) - 'inputResponses': InputResponse.mapToJson(inputResponses!), - if (requestState != null) 'requestState': requestState, - }; + Map toJson() { + validateAbsoluteUriString(uri, 'ReadResourceRequest.uri'); + return { + 'uri': uri, + if (inputResponses != null) + 'inputResponses': InputResponse.mapToJson(inputResponses!), + if (requestState != null) 'requestState': requestState, + }; + } } /// Request sent from client to read a specific resource. @@ -583,9 +611,14 @@ class SubscribeRequest { const SubscribeRequest({required this.uri}); factory SubscribeRequest.fromJson(Map json) => - SubscribeRequest(uri: json['uri'] as String); + SubscribeRequest( + uri: readRequiredAbsoluteUriString(json['uri'], 'SubscribeRequest.uri'), + ); - Map toJson() => {'uri': uri}; + Map toJson() { + validateAbsoluteUriString(uri, 'SubscribeRequest.uri'); + return {'uri': uri}; + } } /// Request sent from client to subscribe to updates for a resource. @@ -621,9 +654,17 @@ class UnsubscribeRequest { const UnsubscribeRequest({required this.uri}); factory UnsubscribeRequest.fromJson(Map json) => - UnsubscribeRequest(uri: json['uri'] as String); + UnsubscribeRequest( + uri: readRequiredAbsoluteUriString( + json['uri'], + 'UnsubscribeRequest.uri', + ), + ); - Map toJson() => {'uri': uri}; + Map toJson() { + validateAbsoluteUriString(uri, 'UnsubscribeRequest.uri'); + return {'uri': uri}; + } } /// Request sent from client to cancel a resource subscription. @@ -661,9 +702,17 @@ class ResourceUpdatedNotification { factory ResourceUpdatedNotification.fromJson( Map json, ) => - ResourceUpdatedNotification(uri: json['uri'] as String); + ResourceUpdatedNotification( + uri: readRequiredAbsoluteUriString( + json['uri'], + 'ResourceUpdatedNotification.uri', + ), + ); - Map toJson() => {'uri': uri}; + Map toJson() { + validateAbsoluteUriString(uri, 'ResourceUpdatedNotification.uri'); + return {'uri': uri}; + } } /// Notification from server indicating a subscribed resource has changed. diff --git a/lib/src/types/roots.dart b/lib/src/types/roots.dart index 6eb2c323..648b7fd2 100644 --- a/lib/src/types/roots.dart +++ b/lib/src/types/roots.dart @@ -1,6 +1,24 @@ import 'json_rpc.dart'; import 'validation.dart'; +String _readRootUri(Object? value) { + final uri = readRequiredString(value, 'Root.uri'); + if (!isAbsoluteUriString(uri)) { + throw const FormatException('Root.uri must be an absolute URI'); + } + if (!uri.startsWith('file://')) { + throw const FormatException('Root.uri must start with file://'); + } + return uri; +} + +void _validateRootUri(String uri) { + validateAbsoluteUriString(uri, 'Root.uri'); + if (!uri.startsWith('file://')) { + throw ArgumentError.value(uri, 'uri', 'Root.uri must start with file://'); + } +} + /// Represents a root directory or file the server can operate on. class Root { /// URI identifying the root (must start with `file://`). @@ -17,24 +35,25 @@ class Root { this.name, this.meta, }) { - if (!uri.startsWith('file://')) { - throw ArgumentError.value(uri, 'uri', 'Root.uri must start with file://'); - } + _validateRootUri(uri); } factory Root.fromJson(Map json) { return Root( - uri: json['uri'] as String, + uri: _readRootUri(json['uri']), name: json['name'] as String?, meta: readOptionalJsonObject(json['_meta'], 'Root._meta'), ); } - Map toJson() => { - 'uri': uri, - if (name != null) 'name': name, - if (meta != null) '_meta': readJsonObject(meta, 'Root._meta'), - }; + Map toJson() { + _validateRootUri(uri); + return { + 'uri': uri, + if (name != null) 'name': name, + if (meta != null) '_meta': readJsonObject(meta, 'Root._meta'), + }; + } } /// Request sent from server to client to get the list of root URIs. diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index 01580869..cd17fbcd 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -25,6 +25,20 @@ Map _asJsonObject( return map; } +String _base64ForJson(String value, String field) { + validateBase64String(value, field); + return value; +} + +Map _annotationsForJson( + Map value, + String field, +) { + final result = readJsonObject(value, field); + validateAnnotationsObject(result, field); + return result; +} + Object _parseSamplingMessageContent(dynamic value) { if (value is List) { return value @@ -287,30 +301,30 @@ sealed class SamplingContent { final SamplingTextContent c => { 'text': c.text, if (c.annotations != null) - 'annotations': readJsonObject( - c.annotations, + 'annotations': _annotationsForJson( + c.annotations!, 'SamplingTextContent.annotations', ), if (c.meta != null) '_meta': readJsonObject(c.meta, 'SamplingTextContent._meta'), }, final SamplingImageContent c => { - 'data': c.data, + 'data': _base64ForJson(c.data, 'SamplingImageContent.data'), 'mimeType': c.mimeType, if (c.annotations != null) - 'annotations': readJsonObject( - c.annotations, + 'annotations': _annotationsForJson( + c.annotations!, 'SamplingImageContent.annotations', ), if (c.meta != null) '_meta': readJsonObject(c.meta, 'SamplingImageContent._meta'), }, final SamplingAudioContent c => { - 'data': c.data, + 'data': _base64ForJson(c.data, 'SamplingAudioContent.data'), 'mimeType': c.mimeType, if (c.annotations != null) - 'annotations': readJsonObject( - c.annotations, + 'annotations': _annotationsForJson( + c.annotations!, 'SamplingAudioContent.annotations', ), if (c.meta != null) @@ -365,7 +379,7 @@ class SamplingTextContent extends SamplingContent { factory SamplingTextContent.fromJson(Map json) => SamplingTextContent( text: json['text'] as String, - annotations: _asJsonObjectOrNull( + annotations: readOptionalAnnotationsObject( json['annotations'], 'SamplingTextContent.annotations', ), @@ -396,9 +410,12 @@ class SamplingImageContent extends SamplingContent { factory SamplingImageContent.fromJson(Map json) => SamplingImageContent( - data: json['data'] as String, + data: readRequiredBase64String( + json['data'], + 'SamplingImageContent.data', + ), mimeType: json['mimeType'] as String, - annotations: _asJsonObjectOrNull( + annotations: readOptionalAnnotationsObject( json['annotations'], 'SamplingImageContent.annotations', ), @@ -429,9 +446,12 @@ class SamplingAudioContent extends SamplingContent { factory SamplingAudioContent.fromJson(Map json) => SamplingAudioContent( - data: json['data'] as String, + data: readRequiredBase64String( + json['data'], + 'SamplingAudioContent.data', + ), mimeType: json['mimeType'] as String, - annotations: _asJsonObjectOrNull( + annotations: readOptionalAnnotationsObject( json['annotations'], 'SamplingAudioContent.annotations', ), @@ -540,7 +560,9 @@ class SamplingMessage { factory SamplingMessage.fromJson(Map json) { return SamplingMessage( - role: SamplingMessageRole.values.byName(json['role'] as String), + role: SamplingMessageRole.values.byName( + readRequiredRoleString(json['role'], 'SamplingMessage.role'), + ), content: _parseSamplingMessageContent(json['content']), meta: _asJsonObjectOrNull(json['_meta'], 'SamplingMessage._meta'), ); @@ -574,11 +596,13 @@ class ToolChoice { return const ToolChoice(); } - if (rawMode is! String) { - throw FormatException('Expected toolChoice mode string, got $rawMode'); - } - - return ToolChoice(mode: ToolChoiceMode.values.byName(rawMode)); + return ToolChoice( + mode: readRequiredEnumValue( + rawMode, + ToolChoiceMode.values, + 'ToolChoice.mode', + ), + ); } Map toJson() => { @@ -645,9 +669,14 @@ class CreateMessageRequest { }); factory CreateMessageRequest.fromJson(Map json) { - final ctxStr = json['includeContext'] as String?; - final task = _asJsonObjectOrNull(json['task']); - final toolChoice = _asJsonObjectOrNull(json['toolChoice']); + final task = _asJsonObjectOrNull(json['task'], 'CreateMessageRequest.task'); + final toolChoice = _asJsonObjectOrNull( + json['toolChoice'], + 'CreateMessageRequest.toolChoice', + ); + if (toolChoice != null) { + ToolChoice.fromJson(toolChoice); + } final messages = json['messages']; if (messages is! List) { throw const FormatException('CreateMessageRequest.messages is required'); @@ -658,13 +687,19 @@ class CreateMessageRequest { .toList(), task: task == null ? null : TaskCreation.fromJson(task), systemPrompt: json['systemPrompt'] as String?, - includeContext: - ctxStr == null ? null : IncludeContext.values.byName(ctxStr), + includeContext: readOptionalEnumValue( + json['includeContext'], + IncludeContext.values, + 'CreateMessageRequest.includeContext', + ), temperature: readOptionalFiniteDouble( json['temperature'], 'CreateMessageRequest.temperature', ), - maxTokens: json['maxTokens'] as int, + maxTokens: readInteger( + json['maxTokens'], + 'CreateMessageRequest.maxTokens', + ), stopSequences: (json['stopSequences'] as List?)?.cast(), metadata: _asJsonObjectOrNull( json['metadata'], @@ -795,7 +830,9 @@ class CreateMessageResult implements BaseResultData { return CreateMessageResult( model: json['model'] as String, stopReason: reason, - role: SamplingMessageRole.values.byName(json['role'] as String), + role: SamplingMessageRole.values.byName( + readRequiredRoleString(json['role'], 'CreateMessageResult.role'), + ), content: _parseSamplingMessageContent(json['content']), meta: meta, ); diff --git a/lib/src/types/tasks.dart b/lib/src/types/tasks.dart index 511e712c..633c6417 100644 --- a/lib/src/types/tasks.dart +++ b/lib/src/types/tasks.dart @@ -178,6 +178,9 @@ int? _readTaskInt( if (value == null || value is int) { return value as int?; } + if (value is double && value.isFinite && value == value.truncateToDouble()) { + return value.toInt(); + } throw FormatException('$owner.$field must be an integer or null'); } @@ -442,7 +445,7 @@ class TaskCreation { const TaskCreation({this.ttl}); factory TaskCreation.fromJson(Map json) => - TaskCreation(ttl: json['ttl'] as int?); + TaskCreation(ttl: readOptionalInteger(json['ttl'], 'TaskCreation.ttl')); Map toJson() => { if (ttl != null) 'ttl': ttl, diff --git a/lib/src/types/validation.dart b/lib/src/types/validation.dart index 21220b97..021862ed 100644 --- a/lib/src/types/validation.dart +++ b/lib/src/types/validation.dart @@ -1,3 +1,173 @@ +import 'dart:convert'; + +import '../shared/uri_template.dart'; + +final _base64Pattern = RegExp( + r'^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$', +); + +String readRequiredString(Object? value, String field) { + if (value is String) { + return value; + } + throw FormatException('$field must be a string'); +} + +bool isAbsoluteUriString(String value) { + return Uri.tryParse(value)?.hasScheme ?? false; +} + +String readRequiredAbsoluteUriString(Object? value, String field) { + final result = readRequiredString(value, field); + if (!isAbsoluteUriString(result)) { + throw FormatException('$field must be an absolute URI'); + } + return result; +} + +void validateAbsoluteUriString(String value, String field) { + if (!isAbsoluteUriString(value)) { + throw ArgumentError.value(value, field, 'must be an absolute URI'); + } +} + +String readRequiredUriTemplateString(Object? value, String field) { + final result = readRequiredString(value, field); + try { + UriTemplateExpander(result); + } on ArgumentError catch (error) { + throw FormatException( + '$field must be a URI template: ${error.message}', + ); + } + return result; +} + +void validateUriTemplateString(String value, String field) { + try { + UriTemplateExpander(value); + } on ArgumentError catch (error) { + throw ArgumentError.value( + value, + field, + 'must be a URI template: ' + '${error.message}'); + } +} + +bool isBase64String(String value) { + if (!_base64Pattern.hasMatch(value)) { + return false; + } + try { + base64.decode(value); + return true; + } on FormatException { + return false; + } +} + +String readRequiredBase64String(Object? value, String field) { + final result = readRequiredString(value, field); + if (!isBase64String(result)) { + throw FormatException('$field must be a base64-encoded string'); + } + return result; +} + +void validateBase64String(String value, String field) { + if (!isBase64String(value)) { + throw ArgumentError.value( + value, + field, + 'must be a base64-encoded string', + ); + } +} + +T readRequiredEnumValue( + Object? value, + Iterable values, + String field, +) { + final name = readRequiredString(value, field); + for (final enumValue in values) { + if (enumValue.name == name) { + return enumValue; + } + } + final allowed = values.map((value) => '"${value.name}"').join(', '); + throw FormatException('$field must be one of: $allowed'); +} + +T? readOptionalEnumValue( + Object? value, + Iterable values, + String field, +) { + if (value == null) { + return null; + } + return readRequiredEnumValue(value, values, field); +} + +bool isRoleString(String value) { + return value == 'user' || value == 'assistant'; +} + +String readRequiredRoleString(Object? value, String field) { + final result = readRequiredString(value, field); + if (!isRoleString(result)) { + throw FormatException('$field must be "user" or "assistant"'); + } + return result; +} + +List? readOptionalAnnotationAudience(Object? value, String field) { + if (value == null) { + return null; + } + if (value is! List) { + throw FormatException('$field must be a list of roles'); + } + return [ + for (final item in value) readRequiredRoleString(item, '$field items'), + ]; +} + +void validateAnnotationAudience(List? value, String field) { + if (value == null) { + return; + } + for (final item in value) { + if (!isRoleString(item)) { + throw ArgumentError.value( + item, + field, + 'items must be "user" or "assistant"', + ); + } + } +} + +void validateAnnotationsObject(Map? value, String field) { + if (value == null) { + return; + } + readOptionalAnnotationAudience(value['audience'], '$field.audience'); + readUnitDouble(value['priority'], '$field.priority'); + readOptionalString(value['lastModified'], '$field.lastModified'); +} + +Map? readOptionalAnnotationsObject( + Object? value, + String field, +) { + final result = readOptionalJsonObject(value, field); + validateAnnotationsObject(result, field); + return result; +} + double? readUnitDouble(Object? value, String field) { final number = readOptionalFiniteNumber(value, field); final result = number?.toDouble(); @@ -63,6 +233,14 @@ int? readOptionalInteger(Object? value, String field) { throw FormatException('$field must be an integer'); } +int readInteger(Object? value, String field) { + final integer = readOptionalInteger(value, field); + if (integer == null) { + throw FormatException('$field is required'); + } + return integer; +} + String? readOptionalString(Object? value, String field) { if (value == null) { return null; diff --git a/packages/mcp_dart_cli/lib/src/conformance_runner.dart b/packages/mcp_dart_cli/lib/src/conformance_runner.dart index 78bf05f6..3ec05a10 100644 --- a/packages/mcp_dart_cli/lib/src/conformance_runner.dart +++ b/packages/mcp_dart_cli/lib/src/conformance_runner.dart @@ -525,6 +525,25 @@ Future _initializeClient( final connectFuture = client.connect(transport); await _settle(); + final discoverRequests = transport.sentMessages + .whereType() + .where((request) => request.method == _serverDiscoverMethod) + .toList(); + for (final discoverRequest in discoverRequests) { + transport.emit( + JsonRpcError( + id: discoverRequest.id, + error: JsonRpcErrorData( + code: ErrorCode.methodNotFound.value, + message: 'Method not found', + ), + ), + ); + } + if (discoverRequests.isNotEmpty) { + await _settle(); + } + final initializeRequests = transport.sentMessages .whereType() .where((request) => request.method == Method.initialize) diff --git a/test/client/client_elicitation_defaults_test.dart b/test/client/client_elicitation_defaults_test.dart index 85d05814..29b0cf0e 100644 --- a/test/client/client_elicitation_defaults_test.dart +++ b/test/client/client_elicitation_defaults_test.dart @@ -17,7 +17,18 @@ class MockTransport extends Transport { @override Future send(JsonRpcMessage message, {int? relatedRequestId}) async { sentMessages.add(message); - if (message is JsonRpcRequest && message.method == Method.initialize) { + if (message is JsonRpcRequest && message.method == Method.serverDiscover) { + _respond( + JsonRpcError( + id: message.id, + error: const JsonRpcErrorData( + code: -32601, + message: 'Method not found', + ), + ), + ); + } else if (message is JsonRpcRequest && + message.method == Method.initialize) { _respond( JsonRpcResponse( id: message.id, diff --git a/test/client/client_test.dart b/test/client/client_test.dart index b790113f..ef07d9d0 100644 --- a/test/client/client_test.dart +++ b/test/client/client_test.dart @@ -109,8 +109,10 @@ void main() { expect(transport.sentMessages.length, greaterThan(0)); expect(transport.sentMessages.first is JsonRpcRequest, isTrue); expect( - (transport.sentMessages.first as JsonRpcRequest).method, - equals('initialize'), + transport.sentMessages + .whereType() + .map((message) => message.method), + containsAllInOrder([Method.serverDiscover, Method.initialize]), ); // Verify that an initialized notification was sent @@ -585,8 +587,20 @@ class MockTransport extends Transport { Future send(JsonRpcMessage message, {int? relatedRequestId}) async { sentMessages.add(message); - // If it's an initialize request, respond with the mock response - if (message is JsonRpcRequest && + // Simulate a legacy peer by rejecting discovery, then respond to initialize. + if (message is JsonRpcRequest && message.method == Method.serverDiscover) { + if (onmessage != null) { + onmessage!( + JsonRpcError( + id: message.id, + error: const JsonRpcErrorData( + code: -32601, + message: 'Method not found', + ), + ), + ); + } + } else if (message is JsonRpcRequest && message.method == 'initialize' && mockInitializeResponse != null) { if (onmessage != null) { diff --git a/test/client/client_tool_validation_test.dart b/test/client/client_tool_validation_test.dart index 252263ed..fc77c58b 100644 --- a/test/client/client_tool_validation_test.dart +++ b/test/client/client_tool_validation_test.dart @@ -28,7 +28,18 @@ class MockTransport extends Transport @override Future send(JsonRpcMessage message, {int? relatedRequestId}) async { sentMessages.add(message); - if (message is JsonRpcRequest && message.method == Method.initialize) { + if (message is JsonRpcRequest && message.method == Method.serverDiscover) { + _respond( + JsonRpcError( + id: message.id, + error: const JsonRpcErrorData( + code: -32601, + message: 'Method not found', + ), + ), + ); + } else if (message is JsonRpcRequest && + message.method == Method.initialize) { _respond( JsonRpcResponse( id: message.id, diff --git a/test/client/streamable_https_test.dart b/test/client/streamable_https_test.dart index 8196269a..835e42a2 100644 --- a/test/client/streamable_https_test.dart +++ b/test/client/streamable_https_test.dart @@ -331,8 +331,11 @@ void main() { expect(initializeCount, 1); expect(initializedNotificationCount, 1); - expect(capturedSessionHeaders, isNotEmpty); - expect(capturedSessionHeaders, everyElement(preconfiguredSessionId)); + expect(capturedSessionHeaders, [ + null, + preconfiguredSessionId, + preconfiguredSessionId, + ]); expect(client.getServerCapabilities()?.logging, isNotNull); expect(client.getServerVersion()?.name, 'PreconfiguredSessionServer'); expect( diff --git a/test/elicitation_test.dart b/test/elicitation_test.dart index f13f2fff..75254bd8 100644 --- a/test/elicitation_test.dart +++ b/test/elicitation_test.dart @@ -19,6 +19,21 @@ class MockTransport extends Transport { Future send(JsonRpcMessage message, {int? relatedRequestId}) async { sentMessages.add(message); + // Handle discovery probe from default 2026 clients against this legacy + // mock transport. + if (message is JsonRpcRequest && message.method == Method.serverDiscover) { + onmessage?.call( + JsonRpcError( + id: message.id, + error: JsonRpcErrorData( + code: ErrorCode.methodNotFound.value, + message: 'Method not found', + ), + ), + ); + return; + } + // Handle initialize request if (message is JsonRpcRequest && message.method == 'initialize' && @@ -808,6 +823,7 @@ void main() { 'mode': 'form', 'message': 'Configure deployment', 'requestedSchema': { + r'$schema': 'https://json-schema.org/draft/2020-12/schema', 'type': 'object', 'properties': { 'email': { @@ -816,6 +832,8 @@ void main() { 'title': 'Email', 'description': 'Contact address', 'default': 'ops@example.com', + 'minLength': 3, + 'maxLength': 320, }, 'size': { 'type': 'string', @@ -846,6 +864,9 @@ void main() { }, 'features': { 'type': 'array', + 'minItems': 1, + 'maxItems': 2, + 'default': ['logs'], 'items': { 'type': 'string', 'enum': ['logs', 'metrics'], @@ -869,6 +890,138 @@ void main() { expect(request.toJson()['requestedSchema'], isA>()); }); + test('Form elicitation defaults to stable number schema keywords', () { + Map requestWithProperty( + String name, + Map property, + ) => + { + 'message': 'Configure deployment', + 'requestedSchema': { + 'type': 'object', + 'properties': {name: property}, + }, + }; + + for (final property in >{ + 'fractionalNumberDefault': { + 'type': 'number', + 'default': 0.5, + }, + 'fractionalNumberMinimum': { + 'type': 'number', + 'minimum': 0.1, + }, + 'fractionalNumberMaximum': { + 'type': 'number', + 'maximum': 0.9, + }, + 'fractionalIntegerDefault': { + 'type': 'integer', + 'default': 1.5, + }, + 'fractionalIntegerMinimum': { + 'type': 'integer', + 'minimum': 1.5, + }, + 'fractionalIntegerMaximum': { + 'type': 'integer', + 'maximum': 10.5, + }, + }.entries) { + expect( + () => ElicitRequestParams.fromJson( + requestWithProperty(property.key, property.value), + ), + throwsA(isA()), + ); + } + + expect( + () => ElicitRequestParams.form( + message: 'Configure deployment', + requestedSchema: JsonSchema.object( + properties: { + 'ratio': JsonSchema.number( + minimum: 0.1, + maximum: 0.9, + defaultValue: 0.5, + ), + }, + ), + ).toJson(), + throwsA(isA()), + ); + }); + + test('Draft form elicitation accepts fractional number schema keywords', + () { + final params = { + 'mode': 'form', + 'message': 'Configure deployment', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'ratio': { + 'type': 'number', + 'minimum': 0.1, + 'maximum': 0.9, + 'default': 0.5, + }, + 'count': { + 'type': 'integer', + 'minimum': 0.5, + 'maximum': 10.5, + 'default': 1.5, + }, + }, + }, + }; + + final request = ElicitRequestParams.fromJson( + params, + protocolVersion: draftProtocolVersion2026_07_28, + ); + final draftJson = request.toJson( + protocolVersion: draftProtocolVersion2026_07_28, + ); + final schema = draftJson['requestedSchema'] as Map; + final properties = schema['properties'] as Map; + + expect( + (properties['ratio'] as Map)['default'], + 0.5, + ); + expect( + (properties['count'] as Map)['default'], + 1.5, + ); + expect( + (properties['count'] as Map)['maximum'], + 10.5, + ); + expect( + () => request.toJson(), + throwsA(isA()), + ); + + final rpc = JsonRpcElicitRequest.fromJson({ + 'id': 1, + 'params': { + ...params, + '_meta': { + McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, + }, + }, + }); + final rpcSchema = rpc.params!['requestedSchema'] as Map; + final rpcProperties = rpcSchema['properties'] as Map; + expect( + (rpcProperties['ratio'] as Map)['minimum'], + 0.1, + ); + }); + test('Form elicitation rejects non-spec schema shapes', () { Map requestWithProperty( String name, @@ -917,6 +1070,68 @@ void main() { }), throwsA(isA()), ); + expect( + () => ElicitRequestParams.fromJson({ + 'message': 'Bad schema URI', + 'requestedSchema': { + r'$schema': 2020, + 'type': 'object', + 'properties': { + 'value': {'type': 'string'}, + }, + }, + }), + throwsA(isA()), + ); + for (final property in >{ + 'badStringTitle': { + 'type': 'string', + 'title': 1, + }, + 'badStringDefault': { + 'type': 'string', + 'default': false, + }, + 'badStringMinLength': { + 'type': 'string', + 'minLength': 1.5, + }, + 'badNumberDefault': { + 'type': 'number', + 'default': '0', + }, + 'badIntegerDefault': { + 'type': 'integer', + 'default': 1.5, + }, + 'badBooleanDefault': { + 'type': 'boolean', + 'default': 'false', + }, + 'badArrayDefault': { + 'type': 'array', + 'default': ['ok', 1], + 'items': { + 'type': 'string', + 'enum': ['ok'], + }, + }, + 'badArrayMinItems': { + 'type': 'array', + 'minItems': '1', + 'items': { + 'type': 'string', + 'enum': ['ok'], + }, + }, + }.entries) { + expect( + () => ElicitRequestParams.fromJson( + requestWithProperty(property.key, property.value), + ), + throwsA(isA()), + ); + } expect( () => ElicitRequestParams.fromJson( requestWithProperty('value', { @@ -1033,7 +1248,7 @@ void main() { 'action': 'accept', 'content': { 'text': 'value', - 'count': 3, + 'count': 3.0, 'confirmed': true, 'selections': ['a', 'b'], }, @@ -1060,13 +1275,13 @@ void main() { throwsA(isA()), ); expect( - ElicitResult.fromJson({ + () => ElicitResult.fromJson({ 'action': 'accept', 'content': { 'ratio': 0.5, }, - }).content?['ratio'], - 0.5, + }), + throwsA(isA()), ); expect( () => ElicitResult.fromJson({ @@ -1087,13 +1302,22 @@ void main() { throwsA(isA()), ); expect( - const ElicitResult( + () => const ElicitResult( action: 'accept', content: { 'ratio': 0.5, }, + ).toJson(), + throwsA(isA()), + ); + expect( + const ElicitResult( + action: 'accept', + content: { + 'count': 3.0, + }, ).toJson()['content'], - containsPair('ratio', 0.5), + containsPair('count', 3), ); expect( () => const ElicitResult( diff --git a/test/lifecycle_test.dart b/test/lifecycle_test.dart index 78ff1684..57c66c06 100644 --- a/test/lifecycle_test.dart +++ b/test/lifecycle_test.dart @@ -376,6 +376,7 @@ void main() { final client = Client( const Implementation(name: 'client', version: '1.0.0'), options: const ClientOptions( + useServerDiscover: false, capabilities: ClientCapabilities( sampling: ClientCapabilitiesSampling(), ), @@ -414,6 +415,7 @@ void main() { final transport = LifecycleTransport(); final client = Client( const Implementation(name: 'client', version: '1.0.0'), + options: const ClientOptions(useServerDiscover: false), ); final errors = []; client.onerror = errors.add; @@ -436,6 +438,7 @@ void main() { final client = Client( const Implementation(name: 'client', version: '1.0.0'), options: const ClientOptions( + useServerDiscover: false, capabilities: ClientCapabilities( sampling: ClientCapabilitiesSampling(), ), @@ -484,6 +487,7 @@ void main() { final transport = FailingInitializedSendTransport(); final client = Client( const Implementation(name: 'client', version: '1.0.0'), + options: const ClientOptions(useServerDiscover: false), ); final connectFuture = client.connect(transport); diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 20250b3b..05932c33 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -56,7 +56,8 @@ void main() { }); test('Icon Field Support', () { - final icon = const ImageContent(data: 'base64', mimeType: 'image/png'); + const iconData = 'YmFzZTY0'; + final icon = const ImageContent(data: iconData, mimeType: 'image/png'); final icons = [ const McpIcon( src: 'https://example.com/icon.png', @@ -71,12 +72,12 @@ void main() { icon: icon, icons: icons, ); - expect(tool.icon?.data, 'base64'); + expect(tool.icon?.data, iconData); expect(tool.toJson().containsKey('icon'), isFalse); expect((tool.toJson()['icons'] as List).first['theme'], 'dark'); expect( Tool.fromJson({...tool.toJson(), 'icon': icon.toJson()}).icon?.data, - 'base64', + iconData, ); final resource = Resource( @@ -85,14 +86,14 @@ void main() { icon: icon, icons: icons, ); - expect(resource.icon?.data, 'base64'); + expect(resource.icon?.data, iconData); expect(resource.toJson().containsKey('icon'), isFalse); expect((resource.toJson()['icons'] as List).first['theme'], 'dark'); expect( Resource.fromJson( {...resource.toJson(), 'icon': icon.toJson()}, ).icon?.data, - 'base64', + iconData, ); final prompt = Prompt( @@ -100,12 +101,12 @@ void main() { icon: icon, icons: icons, ); - expect(prompt.icon?.data, 'base64'); + expect(prompt.icon?.data, iconData); expect(prompt.toJson().containsKey('icon'), isFalse); expect((prompt.toJson()['icons'] as List).first['theme'], 'dark'); expect( Prompt.fromJson({...prompt.toJson(), 'icon': icon.toJson()}).icon?.data, - 'base64', + iconData, ); final template = ResourceTemplate( @@ -114,14 +115,14 @@ void main() { icon: icon, icons: icons, ); - expect(template.icon?.data, 'base64'); + expect(template.icon?.data, iconData); expect(template.toJson().containsKey('icon'), isFalse); expect((template.toJson()['icons'] as List).first['theme'], 'dark'); expect( ResourceTemplate.fromJson( {...template.toJson(), 'icon': icon.toJson()}, ).icon?.data, - 'base64', + iconData, ); }); @@ -238,19 +239,23 @@ void main() { message: 'test', url: 'https://example.com/ui', elicitationId: 'ui-123', + task: TaskCreationParams(ttl: 7200), ); expect(params.url, 'https://example.com/ui'); expect(params.elicitationId, 'ui-123'); + expect(params.task?.ttl, 7200); final json = params.toJson(); expect(json['mode'], 'url'); expect(json['url'], 'https://example.com/ui'); expect(json['elicitationId'], 'ui-123'); + expect(json['task'], {'ttl': 7200}); final deserialized = ElicitRequestParams.fromJson(json); expect(deserialized.url, 'https://example.com/ui'); expect(deserialized.elicitationId, 'ui-123'); + expect(deserialized.task?.ttl, 7200); }); test('Elicitation URL must be absolute URI', () { @@ -326,24 +331,24 @@ void main() { action: 'accept', content: { 'text': 'answer', - 'confidence': 0.75, + 'confidence': 75, 'selection': ['a', 'b'], // List }, ); - expect(result.content?['confidence'], 0.75); + expect(result.content?['confidence'], 75); expect(result.content?['selection'], isA()); expect((result.content?['selection'] as List).first, 'a'); final json = result.toJson(); final deserialized = ElicitResult.fromJson(json); - expect(deserialized.content?['confidence'], 0.75); + expect(deserialized.content?['confidence'], 75); expect((deserialized.content?['selection'] as List).last, 'b'); }); test('McpServer Metadata Logic', () { final server = McpServer(const Implementation(name: 'test', version: '1.0')); - final icon = const ImageContent(data: 'data', mimeType: 'image/png'); + final icon = const ImageContent(data: 'ZGF0YQ==', mimeType: 'image/png'); // We can rely on the fact that we updated the code to pass it through. // Let's rely on the previous unit tests for `Tool` serialization, and here just ensure `McpServer` methods don't crash. @@ -739,6 +744,17 @@ void main() { expect(deserialized.ttl, 3600); }); + test('TaskCreationParams accepts whole-number JSON ttl values', () { + final deserialized = TaskCreationParams.fromJson({'ttl': 3600.0}); + expect(deserialized.ttl, 3600); + expect(deserialized.toJson()['ttl'], 3600); + + expect( + () => TaskCreationParams.fromJson({'ttl': 3600.5}), + throwsA(isA()), + ); + }); + test('TaskCreationParams without ttl', () { final params = const TaskCreationParams(); expect(params.ttl, isNull); @@ -1039,6 +1055,22 @@ void main() { expect(json, isNot(contains('pollInterval'))); }); + test('Task accepts whole-number JSON ttl and poll interval values', () { + final task = Task.fromJson({ + 'taskId': 'numeric-task', + 'status': 'working', + 'ttl': 3600.0, + 'pollInterval': 500.0, + 'createdAt': '2025-01-15T10:00:00Z', + 'lastUpdatedAt': '2025-01-15T10:01:00Z', + }); + + expect(task.ttl, 3600); + expect(task.pollInterval, 500); + expect(task.toJson(), containsPair('ttl', 3600)); + expect(task.toJson(), containsPair('pollInterval', 500)); + }); + test('Task rejects missing MCP-required fields', () { expect( () => Task.fromJson({ @@ -1363,6 +1395,21 @@ void main() { ); expect(request.toJson()['requestedSchema']['type'], 'object'); + expect( + () => ElicitRequest.form( + message: 'Fractional bounds', + requestedSchema: JsonSchema.object( + properties: { + 'ratio': JsonSchema.number( + minimum: 0.1, + maximum: 0.9, + defaultValue: 0.5, + ), + }, + ), + ).toJson(), + throwsA(isA()), + ); expect( () => const ElicitRequest.form( message: 'Nested', @@ -1390,6 +1437,24 @@ void main() { ).toJson(), throwsA(isA()), ); + expect( + () => ElicitResult.fromJson({ + 'action': 'accept', + 'content': { + 'fractional': 1.5, + }, + }), + throwsA(isA()), + ); + expect( + () => const ElicitResult( + action: 'accept', + content: { + 'fractional': 1.5, + }, + ).toJson(), + throwsA(isA()), + ); expect( () => URLElicitationRequiredErrorData.fromJson({ 'elicitations': [ @@ -1417,6 +1482,16 @@ void main() { () => Annotations.fromJson({'priority': double.infinity}), throwsA(isA()), ); + expect( + () => Annotations.fromJson({ + 'audience': ['model'], + }), + throwsA(isA()), + ); + expect( + () => Annotations.fromJson({'lastModified': 1}), + throwsA(isA()), + ); expect( () => CompletionResultData( values: List.generate(101, (index) => '$index'), @@ -1429,10 +1504,27 @@ void main() { }), throwsA(isA()), ); + final completion = CompletionResultData.fromJson({ + 'values': ['a'], + 'total': 10.0, + }); + expect(completion.total, 10); + expect(completion.toJson()['total'], 10); + expect( + () => CompletionResultData.fromJson({ + 'values': ['a'], + 'total': 10.5, + }), + throwsA(isA()), + ); expect( () => Root(uri: 'https://example.com'), throwsA(isA()), ); + expect( + () => Root.fromJson({'uri': 'relative/path'}), + throwsA(isA()), + ); expect( () => ModelPreferences(costPriority: 2).toJson(), throwsA(anyOf(isA(), isA())), @@ -1441,6 +1533,64 @@ void main() { () => ModelPreferences.fromJson({'costPriority': -1}), throwsA(isA()), ); + expect( + () => SamplingMessage.fromJson({ + 'role': 'system', + 'content': {'type': 'text', 'text': 'Hello'}, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageResult.fromJson({ + 'role': 'system', + 'content': {'type': 'text', 'text': 'Hello'}, + 'model': 'model', + }), + throwsA(isA()), + ); + expect( + () => PromptMessage.fromJson({ + 'role': 'system', + 'content': {'type': 'text', 'text': 'Hello'}, + }), + throwsA(isA()), + ); + expect( + () => SetLevelRequestParams.fromJson({'level': 'verbose'}), + throwsA(isA()), + ); + expect( + () => LoggingMessageNotificationParams.fromJson({ + 'level': 'verbose', + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 100, + 'includeContext': 'nearbyServers', + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 100, + 'toolChoice': {'mode': 'sometimes'}, + }), + throwsA(isA()), + ); }); test('bare task containers strip task metadata', () { diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index ac69cfe4..ac056d59 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -130,9 +130,11 @@ class DiscoveringClientTransport extends Transport class LegacyFallbackTransport extends Transport implements ProtocolVersionAwareTransport { LegacyFallbackTransport({ + this.discoveryError, this.toolsListResult = const {'tools': []}, }); + final McpError? discoveryError; final Map toolsListResult; final List sentMessages = []; @@ -152,6 +154,10 @@ class LegacyFallbackTransport extends Transport sentMessages.add(message); if (message is JsonRpcRequest && message.method == Method.serverDiscover) { + final error = discoveryError; + if (error != null) { + throw error; + } onmessage?.call( JsonRpcError( id: message.id, @@ -387,6 +393,44 @@ void main() { ); }); + test('allows fractional elicitation number schema keywords', () { + final request = ElicitRequestParams.form( + message: 'Configure ratio', + requestedSchema: JsonSchema.object( + properties: { + 'ratio': JsonSchema.number( + minimum: 0.1, + maximum: 0.9, + defaultValue: 0.5, + ), + 'count': JsonSchema.integer( + minimum: 0.5, + maximum: 10.5, + defaultValue: 1.5, + ), + }, + ), + ); + + final json = request.toJson( + protocolVersion: draftProtocolVersion2026_07_28, + ); + final schema = json['requestedSchema'] as Map; + final properties = schema['properties'] as Map; + expect((properties['ratio'] as Map)['minimum'], 0.1); + expect((properties['count'] as Map)['default'], 1.5); + expect((properties['count'] as Map)['maximum'], 10.5); + + final inputRequest = InputRequest.elicit(request); + final inputSchema = + inputRequest.params!['requestedSchema'] as Map; + final inputProperties = inputSchema['properties'] as Map; + expect( + (inputProperties['ratio'] as Map)['default'], + 0.5, + ); + }); + test('rejects non-finite JSON numbers', () { expect( () => ProgressNotification.fromJson({ @@ -422,6 +466,13 @@ void main() { }), throwsA(isA()), ); + expect( + () => ElicitResult.fromJson({ + 'action': 'accept', + 'content': {'score': 1.5}, + }), + throwsA(isA()), + ); expect( () => const ElicitResult( action: 'accept', @@ -429,6 +480,13 @@ void main() { ).toJson(), throwsA(isA()), ); + expect( + () => const ElicitResult( + action: 'accept', + content: {'score': 1.5}, + ).toJson(), + throwsA(isA()), + ); }); test('rejects non-JSON sampling object values', () { @@ -567,6 +625,43 @@ void main() { ); }); + test('requires complete resultType on server/discover results', () { + final validResult = const DiscoverResult( + supportedVersions: [draftProtocolVersion2026_07_28], + capabilities: ServerCapabilities(), + serverInfo: Implementation(name: 'server', version: '1.0.0'), + ).toJson(); + + for (final json in [ + { + ...validResult, + }..remove('resultType'), + { + ...validResult, + 'resultType': resultTypeInputRequired, + }, + { + ...validResult, + 'resultType': 1, + }, + ]) { + expect( + () => DiscoverResult.fromJson(json), + throwsFormatException, + ); + } + + expect( + () => const DiscoverResult( + resultType: resultTypeInputRequired, + supportedVersions: [draftProtocolVersion2026_07_28], + capabilities: ServerCapabilities(), + serverInfo: Implementation(name: 'server', version: '1.0.0'), + ).toJson(), + throwsArgumentError, + ); + }); + test('requires server/discover request metadata in params', () { expect( () => JsonRpcServerDiscoverRequest(id: 'discover-1').toJson(), @@ -719,6 +814,7 @@ void main() { properties: {'name': JsonSchema.string()}, required: ['name'], ), + task: const TaskCreation(ttl: 1000), ), ), 'capital_of_france': InputRequest.createMessage( @@ -731,6 +827,7 @@ void main() { ), ), ], + task: TaskCreation(ttl: 1000), maxTokens: 100, ), ), @@ -748,10 +845,18 @@ void main() { json['inputRequests']['github_login']['method'], Method.elicitationCreate, ); + expect( + json['inputRequests']['github_login']['params'], + isNot(contains('task')), + ); expect( json['inputRequests']['capital_of_france']['method'], Method.samplingCreateMessage, ); + expect( + json['inputRequests']['capital_of_france']['params'], + isNot(contains('task')), + ); expect(json['inputRequests']['roots'], {'method': Method.rootsList}); final parsed = InputRequiredResult.fromJson(json); @@ -760,11 +865,19 @@ void main() { parsed.inputRequests!['github_login']!.elicitParams.message, 'Please provide your GitHub username', ); + expect( + parsed.inputRequests!['github_login']!.elicitParams.task, + isNull, + ); expect( parsed .inputRequests!['capital_of_france']!.createMessageParams.maxTokens, 100, ); + expect( + parsed.inputRequests!['capital_of_france']!.createMessageParams.task, + isNull, + ); }); test('serializes MRTR retry fields on supported client requests', () { @@ -897,6 +1010,56 @@ void main() { ), throwsFormatException, ); + expect( + () => InputRequiredResult.fromJson( + const { + 'resultType': resultTypeInputRequired, + 'inputRequests': { + 'legacy_task_elicit': { + 'method': Method.elicitationCreate, + 'params': { + 'mode': 'form', + 'message': 'Need username', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + }, + }, + 'task': {'ttl': 1000}, + }, + }, + }, + }, + ), + throwsFormatException, + ); + expect( + () => InputRequiredResult.fromJson( + const { + 'resultType': resultTypeInputRequired, + 'inputRequests': { + 'legacy_task_sampling': { + 'method': Method.samplingCreateMessage, + 'params': { + 'messages': [ + { + 'role': 'user', + 'content': { + 'type': 'text', + 'text': 'Continue?', + }, + }, + ], + 'maxTokens': 1, + 'task': {'ttl': 1000}, + }, + }, + }, + }, + ), + throwsFormatException, + ); expect( () => CallToolRequest.fromJson( const {'name': 'deploy', 'requestState': 1}, @@ -1541,6 +1704,18 @@ void main() { resultParams: const TaskResultRequest(taskId: 'task-1'), meta: taskExtensionMeta, ), + ) + ..receive( + JsonRpcTaskStatusNotification( + statusParams: const TaskStatusNotification( + taskId: 'task-1', + status: TaskStatus.working, + ttl: null, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:00Z', + ), + meta: taskExtensionMeta, + ), ); await _pump(); @@ -2485,12 +2660,11 @@ void main() { expect(transport.sentMessages.single, isA()); }); - test('client can opt in to server/discover and sends stateless metadata', + test('client defaults to server/discover and sends stateless metadata', () async { final transport = DiscoveringClientTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), ); await client.connect(transport); @@ -2517,6 +2691,62 @@ void main() { expect(listRequest.meta?[McpMetaKey.clientCapabilities], {}); }); + test('client can opt out of discovery for legacy initialization', () async { + final transport = LegacyFallbackTransport(); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: false), + ); + + await client.connect(transport); + + expect(client.getProtocolVersion(), stableProtocolVersion2025_11_25); + expect(transport.protocolVersion, stableProtocolVersion2025_11_25); + expect( + transport.sentMessages + .whereType() + .map((message) => message.method), + isNot(contains(Method.serverDiscover)), + ); + expect( + transport.sentMessages + .whereType() + .map((message) => message.method), + contains(Method.initialize), + ); + }); + + test('client falls back when legacy HTTP rejects discovery before init', + () async { + final errors = [ + McpError( + 0, + 'Error POSTing to endpoint (HTTP 400): ' + '{"jsonrpc":"2.0","error":{"code":-32000,' + '"message":"Bad Request: Server not initialized"},"id":null}', + ), + McpError(0, 'Error POSTing to endpoint (HTTP 400): '), + ]; + + for (final error in errors) { + final transport = LegacyFallbackTransport(discoveryError: error); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + + await client.connect(transport); + + expect(client.getProtocolVersion(), stableProtocolVersion2025_11_25); + expect(transport.protocolVersion, stableProtocolVersion2025_11_25); + expect( + transport.sentMessages + .whereType() + .map((message) => message.method), + [Method.serverDiscover, Method.initialize], + ); + } + }); + test('stateless client rejects removed request methods before send', () async { final transport = DiscoveringClientTransport(); @@ -2623,6 +2853,20 @@ void main() { method: Method.notificationsRootsListChanged, call: client.sendRootsListChanged, ), + ( + method: Method.notificationsTasksStatus, + call: () => client.notification( + JsonRpcTaskStatusNotification( + statusParams: const TaskStatusNotification( + taskId: 'task-1', + status: TaskStatus.working, + ttl: null, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:00Z', + ), + ), + ), + ), ]; for (final scenario in removedNotifications) { @@ -3444,7 +3688,6 @@ void main() { final transport = LegacyFallbackTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), ); await client.connect(transport); diff --git a/test/server/server_test.dart b/test/server/server_test.dart index 7b5e6dda..7c1585c4 100644 --- a/test/server/server_test.dart +++ b/test/server/server_test.dart @@ -507,7 +507,7 @@ void main() { // Send resource updated notification final resourceParams = const ResourceUpdatedNotification( - uri: 'test-resource', + uri: 'file:///test-resource', ); await resourceServer.sendResourceUpdated(resourceParams); @@ -542,7 +542,7 @@ void main() { ); final resourceParams = const ResourceUpdatedNotification( - uri: 'test-resource', + uri: 'file:///test-resource', ); expect( () => plainServer.sendResourceUpdated(resourceParams), diff --git a/test/server/streamable_https_test.dart b/test/server/streamable_https_test.dart index 8c288071..6038152b 100644 --- a/test/server/streamable_https_test.dart +++ b/test/server/streamable_https_test.dart @@ -2730,6 +2730,18 @@ void main() { contains('no matching request _meta protocol version'), ); + final topLevelMetaOnly = const JsonRpcListToolsRequest(id: 20).toJson() + ..['_meta'] = _statelessMeta(); + body = await postJson( + topLevelMetaOnly, + headers: { + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.toolsList, + }, + ); + expect(body['id'], 20); + expect(body['error']['message'], contains('params._meta')); + body = await postJson( JsonRpcListToolsRequest(id: 5, meta: _statelessMeta()).toJson(), headers: { diff --git a/test/server/streamable_mcp_server_test.dart b/test/server/streamable_mcp_server_test.dart index 3998bfcd..3bba902e 100644 --- a/test/server/streamable_mcp_server_test.dart +++ b/test/server/streamable_mcp_server_test.dart @@ -428,6 +428,30 @@ void main() { ); }); + test('keeps top-level metadata as stateless detection fallback', () async { + final response = await http.post( + Uri.parse(baseUrl), + body: jsonEncode( + const JsonRpcListToolsRequest(id: 12).toJson() + ..['_meta'] = const { + McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, + }, + ), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'Mcp-Method': Method.toolsList, + }, + ); + + expect(response.statusCode, HttpStatus.badRequest); + final body = jsonDecode(response.body) as Map; + expect( + body['error']['message'], + contains('MCP-Protocol-Version header is required'), + ); + }); + test('routes 2026 stateless non-POST methods without a session ID', () async { final client = HttpClient(); diff --git a/test/shared/json_schema_from_json_test.dart b/test/shared/json_schema_from_json_test.dart index efccbf09..c19165e3 100644 --- a/test/shared/json_schema_from_json_test.dart +++ b/test/shared/json_schema_from_json_test.dart @@ -22,6 +22,32 @@ void main() { expect(s.enumValues, ['a', 'b']); }); + test('accepts whole-number numeric string schema bounds', () { + final schema = JsonSchema.fromJson({ + 'type': 'string', + 'minLength': 5.0, + 'maxLength': 10.0, + }); + + expect(schema, isA()); + final stringSchema = schema as JsonString; + expect(stringSchema.minLength, 5); + expect(stringSchema.maxLength, 10); + expect(stringSchema.toJson(), { + 'minLength': 5, + 'maxLength': 10, + 'type': 'string', + }); + + expect( + () => JsonSchema.fromJson({ + 'type': 'string', + 'minLength': 1.5, + }), + throwsA(isA()), + ); + }); + test('preserves mixed typed enum schemas conjunctively', () { final json = { 'type': 'string', @@ -146,20 +172,23 @@ void main() { test('parses integer schema', () { final json = { 'type': 'integer', - 'minimum': 1, - 'maximum': 10, - 'exclusiveMinimum': 0, - 'exclusiveMaximum': 11, - 'multipleOf': 2, + 'minimum': 1.5, + 'maximum': 10.5, + 'exclusiveMinimum': 0.5, + 'exclusiveMaximum': 11.5, + 'multipleOf': 0.5, + 'default': 2.0, }; final schema = JsonSchema.fromJson(json); expect(schema, isA()); final s = schema as JsonInteger; - expect(s.minimum, 1); - expect(s.maximum, 10); - expect(s.exclusiveMinimum, 0); - expect(s.exclusiveMaximum, 11); - expect(s.multipleOf, 2); + expect(s.minimum, 1.5); + expect(s.maximum, 10.5); + expect(s.exclusiveMinimum, 0.5); + expect(s.exclusiveMaximum, 11.5); + expect(s.multipleOf, 0.5); + expect(s.defaultValue, 2.0); + expect(s.toJson(), json); }); test('parses boolean schema', () { @@ -191,6 +220,32 @@ void main() { expect(s.uniqueItems, true); }); + test('accepts whole-number numeric array schema bounds', () { + final schema = JsonSchema.fromJson({ + 'type': 'array', + 'minItems': 1.0, + 'maxItems': 5.0, + }); + + expect(schema, isA()); + final arraySchema = schema as JsonArray; + expect(arraySchema.minItems, 1); + expect(arraySchema.maxItems, 5); + expect(arraySchema.toJson(), { + 'minItems': 1, + 'maxItems': 5, + 'type': 'array', + }); + + expect( + () => JsonSchema.fromJson({ + 'type': 'array', + 'minItems': 1.5, + }), + throwsA(isA()), + ); + }); + test('parses object schema', () { final json = { 'type': 'object', diff --git a/test/shared/json_schema_validator_test.dart b/test/shared/json_schema_validator_test.dart index 23dcbbf8..22cc927f 100644 --- a/test/shared/json_schema_validator_test.dart +++ b/test/shared/json_schema_validator_test.dart @@ -160,7 +160,7 @@ void main() { }); test('validates exclusiveMinimum', () { - final schema = JsonSchema.integer(exclusiveMinimum: 5); + final schema = JsonSchema.integer(exclusiveMinimum: 5.5); schema.validate(6); expect( () => schema.validate(5), @@ -169,19 +169,21 @@ void main() { }); test('validates exclusiveMaximum', () { - final schema = JsonSchema.integer(exclusiveMaximum: 10); + final schema = JsonSchema.integer(exclusiveMaximum: 10.5); schema.validate(9); + schema.validate(10); expect( - () => schema.validate(10), + () => schema.validate(11), throwsA(isA()), ); }); test('validates multipleOf', () { - final schema = JsonSchema.integer(multipleOf: 3); - schema.validate(9); + final schema = JsonSchema.integer(multipleOf: 1.5); + schema.validate(3); + schema.validate(6); expect( - () => schema.validate(10), + () => schema.validate(4), throwsA(isA()), ); }); diff --git a/test/shared/protocol_test.dart b/test/shared/protocol_test.dart index d65d86b5..3cd2cf9d 100644 --- a/test/shared/protocol_test.dart +++ b/test/shared/protocol_test.dart @@ -751,6 +751,43 @@ void main() { expect((await requestFuture).task.taskId, 'shape-task'); }); + test('handler extra accepts whole-number JSON task ttl values', () async { + await protocol.connect(transport); + + final observedTtl = Completer(); + protocol.setRequestHandler( + 'test/task-ttl', + (request, extra) async { + observedTtl.complete(extra.taskRequestedTtl); + return TestResult(value: 'ok'); + }, + (id, params, meta) => JsonRpcRequest( + id: id, + method: 'test/task-ttl', + params: params, + meta: meta, + ), + ); + + transport.receiveMessage( + const JsonRpcRequest( + id: 99, + method: 'test/task-ttl', + params: { + 'task': {'ttl': 1234.0}, + }, + ), + ); + + await waitForSentMessages(transport, 1); + expect( + await observedTtl.future.timeout(const Duration(seconds: 5)), + 1234, + ); + final response = transport.sentMessages.single as JsonRpcResponse; + expect(response.result, {'value': 'ok'}); + }); + test('progress notifications reset timeout for custom tokens', () async { await protocol.connect(transport); diff --git a/test/shared/uri_template_test.dart b/test/shared/uri_template_test.dart index 2589f47d..c2c0bffa 100644 --- a/test/shared/uri_template_test.dart +++ b/test/shared/uri_template_test.dart @@ -295,6 +295,49 @@ void main() { ); }); + test('throws on unmatched closing expression', () { + expect( + () => UriTemplateExpander('/path/}'), + throwsA( + isA() + .having((e) => e.message, 'message', contains('Unmatched')), + ), + ); + }); + + test('throws on invalid variable specs', () { + expect( + () => UriTemplateExpander('/path/{valid,}'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid variable name'), + ), + ), + ); + expect( + () => UriTemplateExpander('/path/{invalid-name}'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid variable name'), + ), + ), + ); + expect( + () => UriTemplateExpander('/path/{var:0}'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid prefix modifier'), + ), + ), + ); + }); + test('throws on template too long', () { final longTemplate = 'a' * (maxTemplateLength + 1); expect( diff --git a/test/types/logging_types_test.dart b/test/types/logging_types_test.dart index d1a02f01..d4200bae 100644 --- a/test/types/logging_types_test.dart +++ b/test/types/logging_types_test.dart @@ -48,6 +48,17 @@ void main() { expect(params.level, equals(level)); } }); + + test('rejects malformed logging levels', () { + expect( + () => SetLevelRequestParams.fromJson({'level': 'verbose'}), + throwsA(isA()), + ); + expect( + () => SetLevelRequestParams.fromJson({'level': 1}), + throwsA(isA()), + ); + }); }); group('JsonRpcSetLevelRequest', () { @@ -187,6 +198,23 @@ void main() { expect(restored.logger, equals(original.logger)); expect(restored.data, equals(original.data)); }); + + test('rejects malformed logging levels', () { + expect( + () => LoggingMessageNotificationParams.fromJson({ + 'level': 'verbose', + 'data': 'message', + }), + throwsA(isA()), + ); + expect( + () => LoggingMessageNotificationParams.fromJson({ + 'level': 1, + 'data': 'message', + }), + throwsA(isA()), + ); + }); }); group('JsonRpcLoggingMessageNotification', () { diff --git a/test/types/resources_test.dart b/test/types/resources_test.dart index 64a7df5d..3c137c0e 100644 --- a/test/types/resources_test.dart +++ b/test/types/resources_test.dart @@ -50,6 +50,31 @@ void main() { expect(json.containsKey('priority'), isFalse); expect(json.containsKey('lastModified'), isFalse); }); + + test('validates shared annotation fields', () { + expect( + () => ResourceAnnotations.fromJson({ + 'audience': ['model'], + }), + throwsA(isA()), + ); + expect( + () => ResourceAnnotations.fromJson({ + 'audience': 'user', + }), + throwsA(isA()), + ); + expect( + () => ResourceAnnotations.fromJson({ + 'lastModified': 1, + }), + throwsA(isA()), + ); + expect( + () => const ResourceAnnotations(audience: ['model']).toJson(), + throwsA(isA()), + ); + }); }); group('Resource', () { @@ -76,7 +101,7 @@ void main() { 'mimeType': 'text/plain', 'icon': { 'type': 'image', - 'data': 'base64data', + 'data': 'YmFzZTY0ZGF0YQ==', 'mimeType': 'image/png', }, 'annotations': { @@ -98,7 +123,7 @@ void main() { expect(resource.description, equals('A test file resource')); expect(resource.mimeType, equals('text/plain')); expect(resource.icon, isNotNull); - expect(resource.icon!.data, equals('base64data')); + expect(resource.icon!.data, equals('YmFzZTY0ZGF0YQ==')); expect(resource.icons, isNotNull); expect( resource.icons!.single.src, @@ -225,7 +250,7 @@ void main() { 'mimeType': 'application/json', 'icon': { 'type': 'image', - 'data': 'icondata', + 'data': 'aWNvbmRhdGE=', 'mimeType': 'image/svg+xml', }, 'annotations': { @@ -829,4 +854,106 @@ void main() { expect(notification.meta!['key'], equals('value')); }); }); + + group('Resource URI format validation', () { + test('rejects non-absolute resource URIs from wire JSON', () { + expect( + () => Resource.fromJson({'uri': 'relative/path', 'name': 'Relative'}), + throwsA(isA()), + ); + expect( + () => ResourceContents.fromJson({'uri': 'relative/path'}), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'relative/path', + 'name': 'Relative', + }), + throwsA(isA()), + ); + expect( + () => ReadResourceRequest.fromJson({'uri': 'relative/path'}), + throwsA(isA()), + ); + expect( + () => SubscribeRequest.fromJson({'uri': 'relative/path'}), + throwsA(isA()), + ); + expect( + () => UnsubscribeRequest.fromJson({'uri': 'relative/path'}), + throwsA(isA()), + ); + expect( + () => ResourceUpdatedNotification.fromJson({'uri': 'relative/path'}), + throwsA(isA()), + ); + }); + + test('rejects non-absolute resource URIs during serialization', () { + expect( + () => const Resource(uri: 'relative/path', name: 'Relative').toJson(), + throwsA(isA()), + ); + expect( + () => const TextResourceContents( + uri: 'relative/path', + text: 'Relative', + ).toJson(), + throwsA(isA()), + ); + expect( + () => const ResourceLink( + uri: 'relative/path', + name: 'Relative', + ).toJson(), + throwsA(isA()), + ); + expect( + () => const ReadResourceRequest(uri: 'relative/path').toJson(), + throwsA(isA()), + ); + expect( + () => const SubscribeRequest(uri: 'relative/path').toJson(), + throwsA(isA()), + ); + expect( + () => const UnsubscribeRequest(uri: 'relative/path').toJson(), + throwsA(isA()), + ); + expect( + () => const ResourceUpdatedNotification(uri: 'relative/path').toJson(), + throwsA(isA()), + ); + }); + + test('rejects malformed resource URI templates', () { + expect( + () => ResourceTemplate.fromJson({ + 'uriTemplate': 'file:///{path', + 'name': 'Bad Template', + }), + throwsA(isA()), + ); + expect( + () => const ResourceTemplate( + uriTemplate: 'file:///{path', + name: 'Bad Template', + ).toJson(), + throwsA(isA()), + ); + expect( + () => ResourceReference.fromJson({ + 'type': 'ref/resource', + 'uri': 'file:///{path', + }), + throwsA(isA()), + ); + expect( + () => const ResourceReference(uri: 'file:///{path').toJson(), + throwsA(isA()), + ); + }); + }); } diff --git a/test/types/sampling_test.dart b/test/types/sampling_test.dart index a0e808c5..f0adf2b5 100644 --- a/test/types/sampling_test.dart +++ b/test/types/sampling_test.dart @@ -107,76 +107,157 @@ void main() { }); test('fromJson parses correctly', () { - final json = {'type': 'text', 'text': 'Parsed text'}; + final json = { + 'type': 'text', + 'text': 'Parsed text', + 'annotations': { + 'audience': ['user'], + 'vendor': {'hint': true}, + }, + }; final content = SamplingContent.fromJson(json); expect(content, isA()); - expect((content as SamplingTextContent).text, equals('Parsed text')); + final text = content as SamplingTextContent; + expect(text.text, equals('Parsed text')); + expect(text.annotations?['vendor'], equals({'hint': true})); + }); + + test('validates shared annotation fields', () { + expect( + () => SamplingContent.fromJson({ + 'type': 'text', + 'text': 'Parsed text', + 'annotations': { + 'audience': ['model'], + }, + }), + throwsA(isA()), + ); + expect( + () => const SamplingTextContent( + text: 'Parsed text', + annotations: { + 'priority': 2, + }, + ).toJson(), + throwsA(isA()), + ); }); }); group('SamplingImageContent', () { test('constructs correctly', () { + const imageData = 'YmFzZTY0ZGF0YQ=='; const content = - SamplingImageContent(data: 'base64data', mimeType: 'image/png'); - expect(content.data, equals('base64data')); + SamplingImageContent(data: imageData, mimeType: 'image/png'); + expect(content.data, equals(imageData)); expect(content.mimeType, equals('image/png')); }); test('toJson serializes correctly', () { + const imageData = 'aW1nZGF0YQ=='; const content = - SamplingImageContent(data: 'imgdata', mimeType: 'image/jpeg'); + SamplingImageContent(data: imageData, mimeType: 'image/jpeg'); final json = content.toJson(); expect(json['type'], equals('image')); - expect(json['data'], equals('imgdata')); + expect(json['data'], equals(imageData)); expect(json['mimeType'], equals('image/jpeg')); }); test('fromJson parses correctly', () { + const imageData = 'ZW5jb2RlZA=='; final json = { 'type': 'image', - 'data': 'encoded', + 'data': imageData, 'mimeType': 'image/gif', + 'annotations': { + 'audience': ['assistant'], + }, }; final content = SamplingContent.fromJson(json); expect(content, isA()); final img = content as SamplingImageContent; - expect(img.data, equals('encoded')); + expect(img.data, equals(imageData)); expect(img.mimeType, equals('image/gif')); + expect(img.annotations?['audience'], equals(['assistant'])); + }); + + test('validates base64 byte data', () { + expect( + () => SamplingContent.fromJson({ + 'type': 'image', + 'data': 'not base64!', + 'mimeType': 'image/png', + }), + throwsA(isA()), + ); + expect( + () => const SamplingImageContent( + data: 'not base64!', + mimeType: 'image/png', + ).toJson(), + throwsA(isA()), + ); }); }); group('SamplingAudioContent', () { test('constructs correctly', () { + const audioData = 'YmFzZTY0YXVkaW8='; const content = SamplingAudioContent( - data: 'base64audio', + data: audioData, mimeType: 'audio/wav', ); - expect(content.data, equals('base64audio')); + expect(content.data, equals(audioData)); expect(content.mimeType, equals('audio/wav')); }); test('toJson serializes correctly', () { + const audioData = 'YXVkaW8tZGF0YQ=='; const content = SamplingAudioContent( - data: 'audio-data', + data: audioData, mimeType: 'audio/mpeg', ); final json = content.toJson(); expect(json['type'], equals('audio')); - expect(json['data'], equals('audio-data')); + expect(json['data'], equals(audioData)); expect(json['mimeType'], equals('audio/mpeg')); }); test('fromJson parses correctly', () { + const audioData = 'ZW5jb2RlZC1hdWRpbw=='; final json = { 'type': 'audio', - 'data': 'encoded-audio', + 'data': audioData, 'mimeType': 'audio/ogg', + 'annotations': { + 'priority': 0.2, + }, }; final content = SamplingContent.fromJson(json); expect(content, isA()); final audio = content as SamplingAudioContent; - expect(audio.data, equals('encoded-audio')); + expect(audio.data, equals(audioData)); expect(audio.mimeType, equals('audio/ogg')); + expect(audio.annotations?['priority'], equals(0.2)); + }); + + test('validates base64 byte data', () { + expect( + () => SamplingContent.fromJson({ + 'type': 'audio', + 'data': 'not base64!', + 'mimeType': 'audio/wav', + }), + throwsA(isA()), + ); + expect( + () => const SamplingAudioContent( + data: 'not base64!', + mimeType: 'audio/wav', + ).toJson(), + throwsA(isA()), + ); }); }); @@ -374,6 +455,23 @@ void main() { expect(msg.content, isA()); }); + test('validates role wire values', () { + expect( + () => SamplingMessage.fromJson({ + 'role': 'system', + 'content': {'type': 'text', 'text': 'Question'}, + }), + throwsA(isA()), + ); + expect( + () => SamplingMessage.fromJson({ + 'role': 1, + 'content': {'type': 'text', 'text': 'Question'}, + }), + throwsA(isA()), + ); + }); + test('supports array content with normalized contentBlocks', () { final msg = const SamplingMessage( role: SamplingMessageRole.assistant, @@ -494,6 +592,78 @@ void main() { expect(params.includeContext, equals(IncludeContext.allServers)); }); + test('validates enum wire fields', () { + final messages = [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ]; + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'includeContext': 'nearbyServers', + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'includeContext': 1, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'toolChoice': {'mode': 'sometimes'}, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'toolChoice': {'mode': 1}, + }), + throwsA(isA()), + ); + }); + + test('accepts whole-number JSON maxTokens values', () { + final messages = [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ]; + + final params = CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100.0, + }); + + expect(params.maxTokens, 100); + expect(params.toJson()['maxTokens'], 100); + + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100.5, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + }), + throwsA(isA()), + ); + }); + test('rejects non-finite temperature values', () { final messages = [ { @@ -620,6 +790,25 @@ void main() { expect(result.stopReason, equals('customReason')); }); + test('validates role wire values', () { + expect( + () => CreateMessageResult.fromJson({ + 'role': 'system', + 'content': {'type': 'text', 'text': 'Msg'}, + 'model': 'model-x', + }), + throwsA(isA()), + ); + expect( + () => CreateMessageResult.fromJson({ + 'role': 1, + 'content': {'type': 'text', 'text': 'Msg'}, + 'model': 'model-x', + }), + throwsA(isA()), + ); + }); + test('rejects non-JSON metadata objects', () { expect( () => CreateMessageResult.fromJson({ diff --git a/test/types_test.dart b/test/types_test.dart index 250258ee..c2053ffa 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -482,35 +482,236 @@ void main() { }); test('ImageContent serialization and deserialization', () { + const imageData = 'YmFzZTY0ZGF0YQ=='; final content = - const ImageContent(data: 'base64data', mimeType: 'image/png'); + const ImageContent(data: imageData, mimeType: 'image/png'); final json = content.toJson(); expect(json['type'], equals('image')); - expect(json['data'], equals('base64data')); + expect(json['data'], equals(imageData)); expect(json['mimeType'], equals('image/png')); final deserialized = ImageContent.fromJson(json); - expect(deserialized.data, equals('base64data')); + expect(deserialized.data, equals(imageData)); expect(deserialized.mimeType, equals('image/png')); }); - test('ImageContent supports optional theme', () { + test('ImageContent parses legacy theme without serializing it', () { final content = const ImageContent( - data: 'base64data', + data: 'YmFzZTY0ZGF0YQ==', mimeType: 'image/png', theme: 'dark', ); final json = content.toJson(); - expect(json['theme'], equals('dark')); + expect(json, isNot(contains('theme'))); - final deserialized = ImageContent.fromJson(json); + final deserialized = ImageContent.fromJson({ + ...json, + 'theme': 'dark', + }); expect(deserialized.theme, equals('dark')); }); + test('ImageContent validates base64 byte data', () { + expect( + () => ImageContent.fromJson({ + 'type': 'image', + 'data': 'not base64!', + 'mimeType': 'image/png', + }), + throwsA(isA()), + ); + expect( + () => ImageContent.fromJson({ + 'type': 'image', + 'data': 'a-b_', + 'mimeType': 'image/png', + }), + throwsA(isA()), + ); + expect( + () => const ImageContent( + data: 'not base64!', + mimeType: 'image/png', + ).toJson(), + throwsA(isA()), + ); + }); + + test('McpIcon parses stable wire fields', () { + final icon = McpIcon.fromJson({ + 'src': 'https://example.com/icon.png', + 'mimeType': 'image/png', + 'sizes': ['48x48', 'any'], + 'theme': 'dark', + }); + + expect(icon.src, equals('https://example.com/icon.png')); + expect(icon.mimeType, equals('image/png')); + expect(icon.sizes, equals(['48x48', 'any'])); + expect(icon.theme, equals(IconTheme.dark)); + expect(icon.toJson(), { + 'src': 'https://example.com/icon.png', + 'mimeType': 'image/png', + 'sizes': ['48x48', 'any'], + 'theme': 'dark', + }); + + final dataIcon = McpIcon.fromJson({ + 'src': 'data:image/png;base64,aWNvbg==', + }); + expect(dataIcon.src, equals('data:image/png;base64,aWNvbg==')); + }); + + test('McpIcon rejects malformed stable wire fields', () { + void expectInvalid( + Map json, + ) { + expect(() => McpIcon.fromJson(json), throwsA(isA())); + } + + expectInvalid({}); + expectInvalid({'src': 1}); + expectInvalid({'src': 'icon.png'}); + expectInvalid({'src': '://not-a-uri'}); + expectInvalid({'src': 'https://example.com/icon.png', 'mimeType': null}); + expectInvalid({'src': 'https://example.com/icon.png', 'mimeType': 1}); + expectInvalid({'src': 'https://example.com/icon.png', 'sizes': null}); + expectInvalid({'src': 'https://example.com/icon.png', 'sizes': '48x48'}); + expectInvalid({ + 'src': 'https://example.com/icon.png', + 'sizes': ['48x48', 1], + }); + expectInvalid({'src': 'https://example.com/icon.png', 'theme': null}); + expectInvalid({'src': 'https://example.com/icon.png', 'theme': 1}); + expectInvalid({'src': 'https://example.com/icon.png', 'theme': 'sepia'}); + }); + + test('McpIcon validates src URI during serialization', () { + expect( + () => const McpIcon(src: 'icon.png').toJson(), + throwsA(isA()), + ); + expect( + const McpIcon(src: 'data:image/png;base64,aWNvbg==').toJson()['src'], + equals('data:image/png;base64,aWNvbg=='), + ); + }); + + test('Implementation icon parsing rejects invalid themes', () { + expect( + () => Implementation.fromJson({ + 'name': 'test-client', + 'version': '1.0.0', + 'icons': [ + { + 'src': 'https://example.com/icon.png', + 'theme': 'sepia', + }, + ], + }), + throwsA(isA()), + ); + }); + + test('Implementation parses stable wire fields', () { + final implementation = Implementation.fromJson({ + 'name': 'test-client', + 'title': 'Test Client', + 'version': '1.0.0', + 'description': 'A test MCP client', + 'icons': [ + { + 'src': 'https://example.com/icon.png', + 'theme': 'light', + }, + ], + 'websiteUrl': 'https://example.com', + }); + + expect(implementation.name, equals('test-client')); + expect(implementation.title, equals('Test Client')); + expect(implementation.version, equals('1.0.0')); + expect(implementation.description, equals('A test MCP client')); + expect(implementation.icons!.single.theme, equals(IconTheme.light)); + expect(implementation.websiteUrl, equals('https://example.com')); + expect(implementation.toJson(), { + 'name': 'test-client', + 'title': 'Test Client', + 'version': '1.0.0', + 'description': 'A test MCP client', + 'icons': [ + { + 'src': 'https://example.com/icon.png', + 'theme': 'light', + }, + ], + 'websiteUrl': 'https://example.com', + }); + }); + + test('Implementation rejects malformed stable wire fields', () { + void expectInvalid(Map json) { + expect( + () => Implementation.fromJson(json), + throwsA(isA()), + ); + } + + expectInvalid({}); + expectInvalid({'name': 'test-client'}); + expectInvalid({'name': 1, 'version': '1.0.0'}); + expectInvalid({'name': 'test-client', 'version': 1}); + expectInvalid({'name': 'test-client', 'version': '1.0.0', 'title': null}); + expectInvalid({'name': 'test-client', 'version': '1.0.0', 'title': 1}); + expectInvalid({ + 'name': 'test-client', + 'version': '1.0.0', + 'description': null, + }); + expectInvalid({ + 'name': 'test-client', + 'version': '1.0.0', + 'description': 1, + }); + expectInvalid({'name': 'test-client', 'version': '1.0.0', 'icons': null}); + expectInvalid({'name': 'test-client', 'version': '1.0.0', 'icons': {}}); + expectInvalid({ + 'name': 'test-client', + 'version': '1.0.0', + 'icons': [null], + }); + expectInvalid({ + 'name': 'test-client', + 'version': '1.0.0', + 'websiteUrl': null, + }); + expectInvalid({ + 'name': 'test-client', + 'version': '1.0.0', + 'websiteUrl': 1, + }); + expectInvalid({ + 'name': 'test-client', + 'version': '1.0.0', + 'websiteUrl': 'example.com', + }); + }); + + test('Implementation validates website URL during serialization', () { + expect( + () => const Implementation( + name: 'test-client', + version: '1.0.0', + websiteUrl: 'example.com', + ).toJson(), + throwsA(isA()), + ); + }); + test('ImageContent supports annotations and meta', () { final content = const ImageContent( - data: 'base64data', + data: 'YmFzZTY0ZGF0YQ==', mimeType: 'image/png', annotations: Annotations( audience: [AnnotationAudience.user], @@ -530,21 +731,22 @@ void main() { }); test('AudioContent serialization and deserialization', () { + const audioData = 'YmFzZTY0ZGF0YQ=='; final content = - const AudioContent(data: 'base64data', mimeType: 'audio/wav'); + const AudioContent(data: audioData, mimeType: 'audio/wav'); final json = content.toJson(); expect(json['type'], equals('audio')); - expect(json['data'], equals('base64data')); + expect(json['data'], equals(audioData)); expect(json['mimeType'], equals('audio/wav')); final deserialized = AudioContent.fromJson(json); - expect(deserialized.data, equals('base64data')); + expect(deserialized.data, equals(audioData)); expect(deserialized.mimeType, equals('audio/wav')); }); test('AudioContent supports annotations and meta', () { final content = const AudioContent( - data: 'base64data', + data: 'YmFzZTY0ZGF0YQ==', mimeType: 'audio/wav', annotations: Annotations(priority: 0.3), meta: { @@ -561,6 +763,24 @@ void main() { expect(deserialized.meta?['traceId'], equals('audio-1')); }); + test('AudioContent validates base64 byte data', () { + expect( + () => AudioContent.fromJson({ + 'type': 'audio', + 'data': 'not base64!', + 'mimeType': 'audio/wav', + }), + throwsA(isA()), + ); + expect( + () => const AudioContent( + data: 'not base64!', + mimeType: 'audio/wav', + ).toJson(), + throwsA(isA()), + ); + }); + test('UnknownContent serialization and deserialization', () { final content = const UnknownContent(type: 'unknown'); final json = content.toJson(); @@ -579,6 +799,7 @@ void main() { annotations: { 'audience': ['assistant'], 'priority': 0.5, + 'vendor': {'hint': true}, }, ); @@ -592,12 +813,47 @@ void main() { expect(deserialized.name, equals('readme')); expect(deserialized.mimeType, equals('text/markdown')); expect(deserialized.annotations?['priority'], equals(0.5)); + expect(deserialized.annotations?['vendor'], equals({'hint': true})); expect( deserialized.parsedAnnotations?.audience, equals([AnnotationAudience.assistant]), ); }); + test('ResourceLink validates shared annotation fields', () { + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + 'annotations': { + 'audience': ['model'], + }, + }), + throwsA(isA()), + ); + expect( + () => const ResourceLink( + uri: 'file:///docs/readme.md', + name: 'readme', + annotations: { + 'priority': 2, + }, + ).toJson(), + throwsA(isA()), + ); + expect( + () => const ResourceLink( + uri: 'file:///docs/readme.md', + name: 'readme', + annotations: { + 'lastModified': 1, + }, + ).toJson(), + throwsA(isA()), + ); + }); + test('Content.fromJson handles resource_link content type', () { final json = { 'type': 'resource_link', @@ -610,6 +866,28 @@ void main() { expect((content as ResourceLink).uri, equals('file:///docs/spec.md')); }); + test('ResourceLink accepts whole-number JSON size values', () { + final link = ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/spec.md', + 'name': 'spec', + 'size': 123.0, + }); + + expect(link.size, 123); + expect(link.toJson()['size'], 123); + + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/spec.md', + 'name': 'spec', + 'size': 123.5, + }), + throwsA(isA()), + ); + }); + test('EmbeddedResource supports annotations and meta', () { final content = const EmbeddedResource( resource: TextResourceContents( @@ -714,21 +992,39 @@ void main() { }); test('BlobResourceContents serialization and deserialization', () { + const blobData = 'YmFzZTY0ZGF0YQ=='; final contents = const BlobResourceContents( uri: 'file://example.bin', - blob: 'base64data', + blob: blobData, mimeType: 'application/octet-stream', ); final json = contents.toJson(); expect(json['uri'], equals('file://example.bin')); - expect(json['blob'], equals('base64data')); + expect(json['blob'], equals(blobData)); expect(json['mimeType'], equals('application/octet-stream')); final deserialized = ResourceContents.fromJson(json) as BlobResourceContents; expect(deserialized.uri, equals('file://example.bin')); - expect(deserialized.blob, equals('base64data')); + expect(deserialized.blob, equals(blobData)); + }); + + test('BlobResourceContents validates base64 byte data', () { + expect( + () => ResourceContents.fromJson({ + 'uri': 'file://example.bin', + 'blob': 'not base64!', + }), + throwsA(isA()), + ); + expect( + () => const BlobResourceContents( + uri: 'file://example.bin', + blob: 'not base64!', + ).toJson(), + throwsA(isA()), + ); }); test('ResourceContents rejects non-JSON metadata and passthrough maps', () { @@ -808,6 +1104,23 @@ void main() { expect(deserialized.description, equals('Argument 1')); expect(deserialized.required, equals(true)); }); + + test('PromptMessage validates role wire values', () { + expect( + () => PromptMessage.fromJson({ + 'role': 'system', + 'content': {'type': 'text', 'text': 'Hello'}, + }), + throwsA(isA()), + ); + expect( + () => PromptMessage.fromJson({ + 'role': 1, + 'content': {'type': 'text', 'text': 'Hello'}, + }), + throwsA(isA()), + ); + }); }); group('CreateMessageResult Tests', () { test('CreateMessageResult serialization and deserialization', () { @@ -1066,16 +1379,19 @@ void main() { properties: {'name': JsonSchema.string(minLength: 1)}, required: const ['name'], ), + task: const TaskCreationParams(ttl: 3600), ); final json = params.toJson(); expect(json['message'], equals("Enter your name")); expect(json['requestedSchema']['type'], equals('object')); expect(json['requestedSchema']['properties']['name']['type'], 'string'); + expect(json['task'], {'ttl': 3600}); final restored = ElicitRequestParams.fromJson(json); expect(restored.message, equals("Enter your name")); expect(restored.requestedSchema!.toJson()['type'], equals('object')); + expect(restored.task?.ttl, 3600); }); test('JsonRpcElicitRequest serialization and deserialization', () { From 9ee1f994f692e663a6845f1f2c62a485e54078b0 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 08:39:56 -0400 Subject: [PATCH 02/42] Validate sampling wire fields --- CHANGELOG.md | 2 + lib/src/types/sampling.dart | 63 +++++++++---- lib/src/types/validation.dart | 26 ++++++ test/mcp_2025_11_25_test.dart | 43 +++++++++ test/mcp_2026_07_28_test.dart | 43 +++++++++ test/types/sampling_test.dart | 162 ++++++++++++++++++++++++++++++++++ 6 files changed, 322 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 607ddcc4..9fe7f867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,8 @@ allowing raw enum lookup failures. - Rejected malformed logging level, sampling `includeContext`, and sampling `toolChoice.mode` enum values with protocol parse errors. +- Rejected malformed sampling string, boolean, and string-list wire fields with + protocol parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index cd17fbcd..976980da 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -67,7 +67,9 @@ List _asSamplingContentBlocks( return item; } if (item is Map) { - return SamplingContent.fromJson(item.cast()); + return SamplingContent.fromJson( + readJsonObject(item, '$context items'), + ); } throw FormatException( 'Expected $context items to be SamplingContent or object, got ${item.runtimeType}', @@ -76,7 +78,7 @@ List _asSamplingContentBlocks( } if (value is Map) { - return [SamplingContent.fromJson(value.cast())]; + return [SamplingContent.fromJson(readJsonObject(value, context))]; } throw FormatException( @@ -199,7 +201,9 @@ class ModelHint { const ModelHint({this.name}); factory ModelHint.fromJson(Map json) { - return ModelHint(name: json['name'] as String?); + return ModelHint( + name: readOptionalString(json['name'], 'ModelHint.name'), + ); } Map toJson() => { @@ -238,9 +242,13 @@ class ModelPreferences { ); factory ModelPreferences.fromJson(Map json) { + final hints = json['hints']; + if (hints != null && hints is! List) { + throw const FormatException('ModelPreferences.hints must be a list'); + } return ModelPreferences( - hints: (json['hints'] as List?) - ?.map((h) => ModelHint.fromJson(_asJsonObject(h))) + hints: hints + ?.map((h) => ModelHint.fromJson(_asJsonObject(h))) .toList(), costPriority: readUnitDouble( json['costPriority'], @@ -283,7 +291,7 @@ sealed class SamplingContent { /// Creates specific subclass from JSON. factory SamplingContent.fromJson(Map json) { - final type = json['type'] as String?; + final type = readRequiredString(json['type'], 'SamplingContent.type'); return switch (type) { 'text' => SamplingTextContent.fromJson(json), 'image' => SamplingImageContent.fromJson(json), @@ -378,7 +386,7 @@ class SamplingTextContent extends SamplingContent { factory SamplingTextContent.fromJson(Map json) => SamplingTextContent( - text: json['text'] as String, + text: readRequiredString(json['text'], 'SamplingTextContent.text'), annotations: readOptionalAnnotationsObject( json['annotations'], 'SamplingTextContent.annotations', @@ -414,7 +422,10 @@ class SamplingImageContent extends SamplingContent { json['data'], 'SamplingImageContent.data', ), - mimeType: json['mimeType'] as String, + mimeType: readRequiredString( + json['mimeType'], + 'SamplingImageContent.mimeType', + ), annotations: readOptionalAnnotationsObject( json['annotations'], 'SamplingImageContent.annotations', @@ -450,7 +461,10 @@ class SamplingAudioContent extends SamplingContent { json['data'], 'SamplingAudioContent.data', ), - mimeType: json['mimeType'] as String, + mimeType: readRequiredString( + json['mimeType'], + 'SamplingAudioContent.mimeType', + ), annotations: readOptionalAnnotationsObject( json['annotations'], 'SamplingAudioContent.annotations', @@ -475,8 +489,8 @@ class SamplingToolUseContent extends SamplingContent { factory SamplingToolUseContent.fromJson(Map json) => SamplingToolUseContent( - id: json['id'] as String, - name: json['name'] as String, + id: readRequiredString(json['id'], 'SamplingToolUseContent.id'), + name: readRequiredString(json['name'], 'SamplingToolUseContent.name'), input: _asJsonObject(json['input'], 'SamplingToolUseContent.input'), meta: _asJsonObjectOrNull(json['_meta'], 'SamplingToolUseContent._meta'), @@ -512,7 +526,10 @@ class SamplingToolResultContent extends SamplingContent { factory SamplingToolResultContent.fromJson(Map json) { return SamplingToolResultContent( - toolUseId: json['toolUseId'] as String, + toolUseId: readRequiredString( + json['toolUseId'], + 'SamplingToolResultContent.toolUseId', + ), content: _parseToolResultWireContent(json['content']), structuredContent: json.containsKey('structuredContent') ? readJsonValue( @@ -521,7 +538,10 @@ class SamplingToolResultContent extends SamplingContent { ) : null, hasStructuredContent: json.containsKey('structuredContent'), - isError: json['isError'] as bool?, + isError: readOptionalBool( + json['isError'], + 'SamplingToolResultContent.isError', + ), meta: _asJsonObjectOrNull(json['_meta'], 'SamplingToolResultContent._meta'), ); @@ -686,7 +706,10 @@ class CreateMessageRequest { .map((m) => SamplingMessage.fromJson(_asJsonObject(m))) .toList(), task: task == null ? null : TaskCreation.fromJson(task), - systemPrompt: json['systemPrompt'] as String?, + systemPrompt: readOptionalString( + json['systemPrompt'], + 'CreateMessageRequest.systemPrompt', + ), includeContext: readOptionalEnumValue( json['includeContext'], IncludeContext.values, @@ -700,7 +723,10 @@ class CreateMessageRequest { json['maxTokens'], 'CreateMessageRequest.maxTokens', ), - stopSequences: (json['stopSequences'] as List?)?.cast(), + stopSequences: readOptionalStringList( + json['stopSequences'], + 'CreateMessageRequest.stopSequences', + ), metadata: _asJsonObjectOrNull( json['metadata'], 'CreateMessageRequest.metadata', @@ -759,7 +785,10 @@ class JsonRpcCreateMessageRequest extends JsonRpcRequest { ); factory JsonRpcCreateMessageRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcCreateMessageRequest.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for create message request"); } @@ -828,7 +857,7 @@ class CreateMessageResult implements BaseResultData { ); } return CreateMessageResult( - model: json['model'] as String, + model: readRequiredString(json['model'], 'CreateMessageResult.model'), stopReason: reason, role: SamplingMessageRole.values.byName( readRequiredRoleString(json['role'], 'CreateMessageResult.role'), diff --git a/lib/src/types/validation.dart b/lib/src/types/validation.dart index 021862ed..b45cf656 100644 --- a/lib/src/types/validation.dart +++ b/lib/src/types/validation.dart @@ -251,6 +251,32 @@ String? readOptionalString(Object? value, String field) { throw FormatException('$field must be a string'); } +bool readRequiredBool(Object? value, String field) { + if (value is bool) { + return value; + } + throw FormatException('$field must be a boolean'); +} + +bool? readOptionalBool(Object? value, String field) { + if (value == null) { + return null; + } + return readRequiredBool(value, field); +} + +List? readOptionalStringList(Object? value, String field) { + if (value == null) { + return null; + } + if (value is! List) { + throw FormatException('$field must be a list of strings'); + } + return [ + for (final item in value) readRequiredString(item, '$field items'), + ]; +} + int? readOptionalTtlMs(Object? value, String field) { final ttlMs = readOptionalInteger(value, field); if (ttlMs == null) { diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 05932c33..8e08f412 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1591,6 +1591,49 @@ void main() { }), throwsA(isA()), ); + expect( + () => ModelHint.fromJson({'name': 1}), + throwsA(isA()), + ); + expect( + () => SamplingContent.fromJson({ + 'type': 'text', + 'text': 1, + }), + throwsA(isA()), + ); + expect( + () => SamplingToolResultContent.fromJson({ + 'type': 'tool_result', + 'toolUseId': 'call-1', + 'content': [ + {'type': 'text', 'text': 'Hello'}, + ], + 'isError': 'false', + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 100, + 'stopSequences': ['STOP', 1], + }), + throwsA(isA()), + ); + expect( + () => CreateMessageResult.fromJson({ + 'role': 'assistant', + 'content': {'type': 'text', 'text': 'Hello'}, + 'model': 1, + }), + throwsA(isA()), + ); }); test('bare task containers strip task metadata', () { diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index ac056d59..f5e5bfbf 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -529,6 +529,49 @@ void main() { }), throwsA(isA()), ); + expect( + () => ModelHint.fromJson({'name': 1}), + throwsA(isA()), + ); + expect( + () => SamplingContent.fromJson({ + 'type': 'text', + 'text': 1, + }), + throwsA(isA()), + ); + expect( + () => SamplingToolResultContent.fromJson({ + 'type': 'tool_result', + 'toolUseId': 'call-1', + 'content': [ + {'type': 'text', 'text': 'Hello'}, + ], + 'isError': 'false', + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequest.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 16, + 'stopSequences': ['STOP', 1], + }), + throwsA(isA()), + ); + expect( + () => CreateMessageResult.fromJson({ + 'role': 'assistant', + 'content': {'type': 'text', 'text': 'Hello'}, + 'model': 1, + }), + throwsA(isA()), + ); }); test('rejects non-JSON content object values', () { diff --git a/test/types/sampling_test.dart b/test/types/sampling_test.dart index f0adf2b5..5d74e8a1 100644 --- a/test/types/sampling_test.dart +++ b/test/types/sampling_test.dart @@ -20,6 +20,13 @@ void main() { final hint = ModelHint.fromJson(json); expect(hint.name, equals('gemini-pro')); }); + + test('rejects malformed wire fields', () { + expect( + () => ModelHint.fromJson({'name': 1}), + throwsA(isA()), + ); + }); }); group('ModelPreferences', () { @@ -89,6 +96,21 @@ void main() { ); } }); + + test('rejects malformed hint lists', () { + expect( + () => ModelPreferences.fromJson({'hints': 'model-a'}), + throwsA(isA()), + ); + expect( + () => ModelPreferences.fromJson({ + 'hints': [ + {'name': 1}, + ], + }), + throwsA(isA()), + ); + }); }); group('SamplingContent', () { @@ -143,6 +165,23 @@ void main() { throwsA(isA()), ); }); + + test('rejects malformed text wire fields', () { + expect( + () => SamplingContent.fromJson({ + 'type': 1, + 'text': 'Parsed text', + }), + throwsA(isA()), + ); + expect( + () => SamplingContent.fromJson({ + 'type': 'text', + 'text': 1, + }), + throwsA(isA()), + ); + }); }); group('SamplingImageContent', () { @@ -199,6 +238,17 @@ void main() { throwsA(isA()), ); }); + + test('rejects malformed image wire fields', () { + expect( + () => SamplingContent.fromJson({ + 'type': 'image', + 'data': 'aW1nZGF0YQ==', + 'mimeType': 1, + }), + throwsA(isA()), + ); + }); }); group('SamplingAudioContent', () { @@ -259,6 +309,17 @@ void main() { throwsA(isA()), ); }); + + test('rejects malformed audio wire fields', () { + expect( + () => SamplingContent.fromJson({ + 'type': 'audio', + 'data': 'YXVkaW8tZGF0YQ==', + 'mimeType': 1, + }), + throwsA(isA()), + ); + }); }); group('SamplingToolUseContent', () { @@ -318,6 +379,27 @@ void main() { throwsA(isA()), ); }); + + test('rejects malformed tool use wire fields', () { + expect( + () => SamplingContent.fromJson({ + 'type': 'tool_use', + 'id': 1, + 'name': 'fetch', + 'input': {'url': 'http://test.com'}, + }), + throwsA(isA()), + ); + expect( + () => SamplingContent.fromJson({ + 'type': 'tool_use', + 'id': 'tu1', + 'name': 1, + 'input': {'url': 'http://test.com'}, + }), + throwsA(isA()), + ); + }); }); group('SamplingToolResultContent', () { @@ -421,6 +503,29 @@ void main() { expect(nullContent.hasStructuredContent, isTrue); expect(nullContent.structuredContent, isNull); }); + + test('rejects malformed tool result wire fields', () { + final content = [ + {'type': 'text', 'text': 'result data'}, + ]; + expect( + () => SamplingContent.fromJson({ + 'type': 'tool_result', + 'toolUseId': 1, + 'content': content, + }), + throwsA(isA()), + ); + expect( + () => SamplingContent.fromJson({ + 'type': 'tool_result', + 'toolUseId': 'tr1', + 'content': content, + 'isError': 'false', + }), + throwsA(isA()), + ); + }); }); }); @@ -633,6 +738,39 @@ void main() { ); }); + test('validates string wire fields', () { + final messages = [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ]; + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'systemPrompt': 1, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'stopSequences': 'STOP', + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'stopSequences': ['STOP', 1], + }), + throwsA(isA()), + ); + }); + test('accepts whole-number JSON maxTokens values', () { final messages = [ { @@ -809,6 +947,17 @@ void main() { ); }); + test('validates model wire field', () { + expect( + () => CreateMessageResult.fromJson({ + 'role': 'assistant', + 'content': {'type': 'text', 'text': 'Msg'}, + 'model': 1, + }), + throwsA(isA()), + ); + }); + test('rejects non-JSON metadata objects', () { expect( () => CreateMessageResult.fromJson({ @@ -880,6 +1029,19 @@ void main() { throwsA(isA()), ); }); + + test('fromJson rejects non-object params', () { + final json = { + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'sampling/createMessage', + 'params': 'bad', + }; + expect( + () => JsonRpcCreateMessageRequest.fromJson(json), + throwsA(isA()), + ); + }); }); group('IncludeContext', () { From 58071145af86a2468d732ea7a2086ae9f10d9ecb Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 08:50:39 -0400 Subject: [PATCH 03/42] Validate content resource wire fields --- CHANGELOG.md | 2 + lib/src/types/content.dart | 60 ++++++++---- lib/src/types/resources.dart | 138 ++++++++++++++++++++------ test/mcp_2025_11_25_test.dart | 43 +++++++++ test/mcp_2026_07_28_test.dart | 43 +++++++++ test/types/resources_test.dart | 171 +++++++++++++++++++++++++++++++++ test/types_test.dart | 83 ++++++++++++++++ 7 files changed, 492 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe7f867..ca937fbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,8 @@ `toolChoice.mode` enum values with protocol parse errors. - Rejected malformed sampling string, boolean, and string-list wire fields with protocol parse errors. +- Rejected malformed content and resource string/list wire fields with protocol + parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/content.dart b/lib/src/types/content.dart index fd364653..ebe9ebca 100644 --- a/lib/src/types/content.dart +++ b/lib/src/types/content.dart @@ -22,10 +22,7 @@ Map _asJsonObject( } String _readRequiredString(Object? value, String field) { - if (value is String) { - return value; - } - throw FormatException('$field must be a string'); + return readRequiredString(value, field); } bool _isAbsoluteUri(String value) { @@ -98,6 +95,26 @@ List? _readOptionalPresentStringList( ]; } +List? _readOptionalIconList( + Map json, + String key, + String field, +) { + if (!json.containsKey(key)) { + return null; + } + + final value = json[key]; + if (value is! List) { + throw FormatException('$field must be a list of objects'); + } + + return [ + for (var i = 0; i < value.length; i++) + McpIcon.fromJson(readJsonObject(value[i], '$field[$i]')), + ]; +} + /// Allowed audience values for content/resource annotations. enum AnnotationAudience { user, assistant } @@ -176,7 +193,10 @@ sealed class ResourceContents { json['uri'], 'ResourceContents.uri', ); - final mimeType = json['mimeType'] as String?; + final mimeType = readOptionalString( + json['mimeType'], + 'ResourceContents.mimeType', + ); final meta = _asJsonObjectOrNull( json['_meta'], 'ResourceContents._meta', @@ -198,7 +218,10 @@ sealed class ResourceContents { return TextResourceContents( uri: uri, mimeType: mimeType, - text: json['text'] as String, + text: readRequiredString( + json['text'], + 'TextResourceContents.text', + ), meta: meta, extra: passthrough, ); @@ -351,7 +374,7 @@ sealed class Content { }); factory Content.fromJson(Map json) { - final type = json['type'] as String?; + final type = readOptionalString(json['type'], 'Content.type'); return switch (type) { 'text' => TextContent.fromJson(json), 'image' => ImageContent.fromJson(json), @@ -432,7 +455,7 @@ class TextContent extends Content { factory TextContent.fromJson(Map json) { return TextContent( - text: json['text'] as String, + text: readRequiredString(json['text'], 'TextContent.text'), annotations: json['annotations'] == null ? null : Annotations.fromJson( @@ -475,8 +498,8 @@ class ImageContent extends Content { factory ImageContent.fromJson(Map json) { return ImageContent( data: readRequiredBase64String(json['data'], 'ImageContent.data'), - mimeType: json['mimeType'] as String, - theme: json['theme'] as String?, + mimeType: readRequiredString(json['mimeType'], 'ImageContent.mimeType'), + theme: readOptionalString(json['theme'], 'ImageContent.theme'), annotations: json['annotations'] == null ? null : Annotations.fromJson( @@ -510,7 +533,7 @@ class AudioContent extends Content { factory AudioContent.fromJson(Map json) { return AudioContent( data: readRequiredBase64String(json['data'], 'AudioContent.data'), - mimeType: json['mimeType'] as String, + mimeType: readRequiredString(json['mimeType'], 'AudioContent.mimeType'), annotations: json['annotations'] == null ? null : Annotations.fromJson( @@ -604,14 +627,15 @@ class ResourceLink extends Content { factory ResourceLink.fromJson(Map json) { return ResourceLink( uri: readRequiredAbsoluteUriString(json['uri'], 'ResourceLink.uri'), - name: json['name'] as String, - title: json['title'] as String?, - description: json['description'] as String?, - mimeType: json['mimeType'] as String?, + name: readRequiredString(json['name'], 'ResourceLink.name'), + title: readOptionalString(json['title'], 'ResourceLink.title'), + description: readOptionalString( + json['description'], + 'ResourceLink.description', + ), + mimeType: readOptionalString(json['mimeType'], 'ResourceLink.mimeType'), size: readOptionalInteger(json['size'], 'ResourceLink.size'), - icons: (json['icons'] as List?) - ?.map((icon) => McpIcon.fromJson(_asJsonObject(icon))) - .toList(), + icons: _readOptionalIconList(json, 'icons', 'ResourceLink.icons'), annotations: readOptionalAnnotationsObject( json['annotations'], 'ResourceLink.annotations', diff --git a/lib/src/types/resources.dart b/lib/src/types/resources.dart index aa226545..53860fe6 100644 --- a/lib/src/types/resources.dart +++ b/lib/src/types/resources.dart @@ -2,6 +2,26 @@ import '../types.dart'; import 'json_rpc.dart'; import 'validation.dart'; +List? _readOptionalIconList( + Map json, + String key, + String field, +) { + if (!json.containsKey(key)) { + return null; + } + + final value = json[key]; + if (value is! List) { + throw FormatException('$field must be a list of objects'); + } + + return [ + for (var i = 0; i < value.length; i++) + McpIcon.fromJson(readJsonObject(value[i], '$field[$i]')), + ]; +} + /// Additional properties describing a Resource to clients. class ResourceAnnotations { /// A human-readable title for the resource. @@ -31,7 +51,7 @@ class ResourceAnnotations { factory ResourceAnnotations.fromJson(Map json) { return ResourceAnnotations( - title: json['title'] as String?, + title: readOptionalString(json['title'], 'ResourceAnnotations.title'), audience: readOptionalAnnotationAudience( json['audience'], 'ResourceAnnotations.audience', @@ -111,16 +131,17 @@ class Resource { factory Resource.fromJson(Map json) { return Resource( uri: readRequiredAbsoluteUriString(json['uri'], 'Resource.uri'), - name: json['name'] as String, - title: json['title'] as String?, - description: json['description'] as String?, - mimeType: json['mimeType'] as String?, + name: readRequiredString(json['name'], 'Resource.name'), + title: readOptionalString(json['title'], 'Resource.title'), + description: readOptionalString( + json['description'], + 'Resource.description', + ), + mimeType: readOptionalString(json['mimeType'], 'Resource.mimeType'), icon: json['icon'] != null - ? ImageContent.fromJson(json['icon'] as Map) + ? ImageContent.fromJson(readJsonObject(json['icon'], 'Resource.icon')) : null, - icons: (json['icons'] as List?) - ?.map((e) => McpIcon.fromJson(e as Map)) - .toList(), + icons: _readOptionalIconList(json, 'icons', 'Resource.icons'), size: readOptionalInteger(json['size'], 'Resource.size'), annotations: json['annotations'] != null ? ResourceAnnotations.fromJson( @@ -200,16 +221,26 @@ class ResourceTemplate { json['uriTemplate'], 'ResourceTemplate.uriTemplate', ), - name: json['name'] as String, - title: json['title'] as String?, - description: json['description'] as String?, - mimeType: json['mimeType'] as String?, + name: readRequiredString(json['name'], 'ResourceTemplate.name'), + title: readOptionalString(json['title'], 'ResourceTemplate.title'), + description: readOptionalString( + json['description'], + 'ResourceTemplate.description', + ), + mimeType: readOptionalString( + json['mimeType'], + 'ResourceTemplate.mimeType', + ), icon: json['icon'] != null - ? ImageContent.fromJson(json['icon'] as Map) + ? ImageContent.fromJson( + readJsonObject(json['icon'], 'ResourceTemplate.icon'), + ) : null, - icons: (json['icons'] as List?) - ?.map((e) => McpIcon.fromJson(e as Map)) - .toList(), + icons: _readOptionalIconList( + json, + 'icons', + 'ResourceTemplate.icons', + ), annotations: json['annotations'] != null ? ResourceAnnotations.fromJson( readJsonObject( @@ -251,7 +282,10 @@ class ListResourcesRequest { /// Creates from JSON. factory ListResourcesRequest.fromJson(Map json) => - ListResourcesRequest(cursor: json['cursor'] as String?); + ListResourcesRequest( + cursor: + readOptionalString(json['cursor'], 'ListResourcesRequest.cursor'), + ); /// Converts to JSON. Map toJson() => {if (cursor != null) 'cursor': cursor}; @@ -272,7 +306,10 @@ class JsonRpcListResourcesRequest extends JsonRpcRequest { /// Creates from JSON. factory JsonRpcListResourcesRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcListResourcesRequest.params', + ); final meta = extractRequestMeta(json); return JsonRpcListResourcesRequest( id: parseRequestId(json['id']), @@ -324,9 +361,16 @@ class ListResourcesResult implements CacheableResultData { } return ListResourcesResult( resources: resources - .map((e) => Resource.fromJson(e as Map)) + .map( + (e) => Resource.fromJson( + readJsonObject(e, 'ListResourcesResult.resources items'), + ), + ) .toList(), - nextCursor: json['nextCursor'] as String?, + nextCursor: readOptionalString( + json['nextCursor'], + 'ListResourcesResult.nextCursor', + ), ttlMs: readOptionalTtlMs(json['ttlMs'], 'ListResourcesResult.ttlMs'), cacheScope: readOptionalCacheScope( json['cacheScope'], @@ -362,7 +406,12 @@ class ListResourceTemplatesRequest { factory ListResourceTemplatesRequest.fromJson( Map json, ) => - ListResourceTemplatesRequest(cursor: json['cursor'] as String?); + ListResourceTemplatesRequest( + cursor: readOptionalString( + json['cursor'], + 'ListResourceTemplatesRequest.cursor', + ), + ); Map toJson() => {if (cursor != null) 'cursor': cursor}; } @@ -382,7 +431,10 @@ class JsonRpcListResourceTemplatesRequest extends JsonRpcRequest { factory JsonRpcListResourceTemplatesRequest.fromJson( Map json, ) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcListResourceTemplatesRequest.params', + ); final meta = extractRequestMeta(json); return JsonRpcListResourceTemplatesRequest( id: parseRequestId(json['id']), @@ -434,9 +486,19 @@ class ListResourceTemplatesResult implements CacheableResultData { } return ListResourceTemplatesResult( resourceTemplates: resourceTemplates - .map((e) => ResourceTemplate.fromJson(e as Map)) + .map( + (e) => ResourceTemplate.fromJson( + readJsonObject( + e, + 'ListResourceTemplatesResult.resourceTemplates items', + ), + ), + ) .toList(), - nextCursor: json['nextCursor'] as String?, + nextCursor: readOptionalString( + json['nextCursor'], + 'ListResourceTemplatesResult.nextCursor', + ), ttlMs: readOptionalTtlMs( json['ttlMs'], 'ListResourceTemplatesResult.ttlMs', @@ -520,7 +582,10 @@ class JsonRpcReadResourceRequest extends JsonRpcRequest { }) : super(method: Method.resourcesRead, params: readParams.toJson()); factory JsonRpcReadResourceRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcReadResourceRequest.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for read resource request"); } @@ -567,7 +632,11 @@ class ReadResourceResult implements CacheableResultData { } return ReadResourceResult( contents: contents - .map((e) => ResourceContents.fromJson(e as Map)) + .map( + (e) => ResourceContents.fromJson( + readJsonObject(e, 'ReadResourceResult.contents items'), + ), + ) .toList(), ttlMs: readOptionalTtlMs(json['ttlMs'], 'ReadResourceResult.ttlMs'), cacheScope: readOptionalCacheScope( @@ -633,7 +702,10 @@ class JsonRpcSubscribeRequest extends JsonRpcRequest { }) : super(method: Method.resourcesSubscribe, params: subParams.toJson()); factory JsonRpcSubscribeRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcSubscribeRequest.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for subscribe request"); } @@ -679,7 +751,10 @@ class JsonRpcUnsubscribeRequest extends JsonRpcRequest { }) : super(method: Method.resourcesUnsubscribe, params: unsubParams.toJson()); factory JsonRpcUnsubscribeRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcUnsubscribeRequest.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for unsubscribe request"); } @@ -729,7 +804,10 @@ class JsonRpcResourceUpdatedNotification extends JsonRpcNotification { factory JsonRpcResourceUpdatedNotification.fromJson( Map json, ) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcResourceUpdatedNotification.params', + ); if (paramsMap == null) { throw const FormatException( "Missing params for resource updated notification", diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 8e08f412..e5b8bf9b 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1634,6 +1634,49 @@ void main() { }), throwsA(isA()), ); + expect( + () => Content.fromJson({ + 'type': 1, + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => TextContent.fromJson({ + 'type': 'text', + 'text': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceContents.fromJson({ + 'uri': 'file:///docs/readme.md', + 'text': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 1, + }), + throwsA(isA()), + ); + expect( + () => Resource.fromJson({ + 'uri': 'file:///docs/readme.md', + 'name': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceTemplate.fromJson({ + 'uriTemplate': 'file:///{path}', + 'name': 1, + }), + throwsA(isA()), + ); }); test('bare task containers strip task metadata', () { diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index f5e5bfbf..cf685523 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -572,6 +572,49 @@ void main() { }), throwsA(isA()), ); + expect( + () => Content.fromJson({ + 'type': 1, + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => TextContent.fromJson({ + 'type': 'text', + 'text': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceContents.fromJson({ + 'uri': 'file:///docs/readme.md', + 'text': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 1, + }), + throwsA(isA()), + ); + expect( + () => Resource.fromJson({ + 'uri': 'file:///docs/readme.md', + 'name': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceTemplate.fromJson({ + 'uriTemplate': 'file:///{path}', + 'name': 1, + }), + throwsA(isA()), + ); }); test('rejects non-JSON content object values', () { diff --git a/test/types/resources_test.dart b/test/types/resources_test.dart index 3c137c0e..aaf13335 100644 --- a/test/types/resources_test.dart +++ b/test/types/resources_test.dart @@ -75,6 +75,13 @@ void main() { throwsA(isA()), ); }); + + test('rejects malformed string wire fields', () { + expect( + () => ResourceAnnotations.fromJson({'title': 1}), + throwsA(isA()), + ); + }); }); group('Resource', () { @@ -177,6 +184,48 @@ void main() { ); }); + test('fromJson rejects malformed string and icon wire fields', () { + expect( + () => Resource.fromJson({ + 'uri': 'file:///test.txt', + 'name': 1, + }), + throwsA(isA()), + ); + expect( + () => Resource.fromJson({ + 'uri': 'file:///test.txt', + 'name': 'Test File', + 'title': 1, + }), + throwsA(isA()), + ); + expect( + () => Resource.fromJson({ + 'uri': 'file:///test.txt', + 'name': 'Test File', + 'icon': 'bad', + }), + throwsA(isA()), + ); + expect( + () => Resource.fromJson({ + 'uri': 'file:///test.txt', + 'name': 'Test File', + 'icons': 'bad', + }), + throwsA(isA()), + ); + expect( + () => Resource.fromJson({ + 'uri': 'file:///test.txt', + 'name': 'Test File', + 'icons': ['bad'], + }), + throwsA(isA()), + ); + }); + test('toJson serializes correctly with all fields', () { const resource = Resource( uri: 'file:///example.txt', @@ -340,6 +389,40 @@ void main() { expect(json['_meta'], isNotNull); expect(json['_meta']['ui']['prefersBorder'], isFalse); }); + + test('fromJson rejects malformed string and icon wire fields', () { + expect( + () => ResourceTemplate.fromJson({ + 'uriTemplate': 'file:///{path}', + 'name': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceTemplate.fromJson({ + 'uriTemplate': 'file:///{path}', + 'name': 'File Template', + 'description': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceTemplate.fromJson({ + 'uriTemplate': 'file:///{path}', + 'name': 'File Template', + 'icon': 'bad', + }), + throwsA(isA()), + ); + expect( + () => ResourceTemplate.fromJson({ + 'uriTemplate': 'file:///{path}', + 'name': 'File Template', + 'icons': ['bad'], + }), + throwsA(isA()), + ); + }); }); group('ListResourcesRequest', () { @@ -366,6 +449,13 @@ void main() { final json = request.toJson(); expect(json.containsKey('cursor'), isFalse); }); + + test('fromJson rejects malformed cursor', () { + expect( + () => ListResourcesRequest.fromJson({'cursor': 1}), + throwsA(isA()), + ); + }); }); group('JsonRpcListResourcesRequest', () { @@ -406,6 +496,17 @@ void main() { expect(request.id, equals(4)); expect(request.listParams.cursor, isNull); }); + + test('fromJson rejects non-object params', () { + expect( + () => JsonRpcListResourcesRequest.fromJson({ + 'id': 5, + 'method': 'resources/list', + 'params': 'bad', + }), + throwsA(isA()), + ); + }); }); group('ListResourcesResult', () { @@ -443,6 +544,22 @@ void main() { expect(result.meta!['customKey'], equals('customValue')); }); + test('fromJson rejects malformed resource items and cursor', () { + expect( + () => ListResourcesResult.fromJson({ + 'resources': ['bad'], + }), + throwsA(isA()), + ); + expect( + () => ListResourcesResult.fromJson({ + 'resources': [], + 'nextCursor': 1, + }), + throwsA(isA()), + ); + }); + test('toJson serializes correctly', () { const result = ListResourcesResult( resources: [ @@ -470,6 +587,13 @@ void main() { final json = request.toJson(); expect(json['cursor'], equals('next_tmpl')); }); + + test('fromJson rejects malformed cursor', () { + expect( + () => ListResourceTemplatesRequest.fromJson({'cursor': 1}), + throwsA(isA()), + ); + }); }); group('JsonRpcListResourceTemplatesRequest', () { @@ -489,6 +613,17 @@ void main() { expect(request.id, equals(11)); expect(request.listParams.cursor, equals('tmpl_page')); }); + + test('fromJson rejects non-object params', () { + expect( + () => JsonRpcListResourceTemplatesRequest.fromJson({ + 'id': 12, + 'method': 'resources/templates/list', + 'params': 'bad', + }), + throwsA(isA()), + ); + }); }); group('ListResourceTemplatesResult', () { @@ -512,6 +647,22 @@ void main() { expect(result.resourceTemplates, isEmpty); }); + test('fromJson rejects malformed template items and cursor', () { + expect( + () => ListResourceTemplatesResult.fromJson({ + 'resourceTemplates': ['bad'], + }), + throwsA(isA()), + ); + expect( + () => ListResourceTemplatesResult.fromJson({ + 'resourceTemplates': [], + 'nextCursor': 1, + }), + throwsA(isA()), + ); + }); + test('toJson serializes correctly', () { const result = ListResourceTemplatesResult( resourceTemplates: [ @@ -667,6 +818,26 @@ void main() { expect(roundTripped['payload']['kind'], equals('custom')); expect(roundTripped['_meta']['ui']['prefersBorder'], isTrue); }); + + test('fromJson rejects malformed content items', () { + expect( + () => ReadResourceResult.fromJson({ + 'contents': ['bad'], + }), + throwsA(isA()), + ); + expect( + () => ReadResourceResult.fromJson({ + 'contents': [ + { + 'uri': 'file:///content.txt', + 'text': 1, + }, + ], + }), + throwsA(isA()), + ); + }); }); group('JsonRpcResourceListChangedNotification', () { diff --git a/test/types_test.dart b/test/types_test.dart index c2053ffa..6d4903c0 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -538,6 +538,89 @@ void main() { ); }); + test('content blocks reject malformed wire fields', () { + expect( + () => Content.fromJson({ + 'type': 1, + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => TextContent.fromJson({ + 'type': 'text', + 'text': 1, + }), + throwsA(isA()), + ); + expect( + () => ImageContent.fromJson({ + 'type': 'image', + 'data': 'YmFzZTY0ZGF0YQ==', + 'mimeType': 1, + }), + throwsA(isA()), + ); + expect( + () => ImageContent.fromJson({ + 'type': 'image', + 'data': 'YmFzZTY0ZGF0YQ==', + 'mimeType': 'image/png', + 'theme': 1, + }), + throwsA(isA()), + ); + expect( + () => AudioContent.fromJson({ + 'type': 'audio', + 'data': 'YmFzZTY0ZGF0YQ==', + 'mimeType': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceContents.fromJson({ + 'uri': 'file:///docs/readme.md', + 'mimeType': 1, + 'text': 'README body', + }), + throwsA(isA()), + ); + expect( + () => ResourceContents.fromJson({ + 'uri': 'file:///docs/readme.md', + 'text': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + 'icons': 'bad', + }), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + 'icons': ['bad'], + }), + throwsA(isA()), + ); + }); + test('McpIcon parses stable wire fields', () { final icon = McpIcon.fromJson({ 'src': 'https://example.com/icon.png', From 883532aa54f4cf592ce856fd5aa6b594dd126ed9 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 09:02:36 -0400 Subject: [PATCH 04/42] Validate initialization wire fields --- CHANGELOG.md | 2 + lib/src/types/initialization.dart | 259 ++++++++++++++++++++---------- test/mcp_2025_11_25_test.dart | 40 +++++ test/mcp_2026_07_28_test.dart | 37 +++++ test/types_test.dart | 119 ++++++++++++++ 5 files changed, 374 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca937fbc..252ff2a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,8 @@ protocol parse errors. - Rejected malformed content and resource string/list wire fields with protocol parse errors. +- Rejected malformed initialization and capability wire fields with protocol + parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/initialization.dart b/lib/src/types/initialization.dart index e743be17..4cde02af 100644 --- a/lib/src/types/initialization.dart +++ b/lib/src/types/initialization.dart @@ -2,27 +2,14 @@ import 'content.dart'; import 'json_rpc.dart'; import 'validation.dart'; -Map? _asJsonObject(dynamic value) { +Map? _asJsonObject(Object? value, String field) { if (value == null) { return null; } - if (value is Map) { - return value; - } - if (value is Map) { - return value.cast(); - } if (value is bool) { return value ? {} : null; } - throw FormatException('Expected object capability, got ${value.runtimeType}'); -} - -String _readRequiredString(Object? value, String field) { - if (value is String) { - return value; - } - throw FormatException('$field must be a string'); + return readJsonObject(value, field); } String? _readOptionalPresentString( @@ -33,7 +20,7 @@ String? _readOptionalPresentString( if (!json.containsKey(key)) { return null; } - return _readRequiredString(json[key], field); + return readRequiredString(json[key], field); } bool _isAbsoluteUri(String value) { @@ -85,16 +72,7 @@ Map? _asStrictJsonObject(Object? value, String field) { if (value == null) { return null; } - if (value is Map) { - return value; - } - if (value is Map) { - if (value.keys.any((key) => key is! String)) { - throw FormatException('$field must be an object with string keys'); - } - return value.cast(); - } - throw FormatException('$field must be an object'); + return readJsonObject(value, field); } Map? _asJsonObjectMap(Object? value, String field) { @@ -149,17 +127,15 @@ Map>? _serializeExtensionMap( ); } -bool? _capabilityDeclared(dynamic value) { +bool? _capabilityDeclared(Object? value, String field) { if (value == null) { return null; } if (value is bool) { return value; } - if (value is Map) { - return true; - } - throw FormatException('Expected capability marker, got ${value.runtimeType}'); + readJsonObject(value, field); + return true; } Map? _serializeCapabilityObject(bool? declared) { @@ -213,13 +189,13 @@ class Implementation { factory Implementation.fromJson(Map json) { return Implementation( - name: _readRequiredString(json['name'], 'Implementation.name'), + name: readRequiredString(json['name'], 'Implementation.name'), title: _readOptionalPresentString( json, 'title', 'Implementation.title', ), - version: _readRequiredString(json['version'], 'Implementation.version'), + version: readRequiredString(json['version'], 'Implementation.version'), description: _readOptionalPresentString( json, 'description', @@ -262,7 +238,10 @@ class ClientCapabilitiesRoots { factory ClientCapabilitiesRoots.fromJson(Map json) { return ClientCapabilitiesRoots( - listChanged: json['listChanged'] as bool?, + listChanged: readOptionalBool( + json['listChanged'], + 'ClientCapabilitiesRoots.listChanged', + ), ); } @@ -281,7 +260,10 @@ class ClientElicitationForm { factory ClientElicitationForm.fromJson(Map json) { return ClientElicitationForm( - applyDefaults: json['applyDefaults'] as bool?, + applyDefaults: readOptionalBool( + json['applyDefaults'], + 'ClientElicitationForm.applyDefaults', + ), ); } @@ -343,8 +325,8 @@ class ClientElicitation { return const ClientElicitation.formOnly(); } - final formMap = (json['form'] as Map?)?.cast(); - final urlMap = (json['url'] as Map?)?.cast(); + final formMap = _asJsonObject(json['form'], 'ClientElicitation.form'); + final urlMap = _asJsonObject(json['url'], 'ClientElicitation.url'); return ClientElicitation( form: formMap == null ? null : ClientElicitationForm.fromJson(formMap), @@ -373,8 +355,16 @@ class ClientCapabilitiesSampling { factory ClientCapabilitiesSampling.fromJson(Map json) { return ClientCapabilitiesSampling( - context: _capabilityDeclared(json['context']) ?? false, - tools: _capabilityDeclared(json['tools']) ?? false, + context: _capabilityDeclared( + json['context'], + 'ClientCapabilitiesSampling.context', + ) ?? + false, + tools: _capabilityDeclared( + json['tools'], + 'ClientCapabilitiesSampling.tools', + ) ?? + false, ); } @@ -405,7 +395,10 @@ class ClientCapabilitiesTasksElicitation { factory ClientCapabilitiesTasksElicitation.fromJson( Map json, ) { - final createMap = _asJsonObject(json['create']); + final createMap = _asJsonObject( + json['create'], + 'ClientCapabilitiesTasksElicitation.create', + ); return ClientCapabilitiesTasksElicitation( create: createMap != null ? ClientCapabilitiesTasksElicitationCreate.fromJson(createMap) @@ -437,7 +430,10 @@ class ClientCapabilitiesTasksSampling { const ClientCapabilitiesTasksSampling({this.createMessage}); factory ClientCapabilitiesTasksSampling.fromJson(Map json) { - final createMessageMap = _asJsonObject(json['createMessage']); + final createMessageMap = _asJsonObject( + json['createMessage'], + 'ClientCapabilitiesTasksSampling.createMessage', + ); return ClientCapabilitiesTasksSampling( createMessage: createMessageMap != null ? ClientCapabilitiesTasksSamplingCreateMessage.fromJson( @@ -467,8 +463,14 @@ class ClientCapabilitiesTasksRequests { }); factory ClientCapabilitiesTasksRequests.fromJson(Map json) { - final elicitationMap = _asJsonObject(json['elicitation']); - final samplingMap = _asJsonObject(json['sampling']); + final elicitationMap = _asJsonObject( + json['elicitation'], + 'ClientCapabilitiesTasksRequests.elicitation', + ); + final samplingMap = _asJsonObject( + json['sampling'], + 'ClientCapabilitiesTasksRequests.sampling', + ); return ClientCapabilitiesTasksRequests( elicitation: elicitationMap != null @@ -504,10 +506,19 @@ class ClientCapabilitiesTasks { }); factory ClientCapabilitiesTasks.fromJson(Map json) { - final requestsMap = _asJsonObject(json['requests']); + final requestsMap = _asJsonObject( + json['requests'], + 'ClientCapabilitiesTasks.requests', + ); return ClientCapabilitiesTasks( - cancel: _capabilityDeclared(json['cancel']), - list: _capabilityDeclared(json['list']), + cancel: _capabilityDeclared( + json['cancel'], + 'ClientCapabilitiesTasks.cancel', + ), + list: _capabilityDeclared( + json['list'], + 'ClientCapabilitiesTasks.list', + ), requests: requestsMap == null ? null : ClientCapabilitiesTasksRequests.fromJson(requestsMap), @@ -562,10 +573,16 @@ class ClientCapabilities { }); factory ClientCapabilities.fromJson(Map json) { - final rootsMap = _asJsonObject(json['roots']); - final elicitationMap = _asJsonObject(json['elicitation']); - final tasksMap = _asJsonObject(json['tasks']); - final samplingMap = _asJsonObject(json['sampling']); + final rootsMap = _asJsonObject(json['roots'], 'ClientCapabilities.roots'); + final elicitationMap = _asJsonObject( + json['elicitation'], + 'ClientCapabilities.elicitation', + ); + final tasksMap = _asJsonObject(json['tasks'], 'ClientCapabilities.tasks'); + final samplingMap = _asJsonObject( + json['sampling'], + 'ClientCapabilities.sampling', + ); final extensionsMap = _asExtensionMap( json['extensions'], 'ClientCapabilities.extensions', @@ -631,12 +648,18 @@ class InitializeRequest { factory InitializeRequest.fromJson(Map json) => InitializeRequest( - protocolVersion: json['protocolVersion'] as String, + protocolVersion: readRequiredString( + json['protocolVersion'], + 'InitializeRequest.protocolVersion', + ), capabilities: ClientCapabilities.fromJson( - json['capabilities'] as Map, + readJsonObject( + json['capabilities'], + 'InitializeRequest.capabilities', + ), ), clientInfo: Implementation.fromJson( - json['clientInfo'] as Map, + readJsonObject(json['clientInfo'], 'InitializeRequest.clientInfo'), ), ); @@ -659,7 +682,10 @@ class JsonRpcInitializeRequest extends JsonRpcRequest { }) : super(method: Method.initialize, params: initParams.toJson()); factory JsonRpcInitializeRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcInitializeRequest.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for initialize request"); } @@ -771,8 +797,14 @@ class ServerCapabilitiesElicitation { url = const ServerElicitationUrl(); factory ServerCapabilitiesElicitation.fromJson(Map json) { - final formMap = _asJsonObject(json['form']); - final urlMap = _asJsonObject(json['url']); + final formMap = _asJsonObject( + json['form'], + 'ServerCapabilitiesElicitation.form', + ); + final urlMap = _asJsonObject( + json['url'], + 'ServerCapabilitiesElicitation.url', + ); return ServerCapabilitiesElicitation( form: formMap == null ? null : ServerElicitationForm.fromJson(formMap), @@ -797,7 +829,10 @@ class ServerCapabilitiesPrompts { factory ServerCapabilitiesPrompts.fromJson(Map json) { return ServerCapabilitiesPrompts( - listChanged: json['listChanged'] as bool?, + listChanged: readOptionalBool( + json['listChanged'], + 'ServerCapabilitiesPrompts.listChanged', + ), ); } @@ -821,8 +856,14 @@ class ServerCapabilitiesResources { factory ServerCapabilitiesResources.fromJson(Map json) { return ServerCapabilitiesResources( - subscribe: json['subscribe'] as bool?, - listChanged: json['listChanged'] as bool?, + subscribe: readOptionalBool( + json['subscribe'], + 'ServerCapabilitiesResources.subscribe', + ), + listChanged: readOptionalBool( + json['listChanged'], + 'ServerCapabilitiesResources.listChanged', + ), ); } @@ -843,7 +884,10 @@ class ServerCapabilitiesTools { factory ServerCapabilitiesTools.fromJson(Map json) { return ServerCapabilitiesTools( - listChanged: json['listChanged'] as bool?, + listChanged: readOptionalBool( + json['listChanged'], + 'ServerCapabilitiesTools.listChanged', + ), ); } @@ -869,7 +913,10 @@ class ServerCapabilitiesCompletions { factory ServerCapabilitiesCompletions.fromJson(Map json) { return ServerCapabilitiesCompletions( - listChanged: json['listChanged'] as bool?, + listChanged: readOptionalBool( + json['listChanged'], + 'ServerCapabilitiesCompletions.listChanged', + ), ); } @@ -893,7 +940,10 @@ class ServerCapabilitiesTasksTools { const ServerCapabilitiesTasksTools({this.call}); factory ServerCapabilitiesTasksTools.fromJson(Map json) { - final callMap = _asJsonObject(json['call']); + final callMap = _asJsonObject( + json['call'], + 'ServerCapabilitiesTasksTools.call', + ); return ServerCapabilitiesTasksTools( call: callMap == null ? null @@ -912,7 +962,10 @@ class ServerCapabilitiesTasksRequests { const ServerCapabilitiesTasksRequests({this.tools}); factory ServerCapabilitiesTasksRequests.fromJson(Map json) { - final toolsMap = _asJsonObject(json['tools']); + final toolsMap = _asJsonObject( + json['tools'], + 'ServerCapabilitiesTasksRequests.tools', + ); return ServerCapabilitiesTasksRequests( tools: toolsMap == null ? null @@ -949,14 +1002,26 @@ class ServerCapabilitiesTasks { }); factory ServerCapabilitiesTasks.fromJson(Map json) { - final requestsMap = _asJsonObject(json['requests']); + final requestsMap = _asJsonObject( + json['requests'], + 'ServerCapabilitiesTasks.requests', + ); return ServerCapabilitiesTasks( - list: _capabilityDeclared(json['list']), - cancel: _capabilityDeclared(json['cancel']), + list: _capabilityDeclared( + json['list'], + 'ServerCapabilitiesTasks.list', + ), + cancel: _capabilityDeclared( + json['cancel'], + 'ServerCapabilitiesTasks.cancel', + ), requests: requestsMap == null ? null : ServerCapabilitiesTasksRequests.fromJson(requestsMap), - listChanged: json['listChanged'] as bool?, + listChanged: readOptionalBool( + json['listChanged'], + 'ServerCapabilitiesTasks.listChanged', + ), ); } @@ -1023,12 +1088,21 @@ class ServerCapabilities { }); factory ServerCapabilities.fromJson(Map json) { - final pMap = _asJsonObject(json['prompts']); - final rMap = _asJsonObject(json['resources']); - final cMap = _asJsonObject(json['completions']); - final tMap = _asJsonObject(json['tools']); - final tasksMap = _asJsonObject(json['tasks']); - final elicitationMap = _asJsonObject(json['elicitation']); + final pMap = _asJsonObject(json['prompts'], 'ServerCapabilities.prompts'); + final rMap = _asJsonObject( + json['resources'], + 'ServerCapabilities.resources', + ); + final cMap = _asJsonObject( + json['completions'], + 'ServerCapabilities.completions', + ); + final tMap = _asJsonObject(json['tools'], 'ServerCapabilities.tools'); + final tasksMap = _asJsonObject(json['tasks'], 'ServerCapabilities.tasks'); + final elicitationMap = _asJsonObject( + json['elicitation'], + 'ServerCapabilities.elicitation', + ); final extensionsMap = _asExtensionMap( json['extensions'], 'ServerCapabilities.extensions', @@ -1039,7 +1113,10 @@ class ServerCapabilities { json['experimental'], 'ServerCapabilities.experimental', ), - logging: json['logging'] as Map?, + logging: readOptionalJsonObject( + json['logging'], + 'ServerCapabilities.logging', + ), prompts: pMap == null ? null : ServerCapabilitiesPrompts.fromJson(pMap), resources: rMap == null ? null : ServerCapabilitiesResources.fromJson(rMap), @@ -1061,7 +1138,8 @@ class ServerCapabilities { experimental, 'ServerCapabilities.experimental', ), - if (logging != null) 'logging': logging, + if (logging != null) + 'logging': readJsonObject(logging, 'ServerCapabilities.logging'), if (prompts != null) 'prompts': prompts!.toJson(), if (resources != null) 'resources': resources!.toJson(), if (tools != null) 'tools': tools!.toJson(), @@ -1109,14 +1187,23 @@ class InitializeResult implements BaseResultData { final meta = readOptionalJsonObject(json['_meta'], 'InitializeResult._meta'); return InitializeResult( - protocolVersion: json['protocolVersion'] as String, + protocolVersion: readRequiredString( + json['protocolVersion'], + 'InitializeResult.protocolVersion', + ), capabilities: ServerCapabilities.fromJson( - json['capabilities'] as Map, + readJsonObject( + json['capabilities'], + 'InitializeResult.capabilities', + ), ), serverInfo: Implementation.fromJson( - json['serverInfo'] as Map, + readJsonObject(json['serverInfo'], 'InitializeResult.serverInfo'), + ), + instructions: readOptionalString( + json['instructions'], + 'InitializeResult.instructions', ), - instructions: json['instructions'] as String?, meta: meta, ); } @@ -1181,14 +1268,20 @@ class DiscoverResult implements BaseResultData { } return DiscoverResult( - supportedVersions: supportedVersions.cast(), + supportedVersions: [ + for (final version in supportedVersions) + readRequiredString(version, 'DiscoverResult.supportedVersions items'), + ], capabilities: ServerCapabilities.fromJson( - json['capabilities'] as Map, + readJsonObject(json['capabilities'], 'DiscoverResult.capabilities'), ), serverInfo: Implementation.fromJson( - json['serverInfo'] as Map, + readJsonObject(json['serverInfo'], 'DiscoverResult.serverInfo'), + ), + instructions: readOptionalString( + json['instructions'], + 'DiscoverResult.instructions', ), - instructions: json['instructions'] as String?, meta: readOptionalJsonObject(json['_meta'], 'DiscoverResult._meta'), ); } diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index e5b8bf9b..8e83af2c 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1465,6 +1465,46 @@ void main() { ); }); + test('initialization and capability wire fields reject bad shapes', () { + final initializeRequest = { + 'protocolVersion': latestProtocolVersion, + 'capabilities': {}, + 'clientInfo': {'name': 'client', 'version': '1.0.0'}, + }; + final initializeResult = { + 'protocolVersion': latestProtocolVersion, + 'capabilities': {}, + 'serverInfo': {'name': 'server', 'version': '1.0.0'}, + }; + + for (final parse in [ + () => InitializeRequest.fromJson({ + ...initializeRequest, + 'protocolVersion': 1, + }), + () => InitializeRequest.fromJson({ + ...initializeRequest, + 'capabilities': 'bad', + }), + () => InitializeRequest.fromJson({ + ...initializeRequest, + 'clientInfo': 'bad', + }), + () => InitializeResult.fromJson({ + ...initializeResult, + 'capabilities': 'bad', + }), + () => InitializeResult.fromJson({ + ...initializeResult, + 'instructions': 1, + }), + () => ClientCapabilitiesRoots.fromJson({'listChanged': 'true'}), + () => ServerCapabilitiesResources.fromJson({'subscribe': 'true'}), + ]) { + expect(parse, throwsA(isA())); + } + }); + test('runtime value constraints are enforced without asserts', () { expect( () => Annotations(priority: 2).toJson(), diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index cf685523..3e000fb7 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -711,6 +711,43 @@ void main() { ); }); + test('server/discover and capability fields reject malformed wire shapes', + () { + final result = { + 'resultType': resultTypeComplete, + 'supportedVersions': [draftProtocolVersion2026_07_28], + 'capabilities': {}, + 'serverInfo': {'name': 'server', 'version': '1.0.0'}, + }; + + for (final parse in [ + () => DiscoverResult.fromJson({ + ...result, + 'supportedVersions': [draftProtocolVersion2026_07_28, 1], + }), + () => DiscoverResult.fromJson({ + ...result, + 'capabilities': 'bad', + }), + () => DiscoverResult.fromJson({ + ...result, + 'serverInfo': 'bad', + }), + () => DiscoverResult.fromJson({ + ...result, + 'instructions': 1, + }), + () => ClientCapabilitiesSampling.fromJson({ + 'tools': {'bad': Object()}, + }), + () => ServerCapabilities.fromJson({ + 'logging': {'bad': Object()}, + }), + ]) { + expect(parse, throwsFormatException); + } + }); + test('requires complete resultType on server/discover results', () { final validResult = const DiscoverResult( supportedVersions: [draftProtocolVersion2026_07_28], diff --git a/test/types_test.dart b/test/types_test.dart index 6d4903c0..e8eefc56 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -31,6 +31,37 @@ void main() { ); }); + test('initialize request rejects malformed wire fields', () { + final params = { + 'protocolVersion': latestProtocolVersion, + 'capabilities': {}, + 'clientInfo': {'name': 'test-client', 'version': '1.0.0'}, + }; + + for (final parse in [ + () => InitializeRequest.fromJson({ + ...params, + 'protocolVersion': 1, + }), + () => InitializeRequest.fromJson({ + ...params, + 'capabilities': 'bad', + }), + () => InitializeRequest.fromJson({ + ...params, + 'clientInfo': 'bad', + }), + () => JsonRpcInitializeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.initialize, + 'params': 'bad', + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + test('JsonRpcResponse serialization', () { final response = const JsonRpcResponse( id: 1, @@ -297,6 +328,57 @@ void main() { expect(parse, throwsA(isA())); } }); + + test('initialize and discover results reject malformed wire fields', () { + final initializeResult = { + 'protocolVersion': latestProtocolVersion, + 'capabilities': {}, + 'serverInfo': {'name': 'server', 'version': '1.0.0'}, + }; + final discoverResult = { + 'resultType': resultTypeComplete, + 'supportedVersions': [draftProtocolVersion2026_07_28], + 'capabilities': {}, + 'serverInfo': {'name': 'server', 'version': '1.0.0'}, + }; + + for (final parse in [ + () => InitializeResult.fromJson({ + ...initializeResult, + 'protocolVersion': 1, + }), + () => InitializeResult.fromJson({ + ...initializeResult, + 'capabilities': 'bad', + }), + () => InitializeResult.fromJson({ + ...initializeResult, + 'serverInfo': 'bad', + }), + () => InitializeResult.fromJson({ + ...initializeResult, + 'instructions': 1, + }), + () => DiscoverResult.fromJson({ + ...discoverResult, + 'supportedVersions': [draftProtocolVersion2026_07_28, 1], + }), + () => DiscoverResult.fromJson({ + ...discoverResult, + 'capabilities': 'bad', + }), + () => DiscoverResult.fromJson({ + ...discoverResult, + 'serverInfo': 'bad', + }), + () => DiscoverResult.fromJson({ + ...discoverResult, + 'instructions': 1, + }), + ]) { + expect(parse, throwsA(isA())); + } + }); }); group('ToolExecution Tests', () { @@ -447,6 +529,43 @@ void main() { throwsA(isA()), ); }); + + test('capability parsers reject malformed wire fields', () { + for (final parse in [ + () => ClientCapabilitiesRoots.fromJson({'listChanged': 'true'}), + () => ClientElicitationForm.fromJson({'applyDefaults': 'true'}), + () => ClientElicitation.fromJson({'form': 'bad'}), + () => ClientCapabilitiesSampling.fromJson({ + 'tools': {'bad': Object()}, + }), + () => ClientCapabilities.fromJson({ + 'experimental': { + 'feature': {'bad': Object()}, + }, + }), + () => ClientCapabilities.fromJson({ + 'extensions': { + 'io.example/feature': {'bad': Object()}, + }, + }), + () => ServerCapabilities.fromJson({ + 'logging': {'bad': Object()}, + }), + () => const ServerCapabilities( + logging: {'bad': Object()}, + ).toJson(), + () => ServerCapabilitiesPrompts.fromJson({'listChanged': 'true'}), + () => ServerCapabilitiesResources.fromJson({'subscribe': 'true'}), + () => ServerCapabilitiesTools.fromJson({'listChanged': 'true'}), + () => ServerCapabilitiesCompletions.fromJson({'listChanged': 'true'}), + () => ServerCapabilitiesTasks.fromJson({'listChanged': 'true'}), + () => ServerCapabilitiesTasks.fromJson({ + 'list': {'bad': Object()}, + }), + ]) { + expect(parse, throwsA(isA())); + } + }); }); group('Content Tests', () { From 49e86b1ed854b3ac28a7a39ffc008949a70ce7c3 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 09:14:27 -0400 Subject: [PATCH 05/42] Validate prompt completion wire fields --- CHANGELOG.md | 2 + lib/src/types/completion.dart | 40 +++++++---- lib/src/types/logging.dart | 19 +++-- lib/src/types/misc.dart | 28 +++++--- lib/src/types/prompts.dart | 100 +++++++++++++++++++------- lib/src/types/validation.dart | 11 +++ test/mcp_2025_11_25_test.dart | 47 ++++++++++++ test/mcp_2026_07_28_test.dart | 34 +++++++++ test/types/logging_types_test.dart | 49 +++++++++++++ test/types_edge_cases_test.dart | 14 ++++ test/types_test.dart | 110 +++++++++++++++++++++++++++++ 11 files changed, 402 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 252ff2a1..ede7f576 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,8 @@ parse errors. - Rejected malformed initialization and capability wire fields with protocol parse errors. +- Rejected malformed prompt, completion, logging, and common notification wire + fields with protocol parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/completion.dart b/lib/src/types/completion.dart index 57ceb848..48d4f7d4 100644 --- a/lib/src/types/completion.dart +++ b/lib/src/types/completion.dart @@ -11,7 +11,7 @@ sealed class Reference { }); factory Reference.fromJson(Map json) { - final type = json['type'] as String?; + final type = readRequiredString(json['type'], 'Reference.type'); return switch (type) { 'ref/resource' => ResourceReference.fromJson(json), 'ref/prompt' => PromptReference.fromJson(json), @@ -66,8 +66,8 @@ class PromptReference extends Reference { factory PromptReference.fromJson(Map json) { return PromptReference( - name: json['name'] as String, - title: json['title'] as String?, + name: readRequiredString(json['name'], 'PromptReference.name'), + title: readOptionalString(json['title'], 'PromptReference.title'), ); } } @@ -87,8 +87,8 @@ class ArgumentCompletionInfo { factory ArgumentCompletionInfo.fromJson(Map json) { return ArgumentCompletionInfo( - name: json['name'] as String, - value: json['value'] as String, + name: readRequiredString(json['name'], 'ArgumentCompletionInfo.name'), + value: readRequiredString(json['value'], 'ArgumentCompletionInfo.value'), ); } @@ -107,8 +107,9 @@ class CompletionContext { factory CompletionContext.fromJson(Map json) { return CompletionContext( - arguments: (json['arguments'] as Map?)?.map( - (key, value) => MapEntry(key, value as String), + arguments: readOptionalStringMap( + json['arguments'], + 'CompletionContext.arguments', ), ); } @@ -137,14 +138,16 @@ class CompleteRequest { factory CompleteRequest.fromJson(Map json) => CompleteRequest( - ref: Reference.fromJson(json['ref'] as Map), + ref: Reference.fromJson( + readJsonObject(json['ref'], 'CompleteRequest.ref'), + ), argument: ArgumentCompletionInfo.fromJson( - json['argument'] as Map, + readJsonObject(json['argument'], 'CompleteRequest.argument'), ), context: json['context'] == null ? null : CompletionContext.fromJson( - json['context'] as Map, + readJsonObject(json['context'], 'CompleteRequest.context'), ), ); @@ -170,7 +173,10 @@ class JsonRpcCompleteRequest extends JsonRpcRequest { ); factory JsonRpcCompleteRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcCompleteRequest.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for complete request"); } @@ -211,9 +217,15 @@ class CompletionResultData { ); } return CompletionResultData( - values: values.cast(), + values: [ + for (final value in values) + readRequiredString(value, 'CompletionResultData.values items'), + ], total: readOptionalInteger(json['total'], 'CompletionResultData.total'), - hasMore: json['hasMore'] as bool?, + hasMore: readOptionalBool( + json['hasMore'], + 'CompletionResultData.hasMore', + ), ); } @@ -248,7 +260,7 @@ class CompleteResult implements BaseResultData { final meta = readOptionalJsonObject(json['_meta'], 'CompleteResult._meta'); return CompleteResult( completion: CompletionResultData.fromJson( - json['completion'] as Map, + readJsonObject(json['completion'], 'CompleteResult.completion'), ), meta: meta, ); diff --git a/lib/src/types/logging.dart b/lib/src/types/logging.dart index fe4090ae..6553f1c9 100644 --- a/lib/src/types/logging.dart +++ b/lib/src/types/logging.dart @@ -44,7 +44,10 @@ class JsonRpcSetLevelRequest extends JsonRpcRequest { }) : super(method: Method.loggingSetLevel, params: setParams.toJson()); factory JsonRpcSetLevelRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcSetLevelRequest.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for set level request"); } @@ -83,14 +86,17 @@ class LoggingMessageNotification { LoggingLevel.values, 'LoggingMessageNotification.level', ), - logger: json['logger'] as String?, - data: json['data'], + logger: readOptionalString( + json['logger'], + 'LoggingMessageNotification.logger', + ), + data: readJsonValue(json['data'], 'LoggingMessageNotification.data'), ); Map toJson() => { 'level': level.name, if (logger != null) 'logger': logger, - 'data': data, + 'data': readJsonValue(data, 'LoggingMessageNotification.data'), }; } @@ -105,7 +111,10 @@ class JsonRpcLoggingMessageNotification extends JsonRpcNotification { factory JsonRpcLoggingMessageNotification.fromJson( Map json, ) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcLoggingMessageNotification.params', + ); if (paramsMap == null) { throw const FormatException( "Missing params for logging message notification", diff --git a/lib/src/types/misc.dart b/lib/src/types/misc.dart index e4670eb1..f386e6f3 100644 --- a/lib/src/types/misc.dart +++ b/lib/src/types/misc.dart @@ -17,21 +17,27 @@ class EmptyResult implements BaseResultData { /// Parameters for the `notifications/cancelled` notification. class CancelledNotification { /// The ID of the request to cancel. - final RequestId requestId; + final RequestId? requestId; /// An optional string describing the reason for the cancellation. final String? reason; - const CancelledNotification({required this.requestId, this.reason}); + const CancelledNotification({this.requestId, this.reason}); factory CancelledNotification.fromJson(Map json) => CancelledNotification( - requestId: parseRequestId(json['requestId'], fieldName: 'requestId'), - reason: json['reason'] as String?, + requestId: json.containsKey('requestId') + ? parseRequestId(json['requestId'], fieldName: 'requestId') + : null, + reason: readOptionalString( + json['reason'], + 'CancelledNotification.reason', + ), ); Map toJson() => { - 'requestId': parseRequestId(requestId, fieldName: 'requestId'), + if (requestId != null) + 'requestId': parseRequestId(requestId, fieldName: 'requestId'), if (reason != null) 'reason': reason, }; } @@ -48,7 +54,10 @@ class JsonRpcCancelledNotification extends JsonRpcNotification { ); factory JsonRpcCancelledNotification.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcCancelledNotification.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for cancelled notification"); } @@ -97,7 +106,7 @@ class Progress { return Progress( progress: readFiniteNumber(json['progress'], 'Progress.progress'), total: readOptionalFiniteNumber(json['total'], 'Progress.total'), - message: json['message'] as String?, + message: readOptionalString(json['message'], 'Progress.message'), ); } @@ -171,7 +180,10 @@ class JsonRpcProgressNotification extends JsonRpcNotification { /// Creates from JSON. factory JsonRpcProgressNotification.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcProgressNotification.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for progress notification"); } diff --git a/lib/src/types/prompts.dart b/lib/src/types/prompts.dart index a74ab569..bfadf3ed 100644 --- a/lib/src/types/prompts.dart +++ b/lib/src/types/prompts.dart @@ -2,6 +2,23 @@ import '../types.dart'; import 'json_rpc.dart'; import 'validation.dart'; +List? _readOptionalObjectList( + Object? value, + String field, + T Function(Map json) fromJson, +) { + if (value == null) { + return null; + } + if (value is! List) { + throw FormatException('$field must be a list of objects'); + } + return [ + for (var i = 0; i < value.length; i++) + fromJson(readJsonObject(value[i], '$field[$i]')), + ]; +} + /// Describes an argument accepted by a prompt template. class PromptArgument { /// The name of the argument. @@ -25,10 +42,13 @@ class PromptArgument { factory PromptArgument.fromJson(Map json) { return PromptArgument( - name: json['name'] as String, - title: json['title'] as String?, - description: json['description'] as String?, - required: json['required'] as bool?, + name: readRequiredString(json['name'], 'PromptArgument.name'), + title: readOptionalString(json['title'], 'PromptArgument.title'), + description: readOptionalString( + json['description'], + 'PromptArgument.description', + ), + required: readOptionalBool(json['required'], 'PromptArgument.required'), ); } @@ -78,18 +98,23 @@ class Prompt { factory Prompt.fromJson(Map json) { return Prompt( - name: json['name'] as String, - title: json['title'] as String?, - description: json['description'] as String?, - arguments: (json['arguments'] as List?) - ?.map((a) => PromptArgument.fromJson(a as Map)) - .toList(), + name: readRequiredString(json['name'], 'Prompt.name'), + title: readOptionalString(json['title'], 'Prompt.title'), + description: + readOptionalString(json['description'], 'Prompt.description'), + arguments: _readOptionalObjectList( + json['arguments'], + 'Prompt.arguments', + PromptArgument.fromJson, + ), icon: json['icon'] != null - ? ImageContent.fromJson(json['icon'] as Map) + ? ImageContent.fromJson(readJsonObject(json['icon'], 'Prompt.icon')) : null, - icons: (json['icons'] as List?) - ?.map((e) => McpIcon.fromJson(e as Map)) - .toList(), + icons: _readOptionalObjectList( + json['icons'], + 'Prompt.icons', + McpIcon.fromJson, + ), meta: readOptionalJsonObject(json['_meta'], 'Prompt._meta'), ); } @@ -114,7 +139,9 @@ class ListPromptsRequest { const ListPromptsRequest({this.cursor}); factory ListPromptsRequest.fromJson(Map json) => - ListPromptsRequest(cursor: json['cursor'] as String?); + ListPromptsRequest( + cursor: readOptionalString(json['cursor'], 'ListPromptsRequest.cursor'), + ); Map toJson() => {if (cursor != null) 'cursor': cursor}; } @@ -132,7 +159,10 @@ class JsonRpcListPromptsRequest extends JsonRpcRequest { super(method: Method.promptsList, params: params?.toJson()); factory JsonRpcListPromptsRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcListPromptsRequest.params', + ); final meta = extractRequestMeta(json); return JsonRpcListPromptsRequest( id: parseRequestId(json['id']), @@ -179,9 +209,16 @@ class ListPromptsResult implements CacheableResultData { } return ListPromptsResult( prompts: prompts - .map((p) => Prompt.fromJson(p as Map)) + .map( + (p) => Prompt.fromJson( + readJsonObject(p, 'ListPromptsResult.prompts items'), + ), + ) .toList(), - nextCursor: json['nextCursor'] as String?, + nextCursor: readOptionalString( + json['nextCursor'], + 'ListPromptsResult.nextCursor', + ), ttlMs: readOptionalTtlMs(json['ttlMs'], 'ListPromptsResult.ttlMs'), cacheScope: readOptionalCacheScope( json['cacheScope'], @@ -229,9 +266,10 @@ class GetPromptRequest { factory GetPromptRequest.fromJson(Map json) => GetPromptRequest( - name: json['name'] as String, - arguments: (json['arguments'] as Map?)?.map( - (k, v) => MapEntry(k, v as String), + name: readRequiredString(json['name'], 'GetPromptRequest.name'), + arguments: readOptionalStringMap( + json['arguments'], + 'GetPromptRequest.arguments', ), inputResponses: InputResponse.mapFromJson( json['inputResponses'], @@ -264,7 +302,10 @@ class JsonRpcGetPromptRequest extends JsonRpcRequest { }) : super(method: Method.promptsGet, params: getParams.toJson()); factory JsonRpcGetPromptRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcGetPromptRequest.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for get prompt request"); } @@ -298,7 +339,9 @@ class PromptMessage { role: PromptMessageRole.values.byName( readRequiredRoleString(json['role'], 'PromptMessage.role'), ), - content: Content.fromJson(json['content'] as Map), + content: Content.fromJson( + readJsonObject(json['content'], 'PromptMessage.content'), + ), ); } @@ -329,9 +372,16 @@ class GetPromptResult implements BaseResultData { throw const FormatException('GetPromptResult.messages is required'); } return GetPromptResult( - description: json['description'] as String?, + description: readOptionalString( + json['description'], + 'GetPromptResult.description', + ), messages: messages - .map((m) => PromptMessage.fromJson(m as Map)) + .map( + (m) => PromptMessage.fromJson( + readJsonObject(m, 'GetPromptResult.messages items'), + ), + ) .toList(), meta: meta, ); diff --git a/lib/src/types/validation.dart b/lib/src/types/validation.dart index b45cf656..a27a582b 100644 --- a/lib/src/types/validation.dart +++ b/lib/src/types/validation.dart @@ -277,6 +277,17 @@ List? readOptionalStringList(Object? value, String field) { ]; } +Map? readOptionalStringMap(Object? value, String field) { + if (value == null) { + return null; + } + final map = readJsonObject(value, field); + return { + for (final entry in map.entries) + entry.key: readRequiredString(entry.value, '$field.${entry.key}'), + }; +} + int? readOptionalTtlMs(Object? value, String field) { final ttlMs = readOptionalInteger(value, field); if (ttlMs == null) { diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 8e83af2c..4c82d73b 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1605,6 +1605,53 @@ void main() { }), throwsA(isA()), ); + expect( + () => LoggingMessageNotificationParams.fromJson({ + 'level': 'info', + 'data': Object(), + }), + throwsA(isA()), + ); + expect( + () => PromptArgument.fromJson({'name': 1}), + throwsA(isA()), + ); + expect( + () => Prompt.fromJson({ + 'name': 'prompt', + 'arguments': [1], + }), + throwsA(isA()), + ); + expect( + () => GetPromptRequest.fromJson({ + 'name': 'prompt', + 'arguments': {'arg': 1}, + }), + throwsA(isA()), + ); + expect( + () => CompleteRequest.fromJson({ + 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, + 'argument': {'name': 'arg', 'value': 1}, + }), + throwsA(isA()), + ); + expect( + () => CompletionResultData.fromJson({ + 'values': ['a'], + 'hasMore': 'true', + }), + throwsA(isA()), + ); + expect( + () => ProgressNotification.fromJson({ + 'progressToken': 'progress-1', + 'progress': 1, + 'message': 1, + }), + throwsA(isA()), + ); expect( () => CreateMessageRequestParams.fromJson({ 'messages': [ diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 3e000fb7..a14b8cfe 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -927,6 +927,40 @@ void main() { ); }); + test( + 'prompt completion and notification fields reject malformed wire shapes', + () { + for (final parse in [ + () => Prompt.fromJson({ + 'name': 'prompt', + 'arguments': [1], + }), + () => GetPromptRequest.fromJson({ + 'name': 'prompt', + 'arguments': {'arg': 1}, + }), + () => CompleteRequest.fromJson({ + 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, + 'argument': {'name': 'arg', 'value': 1}, + }), + () => CompletionResultData.fromJson({ + 'values': ['a'], + 'hasMore': 'true', + }), + () => LoggingMessageNotification.fromJson({ + 'level': 'info', + 'data': Object(), + }), + () => ProgressNotification.fromJson({ + 'progressToken': 'progress-1', + 'progress': 1, + 'message': 1, + }), + ]) { + expect(parse, throwsFormatException); + } + }); + test('serializes MRTR input required results', () { final result = InputRequiredResult( inputRequests: { diff --git a/test/types/logging_types_test.dart b/test/types/logging_types_test.dart index d4200bae..a4cfabe0 100644 --- a/test/types/logging_types_test.dart +++ b/test/types/logging_types_test.dart @@ -111,6 +111,19 @@ void main() { ); }); + test('fromJson rejects non-object params', () { + final json = { + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'logging/setLevel', + 'params': 'bad', + }; + expect( + () => JsonRpcSetLevelRequest.fromJson(json), + throwsA(isA()), + ); + }); + test('toJson serializes correctly', () { final request = JsonRpcSetLevelRequest( id: 5, @@ -215,6 +228,30 @@ void main() { throwsA(isA()), ); }); + + test('rejects malformed logger and non-JSON data values', () { + expect( + () => LoggingMessageNotificationParams.fromJson({ + 'level': 'info', + 'logger': 1, + }), + throwsA(isA()), + ); + expect( + () => LoggingMessageNotificationParams.fromJson({ + 'level': 'info', + 'data': Object(), + }), + throwsA(isA()), + ); + expect( + () => const LoggingMessageNotificationParams( + level: LoggingLevel.info, + data: Object(), + ).toJson(), + throwsA(isA()), + ); + }); }); group('JsonRpcLoggingMessageNotification', () { @@ -270,6 +307,18 @@ void main() { ); }); + test('fromJson rejects non-object params', () { + final json = { + 'jsonrpc': '2.0', + 'method': 'notifications/message', + 'params': 'bad', + }; + expect( + () => JsonRpcLoggingMessageNotification.fromJson(json), + throwsA(isA()), + ); + }); + test('toJson serializes correctly', () { final notification = JsonRpcLoggingMessageNotification( logParams: const LoggingMessageNotificationParams( diff --git a/test/types_edge_cases_test.dart b/test/types_edge_cases_test.dart index aff5633d..5333a5ba 100644 --- a/test/types_edge_cases_test.dart +++ b/test/types_edge_cases_test.dart @@ -177,6 +177,20 @@ void main() { expect(json.containsKey('reason'), isFalse); }); + test('allows omitted requestId per notification wire schema', () { + final parsed = JsonRpcCancelledNotification.fromJson({ + 'jsonrpc': '2.0', + 'method': 'notifications/cancelled', + 'params': {'reason': 'Task cancellation uses tasks/cancel'}, + }); + + expect(parsed.cancelParams.requestId, isNull); + expect(parsed.cancelParams.reason, 'Task cancellation uses tasks/cancel'); + expect(parsed.toJson()['params'], { + 'reason': 'Task cancellation uses tasks/cancel', + }); + }); + test('rejects malformed requestId wire values', () { for (final requestId in [ null, diff --git a/test/types_test.dart b/test/types_test.dart index e8eefc56..5f9c9c86 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -1307,6 +1307,69 @@ void main() { expect(deserialized.required, equals(true)); }); + test('Prompt parsers reject malformed wire fields', () { + for (final parse in [ + () => PromptArgument.fromJson({'name': 1}), + () => PromptArgument.fromJson({ + 'name': 'arg', + 'required': 'true', + }), + () => Prompt.fromJson({'name': 1}), + () => Prompt.fromJson({ + 'name': 'prompt', + 'arguments': 'bad', + }), + () => Prompt.fromJson({ + 'name': 'prompt', + 'arguments': [1], + }), + () => Prompt.fromJson({ + 'name': 'prompt', + 'icon': 'bad', + }), + () => Prompt.fromJson({ + 'name': 'prompt', + 'icons': [1], + }), + () => ListPromptsRequest.fromJson({'cursor': 1}), + () => ListPromptsResult.fromJson({ + 'prompts': [1], + }), + () => ListPromptsResult.fromJson({ + 'prompts': [], + 'nextCursor': 1, + }), + () => GetPromptRequest.fromJson({'name': 1}), + () => GetPromptRequest.fromJson({ + 'name': 'prompt', + 'arguments': 'bad', + }), + () => GetPromptRequest.fromJson({ + 'name': 'prompt', + 'arguments': {'arg': 1}, + }), + () => JsonRpcGetPromptRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.promptsGet, + 'params': 'bad', + }), + () => PromptMessage.fromJson({ + 'role': 'user', + 'content': 'bad', + }), + () => GetPromptResult.fromJson({ + 'description': 1, + 'messages': [], + }), + () => GetPromptResult.fromJson({ + 'messages': [1], + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + test('PromptMessage validates role wire values', () { expect( () => PromptMessage.fromJson({ @@ -1324,6 +1387,53 @@ void main() { ); }); }); + + group('Completion Tests', () { + test('Completion parsers reject malformed wire fields', () { + final validRef = {'type': 'ref/prompt', 'name': 'prompt'}; + final validArgument = {'name': 'arg', 'value': 'prefix'}; + + for (final parse in [ + () => Reference.fromJson({'type': 1}), + () => PromptReference.fromJson({'name': 1}), + () => ArgumentCompletionInfo.fromJson({'name': 1, 'value': 'v'}), + () => ArgumentCompletionInfo.fromJson({'name': 'arg', 'value': 1}), + () => CompletionContext.fromJson({ + 'arguments': {'arg': 1}, + }), + () => CompleteRequest.fromJson({ + 'ref': 'bad', + 'argument': validArgument, + }), + () => CompleteRequest.fromJson({ + 'ref': validRef, + 'argument': 'bad', + }), + () => CompleteRequest.fromJson({ + 'ref': validRef, + 'argument': validArgument, + 'context': 'bad', + }), + () => JsonRpcCompleteRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.completionComplete, + 'params': 'bad', + }), + () => CompletionResultData.fromJson({ + 'values': ['one', 1], + }), + () => CompletionResultData.fromJson({ + 'values': ['one'], + 'hasMore': 'true', + }), + () => CompleteResult.fromJson({'completion': 'bad'}), + ]) { + expect(parse, throwsA(isA())); + } + }); + }); + group('CreateMessageResult Tests', () { test('CreateMessageResult serialization and deserialization', () { final result = const CreateMessageResult( From ddd8cb5c4a7220adc9489832c3e265290a47e568 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 09:26:35 -0400 Subject: [PATCH 06/42] Validate tool wire fields --- CHANGELOG.md | 2 + lib/src/types/json_rpc.dart | 11 +++- lib/src/types/tools.dart | 101 ++++++++++++++++++++++++--------- test/mcp_2025_11_25_test.dart | 46 +++++++++++++++ test/mcp_2026_07_28_test.dart | 40 +++++++++++++ test/types_test.dart | 102 ++++++++++++++++++++++++++++++++++ 6 files changed, 275 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ede7f576..d3f863d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,8 @@ parse errors. - Rejected malformed prompt, completion, logging, and common notification wire fields with protocol parse errors. +- Rejected malformed tool definition, tool-list, and tool-call wire fields with + protocol parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index ddc82f9a..88bfc2f0 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -1060,7 +1060,10 @@ class JsonRpcListToolsRequest extends JsonRpcRequest { factory JsonRpcListToolsRequest.fromJson(Map json) { return JsonRpcListToolsRequest( id: parseRequestId(json['id']), - params: json['params'] as Map?, + params: readOptionalJsonObject( + json['params'], + 'JsonRpcListToolsRequest.params', + ), meta: extractRequestMeta(json), ); } @@ -1085,7 +1088,11 @@ class JsonRpcCallToolRequest extends JsonRpcRequest { factory JsonRpcCallToolRequest.fromJson(Map json) { return JsonRpcCallToolRequest( id: parseRequestId(json['id']), - params: json['params'] as Map? ?? {}, + params: readOptionalJsonObject( + json['params'], + 'JsonRpcCallToolRequest.params', + ) ?? + {}, meta: extractRequestMeta(json), ); } diff --git a/lib/src/types/tools.dart b/lib/src/types/tools.dart index 94ca94b2..dd9b1fc1 100644 --- a/lib/src/types/tools.dart +++ b/lib/src/types/tools.dart @@ -77,13 +77,32 @@ class ToolAnnotations { factory ToolAnnotations.fromJson(Map json) { return ToolAnnotations( - title: json['title'] as String?, - readOnlyHint: json['readOnlyHint'] as bool? ?? false, - destructiveHint: json['destructiveHint'] as bool? ?? true, - idempotentHint: json['idempotentHint'] as bool? ?? false, - openWorldHint: json['openWorldHint'] as bool? ?? true, + title: readOptionalString(json['title'], 'ToolAnnotations.title'), + readOnlyHint: readOptionalBool( + json['readOnlyHint'], + 'ToolAnnotations.readOnlyHint', + ) ?? + false, + destructiveHint: readOptionalBool( + json['destructiveHint'], + 'ToolAnnotations.destructiveHint', + ) ?? + true, + idempotentHint: readOptionalBool( + json['idempotentHint'], + 'ToolAnnotations.idempotentHint', + ) ?? + false, + openWorldHint: readOptionalBool( + json['openWorldHint'], + 'ToolAnnotations.openWorldHint', + ) ?? + true, priority: readUnitDouble(json['priority'], 'ToolAnnotations.priority'), - audience: (json['audience'] as List?)?.cast(), + audience: readOptionalAnnotationAudience( + json['audience'], + 'ToolAnnotations.audience', + ), ); } @@ -117,7 +136,9 @@ class ToolExecution { const ToolExecution({this.taskSupport = 'forbidden'}); factory ToolExecution.fromJson(Map json) { - final taskSupport = json['taskSupport'] as String? ?? 'forbidden'; + final taskSupport = + readOptionalString(json['taskSupport'], 'ToolExecution.taskSupport') ?? + 'forbidden'; if (!allowedTaskSupportValues.contains(taskSupport)) { throw FormatException( "Invalid tool execution taskSupport '$taskSupport'. Expected one of: ${allowedTaskSupportValues.join(', ')}", @@ -202,26 +223,30 @@ class Tool { outputSchemaJson == null ? null : JsonSchema.fromJson(outputSchemaJson); return Tool( - name: json['name'] as String, - title: json['title'] as String?, - description: json['description'] as String?, + name: readRequiredString(json['name'], 'Tool.name'), + title: readOptionalString(json['title'], 'Tool.title'), + description: readOptionalString(json['description'], 'Tool.description'), inputSchema: inputSchema, outputSchema: outputSchema, annotations: json['annotations'] != null ? ToolAnnotations.fromJson( - json['annotations'] as Map, + readJsonObject(json['annotations'], 'Tool.annotations'), ) : null, meta: readOptionalJsonObject(json['_meta'], 'Tool._meta'), execution: json['execution'] != null - ? ToolExecution.fromJson(json['execution'] as Map) + ? ToolExecution.fromJson( + readJsonObject(json['execution'], 'Tool.execution'), + ) : null, icon: json['icon'] != null - ? ImageContent.fromJson(json['icon'] as Map) + ? ImageContent.fromJson(readJsonObject(json['icon'], 'Tool.icon')) : null, - icons: (json['icons'] as List?) - ?.map((e) => McpIcon.fromJson(e as Map)) - .toList(), + icons: _readOptionalObjectList( + json['icons'], + 'Tool.icons', + McpIcon.fromJson, + ), ); } @@ -251,7 +276,7 @@ class ListToolsRequest { factory ListToolsRequest.fromJson(Map json) { return ListToolsRequest( - cursor: json['cursor'] as String?, + cursor: readOptionalString(json['cursor'], 'ListToolsRequest.cursor'), ); } @@ -297,9 +322,14 @@ class ListToolsResult implements CacheableResultData { throw const FormatException('ListToolsResult.tools is required'); } return ListToolsResult( - tools: - tools.map((e) => Tool.fromJson(e as Map)).toList(), - nextCursor: json['nextCursor'] as String?, + tools: [ + for (var i = 0; i < tools.length; i++) + Tool.fromJson( + readJsonObject(tools[i], 'ListToolsResult.tools[$i]'), + ), + ], + nextCursor: + readOptionalString(json['nextCursor'], 'ListToolsResult.nextCursor'), ttlMs: readOptionalTtlMs(json['ttlMs'], 'ListToolsResult.ttlMs'), cacheScope: readOptionalCacheScope( json['cacheScope'], @@ -350,7 +380,7 @@ class CallToolRequest { factory CallToolRequest.fromJson(Map json) { final arguments = json['arguments']; return CallToolRequest( - name: json['name'] as String, + name: readRequiredString(json['name'], 'CallToolRequest.name'), arguments: arguments == null ? const {} : _readJsonObject(arguments, 'CallToolRequest.arguments'), @@ -437,10 +467,14 @@ class CallToolResult implements BaseResultData { } return CallToolResult( - content: content - .map((e) => Content.fromJson(e as Map)) - .toList(), - isError: json['isError'] as bool? ?? false, + content: [ + for (var i = 0; i < content.length; i++) + Content.fromJson( + readJsonObject(content[i], 'CallToolResult.content[$i]'), + ), + ], + isError: + readOptionalBool(json['isError'], 'CallToolResult.isError') ?? false, structuredContent: json.containsKey('structuredContent') ? readJsonValue( json['structuredContent'], @@ -511,6 +545,23 @@ Map? _readOptionalJsonObject(Object? value, String field) { return _readJsonObject(value, field); } +List? _readOptionalObjectList( + Object? value, + String field, + T Function(Map json) fromJson, +) { + if (value == null) { + return null; + } + if (value is! List) { + throw FormatException('$field must be a list of JSON objects'); + } + return [ + for (var i = 0; i < value.length; i++) + fromJson(_readJsonObject(value[i], '$field[$i]')), + ]; +} + Map _readJsonObject(Object? value, String field) { return readJsonObject(value, field); } diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 4c82d73b..875646b8 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -621,6 +621,52 @@ void main() { ); }); + test('Tool wire fields reject malformed values', () { + for (final parse in [ + () => ToolAnnotations.fromJson({'title': 1}), + () => ToolAnnotations.fromJson({'readOnlyHint': 'true'}), + () => ToolExecution.fromJson({'taskSupport': 1}), + () => Tool.fromJson({ + 'name': 1, + 'inputSchema': {'type': 'object'}, + }), + () => Tool.fromJson({ + 'name': 'search', + 'inputSchema': {'type': 'object'}, + 'annotations': 'bad', + }), + () => Tool.fromJson({ + 'name': 'search', + 'inputSchema': {'type': 'object'}, + 'icons': [1], + }), + () => ListToolsRequest.fromJson({'cursor': 1}), + () => ListToolsResult.fromJson({ + 'tools': >[], + 'nextCursor': 1, + }), + () => CallToolRequest.fromJson({'name': 1}), + () => CallToolResult.fromJson({ + 'content': >[], + 'isError': 'true', + }), + () => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsList, + 'params': 'bad', + }), + () => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsCall, + 'params': 'bad', + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + test('Result metadata fields reject non-JSON Dart maps', () { expect( () => Root.fromJson({ diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index a14b8cfe..362360e5 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1280,6 +1280,46 @@ void main() { ); }); + test('rejects malformed tool wire shapes', () { + for (final parse in [ + () => ToolAnnotations.fromJson({'openWorldHint': 'false'}), + () => ToolExecution.fromJson({'taskSupport': 1}), + () => Tool.fromJson({ + 'name': 'search', + 'inputSchema': {'type': 'object'}, + 'execution': 'bad', + }), + () => Tool.fromJson({ + 'name': 'search', + 'inputSchema': {'type': 'object'}, + 'icons': [1], + }), + () => ListToolsRequest.fromJson({'cursor': 1}), + () => ListToolsResult.fromJson({ + 'tools': [1], + }), + () => CallToolRequest.fromJson({'name': 1}), + () => CallToolResult.fromJson({ + 'content': >[], + 'isError': 'true', + }), + () => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsList, + 'params': 'bad', + }), + () => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsCall, + 'params': 'bad', + }), + ]) { + expect(parse, throwsFormatException); + } + }); + test('server acknowledges subscriptions/listen with subscription id', () async { final server = Server( diff --git a/test/types_test.dart b/test/types_test.dart index 5f9c9c86..deab3c09 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -381,6 +381,23 @@ void main() { }); }); + group('ToolAnnotations Tests', () { + test('rejects malformed wire fields', () { + for (final parse in [ + () => ToolAnnotations.fromJson({'title': 1}), + () => ToolAnnotations.fromJson({'readOnlyHint': 'true'}), + () => ToolAnnotations.fromJson({'destructiveHint': 'true'}), + () => ToolAnnotations.fromJson({'idempotentHint': 'false'}), + () => ToolAnnotations.fromJson({'openWorldHint': 'false'}), + () => ToolAnnotations.fromJson({ + 'audience': ['user', 1], + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + }); + group('ToolExecution Tests', () { test('rejects invalid taskSupport while parsing wire JSON', () { expect( @@ -389,6 +406,13 @@ void main() { ); }); + test('rejects non-string taskSupport while parsing wire JSON', () { + expect( + () => ToolExecution.fromJson({'taskSupport': 1}), + throwsA(isA()), + ); + }); + test('rejects invalid taskSupport while serializing wire JSON', () { expect( () => const ToolExecution(taskSupport: 'sometimes').toJson(), @@ -397,6 +421,84 @@ void main() { }); }); + group('Tool wire parsing Tests', () { + test('rejects malformed tool definition fields', () { + for (final parse in [ + () => Tool.fromJson({ + 'name': 1, + 'inputSchema': {'type': 'object'}, + }), + () => Tool.fromJson({ + 'name': 'search', + 'title': 1, + 'inputSchema': {'type': 'object'}, + }), + () => Tool.fromJson({ + 'name': 'search', + 'description': 1, + 'inputSchema': {'type': 'object'}, + }), + () => Tool.fromJson({ + 'name': 'search', + 'inputSchema': {'type': 'object'}, + 'annotations': 'bad', + }), + () => Tool.fromJson({ + 'name': 'search', + 'inputSchema': {'type': 'object'}, + 'execution': 'bad', + }), + () => Tool.fromJson({ + 'name': 'search', + 'inputSchema': {'type': 'object'}, + 'icon': 'bad', + }), + () => Tool.fromJson({ + 'name': 'search', + 'inputSchema': {'type': 'object'}, + 'icons': [1], + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + + test('rejects malformed list and call fields', () { + for (final parse in [ + () => ListToolsRequest.fromJson({'cursor': 1}), + () => ListToolsResult.fromJson({ + 'tools': [1], + }), + () => ListToolsResult.fromJson({ + 'tools': >[], + 'nextCursor': 1, + }), + () => CallToolRequest.fromJson({'name': 1}), + () => CallToolResult.fromJson({ + 'content': [1], + }), + () => CallToolResult.fromJson({ + 'content': >[], + 'isError': 'true', + }), + () => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsList, + 'params': 'bad', + }), + () => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsCall, + 'params': 'bad', + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + }); + group('Capabilities Tests', () { test('ServerCapabilitiesCompletions serialization and deserialization', () { final completions = From f2f3b0cc1d70dbc610dd9ad8959e50956788e6ae Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 09:36:30 -0400 Subject: [PATCH 07/42] Validate root wire fields --- CHANGELOG.md | 1 + lib/src/types/roots.dart | 27 ++++++++++++++++++++++----- test/mcp_2025_11_25_test.dart | 33 +++++++++++++++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 33 +++++++++++++++++++++++++++++++++ test/types_test.dart | 35 +++++++++++++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3f863d2..e8e5066d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,7 @@ fields with protocol parse errors. - Rejected malformed tool definition, tool-list, and tool-call wire fields with protocol parse errors. +- Rejected malformed root-list wire fields with protocol parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/roots.dart b/lib/src/types/roots.dart index 648b7fd2..1895ed28 100644 --- a/lib/src/types/roots.dart +++ b/lib/src/types/roots.dart @@ -41,7 +41,7 @@ class Root { factory Root.fromJson(Map json) { return Root( uri: _readRootUri(json['uri']), - name: json['name'] as String?, + name: readOptionalString(json['name'], 'Root.name'), meta: readOptionalJsonObject(json['_meta'], 'Root._meta'), ); } @@ -62,6 +62,7 @@ class JsonRpcListRootsRequest extends JsonRpcRequest { : super(method: Method.rootsList); factory JsonRpcListRootsRequest.fromJson(Map json) { + _readOptionalParamsObject(json, 'JsonRpcListRootsRequest.params'); return JsonRpcListRootsRequest( id: parseRequestId(json['id']), meta: extractRequestMeta(json), @@ -87,8 +88,12 @@ class ListRootsResult implements BaseResultData { throw const FormatException('ListRootsResult.roots is required'); } return ListRootsResult( - roots: - roots.map((r) => Root.fromJson(r as Map)).toList(), + roots: [ + for (var i = 0; i < roots.length; i++) + Root.fromJson( + readJsonObject(roots[i], 'ListRootsResult.roots[$i]'), + ), + ], meta: meta, ); } @@ -108,6 +113,18 @@ class JsonRpcRootsListChangedNotification extends JsonRpcNotification { factory JsonRpcRootsListChangedNotification.fromJson( Map json, - ) => - JsonRpcRootsListChangedNotification(meta: extractRequestMeta(json)); + ) { + _readOptionalParamsObject( + json, + 'JsonRpcRootsListChangedNotification.params', + ); + return JsonRpcRootsListChangedNotification(meta: extractRequestMeta(json)); + } +} + +void _readOptionalParamsObject(Map json, String field) { + if (!json.containsKey('params')) { + return; + } + readJsonObject(json['params'], field); } diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 875646b8..8bb24b64 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -667,6 +667,39 @@ void main() { } }); + test('Root wire fields reject malformed values', () { + for (final parse in [ + () => Root.fromJson({'uri': 'file:///repo', 'name': 1}), + () => ListRootsResult.fromJson({ + 'roots': [1], + }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.rootsList, + 'params': 'bad', + }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.rootsList, + 'params': null, + }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsRootsListChanged, + 'params': 'bad', + }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsRootsListChanged, + 'params': null, + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + test('Result metadata fields reject non-JSON Dart maps', () { expect( () => Root.fromJson({ diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 362360e5..73fc19ad 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1320,6 +1320,39 @@ void main() { } }); + test('rejects malformed root wire shapes', () { + for (final parse in [ + () => Root.fromJson({'uri': 'file:///repo', 'name': 1}), + () => ListRootsResult.fromJson({ + 'roots': [1], + }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.rootsList, + 'params': 'bad', + }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.rootsList, + 'params': null, + }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsRootsListChanged, + 'params': 'bad', + }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsRootsListChanged, + 'params': null, + }), + ]) { + expect(parse, throwsFormatException); + } + }); + test('server acknowledges subscriptions/listen with subscription id', () async { final server = Server( diff --git a/test/types_test.dart b/test/types_test.dart index deab3c09..22664d2a 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -381,6 +381,41 @@ void main() { }); }); + group('Root wire parsing Tests', () { + test('rejects malformed root list fields', () { + for (final parse in [ + () => Root.fromJson({'uri': 'file:///repo', 'name': 1}), + () => ListRootsResult.fromJson({ + 'roots': [1], + }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.rootsList, + 'params': 'bad', + }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.rootsList, + 'params': null, + }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsRootsListChanged, + 'params': 'bad', + }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsRootsListChanged, + 'params': null, + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + }); + group('ToolAnnotations Tests', () { test('rejects malformed wire fields', () { for (final parse in [ From 14cf221f55213150213d1a907dd69ad116b1179a Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 09:52:28 -0400 Subject: [PATCH 08/42] Validate task wire fields --- CHANGELOG.md | 2 + lib/src/server/mcp_server.dart | 2 +- lib/src/types/tasks.dart | 95 +++++++++++++++++----------- test/mcp_2025_11_25_test.dart | 59 +++++++++++++++++ test/mcp_2026_07_28_test.dart | 55 ++++++++++++++++ test/types/tasks_extension_test.dart | 42 ++++++++++++ test/types_test.dart | 61 ++++++++++++++++++ 7 files changed, 279 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e5066d..a4b754ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,6 +124,8 @@ - Rejected malformed tool definition, tool-list, and tool-call wire fields with protocol parse errors. - Rejected malformed root-list wire fields with protocol parse errors. +- Rejected malformed stable task and task-extension wire fields with protocol + parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index a24b4cbc..58773c1d 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -1386,7 +1386,7 @@ class McpServer { }, (id, params, meta) => JsonRpcListTasksRequest.fromJson({ 'id': id, - 'params': params, + if (params != null) 'params': params, if (meta != null) '_meta': meta, }), ); diff --git a/lib/src/types/tasks.dart b/lib/src/types/tasks.dart index 633c6417..8cd1471d 100644 --- a/lib/src/types/tasks.dart +++ b/lib/src/types/tasks.dart @@ -193,7 +193,9 @@ class ListTasksRequest { const ListTasksRequest({this.cursor}); factory ListTasksRequest.fromJson(Map json) => - ListTasksRequest(cursor: json['cursor'] as String?); + ListTasksRequest( + cursor: readOptionalString(json['cursor'], 'ListTasksRequest.cursor'), + ); Map toJson() => {if (cursor != null) 'cursor': cursor}; } @@ -211,7 +213,8 @@ class JsonRpcListTasksRequest extends JsonRpcRequest { super(method: Method.tasksList, params: params?.toJson()); factory JsonRpcListTasksRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = + _readOptionalParamsObject(json, 'JsonRpcListTasksRequest.params'); final meta = extractRequestMeta(json); return JsonRpcListTasksRequest( id: parseRequestId(json['id']), @@ -242,9 +245,14 @@ class ListTasksResult implements BaseResultData { throw const FormatException('ListTasksResult.tasks is required'); } return ListTasksResult( - tasks: - tasks.map((e) => Task.fromJson(e as Map)).toList(), - nextCursor: json['nextCursor'] as String?, + tasks: [ + for (var i = 0; i < tasks.length; i++) + Task.fromJson( + readJsonObject(tasks[i], 'ListTasksResult.tasks[$i]'), + ), + ], + nextCursor: + readOptionalString(json['nextCursor'], 'ListTasksResult.nextCursor'), meta: meta, ); } @@ -266,7 +274,9 @@ class CancelTaskRequest { const CancelTaskRequest({required this.taskId}); factory CancelTaskRequest.fromJson(Map json) => - CancelTaskRequest(taskId: json['taskId'] as String); + CancelTaskRequest( + taskId: readRequiredString(json['taskId'], 'CancelTaskRequest.taskId'), + ); Map toJson() => {'taskId': taskId}; } @@ -283,10 +293,8 @@ class JsonRpcCancelTaskRequest extends JsonRpcRequest { }) : super(method: Method.tasksCancel, params: cancelParams.toJson()); factory JsonRpcCancelTaskRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException("Missing params for cancel task request"); - } + final paramsMap = + _readRequiredParamsObject(json, 'JsonRpcCancelTaskRequest.params'); final meta = extractRequestMeta(json); return JsonRpcCancelTaskRequest( id: parseRequestId(json['id']), @@ -303,8 +311,9 @@ class GetTaskRequest { const GetTaskRequest({required this.taskId}); - factory GetTaskRequest.fromJson(Map json) => - GetTaskRequest(taskId: json['taskId'] as String); + factory GetTaskRequest.fromJson(Map json) => GetTaskRequest( + taskId: readRequiredString(json['taskId'], 'GetTaskRequest.taskId'), + ); Map toJson() => {'taskId': taskId}; } @@ -321,10 +330,8 @@ class JsonRpcGetTaskRequest extends JsonRpcRequest { }) : super(method: Method.tasksGet, params: getParams.toJson()); factory JsonRpcGetTaskRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException("Missing params for get task request"); - } + final paramsMap = + _readRequiredParamsObject(json, 'JsonRpcGetTaskRequest.params'); final meta = extractRequestMeta(json); return JsonRpcGetTaskRequest( id: parseRequestId(json['id']), @@ -342,7 +349,9 @@ class TaskResultRequest { const TaskResultRequest({required this.taskId}); factory TaskResultRequest.fromJson(Map json) => - TaskResultRequest(taskId: json['taskId'] as String); + TaskResultRequest( + taskId: readRequiredString(json['taskId'], 'TaskResultRequest.taskId'), + ); Map toJson() => {'taskId': taskId}; } @@ -359,10 +368,8 @@ class JsonRpcTaskResultRequest extends JsonRpcRequest { }) : super(method: Method.tasksResult, params: resultParams.toJson()); factory JsonRpcTaskResultRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException("Missing params for task result request"); - } + final paramsMap = + _readRequiredParamsObject(json, 'JsonRpcTaskResultRequest.params'); final meta = extractRequestMeta(json); return JsonRpcTaskResultRequest( id: parseRequestId(json['id']), @@ -424,10 +431,8 @@ class JsonRpcUpdateTaskRequest extends JsonRpcRequest { }) : super(method: Method.tasksUpdate, params: updateParams.toJson()); factory JsonRpcUpdateTaskRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException("Missing params for update task request"); - } + final paramsMap = + _readRequiredParamsObject(json, 'JsonRpcUpdateTaskRequest.params'); final meta = extractRequestMeta(json); return JsonRpcUpdateTaskRequest( id: parseRequestId(json['id']), @@ -467,7 +472,9 @@ class CreateTaskResult implements BaseResultData { final meta = readOptionalJsonObject(json['_meta'], 'CreateTaskResult._meta'); return CreateTaskResult( - task: Task.fromJson(json['task'] as Map), + task: Task.fromJson( + _readRequiredJsonObject(json['task'], 'CreateTaskResult.task'), + ), meta: meta, ); } @@ -847,12 +854,10 @@ class JsonRpcTaskStatusNotification extends JsonRpcNotification { ); factory JsonRpcTaskStatusNotification.fromJson(Map json) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException( - "Missing params for task status notification", - ); - } + final paramsMap = _readRequiredParamsObject( + json, + 'JsonRpcTaskStatusNotification.params', + ); final meta = _readOptionalJsonObject( paramsMap['_meta'], 'JsonRpcTaskStatusNotification._meta', @@ -873,10 +878,8 @@ class JsonRpcTaskNotification extends JsonRpcNotification { : super(method: Method.notificationsTasks, params: task.toJson()); factory JsonRpcTaskNotification.fromJson(Map json) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException("Missing params for task notification"); - } + final paramsMap = + _readRequiredParamsObject(json, 'JsonRpcTaskNotification.params'); return JsonRpcTaskNotification( task: TaskExtensionTask.fromJson(paramsMap), meta: _readOptionalJsonObject( @@ -891,6 +894,26 @@ Map _readRequiredJsonObject(Object? value, String field) { return readJsonObject(value, field); } +Map? _readOptionalParamsObject( + Map json, + String field, +) { + if (!json.containsKey('params')) { + return null; + } + return _readRequiredJsonObject(json['params'], field); +} + +Map _readRequiredParamsObject( + Map json, + String field, +) { + if (!json.containsKey('params')) { + throw FormatException('$field is required'); + } + return _readRequiredJsonObject(json['params'], field); +} + Map? _readOptionalJsonObject(Object? value, String field) { if (value == null) { return null; diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 8bb24b64..d1c3e0e8 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -871,6 +871,65 @@ void main() { expect(deserialized.task.ttl, 7200); }); + test('task request and result wire fields reject malformed values', () { + for (final parse in [ + () => ListTasksRequest.fromJson({'cursor': 1}), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksList, + 'params': 'bad', + }), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksList, + 'params': null, + }), + () => ListTasksResult.fromJson({ + 'tasks': [1], + }), + () => ListTasksResult.fromJson({ + 'tasks': >[], + 'nextCursor': 1, + }), + () => CancelTaskRequest.fromJson({'taskId': 1}), + () => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksCancel, + 'params': 'bad', + }), + () => GetTaskRequest.fromJson({'taskId': 1}), + () => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': 'bad', + }), + () => TaskResultRequest.fromJson({'taskId': 1}), + () => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksResult, + 'params': null, + }), + () => CreateTaskResult.fromJson({'task': 'bad'}), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasksStatus, + 'params': 'bad', + }), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasksStatus, + 'params': null, + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + test('TaskStatusNotificationParams serialization', () { final params = const TaskStatusNotificationParams( taskId: 'task-notify-123', diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 73fc19ad..d14f1476 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1353,6 +1353,61 @@ void main() { } }); + test('rejects malformed task wire shapes', () { + for (final parse in [ + () => ListTasksRequest.fromJson({'cursor': 1}), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksList, + 'params': null, + }), + () => ListTasksResult.fromJson({ + 'tasks': [1], + }), + () => CancelTaskRequest.fromJson({'taskId': 1}), + () => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksCancel, + 'params': 'bad', + }), + () => GetTaskRequest.fromJson({'taskId': 1}), + () => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': 'bad', + }), + () => TaskResultRequest.fromJson({'taskId': 1}), + () => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksResult, + 'params': null, + }), + () => CreateTaskResult.fromJson({'task': 'bad'}), + () => JsonRpcUpdateTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksUpdate, + 'params': 'bad', + }), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasksStatus, + 'params': 'bad', + }), + () => JsonRpcTaskNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasks, + 'params': null, + }), + ]) { + expect(parse, throwsFormatException); + } + }); + test('server acknowledges subscriptions/listen with subscription id', () async { final server = Server( diff --git a/test/types/tasks_extension_test.dart b/test/types/tasks_extension_test.dart index 45b51505..801e76e3 100644 --- a/test/types/tasks_extension_test.dart +++ b/test/types/tasks_extension_test.dart @@ -217,6 +217,48 @@ void main() { ), throwsFormatException, ); + expect( + () => JsonRpcUpdateTaskRequest.fromJson( + const { + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksUpdate, + 'params': 'bad', + }, + ), + throwsFormatException, + ); + expect( + () => JsonRpcUpdateTaskRequest.fromJson( + const { + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksUpdate, + 'params': null, + }, + ), + throwsFormatException, + ); + expect( + () => JsonRpcTaskNotification.fromJson( + const { + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasks, + 'params': 'bad', + }, + ), + throwsFormatException, + ); + expect( + () => JsonRpcTaskNotification.fromJson( + const { + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasks, + 'params': null, + }, + ), + throwsFormatException, + ); }); }); } diff --git a/test/types_test.dart b/test/types_test.dart index 22664d2a..00a75934 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -416,6 +416,67 @@ void main() { }); }); + group('Task wire parsing Tests', () { + test('rejects malformed task request and result fields', () { + for (final parse in [ + () => ListTasksRequest.fromJson({'cursor': 1}), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksList, + 'params': 'bad', + }), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksList, + 'params': null, + }), + () => ListTasksResult.fromJson({ + 'tasks': [1], + }), + () => ListTasksResult.fromJson({ + 'tasks': >[], + 'nextCursor': 1, + }), + () => CancelTaskRequest.fromJson({'taskId': 1}), + () => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksCancel, + 'params': 'bad', + }), + () => GetTaskRequest.fromJson({'taskId': 1}), + () => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': 'bad', + }), + () => TaskResultRequest.fromJson({'taskId': 1}), + () => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksResult, + 'params': null, + }), + () => CreateTaskResult.fromJson({'task': 'bad'}), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasksStatus, + 'params': 'bad', + }), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasksStatus, + 'params': null, + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + }); + group('ToolAnnotations Tests', () { test('rejects malformed wire fields', () { for (final parse in [ From 48192a378c631de871e47750a805f5718a5f1baa Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 10:03:41 -0400 Subject: [PATCH 09/42] Validate elicitation wire fields --- CHANGELOG.md | 2 + lib/src/types/elicitation.dart | 77 ++++++++++++++++++++++------------ test/mcp_2025_11_25_test.dart | 29 +++++++++++++ test/mcp_2026_07_28_test.dart | 47 +++++++++++++++++++++ test/types_test.dart | 61 +++++++++++++++++++++++++++ 5 files changed, 190 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4b754ef..eb937cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,6 +126,8 @@ - Rejected malformed root-list wire fields with protocol parse errors. - Rejected malformed stable task and task-extension wire fields with protocol parse errors. +- Rejected malformed elicitation request, result, completion, and URL-required + error wire fields with protocol parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/elicitation.dart b/lib/src/types/elicitation.dart index a8cb4932..e58cbf83 100644 --- a/lib/src/types/elicitation.dart +++ b/lib/src/types/elicitation.dart @@ -117,7 +117,10 @@ class ElicitRequest { throw const FormatException('Elicitation message is required.'); } - final requestedSchemaJson = json['requestedSchema']; + final requestedSchemaJson = readOptionalJsonObject( + json['requestedSchema'], + 'ElicitRequest.requestedSchema', + ); final url = json['url']; final elicitationId = json['elicitationId']; final task = readOptionalJsonObject(json['task'], 'ElicitRequest.task'); @@ -143,7 +146,7 @@ class ElicitRequest { ); } - if (requestedSchemaJson is! Map) { + if (requestedSchemaJson == null) { throw const FormatException('Form elicitation requires requestedSchema.'); } _validateFormRequestedSchemaJson( @@ -236,10 +239,8 @@ class JsonRpcElicitRequest extends JsonRpcRequest { ); factory JsonRpcElicitRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException("Missing params for elicit request"); - } + final paramsMap = + _readRequiredParamsObject(json, 'JsonRpcElicitRequest.params'); final meta = extractRequestMeta(json); final protocolVersion = _protocolVersionFromMeta(meta); return JsonRpcElicitRequest( @@ -311,8 +312,11 @@ class ElicitResult implements BaseResultData { return ElicitResult( action: action, content: content, - url: json['url'] as String?, - elicitationId: json['elicitationId'] as String?, + url: readOptionalString(json['url'], 'ElicitResult.url'), + elicitationId: readOptionalString( + json['elicitationId'], + 'ElicitResult.elicitationId', + ), meta: readOptionalJsonObject(json['_meta'], 'ElicitResult._meta'), ); } @@ -368,7 +372,10 @@ class ElicitationCompleteNotification { factory ElicitationCompleteNotification.fromJson(Map json) { return ElicitationCompleteNotification( - elicitationId: json['elicitationId'] as String, + elicitationId: readRequiredString( + json['elicitationId'], + 'ElicitationCompleteNotification.elicitationId', + ), ); } @@ -394,12 +401,10 @@ class JsonRpcElicitationCompleteNotification extends JsonRpcNotification { factory JsonRpcElicitationCompleteNotification.fromJson( Map json, ) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException( - "Missing params for elicitation complete notification", - ); - } + final paramsMap = _readRequiredParamsObject( + json, + 'JsonRpcElicitationCompleteNotification.params', + ); final meta = readOptionalJsonObject( paramsMap['_meta'], 'JsonRpcElicitationCompleteNotification._meta', @@ -429,8 +434,15 @@ class URLElicitationRequiredErrorData { 'URLElicitationRequiredErrorData.elicitations is required', ); } - final elicitations = elicitationsList - .map((e) => ElicitRequest.fromJson(e as Map)) + final elicitations = elicitationsList.indexed + .map( + (entry) => ElicitRequest.fromJson( + readJsonObject( + entry.$2, + 'URLElicitationRequiredErrorData.elicitations[${entry.$1}]', + ), + ), + ) .toList(); _validateUrlElicitations(elicitations, formatException: true); return URLElicitationRequiredErrorData(elicitations: elicitations); @@ -481,20 +493,26 @@ void _validateFormRequestedSchemaJson( 'Form elicitation requestedSchema must have type object.', ); } - final properties = json['properties']; - if (properties is! Map) { + final properties = readOptionalJsonObject( + json['properties'], + 'ElicitRequest.requestedSchema.properties', + ); + if (properties == null) { throw const FormatException( 'Form elicitation requestedSchema.properties is required.', ); } for (final entry in properties.entries) { - if (entry.key is! String || entry.value is! Map) { + if (entry.value is! Map) { throw const FormatException( 'Form elicitation requestedSchema properties must be schema objects.', ); } _validatePrimitiveSchema( - (entry.value as Map).cast(), + readJsonObject( + entry.value, + 'ElicitRequest.requestedSchema.properties.${entry.key}', + ), 'ElicitRequest.requestedSchema.properties.${entry.key}', protocolVersion: protocolVersion, ); @@ -676,7 +694,7 @@ void _validateMultiSelectEnumSchema( if (items is! Map) { throw FormatException('$context.items is required for array schemas.'); } - final itemMap = items.cast(); + final itemMap = readJsonObject(items, '$context.items'); if (itemMap.containsKey('enum')) { _ensureAllowedKeys(itemMap, const {'type', 'enum'}, '$context.items'); if (itemMap['type'] != 'string') { @@ -768,13 +786,20 @@ Map? _parseElicitResultContent(Object? content) { if (content == null) { return null; } - if (content is! Map) { - throw const FormatException('ElicitResult.content must be an object.'); - } - final result = content.cast(); + final result = readJsonObject(content, 'ElicitResult.content'); return _normalizeElicitResultContent(result, formatException: true); } +Map _readRequiredParamsObject( + Map json, + String field, +) { + if (!json.containsKey('params')) { + throw FormatException('$field is required'); + } + return readJsonObject(json['params'], field); +} + Map? _normalizeElicitResultContent( Map? content, { bool formatException = false, diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index d1c3e0e8..a78d5010 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1601,6 +1601,35 @@ void main() { }), throwsA(isA()), ); + for (final parse in [ + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': 'bad', + }), + () => ElicitRequest.fromJson({ + 'message': 'Bad schema', + 'requestedSchema': 'bad', + }), + () => ElicitResult.fromJson({ + 'action': 'accept', + 'elicitationId': 1, + }), + () => ElicitationCompleteNotification.fromJson({ + 'elicitationId': 1, + }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsElicitationComplete, + 'params': null, + }), + () => URLElicitationRequiredErrorData.fromJson({ + 'elicitations': [1], + }), + ]) { + expect(parse, throwsA(isA())); + } }); test('initialization and capability wire fields reject bad shapes', () { diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index d14f1476..53fd46fa 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -431,6 +431,53 @@ void main() { ); }); + test('rejects malformed elicitation wire shapes', () { + for (final parse in [ + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': 'bad', + }), + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': null, + }), + () => ElicitRequest.fromJson({ + 'message': 'Bad properties', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 1: {'type': 'string'}, + }, + }, + }), + () => ElicitResult.fromJson({ + 'action': 'accept', + 'url': 1, + }), + () => ElicitResult.fromJson({ + 'action': 'accept', + 'content': {1: 'bad'}, + }), + () => ElicitationCompleteNotification.fromJson({ + 'elicitationId': 1, + }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsElicitationComplete, + 'params': 'bad', + }), + () => URLElicitationRequiredErrorData.fromJson({ + 'elicitations': [1], + }), + ]) { + expect(parse, throwsFormatException); + } + }); + test('rejects non-finite JSON numbers', () { expect( () => ProgressNotification.fromJson({ diff --git a/test/types_test.dart b/test/types_test.dart index 00a75934..416974a7 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -1987,6 +1987,67 @@ void main() { expect(result.elicitationId, equals('elicitation-1')); expect(result.toJson(), equals({'action': 'accept'})); }); + + test('elicitation parsers reject malformed wire fields', () { + for (final parse in [ + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': 'bad', + }), + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': null, + }), + () => ElicitRequest.fromJson({ + 'message': 'Bad schema', + 'requestedSchema': 'bad', + }), + () => ElicitRequest.fromJson({ + 'message': 'Bad properties', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 1: {'type': 'string'}, + }, + }, + }), + () => ElicitResult.fromJson({ + 'action': 'accept', + 'url': 1, + }), + () => ElicitResult.fromJson({ + 'action': 'accept', + 'content': {1: 'bad'}, + }), + () => ElicitationCompleteNotification.fromJson({ + 'elicitationId': 1, + }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsElicitationComplete, + 'params': 'bad', + }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsElicitationComplete, + 'params': null, + }), + () => URLElicitationRequiredErrorData.fromJson({ + 'elicitations': [1], + }), + () => URLElicitationRequiredErrorData.fromJson({ + 'elicitations': [ + {1: 'bad'}, + ], + }), + ]) { + expect(parse, throwsA(isA())); + } + }); }); group('ClientElicitation Tests', () { From f3412b789b450ced7a606496f5ca4dead3218ba7 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 10:13:20 -0400 Subject: [PATCH 10/42] Validate subscription wire fields --- CHANGELOG.md | 2 + lib/src/types/subscriptions.dart | 62 +++++++++++++++--------------- test/mcp_2026_07_28_test.dart | 42 ++++++++++++++++++++ test/types/subscriptions_test.dart | 53 +++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb937cd9..f289dc60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,8 @@ parse errors. - Rejected malformed elicitation request, result, completion, and URL-required error wire fields with protocol parse errors. +- Rejected malformed subscription listen and acknowledgment wire fields with + protocol parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/subscriptions.dart b/lib/src/types/subscriptions.dart index bb539824..16393a04 100644 --- a/lib/src/types/subscriptions.dart +++ b/lib/src/types/subscriptions.dart @@ -141,17 +141,13 @@ class SubscriptionsListenRequest { const SubscriptionsListenRequest({required this.notifications}); factory SubscriptionsListenRequest.fromJson(Map json) { - final notifications = json['notifications']; - if (notifications is! Map) { - throw const FormatException( - 'SubscriptionsListenRequest.notifications is required', - ); - } + final notifications = _readRequiredJsonObject( + json['notifications'], + 'SubscriptionsListenRequest.notifications', + ); return SubscriptionsListenRequest( - notifications: SubscriptionFilter.fromJson( - notifications.cast(), - ), + notifications: SubscriptionFilter.fromJson(notifications), ); } @@ -177,12 +173,10 @@ class JsonRpcSubscriptionsListenRequest extends JsonRpcRequest { factory JsonRpcSubscriptionsListenRequest.fromJson( Map json, ) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException( - 'Missing params for subscriptions/listen request', - ); - } + final paramsMap = _readRequiredParamsObject( + json, + 'JsonRpcSubscriptionsListenRequest.params', + ); return JsonRpcSubscriptionsListenRequest( id: parseRequestId(json['id']), @@ -202,17 +196,13 @@ class SubscriptionsAcknowledgedNotification { factory SubscriptionsAcknowledgedNotification.fromJson( Map json, ) { - final notifications = json['notifications']; - if (notifications is! Map) { - throw const FormatException( - 'SubscriptionsAcknowledgedNotification.notifications is required', - ); - } + final notifications = _readRequiredJsonObject( + json['notifications'], + 'SubscriptionsAcknowledgedNotification.notifications', + ); return SubscriptionsAcknowledgedNotification( - notifications: SubscriptionFilter.fromJson( - notifications.cast(), - ), + notifications: SubscriptionFilter.fromJson(notifications), ); } @@ -237,12 +227,10 @@ class JsonRpcSubscriptionsAcknowledgedNotification extends JsonRpcNotification { factory JsonRpcSubscriptionsAcknowledgedNotification.fromJson( Map json, ) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException( - 'Missing params for subscriptions acknowledged notification', - ); - } + final paramsMap = _readRequiredParamsObject( + json, + 'JsonRpcSubscriptionsAcknowledgedNotification.params', + ); return JsonRpcSubscriptionsAcknowledgedNotification( acknowledgedParams: @@ -255,6 +243,20 @@ class JsonRpcSubscriptionsAcknowledgedNotification extends JsonRpcNotification { } } +Map _readRequiredJsonObject(Object? value, String field) { + return readJsonObject(value, field); +} + +Map _readRequiredParamsObject( + Map json, + String field, +) { + if (!json.containsKey('params')) { + throw FormatException('$field is required'); + } + return _readRequiredJsonObject(json['params'], field); +} + bool? _readOptionalBool(Object? value, String field) { if (value == null) { return null; diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 53fd46fa..bcd005c7 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1455,6 +1455,48 @@ void main() { } }); + test('rejects malformed subscription wire shapes', () { + for (final parse in [ + () => JsonRpcSubscriptionsListenRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.subscriptionsListen, + 'params': 'bad', + }), + () => JsonRpcSubscriptionsListenRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.subscriptionsListen, + 'params': null, + }), + () => SubscriptionsListenRequest.fromJson({ + 'notifications': 'bad', + }), + () => SubscriptionsListenRequest.fromJson({ + 'notifications': { + 1: true, + }, + }), + () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsSubscriptionsAcknowledged, + 'params': 'bad', + }), + () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsSubscriptionsAcknowledged, + 'params': null, + }), + () => SubscriptionsAcknowledgedNotification.fromJson({ + 'notifications': { + 1: true, + }, + }), + ]) { + expect(parse, throwsFormatException); + } + }); + test('server acknowledges subscriptions/listen with subscription id', () async { final server = Server( diff --git a/test/types/subscriptions_test.dart b/test/types/subscriptions_test.dart index a6560a64..1004bbca 100644 --- a/test/types/subscriptions_test.dart +++ b/test/types/subscriptions_test.dart @@ -215,6 +215,33 @@ void main() { throwsFormatException, ); }); + + test('rejects malformed listen wire fields', () { + for (final parse in [ + () => JsonRpcSubscriptionsListenRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.subscriptionsListen, + 'params': 'bad', + }), + () => JsonRpcSubscriptionsListenRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.subscriptionsListen, + 'params': null, + }), + () => SubscriptionsListenRequest.fromJson({ + 'notifications': 'bad', + }), + () => SubscriptionsListenRequest.fromJson({ + 'notifications': { + 1: true, + }, + }), + ]) { + expect(parse, throwsFormatException); + } + }); }); group('JsonRpcSubscriptionsAcknowledgedNotification', () { @@ -294,6 +321,32 @@ void main() { }), throwsFormatException, ); + expect( + () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson( + const { + 'method': Method.notificationsSubscriptionsAcknowledged, + 'params': 'bad', + }, + ), + throwsFormatException, + ); + expect( + () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson( + const { + 'method': Method.notificationsSubscriptionsAcknowledged, + 'params': null, + }, + ), + throwsFormatException, + ); + expect( + () => SubscriptionsAcknowledgedNotification.fromJson({ + 'notifications': { + 1: true, + }, + }), + throwsFormatException, + ); }); }); From c6b3f03d85e052ad0657d573763b67b8bf449fbd Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 10:24:17 -0400 Subject: [PATCH 11/42] Validate sampling tool wire fields --- CHANGELOG.md | 2 ++ lib/src/types/sampling.dart | 30 +++++++++++++++++------ test/mcp_2025_11_25_test.dart | 26 ++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 20 ++++++++++++++++ test/types/sampling_test.dart | 45 +++++++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f289dc60..6cc2fbad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,6 +130,8 @@ error wire fields with protocol parse errors. - Rejected malformed subscription listen and acknowledgment wire fields with protocol parse errors. +- Rejected malformed sampling tool-list, tool-choice, and tool-result content + wire fields with protocol parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index 976980da..c4336d6f 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -114,7 +114,7 @@ ToolChoice? _parseToolChoice(dynamic value) { } if (value is Map) { - return ToolChoice.fromJson(value.cast()); + return ToolChoice.fromJson(readJsonObject(value, 'toolChoice')); } throw FormatException( @@ -132,7 +132,7 @@ Map? _toolChoiceToLegacyMap(dynamic value) { } if (value is Map) { - return value.cast(); + return readJsonObject(value, 'toolChoice'); } if (value is ToolChoice) { @@ -164,7 +164,9 @@ List _parseToolResultContent(dynamic rawContent) { } if (item is Map) { - return Content.fromJson(item.cast()); + return Content.fromJson( + readJsonObject(item, 'SamplingToolResultContent.content[]'), + ); } return TextContent(text: item.toString()); @@ -172,7 +174,7 @@ List _parseToolResultContent(dynamic rawContent) { } if (rawContent is Map) { - final map = rawContent.cast(); + final map = readJsonObject(rawContent, 'SamplingToolResultContent.content'); if (map.containsKey('type')) { return [Content.fromJson(map)]; } @@ -193,6 +195,22 @@ List _parseToolResultWireContent(dynamic rawContent) { .toList(); } +List? _parseSamplingTools(Object? value) { + if (value == null) { + return null; + } + if (value is! List) { + throw const FormatException('CreateMessageRequest.tools must be a list'); + } + return value.indexed + .map( + (entry) => Tool.fromJson( + _asJsonObject(entry.$2, 'CreateMessageRequest.tools[${entry.$1}]'), + ), + ) + .toList(); +} + /// Hints for model selection during sampling. class ModelHint { /// Hint for a model name. @@ -736,9 +754,7 @@ class CreateMessageRequest { : ModelPreferences.fromJson( _asJsonObject(json['modelPreferences']), ), - tools: (json['tools'] as List?) - ?.map((t) => Tool.fromJson(_asJsonObject(t))) - .toList(), + tools: _parseSamplingTools(json['tools']), toolChoice: toolChoice, ); } diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index a78d5010..0e12e3c3 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1845,6 +1845,32 @@ void main() { }), throwsA(isA()), ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 100, + 'tools': 'bad', + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 100, + 'tools': [1], + }), + throwsA(isA()), + ); expect( () => ModelHint.fromJson({'name': 1}), throwsA(isA()), diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index bcd005c7..02904d84 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -994,6 +994,26 @@ void main() { 'values': ['a'], 'hasMore': 'true', }), + () => CreateMessageRequestParams.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 100, + 'tools': 'bad', + }), + () => CreateMessageRequestParams.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 100, + 'tools': [1], + }), () => LoggingMessageNotification.fromJson({ 'level': 'info', 'data': Object(), diff --git a/test/types/sampling_test.dart b/test/types/sampling_test.dart index 5d74e8a1..a215665c 100644 --- a/test/types/sampling_test.dart +++ b/test/types/sampling_test.dart @@ -525,6 +525,13 @@ void main() { }), throwsA(isA()), ); + expect( + () => const SamplingToolResultContent( + toolUseId: 'tr1', + content: {1: 'bad'}, + ).toJson(), + throwsA(isA()), + ); }); }); }); @@ -736,6 +743,19 @@ void main() { }), throwsA(isA()), ); + expect( + () => const CreateMessageRequestParams( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Hello'), + ), + ], + maxTokens: 100, + toolChoice: {1: 'required'}, + ).toJson(), + throwsA(isA()), + ); }); test('validates string wire fields', () { @@ -771,6 +791,31 @@ void main() { ); }); + test('validates tool wire fields', () { + final messages = [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ]; + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'tools': 'bad', + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'tools': [1], + }), + throwsA(isA()), + ); + }); + test('accepts whole-number JSON maxTokens values', () { final messages = [ { From 74d3ff7a2a21991c3e13c83abc7b562bc0c1aaa2 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 10:35:36 -0400 Subject: [PATCH 12/42] Validate content block discriminators --- CHANGELOG.md | 2 ++ lib/src/types/content.dart | 22 ++++++++++++++++++++-- test/mcp_2025_11_25_test.dart | 28 ++++++++++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 28 ++++++++++++++++++++++++++++ test/types_test.dart | 28 ++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cc2fbad..51099109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,8 @@ protocol parse errors. - Rejected malformed sampling tool-list, tool-choice, and tool-result content wire fields with protocol parse errors. +- Rejected missing, unknown, and mismatched content block type discriminators + with protocol parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/content.dart b/lib/src/types/content.dart index ebe9ebca..b7ae3df2 100644 --- a/lib/src/types/content.dart +++ b/lib/src/types/content.dart @@ -25,6 +25,17 @@ String _readRequiredString(Object? value, String field) { return readRequiredString(value, field); } +void _expectType( + Map json, + String expected, + String field, +) { + final value = _readRequiredString(json['type'], field); + if (value != expected) { + throw FormatException('$field must be "$expected"'); + } +} + bool _isAbsoluteUri(String value) { return Uri.tryParse(value)?.hasScheme ?? false; } @@ -374,14 +385,16 @@ sealed class Content { }); factory Content.fromJson(Map json) { - final type = readOptionalString(json['type'], 'Content.type'); + final type = readRequiredString(json['type'], 'Content.type'); return switch (type) { 'text' => TextContent.fromJson(json), 'image' => ImageContent.fromJson(json), 'audio' => AudioContent.fromJson(json), 'resource_link' => ResourceLink.fromJson(json), 'resource' => EmbeddedResource.fromJson(json), - _ => UnknownContent(type: type ?? 'unknown'), + _ => throw const FormatException( + 'Content.type must be a known content type', + ), }; } @@ -454,6 +467,7 @@ class TextContent extends Content { }) : super(type: 'text'); factory TextContent.fromJson(Map json) { + _expectType(json, 'text', 'TextContent.type'); return TextContent( text: readRequiredString(json['text'], 'TextContent.text'), annotations: json['annotations'] == null @@ -496,6 +510,7 @@ class ImageContent extends Content { }) : super(type: 'image'); factory ImageContent.fromJson(Map json) { + _expectType(json, 'image', 'ImageContent.type'); return ImageContent( data: readRequiredBase64String(json['data'], 'ImageContent.data'), mimeType: readRequiredString(json['mimeType'], 'ImageContent.mimeType'), @@ -531,6 +546,7 @@ class AudioContent extends Content { }) : super(type: 'audio'); factory AudioContent.fromJson(Map json) { + _expectType(json, 'audio', 'AudioContent.type'); return AudioContent( data: readRequiredBase64String(json['data'], 'AudioContent.data'), mimeType: readRequiredString(json['mimeType'], 'AudioContent.mimeType'), @@ -562,6 +578,7 @@ class EmbeddedResource extends Content { }) : super(type: 'resource'); factory EmbeddedResource.fromJson(Map json) { + _expectType(json, 'resource', 'EmbeddedResource.type'); return EmbeddedResource( resource: ResourceContents.fromJson( _asJsonObject(json['resource'], 'EmbeddedResource.resource'), @@ -625,6 +642,7 @@ class ResourceLink extends Content { }) : super(type: 'resource_link'); factory ResourceLink.fromJson(Map json) { + _expectType(json, 'resource_link', 'ResourceLink.type'); return ResourceLink( uri: readRequiredAbsoluteUriString(json['uri'], 'ResourceLink.uri'), name: readRequiredString(json['name'], 'ResourceLink.name'), diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 0e12e3c3..baa3412f 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1921,6 +1921,26 @@ void main() { }), throwsA(isA()), ); + expect( + () => Content.fromJson({ + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => Content.fromJson({ + 'type': 'unknown', + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => TextContent.fromJson({ + 'type': 'image', + 'text': 'Hello', + }), + throwsA(isA()), + ); expect( () => TextContent.fromJson({ 'type': 'text', @@ -1943,6 +1963,14 @@ void main() { }), throwsA(isA()), ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + }), + throwsA(isA()), + ); expect( () => Resource.fromJson({ 'uri': 'file:///docs/readme.md', diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 02904d84..ced04d2a 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -626,6 +626,26 @@ void main() { }), throwsA(isA()), ); + expect( + () => Content.fromJson({ + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => Content.fromJson({ + 'type': 'unknown', + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => TextContent.fromJson({ + 'type': 'image', + 'text': 'Hello', + }), + throwsA(isA()), + ); expect( () => TextContent.fromJson({ 'type': 'text', @@ -648,6 +668,14 @@ void main() { }), throwsA(isA()), ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + }), + throwsA(isA()), + ); expect( () => Resource.fromJson({ 'uri': 'file:///docs/readme.md', diff --git a/test/types_test.dart b/test/types_test.dart index 416974a7..28b22064 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -863,6 +863,26 @@ void main() { }), throwsA(isA()), ); + expect( + () => Content.fromJson({ + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => Content.fromJson({ + 'type': 'unknown', + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => TextContent.fromJson({ + 'type': 'image', + 'text': 'Hello', + }), + throwsA(isA()), + ); expect( () => TextContent.fromJson({ 'type': 'text', @@ -918,6 +938,14 @@ void main() { }), throwsA(isA()), ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + }), + throwsA(isA()), + ); expect( () => ResourceLink.fromJson({ 'type': 'resource_link', From 5a37ecf60cc833612ec494b3769909e877712dc4 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 10:44:03 -0400 Subject: [PATCH 13/42] Validate resource content union --- CHANGELOG.md | 2 ++ lib/src/types/content.dart | 18 ++++++++---- test/mcp_2025_11_25_test.dart | 6 ++++ test/mcp_2026_07_28_test.dart | 6 ++++ test/types/resources_test.dart | 50 ++++++++++++++++++---------------- test/types_test.dart | 12 ++++++++ 6 files changed, 64 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51099109..82fda9b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -134,6 +134,8 @@ wire fields with protocol parse errors. - Rejected missing, unknown, and mismatched content block type discriminators with protocol parse errors. +- Rejected resource content items that omit both `text` and `blob`, matching + the spec-defined `TextResourceContents | BlobResourceContents` union. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/content.dart b/lib/src/types/content.dart index b7ae3df2..ba828516 100644 --- a/lib/src/types/content.dart +++ b/lib/src/types/content.dart @@ -249,11 +249,8 @@ sealed class ResourceContents { extra: passthrough, ); } - return UnknownResourceContents( - uri: uri, - mimeType: mimeType, - meta: meta, - extra: passthrough, + throw const FormatException( + 'ResourceContents must include text or blob', ); } @@ -266,7 +263,11 @@ sealed class ResourceContents { final BlobResourceContents c => { 'blob': _base64ForJson(c.blob, 'BlobResourceContents.blob'), }, - UnknownResourceContents _ => {}, + UnknownResourceContents _ => throw ArgumentError.value( + this, + 'ResourceContents', + 'must include text or blob', + ), }, if (meta != null) '_meta': readJsonObject(meta, 'ResourceContents._meta'), @@ -303,6 +304,11 @@ class BlobResourceContents extends ResourceContents { } /// Represents unknown or passthrough resource content types. +/// +/// Stable MCP and MCP 2026 wire results require either text or blob content. +/// This class is retained for source compatibility, but serialization rejects +/// it because no current protocol result shape references bare +/// `ResourceContents`. class UnknownResourceContents extends ResourceContents { const UnknownResourceContents({ required super.uri, diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index baa3412f..0e64588b 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1955,6 +1955,12 @@ void main() { }), throwsA(isA()), ); + expect( + () => ResourceContents.fromJson({ + 'uri': 'file:///docs/readme.md', + }), + throwsA(isA()), + ); expect( () => ResourceLink.fromJson({ 'type': 'resource_link', diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index ced04d2a..a5f0d4dc 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -660,6 +660,12 @@ void main() { }), throwsA(isA()), ); + expect( + () => ResourceContents.fromJson({ + 'uri': 'file:///docs/readme.md', + }), + throwsA(isA()), + ); expect( () => ResourceLink.fromJson({ 'type': 'resource_link', diff --git a/test/types/resources_test.dart b/test/types/resources_test.dart index aaf13335..8936f9b8 100644 --- a/test/types/resources_test.dart +++ b/test/types/resources_test.dart @@ -790,33 +790,35 @@ void main() { expect(roundTripped['customField']['enabled'], isTrue); }); - test('unknown resource content preserves passthrough fields', () { - final result = ReadResourceResult.fromJson({ - 'contents': [ - { - 'uri': 'ui://weather/raw', - 'mimeType': 'application/vnd.custom+json', - '_meta': { - 'ui': { - 'prefersBorder': true, + test('resource contents require text or blob', () { + expect( + () => ReadResourceResult.fromJson({ + 'contents': [ + { + 'uri': 'ui://weather/raw', + 'mimeType': 'application/vnd.custom+json', + '_meta': { + 'ui': { + 'prefersBorder': true, + }, + }, + 'payload': { + 'kind': 'custom', }, }, - 'payload': { - 'kind': 'custom', - }, + ], + }), + throwsA(isA()), + ); + expect( + () => const UnknownResourceContents( + uri: 'ui://weather/raw', + extra: { + 'payload': {'kind': 'custom'}, }, - ], - }); - - final content = result.contents.single; - expect(content, isA()); - expect(content.meta!['ui']['prefersBorder'], isTrue); - expect(content.extra!['payload']['kind'], equals('custom')); - - final json = result.toJson(); - final roundTripped = (json['contents'] as List).single; - expect(roundTripped['payload']['kind'], equals('custom')); - expect(roundTripped['_meta']['ui']['prefersBorder'], isTrue); + ).toJson(), + throwsA(isA()), + ); }); test('fromJson rejects malformed content items', () { diff --git a/test/types_test.dart b/test/types_test.dart index 28b22064..3172a7aa 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -1456,6 +1456,12 @@ void main() { }); test('ResourceContents rejects non-JSON metadata and passthrough maps', () { + expect( + () => ResourceContents.fromJson({ + 'uri': 'file://example.txt', + }), + throwsA(isA()), + ); expect( () => ResourceContents.fromJson({ 'uri': 'file://example.txt', @@ -1488,6 +1494,12 @@ void main() { ).toJson(), throwsA(isA()), ); + expect( + () => const UnknownResourceContents( + uri: 'file://example.txt', + ).toJson(), + throwsA(isA()), + ); }); }); From 85974faa48d497d7dd879d3202f32ded881775d2 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 10:53:57 -0400 Subject: [PATCH 14/42] Validate sampling content discriminators --- CHANGELOG.md | 2 + lib/src/types/sampling.dart | 117 ++++++++++++++++++++-------------- test/mcp_2025_11_25_test.dart | 48 ++++++++++++++ test/mcp_2026_07_28_test.dart | 48 ++++++++++++++ test/types/sampling_test.dart | 75 ++++++++++++++++++++++ 5 files changed, 241 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82fda9b1..59b16cb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -134,6 +134,8 @@ wire fields with protocol parse errors. - Rejected missing, unknown, and mismatched content block type discriminators with protocol parse errors. +- Rejected missing and mismatched sampling content type discriminators with + protocol parse errors. - Rejected resource content items that omit both `text` and `blob`, matching the spec-defined `TextResourceContents | BlobResourceContents` union. - Rejected non-finite numeric values for progress, annotation priority, model diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index c4336d6f..d3477cfa 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -39,6 +39,17 @@ Map _annotationsForJson( return result; } +void _expectType( + Map json, + String expected, + String field, +) { + final value = readRequiredString(json['type'], field); + if (value != expected) { + throw FormatException('$field must be "$expected"'); + } +} + Object _parseSamplingMessageContent(dynamic value) { if (value is List) { return value @@ -402,15 +413,17 @@ class SamplingTextContent extends SamplingContent { this.meta, }) : super(type: 'text'); - factory SamplingTextContent.fromJson(Map json) => - SamplingTextContent( - text: readRequiredString(json['text'], 'SamplingTextContent.text'), - annotations: readOptionalAnnotationsObject( - json['annotations'], - 'SamplingTextContent.annotations', - ), - meta: _asJsonObjectOrNull(json['_meta'], 'SamplingTextContent._meta'), - ); + factory SamplingTextContent.fromJson(Map json) { + _expectType(json, 'text', 'SamplingTextContent.type'); + return SamplingTextContent( + text: readRequiredString(json['text'], 'SamplingTextContent.text'), + annotations: readOptionalAnnotationsObject( + json['annotations'], + 'SamplingTextContent.annotations', + ), + meta: _asJsonObjectOrNull(json['_meta'], 'SamplingTextContent._meta'), + ); + } } /// Image content for sampling messages. @@ -434,22 +447,24 @@ class SamplingImageContent extends SamplingContent { this.meta, }) : super(type: 'image'); - factory SamplingImageContent.fromJson(Map json) => - SamplingImageContent( - data: readRequiredBase64String( - json['data'], - 'SamplingImageContent.data', - ), - mimeType: readRequiredString( - json['mimeType'], - 'SamplingImageContent.mimeType', - ), - annotations: readOptionalAnnotationsObject( - json['annotations'], - 'SamplingImageContent.annotations', - ), - meta: _asJsonObjectOrNull(json['_meta'], 'SamplingImageContent._meta'), - ); + factory SamplingImageContent.fromJson(Map json) { + _expectType(json, 'image', 'SamplingImageContent.type'); + return SamplingImageContent( + data: readRequiredBase64String( + json['data'], + 'SamplingImageContent.data', + ), + mimeType: readRequiredString( + json['mimeType'], + 'SamplingImageContent.mimeType', + ), + annotations: readOptionalAnnotationsObject( + json['annotations'], + 'SamplingImageContent.annotations', + ), + meta: _asJsonObjectOrNull(json['_meta'], 'SamplingImageContent._meta'), + ); + } } /// Audio content for sampling messages. @@ -473,22 +488,24 @@ class SamplingAudioContent extends SamplingContent { this.meta, }) : super(type: 'audio'); - factory SamplingAudioContent.fromJson(Map json) => - SamplingAudioContent( - data: readRequiredBase64String( - json['data'], - 'SamplingAudioContent.data', - ), - mimeType: readRequiredString( - json['mimeType'], - 'SamplingAudioContent.mimeType', - ), - annotations: readOptionalAnnotationsObject( - json['annotations'], - 'SamplingAudioContent.annotations', - ), - meta: _asJsonObjectOrNull(json['_meta'], 'SamplingAudioContent._meta'), - ); + factory SamplingAudioContent.fromJson(Map json) { + _expectType(json, 'audio', 'SamplingAudioContent.type'); + return SamplingAudioContent( + data: readRequiredBase64String( + json['data'], + 'SamplingAudioContent.data', + ), + mimeType: readRequiredString( + json['mimeType'], + 'SamplingAudioContent.mimeType', + ), + annotations: readOptionalAnnotationsObject( + json['annotations'], + 'SamplingAudioContent.annotations', + ), + meta: _asJsonObjectOrNull(json['_meta'], 'SamplingAudioContent._meta'), + ); + } } /// Tool use content for sampling messages. @@ -505,14 +522,15 @@ class SamplingToolUseContent extends SamplingContent { this.meta, }) : super(type: 'tool_use'); - factory SamplingToolUseContent.fromJson(Map json) => - SamplingToolUseContent( - id: readRequiredString(json['id'], 'SamplingToolUseContent.id'), - name: readRequiredString(json['name'], 'SamplingToolUseContent.name'), - input: _asJsonObject(json['input'], 'SamplingToolUseContent.input'), - meta: - _asJsonObjectOrNull(json['_meta'], 'SamplingToolUseContent._meta'), - ); + factory SamplingToolUseContent.fromJson(Map json) { + _expectType(json, 'tool_use', 'SamplingToolUseContent.type'); + return SamplingToolUseContent( + id: readRequiredString(json['id'], 'SamplingToolUseContent.id'), + name: readRequiredString(json['name'], 'SamplingToolUseContent.name'), + input: _asJsonObject(json['input'], 'SamplingToolUseContent.input'), + meta: _asJsonObjectOrNull(json['_meta'], 'SamplingToolUseContent._meta'), + ); + } } /// Tool result content for sampling messages. @@ -543,6 +561,7 @@ class SamplingToolResultContent extends SamplingContent { dynamic get legacyContent => content; factory SamplingToolResultContent.fromJson(Map json) { + _expectType(json, 'tool_result', 'SamplingToolResultContent.type'); return SamplingToolResultContent( toolUseId: readRequiredString( json['toolUseId'], diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 0e64588b..7f406a13 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1882,6 +1882,54 @@ void main() { }), throwsA(isA()), ); + expect( + () => SamplingTextContent.fromJson({ + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => SamplingTextContent.fromJson({ + 'type': 'image', + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => SamplingImageContent.fromJson({ + 'type': 'text', + 'data': 'aW1nZGF0YQ==', + 'mimeType': 'image/png', + }), + throwsA(isA()), + ); + expect( + () => SamplingAudioContent.fromJson({ + 'type': 'image', + 'data': 'YXVkaW8tZGF0YQ==', + 'mimeType': 'audio/wav', + }), + throwsA(isA()), + ); + expect( + () => SamplingToolUseContent.fromJson({ + 'type': 'tool_result', + 'id': 'call-1', + 'name': 'search', + 'input': {}, + }), + throwsA(isA()), + ); + expect( + () => SamplingToolResultContent.fromJson({ + 'type': 'tool_use', + 'toolUseId': 'call-1', + 'content': [ + {'type': 'text', 'text': 'Hello'}, + ], + }), + throwsA(isA()), + ); expect( () => SamplingToolResultContent.fromJson({ 'type': 'tool_result', diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index a5f0d4dc..e15d4691 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -587,6 +587,54 @@ void main() { }), throwsA(isA()), ); + expect( + () => SamplingTextContent.fromJson({ + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => SamplingTextContent.fromJson({ + 'type': 'image', + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => SamplingImageContent.fromJson({ + 'type': 'text', + 'data': 'aW1nZGF0YQ==', + 'mimeType': 'image/png', + }), + throwsA(isA()), + ); + expect( + () => SamplingAudioContent.fromJson({ + 'type': 'image', + 'data': 'YXVkaW8tZGF0YQ==', + 'mimeType': 'audio/wav', + }), + throwsA(isA()), + ); + expect( + () => SamplingToolUseContent.fromJson({ + 'type': 'tool_result', + 'id': 'call-1', + 'name': 'search', + 'input': {}, + }), + throwsA(isA()), + ); + expect( + () => SamplingToolResultContent.fromJson({ + 'type': 'tool_use', + 'toolUseId': 'call-1', + 'content': [ + {'type': 'text', 'text': 'Hello'}, + ], + }), + throwsA(isA()), + ); expect( () => SamplingToolResultContent.fromJson({ 'type': 'tool_result', diff --git a/test/types/sampling_test.dart b/test/types/sampling_test.dart index a215665c..c84042d4 100644 --- a/test/types/sampling_test.dart +++ b/test/types/sampling_test.dart @@ -181,6 +181,19 @@ void main() { }), throwsA(isA()), ); + expect( + () => SamplingTextContent.fromJson({ + 'text': 'Parsed text', + }), + throwsA(isA()), + ); + expect( + () => SamplingTextContent.fromJson({ + 'type': 'image', + 'text': 'Parsed text', + }), + throwsA(isA()), + ); }); }); @@ -248,6 +261,21 @@ void main() { }), throwsA(isA()), ); + expect( + () => SamplingImageContent.fromJson({ + 'data': 'aW1nZGF0YQ==', + 'mimeType': 'image/png', + }), + throwsA(isA()), + ); + expect( + () => SamplingImageContent.fromJson({ + 'type': 'text', + 'data': 'aW1nZGF0YQ==', + 'mimeType': 'image/png', + }), + throwsA(isA()), + ); }); }); @@ -319,6 +347,21 @@ void main() { }), throwsA(isA()), ); + expect( + () => SamplingAudioContent.fromJson({ + 'data': 'YXVkaW8tZGF0YQ==', + 'mimeType': 'audio/wav', + }), + throwsA(isA()), + ); + expect( + () => SamplingAudioContent.fromJson({ + 'type': 'image', + 'data': 'YXVkaW8tZGF0YQ==', + 'mimeType': 'audio/wav', + }), + throwsA(isA()), + ); }); }); @@ -399,6 +442,23 @@ void main() { }), throwsA(isA()), ); + expect( + () => SamplingToolUseContent.fromJson({ + 'id': 'tu1', + 'name': 'fetch', + 'input': {'url': 'http://test.com'}, + }), + throwsA(isA()), + ); + expect( + () => SamplingToolUseContent.fromJson({ + 'type': 'tool_result', + 'id': 'tu1', + 'name': 'fetch', + 'input': {'url': 'http://test.com'}, + }), + throwsA(isA()), + ); }); }); @@ -525,6 +585,21 @@ void main() { }), throwsA(isA()), ); + expect( + () => SamplingToolResultContent.fromJson({ + 'toolUseId': 'tr1', + 'content': content, + }), + throwsA(isA()), + ); + expect( + () => SamplingToolResultContent.fromJson({ + 'type': 'tool_use', + 'toolUseId': 'tr1', + 'content': content, + }), + throwsA(isA()), + ); expect( () => const SamplingToolResultContent( toolUseId: 'tr1', From 980822e7176b63772e6babf74281ba4daef83f78 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 11:03:50 -0400 Subject: [PATCH 15/42] Validate completion reference discriminators --- CHANGELOG.md | 2 ++ lib/src/types/completion.dart | 13 +++++++++++++ test/mcp_2025_11_25_test.dart | 26 ++++++++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 14 ++++++++++++++ test/types_test.dart | 10 ++++++++++ 5 files changed, 65 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59b16cb4..9a4be810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,8 @@ parse errors. - Rejected malformed prompt, completion, logging, and common notification wire fields with protocol parse errors. +- Rejected missing and mismatched completion reference type discriminators with + protocol parse errors. - Rejected malformed tool definition, tool-list, and tool-call wire fields with protocol parse errors. - Rejected malformed root-list wire fields with protocol parse errors. diff --git a/lib/src/types/completion.dart b/lib/src/types/completion.dart index 48d4f7d4..46796338 100644 --- a/lib/src/types/completion.dart +++ b/lib/src/types/completion.dart @@ -1,6 +1,17 @@ import 'json_rpc.dart'; import 'validation.dart'; +void _expectType( + Map json, + String expected, + String field, +) { + final value = readRequiredString(json['type'], field); + if (value != expected) { + throw FormatException('$field must be "$expected"'); + } +} + /// Sealed class representing a reference for autocompletion targets. sealed class Reference { /// The type of reference ("ref/resource" or "ref/prompt"). @@ -46,6 +57,7 @@ class ResourceReference extends Reference { const ResourceReference({required this.uri}) : super(type: 'ref/resource'); factory ResourceReference.fromJson(Map json) { + _expectType(json, 'ref/resource', 'ResourceReference.type'); return ResourceReference( uri: readRequiredUriTemplateString(json['uri'], 'ResourceReference.uri'), ); @@ -65,6 +77,7 @@ class PromptReference extends Reference { }) : super(type: 'ref/prompt'); factory PromptReference.fromJson(Map json) { + _expectType(json, 'ref/prompt', 'PromptReference.type'); return PromptReference( name: readRequiredString(json['name'], 'PromptReference.name'), title: readOptionalString(json['title'], 'PromptReference.title'), diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 7f406a13..56400a5a 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1804,6 +1804,32 @@ void main() { }), throwsA(isA()), ); + expect( + () => ResourceReference.fromJson({ + 'uri': 'file:///{path}', + }), + throwsA(isA()), + ); + expect( + () => ResourceReference.fromJson({ + 'type': 'ref/prompt', + 'uri': 'file:///{path}', + }), + throwsA(isA()), + ); + expect( + () => PromptReference.fromJson({ + 'name': 'prompt', + }), + throwsA(isA()), + ); + expect( + () => PromptReference.fromJson({ + 'type': 'ref/resource', + 'name': 'prompt', + }), + throwsA(isA()), + ); expect( () => CompletionResultData.fromJson({ 'values': ['a'], diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index e15d4691..52afa61a 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1072,6 +1072,20 @@ void main() { 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, 'argument': {'name': 'arg', 'value': 1}, }), + () => ResourceReference.fromJson({ + 'uri': 'file:///{path}', + }), + () => ResourceReference.fromJson({ + 'type': 'ref/prompt', + 'uri': 'file:///{path}', + }), + () => PromptReference.fromJson({ + 'name': 'prompt', + }), + () => PromptReference.fromJson({ + 'type': 'ref/resource', + 'name': 'prompt', + }), () => CompletionResultData.fromJson({ 'values': ['a'], 'hasMore': 'true', diff --git a/test/types_test.dart b/test/types_test.dart index 3172a7aa..d240ab90 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -1633,6 +1633,16 @@ void main() { for (final parse in [ () => Reference.fromJson({'type': 1}), + () => ResourceReference.fromJson({'uri': 'file:///{path}'}), + () => ResourceReference.fromJson({ + 'type': 'ref/prompt', + 'uri': 'file:///{path}', + }), + () => PromptReference.fromJson({'name': 'prompt'}), + () => PromptReference.fromJson({ + 'type': 'ref/resource', + 'name': 'prompt', + }), () => PromptReference.fromJson({'name': 1}), () => ArgumentCompletionInfo.fromJson({'name': 1, 'value': 'v'}), () => ArgumentCompletionInfo.fromJson({'name': 'arg', 'value': 1}), From 61ea48637bf7fb649155a472235be4de9b73ab64 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 11:16:33 -0400 Subject: [PATCH 16/42] Validate completion request wrapper constants --- CHANGELOG.md | 2 ++ lib/src/server/mcp_server.dart | 2 ++ lib/src/types/completion.dart | 20 ++++++++++++++++++++ test/mcp_2025_11_25_test.dart | 24 ++++++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 18 ++++++++++++++++++ test/types_test.dart | 18 ++++++++++++++++++ 6 files changed, 84 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a4be810..d5a577e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,8 @@ fields with protocol parse errors. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. +- Rejected malformed completion JSON-RPC wrapper constants with protocol parse + errors. - Rejected malformed tool definition, tool-list, and tool-call wire fields with protocol parse errors. - Rejected malformed root-list wire fields with protocol parse errors. diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index 58773c1d..9b8dc3fe 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -1666,7 +1666,9 @@ class McpServer { ), }, (id, params, meta) => JsonRpcCompleteRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.completionComplete, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/lib/src/types/completion.dart b/lib/src/types/completion.dart index 46796338..8b5d971b 100644 --- a/lib/src/types/completion.dart +++ b/lib/src/types/completion.dart @@ -1,6 +1,21 @@ import 'json_rpc.dart'; import 'validation.dart'; +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + void _expectType( Map json, String expected, @@ -186,6 +201,11 @@ class JsonRpcCompleteRequest extends JsonRpcRequest { ); factory JsonRpcCompleteRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.completionComplete, + 'JsonRpcCompleteRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcCompleteRequest.params', diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 56400a5a..9203bb00 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1804,6 +1804,30 @@ void main() { }), throwsA(isA()), ); + expect( + () => JsonRpcCompleteRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 'complete', + 'method': Method.completionComplete, + 'params': { + 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, + 'argument': {'name': 'arg', 'value': 'prefix'}, + }, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcCompleteRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'complete', + 'method': Method.promptsGet, + 'params': { + 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, + 'argument': {'name': 'arg', 'value': 'prefix'}, + }, + }), + throwsA(isA()), + ); expect( () => ResourceReference.fromJson({ 'uri': 'file:///{path}', diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 52afa61a..c785b5aa 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1072,6 +1072,24 @@ void main() { 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, 'argument': {'name': 'arg', 'value': 1}, }), + () => JsonRpcCompleteRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 'complete', + 'method': Method.completionComplete, + 'params': { + 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, + 'argument': {'name': 'arg', 'value': 'prefix'}, + }, + }), + () => JsonRpcCompleteRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'complete', + 'method': Method.promptsGet, + 'params': { + 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, + 'argument': {'name': 'arg', 'value': 'prefix'}, + }, + }), () => ResourceReference.fromJson({ 'uri': 'file:///{path}', }), diff --git a/test/types_test.dart b/test/types_test.dart index d240ab90..197cf8bd 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -1662,6 +1662,24 @@ void main() { 'argument': validArgument, 'context': 'bad', }), + () => JsonRpcCompleteRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.completionComplete, + 'params': { + 'ref': validRef, + 'argument': validArgument, + }, + }), + () => JsonRpcCompleteRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.promptsGet, + 'params': { + 'ref': validRef, + 'argument': validArgument, + }, + }), () => JsonRpcCompleteRequest.fromJson({ 'jsonrpc': jsonRpcVersion, 'id': 1, From 3c6f9762d3a00e9a139218a5d587a156f2afdc6b Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 11:28:24 -0400 Subject: [PATCH 17/42] Validate prompt request wrapper constants --- CHANGELOG.md | 2 ++ lib/src/server/mcp_server.dart | 4 ++++ lib/src/types/prompts.dart | 35 ++++++++++++++++++++++++++++++++-- test/mcp_2025_11_25_test.dart | 24 +++++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 19 ++++++++++++++++++ test/types_test.dart | 30 +++++++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5a577e1..b775e386 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,8 @@ parse errors. - Rejected malformed prompt, completion, logging, and common notification wire fields with protocol parse errors. +- Rejected malformed prompt JSON-RPC wrapper constants with protocol parse + errors. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index 9b8dc3fe..ba7530ad 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -1858,7 +1858,9 @@ class McpServer { .toList(), ), (id, params, meta) => JsonRpcListPromptsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.promptsList, 'params': params, if (meta != null) '_meta': meta, }), @@ -1906,7 +1908,9 @@ class McpServer { } }, (id, params, meta) => JsonRpcGetPromptRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.promptsGet, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/lib/src/types/prompts.dart b/lib/src/types/prompts.dart index bfadf3ed..040c376d 100644 --- a/lib/src/types/prompts.dart +++ b/lib/src/types/prompts.dart @@ -2,6 +2,21 @@ import '../types.dart'; import 'json_rpc.dart'; import 'validation.dart'; +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + List? _readOptionalObjectList( Object? value, String field, @@ -159,6 +174,11 @@ class JsonRpcListPromptsRequest extends JsonRpcRequest { super(method: Method.promptsList, params: params?.toJson()); factory JsonRpcListPromptsRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.promptsList, + 'JsonRpcListPromptsRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcListPromptsRequest.params', @@ -302,6 +322,11 @@ class JsonRpcGetPromptRequest extends JsonRpcRequest { }) : super(method: Method.promptsGet, params: getParams.toJson()); factory JsonRpcGetPromptRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.promptsGet, + 'JsonRpcGetPromptRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcGetPromptRequest.params', @@ -403,8 +428,14 @@ class JsonRpcPromptListChangedNotification extends JsonRpcNotification { factory JsonRpcPromptListChangedNotification.fromJson( Map json, - ) => - JsonRpcPromptListChangedNotification(meta: extractRequestMeta(json)); + ) { + _expectJsonRpcMethod( + json, + Method.notificationsPromptsListChanged, + 'JsonRpcPromptListChangedNotification', + ); + return JsonRpcPromptListChangedNotification(meta: extractRequestMeta(json)); + } } /// Deprecated alias for [ListPromptsRequest]. diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 9203bb00..c13be0e7 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1797,6 +1797,30 @@ void main() { }), throwsA(isA()), ); + expect( + () => JsonRpcListPromptsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'prompts', + 'method': Method.resourcesList, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcGetPromptRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 'prompt', + 'method': Method.promptsGet, + 'params': {'name': 'prompt'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcPromptListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsResourcesListChanged, + }), + throwsA(isA()), + ); expect( () => CompleteRequest.fromJson({ 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index c785b5aa..f9fd8d07 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1068,6 +1068,21 @@ void main() { 'name': 'prompt', 'arguments': {'arg': 1}, }), + () => JsonRpcListPromptsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'prompts', + 'method': Method.resourcesList, + }), + () => JsonRpcGetPromptRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 'prompt', + 'method': Method.promptsGet, + 'params': {'name': 'prompt'}, + }), + () => JsonRpcPromptListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsResourcesListChanged, + }), () => CompleteRequest.fromJson({ 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, 'argument': {'name': 'arg', 'value': 1}, @@ -1837,7 +1852,9 @@ void main() { Method.promptsList, (request, extra) async => const ListPromptsResult(prompts: []), (id, params, meta) => JsonRpcListPromptsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.promptsList, 'params': params, if (meta != null) '_meta': meta, }), @@ -2643,7 +2660,9 @@ void main() { (request, extra) async => const InputRequiredResult(requestState: 'list-state'), (id, params, meta) => JsonRpcListPromptsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.promptsList, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/test/types_test.dart b/test/types_test.dart index 197cf8bd..8182efb9 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -1570,6 +1570,16 @@ void main() { 'icons': [1], }), () => ListPromptsRequest.fromJson({'cursor': 1}), + () => JsonRpcListPromptsRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.promptsList, + }), + () => JsonRpcListPromptsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.resourcesList, + }), () => ListPromptsResult.fromJson({ 'prompts': [1], }), @@ -1586,12 +1596,32 @@ void main() { 'name': 'prompt', 'arguments': {'arg': 1}, }), + () => JsonRpcGetPromptRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.promptsGet, + 'params': {'name': 'prompt'}, + }), + () => JsonRpcGetPromptRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.resourcesRead, + 'params': {'name': 'prompt'}, + }), () => JsonRpcGetPromptRequest.fromJson({ 'jsonrpc': jsonRpcVersion, 'id': 1, 'method': Method.promptsGet, 'params': 'bad', }), + () => JsonRpcPromptListChangedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsPromptsListChanged, + }), + () => JsonRpcPromptListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsResourcesListChanged, + }), () => PromptMessage.fromJson({ 'role': 'user', 'content': 'bad', From 4a3a59a60e9f64b0a00e5d852d3830b267432817 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 11:41:52 -0400 Subject: [PATCH 18/42] Validate resource request wrapper constants --- CHANGELOG.md | 2 + .../lib/services/streamable_mcp_service.dart | 2 + .../client_streamable_https.dart | 2 + lib/src/server/mcp_server.dart | 6 + lib/src/types/resources.dart | 58 ++++++- test/mcp_2025_11_25_test.dart | 58 +++++++ test/mcp_2026_07_28_test.dart | 31 ++++ test/types/resources_test.dart | 152 ++++++++++++++++++ 8 files changed, 309 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b775e386..f1ae0e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,8 @@ fields with protocol parse errors. - Rejected malformed prompt JSON-RPC wrapper constants with protocol parse errors. +- Rejected malformed resource JSON-RPC wrapper constants with protocol parse + errors. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/example/flutter_http_client/lib/services/streamable_mcp_service.dart b/example/flutter_http_client/lib/services/streamable_mcp_service.dart index 1923f79e..6b2c3519 100644 --- a/example/flutter_http_client/lib/services/streamable_mcp_service.dart +++ b/example/flutter_http_client/lib/services/streamable_mcp_service.dart @@ -173,6 +173,8 @@ class StreamableMcpService extends ChangeNotifier { return Future.value(); }, (params, meta) => JsonRpcResourceListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsResourcesListChanged, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/example/streamable_https/client_streamable_https.dart b/example/streamable_https/client_streamable_https.dart index dd28208c..07327655 100644 --- a/example/streamable_https/client_streamable_https.dart +++ b/example/streamable_https/client_streamable_https.dart @@ -250,6 +250,8 @@ Future connect([String? url]) async { return Future.value(); }, (params, meta) => JsonRpcResourceListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsResourcesListChanged, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index ba7530ad..9bff2216 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -1773,7 +1773,9 @@ class McpServer { return ListResourcesResult(resources: [...fixed, ...templates]); }, (id, params, meta) => JsonRpcListResourcesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.resourcesList, 'params': params, if (meta != null) '_meta': meta, }), @@ -1788,7 +1790,9 @@ class McpServer { .toList(), ), (id, params, meta) => JsonRpcListResourceTemplatesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.resourcesTemplatesList, 'params': params, if (meta != null) '_meta': meta, }), @@ -1831,7 +1835,9 @@ class McpServer { ); }, (id, params, meta) => JsonRpcReadResourceRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.resourcesRead, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/lib/src/types/resources.dart b/lib/src/types/resources.dart index 53860fe6..b26cb171 100644 --- a/lib/src/types/resources.dart +++ b/lib/src/types/resources.dart @@ -22,6 +22,22 @@ List? _readOptionalIconList( ]; } +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + /// Additional properties describing a Resource to clients. class ResourceAnnotations { /// A human-readable title for the resource. @@ -306,6 +322,11 @@ class JsonRpcListResourcesRequest extends JsonRpcRequest { /// Creates from JSON. factory JsonRpcListResourcesRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.resourcesList, + 'JsonRpcListResourcesRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcListResourcesRequest.params', @@ -431,6 +452,11 @@ class JsonRpcListResourceTemplatesRequest extends JsonRpcRequest { factory JsonRpcListResourceTemplatesRequest.fromJson( Map json, ) { + _expectJsonRpcMethod( + json, + Method.resourcesTemplatesList, + 'JsonRpcListResourceTemplatesRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcListResourceTemplatesRequest.params', @@ -582,6 +608,11 @@ class JsonRpcReadResourceRequest extends JsonRpcRequest { }) : super(method: Method.resourcesRead, params: readParams.toJson()); factory JsonRpcReadResourceRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.resourcesRead, + 'JsonRpcReadResourceRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcReadResourceRequest.params', @@ -668,8 +699,16 @@ class JsonRpcResourceListChangedNotification extends JsonRpcNotification { factory JsonRpcResourceListChangedNotification.fromJson( Map json, - ) => - JsonRpcResourceListChangedNotification(meta: extractRequestMeta(json)); + ) { + _expectJsonRpcMethod( + json, + Method.notificationsResourcesListChanged, + 'JsonRpcResourceListChangedNotification', + ); + return JsonRpcResourceListChangedNotification( + meta: extractRequestMeta(json), + ); + } } /// Parameters for the `resources/subscribe` request. @@ -702,6 +741,11 @@ class JsonRpcSubscribeRequest extends JsonRpcRequest { }) : super(method: Method.resourcesSubscribe, params: subParams.toJson()); factory JsonRpcSubscribeRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.resourcesSubscribe, + 'JsonRpcSubscribeRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcSubscribeRequest.params', @@ -751,6 +795,11 @@ class JsonRpcUnsubscribeRequest extends JsonRpcRequest { }) : super(method: Method.resourcesUnsubscribe, params: unsubParams.toJson()); factory JsonRpcUnsubscribeRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.resourcesUnsubscribe, + 'JsonRpcUnsubscribeRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcUnsubscribeRequest.params', @@ -804,6 +853,11 @@ class JsonRpcResourceUpdatedNotification extends JsonRpcNotification { factory JsonRpcResourceUpdatedNotification.fromJson( Map json, ) { + _expectJsonRpcMethod( + json, + Method.notificationsResourcesUpdated, + 'JsonRpcResourceUpdatedNotification', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcResourceUpdatedNotification.params', diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index c13be0e7..2e6507fd 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1821,6 +1821,64 @@ void main() { }), throwsA(isA()), ); + expect( + () => JsonRpcListResourcesRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 'resources', + 'method': Method.resourcesList, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcListResourceTemplatesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'templates', + 'method': Method.resourcesList, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcReadResourceRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'read', + 'method': Method.resourcesList, + 'params': {'uri': 'file:///a.txt'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcSubscribeRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 'subscribe', + 'method': Method.resourcesSubscribe, + 'params': {'uri': 'file:///a.txt'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcUnsubscribeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'unsubscribe', + 'method': Method.resourcesSubscribe, + 'params': {'uri': 'file:///a.txt'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcResourceListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsResourcesUpdated, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcResourceUpdatedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsResourcesUpdated, + 'params': {'uri': 'file:///a.txt'}, + }), + throwsA(isA()), + ); expect( () => CompleteRequest.fromJson({ 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index f9fd8d07..92e4bb82 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1083,6 +1083,31 @@ void main() { 'jsonrpc': jsonRpcVersion, 'method': Method.notificationsResourcesListChanged, }), + () => JsonRpcListResourcesRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 'resources', + 'method': Method.resourcesList, + }), + () => JsonRpcListResourceTemplatesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'templates', + 'method': Method.resourcesList, + }), + () => JsonRpcReadResourceRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'read', + 'method': Method.resourcesList, + 'params': {'uri': 'file:///a.txt'}, + }), + () => JsonRpcResourceListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsResourcesUpdated, + }), + () => JsonRpcResourceUpdatedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsResourcesUpdated, + 'params': {'uri': 'file:///a.txt'}, + }), () => CompleteRequest.fromJson({ 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, 'argument': {'name': 'arg', 'value': 1}, @@ -1863,7 +1888,9 @@ void main() { Method.resourcesList, (request, extra) async => const ListResourcesResult(resources: []), (id, params, meta) => JsonRpcListResourcesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.resourcesList, 'params': params, if (meta != null) '_meta': meta, }), @@ -1873,7 +1900,9 @@ void main() { (request, extra) async => const ListResourceTemplatesResult(resourceTemplates: []), (id, params, meta) => JsonRpcListResourceTemplatesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.resourcesTemplatesList, 'params': params, if (meta != null) '_meta': meta, }), @@ -1884,7 +1913,9 @@ void main() { contents: [TextResourceContents(uri: 'file:///a.txt', text: 'a')], ), (id, params, meta) => JsonRpcReadResourceRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.resourcesRead, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/test/types/resources_test.dart b/test/types/resources_test.dart index 8936f9b8..d6d6eb59 100644 --- a/test/types/resources_test.dart +++ b/test/types/resources_test.dart @@ -476,6 +476,7 @@ void main() { test('fromJson parses correctly', () { final json = { + 'jsonrpc': jsonRpcVersion, 'id': 3, 'method': 'resources/list', 'params': {'cursor': 'xyz'}, @@ -488,6 +489,7 @@ void main() { test('fromJson without params', () { final json = { + 'jsonrpc': jsonRpcVersion, 'id': 4, 'method': 'resources/list', }; @@ -500,6 +502,7 @@ void main() { test('fromJson rejects non-object params', () { expect( () => JsonRpcListResourcesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': 5, 'method': 'resources/list', 'params': 'bad', @@ -507,6 +510,25 @@ void main() { throwsA(isA()), ); }); + + test('fromJson rejects wrong wrapper constants', () { + expect( + () => JsonRpcListResourcesRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 5, + 'method': 'resources/list', + }), + throwsA(isA()), + ); + expect( + () => JsonRpcListResourcesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 5, + 'method': 'resources/read', + }), + throwsA(isA()), + ); + }); }); group('ListResourcesResult', () { @@ -604,6 +626,7 @@ void main() { test('fromJson parses correctly', () { final json = { + 'jsonrpc': jsonRpcVersion, 'id': 11, 'method': 'resources/templates/list', 'params': {'cursor': 'tmpl_page'}, @@ -617,6 +640,7 @@ void main() { test('fromJson rejects non-object params', () { expect( () => JsonRpcListResourceTemplatesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': 12, 'method': 'resources/templates/list', 'params': 'bad', @@ -624,6 +648,25 @@ void main() { throwsA(isA()), ); }); + + test('fromJson rejects wrong wrapper constants', () { + expect( + () => JsonRpcListResourceTemplatesRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 12, + 'method': 'resources/templates/list', + }), + throwsA(isA()), + ); + expect( + () => JsonRpcListResourceTemplatesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 12, + 'method': 'resources/list', + }), + throwsA(isA()), + ); + }); }); group('ListResourceTemplatesResult', () { @@ -702,6 +745,7 @@ void main() { test('fromJson parses correctly', () { final json = { + 'jsonrpc': jsonRpcVersion, 'id': 21, 'method': 'resources/read', 'params': {'uri': 'file:///parsed.txt'}, @@ -714,6 +758,7 @@ void main() { test('fromJson throws on missing params', () { final json = { + 'jsonrpc': jsonRpcVersion, 'id': 22, 'method': 'resources/read', }; @@ -723,6 +768,27 @@ void main() { throwsA(isA()), ); }); + + test('fromJson rejects wrong wrapper constants', () { + expect( + () => JsonRpcReadResourceRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 22, + 'method': 'resources/read', + 'params': {'uri': 'file:///parsed.txt'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcReadResourceRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 22, + 'method': 'resources/list', + 'params': {'uri': 'file:///parsed.txt'}, + }), + throwsA(isA()), + ); + }); }); group('ReadResourceResult', () { @@ -853,6 +919,7 @@ void main() { test('fromJson creates notification', () { final json = { + 'jsonrpc': jsonRpcVersion, 'method': 'notifications/resources/list_changed', }; @@ -863,6 +930,23 @@ void main() { equals('notifications/resources/list_changed'), ); }); + + test('fromJson rejects wrong wrapper constants', () { + expect( + () => JsonRpcResourceListChangedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': 'notifications/resources/list_changed', + }), + throwsA(isA()), + ); + expect( + () => JsonRpcResourceListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': 'notifications/resources/updated', + }), + throwsA(isA()), + ); + }); }); group('SubscribeRequest', () { @@ -890,6 +974,7 @@ void main() { test('fromJson parses correctly', () { final json = { + 'jsonrpc': jsonRpcVersion, 'id': 31, 'method': 'resources/subscribe', 'params': {'uri': 'file:///subscribed.txt'}, @@ -902,6 +987,7 @@ void main() { test('fromJson throws on missing params', () { final json = { + 'jsonrpc': jsonRpcVersion, 'id': 32, 'method': 'resources/subscribe', }; @@ -911,6 +997,27 @@ void main() { throwsA(isA()), ); }); + + test('fromJson rejects wrong wrapper constants', () { + expect( + () => JsonRpcSubscribeRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 32, + 'method': 'resources/subscribe', + 'params': {'uri': 'file:///subscribed.txt'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcSubscribeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 32, + 'method': 'resources/unsubscribe', + 'params': {'uri': 'file:///subscribed.txt'}, + }), + throwsA(isA()), + ); + }); }); group('UnsubscribeRequest', () { @@ -938,6 +1045,7 @@ void main() { test('fromJson parses correctly', () { final json = { + 'jsonrpc': jsonRpcVersion, 'id': 41, 'method': 'resources/unsubscribe', 'params': {'uri': 'file:///unsubscribed.txt'}, @@ -950,6 +1058,7 @@ void main() { test('fromJson throws on missing params', () { final json = { + 'jsonrpc': jsonRpcVersion, 'id': 42, 'method': 'resources/unsubscribe', }; @@ -959,6 +1068,27 @@ void main() { throwsA(isA()), ); }); + + test('fromJson rejects wrong wrapper constants', () { + expect( + () => JsonRpcUnsubscribeRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 42, + 'method': 'resources/unsubscribe', + 'params': {'uri': 'file:///unsubscribed.txt'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcUnsubscribeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 42, + 'method': 'resources/subscribe', + 'params': {'uri': 'file:///unsubscribed.txt'}, + }), + throwsA(isA()), + ); + }); }); group('ResourceUpdatedNotification', () { @@ -991,6 +1121,7 @@ void main() { test('fromJson parses correctly', () { final json = { + 'jsonrpc': jsonRpcVersion, 'method': 'notifications/resources/updated', 'params': {'uri': 'file:///parsed_notify.txt'}, }; @@ -1004,6 +1135,7 @@ void main() { test('fromJson throws on missing params', () { final json = { + 'jsonrpc': jsonRpcVersion, 'method': 'notifications/resources/updated', }; @@ -1015,6 +1147,7 @@ void main() { test('fromJson with meta', () { final json = { + 'jsonrpc': jsonRpcVersion, 'method': 'notifications/resources/updated', 'params': { 'uri': 'file:///with_meta.txt', @@ -1026,6 +1159,25 @@ void main() { expect(notification.meta, isNotNull); expect(notification.meta!['key'], equals('value')); }); + + test('fromJson rejects wrong wrapper constants', () { + expect( + () => JsonRpcResourceUpdatedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': 'notifications/resources/updated', + 'params': {'uri': 'file:///parsed_notify.txt'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcResourceUpdatedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': 'notifications/resources/list_changed', + 'params': {'uri': 'file:///parsed_notify.txt'}, + }), + throwsA(isA()), + ); + }); }); group('Resource URI format validation', () { From 8cc2d0c24f45708495a5785423740642672865c3 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 11:52:36 -0400 Subject: [PATCH 19/42] Validate tool request wrapper constants --- CHANGELOG.md | 2 ++ lib/src/server/mcp_server.dart | 4 +++ lib/src/types/json_rpc.dart | 18 ++++++++++++ lib/src/types/tools.dart | 26 +++++++++++++++-- test/mcp_2025_11_25_test.dart | 30 ++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 40 +++++++++++++++++++++++++++ test/server/server_advanced_test.dart | 8 +++++- test/types_test.dart | 30 ++++++++++++++++++++ 8 files changed, 155 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ae0e40..cafe4826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,8 @@ errors. - Rejected malformed resource JSON-RPC wrapper constants with protocol parse errors. +- Rejected malformed tool JSON-RPC wrapper constants with protocol parse + errors. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index 9bff2216..c8dccd8d 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -1494,7 +1494,9 @@ class McpServer { ); }, (id, params, meta) => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.toolsList, 'params': params, if (meta != null) '_meta': meta, }), @@ -1635,7 +1637,9 @@ class McpServer { } }, (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.toolsCall, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index 88bfc2f0..adc50e66 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -336,6 +336,22 @@ Map? extractRequestMeta(Map json) { return paramsMeta ?? topLevelMeta; } +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + /// Base class for all JSON-RPC messages (requests, notifications, responses, errors). sealed class JsonRpcMessage { /// The JSON-RPC version string. Always "2.0". @@ -1058,6 +1074,7 @@ class JsonRpcListToolsRequest extends JsonRpcRequest { } factory JsonRpcListToolsRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.toolsList, 'JsonRpcListToolsRequest'); return JsonRpcListToolsRequest( id: parseRequestId(json['id']), params: readOptionalJsonObject( @@ -1086,6 +1103,7 @@ class JsonRpcCallToolRequest extends JsonRpcRequest { }) : super(method: Method.toolsCall, params: params); factory JsonRpcCallToolRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.toolsCall, 'JsonRpcCallToolRequest'); return JsonRpcCallToolRequest( id: parseRequestId(json['id']), params: readOptionalJsonObject( diff --git a/lib/src/types/tools.dart b/lib/src/types/tools.dart index dd9b1fc1..486ae33e 100644 --- a/lib/src/types/tools.dart +++ b/lib/src/types/tools.dart @@ -15,6 +15,22 @@ typedef ToolInputSchema = JsonObject; /// [JsonSchema] directly when the output schema root is not an object. typedef ToolOutputSchema = JsonObject; +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + /// Additional properties describing a Tool to clients. /// /// NOTE: all properties in ToolAnnotations are **hints**. @@ -509,8 +525,14 @@ class JsonRpcToolListChangedNotification extends JsonRpcNotification { factory JsonRpcToolListChangedNotification.fromJson( Map json, - ) => - JsonRpcToolListChangedNotification(meta: extractRequestMeta(json)); + ) { + _expectJsonRpcMethod( + json, + Method.notificationsToolsListChanged, + 'JsonRpcToolListChangedNotification', + ); + return JsonRpcToolListChangedNotification(meta: extractRequestMeta(json)); + } } void _validateObjectRootSchema( diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 2e6507fd..805224bc 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -656,12 +656,42 @@ void main() { 'method': Method.toolsList, 'params': 'bad', }), + () => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.toolsList, + }), + () => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.promptsList, + }), () => JsonRpcCallToolRequest.fromJson({ 'jsonrpc': jsonRpcVersion, 'id': 1, 'method': Method.toolsCall, 'params': 'bad', }), + () => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.toolsCall, + 'params': {'name': 'tool'}, + }), + () => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.promptsGet, + 'params': {'name': 'tool'}, + }), + () => JsonRpcToolListChangedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsToolsListChanged, + }), + () => JsonRpcToolListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsPromptsListChanged, + }), ]) { expect(parse, throwsA(isA())); } diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 92e4bb82..5c6aecb6 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1530,12 +1530,42 @@ void main() { 'method': Method.toolsList, 'params': 'bad', }), + () => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.toolsList, + }), + () => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.promptsList, + }), () => JsonRpcCallToolRequest.fromJson({ 'jsonrpc': jsonRpcVersion, 'id': 1, 'method': Method.toolsCall, 'params': 'bad', }), + () => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.toolsCall, + 'params': {'name': 'tool'}, + }), + () => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.promptsGet, + 'params': {'name': 'tool'}, + }), + () => JsonRpcToolListChangedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsToolsListChanged, + }), + () => JsonRpcToolListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsPromptsListChanged, + }), ]) { expect(parse, throwsFormatException); } @@ -1868,7 +1898,9 @@ void main() { cacheScope: CacheScope.public, ), (id, params, meta) => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.toolsList, 'params': params, if (meta != null) '_meta': meta, }), @@ -2369,7 +2401,9 @@ void main() { ), ), (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.toolsCall, 'params': params, if (meta != null) '_meta': meta, }), @@ -2419,7 +2453,9 @@ void main() { ), ), (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.toolsCall, 'params': params, if (meta != null) '_meta': meta, }), @@ -2455,7 +2491,9 @@ void main() { (request, extra) async => const InputRequiredResult(requestState: 'retry-state'), (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.toolsCall, 'params': params, if (meta != null) '_meta': meta, }), @@ -2528,7 +2566,9 @@ void main() { ); }, (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.toolsCall, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/test/server/server_advanced_test.dart b/test/server/server_advanced_test.dart index c9050fc9..4c4fca16 100644 --- a/test/server/server_advanced_test.dart +++ b/test/server/server_advanced_test.dart @@ -185,7 +185,13 @@ void main() { return const EmptyResult(); }, (id, params, meta) => JsonRpcCallToolRequest.fromJson( - {'id': id, 'params': params, '_meta': meta}, + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + '_meta': meta, + }, ), ); diff --git a/test/types_test.dart b/test/types_test.dart index 8182efb9..7e643dcf 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -583,12 +583,42 @@ void main() { 'method': Method.toolsList, 'params': 'bad', }), + () => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.toolsList, + }), + () => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.promptsList, + }), () => JsonRpcCallToolRequest.fromJson({ 'jsonrpc': jsonRpcVersion, 'id': 1, 'method': Method.toolsCall, 'params': 'bad', }), + () => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.toolsCall, + 'params': {'name': 'tool'}, + }), + () => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.promptsGet, + 'params': {'name': 'tool'}, + }), + () => JsonRpcToolListChangedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsToolsListChanged, + }), + () => JsonRpcToolListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsPromptsListChanged, + }), ]) { expect(parse, throwsA(isA())); } From 8f28d5227e6ee47f0e4b90075f2b30f6595546c2 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 12:01:00 -0400 Subject: [PATCH 20/42] Validate root request wrapper constants --- CHANGELOG.md | 2 ++ lib/src/types/roots.dart | 22 ++++++++++++++++++++++ test/client/client_test.dart | 2 ++ test/mcp_2025_11_25_test.dart | 18 ++++++++++++++++++ test/mcp_2026_07_28_test.dart | 18 ++++++++++++++++++ test/types_test.dart | 18 ++++++++++++++++++ 6 files changed, 80 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cafe4826..0e2b012b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,8 @@ errors. - Rejected malformed tool JSON-RPC wrapper constants with protocol parse errors. +- Rejected malformed root JSON-RPC wrapper constants with protocol parse + errors. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/lib/src/types/roots.dart b/lib/src/types/roots.dart index 1895ed28..3911ffc6 100644 --- a/lib/src/types/roots.dart +++ b/lib/src/types/roots.dart @@ -19,6 +19,22 @@ void _validateRootUri(String uri) { } } +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + /// Represents a root directory or file the server can operate on. class Root { /// URI identifying the root (must start with `file://`). @@ -62,6 +78,7 @@ class JsonRpcListRootsRequest extends JsonRpcRequest { : super(method: Method.rootsList); factory JsonRpcListRootsRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.rootsList, 'JsonRpcListRootsRequest'); _readOptionalParamsObject(json, 'JsonRpcListRootsRequest.params'); return JsonRpcListRootsRequest( id: parseRequestId(json['id']), @@ -114,6 +131,11 @@ class JsonRpcRootsListChangedNotification extends JsonRpcNotification { factory JsonRpcRootsListChangedNotification.fromJson( Map json, ) { + _expectJsonRpcMethod( + json, + Method.notificationsRootsListChanged, + 'JsonRpcRootsListChangedNotification', + ); _readOptionalParamsObject( json, 'JsonRpcRootsListChangedNotification.params', diff --git a/test/client/client_test.dart b/test/client/client_test.dart index ef07d9d0..88ccb1b9 100644 --- a/test/client/client_test.dart +++ b/test/client/client_test.dart @@ -1093,7 +1093,9 @@ void _addCriticalPathTests() { 'roots/list', (request, extra) async => const ListRootsResult(roots: []), (id, params, meta) => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.rootsList, if (params != null) 'params': params, if (meta != null) '_meta': meta, }), diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 805224bc..71ecc66b 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -715,6 +715,16 @@ void main() { 'method': Method.rootsList, 'params': null, }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.rootsList, + }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsList, + }), () => JsonRpcRootsListChangedNotification.fromJson({ 'jsonrpc': jsonRpcVersion, 'method': Method.notificationsRootsListChanged, @@ -725,6 +735,14 @@ void main() { 'method': Method.notificationsRootsListChanged, 'params': null, }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsRootsListChanged, + }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsToolsListChanged, + }), ]) { expect(parse, throwsA(isA())); } diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 5c6aecb6..315f4cf0 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1589,6 +1589,16 @@ void main() { 'method': Method.rootsList, 'params': null, }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.rootsList, + }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsList, + }), () => JsonRpcRootsListChangedNotification.fromJson({ 'jsonrpc': jsonRpcVersion, 'method': Method.notificationsRootsListChanged, @@ -1599,6 +1609,14 @@ void main() { 'method': Method.notificationsRootsListChanged, 'params': null, }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsRootsListChanged, + }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsToolsListChanged, + }), ]) { expect(parse, throwsFormatException); } diff --git a/test/types_test.dart b/test/types_test.dart index 7e643dcf..e31711c9 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -400,6 +400,16 @@ void main() { 'method': Method.rootsList, 'params': null, }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.rootsList, + }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsList, + }), () => JsonRpcRootsListChangedNotification.fromJson({ 'jsonrpc': jsonRpcVersion, 'method': Method.notificationsRootsListChanged, @@ -410,6 +420,14 @@ void main() { 'method': Method.notificationsRootsListChanged, 'params': null, }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsRootsListChanged, + }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsToolsListChanged, + }), ]) { expect(parse, throwsA(isA())); } From dfc0fe35ff34a03ccf8080717ee00130d67652f2 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 12:12:41 -0400 Subject: [PATCH 21/42] Validate common request wrapper constants --- CHANGELOG.md | 2 + lib/src/server/server.dart | 2 + lib/src/shared/protocol.dart | 4 ++ lib/src/types/logging.dart | 26 ++++++++++++ lib/src/types/misc.dart | 35 +++++++++++++++ test/mcp_2025_11_25_test.dart | 49 +++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 20 +++++++++ test/types/logging_types_test.dart | 41 ++++++++++++++++++ test/types_edge_cases_test.dart | 68 ++++++++++++++++++++++++++++++ 9 files changed, 247 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e2b012b..f78fc13f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -129,6 +129,8 @@ errors. - Rejected malformed root JSON-RPC wrapper constants with protocol parse errors. +- Rejected malformed common notification and logging JSON-RPC wrapper + constants with protocol parse errors. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index d5a1f52d..0db353fb 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -130,7 +130,9 @@ class Server extends Protocol { return const EmptyResult(); }, (id, params, meta) => JsonRpcSetLevelRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.loggingSetLevel, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 58c08297..15cb561e 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -461,6 +461,8 @@ abstract class Protocol { controller?.abort(params.reason); }, (params, meta) => JsonRpcCancelledNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsCancelled, 'params': params, if (meta != null) '_meta': meta, }), @@ -470,6 +472,8 @@ abstract class Protocol { "notifications/progress", (notification) async => _onprogress(notification), (params, meta) => JsonRpcProgressNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsProgress, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/lib/src/types/logging.dart b/lib/src/types/logging.dart index 6553f1c9..5657b588 100644 --- a/lib/src/types/logging.dart +++ b/lib/src/types/logging.dart @@ -1,6 +1,22 @@ import 'json_rpc.dart'; import 'validation.dart'; +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + /// Severity levels for log messages (syslog levels). enum LoggingLevel { debug, @@ -44,6 +60,11 @@ class JsonRpcSetLevelRequest extends JsonRpcRequest { }) : super(method: Method.loggingSetLevel, params: setParams.toJson()); factory JsonRpcSetLevelRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.loggingSetLevel, + 'JsonRpcSetLevelRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcSetLevelRequest.params', @@ -111,6 +132,11 @@ class JsonRpcLoggingMessageNotification extends JsonRpcNotification { factory JsonRpcLoggingMessageNotification.fromJson( Map json, ) { + _expectJsonRpcMethod( + json, + Method.notificationsMessage, + 'JsonRpcLoggingMessageNotification', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcLoggingMessageNotification.params', diff --git a/lib/src/types/misc.dart b/lib/src/types/misc.dart index f386e6f3..c17f22a9 100644 --- a/lib/src/types/misc.dart +++ b/lib/src/types/misc.dart @@ -1,6 +1,29 @@ import 'json_rpc.dart'; import 'validation.dart'; +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + +void _readOptionalParamsObject(Map json, String field) { + if (!json.containsKey('params')) { + return; + } + readJsonObject(json['params'], field); +} + /// A response that indicates success but carries no specific data. class EmptyResult implements BaseResultData { @override @@ -54,6 +77,11 @@ class JsonRpcCancelledNotification extends JsonRpcNotification { ); factory JsonRpcCancelledNotification.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.notificationsCancelled, + 'JsonRpcCancelledNotification', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcCancelledNotification.params', @@ -78,6 +106,8 @@ class JsonRpcPingRequest extends JsonRpcRequest { : super(method: Method.ping); factory JsonRpcPingRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.ping, 'JsonRpcPingRequest'); + _readOptionalParamsObject(json, 'JsonRpcPingRequest.params'); return JsonRpcPingRequest( id: parseRequestId(json['id']), meta: extractRequestMeta(json), @@ -180,6 +210,11 @@ class JsonRpcProgressNotification extends JsonRpcNotification { /// Creates from JSON. factory JsonRpcProgressNotification.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.notificationsProgress, + 'JsonRpcProgressNotification', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcProgressNotification.params', diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 71ecc66b..d7ef0f67 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1827,6 +1827,55 @@ void main() { }), throwsA(isA()), ); + expect( + () => JsonRpcPingRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 'ping', + 'method': Method.ping, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcPingRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'ping', + 'method': Method.toolsList, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcSetLevelRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'log-level', + 'method': Method.toolsCall, + 'params': {'level': 'info'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcLoggingMessageNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsMessage, + 'params': {'level': 'info', 'data': 'message'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcCancelledNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsProgress, + 'params': {'requestId': 'request-1'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcProgressNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsProgress, + 'params': {'progressToken': 'progress-1', 'progress': 1}, + }), + throwsA(isA()), + ); expect( () => PromptArgument.fromJson({'name': 1}), throwsA(isA()), diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 315f4cf0..f8aee816 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1172,6 +1172,26 @@ void main() { 'level': 'info', 'data': Object(), }), + () => JsonRpcLoggingMessageNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsMessage, + 'params': {'level': 'info', 'data': 'message'}, + }), + () => JsonRpcLoggingMessageNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsProgress, + 'params': {'level': 'info', 'data': 'message'}, + }), + () => JsonRpcCancelledNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsProgress, + 'params': {'requestId': 'request-1'}, + }), + () => JsonRpcProgressNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsProgress, + 'params': {'progressToken': 'progress-1', 'progress': 1}, + }), () => ProgressNotification.fromJson({ 'progressToken': 'progress-1', 'progress': 1, diff --git a/test/types/logging_types_test.dart b/test/types/logging_types_test.dart index a4cfabe0..10943976 100644 --- a/test/types/logging_types_test.dart +++ b/test/types/logging_types_test.dart @@ -1,3 +1,4 @@ +import 'package:mcp_dart/src/types/json_rpc.dart'; import 'package:mcp_dart/src/types/logging.dart'; import 'package:test/test.dart'; @@ -124,6 +125,27 @@ void main() { ); }); + test('fromJson rejects wrong wrapper constants', () { + expect( + () => JsonRpcSetLevelRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': 'logging/setLevel', + 'params': {'level': 'info'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcSetLevelRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': 'notifications/message', + 'params': {'level': 'info'}, + }), + throwsA(isA()), + ); + }); + test('toJson serializes correctly', () { final request = JsonRpcSetLevelRequest( id: 5, @@ -319,6 +341,25 @@ void main() { ); }); + test('fromJson rejects wrong wrapper constants', () { + expect( + () => JsonRpcLoggingMessageNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': 'notifications/message', + 'params': {'level': 'info', 'data': 'message'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcLoggingMessageNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': 'logging/setLevel', + 'params': {'level': 'info', 'data': 'message'}, + }), + throwsA(isA()), + ); + }); + test('toJson serializes correctly', () { final notification = JsonRpcLoggingMessageNotification( logParams: const LoggingMessageNotificationParams( diff --git a/test/types_edge_cases_test.dart b/test/types_edge_cases_test.dart index 5333a5ba..7f3608b2 100644 --- a/test/types_edge_cases_test.dart +++ b/test/types_edge_cases_test.dart @@ -162,6 +162,25 @@ void main() { ); }); + test('rejects wrong wrapper constants', () { + expect( + () => JsonRpcCancelledNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': 'notifications/cancelled', + 'params': {'requestId': 1}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcCancelledNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': 'notifications/progress', + 'params': {'requestId': 1}, + }), + throwsA(isA()), + ); + }); + test('handles optional reason field correctly', () { // With reason final withReason = const CancelledNotificationParams( @@ -302,6 +321,36 @@ void main() { }); }); + group('JsonRpcPingRequest Edge Cases', () { + test('rejects wrong wrapper constants and malformed params', () { + expect( + () => JsonRpcPingRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.ping, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcPingRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsList, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcPingRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.ping, + 'params': null, + }), + throwsA(isA()), + ); + }); + }); + group('JsonRpcProgressNotification Edge Cases', () { test('throws FormatException when params is missing', () { final json = { @@ -322,6 +371,25 @@ void main() { ); }); + test('rejects wrong wrapper constants', () { + expect( + () => JsonRpcProgressNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': 'notifications/progress', + 'params': {'progressToken': 'token', 'progress': 1}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcProgressNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': 'notifications/cancelled', + 'params': {'progressToken': 'token', 'progress': 1}, + }), + throwsA(isA()), + ); + }); + test('handles progress with optional total field', () { // With total final withTotal = const Progress(progress: 50, total: 100); From 4602e75703dc47d7d59a68cc46ad3e8bfe4183d3 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 12:23:58 -0400 Subject: [PATCH 22/42] Validate initialization wrapper constants --- CHANGELOG.md | 2 + lib/src/server/server.dart | 6 ++- lib/src/types/initialization.dart | 45 ++++++++++++++++++++- test/mcp_2025_11_25_test.dart | 25 ++++++++++++ test/mcp_2026_07_28_test.dart | 17 ++++++++ test/types_edge_cases_test.dart | 66 +++++++++++++++++++++++++++++++ test/types_test.dart | 31 +++++++++++++++ 7 files changed, 189 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f78fc13f..128bc6be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,8 @@ errors. - Rejected malformed common notification and logging JSON-RPC wrapper constants with protocol parse errors. +- Rejected malformed initialization and `server/discover` JSON-RPC wrapper + constants with protocol parse errors. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 0db353fb..0554d273 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -104,7 +104,9 @@ class Server extends Protocol { Method.initialize, (request, extra) async => _oninitialize(request.initParams), (id, params, meta) => JsonRpcInitializeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.initialize, 'params': params, if (meta != null) '_meta': meta, }), @@ -117,7 +119,9 @@ class Server extends Protocol { _lifecycleState = _ServerLifecycleState.ready; }, (params, meta) => JsonRpcInitializedNotification.fromJson({ - 'params': params, + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + if (params != null) 'params': params, if (meta != null) '_meta': meta, }), ); diff --git a/lib/src/types/initialization.dart b/lib/src/types/initialization.dart index 4cde02af..f1bb65ba 100644 --- a/lib/src/types/initialization.dart +++ b/lib/src/types/initialization.dart @@ -27,6 +27,29 @@ bool _isAbsoluteUri(String value) { return Uri.tryParse(value)?.hasScheme ?? false; } +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + +void _readOptionalParamsObject(Map json, String field) { + if (!json.containsKey('params')) { + return; + } + readJsonObject(json['params'], field); +} + String? _readOptionalPresentUriString( Map json, String key, @@ -682,6 +705,7 @@ class JsonRpcInitializeRequest extends JsonRpcRequest { }) : super(method: Method.initialize, params: initParams.toJson()); factory JsonRpcInitializeRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.initialize, 'JsonRpcInitializeRequest'); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcInitializeRequest.params', @@ -706,6 +730,11 @@ class JsonRpcServerDiscoverRequest extends JsonRpcRequest { }) : super(method: Method.serverDiscover); factory JsonRpcServerDiscoverRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.serverDiscover, + 'JsonRpcServerDiscoverRequest', + ); final params = readJsonObject( json['params'], 'JsonRpcServerDiscoverRequest.params', @@ -1312,8 +1341,20 @@ class JsonRpcInitializedNotification extends JsonRpcNotification { const JsonRpcInitializedNotification({super.meta}) : super(method: Method.notificationsInitialized); - factory JsonRpcInitializedNotification.fromJson(Map json) => - JsonRpcInitializedNotification(meta: extractRequestMeta(json)); + factory JsonRpcInitializedNotification.fromJson( + Map json, + ) { + _expectJsonRpcMethod( + json, + Method.notificationsInitialized, + 'JsonRpcInitializedNotification', + ); + _readOptionalParamsObject( + json, + 'JsonRpcInitializedNotification.params', + ); + return JsonRpcInitializedNotification(meta: extractRequestMeta(json)); + } } /// Deprecated alias for [InitializeRequest]. diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index d7ef0f67..f0fc6bc6 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1705,6 +1705,31 @@ void main() { ...initializeRequest, 'clientInfo': 'bad', }), + () => JsonRpcInitializeRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.initialize, + 'params': initializeRequest, + }), + () => JsonRpcInitializeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.ping, + 'params': initializeRequest, + }), + () => JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsInitialized, + }), + () => JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsCancelled, + }), + () => JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + 'params': null, + }), () => InitializeResult.fromJson({ ...initializeResult, 'capabilities': 'bad', diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index f8aee816..f81ba66a 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -957,6 +957,23 @@ void main() { ); } + for (final parse in [ + () => JsonRpcServerDiscoverRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 'discover-1', + 'method': Method.serverDiscover, + 'params': {'_meta': _clientMeta()}, + }), + () => JsonRpcServerDiscoverRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'discover-1', + 'method': Method.initialize, + 'params': {'_meta': _clientMeta()}, + }), + ]) { + expect(parse, throwsFormatException); + } + final parsed = JsonRpcMessage.fromJson({ 'jsonrpc': jsonRpcVersion, 'id': 'discover-1', diff --git a/test/types_edge_cases_test.dart b/test/types_edge_cases_test.dart index 7f3608b2..7b5f0a9e 100644 --- a/test/types_edge_cases_test.dart +++ b/test/types_edge_cases_test.dart @@ -303,6 +303,33 @@ void main() { ); }); + test('rejects wrong wrapper constants', () { + final params = { + 'protocolVersion': latestProtocolVersion, + 'capabilities': {}, + 'clientInfo': {'name': 'test', 'version': '1.0'}, + }; + + expect( + () => JsonRpcInitializeRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.initialize, + 'params': params, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcInitializeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.ping, + 'params': params, + }), + throwsA(isA()), + ); + }); + test('handles meta field in initialize request', () { final json = { 'jsonrpc': '2.0', @@ -321,6 +348,45 @@ void main() { }); }); + group('JsonRpcInitializedNotification Edge Cases', () { + test('rejects wrong wrapper constants and malformed params', () { + expect( + () => JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsInitialized, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsCancelled, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + 'params': null, + }), + throwsA(isA()), + ); + }); + + test('handles metadata in params', () { + final notification = JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + 'params': { + '_meta': {'sessionId': 'abc123'}, + }, + }); + + expect(notification.meta, equals({'sessionId': 'abc123'})); + }); + }); + group('JsonRpcPingRequest Edge Cases', () { test('rejects wrong wrapper constants and malformed params', () { expect( diff --git a/test/types_test.dart b/test/types_test.dart index e31711c9..adf718b6 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -57,6 +57,37 @@ void main() { 'method': Method.initialize, 'params': 'bad', }), + () => JsonRpcInitializeRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.initialize, + 'params': params, + }), + () => JsonRpcInitializeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.ping, + 'params': params, + }), + () => JsonRpcInitializeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.initialize, + 'params': null, + }), + () => JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsInitialized, + }), + () => JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsCancelled, + }), + () => JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + 'params': null, + }), ]) { expect(parse, throwsA(isA())); } From b2dca451b4b3d61d19a5da6cf9cd7c45800835f1 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 12:35:03 -0400 Subject: [PATCH 23/42] Validate task wrapper constants --- CHANGELOG.md | 2 + lib/src/server/mcp_server.dart | 8 ++ lib/src/shared/protocol.dart | 6 ++ lib/src/types/tasks.dart | 31 ++++++++ test/mcp_2025_11_25_test.dart | 65 ++++++++++++++++ test/mcp_2026_07_28_test.dart | 108 +++++++++++++++++++++++++++ test/types/tasks_extension_test.dart | 46 ++++++++++++ test/types_test.dart | 65 ++++++++++++++++ 8 files changed, 331 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 128bc6be..fd4fe5ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,8 @@ constants with protocol parse errors. - Rejected malformed initialization and `server/discover` JSON-RPC wrapper constants with protocol parse errors. +- Rejected malformed task and task-extension JSON-RPC wrapper constants with + protocol parse errors. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index c8dccd8d..ceec896f 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -1385,7 +1385,9 @@ class McpServer { return await Future.value(_listTasksCallback!(extra)); }, (id, params, meta) => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksList, if (params != null) 'params': params, if (meta != null) '_meta': meta, }), @@ -1418,7 +1420,9 @@ class McpServer { return task; }, (id, params, meta) => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksCancel, 'params': params, if (meta != null) '_meta': meta, }), @@ -1432,7 +1436,9 @@ class McpServer { return await Future.value(_getTaskCallback!(taskId, extra)); }, (id, params, meta) => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksGet, 'params': params, if (meta != null) '_meta': meta, }), @@ -1450,7 +1456,9 @@ class McpServer { return _withRelatedTaskMeta(result, taskId); }, (id, params, meta) => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksResult, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 15cb561e..312957ed 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -579,7 +579,9 @@ abstract class Protocol { return task; }, (id, params, meta) => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksGet, 'params': params, if (meta != null) '_meta': meta, }), @@ -602,7 +604,9 @@ abstract class Protocol { } }, (id, params, meta) => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksList, if (params != null) 'params': params, if (meta != null) '_meta': meta, }), @@ -656,7 +660,9 @@ abstract class Protocol { } }, (id, params, meta) => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksCancel, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/lib/src/types/tasks.dart b/lib/src/types/tasks.dart index 8cd1471d..32082fd6 100644 --- a/lib/src/types/tasks.dart +++ b/lib/src/types/tasks.dart @@ -2,6 +2,22 @@ import '../types.dart'; import 'json_rpc.dart'; import 'validation.dart'; +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + /// The current state of a task execution. enum TaskStatus { working, @@ -213,6 +229,7 @@ class JsonRpcListTasksRequest extends JsonRpcRequest { super(method: Method.tasksList, params: params?.toJson()); factory JsonRpcListTasksRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.tasksList, 'JsonRpcListTasksRequest'); final paramsMap = _readOptionalParamsObject(json, 'JsonRpcListTasksRequest.params'); final meta = extractRequestMeta(json); @@ -293,6 +310,7 @@ class JsonRpcCancelTaskRequest extends JsonRpcRequest { }) : super(method: Method.tasksCancel, params: cancelParams.toJson()); factory JsonRpcCancelTaskRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.tasksCancel, 'JsonRpcCancelTaskRequest'); final paramsMap = _readRequiredParamsObject(json, 'JsonRpcCancelTaskRequest.params'); final meta = extractRequestMeta(json); @@ -330,6 +348,7 @@ class JsonRpcGetTaskRequest extends JsonRpcRequest { }) : super(method: Method.tasksGet, params: getParams.toJson()); factory JsonRpcGetTaskRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.tasksGet, 'JsonRpcGetTaskRequest'); final paramsMap = _readRequiredParamsObject(json, 'JsonRpcGetTaskRequest.params'); final meta = extractRequestMeta(json); @@ -368,6 +387,7 @@ class JsonRpcTaskResultRequest extends JsonRpcRequest { }) : super(method: Method.tasksResult, params: resultParams.toJson()); factory JsonRpcTaskResultRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.tasksResult, 'JsonRpcTaskResultRequest'); final paramsMap = _readRequiredParamsObject(json, 'JsonRpcTaskResultRequest.params'); final meta = extractRequestMeta(json); @@ -431,6 +451,7 @@ class JsonRpcUpdateTaskRequest extends JsonRpcRequest { }) : super(method: Method.tasksUpdate, params: updateParams.toJson()); factory JsonRpcUpdateTaskRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.tasksUpdate, 'JsonRpcUpdateTaskRequest'); final paramsMap = _readRequiredParamsObject(json, 'JsonRpcUpdateTaskRequest.params'); final meta = extractRequestMeta(json); @@ -854,6 +875,11 @@ class JsonRpcTaskStatusNotification extends JsonRpcNotification { ); factory JsonRpcTaskStatusNotification.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.notificationsTasksStatus, + 'JsonRpcTaskStatusNotification', + ); final paramsMap = _readRequiredParamsObject( json, 'JsonRpcTaskStatusNotification.params', @@ -878,6 +904,11 @@ class JsonRpcTaskNotification extends JsonRpcNotification { : super(method: Method.notificationsTasks, params: task.toJson()); factory JsonRpcTaskNotification.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.notificationsTasks, + 'JsonRpcTaskNotification', + ); final paramsMap = _readRequiredParamsObject(json, 'JsonRpcTaskNotification.params'); return JsonRpcTaskNotification( diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index f0fc6bc6..f716a174 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -920,6 +920,15 @@ void main() { }); test('task request and result wire fields reject malformed values', () { + final taskParams = {'taskId': 'task-1'}; + final taskStatusParams = { + 'taskId': 'task-1', + 'status': 'working', + 'ttl': null, + 'createdAt': '2025-11-25T00:00:00Z', + 'lastUpdatedAt': '2025-11-25T00:00:01Z', + }; + for (final parse in [ () => ListTasksRequest.fromJson({'cursor': 1}), () => JsonRpcListTasksRequest.fromJson({ @@ -934,6 +943,16 @@ void main() { 'method': Method.tasksList, 'params': null, }), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksList, + }), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + }), () => ListTasksResult.fromJson({ 'tasks': [1], }), @@ -948,6 +967,18 @@ void main() { 'method': Method.tasksCancel, 'params': 'bad', }), + () => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksCancel, + 'params': taskParams, + }), + () => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': taskParams, + }), () => GetTaskRequest.fromJson({'taskId': 1}), () => JsonRpcGetTaskRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -955,6 +986,18 @@ void main() { 'method': Method.tasksGet, 'params': 'bad', }), + () => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksGet, + 'params': taskParams, + }), + () => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksCancel, + 'params': taskParams, + }), () => TaskResultRequest.fromJson({'taskId': 1}), () => JsonRpcTaskResultRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -962,6 +1005,18 @@ void main() { 'method': Method.tasksResult, 'params': null, }), + () => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksResult, + 'params': taskParams, + }), + () => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': taskParams, + }), () => CreateTaskResult.fromJson({'task': 'bad'}), () => JsonRpcTaskStatusNotification.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -973,6 +1028,16 @@ void main() { 'method': Method.notificationsTasksStatus, 'params': null, }), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsTasksStatus, + 'params': taskStatusParams, + }), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasks, + 'params': taskStatusParams, + }), ]) { expect(parse, throwsA(isA())); } diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index f81ba66a..bfaf513f 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1660,6 +1660,26 @@ void main() { }); test('rejects malformed task wire shapes', () { + final taskParams = {'taskId': 'task-1'}; + final updateTaskParams = { + 'taskId': 'task-1', + 'inputResponses': {}, + }; + final taskStatusParams = { + 'taskId': 'task-1', + 'status': 'working', + 'ttl': null, + 'createdAt': '2026-07-28T00:00:00Z', + 'lastUpdatedAt': '2026-07-28T00:00:01Z', + }; + final taskExtensionParams = { + 'taskId': 'task-1', + 'status': 'working', + 'createdAt': '2026-07-28T00:00:00Z', + 'lastUpdatedAt': '2026-07-28T00:00:01Z', + 'ttlMs': null, + }; + for (final parse in [ () => ListTasksRequest.fromJson({'cursor': 1}), () => JsonRpcListTasksRequest.fromJson({ @@ -1668,6 +1688,16 @@ void main() { 'method': Method.tasksList, 'params': null, }), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksList, + }), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + }), () => ListTasksResult.fromJson({ 'tasks': [1], }), @@ -1678,6 +1708,18 @@ void main() { 'method': Method.tasksCancel, 'params': 'bad', }), + () => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksCancel, + 'params': taskParams, + }), + () => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': taskParams, + }), () => GetTaskRequest.fromJson({'taskId': 1}), () => JsonRpcGetTaskRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -1685,6 +1727,18 @@ void main() { 'method': Method.tasksGet, 'params': 'bad', }), + () => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksGet, + 'params': taskParams, + }), + () => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksCancel, + 'params': taskParams, + }), () => TaskResultRequest.fromJson({'taskId': 1}), () => JsonRpcTaskResultRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -1692,6 +1746,18 @@ void main() { 'method': Method.tasksResult, 'params': null, }), + () => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksResult, + 'params': taskParams, + }), + () => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': taskParams, + }), () => CreateTaskResult.fromJson({'task': 'bad'}), () => JsonRpcUpdateTaskRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -1699,16 +1765,48 @@ void main() { 'method': Method.tasksUpdate, 'params': 'bad', }), + () => JsonRpcUpdateTaskRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksUpdate, + 'params': updateTaskParams, + }), + () => JsonRpcUpdateTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': updateTaskParams, + }), () => JsonRpcTaskStatusNotification.fromJson({ 'jsonrpc': jsonRpcVersion, 'method': Method.notificationsTasksStatus, 'params': 'bad', }), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsTasksStatus, + 'params': taskStatusParams, + }), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasks, + 'params': taskStatusParams, + }), () => JsonRpcTaskNotification.fromJson({ 'jsonrpc': jsonRpcVersion, 'method': Method.notificationsTasks, 'params': null, }), + () => JsonRpcTaskNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsTasks, + 'params': taskExtensionParams, + }), + () => JsonRpcTaskNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasksStatus, + 'params': taskExtensionParams, + }), ]) { expect(parse, throwsFormatException); } @@ -2166,7 +2264,9 @@ void main() { ), ), (id, params, meta) => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksGet, 'params': params, if (meta != null) '_meta': meta, }), @@ -2175,7 +2275,9 @@ void main() { Method.tasksCancel, (request, extra) async => const TaskExtensionAcknowledgementResult(), (id, params, meta) => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksCancel, 'params': params, if (meta != null) '_meta': meta, }), @@ -2184,7 +2286,9 @@ void main() { Method.tasksUpdate, (request, extra) async => const EmptyResult(), (id, params, meta) => JsonRpcUpdateTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksUpdate, 'params': params, if (meta != null) '_meta': meta, }), @@ -2292,7 +2396,9 @@ void main() { lastUpdatedAt: '2026-07-28T00:01:00Z', ), (id, params, meta) => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksGet, 'params': params, if (meta != null) '_meta': meta, }), @@ -2895,7 +3001,9 @@ void main() { Method.tasksUpdate, (request, extra) async => const TaskExtensionAcknowledgementResult(), (id, params, meta) => JsonRpcUpdateTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksUpdate, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/test/types/tasks_extension_test.dart b/test/types/tasks_extension_test.dart index 801e76e3..cc5cc949 100644 --- a/test/types/tasks_extension_test.dart +++ b/test/types/tasks_extension_test.dart @@ -196,6 +196,18 @@ void main() { }); test('rejects malformed task extension payloads', () { + final updateParams = { + 'taskId': 'task-1', + 'inputResponses': {}, + }; + final taskParams = { + 'taskId': 'task-1', + 'status': 'working', + 'createdAt': '2026-07-28T00:00:00Z', + 'lastUpdatedAt': '2026-07-28T00:00:01Z', + 'ttlMs': null, + }; + expect( () => CreateTaskExtensionResult.fromJson( const { @@ -239,6 +251,24 @@ void main() { ), throwsFormatException, ); + expect( + () => JsonRpcUpdateTaskRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksUpdate, + 'params': updateParams, + }), + throwsFormatException, + ); + expect( + () => JsonRpcUpdateTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': updateParams, + }), + throwsFormatException, + ); expect( () => JsonRpcTaskNotification.fromJson( const { @@ -259,6 +289,22 @@ void main() { ), throwsFormatException, ); + expect( + () => JsonRpcTaskNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsTasks, + 'params': taskParams, + }), + throwsFormatException, + ); + expect( + () => JsonRpcTaskNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasksStatus, + 'params': taskParams, + }), + throwsFormatException, + ); }); }); } diff --git a/test/types_test.dart b/test/types_test.dart index adf718b6..24599473 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -467,6 +467,15 @@ void main() { group('Task wire parsing Tests', () { test('rejects malformed task request and result fields', () { + final taskParams = {'taskId': 'task-1'}; + final taskStatusParams = { + 'taskId': 'task-1', + 'status': 'working', + 'ttl': null, + 'createdAt': '2025-11-25T00:00:00Z', + 'lastUpdatedAt': '2025-11-25T00:00:01Z', + }; + for (final parse in [ () => ListTasksRequest.fromJson({'cursor': 1}), () => JsonRpcListTasksRequest.fromJson({ @@ -481,6 +490,16 @@ void main() { 'method': Method.tasksList, 'params': null, }), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksList, + }), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + }), () => ListTasksResult.fromJson({ 'tasks': [1], }), @@ -495,6 +514,18 @@ void main() { 'method': Method.tasksCancel, 'params': 'bad', }), + () => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksCancel, + 'params': taskParams, + }), + () => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': taskParams, + }), () => GetTaskRequest.fromJson({'taskId': 1}), () => JsonRpcGetTaskRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -502,6 +533,18 @@ void main() { 'method': Method.tasksGet, 'params': 'bad', }), + () => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksGet, + 'params': taskParams, + }), + () => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksCancel, + 'params': taskParams, + }), () => TaskResultRequest.fromJson({'taskId': 1}), () => JsonRpcTaskResultRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -509,6 +552,18 @@ void main() { 'method': Method.tasksResult, 'params': null, }), + () => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksResult, + 'params': taskParams, + }), + () => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': taskParams, + }), () => CreateTaskResult.fromJson({'task': 'bad'}), () => JsonRpcTaskStatusNotification.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -520,6 +575,16 @@ void main() { 'method': Method.notificationsTasksStatus, 'params': null, }), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsTasksStatus, + 'params': taskStatusParams, + }), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasks, + 'params': taskStatusParams, + }), ]) { expect(parse, throwsA(isA())); } From 7a7b5270d07cd3790fcd401be48b4f12ed60a7ab Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 12:49:12 -0400 Subject: [PATCH 24/42] Validate sampling and elicitation wrapper constants --- CHANGELOG.md | 2 + lib/src/types/elicitation.dart | 26 ++++++++++ lib/src/types/sampling.dart | 21 ++++++++ test/client/client_test.dart | 4 ++ test/elicitation_test.dart | 2 + test/mcp_2025_11_25_test.dart | 59 ++++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 90 ++++++++++++++++++++++++++++++++++ test/types/sampling_test.dart | 50 +++++++++++++++++++ test/types_test.dart | 33 +++++++++++++ 9 files changed, 287 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd4fe5ea..6103b7ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -135,6 +135,8 @@ constants with protocol parse errors. - Rejected malformed task and task-extension JSON-RPC wrapper constants with protocol parse errors. +- Rejected malformed sampling and elicitation JSON-RPC wrapper constants while + preserving embedded MRTR input request parsing. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/lib/src/types/elicitation.dart b/lib/src/types/elicitation.dart index e58cbf83..6499b826 100644 --- a/lib/src/types/elicitation.dart +++ b/lib/src/types/elicitation.dart @@ -3,6 +3,22 @@ import 'json_rpc.dart'; import 'tasks.dart'; import 'validation.dart'; +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + /// Legacy alias for [JsonSchema] used in elicitation requests. typedef ElicitationInputSchema = JsonSchema; @@ -239,6 +255,11 @@ class JsonRpcElicitRequest extends JsonRpcRequest { ); factory JsonRpcElicitRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.elicitationCreate, + 'JsonRpcElicitRequest', + ); final paramsMap = _readRequiredParamsObject(json, 'JsonRpcElicitRequest.params'); final meta = extractRequestMeta(json); @@ -401,6 +422,11 @@ class JsonRpcElicitationCompleteNotification extends JsonRpcNotification { factory JsonRpcElicitationCompleteNotification.fromJson( Map json, ) { + _expectJsonRpcMethod( + json, + Method.notificationsElicitationComplete, + 'JsonRpcElicitationCompleteNotification', + ); final paramsMap = _readRequiredParamsObject( json, 'JsonRpcElicitationCompleteNotification.params', diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index d3477cfa..6911c56f 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -25,6 +25,22 @@ Map _asJsonObject( return map; } +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + String _base64ForJson(String value, String field) { validateBase64String(value, field); return value; @@ -820,6 +836,11 @@ class JsonRpcCreateMessageRequest extends JsonRpcRequest { ); factory JsonRpcCreateMessageRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.samplingCreateMessage, + 'JsonRpcCreateMessageRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcCreateMessageRequest.params', diff --git a/test/client/client_test.dart b/test/client/client_test.dart index 88ccb1b9..3e6408c8 100644 --- a/test/client/client_test.dart +++ b/test/client/client_test.dart @@ -1122,7 +1122,9 @@ void _addCriticalPathTests() { content: SamplingTextContent(text: 'response'), ), (id, params, meta) => JsonRpcCreateMessageRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.samplingCreateMessage, 'params': params ?? {}, if (meta != null) '_meta': meta, }), @@ -1311,7 +1313,9 @@ void _addCriticalPathTests() { content: {}, ), (id, params, meta) => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.elicitationCreate, 'params': params ?? {}, if (meta != null) '_meta': meta, }), diff --git a/test/elicitation_test.dart b/test/elicitation_test.dart index 75254bd8..fa1c5cb0 100644 --- a/test/elicitation_test.dart +++ b/test/elicitation_test.dart @@ -1006,7 +1006,9 @@ void main() { ); final rpc = JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': 1, + 'method': Method.elicitationCreate, 'params': { ...params, '_meta': { diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index f716a174..7e35bdaf 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -566,6 +566,33 @@ void main() { }), throwsA(isA()), ); + final createMessageParams = { + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 100, + }; + expect( + () => JsonRpcCreateMessageRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.samplingCreateMessage, + 'params': createMessageParams, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcCreateMessageRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': createMessageParams, + }), + throwsA(isA()), + ); }); test('Content JSON object fields reject non-JSON Dart maps', () { @@ -1714,6 +1741,16 @@ void main() { }), throwsA(isA()), ); + final elicitParams = { + 'message': 'Choose option', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'option': {'type': 'string'}, + }, + }, + }; + final completeParams = {'elicitationId': 'elicitation-1'}; for (final parse in [ () => JsonRpcElicitRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -1721,6 +1758,18 @@ void main() { 'method': Method.elicitationCreate, 'params': 'bad', }), + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.elicitationCreate, + 'params': elicitParams, + }), + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.samplingCreateMessage, + 'params': elicitParams, + }), () => ElicitRequest.fromJson({ 'message': 'Bad schema', 'requestedSchema': 'bad', @@ -1737,6 +1786,16 @@ void main() { 'method': Method.notificationsElicitationComplete, 'params': null, }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsElicitationComplete, + 'params': completeParams, + }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + 'params': completeParams, + }), () => URLElicitationRequiredErrorData.fromJson({ 'elicitations': [1], }), diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index bfaf513f..bc56d30a 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -432,6 +432,17 @@ void main() { }); test('rejects malformed elicitation wire shapes', () { + final elicitParams = { + 'message': 'Choose option', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'option': {'type': 'string'}, + }, + }, + }; + final completeParams = {'elicitationId': 'elicitation-1'}; + for (final parse in [ () => JsonRpcElicitRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -445,6 +456,18 @@ void main() { 'method': Method.elicitationCreate, 'params': null, }), + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.elicitationCreate, + 'params': elicitParams, + }), + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.samplingCreateMessage, + 'params': elicitParams, + }), () => ElicitRequest.fromJson({ 'message': 'Bad properties', 'requestedSchema': { @@ -470,6 +493,16 @@ void main() { 'method': Method.notificationsElicitationComplete, 'params': 'bad', }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsElicitationComplete, + 'params': completeParams, + }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + 'params': completeParams, + }), () => URLElicitationRequiredErrorData.fromJson({ 'elicitations': [1], }), @@ -478,6 +511,36 @@ void main() { } }); + test('embedded MRTR input requests keep method and params shape', () { + final elicitInput = InputRequest.fromJson({ + 'method': Method.elicitationCreate, + 'params': { + 'message': 'Choose option', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'option': {'type': 'string'}, + }, + }, + }, + }); + final samplingInput = InputRequest.fromJson({ + 'method': Method.samplingCreateMessage, + 'params': { + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 16, + }, + }); + + expect(elicitInput.elicitParams.message, 'Choose option'); + expect(samplingInput.createMessageParams.maxTokens, 16); + }); + test('rejects non-finite JSON numbers', () { expect( () => ProgressNotification.fromJson({ @@ -554,6 +617,33 @@ void main() { }), throwsA(isA()), ); + final createMessageParams = { + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 16, + }; + expect( + () => JsonRpcCreateMessageRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.samplingCreateMessage, + 'params': createMessageParams, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcCreateMessageRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': createMessageParams, + }), + throwsA(isA()), + ); expect( () => CreateMessageResult.fromJson({ 'role': 'assistant', diff --git a/test/types/sampling_test.dart b/test/types/sampling_test.dart index c84042d4..477edae9 100644 --- a/test/types/sampling_test.dart +++ b/test/types/sampling_test.dart @@ -1,4 +1,5 @@ import 'package:mcp_dart/src/types/content.dart'; +import 'package:mcp_dart/src/types/json_rpc.dart'; import 'package:mcp_dart/src/types/sampling.dart'; import 'package:test/test.dart'; @@ -1162,6 +1163,55 @@ void main() { throwsA(isA()), ); }); + + test('fromJson rejects wrong wrapper constants', () { + final params = { + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Question'}, + }, + ], + 'maxTokens': 100, + }; + + expect( + () => JsonRpcCreateMessageRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.samplingCreateMessage, + 'params': params, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcCreateMessageRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': params, + }), + throwsA(isA()), + ); + }); + + test('embedded input requests do not require JSON-RPC wrapper fields', () { + final request = InputRequest.fromJson({ + 'method': Method.samplingCreateMessage, + 'params': { + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Question'}, + }, + ], + 'maxTokens': 100, + }, + }); + + expect(request.method, Method.samplingCreateMessage); + expect(request.createMessageParams.maxTokens, 100); + }); }); group('IncludeContext', () { diff --git a/test/types_test.dart b/test/types_test.dart index 24599473..786533c9 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -2231,6 +2231,17 @@ void main() { }); test('elicitation parsers reject malformed wire fields', () { + final elicitParams = { + 'message': 'Choose option', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'option': {'type': 'string'}, + }, + }, + }; + final completeParams = {'elicitationId': 'elicitation-1'}; + for (final parse in [ () => JsonRpcElicitRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -2244,6 +2255,18 @@ void main() { 'method': Method.elicitationCreate, 'params': null, }), + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.elicitationCreate, + 'params': elicitParams, + }), + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.samplingCreateMessage, + 'params': elicitParams, + }), () => ElicitRequest.fromJson({ 'message': 'Bad schema', 'requestedSchema': 'bad', @@ -2278,6 +2301,16 @@ void main() { 'method': Method.notificationsElicitationComplete, 'params': null, }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsElicitationComplete, + 'params': completeParams, + }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + 'params': completeParams, + }), () => URLElicitationRequiredErrorData.fromJson({ 'elicitations': [1], }), From 59a6a0051b05de33ec2427f8c2a0a967b58790b4 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 13:01:31 -0400 Subject: [PATCH 25/42] Preserve empty result metadata --- CHANGELOG.md | 2 ++ lib/src/client/client.dart | 10 +++++----- lib/src/client/task_client.dart | 2 +- lib/src/server/server.dart | 2 +- lib/src/types/misc.dart | 4 ++++ test/client/client_test.dart | 7 +++++-- test/server/server_test.dart | 8 +++++++- test/types_edge_cases_test.dart | 18 ++++++++++++++++++ 8 files changed, 43 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6103b7ff..da2d08ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,8 @@ protocol parse errors. - Rejected malformed sampling and elicitation JSON-RPC wrapper constants while preserving embedded MRTR input request parsing. +- Preserved `Result._meta` while parsing empty results for high-level ping, + logging, and subscription acknowledgments. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 38249a4c..37509c4e 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -879,7 +879,7 @@ class McpClient extends Protocol { Future ping([RequestOptions? options]) { return request( const JsonRpcPingRequest(id: -1), - (json) => const EmptyResult(), + EmptyResult.fromJson, options, ); } @@ -904,7 +904,7 @@ class McpClient extends Protocol { ]) { final params = SetLevelRequest(level: level); final req = JsonRpcSetLevelRequest(id: -1, setParams: params); - return request(req, (json) => const EmptyResult(), options); + return request(req, EmptyResult.fromJson, options); } /// Sends a `prompts/get` request to retrieve a specific prompt/template. @@ -978,7 +978,7 @@ class McpClient extends Protocol { RequestOptions? options, ]) { final req = JsonRpcSubscribeRequest(id: -1, subParams: params); - return request(req, (json) => const EmptyResult(), options); + return request(req, EmptyResult.fromJson, options); } /// Sends a `resources/unsubscribe` request to cancel a resource subscription. @@ -987,7 +987,7 @@ class McpClient extends Protocol { RequestOptions? options, ]) { final req = JsonRpcUnsubscribeRequest(id: -1, unsubParams: params); - return request(req, (json) => const EmptyResult(), options); + return request(req, EmptyResult.fromJson, options); } /// Opens a `subscriptions/listen` stream and demultiplexes notifications. @@ -1014,7 +1014,7 @@ class McpClient extends Protocol { final requestDone = super.requestWithReservedId( requestId, requestData, - (json) => const EmptyResult(), + EmptyResult.fromJson, RequestOptions( signal: abortController.signal, timeoutEnabled: false, diff --git a/lib/src/client/task_client.dart b/lib/src/client/task_client.dart index 785cffee..dee32b09 100644 --- a/lib/src/client/task_client.dart +++ b/lib/src/client/task_client.dart @@ -232,7 +232,7 @@ class TaskClient { ); await client.request( req, - (json) => const EmptyResult(), + EmptyResult.fromJson, ); } diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 0554d273..726e2c01 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -1243,7 +1243,7 @@ class Server extends Protocol { Future ping([RequestOptions? options]) { return request( const JsonRpcPingRequest(id: -1), - (json) => const EmptyResult(), + EmptyResult.fromJson, options, ); } diff --git a/lib/src/types/misc.dart b/lib/src/types/misc.dart index c17f22a9..53b22cbe 100644 --- a/lib/src/types/misc.dart +++ b/lib/src/types/misc.dart @@ -31,6 +31,10 @@ class EmptyResult implements BaseResultData { const EmptyResult({this.meta}); + factory EmptyResult.fromJson(Map json) => EmptyResult( + meta: readOptionalJsonObject(json['_meta'], 'EmptyResult._meta'), + ); + @override Map toJson() => { if (meta != null) '_meta': readJsonObject(meta, 'EmptyResult._meta'), diff --git a/test/client/client_test.dart b/test/client/client_test.dart index 3e6408c8..abbce2be 100644 --- a/test/client/client_test.dart +++ b/test/client/client_test.dart @@ -268,11 +268,12 @@ void main() { capabilities: mockServerCapabilities, serverInfo: const Implementation(name: 'TestServer', version: '2.0.0'), ); + transport.emptyResponseMeta = {'traceId': 'ping-trace'}; await client.connect(transport); transport.clearSentMessages(); - await client.ping(); + final result = await client.ping(); // Verify a ping request was sent expect(transport.sentMessages.length, equals(1)); @@ -280,6 +281,7 @@ void main() { (transport.sentMessages.first as JsonRpcRequest).method, equals('ping'), ); + expect(result.meta, {'traceId': 'ping-trace'}); }); test('complete sends completion request', () async { @@ -570,6 +572,7 @@ void main() { class MockTransport extends Transport { final List sentMessages = []; InitializeResult? mockInitializeResponse; + Map? emptyResponseMeta; bool shouldThrowOnStart = false; void clearSentMessages() { @@ -741,7 +744,7 @@ class MockTransport extends Transport { onmessage!( JsonRpcResponse( id: message.id, - result: const EmptyResult().toJson(), + result: EmptyResult(meta: emptyResponseMeta).toJson(), ), ); } diff --git a/test/server/server_test.dart b/test/server/server_test.dart index 7c1585c4..4cebec5f 100644 --- a/test/server/server_test.dart +++ b/test/server/server_test.dart @@ -13,6 +13,7 @@ class MockTransport extends Transport { bool isStarted = false; bool isClosed = false; ClientCapabilities? clientCapabilities; + Map? emptyResponseMeta; @override String? get sessionId => null; @@ -35,7 +36,10 @@ class MockTransport extends Transport { if (message is JsonRpcRequest) { final request = message; if (request.method == 'ping') { - final response = JsonRpcResponse(id: request.id, result: {}); + final response = JsonRpcResponse( + id: request.id, + result: EmptyResult(meta: emptyResponseMeta).toJson(), + ); if (onmessage != null) { onmessage!(response); } @@ -386,6 +390,7 @@ void main() { // Initialize client capabilities await _initializeClient(transport, server); + transport.emptyResponseMeta = {'traceId': 'server-ping'}; // Send ping request final result = await server.ping(); @@ -399,6 +404,7 @@ void main() { // Verify response was received expect(result, isA()); + expect(result.meta, {'traceId': 'server-ping'}); }); test('Can send createMessage request when client has sampling capability', diff --git a/test/types_edge_cases_test.dart b/test/types_edge_cases_test.dart index 7b5f0a9e..13e7aaf4 100644 --- a/test/types_edge_cases_test.dart +++ b/test/types_edge_cases_test.dart @@ -1064,6 +1064,24 @@ void main() { }), ); }); + + test('parses meta when present', () { + final result = EmptyResult.fromJson({ + '_meta': {'key': 'value'}, + }); + + expect(result.meta, equals({'key': 'value'})); + expect(result.toJson(), { + '_meta': {'key': 'value'}, + }); + }); + + test('rejects malformed meta', () { + expect( + () => EmptyResult.fromJson({'_meta': 'bad'}), + throwsA(isA()), + ); + }); }); group('ClientCapabilitiesRoots Edge Cases', () { From e236fe4f73e28aeced5ed393901fb53723c4a670 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 13:11:57 -0400 Subject: [PATCH 26/42] Preserve filtered tool cache hints --- CHANGELOG.md | 2 ++ lib/src/client/client.dart | 2 ++ test/mcp_2026_07_28_test.dart | 39 +++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da2d08ec..16e989cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,6 +139,8 @@ preserving embedded MRTR input request parsing. - Preserved `Result._meta` while parsing empty results for high-level ping, logging, and subscription acknowledgments. +- Preserved MCP 2026 `tools/list` cache hints when client-side tool metadata + filtering removes invalid tool definitions. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 37509c4e..68c783b4 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -1086,6 +1086,8 @@ class McpClient extends Protocol { return ListToolsResult( tools: tools, nextCursor: result.nextCursor, + ttlMs: result.ttlMs, + cacheScope: result.cacheScope, meta: result.meta, ); } diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index bc56d30a..e4c291fd 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -4389,6 +4389,45 @@ void main() { expect(result.tools, isEmpty); }); + test('client preserves cache hints when filtering invalid tools', () async { + final transport = DiscoveringClientTransport( + toolsListResult: const { + 'resultType': resultTypeComplete, + 'tools': [ + { + 'name': 'valid', + 'inputSchema': {'type': 'object'}, + }, + { + 'name': 'invalid_header', + 'inputSchema': { + 'type': 'object', + 'properties': { + 'ratio': { + 'type': 'number', + 'x-mcp-header': 'Ratio', + }, + }, + }, + }, + ], + 'ttlMs': 300000, + 'cacheScope': CacheScope.public, + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + + await client.connect(transport); + + final result = await client.listTools(); + expect(result.tools.map((tool) => tool.name), ['valid']); + expect(result.ttlMs, 300000); + expect(result.cacheScope, CacheScope.public); + }); + test('stable client sessions do not validate future resultType values', () async { final transport = LegacyFallbackTransport( From c113ff28bba776651a712f04b35e953d02a14340 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 13:26:14 -0400 Subject: [PATCH 27/42] Preserve unknown capability entries --- lib/src/types/initialization.dart | 93 +++++++++++++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 12 ++++ test/types_test.dart | 68 ++++++++++++++++++++++ 3 files changed, 173 insertions(+) diff --git a/lib/src/types/initialization.dart b/lib/src/types/initialization.dart index f1bb65ba..9fb1e156 100644 --- a/lib/src/types/initialization.dart +++ b/lib/src/types/initialization.dart @@ -150,6 +150,50 @@ Map>? _serializeExtensionMap( ); } +Map? _readAdditionalCapabilities( + Map json, + Set knownKeys, + String field, +) { + final additional = {}; + for (final entry in json.entries) { + if (knownKeys.contains(entry.key)) { + continue; + } + additional[entry.key] = readJsonValue( + entry.value, + '$field.${entry.key}', + ); + } + return additional.isEmpty ? null : additional; +} + +Map? _serializeAdditionalCapabilities( + Map? value, + Set knownKeys, + String field, +) { + if (value == null) { + return null; + } + + final additional = {}; + for (final entry in value.entries) { + if (knownKeys.contains(entry.key)) { + throw ArgumentError.value( + entry.key, + '$field.${entry.key}', + 'must not duplicate a known capability key', + ); + } + additional[entry.key] = readJsonValue( + entry.value, + '$field.${entry.key}', + ); + } + return additional; +} + bool? _capabilityDeclared(Object? value, String field) { if (value == null) { return null; @@ -181,6 +225,27 @@ Map> withMcpTasksExtension([ }; } +const _clientCapabilityKeys = { + 'experimental', + 'sampling', + 'roots', + 'elicitation', + 'tasks', + 'extensions', +}; + +const _serverCapabilityKeys = { + 'experimental', + 'logging', + 'prompts', + 'resources', + 'tools', + 'completions', + 'tasks', + 'elicitation', + 'extensions', +}; + /// Describes an MCP implementation (client or server). class Implementation { /// The name of the implementation. @@ -586,6 +651,9 @@ class ClientCapabilities { /// values are extension-specific settings. final Map>? extensions; + /// Additional client capabilities not yet modeled by this SDK. + final Map? additionalCapabilities; + const ClientCapabilities({ this.experimental, this.sampling, @@ -593,6 +661,7 @@ class ClientCapabilities { this.elicitation, this.tasks, this.extensions, + this.additionalCapabilities, }); factory ClientCapabilities.fromJson(Map json) { @@ -627,6 +696,11 @@ class ClientCapabilities { tasks: tasksMap == null ? null : ClientCapabilitiesTasks.fromJson(tasksMap), extensions: extensionsMap, + additionalCapabilities: _readAdditionalCapabilities( + json, + _clientCapabilityKeys, + 'ClientCapabilities', + ), ); } @@ -645,6 +719,11 @@ class ClientCapabilities { extensions, 'ClientCapabilities.extensions', ), + ...?_serializeAdditionalCapabilities( + additionalCapabilities, + _clientCapabilityKeys, + 'ClientCapabilities.additionalCapabilities', + ), }; /// Whether the MCP Tasks extension is declared. @@ -1104,6 +1183,9 @@ class ServerCapabilities { /// values are extension-specific settings. final Map>? extensions; + /// Additional server capabilities not yet modeled by this SDK. + final Map? additionalCapabilities; + const ServerCapabilities({ this.experimental, this.logging, @@ -1114,6 +1196,7 @@ class ServerCapabilities { this.tasks, this.elicitation, this.extensions, + this.additionalCapabilities, }); factory ServerCapabilities.fromJson(Map json) { @@ -1158,6 +1241,11 @@ class ServerCapabilities { ? null : ServerCapabilitiesElicitation.fromJson(elicitationMap), extensions: extensionsMap, + additionalCapabilities: _readAdditionalCapabilities( + json, + _serverCapabilityKeys, + 'ServerCapabilities', + ), ); } @@ -1179,6 +1267,11 @@ class ServerCapabilities { extensions, 'ServerCapabilities.extensions', ), + ...?_serializeAdditionalCapabilities( + additionalCapabilities, + _serverCapabilityKeys, + 'ServerCapabilities.additionalCapabilities', + ), }; /// Whether the MCP Tasks extension is declared. diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index e4c291fd..caa910a6 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -3315,6 +3315,18 @@ void main() { ), isNull, ); + expect( + validateToolRequest( + _clientMeta( + clientCapabilities: const ClientCapabilities( + additionalCapabilities: { + 'com.example/clientFeature': {'enabled': true}, + }, + ), + ), + ), + isNull, + ); }); test('server rejects core RPCs removed from stateless MCP', () async { diff --git a/test/types_test.dart b/test/types_test.dart index 786533c9..1051f831 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -822,6 +822,74 @@ void main() { expect(deserialized.roots?.listChanged, equals(true)); }); + test('capabilities preserve unknown top-level capability entries', () { + final clientCapabilities = ClientCapabilities.fromJson( + const { + 'roots': {}, + 'com.example/clientFeature': { + 'enabled': true, + 'modes': ['fast', 'safe'], + }, + }, + ); + expect(clientCapabilities.roots, isNotNull); + expect(clientCapabilities.additionalCapabilities, { + 'com.example/clientFeature': { + 'enabled': true, + 'modes': ['fast', 'safe'], + }, + }); + expect(clientCapabilities.toJson()['com.example/clientFeature'], { + 'enabled': true, + 'modes': ['fast', 'safe'], + }); + + final serverCapabilities = ServerCapabilities.fromJson( + const { + 'tools': {}, + 'com.example/serverFeature': { + 'limits': {'requests': 10}, + }, + }, + ); + expect(serverCapabilities.tools, isNotNull); + expect(serverCapabilities.additionalCapabilities, { + 'com.example/serverFeature': { + 'limits': {'requests': 10}, + }, + }); + expect(serverCapabilities.toJson()['com.example/serverFeature'], { + 'limits': {'requests': 10}, + }); + }); + + test('additional capability values must be JSON values', () { + expect( + () => ClientCapabilities.fromJson( + {'com.example/clientFeature': Object()}, + ), + throwsA(isA()), + ); + expect( + () => ServerCapabilities.fromJson( + {'com.example/serverFeature': Object()}, + ), + throwsA(isA()), + ); + expect( + () => const ClientCapabilities( + additionalCapabilities: {'roots': {}}, + ).toJson(), + throwsA(isA()), + ); + expect( + () => const ServerCapabilities( + additionalCapabilities: {'tools': {}}, + ).toJson(), + throwsA(isA()), + ); + }); + test('experimental capability values must be objects', () { expect( () => ClientCapabilities.fromJson( From afabf929918df9435342b59d090ec5a3768ced12 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 13:44:34 -0400 Subject: [PATCH 28/42] Handle client input_required retries --- lib/src/client/client.dart | 128 +++++++++++++++++++++++--- lib/src/shared/protocol.dart | 70 +++++++++++++++ test/mcp_2026_07_28_test.dart | 165 ++++++++++++++++++++++++++++++++++ 3 files changed, 351 insertions(+), 12 deletions(-) diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 68c783b4..77fd9d85 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -122,6 +122,8 @@ const Set _statelessRemovedNotificationMethods = { Method.notificationsTasksStatus, }; +const int _maxInputRequiredRetries = 16; + /// An MCP client implementation built on top of a pluggable [Transport]. /// /// Handles the initialization handshake with the server upon connection @@ -580,6 +582,85 @@ class McpClient extends Protocol { } } + BaseResultData _parseExpectedOrInputRequired( + Map json, + T Function(Map) resultFactory, + ) { + if (json['resultType'] == resultTypeInputRequired) { + return InputRequiredResult.fromJson(json); + } + return resultFactory(json); + } + + Future _resolveInputRequests( + InputRequests? inputRequests, + AbortSignal? signal, + ) async { + if (inputRequests == null) { + return null; + } + + final inputResponses = {}; + for (final entry in inputRequests.entries) { + signal?.throwIfAborted(); + final result = await handleEmbeddedInputRequest( + entry.key, + entry.value, + signal: signal, + ); + inputResponses[entry.key] = InputResponse.fromResult(result); + } + return inputResponses; + } + + Future _requestResolvingInputRequired( + String method, + JsonRpcRequest Function( + InputResponses? inputResponses, + String? requestState, + bool isRetry, + ) buildRequest, + T Function(Map) resultFactory, [ + RequestOptions? options, + ]) async { + InputResponses? inputResponses; + String? requestState; + + for (var attempt = 0; attempt <= _maxInputRequiredRetries; attempt++) { + final result = await request( + buildRequest(inputResponses, requestState, attempt > 0), + (json) => _parseExpectedOrInputRequired(json, resultFactory), + options, + ); + + if (result is T) { + return result; + } + + if (result is! InputRequiredResult) { + throw McpError( + ErrorCode.internalError.value, + 'Unexpected result type ${result.runtimeType} for $method.', + ); + } + + if (attempt == _maxInputRequiredRetries) { + throw McpError( + ErrorCode.invalidRequest.value, + 'Exceeded $_maxInputRequiredRetries input_required retries for $method.', + ); + } + + inputResponses = await _resolveInputRequests( + result.inputRequests, + options?.signal, + ); + requestState = result.requestState; + } + + throw StateError('Unreachable input_required retry state for $method.'); + } + @override Future notification( JsonRpcNotification notificationData, { @@ -912,10 +993,18 @@ class McpClient extends Protocol { GetPromptRequest params, [ RequestOptions? options, ]) { - final req = JsonRpcGetPromptRequest(id: -1, getParams: params); - return request( - req, - (json) => GetPromptResult.fromJson(json), + return _requestResolvingInputRequired( + Method.promptsGet, + (inputResponses, requestState, isRetry) => JsonRpcGetPromptRequest( + id: -1, + getParams: GetPromptRequest( + name: params.name, + arguments: params.arguments, + inputResponses: isRetry ? inputResponses : params.inputResponses, + requestState: isRetry ? requestState : params.requestState, + ), + ), + GetPromptResult.fromJson, options, ); } @@ -964,10 +1053,17 @@ class McpClient extends Protocol { ReadResourceRequest params, [ RequestOptions? options, ]) { - final req = JsonRpcReadResourceRequest(id: -1, readParams: params); - return request( - req, - (json) => ReadResourceResult.fromJson(json), + return _requestResolvingInputRequired( + Method.resourcesRead, + (inputResponses, requestState, isRetry) => JsonRpcReadResourceRequest( + id: -1, + readParams: ReadResourceRequest( + uri: params.uri, + inputResponses: isRetry ? inputResponses : params.inputResponses, + requestState: isRetry ? requestState : params.requestState, + ), + ), + ReadResourceResult.fromJson, options, ); } @@ -1043,10 +1139,18 @@ class McpClient extends Protocol { ); } - final req = JsonRpcCallToolRequest(id: -1, params: params.toJson()); - final result = await request( - req, - (json) => CallToolResult.fromJson(json), + final result = await _requestResolvingInputRequired( + Method.toolsCall, + (inputResponses, requestState, isRetry) => JsonRpcCallToolRequest( + id: -1, + params: CallToolRequest( + name: params.name, + arguments: params.arguments, + inputResponses: isRetry ? inputResponses : params.inputResponses, + requestState: isRetry ? requestState : params.requestState, + ).toJson(), + ), + CallToolResult.fromJson, options, ); diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 312957ed..4dd1aaec 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -1092,6 +1092,76 @@ abstract class Protocol { ) => result.toJson(); + /// Handles an MRTR input request embedded in an `InputRequiredResult`. + /// + /// Embedded input requests reuse the locally registered request handlers, but + /// are not received as transport-level JSON-RPC requests. + @protected + Future handleEmbeddedInputRequest( + String inputRequestKey, + InputRequest inputRequest, { + AbortSignal? signal, + }) async { + final request = JsonRpcRequest( + id: inputRequestKey, + method: inputRequest.method, + params: inputRequest.params, + ); + final registeredHandler = _requestHandlers[inputRequest.method]; + final fallbackHandler = fallbackRequestHandler; + if (registeredHandler == null && fallbackHandler == null) { + throw McpError( + ErrorCode.methodNotFound.value, + 'No handler registered for MRTR input request ${inputRequest.method}', + ); + } + + final abortController = signal == null ? BasicAbortController() : null; + final effectiveSignal = signal ?? abortController!.signal; + effectiveSignal.throwIfAborted(); + + final extra = RequestHandlerExtra( + signal: effectiveSignal, + sessionId: _transport?.sessionId, + requestId: request.id, + meta: request.meta, + sendNotification: (notification, {relatedTask}) { + return _notificationWithRequestId( + notification, + relatedTask: relatedTask, + relatedRequestId: request.id, + ); + }, + sendRequest: ( + JsonRpcRequest req, + T Function(Map) resultFactory, + RequestOptions options, + ) { + return _requestWithRequestId( + req, + resultFactory, + options, + request.id, + ); + }, + ); + + try { + if (registeredHandler != null) { + final result = await registeredHandler(request, extra); + effectiveSignal.throwIfAborted(); + return result; + } + + final result = await fallbackHandler!(request); + effectiveSignal.throwIfAborted(); + return result; + } catch (error) { + onIncomingRequestFailed(request, error); + rethrow; + } + } + /// Subclass hook called after protocol-owned state has been cleared for a /// closed transport. @protected diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index caa910a6..03213abf 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -53,6 +53,7 @@ class DiscoveringClientTransport extends Transport 'ttlMs': 0, 'cacheScope': CacheScope.private, }, + this.onRequest, }); final List discoverVersions; @@ -60,6 +61,7 @@ class DiscoveringClientTransport extends Transport final Object? unsupportedDiscoverData; final ServerCapabilities capabilities; final Map toolsListResult; + final void Function(JsonRpcRequest request)? onRequest; final List sentMessages = []; @override @@ -120,6 +122,11 @@ class DiscoveringClientTransport extends Transport result: toolsListResult, ), ); + return; + } + + if (message is JsonRpcRequest) { + onRequest?.call(message); } } @@ -3777,6 +3784,164 @@ void main() { expect(response.error.message, contains('inputRequests')); }); + test('client retries tools/call after fulfilling input_required requests', + () async { + late DiscoveringClientTransport transport; + final callRequests = []; + transport = DiscoveringClientTransport( + onRequest: (request) { + if (request.method != Method.toolsCall) { + return; + } + + callRequests.add(request); + if (callRequests.length == 1) { + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: InputRequiredResult( + requestState: 'state-1', + inputRequests: { + 'profile': InputRequest.elicit( + ElicitRequest.form( + message: 'Enter profile', + requestedSchema: JsonSchema.object( + properties: {'name': JsonSchema.string()}, + required: ['name'], + ), + ), + ), + 'roots': InputRequest.listRoots(), + }, + ).toJson(), + ), + ); + return; + } + + expect(request.params?['requestState'], 'state-1'); + final inputResponses = + request.params?['inputResponses'] as Map; + expect(inputResponses['profile'], { + 'action': 'accept', + 'content': {'name': 'Ada'}, + }); + expect(inputResponses['roots'], { + 'roots': [ + {'uri': 'file:///repo'}, + ], + }); + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: { + 'resultType': resultTypeComplete, + ...const CallToolResult( + content: [TextContent(text: 'ok')], + ).toJson(), + }, + ), + ); + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + capabilities: ClientCapabilities( + elicitation: ClientElicitation.formOnly(), + roots: ClientCapabilitiesRoots(), + ), + ), + ); + client.onElicitRequest = (params) async { + expect(params.message, 'Enter profile'); + return const ElicitResult( + action: 'accept', + content: {'name': 'Ada'}, + ); + }; + client.setRequestHandler( + Method.rootsList, + (request, extra) async => ListRootsResult( + roots: [Root(uri: 'file:///repo')], + ), + (id, params, meta) => JsonRpcListRootsRequest(id: id, meta: meta), + ); + await client.connect(transport); + transport.sentMessages.clear(); + + final result = await client.callTool( + const CallToolRequest(name: 'lookup'), + ); + + expect((result.content.single as TextContent).text, 'ok'); + expect(callRequests, hasLength(2)); + expect(callRequests[1].id, isNot(callRequests[0].id)); + }); + + test('client retries requestState-only input_required without responses', + () async { + late DiscoveringClientTransport transport; + final readRequests = []; + transport = DiscoveringClientTransport( + capabilities: const ServerCapabilities( + resources: ServerCapabilitiesResources(), + ), + onRequest: (request) { + if (request.method != Method.resourcesRead) { + return; + } + + readRequests.add(request); + if (readRequests.length == 1) { + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: const InputRequiredResult( + requestState: 'read-state', + ).toJson(), + ), + ); + return; + } + + expect(request.params?['requestState'], 'read-state'); + expect(request.params, isNot(contains('inputResponses'))); + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: { + 'resultType': resultTypeComplete, + ...const ReadResourceResult( + contents: [ + TextResourceContents( + uri: 'file:///doc.txt', + text: 'hello', + ), + ], + ttlMs: 0, + cacheScope: CacheScope.private, + ).toJson(), + }, + ), + ); + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + await client.connect(transport); + transport.sentMessages.clear(); + + final result = await client.readResource( + const ReadResourceRequest(uri: 'file:///doc.txt'), + ); + + expect((result.contents.single as TextResourceContents).text, 'hello'); + expect(readRequests, hasLength(2)); + expect(readRequests[1].id, isNot(readRequests[0].id)); + }); + test('client listenSubscriptions requires a connected transport', () { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), From 4af899b16d4bebb818ced33c7ff5ba7f3884df2f Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 13:56:51 -0400 Subject: [PATCH 29/42] Handle custom request fallback dispatch --- lib/src/shared/protocol.dart | 15 +++- test/shared/protocol_edge_cases_test.dart | 97 +++++++++++++++-------- 2 files changed, 78 insertions(+), 34 deletions(-) diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 4dd1aaec..2d2b2456 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -1311,7 +1311,8 @@ abstract class Protocol { return; } - final handler = _requestHandlers[request.method] ?? fallbackRequestHandler; + final registeredHandler = _requestHandlers[request.method]; + final fallbackHandler = fallbackRequestHandler; if (_hasTaskAugmentation(request) && !_canHandleTaskAugmentation(request.method)) { @@ -1325,7 +1326,7 @@ abstract class Protocol { meta?[legacyRelatedTaskMetadataKey]) as Map?; final relatedTaskId = relatedTaskJson?['taskId'] as String?; - if (handler == null) { + if (registeredHandler == null && fallbackHandler == null) { _sendErrorResponse( request.id, ErrorCode.methodNotFound.value, @@ -1435,7 +1436,15 @@ abstract class Protocol { ); } - Future.microtask(() => handler(request, extra)).then( + Future invokeHandler() { + final handler = registeredHandler; + if (handler != null) { + return handler(request, extra); + } + return fallbackHandler!(request); + } + + Future.microtask(invokeHandler).then( (result) async { if (abortController.signal.aborted) { return; diff --git a/test/shared/protocol_edge_cases_test.dart b/test/shared/protocol_edge_cases_test.dart index 691777e5..a6ebb9dc 100644 --- a/test/shared/protocol_edge_cases_test.dart +++ b/test/shared/protocol_edge_cases_test.dart @@ -216,43 +216,70 @@ void main() { // Test passes if no exception is thrown }); - test('fallback notification handler would be called if method parsed', + test('parses custom notification methods and calls fallback handler', () async { - // Note: This test documents that fallback handlers CAN'T be tested with - // custom methods because JsonRpcMessage.fromJson throws UnimplementedError - // for unknown notification methods. The fallback handler mechanism exists - // but only works for methods that successfully parse. - await protocol.connect(transport); - // Set up fallback handler (it exists, just can't be triggered with unknown methods) + final received = Completer(); protocol.fallbackNotificationHandler = (notification) async { - // Would be called if a known notification type had no specific handler + received.complete(notification); }; - // Verify fallback handler is set - expect(protocol.fallbackNotificationHandler, isNotNull); + final parsed = JsonRpcMessage.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': 'extension/notification', + 'params': { + 'value': 'custom', + '_meta': {'vendor/trace': 'notification-1'}, + }, + }); + transport.receiveMessage(parsed); + + final notification = await received.future.timeout( + const Duration(seconds: 1), + ); - // Test passes to document this architectural limitation + expect(notification.method, 'extension/notification'); + expect(notification.params?['value'], 'custom'); + expect(notification.meta, {'vendor/trace': 'notification-1'}); }); - test('fallback request handler would be called if method parsed', () async { - // Note: Similar to notifications, fallback request handlers can't be tested - // with custom methods because JsonRpcMessage.fromJson throws UnimplementedError - // for unknown request methods. The fallback mechanism exists but only works - // for methods that successfully parse. - + test('parses custom request methods and calls fallback handler', () async { await protocol.connect(transport); - // Set up fallback handler + JsonRpcRequest? received; protocol.fallbackRequestHandler = (request) async { - return EdgeCaseResult(data: 'fallback'); + received = request; + return EdgeCaseResult( + data: 'fallback', + meta: {'vendor/trace': 'response-1'}, + ); }; - // Verify fallback handler is set - expect(protocol.fallbackRequestHandler, isNotNull); + final parsed = JsonRpcMessage.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'custom-request-1', + 'method': 'extension/request', + 'params': { + 'value': 'custom', + '_meta': {'vendor/trace': 'request-1'}, + }, + }); + transport.receiveMessage(parsed); + + await Future.delayed(const Duration(milliseconds: 50)); - // Test passes to document this architectural limitation + expect(received, isNotNull); + expect(received!.id, 'custom-request-1'); + expect(received!.method, 'extension/request'); + expect(received!.params?['value'], 'custom'); + expect(received!.meta, {'vendor/trace': 'request-1'}); + + expect(transport.sentMessages, hasLength(1)); + final response = transport.sentMessages.single as JsonRpcResponse; + expect(response.id, 'custom-request-1'); + expect(response.result, {'data': 'fallback'}); + expect(response.meta, {'vendor/trace': 'response-1'}); }); test('handles connection close with pending requests', () async { @@ -329,21 +356,29 @@ void main() { }); test('handles notification handler error gracefully', () async { - // Note: Custom notification methods throw UnimplementedError during parsing, - // so we can't test error handling for notification handlers since unknown - // methods never reach the handler. This test documents that errors in - // known notification handlers would be caught and passed to onerror. - await protocol.connect(transport); final receivedErrors = []; protocol.onerror = (error) => receivedErrors.add(error); - // The built-in handlers exist and would propagate errors through _onerror - // if they threw exceptions. Since we can't create a scenario that triggers - // this without modifying protocol internals, we document the behavior. + protocol.fallbackNotificationHandler = (notification) async { + throw StateError('Notification handler error'); + }; - // Test passes to document error propagation architecture + transport.receiveMessage( + const JsonRpcNotification( + method: 'extension/notification', + params: {}, + ), + ); + + await Future.delayed(const Duration(milliseconds: 50)); + + expect(receivedErrors, hasLength(1)); + expect( + receivedErrors.single.toString(), + contains('Notification handler error'), + ); }); }); From c6132afdb38f91e033bfb5fcef8177ec480473ad Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 14:07:28 -0400 Subject: [PATCH 30/42] Validate stateless result types per request --- lib/src/client/client.dart | 21 +++++++++++++ lib/src/shared/protocol.dart | 13 ++++++++ test/mcp_2026_07_28_test.dart | 58 +++++++++++++++++++++++++++++++++-- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 77fd9d85..8d9bff12 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -122,6 +122,12 @@ const Set _statelessRemovedNotificationMethods = { Method.notificationsTasksStatus, }; +const Set _statelessInputRequiredResultMethods = { + Method.toolsCall, + Method.promptsGet, + Method.resourcesRead, +}; + const int _maxInputRequiredRetries = 16; /// An MCP client implementation built on top of a pluggable [Transport]. @@ -729,6 +735,21 @@ class McpClient extends Protocol { (_serverCapabilities?.supportsTasksExtension ?? false); } + @override + bool isResultTypeAllowedForRequest( + JsonRpcRequest request, + String resultType, + ) { + if (resultType == resultTypeInputRequired) { + return _statelessInputRequiredResultMethods.contains(request.method); + } + if (resultType == resultTypeTask) { + return request.method == Method.toolsCall && + (_serverCapabilities?.supportsTasksExtension ?? false); + } + return super.isResultTypeAllowedForRequest(request, resultType); + } + @override McpError? validateIncomingRequest(JsonRpcRequest request) { if (_usesStatelessProtocol) { diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 2d2b2456..813fd086 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -497,6 +497,14 @@ abstract class Protocol { resultType == resultTypeInputRequired; } + /// Returns whether [resultType] is valid for [request]. + @protected + bool isResultTypeAllowedForRequest( + JsonRpcRequest request, + String resultType, + ) => + isRecognizedResultType(resultType); + bool _usesStatelessResultTypes(JsonRpcRequest request) { final requestProtocolVersion = request.meta?[McpMetaKey.protocolVersion]; if (requestProtocolVersion is String && @@ -534,6 +542,11 @@ abstract class Protocol { if (!isRecognizedResultType(resultType)) { throw FormatException('Unrecognized MCP resultType "$resultType"'); } + if (!isResultTypeAllowedForRequest(request, resultType)) { + throw FormatException( + 'MCP resultType "$resultType" is not valid for ${request.method}', + ); + } if (resultType == resultTypeComplete && _statelessCacheableResultMethods.contains(request.method)) { diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 03213abf..1b386bb4 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -4472,6 +4472,41 @@ void main() { ); }); + test('client rejects input_required on non-MRTR requests', () async { + final transport = DiscoveringClientTransport( + toolsListResult: const { + 'resultType': resultTypeInputRequired, + 'requestState': 'list-state', + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + + await client.connect(transport); + + await expectLater( + client.listTools(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.internalError.value, + ) + .having( + (error) => error.data.toString(), + 'data', + contains( + 'MCP resultType "$resultTypeInputRequired" is not valid for ' + '${Method.toolsList}', + ), + ), + ), + ); + }); + for (final scenario in [ ( name: 'missing ttlMs', @@ -4543,7 +4578,7 @@ void main() { }); } - test('client accepts advertised task extension resultType values', + test('client rejects task resultType on non-task-eligible requests', () async { final transport = DiscoveringClientTransport( capabilities: ServerCapabilities( @@ -4562,8 +4597,25 @@ void main() { await client.connect(transport); - final result = await client.listTools(); - expect(result.tools, isEmpty); + await expectLater( + client.listTools(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.internalError.value, + ) + .having( + (error) => error.data.toString(), + 'data', + contains( + 'MCP resultType "$resultTypeTask" is not valid for ' + '${Method.toolsList}', + ), + ), + ), + ); }); test('client preserves cache hints when filtering invalid tools', () async { From 1ed5ea5c9fdad5bd14dcb739647b8656020203c1 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 14:21:53 -0400 Subject: [PATCH 31/42] Handle task extension tool results --- lib/src/client/client.dart | 249 ++++++++++++++++++++++++++++++-- test/mcp_2026_07_28_test.dart | 259 ++++++++++++++++++++++++++++++++++ 2 files changed, 494 insertions(+), 14 deletions(-) diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 8d9bff12..506b6e8f 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -598,6 +598,24 @@ class McpClient extends Protocol { return resultFactory(json); } + BaseResultData _parseToolCallResult(Map json) { + switch (json['resultType']) { + case resultTypeInputRequired: + return InputRequiredResult.fromJson(json); + case resultTypeTask: + if (!_usesStatelessProtocol || + !_capabilities.supportsTasksExtension || + !(_serverCapabilities?.supportsTasksExtension ?? false)) { + throw const FormatException( + 'MCP resultType "task" is not valid for tools/call', + ); + } + return CreateTaskExtensionResult.fromJson(json); + default: + return CallToolResult.fromJson(json); + } + } + Future _resolveInputRequests( InputRequests? inputRequests, AbortSignal? signal, @@ -619,6 +637,35 @@ class McpClient extends Protocol { return inputResponses; } + Future _resolveNewInputRequests( + InputRequests? inputRequests, + Set answeredKeys, + AbortSignal? signal, + ) async { + if (inputRequests == null) { + return null; + } + + final pendingRequests = {}; + for (final entry in inputRequests.entries) { + if (!answeredKeys.contains(entry.key)) { + pendingRequests[entry.key] = entry.value; + } + } + if (pendingRequests.isEmpty) { + return null; + } + + final inputResponses = await _resolveInputRequests( + pendingRequests, + signal, + ); + if (inputResponses != null) { + answeredKeys.addAll(inputResponses.keys); + } + return inputResponses; + } + Future _requestResolvingInputRequired( String method, JsonRpcRequest Function( @@ -667,6 +714,186 @@ class McpClient extends Protocol { throw StateError('Unreachable input_required retry state for $method.'); } + Future _requestResolvingToolCall( + CallToolRequest params, + RequestOptions? options, + ) async { + InputResponses? inputResponses; + String? requestState; + + for (var attempt = 0; attempt <= _maxInputRequiredRetries; attempt++) { + final result = await request( + JsonRpcCallToolRequest( + id: -1, + params: CallToolRequest( + name: params.name, + arguments: params.arguments, + inputResponses: + attempt > 0 ? inputResponses : params.inputResponses, + requestState: attempt > 0 ? requestState : params.requestState, + ).toJson(), + ), + _parseToolCallResult, + options, + ); + + if (result is CallToolResult || result is CreateTaskExtensionResult) { + return result; + } + + if (result is! InputRequiredResult) { + throw McpError( + ErrorCode.internalError.value, + 'Unexpected result type ${result.runtimeType} for ${Method.toolsCall}.', + ); + } + + if (attempt == _maxInputRequiredRetries) { + throw McpError( + ErrorCode.invalidRequest.value, + 'Exceeded $_maxInputRequiredRetries input_required retries for ${Method.toolsCall}.', + ); + } + + inputResponses = await _resolveInputRequests( + result.inputRequests, + options?.signal, + ); + requestState = result.requestState; + } + + throw StateError( + 'Unreachable input_required retry state for ${Method.toolsCall}.', + ); + } + + Future _resolveTaskExtensionToolResult( + TaskExtensionTask initialTask, + RequestOptions? options, + ) async { + var currentTask = initialTask; + final answeredInputKeys = {}; + + while (true) { + options?.signal?.throwIfAborted(); + + switch (currentTask.status) { + case TaskStatus.completed: + final result = currentTask.result; + if (result == null) { + throw McpError( + ErrorCode.internalError.value, + 'Completed task ${currentTask.taskId} is missing a result.', + ); + } + return CallToolResult.fromJson(result); + + case TaskStatus.failed: + final error = currentTask.error; + if (error != null) { + throw McpError(error.code, error.message, error.data); + } + throw McpError( + ErrorCode.internalError.value, + 'Task ${currentTask.taskId} failed without error details.', + ); + + case TaskStatus.cancelled: + throw McpError( + ErrorCode.invalidRequest.value, + 'Task ${currentTask.taskId} was cancelled.', + ); + + case TaskStatus.inputRequired: + final inputResponses = await _resolveNewInputRequests( + currentTask.inputRequests, + answeredInputKeys, + options?.signal, + ); + if (inputResponses != null && inputResponses.isNotEmpty) { + await request( + JsonRpcUpdateTaskRequest( + id: -1, + updateParams: UpdateTaskRequest( + taskId: currentTask.taskId, + inputResponses: inputResponses, + ), + ), + TaskExtensionAcknowledgementResult.fromJson, + _taskFollowUpOptions(options), + ); + } + break; + + case TaskStatus.working: + break; + } + + await _waitForTaskExtensionPoll(currentTask, options?.signal); + currentTask = await _getTaskExtension(currentTask.taskId, options); + } + } + + Future _getTaskExtension( + String taskId, + RequestOptions? options, + ) async { + final result = await request( + JsonRpcGetTaskRequest( + id: -1, + getParams: GetTaskRequest(taskId: taskId), + ), + GetTaskExtensionResult.fromJson, + _taskFollowUpOptions(options), + ); + return result.task; + } + + RequestOptions? _taskFollowUpOptions(RequestOptions? options) { + if (options == null) { + return null; + } + return RequestOptions( + signal: options.signal, + timeout: options.timeout, + resetTimeoutOnProgress: options.resetTimeoutOnProgress, + maxTotalTimeout: options.maxTotalTimeout, + timeoutEnabled: options.timeoutEnabled, + ); + } + + Future _waitForTaskExtensionPoll( + TaskExtensionTask task, + AbortSignal? signal, + ) async { + signal?.throwIfAborted(); + + final interval = task.pollIntervalMs ?? 1000; + final completer = Completer(); + final timer = Timer(Duration(milliseconds: interval), () { + if (!completer.isCompleted) { + completer.complete(); + } + }); + + StreamSubscription? abortSubscription; + if (signal != null) { + abortSubscription = signal.onAbort.listen((_) { + timer.cancel(); + if (!completer.isCompleted) { + completer.completeError(AbortError(signal.reason)); + } + }); + } + + try { + await completer.future; + } finally { + timer.cancel(); + await abortSubscription?.cancel(); + } + } + @override Future notification( JsonRpcNotification notificationData, { @@ -745,6 +972,7 @@ class McpClient extends Protocol { } if (resultType == resultTypeTask) { return request.method == Method.toolsCall && + _capabilities.supportsTasksExtension && (_serverCapabilities?.supportsTasksExtension ?? false); } return super.isResultTypeAllowedForRequest(request, resultType); @@ -1160,20 +1388,13 @@ class McpClient extends Protocol { ); } - final result = await _requestResolvingInputRequired( - Method.toolsCall, - (inputResponses, requestState, isRetry) => JsonRpcCallToolRequest( - id: -1, - params: CallToolRequest( - name: params.name, - arguments: params.arguments, - inputResponses: isRetry ? inputResponses : params.inputResponses, - requestState: isRetry ? requestState : params.requestState, - ).toJson(), - ), - CallToolResult.fromJson, - options, - ); + final taskOrToolResult = await _requestResolvingToolCall(params, options); + final result = taskOrToolResult is CreateTaskExtensionResult + ? await _resolveTaskExtensionToolResult( + taskOrToolResult.task, + options, + ) + : taskOrToolResult as CallToolResult; final outputSchema = _cachedToolOutputSchemas[params.name]; if (outputSchema != null && !result.isError) { diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 1b386bb4..26ce510a 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -3879,6 +3879,265 @@ void main() { expect(callRequests[1].id, isNot(callRequests[0].id)); }); + test('client resolves task resultType tools/call responses', () async { + late DiscoveringClientTransport transport; + final requests = []; + transport = DiscoveringClientTransport( + capabilities: ServerCapabilities( + tools: const ServerCapabilitiesTools(), + extensions: withMcpTasksExtension(null), + ), + onRequest: (request) { + requests.add(request); + switch (request.method) { + case Method.toolsCall: + expect(request.params?['name'], 'delayed'); + final clientCapabilities = request + .meta?[McpMetaKey.clientCapabilities] as Map; + expect( + clientCapabilities['extensions'][mcpTasksExtensionId], + {}, + ); + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: const CreateTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:01Z', + ttlMs: null, + pollIntervalMs: 1, + ), + ).toJson(), + ), + ); + break; + + case Method.tasksGet: + expect(request.params?['taskId'], 'task-1'); + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: const GetTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.completed, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:02Z', + ttlMs: null, + result: { + 'content': [ + {'type': 'text', 'text': 'task done'}, + ], + 'isError': false, + }, + ), + ).toJson(), + ), + ); + break; + } + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: McpClientOptions( + capabilities: ClientCapabilities( + extensions: withMcpTasksExtension(null), + ), + ), + ); + await client.connect(transport); + transport.sentMessages.clear(); + + final result = await client.callTool( + const CallToolRequest(name: 'delayed'), + ); + + expect((result.content.single as TextContent).text, 'task done'); + expect(requests.map((request) => request.method), [ + Method.toolsCall, + Method.tasksGet, + ]); + }); + + test('client updates task input requests once while polling', () async { + late DiscoveringClientTransport transport; + var getCount = 0; + var updateCount = 0; + transport = DiscoveringClientTransport( + capabilities: ServerCapabilities( + tools: const ServerCapabilitiesTools(), + extensions: withMcpTasksExtension(null), + ), + onRequest: (request) { + switch (request.method) { + case Method.toolsCall: + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: const CreateTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-2', + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:01Z', + ttlMs: null, + pollIntervalMs: 1, + ), + ).toJson(), + ), + ); + break; + + case Method.tasksGet: + getCount += 1; + final task = getCount < 3 + ? TaskExtensionTask( + taskId: 'task-2', + status: TaskStatus.inputRequired, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:02Z', + ttlMs: null, + pollIntervalMs: 1, + inputRequests: { + 'approval': InputRequest.elicit( + ElicitRequest.form( + message: 'Approve?', + requestedSchema: JsonSchema.object( + properties: { + 'approved': JsonSchema.boolean(), + }, + required: ['approved'], + ), + ), + ), + }, + ) + : const TaskExtensionTask( + taskId: 'task-2', + status: TaskStatus.completed, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:03Z', + ttlMs: null, + result: { + 'content': [ + {'type': 'text', 'text': 'approved'}, + ], + }, + ); + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: GetTaskExtensionResult(task: task).toJson(), + ), + ); + break; + + case Method.tasksUpdate: + updateCount += 1; + expect(request.params?['taskId'], 'task-2'); + expect( + request.params?['inputResponses']['approval'], + { + 'action': 'accept', + 'content': {'approved': true}, + }, + ); + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: const TaskExtensionAcknowledgementResult().toJson(), + ), + ); + break; + } + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: McpClientOptions( + capabilities: ClientCapabilities( + elicitation: const ClientElicitation.formOnly(), + extensions: withMcpTasksExtension(null), + ), + ), + ); + client.onElicitRequest = (params) async { + expect(params.message, 'Approve?'); + return const ElicitResult( + action: 'accept', + content: {'approved': true}, + ); + }; + await client.connect(transport); + transport.sentMessages.clear(); + + final result = await client.callTool( + const CallToolRequest(name: 'approval-tool'), + ); + + expect((result.content.single as TextContent).text, 'approved'); + expect(getCount, 3); + expect(updateCount, 1); + }); + + test('client rejects task resultType when request lacks task extension', + () async { + late DiscoveringClientTransport transport; + transport = DiscoveringClientTransport( + capabilities: ServerCapabilities( + tools: const ServerCapabilitiesTools(), + extensions: withMcpTasksExtension(null), + ), + onRequest: (request) { + if (request.method != Method.toolsCall) { + return; + } + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: const CreateTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-3', + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:01Z', + ttlMs: null, + ), + ).toJson(), + ), + ); + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + await client.connect(transport); + + await expectLater( + client.callTool(const CallToolRequest(name: 'delayed')), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.internalError.value, + ) + .having( + (error) => error.data.toString(), + 'data', + contains( + 'MCP resultType "$resultTypeTask" is not valid for ' + '${Method.toolsCall}', + ), + ), + ), + ); + }); + test('client retries requestState-only input_required without responses', () async { late DiscoveringClientTransport transport; From 3242957d2f9e22105918f3f45a7548a484513d9d Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 14:29:42 -0400 Subject: [PATCH 32/42] Validate task extension status payloads --- lib/src/types/tasks.dart | 66 ++++++++++++++----- test/types/tasks_extension_test.dart | 97 ++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 16 deletions(-) diff --git a/lib/src/types/tasks.dart b/lib/src/types/tasks.dart index 32082fd6..45dcab0d 100644 --- a/lib/src/types/tasks.dart +++ b/lib/src/types/tasks.dart @@ -580,7 +580,7 @@ class TaskExtensionTask { }); factory TaskExtensionTask.fromJson(Map json) { - return TaskExtensionTask( + final task = TaskExtensionTask( taskId: _readRequiredTaskString( json, 'taskId', @@ -632,23 +632,57 @@ class TaskExtensionTask { ), ), ); + task._validateStatusPayload(); + return task; } - Map toJson({String? resultType}) => { - if (resultType != null) 'resultType': resultType, - 'taskId': taskId, - 'status': status.name, - if (statusMessage != null) 'statusMessage': statusMessage, - 'createdAt': createdAt, - 'lastUpdatedAt': lastUpdatedAt, - 'ttlMs': ttlMs, - if (pollIntervalMs != null) 'pollIntervalMs': pollIntervalMs, - if (inputRequests != null) - 'inputRequests': InputRequest.mapToJson(inputRequests!), - if (result != null) - 'result': readJsonObject(result, 'TaskExtensionTask.result'), - if (error != null) 'error': error!.toJson(), - }; + void _validateStatusPayload() { + switch (status) { + case TaskStatus.inputRequired: + if (inputRequests == null) { + throw const FormatException( + 'TaskExtensionTask.inputRequests is required when status is input_required', + ); + } + break; + case TaskStatus.completed: + if (result == null) { + throw const FormatException( + 'TaskExtensionTask.result is required when status is completed', + ); + } + break; + case TaskStatus.failed: + if (error == null) { + throw const FormatException( + 'TaskExtensionTask.error is required when status is failed', + ); + } + break; + case TaskStatus.working: + case TaskStatus.cancelled: + break; + } + } + + Map toJson({String? resultType}) { + _validateStatusPayload(); + return { + if (resultType != null) 'resultType': resultType, + 'taskId': taskId, + 'status': status.name, + if (statusMessage != null) 'statusMessage': statusMessage, + 'createdAt': createdAt, + 'lastUpdatedAt': lastUpdatedAt, + 'ttlMs': ttlMs, + if (pollIntervalMs != null) 'pollIntervalMs': pollIntervalMs, + if (inputRequests != null) + 'inputRequests': InputRequest.mapToJson(inputRequests!), + if (result != null) + 'result': readJsonObject(result, 'TaskExtensionTask.result'), + if (error != null) 'error': error!.toJson(), + }; + } } /// `resultType: "task"` response from the MCP Tasks extension. diff --git a/test/types/tasks_extension_test.dart b/test/types/tasks_extension_test.dart index cc5cc949..d4b0f555 100644 --- a/test/types/tasks_extension_test.dart +++ b/test/types/tasks_extension_test.dart @@ -167,6 +167,103 @@ void main() { ); }); + test('rejects missing status-specific task payload fields', () { + final baseTask = { + 'taskId': 'task-1', + 'createdAt': '2026-07-28T00:00:00Z', + 'lastUpdatedAt': '2026-07-28T00:02:00Z', + 'ttlMs': null, + }; + + expect( + () => TaskExtensionTask.fromJson({ + ...baseTask, + 'status': 'input_required', + }), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('inputRequests'), + ), + ), + ); + expect( + () => TaskExtensionTask.fromJson({ + ...baseTask, + 'status': 'completed', + }), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('result'), + ), + ), + ); + expect( + () => TaskExtensionTask.fromJson({ + ...baseTask, + 'status': 'failed', + }), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('error'), + ), + ), + ); + expect( + () => const TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.inputRequired, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:02:00Z', + ttlMs: null, + ).toJson(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('inputRequests'), + ), + ), + ); + expect( + () => const TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.completed, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:02:00Z', + ttlMs: null, + ).toJson(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('result'), + ), + ), + ); + expect( + () => const TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.failed, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:02:00Z', + ttlMs: null, + ).toJson(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('error'), + ), + ), + ); + }); + test('serializes notifications/tasks with detailed task state', () { final notification = JsonRpcTaskNotification( task: const TaskExtensionTask( From 89ebf0c0cc159362e985720cec0280048c9faf2f Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 14:39:54 -0400 Subject: [PATCH 33/42] Route TaskClient through task extension flow --- doc/client-guide.md | 7 +++ doc/tools.md | 4 ++ lib/src/client/task_client.dart | 34 +++++++++++-- test/client/task_client_test.dart | 81 +++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 3 deletions(-) diff --git a/doc/client-guide.md b/doc/client-guide.md index c9efd21c..a9d4fed9 100644 --- a/doc/client-guide.md +++ b/doc/client-guide.md @@ -158,6 +158,13 @@ final result = await client.callTool( ### Task-Augmented Tool Calls +For MCP 2026 stateless servers that advertise the +`io.modelcontextprotocol/tasks` extension, task creation is server-directed. +Call `client.callTool()` normally, or call `TaskClient.callToolStream()` without +the legacy `task` argument; the client follows `resultType: "task"` with +`tasks/get`, using `tasks/update` only when the server requests more input, +until the final tool result is available. + For task-capable tools, use `TaskClient.callToolStream()` and pass task creation parameters through the `task` argument. The server must advertise `tasks.requests.tools.call`, and the target tool must be visible from diff --git a/doc/tools.md b/doc/tools.md index e9834080..88a24a1c 100644 --- a/doc/tools.md +++ b/doc/tools.md @@ -514,6 +514,10 @@ final server = McpServer( ``` Clients that call task-augmented tools can use `TaskClient.callToolStream()`. +With MCP 2026 stateless servers that advertise +`io.modelcontextprotocol/tasks`, omit the legacy `task` argument; task creation +is server-directed and the client follows the extension polling flow +transparently. When the `task` argument is supplied, `TaskClient` first verifies that the server advertised `tasks.requests.tools.call`, then lists tools to confirm the target tool advertises `execution.taskSupport` as `optional` or `required`. diff --git a/lib/src/client/task_client.dart b/lib/src/client/task_client.dart index dee32b09..14a628d1 100644 --- a/lib/src/client/task_client.dart +++ b/lib/src/client/task_client.dart @@ -28,6 +28,13 @@ class TaskClient { TaskClient(this.client); + bool get _usesTasksExtension { + final protocolVersion = client.getProtocolVersion(); + return protocolVersion != null && + isStatelessProtocolVersion(protocolVersion) && + (client.getServerCapabilities()?.supportsTasksExtension ?? false); + } + Future _findTool(String name) async { String? cursor; do { @@ -50,9 +57,14 @@ class TaskClient { /// and long-running tasks (yielding [TaskCreatedMessage], multiple /// [TaskStatusMessage]s, and finally [TaskResultMessage]). /// - /// The [task] parameter is used for task augmentation. Pass task creation - /// parameters (e.g., `{'ttl': 60000, 'pollInterval': 50}`) to request - /// task-based execution from tools that support it. + /// For MCP 2026 stateless sessions with the `io.modelcontextprotocol/tasks` + /// extension, task creation is server-directed and [task] must be omitted. + /// The call is routed through [McpClient.callTool], which transparently + /// follows the extension polling flow and yields the final tool result. + /// + /// For MCP 2025-11-25 legacy tasks, [task] is used for task augmentation. + /// Pass task creation parameters (e.g., `{'ttl': 60000, 'pollInterval': 50}`) + /// to request task-based execution from tools that support it. /// /// When [task] is provided, the connected server must advertise /// `tasks.requests.tools.call`, and the target tool must be discoverable from @@ -65,6 +77,22 @@ class TaskClient { Map? task, }) async* { try { + if (_usesTasksExtension) { + if (task != null) { + throw McpError( + ErrorCode.invalidRequest.value, + 'MCP ${client.getProtocolVersion()} uses the ' + '$mcpTasksExtensionId extension instead of the legacy task ' + 'request parameter.', + ); + } + final result = await client.callTool( + CallToolRequest(name: name, arguments: arguments), + ); + yield TaskResultMessage(result); + return; + } + if (task != null) { client.assertTaskCapability(Method.toolsCall); final tool = await _findTool(name); diff --git a/test/client/task_client_test.dart b/test/client/task_client_test.dart index c68021cc..8403090f 100644 --- a/test/client/task_client_test.dart +++ b/test/client/task_client_test.dart @@ -6,6 +6,9 @@ class MockClient implements McpClient { final Map _responses = {}; final List requests = []; bool supportsTaskAugmentedTools = true; + String? protocolVersion; + ServerCapabilities? serverCapabilities; + CallToolResult? callToolResult; List listedTools = const []; Map listedToolPages = const {}; @@ -73,6 +76,29 @@ class MockClient implements McpClient { } } + @override + String? getProtocolVersion() => protocolVersion; + + @override + ServerCapabilities? getServerCapabilities() => serverCapabilities; + + @override + Future callTool( + CallToolRequest params, { + RequestOptions? options, + }) async { + requests.add(JsonRpcCallToolRequest(id: -1, params: params.toJson())); + final result = callToolResult; + if (result != null) { + return result; + } + final response = _responses[Method.toolsCall]; + if (response == null) { + throw Exception('Mock response not found for ${Method.toolsCall}'); + } + return CallToolResult.fromJson(Map.from(response)); + } + @override Future listTools({ ListToolsRequest? params, @@ -121,6 +147,61 @@ void main() { ); }); + test('callToolStream delegates 2026 task extension tools to callTool', + () async { + mockClient.protocolVersion = draftProtocolVersion2026_07_28; + mockClient.serverCapabilities = ServerCapabilities( + tools: const ServerCapabilitiesTools(), + extensions: withMcpTasksExtension(null), + ); + mockClient.callToolResult = const CallToolResult( + content: [TextContent(text: 'Extension task done')], + ); + + final events = await taskClient.callToolStream( + 'extension-tool', + {'city': 'Toronto'}, + ).toList(); + + expect(events, hasLength(1)); + expect(events.single, isA()); + expect( + (((events.single as TaskResultMessage).result as CallToolResult) + .content + .single as TextContent) + .text, + 'Extension task done', + ); + expect(mockClient.requests.map((r) => r.method), [Method.toolsCall]); + expect(mockClient.requests.single.params, isNot(contains('task'))); + expect(mockClient.requests.single.params?['arguments'], { + 'city': 'Toronto', + }); + }); + + test('callToolStream rejects legacy task parameter for 2026 task extension', + () async { + mockClient.protocolVersion = draftProtocolVersion2026_07_28; + mockClient.serverCapabilities = ServerCapabilities( + tools: const ServerCapabilitiesTools(), + extensions: withMcpTasksExtension(null), + ); + + final events = await taskClient.callToolStream( + 'extension-tool', + {}, + task: {'ttl': 1000}, + ).toList(); + + expect(events, hasLength(1)); + expect(events.single, isA()); + expect( + (events.single as TaskErrorMessage).error.toString(), + contains('legacy task request parameter'), + ); + expect(mockClient.requests, isEmpty); + }); + test('callToolStream handles long-running task workflow', () async { final taskId = 'task-123'; From b2213879f58cc107a87b07c0718f9104246c3562 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 14:54:57 -0400 Subject: [PATCH 34/42] Expand compliance coverage for 2026 RC --- .github/workflows/test_cli.yml | 6 +- CHANGELOG.md | 62 +- doc/interoperability.md | 3 +- doc/spec-coverage-2025-11-25.md | 91 +- lib/src/client/client.dart | 30 +- lib/src/client/streamable_https.dart | 75 +- lib/src/server/server.dart | 156 +- lib/src/server/streamable_https.dart | 65 +- lib/src/server/streamable_mcp_server.dart | 8 +- lib/src/shared/json_schema/json_schema.dart | 39 +- lib/src/shared/protocol.dart | 167 +- lib/src/types.dart | 1 + lib/src/types/completion.dart | 21 +- lib/src/types/elicitation.dart | 36 +- lib/src/types/initialization.dart | 42 +- lib/src/types/json_rpc.dart | 96 +- lib/src/types/logging.dart | 10 +- lib/src/types/misc.dart | 21 +- lib/src/types/prompts.dart | 9 +- lib/src/types/resources.dart | 10 +- lib/src/types/roots.dart | 10 +- lib/src/types/sampling.dart | 17 +- lib/src/types/subscriptions.dart | 97 +- lib/src/types/tasks.dart | 10 +- lib/src/types/tools.dart | 14 +- packages/mcp_dart_cli/CHANGELOG.md | 15 + packages/mcp_dart_cli/README.md | 15 +- .../lib/src/conformance_runner.dart | 4811 ++++++++++++++++- .../test/fixtures/raw_stdio_server.dart | 22 +- .../test/src/conformance_command_test.dart | 53 +- test/client/client_test.dart | 21 +- test/client/client_tool_validation_test.dart | 36 +- test/client/streamable_https_test.dart | 4 +- test/docs/markdown_docs_test.dart | 2 +- test/elicitation_test.dart | 123 +- .../ts_client_with_dart_server_test.dart | 15 +- test/mcp_2025_11_25_test.dart | 55 +- test/mcp_2026_07_28_test.dart | 1354 ++++- test/server/server_test.dart | 195 +- test/server/streamable_https_test.dart | 343 +- test/server/streamable_mcp_server_test.dart | 181 + test/shared/json_schema_from_json_test.dart | 36 + test/shared/json_schema_validator_test.dart | 8 +- test/shared/protocol_test.dart | 51 + test/tool/spec_example_audit_test.dart | 182 + test/tool_schema_test.dart | 2 +- test/types/subscriptions_test.dart | 78 +- test/types_edge_cases_test.dart | 123 +- test/types_test.dart | 38 +- tool/spec_example_audit.dart | 287 + 50 files changed, 8471 insertions(+), 675 deletions(-) create mode 100644 test/tool/spec_example_audit_test.dart create mode 100644 tool/spec_example_audit.dart diff --git a/.github/workflows/test_cli.yml b/.github/workflows/test_cli.yml index 93fe580f..4300cf84 100644 --- a/.github/workflows/test_cli.yml +++ b/.github/workflows/test_cli.yml @@ -100,8 +100,10 @@ jobs: # Given the previous task aimed for 160/160, let's keep it strict but maybe allow slight dev. # For this initial workflow file, I will fail if not max points to maintain quality. if [ "$PANA_EXIT" -ne 0 ] || [ "$SCORE" != "$MAX_POINTS" ]; then - if grep -q 'depends on mcp_dart .*which doesn.*t match any versions' pana_output.json; then - echo "::warning::Skipping strict CLI pana score because this PR prepares the CLI against an unpublished mcp_dart release. Publish mcp_dart first, then rerun pana before publishing mcp_dart_cli." + if grep -q 'depends on mcp_dart .*which doesn.*t match any versions' pana_output.json || + { grep -q '^dependency_overrides:' pubspec.yaml && + grep -Eq 'UNDEFINED_(NAMED_PARAMETER|FUNCTION|GETTER|METHOD|CLASS)' pana_output.json; }; then + echo "::warning::Skipping strict CLI pana score because this PR prepares the CLI against unpublished local mcp_dart APIs. Publish mcp_dart first, then rerun pana before publishing mcp_dart_cli." exit 0 fi echo "pana exited with code $PANA_EXIT." diff --git a/CHANGELOG.md b/CHANGELOG.md index 16e989cc..b89b447e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,11 +22,11 @@ - Synced registered tool `x-mcp-header` metadata into Streamable HTTP server transports so 2026 stateless `tools/call` requests reject missing or mismatched `Mcp-Param-*` argument headers. -- Rejected `x-mcp-header` usage on JSON Schema `number` parameters, keeping - mirrored tool headers limited to string, JavaScript-safe integer, and boolean - parameters. - Removed `Mcp-Session-Id` from 2026 stateless Streamable HTTP requests by stripping it from client sends and ignoring it on stateless server POSTs. +- Prevented 2026 stateless request handlers and task-store operations from + inheriting transport session IDs, including direct Streamable HTTP transport + responses after a prior stateful initialization. - Enforced 2026 stateless Streamable HTTP POST-only behavior by skipping legacy client GET/DELETE session paths and returning `Allow: POST` for stateless non-POST server requests. @@ -37,16 +37,39 @@ values beginning with `=?base64?` and ending with `?=` round-trip correctly. - Synced nested 2026 `x-mcp-header` mappings into Streamable HTTP transports using JSON Pointer selectors for nested tool arguments. +- Limited 2026 Streamable HTTP `x-mcp-header` mirroring to string, boolean, + and JavaScript-safe integer argument values; fractional numbers and unsafe + integers are omitted, and `number` schemas are rejected from advertised + header mappings. - Returned HTTP 404 with JSON-RPC `Method not found` for unsupported or removed 2026 stateless Streamable HTTP request methods before opening response streams. +- Sent and required `Mcp-Name` for MCP 2026 task lifecycle requests over + Streamable HTTP, using the request body `taskId` as the routing value. +- Accepted MCP 2026 stateless Streamable HTTP JSON-RPC response POSTs without + requiring request-only metadata in the response body. - Treated client closure of a 2026 stateless Streamable HTTP SSE response stream as cancellation of that pending request. - Sorted 2026 stateless high-level `tools/list` responses by tool name for deterministic list results while preserving legacy registration-order output. +- Omitted stable-only `Tool.execution` metadata from 2026 stateless + `tools/list` responses and embedded MRTR sampling tool definitions while + preserving stable/default serialization. +- Rejected legacy `RequestOptions.task` augmentation before sending 2026 + stateless requests while preserving stable task augmentation. +- Added `tool/spec_example_audit.dart` so upstream machine-readable spec + examples can be parsed through the checked-in typed SDK surfaces during + RC/final release audits. - Gated 2026 stateless task extension methods on advertised server extension support and rejected legacy task result shapes on extension `tasks/get`, `tasks/update`, and `tasks/cancel` handlers. +- Ignored legacy `tools/call` `task` parameters on 2026 stateless requests so + handlers do not receive legacy task TTL hints through `RequestHandlerExtra`. +- Required 2026 stateless task creation results to be immediately resolvable + through `tasks/get` before returning `resultType: "task"`. +- Exposed task-store options on `McpServerOptions` and serialized built-in + task-store `tasks/get`/`tasks/cancel` handlers in the 2026 task-extension + wire shape for stateless requests. - Added request-scoped stateless logging gating via `io.modelcontextprotocol/logLevel` metadata so 2026 log notifications are emitted only when the current request opts in. @@ -63,6 +86,28 @@ `input_required` results instead. - Enforced `subscriptions/listen` stream ordering and filters for 2026 subscription notifications. +- Required nested request `_meta` on `subscriptions/listen` requests, matching + the 2026 schema's per-request metadata requirement. +- Rejected mismatched JSON-RPC `jsonrpc` and `method` wrapper constants when + parsing typed `notifications/subscriptions/acknowledged` notifications. +- Rejected mismatched JSON-RPC wrapper constants when directly parsing the + experimental completion list-changed notification. +- Rejected incoming `tools/call` JSON-RPC requests that omit the MCP-required + `params` object. +- Rejected JSON-RPC envelopes that mix request/notification `method` fields + with response `result` or `error` fields, including direct typed + request/notification/error parsing. +- Returned `MissingRequiredClientCapability` (`-32003`) with required task + extension capability data when 2026 task notification subscriptions omit the + per-request `io.modelcontextprotocol/tasks` client capability. +- Rejected deprecated sampling `includeContext` values unless the client + advertises `sampling.context`, while still allowing omitted context and + `includeContext: "none"`. +- Stopped sending `notifications/cancelled` when an outgoing `initialize` + request is aborted or times out, matching the stable lifecycle rule that + clients must not cancel initialization. +- Required `notifications/cancelled` payloads to carry a valid string-or-integer + `requestId` instead of accepting ID-less cancellation notifications. - Retried `server/discover` with an advertised compatible stateless protocol version after `UnsupportedProtocolVersionError` instead of falling back to legacy initialization. @@ -87,6 +132,8 @@ stateless requests use `-32602` with the missing `uri` in error data. - Enforced MCP 2026 `_meta` key-name grammar on stateless request metadata and the 2026 request metadata builder while preserving legacy metadata parsing. +- Exposed typed request-envelope accessors on `RequestHandlerExtra` for + per-request protocol version, client info, and client capabilities metadata. - Rejected negative cacheable-result `ttlMs` values during parsing instead of clamping malformed wire values to zero. - Validated MRTR `inputResponses` as `CreateMessageResult`, `ListRootsResult`, @@ -94,9 +141,10 @@ - Restricted numeric `ElicitResult.content` values to integers, matching the stable and MCP 2026 `string | integer | boolean | string[]` schemas while still accepting whole-number JSON numeric values. -- Made form elicitation number-schema keyword validation protocol-aware: - stable 2025 keeps integer-only `minimum`, `maximum`, and `default` values, - while MCP 2026 accepts fractional number keywords. +- Required integer `minimum`, `maximum`, and `default` values in form + elicitation number schemas for both stable 2025 and MCP 2026. +- Rejected MCP 2026 `CallToolResult.extra` attempts to spoof non-complete + `resultType` values, and added CLI conformance coverage for that guard. - Rejected form elicitation schemas that provide legacy `enumNames` without the required string `enum`. - Rejected `ElicitResult.content` when the result action is `decline` or @@ -189,6 +237,8 @@ notification methods removed from that protocol revision. - Rejected server-initiated JSON-RPC requests received by stateless MCP 2026 clients on generic transports. +- Omitted the removed `roots.listChanged` client capability from MCP 2026 + stateless request metadata while preserving it for stable 2025 metadata. - Rejected stateless MCP 2026 responses that omit `resultType` or required cacheable-result fields. - Stripped caller-supplied `Mcp-Session-Id` headers case-insensitively from diff --git a/doc/interoperability.md b/doc/interoperability.md index 9eaf3597..cbbc701c 100644 --- a/doc/interoperability.md +++ b/doc/interoperability.md @@ -43,7 +43,8 @@ dart test --tags interop If the compiled fixtures are missing, local test runs skip the interop groups; CI should fail when required fixtures are unavailable. The CLI spec conformance gate covers raw-wire negative cases that do not need a -cross-SDK fixture: +cross-SDK fixture, including stable MCP 2025-11-25 checks and MCP 2026-07-28 RC +stateless/discovery/task-extension checks: ```bash cd packages/mcp_dart_cli diff --git a/doc/spec-coverage-2025-11-25.md b/doc/spec-coverage-2025-11-25.md index 4db17cc9..fa9a21f4 100644 --- a/doc/spec-coverage-2025-11-25.md +++ b/doc/spec-coverage-2025-11-25.md @@ -25,6 +25,14 @@ cd ../../.. dart test -t interop ``` +For MCP 2026 RC/final release audits, also run the upstream +machine-readable example corpus through the checked-in typed parsers after +extracting the upstream `modelcontextprotocol` archive: + +```bash +dart run tool/spec_example_audit.dart /path/to/modelcontextprotocol/schema/draft/examples +``` + CI runs both gates: the core workflow runs the TypeScript interop suite and the full CLI conformance gate, while the CLI workflow runs the conformance gate with the CLI test suite. @@ -33,16 +41,21 @@ the CLI test suite. | Spec area | Spec source | Requirement tracked here | Local coverage | Interop coverage | Conformance case or gap | Status | | --- | --- | --- | --- | --- | --- | --- | -| Lifecycle initialization ordering | [Lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) | `initialize` is first, peers do not run normal operations before lifecycle readiness, and `notifications/initialized` transitions the session into normal operation. | [`test/lifecycle_test.dart`](../test/lifecycle_test.dart) | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/ts/src/lifecycle_client.ts`](../test/interop/ts/src/lifecycle_client.ts) | `lifecycle.rejects-pre-initialize-request` | Verified | -| Protocol version negotiation and HTTP header behavior | [Lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle), [Transports](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) | Peers negotiate a supported protocol version, and Streamable HTTP requests carry valid `MCP-Protocol-Version` after initialization. | [`test/client/client_test.dart`](../test/client/client_test.dart), [`test/server/server_test.dart`](../test/server/server_test.dart), [`test/mcp_2025_11_25_test.dart`](../test/mcp_2025_11_25_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | [`test/interop/dart_client_with_ts_server_test.dart`](../test/interop/dart_client_with_ts_server_test.dart), [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart) | `protocol-version.advertises-latest-2025-11-25` | Verified | +| Lifecycle initialization ordering | [Lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) | `initialize` is first, peers do not run normal operations before lifecycle readiness, clients do not attempt to cancel `initialize`, and `notifications/initialized` transitions the session into normal operation. | [`test/lifecycle_test.dart`](../test/lifecycle_test.dart), [`test/shared/protocol_test.dart`](../test/shared/protocol_test.dart) | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/ts/src/lifecycle_client.ts`](../test/interop/ts/src/lifecycle_client.ts) | `lifecycle.rejects-pre-initialize-request`, `lifecycle.gates-until-initialized-notification`, `lifecycle.does-not-cancel-initialize` | Verified | +| Cancellation notifications | [Cancellation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation) | `notifications/cancelled` preserves a string-or-integer JSON-RPC request ID and rejects payloads that omit or malform the ID; task cancellation uses `tasks/cancel` rather than cancellation notifications. | [`test/types_edge_cases_test.dart`](../test/types_edge_cases_test.dart), [`test/shared/protocol_test.dart`](../test/shared/protocol_test.dart) | Covered by TypeScript interop cancellation and task flows where applicable. | `cancellation.requires-request-id`; task cancellation coverage lives in `tasks-extension.task-store-uses-extension-result-shapes`. | Verified | +| Protocol version negotiation and HTTP header behavior | [Lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle), [Transports](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports), [Draft lifecycle](https://modelcontextprotocol.io/specification/draft/basic/lifecycle) | Peers negotiate a supported protocol version, Streamable HTTP requests carry valid `MCP-Protocol-Version` after initialization, draft stateless requests include protocol, client identity, client capabilities, method, name, and parameter routing headers while omitting stable-only capability fields removed from the draft, and draft HTTP clients inspect modern JSON-RPC `400` error bodies before deciding whether to fall back to legacy `initialize`. | [`test/client/client_test.dart`](../test/client/client_test.dart), [`test/server/server_test.dart`](../test/server/server_test.dart), [`test/mcp_2025_11_25_test.dart`](../test/mcp_2025_11_25_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | [`test/interop/dart_client_with_ts_server_test.dart`](../test/interop/dart_client_with_ts_server_test.dart), [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart) | `protocol-version.advertises-latest-2025-11-25`, `stateless.requires-complete-request-meta`, `protocol-version.http-modern-400-retries-discovery`, `capabilities.http-modern-400-does-not-fallback`, `stateless-http.requires-routing-headers`, `stateless-http.validates-parameter-headers`, `stateless-http.encodes-parameter-header-values` | Verified | | Stable schema metadata and capabilities | [Schema reference](https://modelcontextprotocol.io/specification/2025-11-25/schema) | Stable model serializers preserve schema fields such as `Resource.size` and `Root._meta`, emit stable `icons` and annotation fields, and avoid non-stable server capability fields. | [`test/mcp_2025_11_25_test.dart`](../test/mcp_2025_11_25_test.dart), [`test/types/resources_test.dart`](../test/types/resources_test.dart) | Covered by TypeScript interop initialization and list/read flows. | Legacy singular `icon`, `ResourceAnnotations.title`, `ToolAnnotations.priority`, `ToolAnnotations.audience`, top-level server `elicitation`, and `tasks.listChanged` parse for compatibility but do not serialize on stable wire objects. | Verified | -| JSON-RPC responses and strict required fields | [Schema reference JSON-RPC](https://modelcontextprotocol.io/specification/2025-11-25/schema#jsonrpcmessage) | JSON-RPC response IDs preserve string-or-integer identity, successful responses require an `id`, error responses may omit it, and required result arrays are not silently synthesized when absent. | [`test/types_edge_cases_test.dart`](../test/types_edge_cases_test.dart), [`test/types/resources_test.dart`](../test/types/resources_test.dart), [`test/mcp_2025_11_25_test.dart`](../test/mcp_2025_11_25_test.dart) | Covered by TypeScript interop request/response flows. | `jsonrpc.preserves-string-response-id`; additional strict-array regression coverage lives in `test/mcp_2025_11_25_test.dart`. | Verified | -| Negotiated capability enforcement | [Lifecycle capability negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#capability-negotiation), [Sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) | Requests that require an unadvertised feature are rejected before handler code observes them. | [`test/client/client_tool_validation_test.dart`](../test/client/client_tool_validation_test.dart), [`test/client/client_test.dart`](../test/client/client_test.dart), [`test/server/server_test.dart`](../test/server/server_test.dart) | [`test/interop/dart_client_with_ts_server_features_test.dart`](../test/interop/dart_client_with_ts_server_features_test.dart) | `capabilities.rejects-unnegotiated-sampling-tools` | Verified | +| JSON-RPC responses and strict required fields | [Schema reference JSON-RPC](https://modelcontextprotocol.io/specification/2025-11-25/schema#jsonrpcmessage) | JSON-RPC response IDs preserve string-or-integer identity, successful responses require an `id`, error responses may omit it, request/notification envelopes do not mix `method` with response fields, required request params such as `tools/call.params` are not synthesized, and required result arrays are not silently synthesized when absent. | [`test/types_edge_cases_test.dart`](../test/types_edge_cases_test.dart), [`test/types/resources_test.dart`](../test/types/resources_test.dart), [`test/mcp_2025_11_25_test.dart`](../test/mcp_2025_11_25_test.dart) | Covered by TypeScript interop request/response flows. | `jsonrpc.preserves-string-response-id`, `jsonrpc.accepts-omitted-error-response-id`, `jsonrpc.rejects-method-response-envelope`, `tools-call.requires-params`; additional strict-array regression coverage lives in `test/mcp_2025_11_25_test.dart`. | Verified | +| Negotiated capability enforcement | [Lifecycle capability negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#capability-negotiation), [Sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling), [Draft MethodNotFoundError](https://modelcontextprotocol.io/specification/draft/schema#methodnotfounderror) | Requests that require an unadvertised feature are rejected before handler code observes them, and unadvertised peer method capabilities surface `MethodNotFound` (`-32601`). | [`test/client/client_tool_validation_test.dart`](../test/client/client_tool_validation_test.dart), [`test/client/client_test.dart`](../test/client/client_test.dart), [`test/server/server_test.dart`](../test/server/server_test.dart) | [`test/interop/dart_client_with_ts_server_features_test.dart`](../test/interop/dart_client_with_ts_server_features_test.dart) | `capabilities.rejects-unnegotiated-sampling-tools`, `capabilities.rejects-unnegotiated-sampling-context`; `capabilities.unadvertised-peer-methods-use-method-not-found` | Verified | | Tool schema root-object validation | [Tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools), [Schema reference tools](https://modelcontextprotocol.io/specification/2025-11-25/schema#tool) | `Tool.inputSchema` and `Tool.outputSchema` serialize as object-root JSON Schema values and reject primitive root schemas at the wire boundary. | [`test/tool_schema_test.dart`](../test/tool_schema_test.dart), [`test/mcp_2025_11_25_test.dart`](../test/mcp_2025_11_25_test.dart) | Covered by tool-list and tool-call interop tests. | Root object validation is enforced in `Tool.fromJson()` and `Tool.toJson()` while preserving `JsonSchema`-typed source compatibility. | Verified | | Elicitation form/URL variant validation | [Elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) | `elicitation/create` is treated as a discriminated form/URL shape, form schemas use object-root primitive property schemas, URL-required errors contain URL-mode elicitation requests, and invalid mixed payloads are rejected. | [`test/elicitation_test.dart`](../test/elicitation_test.dart), [`test/client/client_elicitation_defaults_test.dart`](../test/client/client_elicitation_defaults_test.dart), [`test/server/server_validation_test.dart`](../test/server/server_validation_test.dart), [`test/mcp_2025_11_25_test.dart`](../test/mcp_2025_11_25_test.dart) | [`test/interop/dart_client_with_ts_server_features_test.dart`](../test/interop/dart_client_with_ts_server_features_test.dart) | `elicitation.rejects-invalid-form-url-union` | Verified | -| Task metadata and related-task propagation | [Schema reference tasks](https://modelcontextprotocol.io/specification/2025-11-25/schema#tasks) | Task-augmented requests require negotiated task support, and related-task metadata is preserved only where task association is valid. | [`test/server/tasks_test.dart`](../test/server/tasks_test.dart), [`test/client/task_client_test.dart`](../test/client/task_client_test.dart), [`test/shared/protocol_task_handlers_test.dart`](../test/shared/protocol_task_handlers_test.dart), [`test/server/tasks_components_test.dart`](../test/server/tasks_components_test.dart), [`test/server/mcp_test.dart`](../test/server/mcp_test.dart) | [`test/interop/dart_client_with_ts_server_task_test.dart`](../test/interop/dart_client_with_ts_server_task_test.dart), [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/ts/src/client.ts`](../test/interop/ts/src/client.ts) | `tasks.strips-unnegotiated-related-task-metadata`; SDK-generated related responses and `tasks/result` overwrite reserved related-task metadata from the source task id while preserving unrelated handler metadata. | Verified | +| Task metadata and related-task propagation | [Schema reference tasks](https://modelcontextprotocol.io/specification/2025-11-25/schema#tasks) | Task-augmented requests require negotiated task support, related-task metadata is preserved only where task association is valid, and clients do not emit legacy task augmentation on 2026 stateless requests where the schema removed it. | [`test/server/tasks_test.dart`](../test/server/tasks_test.dart), [`test/client/task_client_test.dart`](../test/client/task_client_test.dart), [`test/shared/protocol_task_handlers_test.dart`](../test/shared/protocol_task_handlers_test.dart), [`test/server/tasks_components_test.dart`](../test/server/tasks_components_test.dart), [`test/server/mcp_test.dart`](../test/server/mcp_test.dart), [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart) | [`test/interop/dart_client_with_ts_server_task_test.dart`](../test/interop/dart_client_with_ts_server_task_test.dart), [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/ts/src/client.ts`](../test/interop/ts/src/client.ts) | `tasks.strips-unnegotiated-related-task-metadata`, `stateless-client.rejects-legacy-task-options`; SDK-generated related responses and `tasks/result` overwrite reserved related-task metadata from the source task id while preserving unrelated handler metadata. | Verified | +| Draft cacheable result and list stability | [Draft caching](https://modelcontextprotocol.io/specification/draft/server/utilities/caching), [Draft tools](https://modelcontextprotocol.io/specification/draft/server/tools) | Stateless `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, and `resources/read` cacheable results include `resultType`, `ttlMs`, and `cacheScope`; `tools/list` results are deterministic for client-side caching and omit stable-only `Tool.execution` metadata removed from the draft schema. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart) | Covered by stateless conformance until a released 2026 interop fixture is available. | `stateless.adds-result-type-and-cache-defaults`, `tools-list.stateless-returns-deterministic-order`, `tools-list.stateless-omits-legacy-execution` | Verified | +| Resource read error semantics | [Resources](https://modelcontextprotocol.io/specification/2025-11-25/server/resources), [Draft resources](https://modelcontextprotocol.io/specification/draft/server/resources) | Missing resources return the current stable resource-not-found code for legacy requests and draft `InvalidParams` (`-32602`) for 2026 stateless requests, without returning an ambiguous empty `contents` array. | [`test/server/mcp_server_test.dart`](../test/server/mcp_server_test.dart), [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart) | Covered by TypeScript interop resource read flows for successful reads. | `resources.missing-resource-error-code-by-version` | Verified | +| Draft request-scoped logging | [Draft logging](https://modelcontextprotocol.io/specification/draft/server/utilities/logging), [Draft schema request metadata](https://modelcontextprotocol.io/specification/draft/schema#requestmetaobject) | Stateless requests use `io.modelcontextprotocol/logLevel` as the per-request logging opt-in, removed `logging/setLevel` is rejected, and servers do not emit `notifications/message` unless the request opts in. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/server_advanced_test.dart`](../test/server/server_advanced_test.dart) | Covered by stateless conformance until a released 2026 interop fixture is available. | `stateless.rejects-removed-core-rpcs`, `logging.stateless-requires-request-log-level` | Verified | +| Draft notification subscriptions | [Draft subscriptions](https://modelcontextprotocol.io/specification/draft/server/utilities/subscriptions), [Draft schema subscriptions](https://modelcontextprotocol.io/specification/draft/schema#subscriptionslistenrequest) | `subscriptions/listen` requests require per-request `_meta`, acknowledged subscription filters include only supported notification types, and `notifications/subscriptions/acknowledged` typed parsing rejects mismatched JSON-RPC wrapper constants. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart) | Covered by stateless conformance until a released 2026 interop fixture is available. | `subscriptions-listen.requires-request-meta`, `subscriptions-listen.resource-subscriptions-require-capability`, `subscriptions-acknowledged.rejects-wrapper-mismatch` | Verified | | Progress token preservation and progress stream validation | [Progress](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress) | Progress tokens preserve string-or-integer wire shape, malformed token shapes fail at decode boundaries, and progress values should advance monotonically for a request. | [`test/types_edge_cases_test.dart`](../test/types_edge_cases_test.dart), [`test/shared/progress_test.dart`](../test/shared/progress_test.dart), [`test/shared/protocol_test.dart`](../test/shared/protocol_test.dart) | [`test/interop/dart_client_with_ts_server_features_test.dart`](../test/interop/dart_client_with_ts_server_features_test.dart), [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/test_dart_server.dart`](../test/interop/test_dart_server.dart) | `jsonrpc.preserves-string-progress-token`, `progress.rejects-malformed-progress-token`; `RequestHandlerExtra.sendProgress` rejects repeated/decreasing progress before sending invalid notifications. | Verified | -| Streamable HTTP sessions, stale recovery, SSE replay, and batch rejection | [Transports](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) | Session IDs, stale-session retry, initial SSE event IDs for resumability, `Last-Event-ID` replay, protocol-version headers, and JSON-RPC batch rejection follow Streamable HTTP semantics. | [`test/client/streamable_https_test.dart`](../test/client/streamable_https_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | [`test/interop/dart_client_with_ts_server_test.dart`](../test/interop/dart_client_with_ts_server_test.dart), [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/ts/src/replay_client.ts`](../test/interop/ts/src/replay_client.ts) | Covered by `dart test -t interop` and unit tests; no separate CLI raw-wire case yet because this requires HTTP server fixtures. | Verified | +| Streamable HTTP sessions, stateless connection-independence, stale recovery, SSE replay, and batch rejection | [Transports](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports), [Draft lifecycle](https://modelcontextprotocol.io/specification/draft/basic/lifecycle) | Session IDs, stale-session retry, initial SSE event IDs for resumability, `Last-Event-ID` replay, protocol-version headers, JSON-RPC batch rejection, and draft stateless connection-independence are covered by Streamable HTTP and stateless lifecycle checks. Draft HTTP GET/DELETE removal is enforced with `405 Method Not Allowed`, and draft stateless POST bodies are rejected unless they contain exactly one JSON-RPC message. | [`test/client/streamable_https_test.dart`](../test/client/streamable_https_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart), [`test/server/streamable_mcp_server_test.dart`](../test/server/streamable_mcp_server_test.dart) | [`test/interop/dart_client_with_ts_server_test.dart`](../test/interop/dart_client_with_ts_server_test.dart), [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/ts/src/replay_client.ts`](../test/interop/ts/src/replay_client.ts) | `stateless-http.rejects-non-post-methods`, `stateless-http.rejects-batch-payloads`; `stateless.related-task-uses-explicit-id-across-transports` covers draft related operations across separate transports. | Verified | | Auth/security deployment behavior | [Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization), [Transports security notes](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning) | OAuth, DNS rebinding, Origin/Host restrictions, and production deployment toggles are covered by executable harnesses where practical. OAuth authorization-code clients require authorization servers to advertise PKCE `S256`. | [`test/client/streamable_https_test.dart`](../test/client/streamable_https_test.dart), [`test/server/streamable_security_harness_test.dart`](../test/server/streamable_security_harness_test.dart), [`test/server/streamable_mcp_server_test.dart`](../test/server/streamable_mcp_server_test.dart), [`test/example/oauth_client_example_test.dart`](../test/example/oauth_client_example_test.dart), [`example/authentication/`](../example/authentication/), [`doc/transports.md`](transports.md) | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/ts/src/oauth_client.ts`](../test/interop/ts/src/oauth_client.ts) | Safe local-development and production Host/Origin scenarios, bearer-token gating, compatibility-toggle trade-offs, first-class OAuth protected-resource metadata/challenges, OAuth insufficient-scope 403 challenges, official TypeScript SDK upscoping, OAuth protected-resource discovery, PKCE S256 authorization redirect, resource-bound token exchange, missing-PKCE-metadata refusal, and bearer reconnect are covered by tests. | Verified | ## Stable Conformance Case Names @@ -52,14 +65,80 @@ without relying on output text: - `jsonrpc.rejects-invalid-version` - `jsonrpc.rejects-malformed-message` +- `jsonrpc.rejects-non-string-method` +- `jsonrpc.rejects-result-error-response` +- `jsonrpc.rejects-method-response-envelope` +- `jsonrpc.rejects-malformed-error-object` +- `jsonrpc.rejects-null-error-response-id` +- `jsonrpc.accepts-omitted-error-response-id` +- `jsonrpc.rejects-null-params-member` +- `tools-call.requires-params` - `jsonrpc.preserves-string-response-id` +- `jsonrpc.preserves-integer-response-id` - `jsonrpc.preserves-string-progress-token` +- `jsonrpc.preserves-integer-progress-token` +- `jsonrpc.rejects-fractional-ids-and-progress-tokens` - `protocol-version.advertises-latest-2025-11-25` - `lifecycle.rejects-pre-initialize-request` +- `lifecycle.gates-until-initialized-notification` +- `lifecycle.does-not-cancel-initialize` +- `cancellation.requires-request-id` - `capabilities.rejects-unnegotiated-sampling-tools` +- `capabilities.rejects-unnegotiated-sampling-context` +- `capabilities.unadvertised-peer-methods-use-method-not-found` +- `capabilities.task-scoped-peer-methods-use-method-not-found` - `elicitation.rejects-invalid-form-url-union` - `tasks.strips-unnegotiated-related-task-metadata` - `progress.rejects-malformed-progress-token` +- `progress.dispatches-integer-progress-token` + +The same CLI gate also includes draft MCP 2026-07-28 RC cases while that spec +is being prepared: + +- `protocol-version.advertises-draft-2026-07-28` +- `server-discover.requires-request-meta` +- `server-discover.returns-draft-capabilities` +- `protocol-version.rejects-unsupported-stateless-version` +- `stateless.requires-complete-request-meta` +- `protocol-version.http-modern-400-retries-discovery` +- `capabilities.http-modern-400-does-not-fallback` +- `protocol-version.initialize-negotiates-stateful-version` +- `capabilities.stateless-does-not-infer-initialize-extensions` +- `stateless-http.rejects-mismatched-routing-headers` +- `stateless-http.requires-routing-headers` +- `stateless-http.rejects-non-post-methods` +- `stateless-http.rejects-batch-payloads` +- `stateless-http.task-requests-require-name-header` +- `stateless-http.validates-parameter-headers` +- `stateless-http.omits-invalid-numeric-parameter-headers` +- `stateless-http.encodes-parameter-header-values` +- `stateless-http.accepts-response-posts` +- `stateless-http.task-subscription-requires-client-capability` +- `stateless-http.omits-session-header-after-initialize` +- `stateless.related-task-uses-explicit-id-across-transports` +- `stateless.ignores-legacy-task-parameter` +- `stateless-client.rejects-legacy-task-options` +- `stateless.adds-result-type-and-cache-defaults` +- `tools-list.stateless-returns-deterministic-order` +- `tools-list.stateless-omits-legacy-execution` +- `resources.missing-resource-error-code-by-version` +- `stateless.rejects-unrecognized-result-type` +- `mrtr.input-required-supported-requests` +- `mrtr.rejects-unsupported-input-required-results` +- `mrtr.input-requests-require-client-capabilities` +- `stateless.rejects-removed-core-rpcs` +- `stateless.rejects-removed-core-notifications` +- `logging.stateless-requires-request-log-level` +- `tasks-extension.lifecycle-methods-do-not-require-repeated-capability` +- `tasks-extension.task-store-uses-extension-result-shapes` +- `tasks-extension.call-tool-result-cannot-spoof-task-result` +- `tasks-extension.task-result-requires-client-extension` +- `subscriptions-listen.task-ids-require-client-capability` +- `subscriptions-listen.requires-request-meta` +- `subscriptions-listen.resource-subscriptions-require-capability` +- `subscriptions-acknowledged.rejects-wrapper-mismatch` +- `capabilities.stateless-omits-legacy-task-capabilities` +- `elicitation.accepts-numeric-number-schema-keywords` Use exact-case filtering when diagnosing one row: diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 506b6e8f..c97329a6 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -266,7 +266,7 @@ class McpClient extends Protocol { request.createParams.toolChoice != null) && _capabilities.sampling?.tools != true) { throw McpError( - ErrorCode.invalidRequest.value, + ErrorCode.methodNotFound.value, "Client does not support 'sampling.tools' capability required by sampling/createMessage request.", ); } @@ -510,6 +510,10 @@ class McpClient extends Protocol { if (error.code == ErrorCode.methodNotFound.value) { return true; } + if (error.code == ErrorCode.invalidParams.value && + error.message.contains('Invalid request parameters')) { + return true; + } final message = error.message; if (error.code == 0 && @@ -981,6 +985,15 @@ class McpClient extends Protocol { @override McpError? validateIncomingRequest(JsonRpcRequest request) { if (_usesStatelessProtocol) { + final missingPeerCapability = + _missingPeerCapabilityForIncomingRequest(request.method); + if (missingPeerCapability != null) { + return McpError( + ErrorCode.methodNotFound.value, + "Client does not support capability '$missingPeerCapability' " + "required for method '${request.method}'", + ); + } return McpError( ErrorCode.invalidRequest.value, 'Server-initiated JSON-RPC requests are not supported in stateless ' @@ -998,6 +1011,17 @@ class McpClient extends Protocol { ); } + String? _missingPeerCapabilityForIncomingRequest(String method) { + return switch (method) { + Method.rootsList => _capabilities.roots == null ? 'roots' : null, + Method.samplingCreateMessage => + _capabilities.sampling == null ? 'sampling' : null, + Method.elicitationCreate => + _capabilities.elicitation == null ? 'elicitation' : null, + _ => null, + }; + } + @override McpError? validateIncomingNotification(JsonRpcNotification notification) { if (_sentInitialized) { @@ -1111,7 +1135,7 @@ class McpClient extends Protocol { if (!supported) { throw McpError( - ErrorCode.invalidRequest.value, + ErrorCode.methodNotFound.value, "Server does not support capability '$requiredCapability' required for method '$method'", ); } @@ -1178,7 +1202,7 @@ class McpClient extends Protocol { if (missingCapability != null) { throw McpError( - ErrorCode.invalidRequest.value, + ErrorCode.methodNotFound.value, "Server does not support capability '$missingCapability' required for task-based '$method'", ); } diff --git a/lib/src/client/streamable_https.dart b/lib/src/client/streamable_https.dart index fdfe65ff..044f3c97 100644 --- a/lib/src/client/streamable_https.dart +++ b/lib/src/client/streamable_https.dart @@ -643,9 +643,11 @@ class StreamableHttpClientTransport } String? _toolParameterHeaderString(Object? value) { - final integer = _safeHeaderInteger(value); - if (integer != null) { - return integer.toString(); + if (value is int) { + if (value < _minSafeHeaderInteger || value > _maxSafeHeaderInteger) { + return null; + } + return value.toString(); } return switch (value) { @@ -655,25 +657,6 @@ class StreamableHttpClientTransport }; } - int? _safeHeaderInteger(Object? value) { - if (value is int) { - if (value < _minSafeHeaderInteger || value > _maxSafeHeaderInteger) { - return null; - } - return value; - } - - if (value is double && - value.isFinite && - value.truncateToDouble() == value && - value >= _minSafeHeaderInteger && - value <= _maxSafeHeaderInteger) { - return value.toInt(); - } - - return null; - } - String _encodeToolParameterHeaderValue(String value) { if (_isPlainToolParameterHeaderValue(value)) { return value; @@ -754,9 +737,9 @@ class StreamableHttpClientTransport Method.toolsCall => params['name'], Method.resourcesRead => params['uri'], Method.promptsGet => params['name'], - Method.tasksCancel || Method.tasksGet || - Method.tasksUpdate => + Method.tasksUpdate || + Method.tasksCancel => params['taskId'], _ => null, }; @@ -1295,6 +1278,13 @@ class StreamableHttpClientTransport } return; } + if (_dispatchHttpJsonRpcErrorBody( + text, + message, + rejectServerRequests: isStatelessRequest, + )) { + return; + } throw McpError( 0, "Error POSTing to endpoint (HTTP ${response.statusCode}): $text", @@ -1397,6 +1387,43 @@ class StreamableHttpClientTransport } } + bool _dispatchHttpJsonRpcErrorBody( + String body, + JsonRpcMessage requestMessage, { + required bool rejectServerRequests, + }) { + if (requestMessage is! JsonRpcRequest || body.trim().isEmpty) { + return false; + } + + try { + final decoded = jsonDecode(body); + final responseCandidates = decoded is List ? decoded : [decoded]; + var dispatched = false; + + for (final candidate in responseCandidates) { + if (candidate is! Map) { + continue; + } + final parsed = JsonRpcMessage.fromJson( + candidate.cast(), + ); + if (parsed is! JsonRpcError || parsed.id != requestMessage.id) { + continue; + } + _dispatchReceivedMessage( + parsed, + rejectServerRequests: rejectServerRequests, + ); + dispatched = true; + } + + return dispatched; + } catch (_) { + return false; + } + } + @override String? get sessionId => _sessionId; diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 726e2c01..543222fc 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -25,6 +25,10 @@ class McpServerOptions extends ProtocolOptions { const McpServerOptions({ super.enforceStrictCapabilities, + super.taskStore, + super.taskMessageQueue, + super.defaultTaskPollInterval, + super.maxTaskQueueSize, this.capabilities, this.instructions, }); @@ -256,9 +260,11 @@ class Server extends Protocol { return McpError( ErrorCode.missingRequiredClientCapability.value, 'Missing required client capability', - { + const { 'requiredCapabilities': { - 'extensions': {mcpTasksExtensionId: {}}, + 'extensions': { + mcpTasksExtensionId: {}, + }, }, }, ); @@ -402,12 +408,8 @@ class Server extends Protocol { McpError? _validateTasksExtensionCapabilities(JsonRpcRequest request) { final requiresTasksExtension = - (request is JsonRpcSubscriptionsListenRequest && - request.listenParams.notifications.taskIds != null) || - (_isStatelessRequest(request) && - (request.method == Method.tasksGet || - request.method == Method.tasksCancel || - request.method == Method.tasksUpdate)); + request is JsonRpcSubscriptionsListenRequest && + request.listenParams.notifications.taskIds != null; if (!requiresTasksExtension) { return null; @@ -529,21 +531,112 @@ class Server extends Protocol { return null; } - bool _allowsToolCallResult(BaseResultData result, JsonRpcRequest request) { + Future _allowsToolCallResult( + BaseResultData result, + JsonRpcRequest request, + RequestHandlerExtra extra, + ) async { if (result is CallToolResult) { + _validateCallToolResult(result, request); return true; } if (_allowsInputRequiredResult(result, request)) { return true; } if (result is CreateTaskExtensionResult && _isStatelessRequest(request)) { - _assertTasksExtensionClientCapability(request); + await _validateTaskCreationResult(result, request, extra); return true; } return false; } + void _validateCallToolResult( + CallToolResult result, + JsonRpcRequest request, + ) { + if (!_isStatelessRequest(request)) { + return; + } + + final resultType = result.extra?['resultType']; + if (resultType == null || resultType == resultTypeComplete) { + return; + } + + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid ${request.method} result: CallToolResult cannot set MCP ' + 'resultType "$resultType"; use InputRequiredResult or ' + 'CreateTaskExtensionResult.', + ); + } + + Future _validateTaskCreationResult( + CreateTaskExtensionResult result, + JsonRpcRequest request, + RequestHandlerExtra extra, + ) async { + if (!_capabilities.supportsTasksExtension) { + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid ${request.method} result: CreateTaskExtensionResult requires ' + 'server support for $mcpTasksExtensionId.', + ); + } + + _assertTasksExtensionClientCapability(request); + + if (!canHandleRequestMethod(Method.tasksGet)) { + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid ${request.method} result: CreateTaskExtensionResult requires ' + 'a tasks/get handler so ${result.task.taskId} can be resolved.', + ); + } + + final resolvedResult = await _resolveCreatedTask(result, request, extra); + if (resolvedResult is! GetTaskExtensionResult) { + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid ${request.method} result: tasks/get for ' + '${result.task.taskId} must return GetTaskExtensionResult.', + ); + } + if (resolvedResult.task.taskId != result.task.taskId) { + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid ${request.method} result: tasks/get resolved ' + '${resolvedResult.task.taskId} instead of ${result.task.taskId}.', + ); + } + } + + Future _resolveCreatedTask( + CreateTaskExtensionResult result, + JsonRpcRequest request, + RequestHandlerExtra extra, + ) async { + try { + return await invokeRequestHandlerForValidation( + JsonRpcGetTaskRequest( + id: request.id, + getParams: GetTaskRequest(taskId: result.task.taskId), + meta: request.meta, + ), + extra, + ); + } catch (error) { + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid ${request.method} result: CreateTaskExtensionResult taskId ' + '${result.task.taskId} must be resolvable by tasks/get before ' + 'returning.', + error.toString(), + ); + } + } + bool _allowsPromptGetResult(BaseResultData result, JsonRpcRequest request) { return result is GetPromptResult || _allowsInputRequiredResult(result, request); @@ -773,6 +866,20 @@ class Server extends Protocol { }; } + void _omitStatelessLegacyToolExecution( + Map resultJson, + ) { + final tools = resultJson['tools']; + if (tools is! List) { + return; + } + for (final tool in tools) { + if (tool is Map) { + tool.remove('execution'); + } + } + } + @override Map serializeIncomingResult( JsonRpcRequest request, @@ -783,6 +890,10 @@ class Server extends Protocol { return json; } + if (request.method == Method.toolsList) { + _omitStatelessLegacyToolExecution(json); + } + json.putIfAbsent('resultType', () => resultTypeComplete); if (_requiresCacheableResult(request.method)) { json.putIfAbsent( @@ -860,7 +971,7 @@ class Server extends Protocol { ); } } else { - if (!_allowsToolCallResult(result, request)) { + if (!await _allowsToolCallResult(result, request, extra)) { throw McpError( ErrorCode.invalidParams.value, "Invalid tools/call result: Expected CallToolResult", @@ -985,7 +1096,7 @@ class Server extends Protocol { case Method.samplingCreateMessage: if (!(_clientCapabilities?.sampling != null)) { throw McpError( - ErrorCode.invalidRequest.value, + ErrorCode.methodNotFound.value, "Client does not support sampling (required for server to send $method)", ); } @@ -994,7 +1105,7 @@ class Server extends Protocol { case Method.rootsList: if (!(_clientCapabilities?.roots != null)) { throw McpError( - ErrorCode.invalidRequest.value, + ErrorCode.methodNotFound.value, "Client does not support listing roots (required for server to send $method)", ); } @@ -1003,7 +1114,7 @@ class Server extends Protocol { case Method.elicitationCreate: if (!(_clientCapabilities?.elicitation != null)) { throw McpError( - ErrorCode.invalidRequest.value, + ErrorCode.methodNotFound.value, "Client does not support elicitation (required for server to send $method)", ); } @@ -1168,9 +1279,10 @@ class Server extends Protocol { case Method.tasksList: case Method.tasksResult: - if (!(_capabilities.tasks != null)) { + if (!(_capabilities.tasks != null || + _capabilities.supportsTasksExtension)) { throw StateError( - "Server setup error: Cannot handle '$method' without 'tasks' capability", + "Server setup error: Cannot handle '$method' without 'tasks' capability or '$mcpTasksExtensionId' extension", ); } break; @@ -1217,7 +1329,7 @@ class Server extends Protocol { if (missingCapability != null) { throw McpError( - ErrorCode.invalidRequest.value, + ErrorCode.methodNotFound.value, "Client does not support capability '$missingCapability' required for task-based '$method'", ); } @@ -1257,11 +1369,19 @@ class Server extends Protocol { if (params.tools != null || params.toolChoice != null) { if (!(_clientCapabilities?.sampling?.tools ?? false)) { throw McpError( - ErrorCode.invalidRequest.value, + ErrorCode.methodNotFound.value, "Client does not support sampling tools capability.", ); } } + if (params.includeContext != null && + params.includeContext != IncludeContext.none && + !(_clientCapabilities?.sampling?.context ?? false)) { + throw McpError( + ErrorCode.methodNotFound.value, + "Client does not support sampling context capability.", + ); + } // Message structure validation - always validate tool_use/tool_result pairs. if (params.messages.isNotEmpty) { diff --git a/lib/src/server/streamable_https.dart b/lib/src/server/streamable_https.dart index b0994a79..101f497a 100644 --- a/lib/src/server/streamable_https.dart +++ b/lib/src/server/streamable_https.dart @@ -10,8 +10,6 @@ import '../shared/transport.dart'; import '../types.dart'; import 'dns_rebinding_protection.dart'; -const int _maxSafeHeaderInteger = 9007199254740991; -const int _minSafeHeaderInteger = -9007199254740991; const String _xAccelBufferingHeader = 'X-Accel-Buffering'; /// ID for SSE streams @@ -497,9 +495,9 @@ class StreamableHTTPServerTransport Method.toolsCall => params['name'], Method.resourcesRead => params['uri'], Method.promptsGet => params['name'], - Method.tasksCancel || Method.tasksGet || - Method.tasksUpdate => + Method.tasksUpdate || + Method.tasksCancel => params['taskId'], _ => null, }; @@ -520,9 +518,14 @@ class StreamableHTTPServerTransport } String? _primitiveHeaderString(Object? value) { - final integer = _safeHeaderInteger(value); - if (integer != null) { - return integer.toString(); + if (value is num) { + if (!value.isFinite) { + return null; + } + if (value is double && value.truncateToDouble() == value) { + return value.toInt().toString(); + } + return value.toString(); } return switch (value) { @@ -533,30 +536,15 @@ class StreamableHTTPServerTransport }; } - int? _safeHeaderInteger(Object? value) { - if (value is int) { - if (value < _minSafeHeaderInteger || value > _maxSafeHeaderInteger) { - return null; - } - return value; - } - - if (value is double && - value.isFinite && - value.truncateToDouble() == value && - value >= _minSafeHeaderInteger && - value <= _maxSafeHeaderInteger) { - return value.toInt(); - } - - return null; - } - bool _headerValueMatchesPrimitive(Object? bodyValue, String headerValue) { - final integer = _safeHeaderInteger(bodyValue); - if (integer != null) { - final headerInteger = _safeHeaderInteger(num.tryParse(headerValue)); - return headerInteger != null && headerInteger == integer; + if (bodyValue is num) { + if (!bodyValue.isFinite) { + return false; + } + final headerNumber = num.tryParse(headerValue); + return headerNumber != null && + headerNumber.isFinite && + headerNumber == bodyValue; } final value = _primitiveHeaderString(bodyValue); @@ -805,6 +793,10 @@ class StreamableHTTPServerTransport return false; } + if (message is JsonRpcResponse || message is JsonRpcError) { + return true; + } + final metadataVersion = _nestedMetadataProtocolVersion(messageJson); if (metadataVersion == null) { await _writeHeaderMismatchResponse( @@ -1392,6 +1384,8 @@ class StreamableHTTPServerTransport } } + final usesStatelessHttpValidation = + _usesStatelessHttpValidation(req, messages); if (!await _validateStatelessHttpHeaders(req, messages, messageJsons)) { return; } @@ -1400,6 +1394,8 @@ class StreamableHTTPServerTransport // https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle/ final isInitializationRequest = messages.any(_isInitializeRequest); final isStatelessRequest = messages.any(_isStatelessJsonRpcRequest); + final isStatelessMessage = + usesStatelessHttpValidation || isStatelessRequest; if (isInitializationRequest) { final requestSessionId = req.headers.value('mcp-session-id'); @@ -1475,7 +1471,7 @@ class StreamableHTTPServerTransport // clients using the Streamable HTTP transport MUST include it // in the Mcp-Session-Id header on all of their subsequent HTTP requests. if (!isInitializationRequest && - !isStatelessRequest && + !isStatelessMessage && !await _validateSession(req, req.response)) { return; } @@ -1506,7 +1502,7 @@ class StreamableHTTPServerTransport final headers = _sseResponseHeaders(); // After initialization, always include the session ID if we have one - if (sessionId != null) { + if (sessionId != null && !isStatelessRequest) { headers["mcp-session-id"] = sessionId!; } @@ -1860,7 +1856,10 @@ class StreamableHTTPServerTransport HttpHeaders.contentTypeHeader: 'application/json; charset=utf-8', }; - if (sessionId != null) { + final isStatelessResponse = relatedIds.any( + (id) => _statelessRequestIds.contains(id), + ); + if (sessionId != null && !isStatelessResponse) { headers['mcp-session-id'] = sessionId!; } diff --git a/lib/src/server/streamable_mcp_server.dart b/lib/src/server/streamable_mcp_server.dart index 9cdb615e..d0d711b1 100644 --- a/lib/src/server/streamable_mcp_server.dart +++ b/lib/src/server/streamable_mcp_server.dart @@ -258,6 +258,12 @@ class StreamableMcpServer { /// Port to bind the HTTP server to. final int port; + /// Port currently bound by the HTTP server. + /// + /// This differs from [port] when the server was configured with `port: 0` + /// and the operating system selected an available port during [start]. + int get boundPort => _httpServer?.port ?? port; + /// Path to listen for MCP requests on. final String path; @@ -334,7 +340,7 @@ class StreamableMcpServer { _httpServer = await HttpServer.bind(host, port); _logger.info( - 'MCP Streamable HTTP Server listening on http://$host:$port$path', + 'MCP Streamable HTTP Server listening on http://$host:$boundPort$path', ); final httpServer = _httpServer; diff --git a/lib/src/shared/json_schema/json_schema.dart b/lib/src/shared/json_schema/json_schema.dart index c9e0b927..731b7825 100644 --- a/lib/src/shared/json_schema/json_schema.dart +++ b/lib/src/shared/json_schema/json_schema.dart @@ -32,14 +32,35 @@ sealed class JsonSchema { } static JsonSchema _fromJson(Map json) { - if (_hasMcpHeaderOnNonPrimitiveSchema(json)) { - return JsonAny.fromJson(json); - } - if (JsonEnum._canParse(json)) { return JsonEnum.fromJson(json); } + final type = json['type']; + if (json.containsKey('type')) { + if (type is List) { + if (!_isValidJsonTypeArray(type)) { + throw const FormatException( + 'JsonSchema.type must be a non-empty array of unique JSON Schema type strings', + ); + } + } else if (type is String) { + if (!_knownJsonTypes.contains(type)) { + throw FormatException( + "JsonSchema.type '$type' is not a supported JSON Schema type", + ); + } + } else { + throw const FormatException( + 'JsonSchema.type must be a string or array of strings', + ); + } + } + + if (_hasMcpHeaderOnNonPrimitiveSchema(json)) { + return JsonAny.fromJson(json); + } + final conjunctiveSchema = _splitConjunctiveSchema(json); if (conjunctiveSchema != null) { return conjunctiveSchema; @@ -61,11 +82,7 @@ sealed class JsonSchema { return JsonNot.fromJson(json); } - final type = json['type']; if (type is List) { - if (!_isValidJsonTypeArray(type)) { - return JsonAny.fromJson(json); - } return JsonUnion.fromJson(json); } if (type is String) { @@ -561,7 +578,11 @@ class JsonNumber extends JsonSchema { final num? exclusiveMaximum; final num? multipleOf; - /// MCP `x-mcp-header` extension for mirroring this parameter into HTTP. + /// MCP `x-mcp-header` extension metadata. + /// + /// This is preserved for schema round-tripping. MCP 2026 stateless + /// Streamable HTTP header mirroring only accepts string, integer, and boolean + /// schemas, so number schemas carrying this metadata are not mirrored. final String? mcpHeader; const JsonNumber({ diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 813fd086..d4717c86 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -128,6 +128,34 @@ class RequestHandlerExtra { /// Metadata from the original request. final Map? meta; + /// MCP protocol version from the request metadata, when present. + String? get protocolVersion { + final value = meta?[McpMetaKey.protocolVersion]; + return value is String ? value : null; + } + + /// Client implementation from the request metadata, when present. + Implementation? get clientInfo { + final value = meta?[McpMetaKey.clientInfo]; + if (value == null) { + return null; + } + return Implementation.fromJson( + readJsonObject(value, 'RequestHandlerExtra.clientInfo'), + ); + } + + /// Client capabilities from the request metadata, when present. + ClientCapabilities? get clientCapabilities { + final value = meta?[McpMetaKey.clientCapabilities]; + if (value == null) { + return null; + } + return ClientCapabilities.fromJson( + readJsonObject(value, 'RequestHandlerExtra.clientCapabilities'), + ); + } + /// Information about a validated access token. final AuthInfo? authInfo; @@ -589,6 +617,14 @@ abstract class Protocol { 'Failed to retrieve task: Task not found', ); } + if (_usesStatelessResultTypes(request)) { + return GetTaskExtensionResult( + task: await _taskExtensionTaskFromStore( + task, + extra.sessionId, + ), + ); + } return task; }, (id, params, meta) => JsonRpcGetTaskRequest.fromJson({ @@ -662,6 +698,9 @@ abstract class Protocol { 'Task not found after cancellation: $taskId', ); } + if (_usesStatelessResultTypes(request)) { + return const TaskExtensionAcknowledgementResult(); + } return cancelledTask; } catch (error) { if (error is McpError) rethrow; @@ -682,6 +721,50 @@ abstract class Protocol { ); } + Future _taskExtensionTaskFromStore( + Task task, + String? sessionId, + ) async { + Map? result; + JsonRpcErrorData? error; + InputRequests? inputRequests; + + switch (task.status) { + case TaskStatus.completed: + result = (await _taskStore!.getTaskResult( + task.taskId, + sessionId, + )) + .toJson(); + break; + case TaskStatus.failed: + error = JsonRpcErrorData( + code: ErrorCode.internalError.value, + message: task.statusMessage ?? 'Task failed', + ); + break; + case TaskStatus.inputRequired: + inputRequests = const {}; + break; + case TaskStatus.working: + case TaskStatus.cancelled: + break; + } + + return TaskExtensionTask( + taskId: task.taskId, + status: task.status, + statusMessage: task.statusMessage, + createdAt: task.createdAt, + lastUpdatedAt: task.lastUpdatedAt, + ttlMs: task.ttl, + pollIntervalMs: task.pollInterval, + inputRequests: inputRequests, + result: result, + error: error, + ); + } + /// Attaches to the given transport, starts it, and starts listening for messages. Future connect(Transport transport) async { if (_transport != null) { @@ -695,7 +778,7 @@ abstract class Protocol { validateIncomingRequest, ); validationAwareTransport - .setRequestMethodSupported(_supportsRequestMethod); + .setRequestMethodSupported(canHandleRequestMethod); } _transport!.onclose = _onclose; _transport!.onerror = _onerror; @@ -733,9 +816,31 @@ abstract class Protocol { } } - bool _supportsRequestMethod(String method) => + @protected + bool canHandleRequestMethod(String method) => _requestHandlers.containsKey(method) || fallbackRequestHandler != null; + @protected + Future invokeRequestHandlerForValidation( + JsonRpcRequest request, + RequestHandlerExtra extra, + ) { + final registeredHandler = _requestHandlers[request.method]; + if (registeredHandler != null) { + return registeredHandler(request, extra); + } + + final fallbackHandler = fallbackRequestHandler; + if (fallbackHandler != null) { + return fallbackHandler(request); + } + + throw McpError( + ErrorCode.methodNotFound.value, + 'Method not found: ${request.method}', + ); + } + /// Gets the currently attached transport, or null if not connected. Transport? get transport => _transport; @@ -951,6 +1056,23 @@ abstract class Protocol { return token; } + bool _usesStatelessRequestShape(JsonRpcRequest request) { + final requestProtocolVersion = request.meta?[McpMetaKey.protocolVersion]; + if (requestProtocolVersion is String) { + return isStatelessProtocolVersion(requestProtocolVersion); + } + + final activeTransport = _transport; + if (activeTransport is! ProtocolVersionAwareTransport) { + return false; + } + final versionAwareTransport = + activeTransport as ProtocolVersionAwareTransport; + final transportProtocolVersion = versionAwareTransport.protocolVersion; + return transportProtocolVersion != null && + isStatelessProtocolVersion(transportProtocolVersion); + } + Map? _mergeRelatedTaskMeta( Map? meta, Map? relatedTaskJson, @@ -1355,10 +1477,13 @@ abstract class Protocol { final subscriptionState = request is JsonRpcSubscriptionsListenRequest ? _SubscriptionStreamState() : null; + final usesStatelessResultTypes = _usesStatelessResultTypes(request); + final requestSessionId = + usesStatelessResultTypes ? null : _transport?.sessionId; final extra = RequestHandlerExtra( signal: abortController.signal, - sessionId: _transport?.sessionId, + sessionId: requestSessionId, requestId: request.id, meta: request.meta, taskId: relatedTaskId, @@ -1366,14 +1491,16 @@ abstract class Protocol { ? _RequestTaskStoreImpl( _taskStore!, request, - _transport?.sessionId, + requestSessionId, this, ) : null, - taskRequestedTtl: readOptionalInteger( - (request.params?['task'] as Map?)?['ttl'], - 'RequestOptions.task.ttl', - ), + taskRequestedTtl: usesStatelessResultTypes + ? null + : readOptionalInteger( + (request.params?['task'] as Map?)?['ttl'], + 'RequestOptions.task.ttl', + ), sendNotification: (notification, {relatedTask}) { var outgoingNotification = notification; if (subscriptionState != null) { @@ -1421,8 +1548,9 @@ abstract class Protocol { } // If task creation is requested, check capability - if (extra.taskRequestedTtl != null || - request.params?.containsKey('task') == true) { + if (!usesStatelessResultTypes && + (extra.taskRequestedTtl != null || + request.params?.containsKey('task') == true)) { try { assertTaskHandlerCapability(request.method); } catch (e) { @@ -1445,7 +1573,7 @@ abstract class Protocol { relatedTaskId, TaskStatus.inputRequired, null, - _transport?.sessionId, + requestSessionId, ); } @@ -1749,6 +1877,17 @@ abstract class Protocol { Map? finalMeta = requestData.meta; Map? finalParams = requestData.params; + final usesStatelessRequestShape = _usesStatelessRequestShape(requestData); + + if (usesStatelessRequestShape && options?.task != null) { + return Future.error( + McpError( + ErrorCode.invalidRequest.value, + 'RequestOptions.task is not supported for stateless MCP requests; ' + 'use the $mcpTasksExtensionId extension flow instead.', + ), + ); + } if (options?.onprogress != null) { final currentMeta = Map.from(finalMeta ?? {}); @@ -1854,6 +1993,12 @@ abstract class Protocol { _cleanupProgressHandler(messageId); _cleanupTimeout(messageId); + // MCP 2025-11-25 forbids clients from cancelling `initialize`. + if (jsonrpcRequest.method == Method.initialize) { + completer.completeError(errorReason); + return; + } + final cancelReason = reason?.toString() ?? 'Request cancelled'; final notification = JsonRpcCancelledNotification( cancelParams: CancelledNotification( diff --git a/lib/src/types.dart b/lib/src/types.dart index 7f392fc4..a8147418 100644 --- a/lib/src/types.dart +++ b/lib/src/types.dart @@ -6,6 +6,7 @@ export 'types/tools.dart'; export 'types/tasks.dart'; export 'types/json_rpc.dart' hide + expectJsonRpcMethod, extractRequestMeta, parseProgressToken, parseRequestId, diff --git a/lib/src/types/completion.dart b/lib/src/types/completion.dart index 8b5d971b..a67e302a 100644 --- a/lib/src/types/completion.dart +++ b/lib/src/types/completion.dart @@ -6,14 +6,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } void _expectType( @@ -319,8 +312,16 @@ class JsonRpcCompletionListChangedNotification extends JsonRpcNotification { factory JsonRpcCompletionListChangedNotification.fromJson( Map json, - ) => - JsonRpcCompletionListChangedNotification(meta: extractRequestMeta(json)); + ) { + _expectJsonRpcMethod( + json, + Method.notificationsExperimentalCompletionsListChanged, + 'JsonRpcCompletionListChangedNotification', + ); + return JsonRpcCompletionListChangedNotification( + meta: extractRequestMeta(json), + ); + } } /// Deprecated alias for [CompleteRequest]. diff --git a/lib/src/types/elicitation.dart b/lib/src/types/elicitation.dart index 6499b826..d59e14ee 100644 --- a/lib/src/types/elicitation.dart +++ b/lib/src/types/elicitation.dart @@ -8,15 +8,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } /// Legacy alias for [JsonSchema] used in elicitation requests. @@ -577,11 +569,7 @@ void _validatePrimitiveSchema( context, ); _validatePrimitiveBaseKeywords(json, context); - _validateNumberSchemaKeywords( - json, - context, - protocolVersion: protocolVersion, - ); + _validateNumberSchemaKeywords(json, context); return; case 'boolean': _ensureAllowedKeys( @@ -614,20 +602,10 @@ void _validatePrimitiveBaseKeywords( void _validateNumberSchemaKeywords( Map json, - String context, { - String? protocolVersion, -}) { - if (!_usesDraftNumberSchemaKeywords(protocolVersion)) { - for (final key in const ['default', 'minimum', 'maximum']) { - _validateOptionalIntegerKeyword(json, key, context); - } - return; - } - + String context, +) { for (final key in const ['default', 'minimum', 'maximum']) { - if (json[key] != null) { - readFiniteNumber(json[key], '$context.$key'); - } + readOptionalFiniteNumber(json[key], '$context.$key'); } } @@ -804,10 +782,6 @@ String? _protocolVersionFromMeta(Map? meta) { return protocolVersion is String ? protocolVersion : null; } -bool _usesDraftNumberSchemaKeywords(String? protocolVersion) { - return protocolVersion != null && isStatelessProtocolVersion(protocolVersion); -} - Map? _parseElicitResultContent(Object? content) { if (content == null) { return null; diff --git a/lib/src/types/initialization.dart b/lib/src/types/initialization.dart index 9fb1e156..352c49b0 100644 --- a/lib/src/types/initialization.dart +++ b/lib/src/types/initialization.dart @@ -32,15 +32,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } void _readOptionalParamsObject(Map json, String field) { @@ -333,8 +325,8 @@ class ClientCapabilitiesRoots { ); } - Map toJson() => { - if (listChanged != null) 'listChanged': listChanged, + Map toJson({bool omitListChanged = false}) => { + if (!omitListChanged && listChanged != null) 'listChanged': listChanged, }; } @@ -645,7 +637,7 @@ class ClientCapabilities { /// Present if the client supports tasks (`tasks/list`, `tasks/requests`, etc). final ClientCapabilitiesTasks? tasks; - /// Optional MCP extension capabilities (SEP-1724). + /// Optional MCP extension capabilities. /// /// Keys are extension identifiers (e.g. `"io.modelcontextprotocol/ui"`), /// values are extension-specific settings. @@ -704,16 +696,23 @@ class ClientCapabilities { ); } - Map toJson() => { + Map toJson({ + bool omitLegacyTasks = false, + bool omitLegacyRootsListChanged = false, + }) => + { if (experimental != null) 'experimental': _serializeJsonObjectMap( experimental, 'ClientCapabilities.experimental', ), if (sampling != null) 'sampling': sampling!.toJson(), - if (roots != null) 'roots': roots!.toJson(), + if (roots != null) + 'roots': roots!.toJson( + omitListChanged: omitLegacyRootsListChanged, + ), if (elicitation != null) 'elicitation': elicitation!.toJson(), - if (tasks != null) 'tasks': tasks!.toJson(), + if (!omitLegacyTasks && tasks != null) 'tasks': tasks!.toJson(), if (extensions != null) 'extensions': _serializeExtensionMap( extensions, @@ -951,7 +950,10 @@ class ServerCapabilitiesPrompts { /// Describes capabilities related to resources. class ServerCapabilitiesResources { - /// Whether the server supports `resources/subscribe` and `resources/unsubscribe`. + /// Whether the server supports resource update subscriptions. + /// + /// MCP 2025 uses `resources/subscribe` and `resources/unsubscribe`; MCP 2026 + /// uses `subscriptions/listen` with `resourceSubscriptions`. final bool? subscribe; /// Whether the server supports `notifications/resources/list_changed`. @@ -1177,7 +1179,7 @@ class ServerCapabilities { ) final ServerCapabilitiesElicitation? elicitation; - /// Optional MCP extension capabilities (SEP-1724). + /// Optional MCP extension capabilities. /// /// Keys are extension identifiers (e.g. `"io.modelcontextprotocol/ui"`), /// values are extension-specific settings. @@ -1249,7 +1251,7 @@ class ServerCapabilities { ); } - Map toJson() => { + Map toJson({bool omitLegacyTasks = false}) => { if (experimental != null) 'experimental': _serializeJsonObjectMap( experimental, @@ -1261,7 +1263,7 @@ class ServerCapabilities { if (resources != null) 'resources': resources!.toJson(), if (tools != null) 'tools': tools!.toJson(), if (completions != null) 'completions': completions!.toJson(), - if (tasks != null) 'tasks': tasks!.toJson(), + if (!omitLegacyTasks && tasks != null) 'tasks': tasks!.toJson(), if (extensions != null) 'extensions': _serializeExtensionMap( extensions, @@ -1421,7 +1423,7 @@ class DiscoverResult implements BaseResultData { return { 'resultType': resultType, 'supportedVersions': supportedVersions, - 'capabilities': capabilities.toJson(), + 'capabilities': capabilities.toJson(omitLegacyTasks: true), 'serverInfo': serverInfo.toJson(), if (instructions != null) 'instructions': instructions, if (meta != null) '_meta': readJsonObject(meta, 'DiscoverResult._meta'), diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index adc50e66..fe4c3b11 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -87,7 +87,10 @@ Map buildProtocolRequestMeta({ ...?meta, McpMetaKey.protocolVersion: protocolVersion, McpMetaKey.clientInfo: clientInfo.toJson(), - McpMetaKey.clientCapabilities: clientCapabilities.toJson(), + McpMetaKey.clientCapabilities: clientCapabilities.toJson( + omitLegacyTasks: isStatelessProtocolVersion(protocolVersion), + omitLegacyRootsListChanged: isStatelessProtocolVersion(protocolVersion), + ), if (logLevel != null) McpMetaKey.logLevel: logLevel, }; } @@ -336,20 +339,42 @@ Map? extractRequestMeta(Map json) { return paramsMeta ?? topLevelMeta; } -void _expectJsonRpcMethod( - Map json, - String expected, - String context, -) { +void _expectJsonRpcVersion(Map json, String context) { final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); if (version != jsonRpcVersion) { throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); } +} + +/// Validates the JSON-RPC wrapper fields for a typed request or notification. +/// +/// This is hidden from the public `mcp_dart` export surface but shared by the +/// typed protocol modules so direct parser calls enforce the same envelope +/// constraints as [JsonRpcMessage.fromJson]. +void expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + _expectJsonRpcVersion(json, context); final method = readRequiredString(json['method'], '$context.method'); if (method != expected) { throw FormatException('$context.method must be "$expected"'); } + if (json.containsKey('result') || json.containsKey('error')) { + throw const FormatException( + 'Invalid JSON-RPC message: method cannot be combined with result or error', + ); + } +} + +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + expectJsonRpcMethod(json, expected, context); } /// Base class for all JSON-RPC messages (requests, notifications, responses, errors). @@ -366,10 +391,22 @@ sealed class JsonRpcMessage { throw FormatException('Invalid JSON-RPC version: ${json['jsonrpc']}'); } + final hasMethod = json.containsKey('method'); final hasResult = json.containsKey('result'); final hasError = json.containsKey('error'); - if (json.containsKey('method')) { + if (hasResult && hasError) { + throw const FormatException( + 'Invalid JSON-RPC response: result and error are mutually exclusive', + ); + } + if (hasMethod && (hasResult || hasError)) { + throw const FormatException( + 'Invalid JSON-RPC message: method cannot be combined with result or error', + ); + } + + if (hasMethod) { final method = _parseMethod(json['method']); final hasId = json.containsKey('id'); final params = _parseOptionalParamsObject( @@ -455,10 +492,6 @@ sealed class JsonRpcMessage { ), }; } - } else if (hasResult && hasError) { - throw const FormatException( - 'Invalid JSON-RPC response: result and error are mutually exclusive', - ); } else if (hasResult) { final id = _parseResultResponseId(json['id']); final resultData = @@ -656,12 +689,26 @@ class JsonRpcError extends JsonRpcMessage { const JsonRpcError({required this.id, required this.error}); - factory JsonRpcError.fromJson(Map json) => JsonRpcError( - id: _parseErrorResponseId(json), - error: JsonRpcErrorData.fromJson( - readJsonObject(json['error'], 'JsonRpcError.error'), - ), + factory JsonRpcError.fromJson(Map json) { + _expectJsonRpcVersion(json, 'JsonRpcError'); + if (json.containsKey('method')) { + throw const FormatException( + 'Invalid JSON-RPC error response: method cannot be combined with error', + ); + } + if (json.containsKey('result')) { + throw const FormatException( + 'Invalid JSON-RPC error response: result and error are mutually exclusive', ); + } + + return JsonRpcError( + id: _parseErrorResponseId(json), + error: JsonRpcErrorData.fromJson( + readJsonObject(json['error'], 'JsonRpcError.error'), + ), + ); + } @override Map toJson() => { @@ -738,7 +785,7 @@ class InputRequest { /// Creates an embedded `sampling/createMessage` input request. factory InputRequest.createMessage(CreateMessageRequest params) { - final inputParams = params.toJson()..remove('task'); + final inputParams = params.toJson(omitToolExecution: true)..remove('task'); return InputRequest._( method: Method.samplingCreateMessage, params: inputParams, @@ -1104,13 +1151,18 @@ class JsonRpcCallToolRequest extends JsonRpcRequest { factory JsonRpcCallToolRequest.fromJson(Map json) { _expectJsonRpcMethod(json, Method.toolsCall, 'JsonRpcCallToolRequest'); + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcCallToolRequest.params', + ); + if (paramsMap == null) { + throw const FormatException( + 'JsonRpcCallToolRequest.params is required', + ); + } return JsonRpcCallToolRequest( id: parseRequestId(json['id']), - params: readOptionalJsonObject( - json['params'], - 'JsonRpcCallToolRequest.params', - ) ?? - {}, + params: paramsMap, meta: extractRequestMeta(json), ); } diff --git a/lib/src/types/logging.dart b/lib/src/types/logging.dart index 5657b588..87789783 100644 --- a/lib/src/types/logging.dart +++ b/lib/src/types/logging.dart @@ -6,15 +6,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } /// Severity levels for log messages (syslog levels). diff --git a/lib/src/types/misc.dart b/lib/src/types/misc.dart index 53b22cbe..66ab94d5 100644 --- a/lib/src/types/misc.dart +++ b/lib/src/types/misc.dart @@ -6,15 +6,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } void _readOptionalParamsObject(Map json, String field) { @@ -44,18 +36,16 @@ class EmptyResult implements BaseResultData { /// Parameters for the `notifications/cancelled` notification. class CancelledNotification { /// The ID of the request to cancel. - final RequestId? requestId; + final RequestId requestId; /// An optional string describing the reason for the cancellation. final String? reason; - const CancelledNotification({this.requestId, this.reason}); + const CancelledNotification({required this.requestId, this.reason}); factory CancelledNotification.fromJson(Map json) => CancelledNotification( - requestId: json.containsKey('requestId') - ? parseRequestId(json['requestId'], fieldName: 'requestId') - : null, + requestId: parseRequestId(json['requestId'], fieldName: 'requestId'), reason: readOptionalString( json['reason'], 'CancelledNotification.reason', @@ -63,8 +53,7 @@ class CancelledNotification { ); Map toJson() => { - if (requestId != null) - 'requestId': parseRequestId(requestId, fieldName: 'requestId'), + 'requestId': parseRequestId(requestId, fieldName: 'requestId'), if (reason != null) 'reason': reason, }; } diff --git a/lib/src/types/prompts.dart b/lib/src/types/prompts.dart index 040c376d..cee66287 100644 --- a/lib/src/types/prompts.dart +++ b/lib/src/types/prompts.dart @@ -7,14 +7,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } List? _readOptionalObjectList( diff --git a/lib/src/types/resources.dart b/lib/src/types/resources.dart index b26cb171..e727aa0b 100644 --- a/lib/src/types/resources.dart +++ b/lib/src/types/resources.dart @@ -27,15 +27,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } /// Additional properties describing a Resource to clients. diff --git a/lib/src/types/roots.dart b/lib/src/types/roots.dart index 3911ffc6..c4e4078d 100644 --- a/lib/src/types/roots.dart +++ b/lib/src/types/roots.dart @@ -24,15 +24,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } /// Represents a root directory or file the server can operate on. diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index 6911c56f..609942c7 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -30,15 +30,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } String _base64ForJson(String value, String field) { @@ -795,7 +787,7 @@ class CreateMessageRequest { } /// Converts to JSON. - Map toJson() { + Map toJson({bool omitToolExecution = false}) { validateOptionalFiniteNumber( temperature, 'CreateMessageRequest.temperature', @@ -815,7 +807,10 @@ class CreateMessageRequest { ), if (modelPreferences != null) 'modelPreferences': modelPreferences!.toJson(), - if (tools != null) 'tools': tools!.map((t) => t.toJson()).toList(), + if (tools != null) + 'tools': tools! + .map((t) => t.toJson(omitExecution: omitToolExecution)) + .toList(), if (toolChoiceConfig != null) 'toolChoice': toolChoiceConfig!.toJson(), }; } diff --git a/lib/src/types/subscriptions.dart b/lib/src/types/subscriptions.dart index 16393a04..4c5d3ad9 100644 --- a/lib/src/types/subscriptions.dart +++ b/lib/src/types/subscriptions.dart @@ -67,10 +67,10 @@ class SubscriptionFilter { (capabilities.resources?.listChanged ?? false) ? true : null, - resourceSubscriptions: - resourceSubscriptions != null && capabilities.resources != null - ? List.unmodifiable(resourceSubscriptions!) - : null, + resourceSubscriptions: resourceSubscriptions != null && + (capabilities.resources?.subscribe ?? false) + ? List.unmodifiable(resourceSubscriptions!) + : null, taskIds: taskIds != null && capabilities.supportsTasksExtension ? List.unmodifiable(taskIds!) : null, @@ -112,7 +112,7 @@ class SubscriptionFilter { return resourcesListChanged == true; case Method.notificationsResourcesUpdated: final uri = notification.params?['uri']; - return uri is String && (resourceSubscriptions?.contains(uri) ?? false); + return uri is String && _allowsResourceUri(uri, resourceSubscriptions); case Method.notificationsTasks: final taskId = notification.params?['taskId']; return taskId is String && (taskIds?.contains(taskId) ?? false); @@ -133,6 +133,41 @@ class SubscriptionFilter { }; } +bool _allowsResourceUri(String uri, List? subscribedUris) { + if (subscribedUris == null) { + return false; + } + return subscribedUris.any((subscribedUri) { + if (uri == subscribedUri) { + return true; + } + return _isSubResourceUri(uri, subscribedUri); + }); +} + +bool _isSubResourceUri(String uri, String subscribedUri) { + final updated = Uri.tryParse(uri); + final subscribed = Uri.tryParse(subscribedUri); + if (updated == null || + subscribed == null || + !updated.hasScheme || + !subscribed.hasScheme) { + return false; + } + if (updated.scheme != subscribed.scheme || + updated.authority != subscribed.authority) { + return false; + } + if (subscribed.query.isNotEmpty || subscribed.fragment.isNotEmpty) { + return false; + } + + final subscribedPath = subscribed.path.isEmpty ? '/' : subscribed.path; + final childPathPrefix = + subscribedPath.endsWith('/') ? subscribedPath : '$subscribedPath/'; + return updated.path.startsWith(childPathPrefix); +} + /// Parameters for a `subscriptions/listen` request. class SubscriptionsListenRequest { /// Notifications the client opts into on this stream. @@ -173,17 +208,54 @@ class JsonRpcSubscriptionsListenRequest extends JsonRpcRequest { factory JsonRpcSubscriptionsListenRequest.fromJson( Map json, ) { + _expectJsonRpcMethod( + json, + Method.subscriptionsListen, + 'JsonRpcSubscriptionsListenRequest', + ); final paramsMap = _readRequiredParamsObject( json, 'JsonRpcSubscriptionsListenRequest.params', ); + final meta = validateRequestMeta( + readJsonObject( + paramsMap['_meta'], + 'JsonRpcSubscriptionsListenRequest.params._meta', + ), + validateKeys: true, + )!; return JsonRpcSubscriptionsListenRequest( id: parseRequestId(json['id']), listenParams: SubscriptionsListenRequest.fromJson(paramsMap), - meta: extractRequestMeta(json), + meta: meta, ); } + + @override + Map toJson() { + final meta = this.meta; + if (meta == null) { + throw const FormatException( + 'JsonRpcSubscriptionsListenRequest.params._meta is required', + ); + } + return { + 'jsonrpc': jsonrpc, + 'id': parseRequestId( + id, + fieldName: 'JsonRpcSubscriptionsListenRequest.id', + ), + 'method': method, + 'params': { + ...listenParams.toJson(), + '_meta': readJsonObject( + validateRequestMeta(meta, validateKeys: true), + 'JsonRpcSubscriptionsListenRequest.params._meta', + ), + }, + }; + } } /// Parameters for `notifications/subscriptions/acknowledged`. @@ -227,6 +299,11 @@ class JsonRpcSubscriptionsAcknowledgedNotification extends JsonRpcNotification { factory JsonRpcSubscriptionsAcknowledgedNotification.fromJson( Map json, ) { + _expectJsonRpcMethod( + json, + Method.notificationsSubscriptionsAcknowledged, + 'JsonRpcSubscriptionsAcknowledgedNotification', + ); final paramsMap = _readRequiredParamsObject( json, 'JsonRpcSubscriptionsAcknowledgedNotification.params', @@ -297,3 +374,11 @@ Map? _readOptionalJsonObject(Object? value, String field) { } return readJsonObject(value, field); } + +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + expectJsonRpcMethod(json, expected, context); +} diff --git a/lib/src/types/tasks.dart b/lib/src/types/tasks.dart index 45dcab0d..61824b8e 100644 --- a/lib/src/types/tasks.dart +++ b/lib/src/types/tasks.dart @@ -7,15 +7,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } /// The current state of a task execution. diff --git a/lib/src/types/tools.dart b/lib/src/types/tools.dart index 486ae33e..9a48acea 100644 --- a/lib/src/types/tools.dart +++ b/lib/src/types/tools.dart @@ -20,15 +20,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } /// Additional properties describing a Tool to clients. @@ -266,7 +258,7 @@ class Tool { ); } - Map toJson() { + Map toJson({bool omitExecution = false}) { _validateObjectRootSchema(inputSchema, 'Tool.inputSchema'); return { @@ -277,7 +269,7 @@ class Tool { if (outputSchema != null) 'outputSchema': outputSchema!.toJson(), if (annotations != null) 'annotations': annotations!.toJson(), if (meta != null) '_meta': readJsonObject(meta, 'Tool._meta'), - if (execution != null) 'execution': execution!.toJson(), + if (!omitExecution && execution != null) 'execution': execution!.toJson(), if (icons != null) 'icons': icons!.map((icon) => icon.toJson()).toList(), }; } diff --git a/packages/mcp_dart_cli/CHANGELOG.md b/packages/mcp_dart_cli/CHANGELOG.md index b300d7e8..f1334eb3 100644 --- a/packages/mcp_dart_cli/CHANGELOG.md +++ b/packages/mcp_dart_cli/CHANGELOG.md @@ -35,6 +35,21 @@ - Add `mcp_dart conformance` with built-in JSON-RPC and protocol-version fixture checks, deterministic JSON-RPC fuzz cases, exact-case filtering, and JSON output for CI/scripts. - Add `mcp_dart conformance --suite spec` for MCP 2025-11-25 lifecycle, capability, elicitation, task-metadata, and progress-token raw-wire checks. +- Extend `mcp_dart conformance --suite spec` with MCP 2026-07-28 RC checks for + draft protocol advertisement, `server/discover`, stateless result/cache + defaults, removed core RPCs, stateless HTTP parameter header encoding, and + task subscription missing-capability errors. +- Add conformance coverage for `sampling.context` negotiation before deprecated + sampling `includeContext` values are sent. +- Add conformance coverage that aborted `initialize` requests do not emit + `notifications/cancelled`. +- Add conformance coverage that `notifications/cancelled` payloads require a + valid `requestId`. +- Add conformance coverage that `notifications/subscriptions/acknowledged` + typed parsers reject mismatched JSON-RPC wrapper constants. +- Add JSON-RPC fixture conformance coverage for rejecting envelopes that mix + request/notification `method` fields with response `result` or `error` + fields, including direct typed parser coverage. - Document `mcp_dart conformance --suite all` as the stable non-fuzz coverage gate used by CI. diff --git a/packages/mcp_dart_cli/README.md b/packages/mcp_dart_cli/README.md index 1d246f79..e0ec836e 100644 --- a/packages/mcp_dart_cli/README.md +++ b/packages/mcp_dart_cli/README.md @@ -351,12 +351,13 @@ mcp_dart call-tool search --url http://localhost:3000/mcp --json-args '{"q":"mcp ### Conformance -Run built-in fixture checks, MCP 2025-11-25 spec-critical checks, and -deterministic fuzz checks for protocol edge cases in this Dart SDK/CLI package. -The fixture suite covers JSON-RPC malformed-message handling, string and -integer request IDs, string and integer progress tokens, fractional ID/token -rejection, and advertised protocol-version support. The spec suite covers -raw-wire lifecycle, capability, elicitation, task-metadata, progress-token +Run built-in fixture checks, MCP 2025-11-25 spec-critical checks, MCP +2026-07-28 RC stateless checks, and deterministic fuzz checks for protocol edge +cases in this Dart SDK/CLI package. The fixture suite covers JSON-RPC +malformed-message handling, string and integer request IDs, string and integer +progress tokens, fractional ID/token rejection, and advertised protocol-version +support. The spec suite covers raw-wire lifecycle, discovery, stateless +result/cache behavior, capability, elicitation, task-metadata, progress-token dispatch, and negative cases. This command is useful as a regression gate for the Dart SDK and CLI, but it is @@ -372,7 +373,7 @@ mcp_dart conformance # Run all stable non-fuzz suites mcp_dart conformance --suite all -# Run only MCP 2025-11-25 raw-wire spec cases +# Run only raw-wire spec cases mcp_dart conformance --suite spec # Run one case by exact name diff --git a/packages/mcp_dart_cli/lib/src/conformance_runner.dart b/packages/mcp_dart_cli/lib/src/conformance_runner.dart index 3ec05a10..fff05f62 100644 --- a/packages/mcp_dart_cli/lib/src/conformance_runner.dart +++ b/packages/mcp_dart_cli/lib/src/conformance_runner.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; import 'dart:math'; import 'package:mcp_dart/mcp_dart.dart'; @@ -13,6 +15,17 @@ const String _protocolVersionMetaKey = const String _clientInfoMetaKey = 'io.modelcontextprotocol/clientInfo'; const String _clientCapabilitiesMetaKey = 'io.modelcontextprotocol/clientCapabilities'; +const String _resultTypeComplete = 'complete'; +const String _resultTypeInputRequired = 'input_required'; +const String _resultTypeFutureExtension = 'future_extension'; +const String _cacheScopePrivate = 'private'; +const String _tasksExtensionId = 'io.modelcontextprotocol/tasks'; +const String _methodTasksGet = 'tasks/get'; +const String _methodTasksUpdate = 'tasks/update'; +const String _methodSubscriptionsListen = 'subscriptions/listen'; +const String _methodNotificationsTasksStatus = 'notifications/tasks/status'; +const int _headerMismatchCode = -32001; +const int _unsupportedProtocolVersionCode = -32004; const List conformanceSuiteNames = [ _fixtureSuite, @@ -82,6 +95,20 @@ class _ConformanceCase { }); } +class _MissingCapabilityScenario { + final String name; + final ClientCapabilities capabilities; + final String method; + final Map requiredCapabilities; + + const _MissingCapabilityScenario({ + required this.name, + required this.capabilities, + required this.method, + required this.requiredCapabilities, + }); +} + /// Runs the built-in MCP conformance fixture checks. class ConformanceRunner { final List<_ConformanceCase> _fixtureCases; @@ -117,6 +144,13 @@ class ConformanceRunner { 'Rejects JSON-RPC responses that include both result and error members.', check: _rejectsResultErrorJsonRpcResponse, ), + _ConformanceCase( + suite: _fixtureSuite, + name: 'jsonrpc.rejects-method-response-envelope', + description: + 'Rejects JSON-RPC envelopes that combine request/notification method fields with response result or error fields.', + check: _rejectsMethodResponseJsonRpcEnvelope, + ), _ConformanceCase( suite: _fixtureSuite, name: 'jsonrpc.rejects-malformed-error-object', @@ -131,6 +165,13 @@ class ConformanceRunner { 'Rejects JSON-RPC error responses whose id member is explicitly null.', check: _rejectsNullJsonRpcErrorResponseId, ), + _ConformanceCase( + suite: _fixtureSuite, + name: 'jsonrpc.accepts-omitted-error-response-id', + description: + 'Parses and serializes JSON-RPC error responses that omit the optional id member.', + check: _acceptsOmittedJsonRpcErrorResponseId, + ), _ConformanceCase( suite: _fixtureSuite, name: 'jsonrpc.rejects-null-params-member', @@ -138,6 +179,13 @@ class ConformanceRunner { 'Rejects JSON-RPC request and notification envelopes whose params member is null.', check: _rejectsNullJsonRpcParamsMember, ), + _ConformanceCase( + suite: _fixtureSuite, + name: 'tools-call.requires-params', + description: + 'Rejects tools/call requests that omit the required params object.', + check: _requiresCallToolRequestParams, + ), _ConformanceCase( suite: _fixtureSuite, name: 'jsonrpc.preserves-string-response-id', @@ -180,6 +228,13 @@ class ConformanceRunner { 'Advertises MCP 2025-11-25 as the latest supported protocol version.', check: _advertisesLatestProtocolVersion, ), + _ConformanceCase( + suite: _fixtureSuite, + name: 'protocol-version.advertises-draft-2026-07-28', + description: + 'Advertises MCP 2026-07-28 as the latest draft stateless protocol version.', + check: _advertisesDraftProtocolVersion, + ), ], _specCases = <_ConformanceCase>[ _ConformanceCase( @@ -189,6 +244,27 @@ class ConformanceRunner { 'Rejects operation requests before the initialize handshake.', check: _rejectsPreInitializeRequest, ), + _ConformanceCase( + suite: _specSuite, + name: 'lifecycle.gates-until-initialized-notification', + description: + 'Keeps normal operation requests gated until notifications/initialized is received.', + check: _gatesUntilInitializedNotification, + ), + _ConformanceCase( + suite: _specSuite, + name: 'lifecycle.does-not-cancel-initialize', + description: + 'Does not send notifications/cancelled for initialize request cancellation.', + check: _doesNotCancelInitializeRequest, + ), + _ConformanceCase( + suite: _specSuite, + name: 'cancellation.requires-request-id', + description: + 'Rejects notifications/cancelled payloads without a requestId.', + check: _requiresCancellationRequestId, + ), _ConformanceCase( suite: _specSuite, name: 'server-discover.requires-request-meta', @@ -196,6 +272,288 @@ class ConformanceRunner { 'Rejects server/discover requests that omit params._meta request metadata.', check: _serverDiscoverRequiresRequestMeta, ), + _ConformanceCase( + suite: _specSuite, + name: 'server-discover.returns-draft-capabilities', + description: + 'Returns complete server/discover results with supported draft protocol versions.', + check: _serverDiscoverReturnsDraftCapabilities, + ), + _ConformanceCase( + suite: _specSuite, + name: 'protocol-version.rejects-unsupported-stateless-version', + description: + 'Rejects unsupported stateless protocol versions with supported/requested error data.', + check: _rejectsUnsupportedStatelessProtocolVersion, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless.requires-complete-request-meta', + description: + 'Rejects 2026 stateless requests whose _meta omits required client identity or capability fields.', + check: _statelessRequestsRequireCompleteRequestMeta, + ), + _ConformanceCase( + suite: _specSuite, + name: 'protocol-version.http-modern-400-retries-discovery', + description: + 'Retries server/discover with an advertised version after HTTP 400 UnsupportedProtocolVersion without falling back to initialize.', + check: _httpModernProtocolErrorsRetryDiscovery, + ), + _ConformanceCase( + suite: _specSuite, + name: 'capabilities.http-modern-400-does-not-fallback', + description: + 'Surfaces HTTP 400 MissingRequiredClientCapability errors without falling back to initialize.', + check: _httpModernMissingCapabilityErrorsDoNotFallback, + ), + _ConformanceCase( + suite: _specSuite, + name: 'protocol-version.initialize-negotiates-stateful-version', + description: + 'Keeps initialize negotiation on stateful MCP versions even when the draft stateless version is preferred.', + check: _initializeNegotiatesStatefulProtocolVersion, + ), + _ConformanceCase( + suite: _specSuite, + name: 'capabilities.stateless-does-not-infer-initialize-extensions', + description: + 'Requires 2026 stateless requests to declare extension capabilities per request instead of inheriting initialize capabilities.', + check: _statelessDoesNotInferInitializeExtensions, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.rejects-mismatched-routing-headers', + description: + 'Rejects 2026 Streamable HTTP requests whose routing headers disagree with the JSON-RPC body.', + check: _rejectsMismatchedStatelessHttpRoutingHeaders, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.requires-routing-headers', + description: + 'Requires 2026 Streamable HTTP requests to include protocol and method routing headers.', + check: _requiresStatelessHttpRoutingHeaders, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.rejects-non-post-methods', + description: + 'Returns HTTP 405 for 2026 stateless Streamable HTTP methods other than POST.', + check: _rejectsStatelessHttpNonPostMethods, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.rejects-batch-payloads', + description: + 'Rejects 2026 stateless Streamable HTTP POST bodies that contain more than one JSON-RPC message.', + check: _rejectsStatelessHttpBatchPayloads, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.task-requests-require-name-header', + description: + 'Requires 2026 task lifecycle requests to route with Mcp-Name task IDs.', + check: _taskRequestsRequireStatelessHttpNameHeader, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.validates-parameter-headers', + description: + 'Requires and matches 2026 Mcp-Param routing headers for configured tool arguments.', + check: _validatesStatelessHttpParameterHeaders, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.omits-invalid-numeric-parameter-headers', + description: + 'Omits fractional and unsafe integer x-mcp-header values while preserving safe integers.', + check: _omitsInvalidNumericParameterHeaders, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.encodes-parameter-header-values', + description: + 'Encodes non-plain 2026 Mcp-Param string header values while preserving plain strings.', + check: _encodesStatelessHttpParameterHeaderValues, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.accepts-response-posts', + description: + 'Accepts 2026 JSON-RPC response POSTs without request-body metadata.', + check: _acceptsStatelessHttpResponsePosts, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.omits-session-header-after-initialize', + description: + 'Omits Mcp-Session-Id on 2026 stateless responses even after stateful initialization.', + check: _statelessHttpOmitsSessionHeaderAfterInitialize, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.task-subscription-requires-client-capability', + description: + 'Returns MissingRequiredClientCapability for stateless task subscriptions when the client did not advertise the task extension.', + check: _taskSubscriptionRequiresClientCapability, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless.related-task-uses-explicit-id-across-transports', + description: + 'Processes related task operations across separate transports using explicit task IDs.', + check: _relatedTaskUsesExplicitIdAcrossTransports, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless.ignores-legacy-task-parameter', + description: + 'Ignores legacy tools/call task parameters on 2026 stateless requests.', + check: _statelessIgnoresLegacyTaskParameter, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-client.rejects-legacy-task-options', + description: + 'Rejects legacy RequestOptions.task before sending 2026 stateless requests.', + check: _statelessClientRejectsLegacyTaskOptions, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless.adds-result-type-and-cache-defaults', + description: + 'Adds 2026 complete resultType and cache defaults for all cacheable stateless results.', + check: _statelessAddsResultTypeAndCacheDefaults, + ), + _ConformanceCase( + suite: _specSuite, + name: 'tools-list.stateless-returns-deterministic-order', + description: + 'Returns 2026 stateless tools/list results in deterministic name order.', + check: _statelessToolsListReturnsDeterministicOrder, + ), + _ConformanceCase( + suite: _specSuite, + name: 'tools-list.stateless-omits-legacy-execution', + description: + 'Omits stable-only Tool.execution metadata from 2026 stateless tools/list results.', + check: _statelessToolsListOmitsLegacyExecution, + ), + _ConformanceCase( + suite: _specSuite, + name: 'resources.missing-resource-error-code-by-version', + description: + 'Uses legacy ResourceNotFound for stable resource misses and InvalidParams for 2026 stateless resource misses.', + check: _missingResourceErrorCodeByVersion, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless.rejects-unrecognized-result-type', + description: + 'Rejects 2026 stateless responses with unrecognized resultType values.', + check: _statelessRejectsUnrecognizedResultType, + ), + _ConformanceCase( + suite: _specSuite, + name: 'mrtr.input-required-supported-requests', + description: + 'Allows input_required results on tools/call, prompts/get, and resources/read.', + check: _mrtrInputRequiredSupportedRequests, + ), + _ConformanceCase( + suite: _specSuite, + name: 'mrtr.rejects-unsupported-input-required-results', + description: + 'Rejects input_required results on methods outside the MRTR allowlist.', + check: _mrtrRejectsUnsupportedInputRequiredResults, + ), + _ConformanceCase( + suite: _specSuite, + name: 'mrtr.input-requests-require-client-capabilities', + description: + 'Rejects MRTR inputRequests whose client capabilities were not declared.', + check: _mrtrInputRequestsRequireClientCapabilities, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless.rejects-removed-core-rpcs', + description: + 'Rejects initialize, ping, logging/setLevel, and resource subscription RPCs in stateless MCP.', + check: _rejectsRemovedStatelessCoreRpcs, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless.rejects-removed-core-notifications', + description: + 'Rejects initialized, roots/list_changed, and legacy task status notifications in stateless MCP.', + check: _rejectsRemovedStatelessCoreNotifications, + ), + _ConformanceCase( + suite: _specSuite, + name: 'logging.stateless-requires-request-log-level', + description: + 'Sends stateless logging notifications only when the request opts in with io.modelcontextprotocol/logLevel.', + check: _statelessLoggingRequiresRequestLogLevel, + ), + _ConformanceCase( + suite: _specSuite, + name: + 'tasks-extension.lifecycle-methods-do-not-require-repeated-capability', + description: + 'Does not reject task lifecycle requests solely because the request omits repeated task extension capability metadata.', + check: _taskLifecycleMethodsAllowResumedClientCapability, + ), + _ConformanceCase( + suite: _specSuite, + name: 'tasks-extension.task-store-uses-extension-result-shapes', + description: + 'Serializes built-in task-store tasks/get and tasks/cancel responses in the MCP Tasks extension wire shape.', + check: _taskStoreUsesTaskExtensionResultShapes, + ), + _ConformanceCase( + suite: _specSuite, + name: 'tasks-extension.call-tool-result-cannot-spoof-task-result', + description: + 'Rejects CallToolResult.extra attempts to spoof resultType task.', + check: _callToolResultCannotSpoofTaskResult, + ), + _ConformanceCase( + suite: _specSuite, + name: 'tasks-extension.task-result-requires-client-extension', + description: + 'Rejects resultType task unless the tools/call request negotiated the tasks extension.', + check: _taskResultRequiresClientExtension, + ), + _ConformanceCase( + suite: _specSuite, + name: 'subscriptions-listen.task-ids-require-client-capability', + description: + 'Rejects task-status subscriptions when the client did not advertise the task extension.', + check: _subscriptionTaskIdsRequireClientCapability, + ), + _ConformanceCase( + suite: _specSuite, + name: 'subscriptions-listen.requires-request-meta', + description: + 'Rejects subscriptions/listen requests that omit params._meta request metadata.', + check: _subscriptionsListenRequiresRequestMeta, + ), + _ConformanceCase( + suite: _specSuite, + name: + 'subscriptions-listen.resource-subscriptions-require-capability', + description: + 'Acknowledges resource subscriptions only when resources.subscribe is advertised.', + check: _subscriptionsListenRequiresResourceSubscribeCapability, + ), + _ConformanceCase( + suite: _specSuite, + name: 'subscriptions-acknowledged.rejects-wrapper-mismatch', + description: + 'Rejects notifications/subscriptions/acknowledged wrappers with mismatched JSON-RPC constants.', + check: _subscriptionsAcknowledgedRejectsWrapperMismatch, + ), _ConformanceCase( suite: _specSuite, name: 'capabilities.rejects-unnegotiated-sampling-tools', @@ -203,6 +561,34 @@ class ConformanceRunner { 'Rejects sampling/createMessage tool-use when sampling.tools was not negotiated.', check: _rejectsUnnegotiatedSamplingTools, ), + _ConformanceCase( + suite: _specSuite, + name: 'capabilities.rejects-unnegotiated-sampling-context', + description: + 'Rejects deprecated sampling includeContext values when sampling.context was not negotiated.', + check: _rejectsUnnegotiatedSamplingContext, + ), + _ConformanceCase( + suite: _specSuite, + name: 'capabilities.unadvertised-peer-methods-use-method-not-found', + description: + 'Uses MethodNotFound for MCP methods whose peer capability was not advertised.', + check: _unadvertisedPeerMethodsUseMethodNotFound, + ), + _ConformanceCase( + suite: _specSuite, + name: 'capabilities.task-scoped-peer-methods-use-method-not-found', + description: + 'Uses MethodNotFound for task-scoped MCP requests whose peer task capability was not advertised.', + check: _taskScopedPeerMethodsUseMethodNotFound, + ), + _ConformanceCase( + suite: _specSuite, + name: 'capabilities.stateless-omits-legacy-task-capabilities', + description: + 'Omits legacy task and removed roots.listChanged capability fields from 2026 stateless metadata.', + check: _statelessOmitsLegacyTaskCapabilities, + ), _ConformanceCase( suite: _specSuite, name: 'elicitation.rejects-invalid-form-url-union', @@ -210,6 +596,13 @@ class ConformanceRunner { 'Rejects elicitation/create payloads that mix form and URL variants.', check: _rejectsInvalidElicitationVariantPayload, ), + _ConformanceCase( + suite: _specSuite, + name: 'elicitation.accepts-numeric-number-schema-keywords', + description: + 'Accepts finite numeric default/minimum/maximum keywords in elicitation number schemas.', + check: _acceptsNumericElicitationNumberSchemaKeywords, + ), _ConformanceCase( suite: _specSuite, name: 'tasks.strips-unnegotiated-related-task-metadata', @@ -258,7 +651,7 @@ class ConformanceRunner { return _runCases(_fixtureCases, filter: filter); } - /// Runs MCP 2025-11-25 spec-critical raw-wire behavior checks. + /// Runs spec-critical raw-wire behavior checks. Future runSpecSuite({String? filter}) { return _runCases(_specCases, filter: filter); } @@ -467,44 +860,164 @@ class _ConformanceTransport extends Transport { } } -Future _settle() => Future.delayed(Duration.zero); +class _ConformanceProtocol extends Protocol { + _ConformanceProtocol() : super(null); -JsonRpcInitializeRequest _initializeRequest({ - RequestId id = 1, - ClientCapabilities capabilities = const ClientCapabilities(), -}) { - return JsonRpcInitializeRequest( - id: id, - initParams: InitializeRequest( - protocolVersion: latestProtocolVersion, - capabilities: capabilities, - clientInfo: const Implementation( - name: 'conformance-client', - version: '1.0.0', - ), - ), - ); -} + @override + void assertCapabilityForMethod(String method) {} -JsonRpcResponse _initializeResponse({ - required RequestId id, - ServerCapabilities capabilities = const ServerCapabilities(), -}) { - return JsonRpcResponse( - id: id, - result: InitializeResult( - protocolVersion: latestProtocolVersion, - capabilities: capabilities, - serverInfo: const Implementation( - name: 'conformance-server', - version: '1.0.0', - ), - ).toJson(), - ); + @override + void assertNotificationCapability(String method) {} + + @override + void assertRequestHandlerCapability(String method) {} + + @override + void assertTaskCapability(String method) {} + + @override + void assertTaskHandlerCapability(String method) {} } -Future _initializeMcpServer( - McpServer server, +class _DiscoveringConformanceTransport extends Transport + implements ProtocolVersionAwareTransport { + _DiscoveringConformanceTransport({ + required this.toolsListResult, + Map? capabilities, + this.toolsCallResult, + }) : capabilities = capabilities ?? + const { + 'tools': {}, + }; + + final Map toolsListResult; + final Map capabilities; + final Map? toolsCallResult; + final List sentMessages = []; + + @override + String? protocolVersion; + + @override + String? get sessionId => null; + + @override + Future start() async {} + + @override + Future send(JsonRpcMessage message, {int? relatedRequestId}) async { + sentMessages.add(message); + + if (message is JsonRpcRequest && message.method == _serverDiscoverMethod) { + onmessage?.call( + JsonRpcResponse( + id: message.id, + result: { + 'resultType': _resultTypeComplete, + 'supportedVersions': const [ + _draftProtocolVersion2026_07_28, + ], + 'capabilities': capabilities, + 'serverInfo': const { + 'name': 'conformance-server', + 'version': '1.0.0', + }, + }, + ), + ); + return; + } + + final toolsCallResult = this.toolsCallResult; + if (message is JsonRpcRequest && + message.method == Method.toolsCall && + toolsCallResult != null) { + onmessage?.call( + JsonRpcResponse(id: message.id, result: toolsCallResult), + ); + return; + } + + if (message is JsonRpcRequest && message.method == Method.toolsList) { + onmessage?.call( + JsonRpcResponse(id: message.id, result: toolsListResult), + ); + } + } + + @override + Future close() async { + onclose?.call(); + } +} + +Future _settle() => Future.delayed(Duration.zero); + +bool _stringListEquals(List actual, List expected) { + if (actual.length != expected.length) { + return false; + } + for (var index = 0; index < actual.length; index += 1) { + if (actual[index] != expected[index]) { + return false; + } + } + return true; +} + +JsonRpcInitializeRequest _initializeRequest({ + RequestId id = 1, + ClientCapabilities capabilities = const ClientCapabilities(), +}) { + return JsonRpcInitializeRequest( + id: id, + initParams: InitializeRequest( + protocolVersion: latestProtocolVersion, + capabilities: capabilities, + clientInfo: const Implementation( + name: 'conformance-client', + version: '1.0.0', + ), + ), + ); +} + +JsonRpcResponse _initializeResponse({ + required RequestId id, + ServerCapabilities capabilities = const ServerCapabilities(), +}) { + return JsonRpcResponse( + id: id, + result: InitializeResult( + protocolVersion: latestProtocolVersion, + capabilities: capabilities, + serverInfo: const Implementation( + name: 'conformance-server', + version: '1.0.0', + ), + ).toJson(), + ); +} + +Map _statelessRequestMeta({ + String protocolVersion = _draftProtocolVersion2026_07_28, + ClientCapabilities capabilities = const ClientCapabilities(), +}) { + return { + _protocolVersionMetaKey: protocolVersion, + _clientInfoMetaKey: const Implementation( + name: 'conformance-client', + version: '1.0.0', + ).toJson(), + _clientCapabilitiesMetaKey: capabilities.toJson( + omitLegacyTasks: isStatelessProtocolVersion(protocolVersion), + omitLegacyRootsListChanged: isStatelessProtocolVersion(protocolVersion), + ), + }; +} + +Future _initializeMcpServer( + McpServer server, _ConformanceTransport transport, { ClientCapabilities clientCapabilities = const ClientCapabilities(), }) async { @@ -520,8 +1033,9 @@ Future _initializeMcpServer( Future _initializeClient( McpClient client, - _ConformanceTransport transport, -) async { + _ConformanceTransport transport, { + ServerCapabilities serverCapabilities = const ServerCapabilities(), +}) async { final connectFuture = client.connect(transport); await _settle(); @@ -553,7 +1067,12 @@ Future _initializeClient( } final initializeRequest = initializeRequests.single; - transport.emit(_initializeResponse(id: initializeRequest.id)); + transport.emit( + _initializeResponse( + id: initializeRequest.id, + capabilities: serverCapabilities, + ), + ); await connectFuture.timeout(const Duration(seconds: 1)); transport.sentMessages.clear(); } @@ -586,6 +1105,148 @@ Future _rejectsPreInitializeRequest() async { await server.close(); } +Future _gatesUntilInitializedNotification() async { + final transport = _ConformanceTransport(); + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), + ), + ); + var handlerCallCount = 0; + server.registerTool( + 'probe', + callback: (args, extra) async { + handlerCallCount += 1; + return const CallToolResult( + content: [TextContent(text: 'ok')], + ); + }, + ); + + await server.connect(transport); + transport.emit(_initializeRequest()); + await _settle(); + _expectSingleErrorFreeResponse(transport.sentMessages, id: 1); + + transport.sentMessages.clear(); + transport.emit( + const JsonRpcCallToolRequest( + id: 101, + params: { + 'name': 'probe', + 'arguments': {}, + }, + ), + ); + await _settle(); + + if (handlerCallCount != 0) { + throw StateError('Tool handler ran before notifications/initialized.'); + } + _expectSingleError( + transport.sentMessages, + id: 101, + code: ErrorCode.invalidRequest.value, + messageContains: 'notifications/initialized', + ); + + transport.sentMessages.clear(); + transport.emit(const JsonRpcInitializedNotification()); + transport.emit( + const JsonRpcCallToolRequest( + id: 102, + params: { + 'name': 'probe', + 'arguments': {}, + }, + ), + ); + await _settle(); + + if (handlerCallCount != 1) { + throw StateError( + 'Tool handler did not run after initialized notification.'); + } + _expectSingleErrorFreeResponse(transport.sentMessages, id: 102); + await server.close(); +} + +Future _doesNotCancelInitializeRequest() async { + final transport = _ConformanceTransport(); + final protocol = _ConformanceProtocol(); + await protocol.connect(transport); + + final controller = BasicAbortController(); + final requestFuture = protocol.request( + _initializeRequest(), + InitializeResult.fromJson, + RequestOptions( + signal: controller.signal, + timeoutEnabled: false, + ), + ); + await _settle(); + + final initializeRequests = transport.sentMessages + .whereType() + .where((request) => request.method == Method.initialize) + .toList(); + if (initializeRequests.length != 1) { + throw StateError( + 'Expected one initialize request, got ${initializeRequests.length}.', + ); + } + + controller.abort('cancel initialize'); + try { + await requestFuture.timeout(const Duration(seconds: 1)); + throw StateError( + 'Expected initialize request cancellation to fail locally.'); + } catch (error) { + if (!error.toString().contains('cancel initialize')) { + throw StateError( + 'Expected initialize cancellation reason, got $error.', + ); + } + } + await _settle(); + + final cancellations = + transport.sentMessages.whereType().toList(); + if (cancellations.isNotEmpty) { + throw StateError( + 'Expected no cancellation notification for initialize, got $cancellations.', + ); + } + + await protocol.close(); +} + +Future _requiresCancellationRequestId() async { + _expectThrowsFormatException( + () => JsonRpcMessage.fromJson(const { + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsCancelled, + 'params': { + 'reason': 'missing request id', + }, + }), + ); + + try { + const CancelledNotification( + requestId: null, + reason: 'missing request id', + ).toJson(); + } on FormatException { + return; + } + + throw StateError( + 'Expected CancelledNotification.toJson to require requestId.'); +} + Future _serverDiscoverRequiresRequestMeta() async { for (final message in [ { @@ -611,82 +1272,3743 @@ Future _serverDiscoverRequiresRequestMeta() async { _expectThrowsFormatException(() => JsonRpcMessage.fromJson(message)); } - final parsed = JsonRpcMessage.fromJson({ - 'jsonrpc': jsonRpcVersion, - 'id': 'discover-1', - 'method': _serverDiscoverMethod, - 'params': { - '_meta': { - _protocolVersionMetaKey: _draftProtocolVersion2026_07_28, - _clientInfoMetaKey: { - 'name': 'client', - 'version': '1.0.0', - }, - _clientCapabilitiesMetaKey: {}, - }, - }, - }); - if (parsed is! JsonRpcRequest) { + final parsed = JsonRpcMessage.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'discover-1', + 'method': _serverDiscoverMethod, + 'params': { + '_meta': { + _protocolVersionMetaKey: _draftProtocolVersion2026_07_28, + _clientInfoMetaKey: { + 'name': 'client', + 'version': '1.0.0', + }, + _clientCapabilitiesMetaKey: {}, + }, + }, + }); + if (parsed is! JsonRpcRequest) { + throw StateError( + 'Expected JsonRpcRequest, got ${parsed.runtimeType}.', + ); + } + if (parsed.meta?[_protocolVersionMetaKey] != + _draftProtocolVersion2026_07_28) { + throw StateError('Expected server/discover metadata to be preserved.'); + } +} + +Future _serverDiscoverReturnsDraftCapabilities() async { + final transport = _ConformanceTransport(); + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + instructions: 'Conformance server.', + ), + ); + + await server.connect(transport); + transport.emit( + JsonRpcRequest( + id: 'discover-1', + method: _serverDiscoverMethod, + meta: _statelessRequestMeta(), + ), + ); + await _settle(); + + final response = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: 'discover-1', + ); + final result = response.result; + if (result['resultType'] != _resultTypeComplete) { + throw StateError('Expected complete server/discover result.'); + } + final supportedVersions = result['supportedVersions']; + if (supportedVersions is! List || + !supportedVersions.contains(_draftProtocolVersion2026_07_28)) { + throw StateError( + 'Expected server/discover to include $_draftProtocolVersion2026_07_28.', + ); + } + final serverInfo = result['serverInfo']; + if (serverInfo is! Map || serverInfo['name'] != 'server') { + throw StateError('Expected server/discover to include server identity.'); + } + if (result['instructions'] != 'Conformance server.') { + throw StateError('Expected server/discover to include instructions.'); + } + final capabilities = result['capabilities']; + if (capabilities is! Map || capabilities['tools'] is! Map) { + throw StateError('Expected server/discover to include tool capabilities.'); + } + + await server.close(); +} + +Future _rejectsUnsupportedStatelessProtocolVersion() async { + final transport = _ConformanceTransport(); + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + + await server.connect(transport); + transport.emit( + JsonRpcRequest( + id: 'unsupported-version', + method: _serverDiscoverMethod, + meta: _statelessRequestMeta(protocolVersion: '1900-01-01'), + ), + ); + await _settle(); + + final error = _expectSingleError( + transport.sentMessages, + id: 'unsupported-version', + code: _unsupportedProtocolVersionCode, + messageContains: 'Unsupported protocol version', + ); + _expectUnsupportedProtocolVersionData(error, requested: '1900-01-01'); + + await server.close(); +} + +Future _statelessRequestsRequireCompleteRequestMeta() async { + final scenarios = <({String id, Map meta, String missing})>[ + ( + id: 'missing-client-info', + meta: { + _protocolVersionMetaKey: _draftProtocolVersion2026_07_28, + _clientCapabilitiesMetaKey: {}, + }, + missing: _clientInfoMetaKey, + ), + ( + id: 'missing-client-capabilities', + meta: { + _protocolVersionMetaKey: _draftProtocolVersion2026_07_28, + _clientInfoMetaKey: { + 'name': 'client', + 'version': '1.0.0', + }, + }, + missing: _clientCapabilitiesMetaKey, + ), + ]; + + final transport = _ConformanceTransport(); + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + + await server.connect(transport); + for (final scenario in scenarios) { + transport.emit( + JsonRpcListToolsRequest( + id: scenario.id, + meta: scenario.meta, + ), + ); + await _settle(); + + _expectSingleError( + transport.sentMessages, + id: scenario.id, + code: ErrorCode.invalidRequest.value, + messageContains: scenario.missing, + ); + transport.sentMessages.clear(); + } + await server.close(); +} + +Future _httpModernProtocolErrorsRetryDiscovery() async { + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final receivedMethods = []; + + late final StreamSubscription serverSubscription; + serverSubscription = httpServer.listen((request) { + unawaited(() async { + try { + final bodyText = await utf8.decodeStream(request); + final body = jsonDecode(bodyText) as Map; + final id = body['id']; + final method = body['method']; + if (method is String) { + receivedMethods.add(method); + } + + request.response.headers.contentType = ContentType.json; + + if (method == _serverDiscoverMethod) { + final params = body['params']; + final meta = params is Map ? params['_meta'] : null; + final requestedVersion = + meta is Map ? meta[_protocolVersionMetaKey] : null; + + if (requestedVersion == '1900-01-01') { + request.response.statusCode = HttpStatus.badRequest; + request.response.write( + jsonEncode( + JsonRpcError( + id: id, + error: JsonRpcErrorData( + code: ErrorCode.unsupportedProtocolVersion.value, + message: 'Unsupported protocol version', + data: const { + 'supported': [_draftProtocolVersion2026_07_28], + 'requested': '1900-01-01', + }, + ), + ).toJson(), + ), + ); + } else { + request.response.statusCode = HttpStatus.ok; + request.response.write( + jsonEncode( + JsonRpcResponse( + id: id, + result: const DiscoverResult( + supportedVersions: [ + _draftProtocolVersion2026_07_28, + ], + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + serverInfo: Implementation( + name: 'modern-http-server', + version: '1.0.0', + ), + ).toJson(), + ).toJson(), + ), + ); + } + } else if (method == Method.initialize) { + request.response.statusCode = HttpStatus.ok; + request.response.write( + jsonEncode(_initializeResponse(id: id).toJson()), + ); + } else { + request.response.statusCode = HttpStatus.accepted; + } + } finally { + await request.response.close(); + } + }()); + }); + + final transport = StreamableHttpClientTransport( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocolVersion: '1900-01-01', + useServerDiscover: true, + ), + ); + + try { + await client.connect(transport); + if (receivedMethods.contains(Method.initialize)) { + throw StateError( + 'Modern HTTP 400 JSON-RPC errors must not trigger initialize fallback.', + ); + } + if (client.getProtocolVersion() != _draftProtocolVersion2026_07_28) { + throw StateError( + 'Expected retry to negotiate $_draftProtocolVersion2026_07_28, ' + 'got ${client.getProtocolVersion()}.', + ); + } + if (receivedMethods + .where((method) => method == _serverDiscoverMethod) + .length != + 2) { + throw StateError( + 'Expected two server/discover attempts, got $receivedMethods.', + ); + } + } finally { + await client.close(); + await serverSubscription.cancel(); + await httpServer.close(force: true); + } +} + +Future _httpModernMissingCapabilityErrorsDoNotFallback() async { + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final receivedMethods = []; + + late final StreamSubscription serverSubscription; + serverSubscription = httpServer.listen((request) { + unawaited(() async { + try { + final bodyText = await utf8.decodeStream(request); + final body = jsonDecode(bodyText) as Map; + final id = body['id']; + final method = body['method']; + if (method is String) { + receivedMethods.add(method); + } + + request.response.headers.contentType = ContentType.json; + + if (method == _serverDiscoverMethod) { + request.response.statusCode = HttpStatus.badRequest; + request.response.write( + jsonEncode( + JsonRpcError( + id: id, + error: JsonRpcErrorData( + code: ErrorCode.missingRequiredClientCapability.value, + message: + 'Server requires the elicitation capability for this request', + data: const { + 'requiredCapabilities': { + 'elicitation': {}, + }, + }, + ), + ).toJson(), + ), + ); + } else if (method == Method.initialize) { + request.response.statusCode = HttpStatus.ok; + request.response.write( + jsonEncode(_initializeResponse(id: id).toJson()), + ); + } else { + request.response.statusCode = HttpStatus.accepted; + } + } finally { + await request.response.close(); + } + }()); + }); + + final transport = StreamableHttpClientTransport( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocolVersion: _draftProtocolVersion2026_07_28, + useServerDiscover: true, + ), + ); + + try { + await client.connect(transport); + throw StateError('Expected missing capability error.'); + } on McpError catch (error) { + if (error.code != ErrorCode.missingRequiredClientCapability.value) { + throw StateError( + 'Expected missing client capability error code, got ${error.code}.', + ); + } + if (!error.message.contains('elicitation capability')) { + throw StateError( + 'Expected missing elicitation capability message, got ' + "'${error.message}'.", + ); + } + final data = error.data; + if (data is! Map || + data['requiredCapabilities'] is! Map || + (data['requiredCapabilities'] as Map)['elicitation'] is! Map) { + throw StateError( + 'Expected requiredCapabilities.elicitation error data, got $data.', + ); + } + if (receivedMethods.contains(Method.initialize)) { + throw StateError( + 'Modern HTTP 400 JSON-RPC errors must not trigger initialize fallback.', + ); + } + if (receivedMethods + .where((method) => method == _serverDiscoverMethod) + .length != + 1) { + throw StateError( + 'Expected one server/discover attempt, got $receivedMethods.', + ); + } + } finally { + await client.close(); + await serverSubscription.cancel(); + await httpServer.close(force: true); + } +} + +Future _initializeNegotiatesStatefulProtocolVersion() async { + final serverTransport = _ConformanceTransport(); + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + await server.connect(serverTransport); + serverTransport.emit( + JsonRpcInitializeRequest( + id: 'draft-initialize', + initParams: const InitializeRequest( + protocolVersion: _draftProtocolVersion2026_07_28, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'client', version: '1.0.0'), + ), + ), + ); + await _settle(); + + final serverResponse = _expectSingleErrorFreeResponse( + serverTransport.sentMessages, + id: 'draft-initialize', + ); + if (serverResponse.result['protocolVersion'] != latestProtocolVersion) { + throw StateError( + 'Expected initialize response protocolVersion $latestProtocolVersion, ' + 'got ${serverResponse.result['protocolVersion']}.', + ); + } + if (serverResponse.result['protocolVersion'] == + _draftProtocolVersion2026_07_28) { + throw StateError('initialize must not negotiate the stateless draft.'); + } + await server.close(); + + final clientTransport = _ConformanceTransport(); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + final connectFuture = client.connect(clientTransport); + await _settle(); + + final discoverRequest = clientTransport.sentMessages + .whereType() + .singleWhere((request) => request.method == _serverDiscoverMethod); + clientTransport.emit( + JsonRpcError( + id: discoverRequest.id, + error: JsonRpcErrorData( + code: ErrorCode.methodNotFound.value, + message: 'Method not found', + ), + ), + ); + await _settle(); + + final initializeRequest = clientTransport.sentMessages + .whereType() + .singleWhere((request) => request.method == Method.initialize); + if (initializeRequest.params?['protocolVersion'] != latestProtocolVersion) { + throw StateError( + 'Expected fallback initialize request protocolVersion ' + '$latestProtocolVersion, got ' + '${initializeRequest.params?['protocolVersion']}.', + ); + } + if (initializeRequest.params?['protocolVersion'] == + _draftProtocolVersion2026_07_28) { + throw StateError('client fallback initialize must not send the draft.'); + } + + clientTransport.emit(_initializeResponse(id: initializeRequest.id)); + await connectFuture.timeout(const Duration(seconds: 1)); + await client.close(); +} + +Future _statelessDoesNotInferInitializeExtensions() async { + final transport = _ConformanceTransport(); + // Raw map parsing keeps this conformance case analyzable against the hosted + // CLI package lower bound while still exercising the 2026 wire behavior. + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ); + + await server.connect(transport); + transport.emit( + _initializeRequest( + id: 'init', + capabilities: const ClientCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ); + await _settle(); + _expectSingleErrorFreeResponse(transport.sentMessages, id: 'init'); + transport.sentMessages.clear(); + + final request = JsonRpcMessage.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': 'stateless-subscribe', + 'method': _methodSubscriptionsListen, + 'params': { + '_meta': _statelessRequestMeta(), + 'notifications': { + 'taskIds': ['task-1'], + }, + }, + }, + ); + if (request is! JsonRpcRequest) { + throw StateError( + 'Expected subscriptions/listen to parse as a request, got ' + '${request.runtimeType}.', + ); + } + + transport.emit(request); + await _settle(); + + final error = _expectSingleError( + transport.sentMessages, + id: 'stateless-subscribe', + code: ErrorCode.missingRequiredClientCapability.value, + messageContains: 'Missing required client capability', + ); + _expectMissingTasksExtensionCapabilityData(error.error.data); + + await server.close(); +} + +Future _rejectsMismatchedStatelessHttpRoutingHeaders() async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + enableDnsRebindingProtection: false, + ), + ); + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final httpClient = HttpClient(); + + await transport.start(); + final serverSubscription = httpServer.listen((request) { + unawaited(transport.handleRequest(request)); + }); + + try { + final request = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + request.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', _draftProtocolVersion2026_07_28) + ..set('Mcp-Method', Method.toolsCall) + ..set('Mcp-Name', 'wrong-tool'); + request.write( + jsonEncode( + { + 'jsonrpc': jsonRpcVersion, + 'id': 'http-header-mismatch', + 'method': Method.toolsCall, + 'params': { + 'name': 'actual-tool', + 'arguments': {}, + '_meta': _statelessRequestMeta(), + }, + }, + ), + ); + + final response = await request.close(); + final responseBody = + jsonDecode(await utf8.decodeStream(response)) as Map; + + if (response.statusCode != HttpStatus.badRequest) { + throw StateError( + 'Expected HTTP 400 for mismatched stateless routing headers, got ' + '${response.statusCode}.', + ); + } + if (responseBody['id'] != 'http-header-mismatch') { + throw StateError( + 'Expected JSON-RPC error id http-header-mismatch, got ' + "${responseBody['id']}.", + ); + } + final error = responseBody['error']; + if (error is! Map || error['code'] != _headerMismatchCode) { + throw StateError('Expected HeaderMismatch error, got $error.'); + } + final message = error['message']; + if (message is! String || !message.contains('Mcp-Name header value')) { + throw StateError('Expected Mcp-Name mismatch diagnostic, got $message.'); + } + } finally { + httpClient.close(force: true); + await serverSubscription.cancel(); + await httpServer.close(force: true); + await transport.close(); + } +} + +Future _requiresStatelessHttpRoutingHeaders() async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + enableDnsRebindingProtection: false, + ), + ); + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final httpClient = HttpClient(); + + await transport.start(); + final serverSubscription = httpServer.listen((request) { + unawaited(transport.handleRequest(request)); + }); + + Future expectHeaderMismatch( + String id, { + required void Function(HttpHeaders headers) addRoutingHeaders, + required String messageFragment, + }) async { + final request = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + request.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream'); + addRoutingHeaders(request.headers); + request.write( + jsonEncode( + JsonRpcListToolsRequest(id: id, meta: _statelessRequestMeta()).toJson(), + ), + ); + + final response = await request.close(); + final responseBody = + jsonDecode(await utf8.decodeStream(response)) as Map; + + if (response.statusCode != HttpStatus.badRequest) { + throw StateError( + 'Expected HTTP 400 for missing stateless routing header, got ' + '${response.statusCode}: $responseBody.', + ); + } + if (responseBody['id'] != id) { + throw StateError( + 'Expected JSON-RPC error id $id, got ${responseBody['id']}.', + ); + } + final error = responseBody['error']; + if (error is! Map || error['code'] != _headerMismatchCode) { + throw StateError('Expected HeaderMismatch error, got $error.'); + } + final message = error['message']; + if (message is! String || !message.contains(messageFragment)) { + throw StateError( + 'Expected diagnostic containing $messageFragment, got $message.', + ); + } + if (response.headers.value('mcp-session-id') != null) { + throw StateError( + 'Expected stateless header mismatch response to omit Mcp-Session-Id, ' + 'got ${response.headers.value('mcp-session-id')}.', + ); + } + } + + try { + await expectHeaderMismatch( + 'http-missing-protocol-header', + addRoutingHeaders: (headers) { + headers.set('Mcp-Method', Method.toolsList); + }, + messageFragment: 'MCP-Protocol-Version header is required', + ); + await expectHeaderMismatch( + 'http-missing-method-header', + addRoutingHeaders: (headers) { + headers.set('MCP-Protocol-Version', _draftProtocolVersion2026_07_28); + }, + messageFragment: 'Mcp-Method header is required', + ); + } finally { + httpClient.close(force: true); + await serverSubscription.cancel(); + await httpServer.close(force: true); + await transport.close(); + } +} + +Future _rejectsStatelessHttpNonPostMethods() async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + enableDnsRebindingProtection: false, + ), + ); + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final httpClient = HttpClient(); + + await transport.start(); + final serverSubscription = httpServer.listen((request) { + unawaited(transport.handleRequest(request)); + }); + + Future expectMethodNotAllowed(String method) async { + final request = await httpClient.openUrl( + method, + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + request.headers.set( + 'MCP-Protocol-Version', + _draftProtocolVersion2026_07_28, + ); + + final response = await request.close(); + final responseBody = + jsonDecode(await utf8.decodeStream(response)) as Map; + + if (response.statusCode != HttpStatus.methodNotAllowed) { + throw StateError( + 'Expected HTTP 405 for stateless $method, got ' + '${response.statusCode}: $responseBody.', + ); + } + if (response.headers.value(HttpHeaders.allowHeader) != 'POST') { + throw StateError( + 'Expected Allow: POST for stateless $method, got ' + '${response.headers.value(HttpHeaders.allowHeader)}.', + ); + } + final error = responseBody['error']; + if (error is! Map || error['code'] != ErrorCode.connectionClosed.value) { + throw StateError( + 'Expected stateless $method to return connection closed error, got ' + '$responseBody.', + ); + } + if (response.headers.value('mcp-session-id') != null) { + throw StateError( + 'Expected stateless $method response to omit Mcp-Session-Id, got ' + '${response.headers.value('mcp-session-id')}.', + ); + } + } + + try { + await expectMethodNotAllowed('GET'); + await expectMethodNotAllowed('DELETE'); + } finally { + httpClient.close(force: true); + await serverSubscription.cancel(); + await httpServer.close(force: true); + await transport.close(); + } +} + +Future _rejectsStatelessHttpBatchPayloads() async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + enableDnsRebindingProtection: false, + rejectBatchJsonRpcPayloads: false, + ), + ); + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final httpClient = HttpClient(); + + await transport.start(); + final serverSubscription = httpServer.listen((request) { + unawaited(transport.handleRequest(request)); + }); + + try { + final request = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + request.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', _draftProtocolVersion2026_07_28); + request.write( + jsonEncode( + >[ + JsonRpcListToolsRequest( + id: 'http-batch-tools-1', + meta: _statelessRequestMeta(), + ).toJson(), + JsonRpcListToolsRequest( + id: 'http-batch-tools-2', + meta: _statelessRequestMeta(), + ).toJson(), + ], + ), + ); + + final response = await request.close(); + final responseBody = + jsonDecode(await utf8.decodeStream(response)) as Map; + + if (response.statusCode != HttpStatus.badRequest) { + throw StateError( + 'Expected HTTP 400 for stateless batch POST body, got ' + '${response.statusCode}: $responseBody.', + ); + } + if (responseBody.containsKey('id')) { + throw StateError( + 'Expected batch-level JSON-RPC error to omit id, got $responseBody.', + ); + } + final error = responseBody['error']; + if (error is! Map || error['code'] != ErrorCode.invalidRequest.value) { + throw StateError('Expected InvalidRequest error, got $error.'); + } + final message = error['message']; + if (message is! String || !message.contains('must contain one')) { + throw StateError( + 'Expected one-message diagnostic for stateless batch body, got ' + '$message.', + ); + } + } finally { + httpClient.close(force: true); + await serverSubscription.cancel(); + await httpServer.close(force: true); + await transport.close(); + } +} + +Future _taskRequestsRequireStatelessHttpNameHeader() async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + enableDnsRebindingProtection: false, + enableJsonResponse: true, + ), + ); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ); + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final httpClient = HttpClient(); + + server.setRequestHandler( + _methodTasksUpdate, + (request, extra) async => const EmptyResult(), + (id, params, meta) => JsonRpcRequest( + id: id, + method: _methodTasksUpdate, + params: params, + meta: meta, + ), + ); + + await server.connect(transport); + final serverSubscription = httpServer.listen((request) { + unawaited(transport.handleRequest(request)); + }); + + try { + final missingNameRequest = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + missingNameRequest.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', _draftProtocolVersion2026_07_28) + ..set('Mcp-Method', _methodTasksUpdate); + missingNameRequest.write( + jsonEncode( + { + 'jsonrpc': jsonRpcVersion, + 'id': 'http-task-update-no-name', + 'method': _methodTasksUpdate, + 'params': { + '_meta': _statelessRequestMeta( + capabilities: const ClientCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + 'taskId': 'task-1', + 'inputResponses': {}, + }, + }, + ), + ); + + final missingNameResponse = await missingNameRequest.close(); + final missingNameBody = jsonDecode( + await utf8.decodeStream(missingNameResponse), + ) as Map; + + if (missingNameResponse.statusCode != HttpStatus.badRequest) { + throw StateError( + 'Expected HTTP 400 for task request without Mcp-Name, got ' + '${missingNameResponse.statusCode}: $missingNameBody.', + ); + } + final missingNameError = missingNameBody['error']; + if (missingNameError is! Map || + missingNameError['code'] != _headerMismatchCode) { + throw StateError( + 'Expected HeaderMismatch for missing task Mcp-Name, got ' + '$missingNameBody.', + ); + } + final missingNameMessage = missingNameError['message']; + if (missingNameMessage is! String || + !missingNameMessage.contains('Mcp-Name header')) { + throw StateError( + 'Expected missing Mcp-Name diagnostic, got $missingNameMessage.', + ); + } + + final request = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + request.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', _draftProtocolVersion2026_07_28) + ..set('Mcp-Method', _methodTasksUpdate) + ..set('Mcp-Name', 'task-1'); + request.write( + jsonEncode( + { + 'jsonrpc': jsonRpcVersion, + 'id': 'http-task-update-name', + 'method': _methodTasksUpdate, + 'params': { + '_meta': _statelessRequestMeta( + capabilities: const ClientCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + 'taskId': 'task-1', + 'inputResponses': {}, + }, + }, + ), + ); + + final response = await request.close(); + final responseBody = + jsonDecode(await utf8.decodeStream(response)) as Map; + + if (response.statusCode != HttpStatus.ok) { + throw StateError( + 'Expected HTTP 200 for task request with Mcp-Name, got ' + '${response.statusCode}: $responseBody.', + ); + } + if (responseBody['id'] != 'http-task-update-name') { + throw StateError( + 'Expected JSON-RPC response id http-task-update-name, got ' + "${responseBody['id']}.", + ); + } + final result = responseBody['result']; + if (result is! Map || result['resultType'] != _resultTypeComplete) { + throw StateError('Expected complete task acknowledgement, got $result.'); + } + } finally { + httpClient.close(force: true); + await serverSubscription.cancel(); + await httpServer.close(force: true); + await server.close(); + } +} + +Future _validatesStatelessHttpParameterHeaders() async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + enableDnsRebindingProtection: false, + enableJsonResponse: true, + ), + ); + // Keep this dynamic so mcp_dart_cli remains analyzable against the published + // mcp_dart lower bound until this SDK branch is released. + (transport as dynamic).setToolParameterHeaderMappings( + const >{ + 'execute': { + 'count': 'Count', + 'dryRun': 'Dry-Run', + 'region': 'Region', + }, + }, + ); + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final httpClient = HttpClient(); + + transport.onmessage = (message) { + if (message is JsonRpcCallToolRequest) { + unawaited( + transport.send( + JsonRpcResponse( + id: message.id, + result: const CallToolResult(content: []).toJson(), + ), + ), + ); + } + }; + + await transport.start(); + final serverSubscription = httpServer.listen((request) { + unawaited(transport.handleRequest(request)); + }); + + Future> postToolCall({ + required String id, + required Map headers, + Map arguments = const { + 'dryRun': false, + 'region': 'us-east1', + }, + }) async { + final request = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + request.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', _draftProtocolVersion2026_07_28) + ..set('Mcp-Method', Method.toolsCall) + ..set('Mcp-Name', 'execute'); + headers.forEach(request.headers.set); + request.write( + jsonEncode( + JsonRpcCallToolRequest( + id: id, + params: { + 'name': 'execute', + 'arguments': arguments, + }, + meta: _statelessRequestMeta(), + ).toJson(), + ), + ); + + final response = await request.close(); + final responseBody = + jsonDecode(await utf8.decodeStream(response)) as Map; + return { + 'statusCode': response.statusCode, + 'body': responseBody, + }; + } + + void expectHeaderMismatch( + Map response, { + required String id, + required String messageFragment, + }) { + final statusCode = response['statusCode']; + final responseBody = response['body'] as Map; + if (statusCode != HttpStatus.badRequest) { + throw StateError( + 'Expected HTTP 400 for parameter header mismatch, got ' + '$statusCode: $responseBody.', + ); + } + if (responseBody['id'] != id) { + throw StateError( + 'Expected JSON-RPC error id $id, got ${responseBody['id']}.', + ); + } + final error = responseBody['error']; + if (error is! Map || error['code'] != _headerMismatchCode) { + throw StateError('Expected HeaderMismatch error, got $error.'); + } + final message = error['message']; + if (message is! String || !message.contains(messageFragment)) { + throw StateError( + 'Expected diagnostic containing $messageFragment, got $message.', + ); + } + } + + try { + expectHeaderMismatch( + await postToolCall( + id: 'http-missing-param-header', + headers: const { + 'Mcp-Param-Region': 'us-east1', + }, + ), + id: 'http-missing-param-header', + messageFragment: 'Mcp-Param-Dry-Run header is required', + ); + + expectHeaderMismatch( + await postToolCall( + id: 'http-mismatched-param-header', + headers: const { + 'Mcp-Param-Dry-Run': 'true', + 'Mcp-Param-Region': 'us-east1', + }, + ), + id: 'http-mismatched-param-header', + messageFragment: "body argument 'dryRun'", + ); + + final success = await postToolCall( + id: 'http-matched-param-headers', + arguments: const { + 'count': 42, + 'dryRun': false, + 'region': 'us-east1', + }, + headers: const { + 'Mcp-Param-Count': '42', + 'Mcp-Param-Dry-Run': 'false', + 'Mcp-Param-Region': 'us-east1', + }, + ); + final statusCode = success['statusCode']; + final responseBody = success['body'] as Map; + if (statusCode != HttpStatus.ok) { + throw StateError( + 'Expected HTTP 200 for matching parameter headers, got ' + '$statusCode: $responseBody.', + ); + } + if (responseBody['id'] != 'http-matched-param-headers') { + throw StateError('Unexpected matched parameter response $responseBody.'); + } + final result = responseBody['result']; + if (result is! Map || result['content'] is! List) { + throw StateError('Expected successful tool result, got $result.'); + } + } finally { + httpClient.close(force: true); + await serverSubscription.cancel(); + await httpServer.close(force: true); + await transport.close(); + } +} + +Future _omitsInvalidNumericParameterHeaders() async { + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final receivedHeaders = Completer>(); + final responseMessage = Completer(); + final transport = StreamableHttpClientTransport( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + )..protocolVersion = _draftProtocolVersion2026_07_28; + // Keep this dynamic so mcp_dart_cli remains analyzable against the published + // mcp_dart lower bound until this SDK branch is released. + (transport as dynamic).setToolParameterHeaderMappings( + const >{ + 'calculate': { + 'limit': 'Limit', + 'ratio': 'Ratio', + 'unsafe': 'Unsafe', + }, + }, + ); + + final serverSubscription = httpServer.listen((request) async { + if (!receivedHeaders.isCompleted) { + receivedHeaders.complete( + { + 'limit': request.headers.value('mcp-param-limit'), + 'ratio': request.headers.value('mcp-param-ratio'), + 'unsafe': request.headers.value('mcp-param-unsafe'), + }, + ); + } + await request.drain(); + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write( + jsonEncode( + const JsonRpcResponse( + id: 'number-headers', + result: { + 'resultType': _resultTypeComplete, + 'content': [], + }, + ).toJson(), + ), + ); + await request.response.close(); + }); + + transport.onmessage = responseMessage.complete; + await transport.start(); + + try { + await transport.send( + JsonRpcCallToolRequest( + id: 'number-headers', + params: const { + 'name': 'calculate', + 'arguments': { + 'limit': 42, + 'ratio': 1.5, + 'unsafe': 9007199254740992, + }, + }, + meta: _statelessRequestMeta(), + ), + ); + + final headers = await receivedHeaders.future.timeout( + const Duration(seconds: 5), + ); + if (headers['limit'] != '42') { + throw StateError( + 'Expected safe integer header 42, got ${headers['limit']}.', + ); + } + if (headers['ratio'] != null) { + throw StateError( + 'Expected fractional number header to be omitted, got ' + "${headers['ratio']}.", + ); + } + if (headers['unsafe'] != null) { + throw StateError( + 'Expected unsafe integer header to be omitted, got ' + "${headers['unsafe']}.", + ); + } + + final response = await responseMessage.future.timeout( + const Duration(seconds: 5), + ); + if (response is! JsonRpcResponse || response.id != 'number-headers') { + throw StateError('Expected JSON-RPC response, got $response.'); + } + } finally { + await transport.close(); + await serverSubscription.cancel(); + await httpServer.close(force: true); + } +} + +Future _encodesStatelessHttpParameterHeaderValues() async { + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final receivedHeaders = Completer>(); + final responseMessage = Completer(); + final transport = StreamableHttpClientTransport( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + )..protocolVersion = _draftProtocolVersion2026_07_28; + // Keep this dynamic so mcp_dart_cli remains analyzable against the published + // mcp_dart lower bound until this SDK branch is released. + (transport as dynamic).setToolParameterHeaderMappings( + const >{ + 'echo': { + 'greeting': 'Greeting', + 'plain': 'Plain', + 'sentinel': 'Sentinel', + 'spaced': 'Spaced', + }, + }, + ); + + final serverSubscription = httpServer.listen((request) async { + if (!receivedHeaders.isCompleted) { + receivedHeaders.complete( + { + 'greeting': request.headers.value('mcp-param-greeting'), + 'plain': request.headers.value('mcp-param-plain'), + 'sentinel': request.headers.value('mcp-param-sentinel'), + 'spaced': request.headers.value('mcp-param-spaced'), + }, + ); + } + await request.drain(); + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write( + jsonEncode( + const JsonRpcResponse( + id: 'encoded-headers', + result: { + 'resultType': _resultTypeComplete, + 'content': [], + }, + ).toJson(), + ), + ); + await request.response.close(); + }); + + transport.onmessage = responseMessage.complete; + await transport.start(); + + String encodedHeaderValue(String value) => + '=?base64?${base64Encode(utf8.encode(value))}?='; + + final nonAsciiGreeting = 'Hello, ${String.fromCharCodes( + const [0x4e16, 0x754c], + )}'; + + try { + await transport.send( + JsonRpcCallToolRequest( + id: 'encoded-headers', + params: { + 'name': 'echo', + 'arguments': { + 'greeting': nonAsciiGreeting, + 'plain': 'us-east1', + 'sentinel': '=?base64?literal?=', + 'spaced': ' padded ', + }, + }, + meta: _statelessRequestMeta(), + ), + ); + + final headers = await receivedHeaders.future.timeout( + const Duration(seconds: 5), + ); + final expectedGreeting = encodedHeaderValue(nonAsciiGreeting); + if (headers['greeting'] != expectedGreeting) { + throw StateError( + 'Expected non-ASCII string header $expectedGreeting, got ' + "${headers['greeting']}.", + ); + } + if (headers['plain'] != 'us-east1') { + throw StateError( + 'Expected plain string header us-east1, got ${headers['plain']}.', + ); + } + final expectedSentinel = encodedHeaderValue('=?base64?literal?='); + if (headers['sentinel'] != expectedSentinel) { + throw StateError( + 'Expected sentinel-looking string header $expectedSentinel, got ' + "${headers['sentinel']}.", + ); + } + final expectedSpaced = encodedHeaderValue(' padded '); + if (headers['spaced'] != expectedSpaced) { + throw StateError( + 'Expected trim-sensitive string header $expectedSpaced, got ' + "${headers['spaced']}.", + ); + } + + final response = await responseMessage.future.timeout( + const Duration(seconds: 5), + ); + if (response is! JsonRpcResponse || response.id != 'encoded-headers') { + throw StateError('Expected JSON-RPC response, got $response.'); + } + } finally { + await transport.close(); + await serverSubscription.cancel(); + await httpServer.close(force: true); + } +} + +Future _acceptsStatelessHttpResponsePosts() async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + enableDnsRebindingProtection: false, + enableJsonResponse: true, + ), + ); + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final httpClient = HttpClient(); + final receivedMessage = Completer(); + + transport.onmessage = receivedMessage.complete; + + await transport.start(); + final serverSubscription = httpServer.listen((request) { + unawaited(transport.handleRequest(request)); + }); + + try { + final request = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + request.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', _draftProtocolVersion2026_07_28); + request.write( + jsonEncode( + const JsonRpcResponse( + id: 'http-input-response', + result: {'ok': true}, + ).toJson(), + ), + ); + + final response = await request.close(); + final responseBody = await utf8.decodeStream(response); + + if (response.statusCode != HttpStatus.accepted) { + throw StateError( + 'Expected HTTP 202 for stateless response POST, got ' + '${response.statusCode}: $responseBody.', + ); + } + if (responseBody.isNotEmpty) { + throw StateError( + 'Expected empty stateless response POST body, got $responseBody.', + ); + } + + final message = await receivedMessage.future.timeout( + const Duration(seconds: 5), + ); + if (message is! JsonRpcResponse) { + throw StateError( + 'Expected server transport to receive JsonRpcResponse, got ' + '${message.runtimeType}.', + ); + } + if (message.id != 'http-input-response' || message.result['ok'] != true) { + throw StateError('Unexpected stateless response POST message $message.'); + } + } finally { + httpClient.close(force: true); + await serverSubscription.cancel(); + await httpServer.close(force: true); + await transport.close(); + } +} + +Future _statelessHttpOmitsSessionHeaderAfterInitialize() async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => 'stateful-session-id', + enableDnsRebindingProtection: false, + enableJsonResponse: true, + ), + ); + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final httpClient = HttpClient(); + + transport.onmessage = (message) { + if (message is JsonRpcInitializeRequest) { + unawaited( + transport.send( + JsonRpcResponse( + id: message.id, + result: const InitializeResult( + protocolVersion: latestProtocolVersion, + capabilities: ServerCapabilities(), + serverInfo: Implementation( + name: 'conformance-server', + version: '1.0.0', + ), + ).toJson(), + ), + ), + ); + } else if (message is JsonRpcListToolsRequest) { + unawaited( + transport.send( + JsonRpcResponse( + id: message.id, + result: const ListToolsResult(tools: []).toJson(), + ), + ), + ); + } + }; + + await transport.start(); + final serverSubscription = httpServer.listen((request) { + unawaited(transport.handleRequest(request)); + }); + + try { + final initRequest = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + initRequest.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream'); + initRequest.write( + jsonEncode( + JsonRpcInitializeRequest( + id: 'initialize-session', + initParams: const InitializeRequest( + protocolVersion: latestProtocolVersion, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'client', version: '1.0.0'), + ), + ).toJson(), + ), + ); + + final initResponse = await initRequest.close(); + await utf8.decodeStream(initResponse); + final sessionId = initResponse.headers.value('mcp-session-id'); + if (initResponse.statusCode != HttpStatus.ok || + sessionId != 'stateful-session-id') { + throw StateError( + 'Expected stateful initialize to create a session, got ' + '${initResponse.statusCode} with session $sessionId.', + ); + } + final confirmedSessionId = sessionId!; + + final statelessRequest = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + statelessRequest.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', _draftProtocolVersion2026_07_28) + ..set('Mcp-Method', Method.toolsList) + ..set('Mcp-Session-Id', confirmedSessionId); + statelessRequest.write( + jsonEncode( + JsonRpcListToolsRequest( + id: 'stateless-tools', + meta: _statelessRequestMeta(), + ).toJson(), + ), + ); + + final statelessResponse = await statelessRequest.close(); + final responseBody = jsonDecode(await utf8.decodeStream(statelessResponse)) + as Map; + if (statelessResponse.statusCode != HttpStatus.ok) { + throw StateError( + 'Expected stateless request to succeed, got ' + '${statelessResponse.statusCode}: $responseBody.', + ); + } + if (statelessResponse.headers.value('mcp-session-id') != null) { + throw StateError( + 'Expected stateless response to omit Mcp-Session-Id, got ' + '${statelessResponse.headers.value('mcp-session-id')}.', + ); + } + if (responseBody['id'] != 'stateless-tools') { + throw StateError('Unexpected stateless response body $responseBody.'); + } + } finally { + httpClient.close(force: true); + await serverSubscription.cancel(); + await httpServer.close(force: true); + await transport.close(); + } +} + +Future _taskSubscriptionRequiresClientCapability() async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + enableDnsRebindingProtection: false, + ), + ); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ); + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final httpClient = HttpClient(); + + await server.connect(transport); + final serverSubscription = httpServer.listen((request) { + unawaited(transport.handleRequest(request)); + }); + + try { + final request = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + request.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', _draftProtocolVersion2026_07_28) + ..set('Mcp-Method', _methodSubscriptionsListen); + request.write( + jsonEncode( + { + 'jsonrpc': jsonRpcVersion, + 'id': 'http-task-subscription-capability', + 'method': _methodSubscriptionsListen, + 'params': { + '_meta': _statelessRequestMeta(), + 'notifications': { + 'taskIds': ['task-1'], + }, + }, + }, + ), + ); + + final response = await request.close(); + final responseBody = + jsonDecode(await utf8.decodeStream(response)) as Map; + + if (response.statusCode != HttpStatus.badRequest) { + throw StateError( + 'Expected HTTP 400 for missing stateless task extension capability, got ' + '${response.statusCode}.', + ); + } + if (responseBody['id'] != 'http-task-subscription-capability') { + throw StateError( + 'Expected JSON-RPC error id http-task-subscription-capability, got ' + "${responseBody['id']}.", + ); + } + final error = responseBody['error']; + if (error is! Map || + error['code'] != ErrorCode.missingRequiredClientCapability.value) { + throw StateError( + 'Expected MissingRequiredClientCapability error, got $error.', + ); + } + if (!'${error['message']}'.contains('Missing required client capability')) { + throw StateError( + 'Expected MissingRequiredClientCapability message, ' + 'got ${error['message']}.', + ); + } + _expectMissingTasksExtensionCapabilityData(error['data']); + } finally { + httpClient.close(force: true); + await serverSubscription.cancel(); + await httpServer.close(force: true); + await server.close(); + } +} + +Future _relatedTaskUsesExplicitIdAcrossTransports() async { + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ); + var handlerCalls = 0; + final seenTaskIds = []; + final errors = []; + server.onerror = errors.add; + server.setRequestHandler( + _methodTasksUpdate, + (request, extra) async { + if (extra.sessionId != null) { + throw StateError('Stateless task request unexpectedly had a session.'); + } + final params = request.params; + if (params == null) { + throw StateError('Expected task update params.'); + } + final taskId = params['taskId']; + if (taskId is! String) { + throw StateError('Expected task update params to include taskId.'); + } + final inputResponses = params['inputResponses']; + if (inputResponses is! Map || inputResponses.isNotEmpty) { + throw StateError('Expected empty task inputResponses.'); + } + handlerCalls += 1; + seenTaskIds.add(taskId); + return const EmptyResult(); + }, + (id, params, meta) => JsonRpcRequest( + id: id, + method: _methodTasksUpdate, + params: params, + meta: meta, + ), + ); + + Future> updateTaskOverNewTransport(int id) async { + final transport = _ConformanceTransport(); + await server.connect(transport); + transport.emit( + JsonRpcRequest( + id: id, + method: _methodTasksUpdate, + params: const { + 'taskId': 'task-connection', + 'inputResponses': {}, + }, + meta: _statelessRequestMeta( + capabilities: const ClientCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 100)); + if (transport.sentMessages.isEmpty && errors.isNotEmpty) { + throw StateError('Server errors: $errors.'); + } + final response = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: id, + ); + await server.close(); + return response.result; + } + + try { + final firstResult = await updateTaskOverNewTransport(201); + final secondResult = await updateTaskOverNewTransport(202); + + if (seenTaskIds.length != 2 || + seenTaskIds.any((taskId) => taskId != 'task-connection')) { + throw StateError( + 'Expected both task updates to use the explicit task ID, got ' + '$seenTaskIds.', + ); + } + if (firstResult['resultType'] != _resultTypeComplete || + secondResult['resultType'] != _resultTypeComplete) { + throw StateError( + 'Expected stateless task updates to receive complete acknowledgements, ' + 'got $firstResult and $secondResult.', + ); + } + if (handlerCalls != 2) { + throw StateError('Expected two task handler calls, got $handlerCalls.'); + } + } finally { + await server.close(); + } +} + +Future _statelessIgnoresLegacyTaskParameter() async { + final transport = _ConformanceTransport(); + RequestHandlerExtra? receivedExtra; + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + tasks: ServerCapabilitiesTasks( + requests: ServerCapabilitiesTasksRequests( + tools: ServerCapabilitiesTasksTools( + call: ServerCapabilitiesTasksToolsCall(), + ), + ), + ), + ), + ), + ); + server.setRequestHandler( + Method.toolsCall, + (request, extra) async { + receivedExtra = extra; + return const CallToolResult( + content: [TextContent(text: 'ok')], + ); + }, + (id, params, meta) => JsonRpcCallToolRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + + await server.connect(transport); + transport.emit( + JsonRpcCallToolRequest( + id: 'legacy-task-param', + params: { + ...const CallToolRequest(name: 'legacy-task').toJson(), + 'task': {'ttl': 1000}, + }, + meta: _statelessRequestMeta(), + ), + ); + await _settle(); + + final response = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: 'legacy-task-param', + ); + if (response.result['resultType'] != _resultTypeComplete || + receivedExtra?.taskRequestedTtl != null) { + throw StateError( + 'Expected stateless request to ignore legacy task parameter; result ' + '${response.result}, taskRequestedTtl ' + '${receivedExtra?.taskRequestedTtl}.', + ); + } + + await server.close(); +} + +Future _statelessClientRejectsLegacyTaskOptions() async { + final transport = _DiscoveringConformanceTransport( + toolsListResult: const { + 'resultType': _resultTypeComplete, + 'tools': [], + 'ttlMs': 0, + 'cacheScope': CacheScope.private, + }, + toolsCallResult: const { + 'resultType': _resultTypeComplete, + 'content': [], + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + + await client.connect(transport); + final sentBeforeCall = transport.sentMessages.length; + + try { + await client.callTool( + const CallToolRequest(name: 'legacy-task'), + options: const RequestOptions(task: TaskCreation(ttl: 1000)), + ); + } on McpError catch (error) { + if (error.code != ErrorCode.invalidRequest.value || + !error.message.contains('RequestOptions.task')) { + throw StateError( + 'Expected InvalidRequest for RequestOptions.task, got ' + '${error.code}: ${error.message}.', + ); + } + final toolsCallRequests = transport.sentMessages + .skip(sentBeforeCall) + .whereType() + .where((request) => request.method == Method.toolsCall) + .toList(); + if (toolsCallRequests.isNotEmpty) { + throw StateError( + 'Expected no stateless tools/call request after legacy task option, ' + 'got ${toolsCallRequests.single.toJson()}.', + ); + } + await client.close(); + return; + } + + await client.close(); + throw StateError('Expected stateless client to reject RequestOptions.task.'); +} + +Future _statelessAddsResultTypeAndCacheDefaults() async { + final transport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + prompts: ServerCapabilitiesPrompts(), + resources: ServerCapabilitiesResources(), + tools: ServerCapabilitiesTools(), + ), + ), + ); + server.setRequestHandler( + Method.toolsList, + (request, extra) async => const ListToolsResult( + tools: [], + ttlMs: 300000, + cacheScope: CacheScope.public, + ), + (id, params, meta) => JsonRpcListToolsRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsList, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + server.setRequestHandler( + Method.promptsList, + (request, extra) async => const ListPromptsResult(prompts: []), + (id, params, meta) => JsonRpcListPromptsRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.promptsList, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + server.setRequestHandler( + Method.resourcesList, + (request, extra) async => const ListResourcesResult( + resources: [], + ), + (id, params, meta) => JsonRpcListResourcesRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.resourcesList, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + server.setRequestHandler( + Method.resourcesTemplatesList, + (request, extra) async => const ListResourceTemplatesResult( + resourceTemplates: [], + ), + (id, params, meta) => JsonRpcListResourceTemplatesRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.resourcesTemplatesList, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + server.setRequestHandler( + Method.resourcesRead, + (request, extra) async => const ReadResourceResult( + contents: [ + TextResourceContents(uri: 'file:///a.txt', text: 'a'), + ], + ), + (id, params, meta) => JsonRpcReadResourceRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.resourcesRead, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + + await server.connect(transport); + final requests = [ + JsonRpcListToolsRequest( + id: 'tools-list', + meta: _statelessRequestMeta(), + ), + JsonRpcListPromptsRequest( + id: 'prompts-list', + meta: _statelessRequestMeta(), + ), + JsonRpcListResourcesRequest( + id: 'resources-list', + meta: _statelessRequestMeta(), + ), + JsonRpcListResourceTemplatesRequest( + id: 'resource-templates-list', + meta: _statelessRequestMeta(), + ), + JsonRpcReadResourceRequest( + id: 'resources-read', + readParams: const ReadResourceRequest(uri: 'file:///a.txt'), + meta: _statelessRequestMeta(), + ), + ]; + for (final request in requests) { + transport.emit(request); + await _settle(); + } + + final responses = transport.sentMessages.cast().toList(); + if (responses.length != requests.length) { + throw StateError( + 'Expected ${requests.length} cacheable responses, got ' + '${responses.length}: ${transport.sentMessages}.', + ); + } + + for (final response in responses) { + final result = response.result; + if (result['resultType'] != _resultTypeComplete) { + throw StateError( + 'Expected stateless ${response.id} resultType complete, got $result.', + ); + } + } + + final toolsResult = responses.first.result; + if (toolsResult['ttlMs'] != 300000 || + toolsResult['cacheScope'] != CacheScope.public) { + throw StateError( + 'Expected explicit tools/list cache hints to be preserved, got ' + '$toolsResult.', + ); + } + + for (final response in responses.skip(1)) { + final result = response.result; + if (result['ttlMs'] != 0 || result['cacheScope'] != _cacheScopePrivate) { + throw StateError( + 'Expected stateless ${response.id} cache defaults, got $result.', + ); + } + } + + await server.close(); +} + +Future _statelessToolsListReturnsDeterministicOrder() async { + final transport = _ConformanceTransport(); + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), + ); + for (final name in const ['zeta', 'alpha', 'middle']) { + server.registerTool( + name, + callback: (args, extra) async { + return const CallToolResult( + content: [TextContent(text: 'ok')], + ); + }, + ); + } + + await server.connect(transport); + transport.emit( + JsonRpcListToolsRequest( + id: 'tools-order', + meta: _statelessRequestMeta(), + ), + ); + await _settle(); + + final response = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: 'tools-order', + ); + final tools = response.result['tools']; + if (tools is! List) { + throw StateError('Expected tools/list result tools array, got $tools.'); + } + final names = tools.map((tool) { + if (tool is! Map) { + throw StateError('Expected tool object, got $tool.'); + } + final name = tool['name']; + if (name is! String) { + throw StateError('Expected tool name string, got $tool.'); + } + return name; + }).toList(growable: false); + const expectedNames = ['alpha', 'middle', 'zeta']; + if (!_stringListEquals(names, expectedNames)) { + throw StateError( + 'Expected deterministic tools/list order $expectedNames, got $names.', + ); + } + + await server.close(); +} + +Future _statelessToolsListOmitsLegacyExecution() async { + final transport = _ConformanceTransport(); + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), + ); + server.server.setRequestHandler( + Method.toolsList, + (request, extra) async => const ListToolsResult( + tools: [ + Tool( + name: 'task-tool', + inputSchema: JsonObject(), + execution: ToolExecution(taskSupport: 'required'), + ), + ], + ), + (id, params, meta) => JsonRpcListToolsRequest( + id: id, + params: params, + meta: meta, + ), + ); + + await server.connect(transport); + transport.emit( + JsonRpcListToolsRequest( + id: 'tools-execution', + meta: _statelessRequestMeta(), + ), + ); + await _settle(); + + final response = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: 'tools-execution', + ); + final tools = response.result['tools']; + if (tools is! List || tools.length != 1 || tools.single is! Map) { + throw StateError('Expected one tool object, got $tools.'); + } + final tool = tools.single as Map; + if (tool.containsKey('execution')) { + throw StateError( + 'Expected stateless tools/list to omit legacy execution, got $tool.', + ); + } + + await server.close(); +} + +Future _missingResourceErrorCodeByVersion() async { + final legacyTransport = _ConformanceTransport(); + final legacyServer = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + legacyServer.registerResource( + 'Known Resource', + 'memory://known', + null, + (uri, extra) async => ReadResourceResult( + contents: [ + TextResourceContents(uri: uri.toString(), text: 'known'), + ], + ), + ); + + await _initializeMcpServer(legacyServer, legacyTransport); + legacyTransport.emit( + JsonRpcReadResourceRequest( + id: 'legacy-missing-resource', + readParams: const ReadResourceRequest(uri: 'memory://missing'), + ), + ); + await _settle(); + + var error = _expectSingleError( + legacyTransport.sentMessages, + id: 'legacy-missing-resource', + code: ErrorCode.resourceNotFound.value, + messageContains: 'Resource not found', + ); + if (error.error.data is! Map || + (error.error.data as Map)['uri'] != 'memory://missing') { + throw StateError( + 'Expected legacy missing resource URI in error data, got ' + '${error.error.data}.', + ); + } + await legacyServer.close(); + + final statelessTransport = _ConformanceTransport(); + final statelessServer = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + statelessServer.registerResource( + 'Known Resource', + 'memory://known', + null, + (uri, extra) async => ReadResourceResult( + contents: [ + TextResourceContents(uri: uri.toString(), text: 'known'), + ], + ), + ); + + await statelessServer.connect(statelessTransport); + statelessTransport.emit( + JsonRpcReadResourceRequest( + id: 'stateless-missing-resource', + readParams: const ReadResourceRequest(uri: 'memory://missing'), + meta: _statelessRequestMeta(), + ), + ); + await _settle(); + + error = _expectSingleError( + statelessTransport.sentMessages, + id: 'stateless-missing-resource', + code: ErrorCode.invalidParams.value, + messageContains: 'Resource not found', + ); + if (error.error.data is! Map || + (error.error.data as Map)['uri'] != 'memory://missing') { + throw StateError( + 'Expected stateless missing resource URI in error data, got ' + '${error.error.data}.', + ); + } + + await statelessServer.close(); +} + +Future _statelessRejectsUnrecognizedResultType() async { + final transport = _DiscoveringConformanceTransport( + toolsListResult: const { + 'resultType': _resultTypeFutureExtension, + 'tools': [], + 'ttlMs': 0, + 'cacheScope': _cacheScopePrivate, + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + + try { + await client.connect(transport); + try { + await client.listTools(); + } on McpError catch (error) { + if (error.code != ErrorCode.internalError.value) { + throw StateError( + 'Expected internal error for unrecognized resultType, got ' + '${error.code}.', + ); + } + final data = error.data.toString(); + if (!data.contains( + 'Unrecognized MCP resultType "$_resultTypeFutureExtension"', + )) { + throw StateError( + 'Expected unrecognized resultType diagnostic, got ${error.data}.', + ); + } + return; + } + + throw StateError( + 'Expected unrecognized stateless resultType to be rejected.', + ); + } finally { + await client.close(); + } +} + +Future _mrtrInputRequiredSupportedRequests() async { + final transport = _ConformanceTransport(); + // Raw protocol conformance needs the low-level server so resultType + // validation is exercised directly at the JSON-RPC boundary. + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + prompts: ServerCapabilitiesPrompts(), + resources: ServerCapabilitiesResources(), + tools: ServerCapabilitiesTools(), + ), + ), + ); + + server.setRequestHandler( + Method.toolsCall, + (request, extra) async => + const InputRequiredResult(requestState: 'tool-state'), + (id, params, meta) => JsonRpcCallToolRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + server.setRequestHandler( + Method.promptsGet, + (request, extra) async => + const InputRequiredResult(requestState: 'prompt-state'), + (id, params, meta) => JsonRpcGetPromptRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.promptsGet, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + server.setRequestHandler( + Method.resourcesRead, + (request, extra) async => + const InputRequiredResult(requestState: 'resource-state'), + (id, params, meta) => JsonRpcReadResourceRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.resourcesRead, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + + try { + await server.connect(transport); + + final scenarios = >[ + MapEntry( + JsonRpcCallToolRequest( + id: 'mrtr-tool', + params: const CallToolRequest(name: 'needs-input').toJson(), + meta: _statelessRequestMeta(), + ), + 'tool-state', + ), + MapEntry( + JsonRpcGetPromptRequest( + id: 'mrtr-prompt', + getParams: const GetPromptRequest(name: 'needs_input'), + meta: _statelessRequestMeta(), + ), + 'prompt-state', + ), + MapEntry( + JsonRpcReadResourceRequest( + id: 'mrtr-resource', + readParams: const ReadResourceRequest(uri: 'memory://needs-input'), + meta: _statelessRequestMeta(), + ), + 'resource-state', + ), + ]; + + for (final scenario in scenarios) { + transport.sentMessages.clear(); + transport.emit(scenario.key); + await _settle(); + + final response = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: scenario.key.id, + ); + if (response.result['resultType'] != _resultTypeInputRequired || + response.result['requestState'] != scenario.value) { + throw StateError( + 'Expected ${scenario.key.method} to allow input_required with ' + 'requestState ${scenario.value}, got ${response.result}.', + ); + } + } + } finally { + await server.close(); + } +} + +Future _mrtrRejectsUnsupportedInputRequiredResults() async { + final transport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), + ), + ); + server.setRequestHandler( + Method.toolsList, + (request, extra) async => + const InputRequiredResult(requestState: 'list-state'), + (id, params, meta) => JsonRpcListToolsRequest( + id: id, + params: params, + meta: meta, + ), + ); + + try { + await server.connect(transport); + transport.emit( + JsonRpcListToolsRequest( + id: 'mrtr-list-tools', + meta: _statelessRequestMeta(), + ), + ); + await _settle(); + + _expectSingleError( + transport.sentMessages, + id: 'mrtr-list-tools', + code: ErrorCode.invalidParams.value, + messageContains: 'InputRequiredResult', + ); + } finally { + await server.close(); + } +} + +Future _mrtrInputRequestsRequireClientCapabilities() async { + final transport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), + ), + ); + server.setRequestHandler( + Method.toolsCall, + (request, extra) async { + final inputRequest = switch (request.callParams.name) { + 'needs-form' => InputRequest.elicit( + ElicitRequest.form( + message: 'Enter name', + requestedSchema: JsonSchema.object( + properties: { + 'name': JsonSchema.string(), + }, + required: const ['name'], + ), + ), + ), + 'needs-url' => InputRequest.elicit( + const ElicitRequest.url( + message: 'Open browser', + url: 'https://example.com/authorize', + elicitationId: 'auth-1', + ), + ), + 'needs-roots' => InputRequest.listRoots(), + 'needs-sampling-tools' => InputRequest.createMessage( + const CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Search'), + ), + ], + maxTokens: 16, + tools: [ + Tool(name: 'lookup', inputSchema: JsonObject()), + ], + ), + ), + _ => throw StateError('Unknown tool ${request.callParams.name}'), + }; + + return InputRequiredResult( + inputRequests: { + request.callParams.name: inputRequest, + }, + ); + }, + (id, params, meta) => JsonRpcCallToolRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + + final missingCapabilityScenarios = <_MissingCapabilityScenario>[ + _MissingCapabilityScenario( + name: 'needs-form', + capabilities: const ClientCapabilities(), + method: Method.elicitationCreate, + requiredCapabilities: const { + 'elicitation': { + 'form': {}, + }, + }, + ), + _MissingCapabilityScenario( + name: 'needs-url', + capabilities: const ClientCapabilities( + elicitation: ClientElicitation.formOnly(), + ), + method: Method.elicitationCreate, + requiredCapabilities: const { + 'elicitation': { + 'url': {}, + }, + }, + ), + _MissingCapabilityScenario( + name: 'needs-roots', + capabilities: const ClientCapabilities(), + method: Method.rootsList, + requiredCapabilities: const { + 'roots': {}, + }, + ), + _MissingCapabilityScenario( + name: 'needs-sampling-tools', + capabilities: const ClientCapabilities( + sampling: ClientCapabilitiesSampling(), + ), + method: Method.samplingCreateMessage, + requiredCapabilities: const { + 'sampling': { + 'tools': {}, + }, + }, + ), + ]; + + try { + await server.connect(transport); + + for (final scenario in missingCapabilityScenarios) { + transport.sentMessages.clear(); + transport.emit( + JsonRpcCallToolRequest( + id: scenario.name, + params: CallToolRequest(name: scenario.name).toJson(), + meta: _statelessRequestMeta(capabilities: scenario.capabilities), + ), + ); + await _settle(); + + final error = _expectSingleError( + transport.sentMessages, + id: scenario.name, + code: ErrorCode.missingRequiredClientCapability.value, + messageContains: 'Missing required client capability', + ); + final data = error.error.data; + if (data is! Map || + data['inputRequest'] != scenario.name || + data['method'] != scenario.method || + !_mapsDeepEqual( + data['requiredCapabilities'], + scenario.requiredCapabilities, + )) { + throw StateError( + 'Expected missing client capability details for ${scenario.name}, ' + 'got $data.', + ); + } + } + + transport.sentMessages.clear(); + transport.emit( + JsonRpcCallToolRequest( + id: 'mrtr-allowed-form', + params: const CallToolRequest(name: 'needs-form').toJson(), + meta: _statelessRequestMeta( + capabilities: const ClientCapabilities( + elicitation: ClientElicitation.formOnly(), + ), + ), + ), + ); + await _settle(); + + final allowedResponse = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: 'mrtr-allowed-form', + ); + if (allowedResponse.result['resultType'] != _resultTypeInputRequired) { + throw StateError( + 'Expected declared form elicitation capability to allow MRTR input ' + 'request, got ${allowedResponse.result}.', + ); + } + } finally { + await server.close(); + } +} + +Future _callToolResultCannotSpoofTaskResult() async { + final transport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ); + + server.setRequestHandler( + Method.toolsCall, + (request, extra) async => const CallToolResult( + content: [TextContent(text: 'spoof')], + extra: { + 'resultType': 'task', + 'taskId': 'spoofed-task', + }, + ), + (id, params, meta) => JsonRpcCallToolRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + + try { + await server.connect(transport); + transport.emit( + JsonRpcCallToolRequest( + id: 'spoof-task-result', + params: const CallToolRequest(name: 'spoof').toJson(), + meta: _statelessRequestMeta( + capabilities: const ClientCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ), + ); + await _settle(); + + _expectSingleError( + transport.sentMessages, + id: 'spoof-task-result', + code: ErrorCode.invalidParams.value, + messageContains: 'CallToolResult cannot set MCP resultType', + ); + } finally { + await server.close(); + } +} + +Future _taskResultRequiresClientExtension() async { + final transport = _DiscoveringConformanceTransport( + capabilities: const { + 'tools': {}, + 'extensions': { + _tasksExtensionId: {}, + }, + }, + toolsListResult: const { + 'resultType': _resultTypeComplete, + 'tools': [], + 'ttlMs': 0, + 'cacheScope': _cacheScopePrivate, + }, + toolsCallResult: const { + 'resultType': 'task', + 'taskId': 'task-without-client-extension', + 'status': 'working', + 'createdAt': '2026-07-28T00:00:00Z', + 'lastUpdatedAt': '2026-07-28T00:00:00Z', + 'ttlMs': null, + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + + try { + await client.connect(transport); + try { + await client.callTool(const CallToolRequest(name: 'delayed')); + } on McpError catch (error) { + if (error.code != ErrorCode.internalError.value) { + throw StateError( + 'Expected internal error for unnegotiated task result, got ' + '${error.code}.', + ); + } + final data = error.data.toString(); + if (!data.contains('MCP resultType "task" is not valid for tools/call')) { + throw StateError( + 'Expected unnegotiated task result diagnostic, got ${error.data}.', + ); + } + return; + } + + throw StateError( + 'Expected unnegotiated task resultType to be rejected.', + ); + } finally { + await client.close(); + } +} + +Future _rejectsRemovedStatelessCoreRpcs() async { + final transport = _ConformanceTransport(); + // Raw protocol conformance needs the low-level server so removed core RPCs + // are not intercepted by high-level convenience handlers. + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + ); + await server.connect(transport); + + final removedRequests = [ + JsonRpcRequest( + id: 1, + method: Method.initialize, + params: const { + 'protocolVersion': _draftProtocolVersion2026_07_28, + 'capabilities': {}, + 'clientInfo': { + 'name': 'client', + 'version': '1.0.0', + }, + }, + meta: _statelessRequestMeta(), + ), + JsonRpcRequest( + id: 2, + method: Method.ping, + meta: _statelessRequestMeta(), + ), + JsonRpcRequest( + id: 3, + method: Method.loggingSetLevel, + params: const {'level': 'info'}, + meta: _statelessRequestMeta(), + ), + JsonRpcRequest( + id: 4, + method: Method.resourcesSubscribe, + params: const {'uri': 'file:///tmp/example.txt'}, + meta: _statelessRequestMeta(), + ), + JsonRpcRequest( + id: 5, + method: Method.resourcesUnsubscribe, + params: const {'uri': 'file:///tmp/example.txt'}, + meta: _statelessRequestMeta(), + ), + ]; + + for (final request in removedRequests) { + transport.sentMessages.clear(); + + transport.emit(request); + await _settle(); + + _expectSingleError( + transport.sentMessages, + id: request.id, + code: ErrorCode.methodNotFound.value, + messageContains: request.method, + ); + } + + await server.close(); +} + +Future _rejectsRemovedStatelessCoreNotifications() async { + final transport = _ConformanceTransport(); + final errors = []; + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + )..onerror = errors.add; + await server.connect(transport); + + final removedNotifications = [ + _notificationFromWire( + { + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + 'params': { + '_meta': _statelessRequestMeta(), + }, + }, + ), + _notificationFromWire( + { + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsRootsListChanged, + 'params': { + '_meta': _statelessRequestMeta(), + }, + }, + ), + _notificationFromWire( + { + 'jsonrpc': jsonRpcVersion, + 'method': _methodNotificationsTasksStatus, + 'params': { + '_meta': _statelessRequestMeta(), + 'taskId': 'task-1', + 'status': 'working', + 'ttl': null, + 'createdAt': '2026-07-28T00:00:00Z', + 'lastUpdatedAt': '2026-07-28T00:00:00Z', + }, + }, + ), + ]; + + for (final notification in removedNotifications) { + errors.clear(); + transport.sentMessages.clear(); + + transport.emit(notification); + await _settle(); + + _expectSingleProtocolError( + errors, + code: ErrorCode.methodNotFound.value, + messageContains: notification.method, + ); + if (transport.sentMessages.isNotEmpty) { + throw StateError( + 'Removed stateless notification ${notification.method} sent a response.', + ); + } + } + + await server.close(); +} + +Future _statelessLoggingRequiresRequestLogLevel() async { + final transport = _ConformanceTransport(); + // Raw protocol conformance needs the low-level server so request-scoped + // logging can be emitted from inside the registered handler. + // ignore: deprecated_member_use + late final Server server; + // ignore: deprecated_member_use + server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + logging: {}, + tools: ServerCapabilitiesTools(), + ), + ), + ); + server.setRequestHandler( + Method.toolsList, + (request, extra) async { + await server.sendLoggingMessage( + const LoggingMessageNotification( + level: LoggingLevel.debug, + data: 'below-threshold', + ), + requestMeta: extra.meta, + ); + await server.sendLoggingMessage( + const LoggingMessageNotification( + level: LoggingLevel.warning, + data: 'threshold-match', + ), + requestMeta: extra.meta, + ); + return const ListToolsResult(tools: []); + }, + (id, params, meta) => JsonRpcListToolsRequest( + id: id, + params: params, + meta: meta, + ), + ); + await server.connect(transport); + + transport.emit( + JsonRpcListToolsRequest( + id: 'without-log-level', + meta: _statelessRequestMeta(), + ), + ); + await _settle(); + + if (transport.sentMessages.length != 1 || + transport.sentMessages.single is! JsonRpcResponse) { + throw StateError( + 'Expected only a tools/list response without stateless logLevel, got ' + '${transport.sentMessages}.', + ); + } + + transport.sentMessages.clear(); + transport.emit( + JsonRpcListToolsRequest( + id: 'with-log-level', + meta: { + ..._statelessRequestMeta(), + McpMetaKey.logLevel: 'warning', + }, + ), + ); + await _settle(); + + final loggingNotifications = transport.sentMessages + .whereType() + .where((message) => message.method == Method.notificationsMessage) + .toList(); + if (loggingNotifications.length != 1) { + throw StateError( + 'Expected exactly one threshold-matching stateless log notification, got ' + '$loggingNotifications.', + ); + } + final loggingParams = loggingNotifications.single.params; + if (loggingParams?['level'] != LoggingLevel.warning.name || + loggingParams?['data'] != 'threshold-match') { + throw StateError( + 'Expected warning threshold log notification, got ' + '$loggingParams.', + ); + } + final responses = + transport.sentMessages.whereType().toList(); + _expectSingleErrorFreeResponse(responses, id: 'with-log-level'); + + await server.close(); +} + +Future _taskLifecycleMethodsAllowResumedClientCapability() async { + final transport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ); + await server.connect(transport); + + final requests = [ + JsonRpcRequest( + id: 'task-get', + method: _methodTasksGet, + params: const {'taskId': 'task-1'}, + meta: _statelessRequestMeta(), + ), + JsonRpcRequest( + id: 'task-update', + method: _methodTasksUpdate, + params: const { + 'taskId': 'task-1', + 'inputResponses': {}, + }, + meta: _statelessRequestMeta(), + ), + JsonRpcRequest( + id: 'task-cancel', + method: Method.tasksCancel, + params: const {'taskId': 'task-1'}, + meta: _statelessRequestMeta(), + ), + ]; + + for (final request in requests) { + transport.sentMessages.clear(); + transport.emit(request); + await _settle(); + + _expectSingleError( + transport.sentMessages, + id: request.id, + code: ErrorCode.methodNotFound.value, + messageContains: request.method, + ); + } + + await server.close(); +} + +Future _taskStoreUsesTaskExtensionResultShapes() async { + final store = InMemoryTaskStore(); + final completedTask = await store.createTask( + const TaskCreation(ttl: 60000), + 'source-request', + const { + 'method': Method.toolsCall, + 'params': {'name': 'long-running'}, + }, + null, + ); + await store.storeTaskResult( + completedTask.taskId, + TaskStatus.completed, + const CallToolResult( + content: [TextContent(text: 'task complete')], + ), + ); + final workingTask = await store.createTask( + const TaskCreation(ttl: null), + 'cancel-request', + const { + 'method': Method.toolsCall, + 'params': {'name': 'cancel-me'}, + }, + null, + ); + final transport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: _mcpServerOptionsWithTaskStore( + capabilities: const ServerCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + taskStore: store, + ), + ); + + try { + await server.connect(transport); + transport.emit( + JsonRpcRequest( + id: 'task-store-get', + method: _methodTasksGet, + params: {'taskId': completedTask.taskId}, + meta: _statelessRequestMeta( + capabilities: const ClientCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ), + ); + await _settle(); + + final getResponse = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: 'task-store-get', + ); + final getResult = getResponse.result; + if (getResult['resultType'] != _resultTypeComplete || + getResult['taskId'] != completedTask.taskId || + getResult['status'] != 'completed' || + getResult['ttlMs'] != 60000 || + getResult.containsKey('ttl') || + (getResult['result'] as Map?)?['content'] == null) { + throw StateError( + 'Expected built-in tasks/get to use the task extension result shape, ' + 'got $getResult.', + ); + } + + transport.sentMessages.clear(); + transport.emit( + JsonRpcRequest( + id: 'task-store-cancel', + method: Method.tasksCancel, + params: {'taskId': workingTask.taskId}, + meta: _statelessRequestMeta( + capabilities: const ClientCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ), + ); + await _settle(); + + final cancelResponse = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: 'task-store-cancel', + ); + if (cancelResponse.result.length != 1 || + cancelResponse.result['resultType'] != _resultTypeComplete) { + throw StateError( + 'Expected built-in tasks/cancel to acknowledge with complete result, ' + 'got ${cancelResponse.result}.', + ); + } + final cancelledTask = await store.getTask(workingTask.taskId); + if (cancelledTask?.status != TaskStatus.cancelled) { + throw StateError( + 'Expected task ${workingTask.taskId} to be cancelled, ' + 'got ${cancelledTask?.status}.', + ); + } + } finally { + await server.close(); + store.dispose(); + } +} + +McpServerOptions _mcpServerOptionsWithTaskStore({ + required ServerCapabilities capabilities, + required InMemoryTaskStore taskStore, +}) { + // Keep this dynamic so mcp_dart_cli remains analyzable against the published + // mcp_dart lower bound until this SDK branch is released. + return Function.apply( + McpServerOptions.new, + const [], + { + #capabilities: capabilities, + #taskStore: taskStore, + }, + ) as McpServerOptions; +} + +Future _subscriptionTaskIdsRequireClientCapability() async { + final transport = _ConformanceTransport(); + // Raw map parsing exercises the wire shape without depending on draft-only + // subscription request symbols in the hosted CLI package analysis. + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ); + await server.connect(transport); + + final request = JsonRpcMessage.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': 'task-subscription-capability', + 'method': _methodSubscriptionsListen, + 'params': { + '_meta': _statelessRequestMeta(), + 'notifications': { + 'taskIds': ['task-1'], + }, + }, + }, + ); + if (request is! JsonRpcRequest) { + throw StateError( + 'Expected subscriptions/listen to parse as a request, got ' + '${request.runtimeType}.', + ); + } + + transport.emit(request); + await _settle(); + + final error = _expectSingleError( + transport.sentMessages, + id: 'task-subscription-capability', + code: ErrorCode.missingRequiredClientCapability.value, + messageContains: 'Missing required client capability', + ); + _expectMissingTasksExtensionCapabilityData(error.error.data); + + await server.close(); +} + +Future _subscriptionsListenRequiresRequestMeta() async { + _expectThrowsFormatException( + () => JsonRpcMessage.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': 'missing-subscription-meta', + 'method': _methodSubscriptionsListen, + 'params': { + 'notifications': { + 'toolsListChanged': true, + }, + }, + }, + ), + ); + + final parsed = JsonRpcMessage.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': 'subscription-meta', + 'method': _methodSubscriptionsListen, + 'params': { + '_meta': _statelessRequestMeta(), + 'notifications': { + 'toolsListChanged': true, + }, + }, + }, + ); + if (parsed is! JsonRpcRequest || + parsed.meta?[_protocolVersionMetaKey] != + _draftProtocolVersion2026_07_28) { + throw StateError( + 'Expected subscriptions/listen request to preserve params._meta, got ' + '$parsed.', + ); + } +} + +Future _subscriptionsListenRequiresResourceSubscribeCapability() async { + final request = JsonRpcMessage.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': 'resource-subscription-capability', + 'method': _methodSubscriptionsListen, + 'params': { + '_meta': _statelessRequestMeta(), + 'notifications': { + 'resourceSubscriptions': ['file:///project/config.json'], + }, + }, + }, + ); + if (request is! JsonRpcRequest) { + throw StateError( + 'Expected subscriptions/listen to parse as a request, got ' + '${request.runtimeType}.', + ); + } + final notifications = request.params?['notifications']; + if (notifications is! Map) { + throw StateError( + 'Expected subscriptions/listen notifications object, got ' + '$notifications.', + ); + } + + final unacknowledged = _acknowledgeResourceSubscriptions( + notifications, + resourcesSubscribe: false, + ); + if (unacknowledged.containsKey('resourceSubscriptions')) { + throw StateError( + 'Expected resourceSubscriptions to be omitted when resources.subscribe ' + 'is not advertised, got $unacknowledged.', + ); + } + + final acknowledged = _acknowledgeResourceSubscriptions( + notifications, + resourcesSubscribe: true, + ); + final acknowledgedResources = acknowledged['resourceSubscriptions']; + if (acknowledgedResources is! List || + acknowledgedResources.single != 'file:///project/config.json') { + throw StateError( + 'Expected resourceSubscriptions to be acknowledged when ' + 'resources.subscribe is advertised, got $acknowledged.', + ); + } + + if (!_allowsResourceSubscription( + 'file:///project/config.json', + ['file:///project'], + )) { + throw StateError( + 'Expected resourceSubscriptions to allow notifications for ' + 'sub-resources of a subscribed URI.', + ); + } + if (_allowsResourceSubscription( + 'file:///project-other/config.json', + ['file:///project'], + )) { + throw StateError( + 'Expected resourceSubscriptions to reject sibling resources that only ' + 'share a string prefix.', + ); + } +} + +Future _subscriptionsAcknowledgedRejectsWrapperMismatch() async { + for (final message in const [ + { + 'jsonrpc': '1.0', + 'method': Method.notificationsSubscriptionsAcknowledged, + 'params': { + 'notifications': {}, + }, + }, + { + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsProgress, + 'params': { + 'notifications': {}, + }, + }, + ]) { + _expectThrowsFormatException( + () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson(message), + ); + } + + final parsed = JsonRpcSubscriptionsAcknowledgedNotification.fromJson( + const { + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsSubscriptionsAcknowledged, + 'params': { + 'notifications': { + 'toolsListChanged': true, + }, + }, + }, + ); + if (parsed.acknowledgedParams.notifications.toolsListChanged != true) { + throw StateError('Expected acknowledged toolsListChanged to parse.'); + } +} + +Map _acknowledgeResourceSubscriptions( + Map notifications, { + required bool resourcesSubscribe, +}) { + if (!resourcesSubscribe) { + return {}; + } + + final resourceSubscriptions = notifications['resourceSubscriptions']; + if (resourceSubscriptions is! List || + resourceSubscriptions.any((value) => value is! String)) { + return {}; + } + + return { + 'resourceSubscriptions': [ + for (final value in resourceSubscriptions) value as String, + ], + }; +} + +bool _allowsResourceSubscription(String uri, List subscribedUris) { + for (final subscribedUri in subscribedUris) { + if (uri == subscribedUri || _isSubResourceUri(uri, subscribedUri)) { + return true; + } + } + return false; +} + +bool _isSubResourceUri(String uri, String subscribedUri) { + final parsedUri = Uri.tryParse(uri); + final parsedSubscribedUri = Uri.tryParse(subscribedUri); + if (parsedUri == null || parsedSubscribedUri == null) { + return false; + } + if (parsedUri.scheme != parsedSubscribedUri.scheme || + parsedUri.authority != parsedSubscribedUri.authority) { + return false; + } + + final subscribedPath = parsedSubscribedUri.path; + final path = parsedUri.path; + if (subscribedPath.isEmpty || !path.startsWith(subscribedPath)) { + return false; + } + if (subscribedPath.endsWith('/')) { + return true; + } + return path.length > subscribedPath.length && + path.codeUnitAt(subscribedPath.length) == 0x2f; +} + +Future _rejectsUnnegotiatedSamplingTools() async { + final transport = _ConformanceTransport(); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + capabilities: ClientCapabilities( + sampling: ClientCapabilitiesSampling(), + ), + ), + ); + var handlerCalled = false; + client.onSamplingRequest = (params) async { + handlerCalled = true; + return const CreateMessageResult( + model: 'conformance-model', + role: SamplingMessageRole.assistant, + content: SamplingTextContent(text: 'unexpected'), + ); + }; + + await _initializeClient(client, transport); + transport.emit( + JsonRpcCreateMessageRequest( + id: 101, + createParams: const CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Use a tool'), + ), + ], + maxTokens: 4, + tools: [ + Tool(name: 'search', inputSchema: JsonObject()), + ], + ), + ), + ); + await _settle(); + + if (handlerCalled) { + throw StateError('sampling handler ran without sampling.tools capability.'); + } + _expectSingleError( + transport.sentMessages, + id: 101, + code: ErrorCode.methodNotFound.value, + messageContains: 'sampling.tools', + ); + await client.close(); +} + +Future _rejectsUnnegotiatedSamplingContext() async { + final transport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + ); + await server.connect(transport); + transport.emit( + _initializeRequest( + capabilities: const ClientCapabilities( + sampling: ClientCapabilitiesSampling(), + ), + ), + ); + await _settle(); + _expectSingleErrorFreeResponse(transport.sentMessages, id: 1); + transport.sentMessages.clear(); + transport.emit(const JsonRpcInitializedNotification()); + await _settle(); + transport.sentMessages.clear(); + + _expectMcpError( + () => server.createMessage( + const CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Use server context'), + ), + ], + includeContext: IncludeContext.thisServer, + maxTokens: 4, + ), + ), + code: ErrorCode.methodNotFound.value, + messageContains: 'sampling context', + ); + + final samplingRequests = transport.sentMessages + .whereType() + .where((message) => message.method == Method.samplingCreateMessage); + if (samplingRequests.isNotEmpty) { + throw StateError( + 'sampling/createMessage was sent without sampling.context capability.', + ); + } + await server.close(); +} + +Future _unadvertisedPeerMethodsUseMethodNotFound() async { + final clientTransport = _ConformanceTransport(); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + await _initializeClient(client, clientTransport); + _expectMcpError( + () => client.assertCapabilityForMethod(Method.toolsList), + code: ErrorCode.methodNotFound.value, + messageContains: 'tools', + ); + await client.close(); + + final statelessClientTransport = _DiscoveringConformanceTransport( + toolsListResult: const { + 'resultType': _resultTypeComplete, + 'tools': [], + 'ttlMs': 0, + 'cacheScope': _cacheScopePrivate, + }, + ); + final statelessClient = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + await statelessClient.connect(statelessClientTransport); + statelessClientTransport.sentMessages.clear(); + statelessClientTransport.onmessage?.call( + const JsonRpcListRootsRequest(id: 'roots-list'), + ); + await _settle(); + _expectSingleError( + statelessClientTransport.sentMessages, + id: 'roots-list', + code: ErrorCode.methodNotFound.value, + messageContains: 'roots', + ); + await statelessClient.close(); + + final serverTransport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities(), + ), + ); + await server.connect(serverTransport); + serverTransport.emit(_initializeRequest()); + await _settle(); + _expectSingleErrorFreeResponse(serverTransport.sentMessages, id: 1); + serverTransport.sentMessages.clear(); + serverTransport.emit(const JsonRpcInitializedNotification()); + await _settle(); + serverTransport.sentMessages.clear(); + _expectMcpError( + () => server.assertCapabilityForMethod(Method.rootsList), + code: ErrorCode.methodNotFound.value, + messageContains: 'roots', + ); + await server.close(); +} + +Future _taskScopedPeerMethodsUseMethodNotFound() async { + final clientTransport = _ConformanceTransport(); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + await _initializeClient( + client, + clientTransport, + serverCapabilities: const ServerCapabilities( + tools: ServerCapabilitiesTools(), + tasks: ServerCapabilitiesTasks(), + ), + ); + _expectMcpError( + () => client.assertTaskCapability(Method.toolsCall), + code: ErrorCode.methodNotFound.value, + messageContains: 'tasks.requests.tools.call', + ); + await client.close(); + + final serverTransport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + ); + await server.connect(serverTransport); + serverTransport.emit( + _initializeRequest( + capabilities: const ClientCapabilities( + sampling: ClientCapabilitiesSampling(), + tasks: ClientCapabilitiesTasks(), + ), + ), + ); + await _settle(); + _expectSingleErrorFreeResponse(serverTransport.sentMessages, id: 1); + serverTransport.sentMessages.clear(); + serverTransport.emit(const JsonRpcInitializedNotification()); + await _settle(); + _expectMcpError( + () => server.assertTaskCapability(Method.samplingCreateMessage), + code: ErrorCode.methodNotFound.value, + messageContains: 'tasks.requests.sampling.createMessage', + ); + await server.close(); +} + +Future _statelessOmitsLegacyTaskCapabilities() async { + const clientCapabilities = ClientCapabilities( + sampling: ClientCapabilitiesSampling(tools: true), + roots: ClientCapabilitiesRoots(listChanged: true), + tasks: ClientCapabilitiesTasks( + cancel: true, + list: true, + requests: ClientCapabilitiesTasksRequests( + sampling: ClientCapabilitiesTasksSampling( + createMessage: ClientCapabilitiesTasksSamplingCreateMessage(), + ), + ), + ), + extensions: >{ + _tasksExtensionId: {}, + }, + ); + if (!clientCapabilities.toJson().containsKey('tasks')) { + throw StateError('Expected stable client capabilities to include tasks.'); + } + + final statelessMeta = _statelessRequestMeta(capabilities: clientCapabilities); + final statelessCapabilities = + statelessMeta[_clientCapabilitiesMetaKey] as Map; + if (statelessCapabilities.containsKey('tasks')) { + throw StateError( + 'Expected 2026 request metadata to omit legacy tasks capability, got ' + '$statelessCapabilities.', + ); + } + final statelessRoots = statelessCapabilities['roots']; + if (statelessRoots is! Map || statelessRoots.containsKey('listChanged')) { + throw StateError( + 'Expected 2026 request metadata to omit legacy roots.listChanged ' + 'capability, got $statelessCapabilities.', + ); + } + final statelessExtensions = statelessCapabilities['extensions']; + if (statelessExtensions is! Map || + statelessExtensions[_tasksExtensionId] is! Map) { + throw StateError( + 'Expected 2026 request metadata to retain tasks extension, got ' + '$statelessCapabilities.', + ); + } + + final stableMeta = _statelessRequestMeta( + protocolVersion: latestProtocolVersion, + capabilities: clientCapabilities, + ); + final stableCapabilities = + stableMeta[_clientCapabilitiesMetaKey] as Map; + if (!stableCapabilities.containsKey('tasks')) { throw StateError( - 'Expected JsonRpcRequest, got ${parsed.runtimeType}.', + 'Expected stable request metadata to keep legacy tasks capability, got ' + '$stableCapabilities.', ); } - if (parsed.meta?[_protocolVersionMetaKey] != - _draftProtocolVersion2026_07_28) { - throw StateError('Expected server/discover metadata to be preserved.'); + final stableRoots = stableCapabilities['roots']; + if (stableRoots is! Map || stableRoots['listChanged'] != true) { + throw StateError( + 'Expected stable request metadata to keep roots.listChanged capability, ' + 'got $stableCapabilities.', + ); } -} -Future _rejectsUnnegotiatedSamplingTools() async { - final transport = _ConformanceTransport(); + final clientTransport = _DiscoveringConformanceTransport( + capabilities: const { + 'tools': {}, + 'extensions': { + _tasksExtensionId: {}, + }, + }, + toolsListResult: const { + 'resultType': _resultTypeComplete, + 'tools': [], + 'ttlMs': 0, + 'cacheScope': _cacheScopePrivate, + }, + ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: const McpClientOptions( - capabilities: ClientCapabilities( - sampling: ClientCapabilitiesSampling(), - ), + capabilities: clientCapabilities, + useServerDiscover: true, ), ); - var handlerCalled = false; - client.onSamplingRequest = (params) async { - handlerCalled = true; - return const CreateMessageResult( - model: 'conformance-model', - role: SamplingMessageRole.assistant, - content: SamplingTextContent(text: 'unexpected'), + await client.connect(clientTransport); + final discoverRequest = clientTransport.sentMessages + .whereType() + .firstWhere((message) => message.method == _serverDiscoverMethod); + final discoverClientCapabilities = + discoverRequest.meta?[_clientCapabilitiesMetaKey] as Map; + if (discoverClientCapabilities.containsKey('tasks')) { + throw StateError( + 'Expected client-generated server/discover metadata to omit legacy ' + 'tasks capability, got $discoverClientCapabilities.', ); - }; + } + await client.close(); - await _initializeClient(client, transport); - transport.emit( - JsonRpcCreateMessageRequest( - id: 101, - createParams: const CreateMessageRequest( - messages: [ - SamplingMessage( - role: SamplingMessageRole.user, - content: SamplingTextContent(text: 'Use a tool'), - ), - ], - maxTokens: 4, - tools: [ - Tool(name: 'search', inputSchema: JsonObject()), - ], + const serverCapabilities = ServerCapabilities( + tools: ServerCapabilitiesTools(), + tasks: ServerCapabilitiesTasks( + list: true, + cancel: true, + requests: ServerCapabilitiesTasksRequests( + tools: ServerCapabilitiesTasksTools( + call: ServerCapabilitiesTasksToolsCall(), + ), ), ), + extensions: >{ + _tasksExtensionId: {}, + }, ); - await _settle(); - - if (handlerCalled) { - throw StateError('sampling handler ran without sampling.tools capability.'); + if (!serverCapabilities.toJson().containsKey('tasks')) { + throw StateError('Expected stable server capabilities to include tasks.'); } - _expectSingleError( - transport.sentMessages, - id: 101, - code: ErrorCode.invalidRequest.value, - messageContains: 'sampling.tools', + + final serverTransport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions(capabilities: serverCapabilities), ); - await client.close(); + await server.connect(serverTransport); + serverTransport.emit( + JsonRpcServerDiscoverRequest( + id: 'discover-capabilities', + meta: _statelessRequestMeta(), + ), + ); + await _settle(); + final response = _expectSingleErrorFreeResponse( + serverTransport.sentMessages, + id: 'discover-capabilities', + ); + final discoveredCapabilities = + response.result['capabilities'] as Map; + if (discoveredCapabilities.containsKey('tasks')) { + throw StateError( + 'Expected server/discover result to omit legacy tasks capability, got ' + '$discoveredCapabilities.', + ); + } + final discoveredExtensions = discoveredCapabilities['extensions']; + if (discoveredExtensions is! Map || + discoveredExtensions[_tasksExtensionId] is! Map) { + throw StateError( + 'Expected server/discover result to retain tasks extension, got ' + '$discoveredCapabilities.', + ); + } + await server.close(); } Future _rejectsInvalidElicitationVariantPayload() async { @@ -705,6 +5027,60 @@ Future _rejectsInvalidElicitationVariantPayload() async { ); } +Future _acceptsNumericElicitationNumberSchemaKeywords() async { + final parsed = JsonRpcMessage.fromJson(const { + 'jsonrpc': jsonRpcVersion, + 'id': 104, + 'method': Method.elicitationCreate, + 'params': { + 'mode': 'form', + 'message': 'Configure ratio', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'ratio': { + 'type': 'number', + 'minimum': 0.1, + 'maximum': 0.9, + 'default': 0.5, + }, + }, + }, + '_meta': { + _protocolVersionMetaKey: _draftProtocolVersion2026_07_28, + }, + }, + }); + if (parsed is! JsonRpcElicitRequest) { + throw StateError( + 'Expected JsonRpcElicitRequest, got ${parsed.runtimeType}.'); + } + + _expectThrowsFormatException( + () => JsonRpcMessage.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 104, + 'method': Method.elicitationCreate, + 'params': { + 'mode': 'form', + 'message': 'Configure ratio', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'ratio': { + 'type': 'number', + 'maximum': double.infinity, + }, + }, + }, + '_meta': { + _protocolVersionMetaKey: _draftProtocolVersion2026_07_28, + }, + }, + }), + ); +} + Future _stripsUnnegotiatedRelatedTaskMetadata() async { final transport = _ConformanceTransport(); final server = McpServer( @@ -873,6 +5249,50 @@ Future _rejectsResultErrorJsonRpcResponse() async { ); } +Future _rejectsMethodResponseJsonRpcEnvelope() async { + final messages = >[ + { + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': 'unknown/request', + 'result': {'ok': true}, + }, + { + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': 'unknown/request', + 'error': { + 'code': ErrorCode.invalidRequest.value, + 'message': 'Invalid request', + }, + }, + ]; + + for (final message in messages) { + _expectThrowsFormatException(() => JsonRpcMessage.fromJson(message)); + } + _expectThrowsFormatException( + () => JsonRpcError.fromJson(messages.last), + ); + _expectThrowsFormatException( + () => JsonRpcPingRequest.fromJson(messages.first), + ); + _expectThrowsFormatException( + () => JsonRpcProgressNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsProgress, + 'params': { + 'progressToken': 'progress-1', + 'progress': 1, + }, + 'error': { + 'code': ErrorCode.invalidRequest.value, + 'message': 'Invalid request', + }, + }), + ); +} + Future _rejectsMalformedJsonRpcErrorObject() async { _expectThrowsFormatException( () => JsonRpcMessage.fromJson(const { @@ -899,6 +5319,26 @@ Future _rejectsNullJsonRpcErrorResponseId() async { ); } +Future _acceptsOmittedJsonRpcErrorResponseId() async { + final message = JsonRpcMessage.fromJson(const { + 'jsonrpc': jsonRpcVersion, + 'error': { + 'code': -32600, + 'message': 'Invalid request', + }, + }); + + if (message is! JsonRpcError) { + throw StateError('Expected JsonRpcError, got ${message.runtimeType}.'); + } + if (message.id != null) { + throw StateError('Expected omitted error response ID to stay absent.'); + } + if (message.toJson().containsKey('id')) { + throw StateError('Expected serialized error response to omit id.'); + } +} + Future _rejectsNullJsonRpcParamsMember() async { for (final message in const [ { @@ -917,6 +5357,17 @@ Future _rejectsNullJsonRpcParamsMember() async { } } +Future _requiresCallToolRequestParams() async { + const message = { + 'jsonrpc': jsonRpcVersion, + 'id': 'call-1', + 'method': Method.toolsCall, + }; + + _expectThrowsFormatException(() => JsonRpcCallToolRequest.fromJson(message)); + _expectThrowsFormatException(() => JsonRpcMessage.fromJson(message)); +} + Future _rejectsFractionalIdsAndProgressTokens() async { for (final message in const [ { @@ -1039,6 +5490,104 @@ JsonRpcError _expectSingleError( return message; } +void _expectMissingTasksExtensionCapabilityData(Object? data) { + if (data is! Map) { + throw StateError('Expected missing-capability error data, got $data.'); + } + final requiredCapabilities = data['requiredCapabilities']; + if (requiredCapabilities is! Map) { + throw StateError( + 'Expected requiredCapabilities in missing-capability data, got $data.', + ); + } + final extensions = requiredCapabilities['extensions']; + if (extensions is! Map || extensions[_tasksExtensionId] is! Map) { + throw StateError( + 'Expected requiredCapabilities.extensions.$_tasksExtensionId, got $data.', + ); + } +} + +void _expectSingleProtocolError( + List errors, { + required int code, + required String messageContains, +}) { + if (errors.length != 1) { + throw StateError('Expected one protocol error, got ${errors.length}.'); + } + final error = errors.single; + if (error is! McpError) { + throw StateError('Expected McpError, got ${error.runtimeType}.'); + } + if (error.code != code) { + throw StateError('Expected error code $code, got ${error.code}.'); + } + if (!error.message.contains(messageContains)) { + throw StateError( + "Expected error message to contain '$messageContains', got " + "'${error.message}'.", + ); + } +} + +void _expectMcpError( + void Function() callback, { + required int code, + required String messageContains, +}) { + try { + callback(); + } on McpError catch (error) { + if (error.code != code) { + throw StateError('Expected error code $code, got ${error.code}.'); + } + if (!error.message.contains(messageContains)) { + throw StateError( + "Expected error message to contain '$messageContains', got " + "'${error.message}'.", + ); + } + return; + } + + throw StateError('Expected McpError.'); +} + +void _expectUnsupportedProtocolVersionData( + JsonRpcError error, { + required String requested, +}) { + final data = error.error.data; + if (data is! Map) { + throw StateError('Expected unsupported version error data, got $data.'); + } + final supported = data['supported']; + if (supported is! List || + !supported.contains(_draftProtocolVersion2026_07_28) || + !supported.contains('2025-11-25')) { + throw StateError( + 'Expected supported protocol versions in error data, got $supported.', + ); + } + if (data['requested'] != requested) { + throw StateError( + 'Expected requested protocol version $requested, got ' + "${data['requested']}.", + ); + } +} + +JsonRpcNotification _notificationFromWire(Map json) { + final message = JsonRpcMessage.fromJson(json); + if (message is! JsonRpcNotification) { + throw StateError( + 'Expected JsonRpcNotification, got ${message.runtimeType}.', + ); + } + return message; +} + Future _preservesStringProgressToken() async { final message = JsonRpcMessage.fromJson(const { 'jsonrpc': jsonRpcVersion, @@ -1101,6 +5650,47 @@ Future _advertisesLatestProtocolVersion() async { } } +Future _advertisesDraftProtocolVersion() async { + final transport = _ConformanceTransport(); + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities(), + ), + ); + + await server.connect(transport); + transport.emit( + JsonRpcRequest( + id: 'draft-version', + method: _serverDiscoverMethod, + meta: _statelessRequestMeta(), + ), + ); + await _settle(); + + final response = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: 'draft-version', + ); + final supportedVersions = response.result['supportedVersions']; + if (supportedVersions is! List) { + throw StateError('Expected server/discover supportedVersions list.'); + } + if (supportedVersions.firstOrNull != _draftProtocolVersion2026_07_28) { + throw StateError( + 'Expected $_draftProtocolVersion2026_07_28 to be advertised first.', + ); + } + if (!supportedVersions.contains(_draftProtocolVersion2026_07_28)) { + throw StateError( + 'Expected server/discover to advertise $_draftProtocolVersion2026_07_28.', + ); + } + + await server.close(); +} + void _expectThrowsFormatException(void Function() callback) { try { callback(); @@ -1156,6 +5746,33 @@ void _expectProgressTokenRoundTrip(Map message) { } } +bool _mapsDeepEqual(Object? a, Object? b) { + if (a is Map && b is Map) { + if (a.length != b.length) { + return false; + } + for (final entry in a.entries) { + if (!b.containsKey(entry.key) || + !_mapsDeepEqual(entry.value, b[entry.key])) { + return false; + } + } + return true; + } + if (a is List && b is List) { + if (a.length != b.length) { + return false; + } + for (var i = 0; i < a.length; i++) { + if (!_mapsDeepEqual(a[i], b[i])) { + return false; + } + } + return true; + } + return a == b; +} + void _expectIdRoundTrip( RequestId actualId, Object expectedId, Map json) { if (actualId != expectedId) { diff --git a/packages/mcp_dart_cli/test/fixtures/raw_stdio_server.dart b/packages/mcp_dart_cli/test/fixtures/raw_stdio_server.dart index 85b0aba6..04a10439 100644 --- a/packages/mcp_dart_cli/test/fixtures/raw_stdio_server.dart +++ b/packages/mcp_dart_cli/test/fixtures/raw_stdio_server.dart @@ -72,7 +72,11 @@ Future main(List args) async { break; default: if (id != null) { - await _writeResponse(id, const {}); + await _writeErrorResponse( + id, + -32601, + 'Method not found', + ); } } } @@ -87,6 +91,22 @@ Future _writeResponse(Object? id, Map result) async { await stdout.flush(); } +Future _writeErrorResponse( + Object? id, + int code, + String message, +) async { + stdout.writeln(jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'error': { + 'code': code, + 'message': message, + }, + })); + await stdout.flush(); +} + Future _writeLoggingNotification() async { stdout.writeln(jsonEncode({ 'jsonrpc': '2.0', diff --git a/packages/mcp_dart_cli/test/src/conformance_command_test.dart b/packages/mcp_dart_cli/test/src/conformance_command_test.dart index 71eb2708..4faf8729 100644 --- a/packages/mcp_dart_cli/test/src/conformance_command_test.dart +++ b/packages/mcp_dart_cli/test/src/conformance_command_test.dart @@ -22,20 +22,25 @@ void main() { 'jsonrpc.rejects-malformed-message', 'jsonrpc.rejects-non-string-method', 'jsonrpc.rejects-result-error-response', + 'jsonrpc.rejects-method-response-envelope', 'jsonrpc.rejects-malformed-error-object', 'jsonrpc.rejects-null-error-response-id', + 'jsonrpc.accepts-omitted-error-response-id', 'jsonrpc.rejects-null-params-member', + 'tools-call.requires-params', 'jsonrpc.preserves-string-response-id', 'jsonrpc.preserves-integer-response-id', 'jsonrpc.preserves-string-progress-token', 'jsonrpc.preserves-integer-progress-token', 'jsonrpc.rejects-fractional-ids-and-progress-tokens', 'protocol-version.advertises-latest-2025-11-25', + 'protocol-version.advertises-draft-2026-07-28', ]), ); }); - test('spec suite covers MCP 2025-11-25 high-risk wire cases', () async { + test('spec suite covers high-risk wire cases across spec versions', + () async { final result = await ConformanceRunner().runSpecSuite(); expect(result.passed, isTrue); @@ -44,9 +49,55 @@ void main() { result.caseNames, containsAll([ 'lifecycle.rejects-pre-initialize-request', + 'lifecycle.gates-until-initialized-notification', + 'lifecycle.does-not-cancel-initialize', + 'cancellation.requires-request-id', 'server-discover.requires-request-meta', + 'server-discover.returns-draft-capabilities', + 'protocol-version.rejects-unsupported-stateless-version', + 'stateless.requires-complete-request-meta', + 'protocol-version.http-modern-400-retries-discovery', + 'capabilities.http-modern-400-does-not-fallback', + 'protocol-version.initialize-negotiates-stateful-version', + 'capabilities.stateless-does-not-infer-initialize-extensions', + 'stateless-http.rejects-mismatched-routing-headers', + 'stateless-http.requires-routing-headers', + 'stateless-http.rejects-non-post-methods', + 'stateless-http.rejects-batch-payloads', + 'stateless-http.task-requests-require-name-header', + 'stateless-http.validates-parameter-headers', + 'stateless-http.omits-invalid-numeric-parameter-headers', + 'stateless-http.encodes-parameter-header-values', + 'stateless-http.accepts-response-posts', + 'stateless-http.task-subscription-requires-client-capability', + 'stateless-http.omits-session-header-after-initialize', + 'stateless.related-task-uses-explicit-id-across-transports', + 'stateless.ignores-legacy-task-parameter', + 'stateless.adds-result-type-and-cache-defaults', + 'tools-list.stateless-returns-deterministic-order', + 'resources.missing-resource-error-code-by-version', + 'stateless.rejects-unrecognized-result-type', + 'mrtr.input-required-supported-requests', + 'mrtr.rejects-unsupported-input-required-results', + 'mrtr.input-requests-require-client-capabilities', + 'stateless.rejects-removed-core-rpcs', + 'stateless.rejects-removed-core-notifications', + 'logging.stateless-requires-request-log-level', + 'tasks-extension.lifecycle-methods-do-not-require-repeated-capability', + 'tasks-extension.task-store-uses-extension-result-shapes', + 'tasks-extension.call-tool-result-cannot-spoof-task-result', + 'tasks-extension.task-result-requires-client-extension', + 'subscriptions-listen.task-ids-require-client-capability', + 'subscriptions-listen.requires-request-meta', + 'subscriptions-listen.resource-subscriptions-require-capability', + 'subscriptions-acknowledged.rejects-wrapper-mismatch', 'capabilities.rejects-unnegotiated-sampling-tools', + 'capabilities.rejects-unnegotiated-sampling-context', + 'capabilities.unadvertised-peer-methods-use-method-not-found', + 'capabilities.task-scoped-peer-methods-use-method-not-found', + 'capabilities.stateless-omits-legacy-task-capabilities', 'elicitation.rejects-invalid-form-url-union', + 'elicitation.accepts-numeric-number-schema-keywords', 'tasks.strips-unnegotiated-related-task-metadata', 'progress.rejects-malformed-progress-token', 'progress.dispatches-integer-progress-token', diff --git a/test/client/client_test.dart b/test/client/client_test.dart index abbce2be..78c0ac29 100644 --- a/test/client/client_test.dart +++ b/test/client/client_test.dart @@ -187,11 +187,23 @@ void main() { // Should throw for unsupported capabilities expect( () => limitedClient.assertCapabilityForMethod("logging/setLevel"), - throwsA(isA()), + throwsA( + isA().having( + (e) => e.code, + 'code', + ErrorCode.methodNotFound.value, + ), + ), ); expect( () => limitedClient.assertCapabilityForMethod("prompts/list"), - throwsA(isA()), + throwsA( + isA().having( + (e) => e.code, + 'code', + ErrorCode.methodNotFound.value, + ), + ), ); expect( () => limitedClient.assertCapabilityForMethod( @@ -933,6 +945,7 @@ void _addCriticalPathTests() { () => client.assertCapabilityForMethod('resources/read'), throwsA( isA() + .having((e) => e.code, 'code', ErrorCode.methodNotFound.value) .having((e) => e.message, 'message', contains('resources')), ), ); @@ -1190,7 +1203,7 @@ void _addCriticalPathTests() { expect(transport.sentMessages.single, isA()); final error = transport.sentMessages.single as JsonRpcError; expect(error.id, 'sample-1'); - expect(error.error.code, ErrorCode.invalidRequest.value); + expect(error.error.code, ErrorCode.methodNotFound.value); expect(error.error.message, contains('sampling.tools')); }); @@ -1244,7 +1257,7 @@ void _addCriticalPathTests() { expect(transport.sentMessages.single, isA()); final error = transport.sentMessages.single as JsonRpcError; expect(error.id, 7); - expect(error.error.code, ErrorCode.invalidRequest.value); + expect(error.error.code, ErrorCode.methodNotFound.value); expect(error.error.message, contains('sampling.tools')); }); diff --git a/test/client/client_tool_validation_test.dart b/test/client/client_tool_validation_test.dart index fc77c58b..e377c424 100644 --- a/test/client/client_tool_validation_test.dart +++ b/test/client/client_tool_validation_test.dart @@ -260,7 +260,9 @@ void main() { await client.connect(transport); final result = await client.listTools(); - expect(result.tools.map((tool) => tool.name), ['valid_headers']); + expect(result.tools.map((tool) => tool.name), [ + 'valid_headers', + ]); expect(transport.toolParameterHeaderMappings, { 'valid_headers': { 'region': 'Region', @@ -365,11 +367,17 @@ void main() { expect( () => client.assertTaskCapability(Method.toolsCall), throwsA( - isA().having( - (e) => e.message, - 'message', - contains('tasks.requests.tools.call'), - ), + isA() + .having( + (e) => e.code, + 'code', + ErrorCode.methodNotFound.value, + ) + .having( + (e) => e.message, + 'message', + contains('tasks.requests.tools.call'), + ), ), ); }); @@ -386,11 +394,17 @@ void main() { expect( () => client.assertTaskCapability(Method.completionComplete), throwsA( - isA().having( - (e) => e.message, - 'message', - contains('tasks.requests.completion/complete'), - ), + isA() + .having( + (e) => e.code, + 'code', + ErrorCode.methodNotFound.value, + ) + .having( + (e) => e.message, + 'message', + contains('tasks.requests.completion/complete'), + ), ), ); }); diff --git a/test/client/streamable_https_test.dart b/test/client/streamable_https_test.dart index 835e42a2..133e7399 100644 --- a/test/client/streamable_https_test.dart +++ b/test/client/streamable_https_test.dart @@ -1265,7 +1265,7 @@ void main() { }); }); - test('send adds task id as 2026 stateless task name header', () async { + test('send adds 2026 stateless task name header', () async { final capturedHeaders = {}; final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); addTearDown(() => server.close(force: true)); @@ -1495,7 +1495,7 @@ void main() { '=?base64?${base64Encode(utf8.encode('Hello, 世界'))}?=', ); expect(capturedHeaders['limit'], '42'); - expect(capturedHeaders['rounded'], '42'); + expect(capturedHeaders['rounded'], isNull); expect(capturedHeaders['unsafe'], isNull); expect(capturedHeaders['ratio'], isNull); expect(capturedHeaders['dryRun'], 'false'); diff --git a/test/docs/markdown_docs_test.dart b/test/docs/markdown_docs_test.dart index 5fef4523..33d8590f 100644 --- a/test/docs/markdown_docs_test.dart +++ b/test/docs/markdown_docs_test.dart @@ -79,7 +79,7 @@ final _markdownLinkPattern = RegExp( ); final _dartRunFilePattern = RegExp( - r'dart run (?(?:example|packages|test|bin)/[^\s`]+\.dart)', + r'dart run (?(?:example|packages|test|bin|tool)/[^\s`]+\.dart)', ); Iterable _markdownFiles(String repoRoot) sync* { diff --git a/test/elicitation_test.dart b/test/elicitation_test.dart index fa1c5cb0..7595c5cc 100644 --- a/test/elicitation_test.dart +++ b/test/elicitation_test.dart @@ -890,7 +890,7 @@ void main() { expect(request.toJson()['requestedSchema'], isA>()); }); - test('Form elicitation defaults to stable number schema keywords', () { + test('Form elicitation accepts numeric number schema keywords', () { Map requestWithProperty( String name, Map property, @@ -929,33 +929,44 @@ void main() { 'maximum': 10.5, }, }.entries) { + final params = ElicitRequestParams.fromJson( + requestWithProperty(property.key, property.value), + ); expect( - () => ElicitRequestParams.fromJson( - requestWithProperty(property.key, property.value), - ), - throwsA(isA()), + params.requestedSchema!.toJson()['properties'][property.key], + containsPair(property.value.keys.last, property.value.values.last), ); } + final serialized = ElicitRequestParams.form( + message: 'Configure deployment', + requestedSchema: JsonSchema.object( + properties: { + 'ratio': JsonSchema.number( + minimum: 0.1, + maximum: 0.9, + defaultValue: 0.5, + ), + }, + ), + ).toJson(); + final ratioSchema = serialized['requestedSchema']['properties']['ratio']; + expect(ratioSchema['minimum'], 0.1); + expect(ratioSchema['maximum'], 0.9); + expect(ratioSchema['default'], 0.5); + expect( - () => ElicitRequestParams.form( - message: 'Configure deployment', - requestedSchema: JsonSchema.object( - properties: { - 'ratio': JsonSchema.number( - minimum: 0.1, - maximum: 0.9, - defaultValue: 0.5, - ), - }, - ), - ).toJson(), + () => ElicitRequestParams.fromJson( + requestWithProperty('notFinite', { + 'type': 'number', + 'default': double.nan, + }), + ), throwsA(isA()), ); }); - test('Draft form elicitation accepts fractional number schema keywords', - () { + test('Draft form elicitation accepts numeric number schema keywords', () { final params = { 'mode': 'form', 'message': 'Configure deployment', @@ -978,49 +989,77 @@ void main() { }, }; - final request = ElicitRequestParams.fromJson( + final parsed = ElicitRequestParams.fromJson( params, protocolVersion: draftProtocolVersion2026_07_28, ); - final draftJson = request.toJson( + final parsedJson = parsed.toJson( protocolVersion: draftProtocolVersion2026_07_28, ); - final schema = draftJson['requestedSchema'] as Map; - final properties = schema['properties'] as Map; - expect( - (properties['ratio'] as Map)['default'], - 0.5, - ); - expect( - (properties['count'] as Map)['default'], - 1.5, + parsedJson['requestedSchema']['properties']['ratio']['minimum'], + 0.1, ); expect( - (properties['count'] as Map)['maximum'], + parsedJson['requestedSchema']['properties']['count']['maximum'], 10.5, ); + + final serialized = ElicitRequestParams.form( + message: 'Configure deployment', + requestedSchema: JsonSchema.object( + properties: { + 'ratio': JsonSchema.number( + minimum: 0.1, + maximum: 0.9, + defaultValue: 0.5, + ), + }, + ), + ).toJson(protocolVersion: draftProtocolVersion2026_07_28); expect( - () => request.toJson(), - throwsA(isA()), + serialized['requestedSchema']['properties']['ratio']['default'], + 0.5, ); - final rpc = JsonRpcElicitRequest.fromJson({ + final request = JsonRpcElicitRequest.fromJson({ 'jsonrpc': jsonRpcVersion, 'id': 1, 'method': Method.elicitationCreate, 'params': { ...params, - '_meta': { - McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, - }, + '_meta': {McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28}, }, }); - final rpcSchema = rpc.params!['requestedSchema'] as Map; - final rpcProperties = rpcSchema['properties'] as Map; expect( - (rpcProperties['ratio'] as Map)['minimum'], - 0.1, + request.toJson()['params']['requestedSchema']['properties']['count'] + ['minimum'], + 0.5, + ); + + expect( + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': { + 'mode': 'form', + 'message': 'Configure deployment', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'ratio': { + 'type': 'number', + 'maximum': double.infinity, + }, + }, + }, + '_meta': { + McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, + }, + }, + }), + throwsA(isA()), ); }); @@ -1104,7 +1143,7 @@ void main() { }, 'badIntegerDefault': { 'type': 'integer', - 'default': 1.5, + 'default': double.nan, }, 'badBooleanDefault': { 'type': 'boolean', diff --git a/test/interop/ts_client_with_dart_server_test.dart b/test/interop/ts_client_with_dart_server_test.dart index dd33b7ba..2f14c996 100644 --- a/test/interop/ts_client_with_dart_server_test.dart +++ b/test/interop/ts_client_with_dart_server_test.dart @@ -361,15 +361,14 @@ void main() { test( 'official TS Streamable HTTP client lists tools immediately after lifecycle', () async { - final port = await _findAvailablePort(); - final baseUrl = 'http://127.0.0.1:$port/mcp'; final streamableServer = StreamableMcpServer( serverFactory: (_) => createServer(), host: '127.0.0.1', - port: port, + port: 0, ); await streamableServer.start(); + final baseUrl = 'http://127.0.0.1:${streamableServer.boundPort}/mcp'; try { final result = await Process.run( 'node', @@ -644,15 +643,14 @@ void main() { test( 'Dart Streamable HTTP server rejects operations before initialized', () async { - final port = await _findAvailablePort(); - final baseUrl = 'http://127.0.0.1:$port/mcp'; final streamableServer = StreamableMcpServer( serverFactory: (_) => createServer(), host: '127.0.0.1', - port: port, + port: 0, ); await streamableServer.start(); + final baseUrl = 'http://127.0.0.1:${streamableServer.boundPort}/mcp'; try { final initRes = await http.post( Uri.parse(baseUrl), @@ -739,8 +737,6 @@ void main() { test( 'official TS client resumes Dart server SSE replay by Last-Event-ID', () async { - final port = await _findAvailablePort(); - final baseUrl = 'http://127.0.0.1:$port/mcp'; final servers = {}; final streamableServer = StreamableMcpServer( @@ -752,7 +748,7 @@ void main() { return mcpServer; }, host: '127.0.0.1', - port: port, + port: 0, eventStore: InMemoryEventStore(), ); @@ -769,6 +765,7 @@ void main() { } await streamableServer.start(); + final baseUrl = 'http://127.0.0.1:${streamableServer.boundPort}/mcp'; try { final initRes = await http.post( Uri.parse(baseUrl), diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 7e35bdaf..f59dbcad 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1673,16 +1673,30 @@ void main() { ); expect(request.toJson()['requestedSchema']['type'], 'object'); + final fractionalBounds = ElicitRequest.form( + message: 'Fractional bounds', + requestedSchema: JsonSchema.object( + properties: { + 'ratio': JsonSchema.number( + minimum: 0.1, + maximum: 0.9, + defaultValue: 0.5, + ), + }, + ), + ).toJson(); + final ratioSchema = + fractionalBounds['requestedSchema']['properties']['ratio']; + expect(ratioSchema['minimum'], 0.1); + expect(ratioSchema['maximum'], 0.9); + expect(ratioSchema['default'], 0.5); + expect( () => ElicitRequest.form( - message: 'Fractional bounds', + message: 'Non-finite bound', requestedSchema: JsonSchema.object( properties: { - 'ratio': JsonSchema.number( - minimum: 0.1, - maximum: 0.9, - defaultValue: 0.5, - ), + 'ratio': JsonSchema.number(maximum: double.infinity), }, ), ).toJson(), @@ -2473,6 +2487,35 @@ void main() { throwsA(isA()), ); }); + + test('strict incoming tools/call requests require params', () { + final json = { + 'jsonrpc': '2.0', + 'id': 'call-1', + 'method': Method.toolsCall, + }; + + expect( + () => JsonRpcCallToolRequest.fromJson(json), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('params'), + ), + ), + ); + expect( + () => JsonRpcMessage.fromJson(json), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('params'), + ), + ), + ); + }); }); }); } diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 26ce510a..cf85fe73 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -3,19 +3,23 @@ import 'dart:async'; import 'package:mcp_dart/src/client/client.dart'; import 'package:mcp_dart/src/server/mcp_server.dart'; import 'package:mcp_dart/src/server/server.dart'; -import 'package:mcp_dart/src/server/tasks/handler.dart'; +import 'package:mcp_dart/src/server/tasks.dart'; import 'package:mcp_dart/src/shared/protocol.dart'; import 'package:mcp_dart/src/shared/transport.dart'; import 'package:mcp_dart/src/types.dart'; import 'package:test/test.dart'; class RecordingTransport extends Transport { + RecordingTransport({this.sessionIdValue}); + final List sentMessages = []; + final List sentRelatedRequestIds = []; + final String? sessionIdValue; bool started = false; bool closed = false; @override - String? get sessionId => null; + String? get sessionId => sessionIdValue; @override Future close() async { @@ -26,6 +30,7 @@ class RecordingTransport extends Transport { @override Future send(JsonRpcMessage message, {int? relatedRequestId}) async { sentMessages.add(message); + sentRelatedRequestIds.add(relatedRequestId); } @override @@ -38,6 +43,33 @@ class RecordingTransport extends Transport { } } +class SessionRecordingTaskStore extends InMemoryTaskStore { + final List createTaskSessionIds = []; + final List updateTaskStatusSessionIds = []; + + @override + Future createTask( + TaskCreation taskParams, + RequestId requestId, + Map requestData, + String? sessionId, + ) { + createTaskSessionIds.add(sessionId); + return super.createTask(taskParams, requestId, requestData, sessionId); + } + + @override + Future updateTaskStatus( + String taskId, + TaskStatus status, [ + String? statusMessage, + String? sessionId, + ]) { + updateTaskStatusSessionIds.add(sessionId); + return super.updateTaskStatus(taskId, status, statusMessage, sessionId); + } +} + class DiscoveringClientTransport extends Transport implements ProtocolVersionAwareTransport { DiscoveringClientTransport({ @@ -208,20 +240,24 @@ class LegacyFallbackTransport extends Transport } class CompletedTaskHandler extends CancelTaskResultHandler { + RequestHandlerExtra? lastCreateTaskExtra; + @override Future createTask( Map? args, RequestHandlerExtra? extra, - ) async => - const CreateTaskResult( - task: Task( - taskId: 'task-1', - status: TaskStatus.completed, - ttl: null, - createdAt: '2026-07-28T00:00:00Z', - lastUpdatedAt: '2026-07-28T00:01:00Z', - ), - ); + ) async { + lastCreateTaskExtra = extra; + return const CreateTaskResult( + task: Task( + taskId: 'task-1', + status: TaskStatus.completed, + ttl: null, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:01:00Z', + ), + ); + } @override Future getTask(String taskId, RequestHandlerExtra? extra) async => Task( @@ -266,6 +302,28 @@ Map _clientMeta({ Future _pump() => Future.delayed(Duration.zero); +void _registerTaskGetExtensionHandler(Server server) { + server.setRequestHandler( + Method.tasksGet, + (request, extra) async => GetTaskExtensionResult( + task: TaskExtensionTask( + taskId: request.getParams.taskId, + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:00Z', + ttlMs: null, + ), + ), + (id, params, meta) => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.tasksGet, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); +} + void main() { group('MCP 2026-07-28 RC protocol foundation', () { test('defines draft protocol version separately from stable default', () { @@ -400,7 +458,7 @@ void main() { ); }); - test('allows fractional elicitation number schema keywords', () { + test('accepts fractional elicitation number schema keywords', () { final request = ElicitRequestParams.form( message: 'Configure ratio', requestedSchema: JsonSchema.object( @@ -410,31 +468,69 @@ void main() { maximum: 0.9, defaultValue: 0.5, ), - 'count': JsonSchema.integer( - minimum: 0.5, - maximum: 10.5, - defaultValue: 1.5, - ), }, ), ); - final json = request.toJson( + final requestJson = request.toJson( protocolVersion: draftProtocolVersion2026_07_28, ); - final schema = json['requestedSchema'] as Map; - final properties = schema['properties'] as Map; - expect((properties['ratio'] as Map)['minimum'], 0.1); - expect((properties['count'] as Map)['default'], 1.5); - expect((properties['count'] as Map)['maximum'], 10.5); + final ratioSchema = requestJson['requestedSchema']['properties']['ratio']; + expect(ratioSchema['minimum'], 0.1); + expect(ratioSchema['maximum'], 0.9); + expect(ratioSchema['default'], 0.5); + + final inputRequestJson = InputRequest.elicit(request).toJson(); + final inputRatioSchema = + inputRequestJson['params']['requestedSchema']['properties']['ratio']; + expect(inputRatioSchema['minimum'], 0.1); + + final parsed = JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': { + 'message': 'Configure ratio', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'count': { + 'type': 'integer', + 'maximum': 10.5, + }, + }, + }, + '_meta': { + McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, + }, + }, + }); + final countSchema = + parsed.elicitParams.requestedSchema!.toJson()['properties']['count']; + expect(countSchema['maximum'], 10.5); - final inputRequest = InputRequest.elicit(request); - final inputSchema = - inputRequest.params!['requestedSchema'] as Map; - final inputProperties = inputSchema['properties'] as Map; expect( - (inputProperties['ratio'] as Map)['default'], - 0.5, + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': { + 'message': 'Configure ratio', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'count': { + 'type': 'integer', + 'maximum': double.infinity, + }, + }, + }, + '_meta': { + McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, + }, + }, + }), + throwsA(isA()), ); }); @@ -937,6 +1033,75 @@ void main() { ); }); + test('stateless metadata omits legacy task capabilities', () { + const clientCapabilities = ClientCapabilities( + sampling: ClientCapabilitiesSampling(tools: true), + roots: ClientCapabilitiesRoots(listChanged: true), + tasks: ClientCapabilitiesTasks( + cancel: true, + list: true, + requests: ClientCapabilitiesTasksRequests( + sampling: ClientCapabilitiesTasksSampling( + createMessage: ClientCapabilitiesTasksSamplingCreateMessage(), + ), + ), + ), + extensions: {mcpTasksExtensionId: {}}, + ); + expect(clientCapabilities.toJson(), contains('tasks')); + + final draftMeta = buildProtocolRequestMeta( + protocolVersion: draftProtocolVersion2026_07_28, + clientInfo: const Implementation(name: 'client', version: '1.0.0'), + clientCapabilities: clientCapabilities, + ); + final draftCapabilities = + draftMeta[McpMetaKey.clientCapabilities] as Map; + expect(draftCapabilities, isNot(contains('tasks'))); + expect(draftCapabilities['roots'], isNot(contains('listChanged'))); + expect( + (draftCapabilities['extensions'] as Map)[mcpTasksExtensionId], + isEmpty, + ); + + final stableMeta = buildProtocolRequestMeta( + protocolVersion: stableProtocolVersion2025_11_25, + clientInfo: const Implementation(name: 'client', version: '1.0.0'), + clientCapabilities: clientCapabilities, + ); + final stableCapabilities = + stableMeta[McpMetaKey.clientCapabilities] as Map; + expect(stableCapabilities, contains('tasks')); + expect(stableCapabilities['roots'], contains('listChanged')); + }); + + test('server/discover result omits legacy task capabilities', () { + const serverCapabilities = ServerCapabilities( + tools: ServerCapabilitiesTools(), + tasks: ServerCapabilitiesTasks( + list: true, + cancel: true, + requests: ServerCapabilitiesTasksRequests( + tools: ServerCapabilitiesTasksTools( + call: ServerCapabilitiesTasksToolsCall(), + ), + ), + ), + extensions: {mcpTasksExtensionId: {}}, + ); + expect(serverCapabilities.toJson(), contains('tasks')); + + final json = const DiscoverResult( + supportedVersions: [draftProtocolVersion2026_07_28], + capabilities: serverCapabilities, + serverInfo: Implementation(name: 'server', version: '1.0.0'), + ).toJson(); + final capabilities = json['capabilities'] as Map; + expect(capabilities, isNot(contains('tasks'))); + expect(capabilities, contains('tools')); + expect((capabilities['extensions'] as Map)[mcpTasksExtensionId], isEmpty); + }); + test('server/discover and capability fields reject malformed wire shapes', () { final result = { @@ -1341,6 +1506,13 @@ void main() { ], task: TaskCreation(ttl: 1000), maxTokens: 100, + tools: [ + Tool( + name: 'lookup', + inputSchema: JsonObject(), + execution: ToolExecution(taskSupport: 'optional'), + ), + ], ), ), 'roots': InputRequest.listRoots(), @@ -1369,6 +1541,10 @@ void main() { json['inputRequests']['capital_of_france']['params'], isNot(contains('task')), ); + expect( + json['inputRequests']['capital_of_france']['params']['tools'][0], + isNot(contains('execution')), + ); expect(json['inputRequests']['roots'], {'method': Method.rootsList}); final parsed = InputRequiredResult.fromJson(json); @@ -1911,6 +2087,41 @@ void main() { test('rejects malformed subscription wire shapes', () { for (final parse in [ + () => JsonRpcSubscriptionsListenRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.subscriptionsListen, + 'params': { + '_meta': _clientMeta(), + 'notifications': {}, + }, + }), + () => JsonRpcSubscriptionsListenRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsList, + 'params': { + '_meta': _clientMeta(), + 'notifications': {}, + }, + }), + () => JsonRpcSubscriptionsListenRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.subscriptionsListen, + 'params': { + 'notifications': {}, + }, + }), + () => JsonRpcSubscriptionsListenRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.subscriptionsListen, + 'params': { + '_meta': 'bad', + 'notifications': {}, + }, + }), () => JsonRpcSubscriptionsListenRequest.fromJson({ 'jsonrpc': jsonRpcVersion, 'id': 1, @@ -1931,6 +2142,20 @@ void main() { 1: true, }, }), + () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsSubscriptionsAcknowledged, + 'params': { + 'notifications': {}, + }, + }), + () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsProgress, + 'params': { + 'notifications': {}, + }, + }), () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson({ 'jsonrpc': jsonRpcVersion, 'method': Method.notificationsSubscriptionsAcknowledged, @@ -1951,6 +2176,71 @@ void main() { } }); + test('serializes subscriptions/listen with required request metadata', () { + final request = JsonRpcSubscriptionsListenRequest( + id: 'sub-1', + listenParams: const SubscriptionsListenRequest( + notifications: SubscriptionFilter(toolsListChanged: true), + ), + meta: _clientMeta(), + ); + + final json = request.toJson(); + expect(json['method'], Method.subscriptionsListen); + expect(json['params']['notifications'], {'toolsListChanged': true}); + expect( + json['params']['_meta'][McpMetaKey.protocolVersion], + draftProtocolVersion2026_07_28, + ); + expect( + json['params']['_meta'][McpMetaKey.clientCapabilities], + {}, + ); + + final parsed = JsonRpcSubscriptionsListenRequest.fromJson(json); + expect(parsed.id, 'sub-1'); + expect(parsed.meta, _clientMeta()); + expect(parsed.listenParams.notifications.toolsListChanged, isTrue); + expect( + () => JsonRpcSubscriptionsListenRequest( + id: 'missing-meta', + listenParams: const SubscriptionsListenRequest( + notifications: SubscriptionFilter(toolsListChanged: true), + ), + ).toJson(), + throwsFormatException, + ); + }); + + test('resource subscriptions require resources.subscribe capability', () { + const requested = SubscriptionFilter( + resourceSubscriptions: ['file:///project/config.json'], + ); + + expect( + requested + .acknowledgedBy( + const ServerCapabilities( + resources: ServerCapabilitiesResources(), + ), + ) + .toJson(), + isEmpty, + ); + expect( + requested + .acknowledgedBy( + const ServerCapabilities( + resources: ServerCapabilitiesResources(subscribe: true), + ), + ) + .toJson(), + { + 'resourceSubscriptions': ['file:///project/config.json'], + }, + ); + }); + test('server acknowledges subscriptions/listen with subscription id', () async { final server = Server( @@ -1958,7 +2248,7 @@ void main() { options: const McpServerOptions( capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(listChanged: true), - resources: ServerCapabilitiesResources(), + resources: ServerCapabilitiesResources(subscribe: true), ), ), ); @@ -1969,7 +2259,7 @@ void main() { request.listenParams.notifications.acknowledgedBy( const ServerCapabilities( tools: ServerCapabilitiesTools(listChanged: true), - resources: ServerCapabilitiesResources(), + resources: ServerCapabilitiesResources(subscribe: true), ), ), ); @@ -2276,65 +2566,19 @@ void main() { ErrorCode.missingRequiredClientCapability.value, ); expect( - response.error.data['requiredCapabilities']['extensions'] - [mcpTasksExtensionId], - isEmpty, - ); - }); - - test('server rejects task extension methods without client capability', - () async { - final server = Server( - const Implementation(name: 'server', version: '1.0.0'), - options: const McpServerOptions( - capabilities: ServerCapabilities( - extensions: {mcpTasksExtensionId: {}}, - ), - ), - ); - final transport = RecordingTransport(); - await server.connect(transport); - - transport - ..receive( - JsonRpcGetTaskRequest( - id: 'get-task', - getParams: const GetTaskRequest(taskId: 'task-1'), - meta: _clientMeta(), - ), - ) - ..receive( - JsonRpcCancelTaskRequest( - id: 'cancel-task', - cancelParams: const CancelTaskRequest(taskId: 'task-1'), - meta: _clientMeta(), - ), - ) - ..receive( - JsonRpcUpdateTaskRequest( - id: 'update-task', - updateParams: const UpdateTaskRequest( - taskId: 'task-1', - inputResponses: {}, - ), - meta: _clientMeta(), - ), - ); - await _pump(); - - final errors = transport.sentMessages.cast(); - expect( - errors.map((response) => response.error.code), - everyElement(ErrorCode.missingRequiredClientCapability.value), - ); - expect( - errors.first.error.data['requiredCapabilities']['extensions'] - [mcpTasksExtensionId], - isEmpty, + response.error.message, + contains('Missing required client capability'), ); + expect(response.error.data, { + 'requiredCapabilities': { + 'extensions': { + mcpTasksExtensionId: {}, + }, + }, + }); }); - test('server handles task extension methods with 2026 result shapes', + test('server handles task extension methods without per-request capability', () async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), @@ -2392,25 +2636,21 @@ void main() { ); final transport = RecordingTransport(); await server.connect(transport); - final taskExtensionMeta = _clientMeta( - clientCapabilities: const ClientCapabilities( - extensions: {mcpTasksExtensionId: {}}, - ), - ); + final statelessMeta = _clientMeta(); transport ..receive( JsonRpcGetTaskRequest( id: 'get-task', getParams: const GetTaskRequest(taskId: 'task-1'), - meta: taskExtensionMeta, + meta: statelessMeta, ), ) ..receive( JsonRpcCancelTaskRequest( id: 'cancel-task', cancelParams: const CancelTaskRequest(taskId: 'task-1'), - meta: taskExtensionMeta, + meta: statelessMeta, ), ) ..receive( @@ -2420,7 +2660,7 @@ void main() { taskId: 'task-1', inputResponses: {}, ), - meta: taskExtensionMeta, + meta: statelessMeta, ), ); await _pump(); @@ -2435,21 +2675,99 @@ void main() { expect(responses[2].result, {'resultType': resultTypeComplete}); }); - test('server does not expose legacy task handlers as task extension', + test('server task store uses task extension results for stateless requests', () async { - final server = McpServer( + final store = InMemoryTaskStore(); + addTearDown(store.dispose); + final completedTask = await store.createTask( + const TaskCreation(ttl: 60000), + 'source-request', + const { + 'method': Method.toolsCall, + 'params': {'name': 'long'}, + }, + null, + ); + await store.storeTaskResult( + completedTask.taskId, + TaskStatus.completed, + const CallToolResult(content: [TextContent(text: 'done')]), + ); + final workingTask = await store.createTask( + const TaskCreation(ttl: null), + 'cancel-request', + const { + 'method': Method.toolsCall, + 'params': {'name': 'cancel-me'}, + }, + null, + ); + final server = Server( const Implementation(name: 'server', version: '1.0.0'), + options: McpServerOptions( + capabilities: const ServerCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + taskStore: store, + ), ); - var handlerCalled = false; - server.experimental.onGetTask((taskId, extra) async { - handlerCalled = true; - return Task( - taskId: taskId, - status: TaskStatus.completed, - ttl: null, - createdAt: '2026-07-28T00:00:00Z', - lastUpdatedAt: '2026-07-28T00:01:00Z', - ); + final transport = RecordingTransport(); + await server.connect(transport); + + final meta = _clientMeta( + clientCapabilities: const ClientCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + ); + transport + ..receive( + JsonRpcGetTaskRequest( + id: 'get-task', + getParams: GetTaskRequest(taskId: completedTask.taskId), + meta: meta, + ), + ) + ..receive( + JsonRpcCancelTaskRequest( + id: 'cancel-task', + cancelParams: CancelTaskRequest(taskId: workingTask.taskId), + meta: meta, + ), + ); + await _pump(); + + final responses = transport.sentMessages.cast().toList(); + expect(responses, hasLength(2)); + expect(responses[0].result['resultType'], resultTypeComplete); + expect(responses[0].result['taskId'], completedTask.taskId); + expect(responses[0].result['status'], TaskStatus.completed.name); + expect(responses[0].result['ttlMs'], 60000); + expect(responses[0].result, isNot(contains('ttl'))); + expect(responses[0].result['result']['content'], [ + {'type': 'text', 'text': 'done'}, + ]); + expect(responses[1].result, {'resultType': resultTypeComplete}); + expect( + (await store.getTask(workingTask.taskId))?.status, + TaskStatus.cancelled, + ); + }); + + test('server does not expose legacy task handlers as task extension', + () async { + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + var handlerCalled = false; + server.experimental.onGetTask((taskId, extra) async { + handlerCalled = true; + return Task( + taskId: taskId, + status: TaskStatus.completed, + ttl: null, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:01:00Z', + ); }); final transport = RecordingTransport(); await server.connect(transport); @@ -2647,6 +2965,7 @@ void main() { ), ), ); + _registerTaskGetExtensionHandler(server); server.setRequestHandler( Method.toolsCall, (request, extra) async => const CreateTaskExtensionResult( @@ -2687,6 +3006,302 @@ void main() { expect(response.result['taskId'], 'task-1'); }); + test('stateless task support is not inferred from initialize capabilities', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ); + _registerTaskGetExtensionHandler(server); + server.setRequestHandler( + Method.toolsCall, + (request, extra) async => const CreateTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:00Z', + ttlMs: null, + ), + ), + (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcInitializeRequest( + id: 'init', + initParams: const InitializeRequest( + protocolVersion: stableProtocolVersion2025_11_25, + capabilities: ClientCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + clientInfo: Implementation(name: 'client', version: '1.0.0'), + ), + ), + ); + await _pump(); + transport.sentMessages.clear(); + + transport.receive( + JsonRpcCallToolRequest( + id: 'call-1', + params: const CallToolRequest(name: 'long').toJson(), + meta: _clientMeta(), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect( + response.error.code, + ErrorCode.missingRequiredClientCapability.value, + ); + expect( + response.error.message, + contains('Missing required client capability'), + ); + expect(response.error.data, { + 'requiredCapabilities': { + 'extensions': { + mcpTasksExtensionId: {}, + }, + }, + }); + }); + + test('stateless tools/call rejects task result without server extension', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), + ), + ); + server.setRequestHandler( + Method.toolsCall, + (request, extra) async => const CreateTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:00Z', + ttlMs: null, + ), + ), + (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcCallToolRequest( + id: 'call-1', + params: const CallToolRequest(name: 'long').toJson(), + meta: _clientMeta( + clientCapabilities: const ClientCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect(response.error.code, ErrorCode.invalidParams.value); + expect(response.error.message, contains(mcpTasksExtensionId)); + }); + + test('stateless tools/call rejects task result without tasks/get handler', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ); + server.setRequestHandler( + Method.toolsCall, + (request, extra) async => const CreateTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:00Z', + ttlMs: null, + ), + ), + (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcCallToolRequest( + id: 'call-1', + params: const CallToolRequest(name: 'long').toJson(), + meta: _clientMeta( + clientCapabilities: const ClientCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect(response.error.code, ErrorCode.invalidParams.value); + expect(response.error.message, contains('tasks/get handler')); + }); + + test('stateless tools/call rejects task result before task is readable', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ); + var getTaskCalled = false; + server.setRequestHandler( + Method.tasksGet, + (request, extra) async { + getTaskCalled = true; + throw McpError( + ErrorCode.invalidParams.value, + 'Task not found', + ); + }, + (id, params, meta) => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.tasksGet, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + server.setRequestHandler( + Method.toolsCall, + (request, extra) async => const CreateTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:00Z', + ttlMs: null, + ), + ), + (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcCallToolRequest( + id: 'call-1', + params: const CallToolRequest(name: 'long').toJson(), + meta: _clientMeta( + clientCapabilities: const ClientCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect(getTaskCalled, isTrue); + expect(response.error.code, ErrorCode.invalidParams.value); + expect(response.error.message, contains('must be resolvable')); + expect(response.error.data, contains('Task not found')); + }); + + test('stateless tools/call rejects CallToolResult resultType spoof', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ); + server.setRequestHandler( + Method.toolsCall, + (request, extra) async => const CallToolResult( + content: [TextContent(text: 'spoof')], + extra: { + 'resultType': resultTypeTask, + 'taskId': 'spoofed-task', + }, + ), + (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcCallToolRequest( + id: 'call-1', + params: const CallToolRequest(name: 'spoof').toJson(), + meta: _clientMeta( + clientCapabilities: const ClientCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect(response.error.code, ErrorCode.invalidParams.value); + expect(response.error.message, contains('CallToolResult')); + expect(response.error.message, contains('resultType')); + }); + test( 'stateless tools/call rejects task extension result without capability', () async { @@ -2735,6 +3350,17 @@ void main() { response.error.code, ErrorCode.missingRequiredClientCapability.value, ); + expect( + response.error.message, + contains('Missing required client capability'), + ); + expect(response.error.data, { + 'requiredCapabilities': { + 'extensions': { + mcpTasksExtensionId: {}, + }, + }, + }); }); test('stateless tools/call permits input required results', () async { @@ -3017,9 +3643,10 @@ void main() { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), ); + final handler = CompletedTaskHandler(); server.experimental.registerToolTask( 'long', - handler: CompletedTaskHandler(), + handler: handler, ); final transport = RecordingTransport(); await server.connect(transport); @@ -3027,7 +3654,10 @@ void main() { transport.receive( JsonRpcCallToolRequest( id: 'call-1', - params: const CallToolRequest(name: 'long').toJson(), + params: { + ...const CallToolRequest(name: 'long').toJson(), + 'task': {'ttl': 1000}, + }, meta: _clientMeta(), ), ); @@ -3035,6 +3665,7 @@ void main() { final response = transport.sentMessages.single as JsonRpcResponse; expect(response.result['content'][0]['text'], 'task complete'); + expect(handler.lastCreateTaskExtra?.taskRequestedTtl, isNull); }); test('stateless tools/list omits legacy task execution metadata', () async { @@ -3059,6 +3690,48 @@ void main() { expect(tool, isNot(contains('execution'))); }); + test('stateless custom tools/list handlers omit legacy execution metadata', + () async { + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), + ); + server.server.setRequestHandler( + Method.toolsList, + (request, extra) async => const ListToolsResult( + tools: [ + Tool( + name: 'task-tool', + inputSchema: JsonObject(), + execution: ToolExecution(taskSupport: 'required'), + ), + ], + ), + (id, params, meta) => JsonRpcListToolsRequest( + id: id, + params: params, + meta: meta, + ), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport + .receive(JsonRpcListToolsRequest(id: 'tools', meta: _clientMeta())); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + final tool = (response.result['tools'] as List).single as Map; + expect(tool, isNot(contains('execution'))); + expect(response.result['resultType'], resultTypeComplete); + expect(response.result['ttlMs'], 0); + expect(response.result['cacheScope'], CacheScope.private); + }); + test('stateless tools/list returns tools sorted by name', () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), @@ -3105,11 +3778,136 @@ void main() { if (meta != null) '_meta': meta, }), ), - throwsStateError, + throwsStateError, + ); + }); + + test('server handles server/discover before legacy initialization', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + instructions: 'Discovery instructions.', + ), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcServerDiscoverRequest(id: 'discover-1', meta: _clientMeta()), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + expect(response.id, 'discover-1'); + expect( + response.result['supportedVersions'], + contains(draftProtocolVersion2026_07_28), + ); + expect(response.result['serverInfo']['name'], 'server'); + expect(response.result['instructions'], 'Discovery instructions.'); + }); + + test('server accepts stateless requests without initialize', () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), + ); + String? receivedProtocolVersion; + Implementation? receivedClientInfo; + ClientCapabilities? receivedClientCapabilities; + server.setRequestHandler( + Method.toolsList, + (request, extra) async { + receivedProtocolVersion = extra.protocolVersion; + receivedClientInfo = extra.clientInfo; + receivedClientCapabilities = extra.clientCapabilities; + return const ListToolsResult( + tools: [ + Tool(name: 'echo', inputSchema: JsonObject()), + ], + ); + }, + (id, params, meta) => JsonRpcListToolsRequest( + id: id, + params: params, + meta: meta, + ), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive(JsonRpcListToolsRequest(id: 1, meta: _clientMeta())); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + final tools = response.result['tools'] as List; + expect(tools.single['name'], 'echo'); + expect(receivedProtocolVersion, draftProtocolVersion2026_07_28); + expect(receivedClientInfo?.name, 'client'); + expect(receivedClientInfo?.version, '1.0.0'); + expect(receivedClientCapabilities?.toJson(), isEmpty); + }); + + test('stateless handlers do not inherit transport session identity', + () async { + final taskStore = SessionRecordingTaskStore(); + addTearDown(taskStore.dispose); + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: McpServerOptions( + capabilities: const ServerCapabilities( + tools: ServerCapabilitiesTools(), + extensions: {mcpTasksExtensionId: {}}, + ), + taskStore: taskStore, + ), + ); + RequestHandlerExtra? receivedExtra; + server.setRequestHandler( + Method.toolsCall, + (request, extra) async { + receivedExtra = extra; + await extra.taskStore!.createTask(const TaskCreation(ttl: 1000)); + return const CallToolResult( + content: [TextContent(text: 'ok')], + ); + }, + (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + final transport = + RecordingTransport(sessionIdValue: 'stateful-session-id'); + await server.connect(transport); + + transport.receive( + JsonRpcCallToolRequest( + id: 'call-1', + params: const CallToolRequest(name: 'tool').toJson(), + meta: _clientMeta(), + ), ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + expect(response.result['resultType'], resultTypeComplete); + expect(receivedExtra?.sessionId, isNull); + expect(taskStore.createTaskSessionIds, [isNull]); }); - test('server handles server/discover before legacy initialization', + test('server handler client requests stay associated with origin request', () async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), @@ -3117,64 +3915,118 @@ void main() { capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), ), - instructions: 'Discovery instructions.', ), ); + server.setRequestHandler( + Method.toolsCall, + (request, extra) async { + final result = await extra.sendRequest( + JsonRpcElicitRequest( + id: -1, + elicitParams: ElicitRequest.form( + message: 'Approve tool execution?', + requestedSchema: JsonSchema.object( + properties: {'approved': JsonSchema.boolean()}, + required: ['approved'], + ), + ), + ), + ElicitResult.fromJson, + const RequestOptions(timeout: Duration(seconds: 1)), + ); + expect(result.accepted, isTrue); + return const CallToolResult( + content: [TextContent(text: 'approved')], + ); + }, + (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); final transport = RecordingTransport(); await server.connect(transport); transport.receive( - JsonRpcServerDiscoverRequest(id: 'discover-1', meta: _clientMeta()), + JsonRpcInitializeRequest( + id: 1, + initParams: const InitializeRequestParams( + protocolVersion: stableProtocolVersion2025_11_25, + capabilities: ClientCapabilities( + elicitation: ClientElicitation.formOnly(), + ), + clientInfo: Implementation(name: 'client', version: '1.0.0'), + ), + ), ); await _pump(); + transport + ..sentMessages.clear() + ..sentRelatedRequestIds.clear() + ..receive(const JsonRpcInitializedNotification()); + await _pump(); - final response = transport.sentMessages.single as JsonRpcResponse; - expect(response.id, 'discover-1'); - expect( - response.result['supportedVersions'], - contains(draftProtocolVersion2026_07_28), + transport.receive( + JsonRpcCallToolRequest( + id: 42, + params: const CallToolRequest(name: 'needs-approval').toJson(), + ), ); - expect(response.result['serverInfo']['name'], 'server'); - expect(response.result['instructions'], 'Discovery instructions.'); + await _pump(); + + final nestedRequest = transport.sentMessages.single as JsonRpcRequest; + expect(nestedRequest.method, Method.elicitationCreate); + expect(transport.sentRelatedRequestIds.single, 42); + + transport.receive( + JsonRpcResponse( + id: nestedRequest.id, + result: const ElicitResult( + action: 'accept', + content: {'approved': true}, + ).toJson(), + ), + ); + await _pump(); + + expect(transport.sentMessages, hasLength(2)); + final response = transport.sentMessages.last as JsonRpcResponse; + expect(response.id, 42); + expect(response.result['content'][0]['text'], 'approved'); }); - test('server accepts stateless requests without initialize', () async { + test('server initialize never negotiates stateless draft version', + () async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), - options: const McpServerOptions( - capabilities: ServerCapabilities( - tools: ServerCapabilitiesTools(), - ), - ), - ); - server.setRequestHandler( - Method.toolsList, - (request, extra) async { - expect( - extra.meta?[McpMetaKey.protocolVersion], - draftProtocolVersion2026_07_28, - ); - return const ListToolsResult( - tools: [ - Tool(name: 'echo', inputSchema: JsonObject()), - ], - ); - }, - (id, params, meta) => JsonRpcListToolsRequest( - id: id, - params: params, - meta: meta, - ), ); final transport = RecordingTransport(); await server.connect(transport); - transport.receive(JsonRpcListToolsRequest(id: 1, meta: _clientMeta())); + transport.receive( + JsonRpcInitializeRequest( + id: 1, + initParams: const InitializeRequestParams( + protocolVersion: draftProtocolVersion2026_07_28, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'client', version: '1.0.0'), + ), + ), + ); await _pump(); final response = transport.sentMessages.single as JsonRpcResponse; - final tools = response.result['tools'] as List; - expect(tools.single['name'], 'echo'); + expect( + response.result['protocolVersion'], + stableProtocolVersion2025_11_25, + ); + expect( + response.result['protocolVersion'], + isNot(latestDraftProtocolVersion), + ); }); test('server returns unsupported protocol version for stateless metadata', @@ -3561,6 +4413,44 @@ void main() { expect(listRequest.meta?[McpMetaKey.clientCapabilities], {}); }); + test('stateless client rejects legacy task request options before send', + () async { + final transport = DiscoveringClientTransport(); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + + await client.connect(transport); + final sentBeforeCall = transport.sentMessages.length; + + await expectLater( + client.callTool( + const CallToolRequest(name: 'echo'), + options: const RequestOptions(task: TaskCreation(ttl: 1000)), + ), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.invalidRequest.value, + ) + .having( + (error) => error.message, + 'message', + contains('RequestOptions.task'), + ) + .having( + (error) => error.message, + 'message', + contains(mcpTasksExtensionId), + ), + ), + ); + + expect(transport.sentMessages, hasLength(sentBeforeCall)); + }); + test('client can opt out of discovery for legacy initialization', () async { final transport = LegacyFallbackTransport(); final client = McpClient( @@ -3584,6 +4474,13 @@ void main() { .map((message) => message.method), contains(Method.initialize), ); + final initializeRequest = transport.sentMessages + .whereType() + .singleWhere((message) => message.method == Method.initialize); + expect( + initializeRequest.params?['protocolVersion'], + stableProtocolVersion2025_11_25, + ); }); test('client falls back when legacy HTTP rejects discovery before init', @@ -3596,6 +4493,10 @@ void main() { '"message":"Bad Request: Server not initialized"},"id":null}', ), McpError(0, 'Error POSTing to endpoint (HTTP 400): '), + McpError( + ErrorCode.invalidParams.value, + 'Invalid request parameters', + ), ]; for (final error in errors) { @@ -3784,6 +4685,26 @@ void main() { expect(response.error.message, contains('inputRequests')); }); + test( + 'stateless client reports method not found for unadvertised peer method', + () async { + final transport = DiscoveringClientTransport(); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + await client.connect(transport); + transport.sentMessages.clear(); + + transport.onmessage?.call(const JsonRpcListRootsRequest(id: 'roots-1')); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect(response.id, 'roots-1'); + expect(response.error.code, ErrorCode.methodNotFound.value); + expect(response.error.message, contains('roots')); + }); + test('client retries tools/call after fulfilling input_required requests', () async { late DiscoveringClientTransport transport; @@ -3963,6 +4884,128 @@ void main() { ]); }); + test('client handles input_required before task resultType tools/call', + () async { + late DiscoveringClientTransport transport; + final requests = []; + transport = DiscoveringClientTransport( + capabilities: ServerCapabilities( + tools: const ServerCapabilitiesTools(), + extensions: withMcpTasksExtension(null), + ), + onRequest: (request) { + requests.add(request); + switch (request.method) { + case Method.toolsCall: + if (requests + .where((sent) => sent.method == Method.toolsCall) + .length == + 1) { + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: InputRequiredResult( + requestState: 'approved-state', + inputRequests: { + 'approval': InputRequest.elicit( + ElicitRequest.form( + message: 'Approve async work?', + requestedSchema: JsonSchema.object( + properties: { + 'approved': JsonSchema.boolean(), + }, + required: ['approved'], + ), + ), + ), + }, + ).toJson(), + ), + ); + return; + } + + expect(request.params?['requestState'], 'approved-state'); + expect( + request.params?['inputResponses']['approval'], + { + 'action': 'accept', + 'content': {'approved': true}, + }, + ); + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: const CreateTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-after-mrtr', + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:01Z', + ttlMs: null, + pollIntervalMs: 1, + ), + ).toJson(), + ), + ); + break; + + case Method.tasksGet: + expect(request.params?['taskId'], 'task-after-mrtr'); + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: const GetTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-after-mrtr', + status: TaskStatus.completed, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:02Z', + ttlMs: null, + result: { + 'content': [ + {'type': 'text', 'text': 'approved task done'}, + ], + }, + ), + ).toJson(), + ), + ); + break; + } + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: McpClientOptions( + capabilities: ClientCapabilities( + elicitation: const ClientElicitation.formOnly(), + extensions: withMcpTasksExtension(null), + ), + ), + ); + client.onElicitRequest = (params) async { + expect(params.message, 'Approve async work?'); + return const ElicitResult( + action: 'accept', + content: {'approved': true}, + ); + }; + await client.connect(transport); + transport.sentMessages.clear(); + + final result = await client.callTool( + const CallToolRequest(name: 'async-approval-tool'), + ); + + expect((result.content.single as TextContent).text, 'approved task done'); + expect(requests.map((request) => request.method), [ + Method.toolsCall, + Method.toolsCall, + Method.tasksGet, + ]); + }); + test('client updates task input requests once while polling', () async { late DiscoveringClientTransport transport; var getCount = 0; @@ -4891,9 +5934,9 @@ void main() { 'inputSchema': { 'type': 'object', 'properties': { - 'ratio': { - 'type': 'number', - 'x-mcp-header': 'Ratio', + 'payload': { + 'type': 'object', + 'x-mcp-header': 'Payload', }, }, }, @@ -5078,6 +6121,13 @@ void main() { .map((message) => message.method), containsAllInOrder([Method.serverDiscover, Method.initialize]), ); + final initializeRequest = transport.sentMessages + .whereType() + .singleWhere((message) => message.method == Method.initialize); + expect( + initializeRequest.params?['protocolVersion'], + stableProtocolVersion2025_11_25, + ); expect( transport.sentMessages.whereType(), isEmpty, diff --git a/test/server/server_test.dart b/test/server/server_test.dart index 4cebec5f..4a17b7d6 100644 --- a/test/server/server_test.dart +++ b/test/server/server_test.dart @@ -292,11 +292,17 @@ void main() { expect( () => server.assertTaskCapability(Method.samplingCreateMessage), throwsA( - isA().having( - (e) => e.message, - 'message', - contains('tasks.requests.sampling.createMessage'), - ), + isA() + .having( + (e) => e.code, + 'code', + ErrorCode.methodNotFound.value, + ) + .having( + (e) => e.message, + 'message', + contains('tasks.requests.sampling.createMessage'), + ), ), ); }); @@ -354,11 +360,17 @@ void main() { expect( () => server.assertTaskCapability(Method.rootsList), throwsA( - isA().having( - (e) => e.message, - 'message', - contains('tasks.requests.roots/list'), - ), + isA() + .having( + (e) => e.code, + 'code', + ErrorCode.methodNotFound.value, + ) + .having( + (e) => e.message, + 'message', + contains('tasks.requests.roots/list'), + ), ), ); }); @@ -455,10 +467,157 @@ void main() { // Attempt to send create message request should throw synchronously expect( () => server.assertCapabilityForMethod('sampling/createMessage'), - throwsA(isA()), + throwsA( + isA().having( + (e) => e.code, + 'code', + ErrorCode.methodNotFound.value, + ), + ), ); }); + test('Cannot send tool-enabled sampling without sampling.tools capability', + () async { + await server.connect(transport); + await _initializeClient(transport, server, withSampling: true); + + const createParams = CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Use a tool'), + ), + ], + maxTokens: 100, + tools: [ + Tool(name: 'search', inputSchema: JsonObject()), + ], + ); + + expect( + () => server.createMessage(createParams), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.methodNotFound.value, + ) + .having( + (error) => error.message, + 'message', + contains('sampling tools capability'), + ), + ), + ); + expect( + transport.sentMessages + .whereType() + .where((message) => message.method == Method.samplingCreateMessage), + isEmpty, + ); + }); + + test( + 'Can send sampling with includeContext none without context capability', + () async { + await server.connect(transport); + await _initializeClient(transport, server, withSampling: true); + + const createParams = CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Use no context'), + ), + ], + includeContext: IncludeContext.none, + maxTokens: 100, + ); + + final result = await server.createMessage(createParams); + + expect(result.role, equals(SamplingMessageRole.assistant)); + expect( + transport.sentMessages + .whereType() + .where((message) => message.method == Method.samplingCreateMessage), + hasLength(1), + ); + }); + + test('Cannot send deprecated sampling context without context capability', + () async { + await server.connect(transport); + await _initializeClient(transport, server, withSampling: true); + + const createParams = CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Use server context'), + ), + ], + includeContext: IncludeContext.thisServer, + maxTokens: 100, + ); + + expect( + () => server.createMessage(createParams), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.methodNotFound.value, + ) + .having( + (error) => error.message, + 'message', + contains('sampling context capability'), + ), + ), + ); + expect( + transport.sentMessages + .whereType() + .where((message) => message.method == Method.samplingCreateMessage), + isEmpty, + ); + }); + + test('Can send deprecated sampling context with context capability', + () async { + await server.connect(transport); + await _initializeClient( + transport, + server, + withSampling: true, + withSamplingContext: true, + ); + + const createParams = CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Use all context'), + ), + ], + includeContext: IncludeContext.allServers, + maxTokens: 100, + ); + + final result = await server.createMessage(createParams); + + expect(result.role, equals(SamplingMessageRole.assistant)); + final request = + transport.sentMessages.whereType().singleWhere( + (message) => message.method == Method.samplingCreateMessage, + ); + expect(request.params?['includeContext'], IncludeContext.allServers.name); + }); + test('Can send listRoots request when client has roots capability', () async { await server.connect(transport); @@ -492,7 +651,13 @@ void main() { // Attempt to check capability directly should throw expect( () => server.assertCapabilityForMethod('roots/list'), - throwsA(isA()), + throwsA( + isA().having( + (e) => e.code, + 'code', + ErrorCode.methodNotFound.value, + ), + ), ); }); @@ -616,11 +781,14 @@ Future _initializeClient( MockTransport transport, Server server, { bool withSampling = false, + bool withSamplingContext = false, bool withRoots = false, bool withElicitation = false, }) async { final clientCapabilities = ClientCapabilities( - sampling: withSampling ? const ClientCapabilitiesSampling() : null, + sampling: withSampling + ? ClientCapabilitiesSampling(context: withSamplingContext) + : null, roots: withRoots ? const ClientCapabilitiesRoots() : null, elicitation: withElicitation ? const ClientElicitation.formOnly() : null, ); @@ -669,6 +837,7 @@ void _addCriticalPathTests() { () => server.assertCapabilityForMethod('elicitation/create'), throwsA( isA() + .having((e) => e.code, 'code', ErrorCode.methodNotFound.value) .having((e) => e.message, 'message', contains('elicitation')), ), ); diff --git a/test/server/streamable_https_test.dart b/test/server/streamable_https_test.dart index 6038152b..327a9e81 100644 --- a/test/server/streamable_https_test.dart +++ b/test/server/streamable_https_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:mcp_dart/src/server/server.dart'; import 'package:mcp_dart/src/server/streamable_https.dart'; +import 'package:mcp_dart/src/shared/protocol.dart'; import 'package:mcp_dart/src/shared/uuid.dart'; import 'package:mcp_dart/src/types.dart'; import 'package:test/test.dart'; @@ -625,6 +626,114 @@ void main() { timeout: const Timeout(Duration(seconds: 5)), ); + test( + 'routes handler client requests on originating POST SSE stream', + () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + ), + ); + addTearDown(transport.close); + + final server = Server( + const Implementation(name: 'TestServer', version: '1.0.0'), + ); + addTearDown(server.close); + server.setRequestHandler( + 'test/nested-request', + (request, extra) async { + await extra.sendRequest( + const JsonRpcRequest( + id: 0, + method: 'test/client-question', + params: {'prompt': 'confirm'}, + ), + EmptyResult.fromJson, + const RequestOptions(timeout: Duration(seconds: 2)), + ); + return const EmptyResult(); + }, + (id, params, meta) => JsonRpcRequest( + id: id, + method: 'test/nested-request', + params: params, + meta: meta, + ), + ); + await server.connect(transport); + transports['/mcp'] = transport; + + Future postJsonRpc(JsonRpcMessage message) async { + final client = HttpClient(); + addTearDown(() => client.close(force: true)); + + final request = await client.postUrl(Uri.parse('$serverUrlBase/mcp')); + request.headers + ..contentType = ContentType.json + ..set( + HttpHeaders.acceptHeader, + 'application/json, text/event-stream', + ); + request.write(jsonEncode(message.toJson())); + return request.close(); + } + + final initResponse = await postJsonRpc( + JsonRpcInitializeRequest( + id: 1, + initParams: const InitializeRequestParams( + protocolVersion: latestProtocolVersion, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'TestClient', version: '1.0.0'), + ), + ), + ); + expect(initResponse.statusCode, HttpStatus.ok); + expect( + _decodeSseJsonMessages(await utf8.decodeStream(initResponse)).single, + containsPair('id', 1), + ); + + final initializedResponse = await postJsonRpc( + const JsonRpcInitializedNotification(), + ); + expect(initializedResponse.statusCode, HttpStatus.accepted); + await initializedResponse.drain(); + + final response = await postJsonRpc( + const JsonRpcRequest( + id: 'originating-request', + method: 'test/nested-request', + ), + ); + expect(response.statusCode, HttpStatus.ok); + expect(response.headers.contentType?.mimeType, 'text/event-stream'); + final lines = StreamIterator( + response.transform(utf8.decoder).transform(const LineSplitter()), + ); + addTearDown(lines.cancel); + + final nestedRequest = await _readSseJsonEvent(lines); + expect(nestedRequest.json['method'], 'test/client-question'); + expect(nestedRequest.json['params'], {'prompt': 'confirm'}); + + final nestedResponse = await postJsonRpc( + JsonRpcResponse( + id: nestedRequest.json['id'], + result: const EmptyResult().toJson(), + ), + ); + expect(nestedResponse.statusCode, HttpStatus.accepted); + await nestedResponse.drain(); + + final finalResponse = await _readSseJsonEvent(lines); + expect(finalResponse.json['id'], 'originating-request'); + expect(finalResponse.json['result'], const EmptyResult().toJson()); + }, + timeout: const Timeout(Duration(seconds: 5)), + ); + test('enableJsonResponse option is accepted', () async { // Create a transport with JSON response enabled final transport = StreamableHTTPServerTransport( @@ -2159,7 +2268,7 @@ void main() { expect(body['error']['message'], contains('Mcp-Name header value')); }); - test('2026 stateless HTTP requires task id name header for task requests', + test('2026 stateless HTTP accepts task requests with matching name header', () async { final transport = StreamableHTTPServerTransport( options: StreamableHTTPServerTransportOptions( @@ -2184,6 +2293,49 @@ void main() { } }; + final client = HttpClient(); + addTearDown(() => client.close(force: true)); + final request = await client.postUrl(Uri.parse('$serverUrlBase/mcp')); + request.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', draftProtocolVersion2026_07_28) + ..set('Mcp-Method', Method.tasksUpdate) + ..set('Mcp-Name', 'task-1'); + request.write( + jsonEncode( + JsonRpcUpdateTaskRequest( + id: 4, + updateParams: const UpdateTaskRequest( + taskId: 'task-1', + inputResponses: {}, + ), + meta: _statelessMeta(), + ), + ), + ); + + final response = await request.close(); + + expect(response.statusCode, HttpStatus.ok); + final body = + jsonDecode(await utf8.decodeStream(response)) as Map; + expect(body['id'], 4); + expect(body['result'], {'resultType': resultTypeComplete}); + }); + + test('2026 stateless HTTP rejects task requests without name header', + () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => "unused-session-id", + enableJsonResponse: true, + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + final client = HttpClient(); addTearDown(() => client.close(force: true)); final request = await client.postUrl(Uri.parse('$serverUrlBase/mcp')); @@ -2212,7 +2364,50 @@ void main() { jsonDecode(await utf8.decodeStream(response)) as Map; expect(body['id'], 4); expect(body['error']['code'], ErrorCode.headerMismatch.value); - expect(body['error']['message'], contains('Mcp-Name header is required')); + expect(body['error']['message'], contains('Mcp-Name header')); + }); + + test('2026 stateless HTTP accepts response posts without body metadata', + () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => "unused-session-id", + enableJsonResponse: true, + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + + final receivedMessage = Completer(); + transport.onmessage = receivedMessage.complete; + + final client = HttpClient(); + addTearDown(() => client.close(force: true)); + final request = await client.postUrl(Uri.parse('$serverUrlBase/mcp')); + request.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', draftProtocolVersion2026_07_28); + request.write( + jsonEncode( + const JsonRpcResponse( + id: 'input-response', + result: {'ok': true}, + ).toJson(), + ), + ); + + final response = await request.close(); + + expect(response.statusCode, HttpStatus.accepted); + expect(await utf8.decodeStream(response), isEmpty); + final message = + await receivedMessage.future.timeout(const Duration(seconds: 5)); + expect(message, isA()); + final jsonRpcResponse = message as JsonRpcResponse; + expect(jsonRpcResponse.id, 'input-response'); + expect(jsonRpcResponse.result, {'ok': true}); }); test('2026 stateless HTTP ignores session header', () async { @@ -2262,6 +2457,94 @@ void main() { expect(body['result']['tools'], isEmpty); }); + test('2026 stateless HTTP omits existing transport session header', + () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => 'stateful-session-id', + enableJsonResponse: true, + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + + transport.onmessage = (message) { + if (message is JsonRpcInitializeRequest) { + unawaited( + transport.send( + JsonRpcResponse( + id: message.id, + result: const InitializeResult( + protocolVersion: latestProtocolVersion, + capabilities: ServerCapabilities(), + serverInfo: Implementation( + name: 'StatefulServer', + version: '1.0.0', + ), + ).toJson(), + ), + ), + ); + } else if (message is JsonRpcListToolsRequest) { + unawaited( + transport.send( + JsonRpcResponse( + id: message.id, + result: const ListToolsResult(tools: []).toJson(), + ), + ), + ); + } + }; + + final client = HttpClient(); + addTearDown(() => client.close(force: true)); + final initRequest = await client.postUrl(Uri.parse('$serverUrlBase/mcp')); + initRequest.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream'); + initRequest.write( + jsonEncode( + JsonRpcInitializeRequest( + id: 1, + initParams: const InitializeRequest( + protocolVersion: latestProtocolVersion, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'client', version: '1.0.0'), + ), + ), + ), + ); + + final initResponse = await initRequest.close(); + expect(initResponse.statusCode, HttpStatus.ok); + final sessionId = initResponse.headers.value('mcp-session-id'); + expect(sessionId, 'stateful-session-id'); + await utf8.decodeStream(initResponse); + + final statelessRequest = + await client.postUrl(Uri.parse('$serverUrlBase/mcp')); + statelessRequest.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', draftProtocolVersion2026_07_28) + ..set('Mcp-Method', Method.toolsList) + ..set('Mcp-Session-Id', sessionId!); + statelessRequest.write( + jsonEncode(JsonRpcListToolsRequest(id: 6, meta: _statelessMeta())), + ); + + final statelessResponse = await statelessRequest.close(); + + expect(statelessResponse.statusCode, HttpStatus.ok); + expect(statelessResponse.headers.value('mcp-session-id'), isNull); + final body = jsonDecode(await utf8.decodeStream(statelessResponse)) + as Map; + expect(body['id'], 6); + expect(body['result']['tools'], isEmpty); + }); + test('2026 stateless HTTP accepts matching standard and parameter headers', () async { final transport = StreamableHTTPServerTransport( @@ -2297,6 +2580,7 @@ void main() { ..set('Mcp-Method', Method.toolsCall) ..set('Mcp-Name', 'execute') ..set('Mcp-Param-region', 'us-east1') + ..set('Mcp-Param-ratio', '1.5') ..set('Mcp-Param-dryRun', 'false'); request.write( jsonEncode( @@ -2306,6 +2590,7 @@ void main() { 'name': 'execute', 'arguments': { 'region': 'us-east1', + 'ratio': 1.5, 'dryRun': false, }, }, @@ -2490,9 +2775,8 @@ void main() { transport.setToolParameterHeaderMappings( const { 'execute': { + 'count': 'Count', 'dryRun': 'Dry-Run', - 'rounded': 'Rounded', - 'ratio': 'Ratio', 'region': 'Region', 'sentinel': 'Sentinel', '/location/zone': 'Zone', @@ -2603,33 +2887,50 @@ void main() { (statusCode, body) = await postToolCall( id: 34, arguments: const { + 'count': 42, 'dryRun': false, - 'ratio': 1.5, 'region': 'us-east1', }, headers: const { + 'Mcp-Param-Count': '42', 'Mcp-Param-Dry-Run': 'false', - 'Mcp-Param-Ratio': '1.5', 'Mcp-Param-Region': 'us-east1', }, ); - expect(statusCode, HttpStatus.badRequest); + expect(statusCode, HttpStatus.ok); expect(body['id'], 34); + expect(body['result']['content'], isEmpty); + + (statusCode, body) = await postToolCall( + id: 38, + arguments: const { + 'count': 42, + 'dryRun': false, + 'region': 'us-east1', + }, + headers: const { + 'Mcp-Param-Count': '43', + 'Mcp-Param-Dry-Run': 'false', + 'Mcp-Param-Region': 'us-east1', + }, + ); + expect(statusCode, HttpStatus.badRequest); + expect(body['id'], 38); expect( body['error']['message'], - contains('no matching primitive body argument'), + contains("body argument 'count'"), ); (statusCode, body) = await postToolCall( id: 35, arguments: const { + 'count': 42, 'dryRun': false, - 'rounded': 42.0, 'region': 'us-east1', }, headers: const { + 'Mcp-Param-Count': '42.0', 'Mcp-Param-Dry-Run': 'false', - 'Mcp-Param-Rounded': '42.0', 'Mcp-Param-Region': 'us-east1', }, ); @@ -3052,6 +3353,28 @@ void main() { patchBody['error']['message'], 'Method not allowed for stateless MCP requests.', ); + + final deleteRequest = await client.deleteUrl( + Uri.parse('$serverUrlBase/mcp'), + ); + deleteRequest.headers + ..set('MCP-Protocol-Version', draftProtocolVersion2026_07_28) + ..set('Mcp-Session-Id', 'ignored-stateless-session'); + + final deleteResponse = await deleteRequest.close(); + final deleteBody = jsonDecode( + await utf8.decodeStream(deleteResponse), + ) as Map; + + expect(deleteResponse.statusCode, HttpStatus.methodNotAllowed); + expect(deleteResponse.headers.contentType?.mimeType, 'application/json'); + expect(deleteResponse.headers.value(HttpHeaders.allowHeader), 'POST'); + expect(deleteResponse.headers.value('mcp-session-id'), isNull); + expect(deleteBody['error']['code'], ErrorCode.connectionClosed.value); + expect( + deleteBody['error']['message'], + 'Method not allowed for stateless MCP requests.', + ); }); test('close cleans up all resources', () async { diff --git a/test/server/streamable_mcp_server_test.dart b/test/server/streamable_mcp_server_test.dart index 3bba902e..60f32ec6 100644 --- a/test/server/streamable_mcp_server_test.dart +++ b/test/server/streamable_mcp_server_test.dart @@ -362,6 +362,172 @@ void main() { expect(messages.single['result']['tools'][0]['name'], 'echo'); }); + test('handles 2026 stateless request with unknown session ID', () async { + await server.stop(); + server = StreamableMcpServer( + serverFactory: (sessionId) { + final mcpServer = McpServer( + const Implementation(name: 'StatelessServer', version: '1.0.0'), + ); + mcpServer.registerTool( + 'echo', + inputSchema: const ToolInputSchema(), + callback: (args, extra) async => const CallToolResult(content: []), + ); + return mcpServer; + }, + host: host, + port: port, + ); + await server.start(); + + final response = await http.post( + Uri.parse(baseUrl), + body: jsonEncode( + JsonRpcListToolsRequest(id: 2, meta: statelessMeta()).toJson(), + ), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.toolsList, + 'mcp-session-id': 'unknown-legacy-session', + }, + ); + + expect(response.statusCode, HttpStatus.ok); + expect(response.headers['mcp-session-id'], isNull); + final messages = _decodeSseJsonMessages(response.body); + expect(messages.single['id'], 2); + expect(messages.single['result']['tools'][0]['name'], 'echo'); + }); + + test('handles stateless task lookup across independent requests', () async { + const taskId = 'task-http-1'; + final tasks = {}; + await server.stop(); + server = StreamableMcpServer( + serverFactory: (sessionId) { + final mcpServer = McpServer( + const Implementation(name: 'StatelessServer', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ); + mcpServer.server.setRequestHandler( + Method.toolsCall, + (request, extra) async { + final task = TaskExtensionTask( + taskId: taskId, + status: TaskStatus.working, + createdAt: DateTime.utc(2026, 7, 28).toIso8601String(), + lastUpdatedAt: DateTime.utc(2026, 7, 28).toIso8601String(), + ttlMs: 60000, + ); + tasks[task.taskId] = task; + return CreateTaskExtensionResult(task: task); + }, + (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + mcpServer.server.setRequestHandler( + Method.tasksGet, + (request, extra) async { + final task = tasks[request.getParams.taskId]; + if (task == null) { + throw McpError( + ErrorCode.invalidParams.value, + 'Task not found', + ); + } + return GetTaskExtensionResult(task: task); + }, + (id, params, meta) => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.tasksGet, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + return mcpServer; + }, + host: host, + port: port, + ); + await server.start(); + + final meta = buildProtocolRequestMeta( + protocolVersion: draftProtocolVersion2026_07_28, + clientInfo: const Implementation(name: 'Client', version: '1.0'), + clientCapabilities: const ClientCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + ); + final createResponse = await http.post( + Uri.parse(baseUrl), + body: jsonEncode( + JsonRpcCallToolRequest( + id: 'call-task', + params: const CallToolRequest(name: 'long').toJson(), + meta: meta, + ).toJson(), + ), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.toolsCall, + 'Mcp-Name': 'long', + }, + ); + + expect(createResponse.statusCode, HttpStatus.ok); + expect(createResponse.headers['mcp-session-id'], isNull); + final createMessages = _decodeSseJsonMessages(createResponse.body); + expect(createMessages.single['result']['resultType'], resultTypeTask); + expect(createMessages.single['result']['taskId'], taskId); + + final lookupResponse = await http.post( + Uri.parse(baseUrl), + body: jsonEncode( + JsonRpcGetTaskRequest( + id: 'get-task', + getParams: const GetTaskRequest(taskId: taskId), + meta: meta, + ).toJson(), + ), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.tasksGet, + 'Mcp-Name': taskId, + 'mcp-session-id': 'unknown-legacy-session', + }, + ); + + expect(lookupResponse.statusCode, HttpStatus.ok); + expect(lookupResponse.headers['mcp-session-id'], isNull); + final lookupMessages = _decodeSseJsonMessages(lookupResponse.body); + expect(lookupMessages.single['id'], 'get-task'); + expect(lookupMessages.single['result']['resultType'], resultTypeComplete); + expect(lookupMessages.single['result']['taskId'], taskId); + expect( + lookupMessages.single['result']['status'], + TaskStatus.working.name, + ); + expect(lookupMessages.single['result']['ttlMs'], 60000); + }); + test('detects stateless requests from nested metadata before top-level', () async { await server.stop(); @@ -1372,6 +1538,21 @@ void main() { test('server port is exposed correctly', () async { expect(server.port, equals(port)); + expect(server.boundPort, equals(port)); + }); + + test('bound port exposes OS-assigned port', () async { + await server.stop(); + server = StreamableMcpServer( + serverFactory: (sid) => + McpServer(const Implementation(name: 'PortServer', version: '1.0')), + host: host, + port: 0, + ); + await server.start(); + + expect(server.port, equals(0)); + expect(server.boundPort, isNot(0)); }); }); } diff --git a/test/shared/json_schema_from_json_test.dart b/test/shared/json_schema_from_json_test.dart index c19165e3..0311e777 100644 --- a/test/shared/json_schema_from_json_test.dart +++ b/test/shared/json_schema_from_json_test.dart @@ -80,6 +80,42 @@ void main() { expect(s.toJson(), json); }); + test('rejects invalid explicit type values', () { + expect( + () => JsonSchema.fromJson({'type': 'unknown'}), + throwsA(isA()), + ); + expect( + () => JsonSchema.fromJson({ + 'type': ['string', 'unknown'], + }), + throwsA(isA()), + ); + expect( + () => JsonSchema.fromJson({ + 'type': ['string', 'string'], + }), + throwsA(isA()), + ); + expect( + () => JsonSchema.fromJson({'type': 1}), + throwsA(isA()), + ); + }); + + test('keeps schemas without explicit type as any schemas', () { + final schema = JsonSchema.fromJson({ + 'title': 'Any JSON value', + 'description': 'No type restriction.', + }); + + expect(schema, isA()); + expect(schema.toJson(), { + 'title': 'Any JSON value', + 'description': 'No type restriction.', + }); + }); + test('parses const schema', () { final json = {'const': 'DELETE'}; final schema = JsonSchema.fromJson(json); diff --git a/test/shared/json_schema_validator_test.dart b/test/shared/json_schema_validator_test.dart index 22cc927f..ec46f11f 100644 --- a/test/shared/json_schema_validator_test.dart +++ b/test/shared/json_schema_validator_test.dart @@ -669,7 +669,7 @@ void main() { } }); - test('rejects malformed raw type arrays', () { + test('rejects malformed raw type arrays at parse time', () { final schemas = [ { 'type': ['string', 1], @@ -688,11 +688,9 @@ void main() { ]; for (final json in schemas) { - final schema = JsonSchema.fromJson(json); - expect(schema.toJson(), json); expect( - () => schema.validate('value'), - throwsA(isA()), + () => JsonSchema.fromJson(json), + throwsA(isA()), ); } }); diff --git a/test/shared/protocol_test.dart b/test/shared/protocol_test.dart index 3cd2cf9d..60f37a47 100644 --- a/test/shared/protocol_test.dart +++ b/test/shared/protocol_test.dart @@ -2739,6 +2739,57 @@ void main() { } }); + test('does not send cancellation notification for initialize request', + () async { + await protocol.connect(transport); + + final controller = BasicAbortController(); + final requestFuture = protocol + .request( + JsonRpcInitializeRequest( + id: 0, + initParams: const InitializeRequest( + protocolVersion: latestProtocolVersion, + capabilities: ClientCapabilities(), + clientInfo: Implementation( + name: 'test-client', + version: '1.0.0', + ), + ), + ), + InitializeResult.fromJson, + RequestOptions( + signal: controller.signal, + timeoutEnabled: false, + ), + ) + .timeout(const Duration(seconds: 5)); + + expect(transport.sentMessages, hasLength(1)); + expect( + (transport.sentMessages.single as JsonRpcRequest).method, + Method.initialize, + ); + + controller.abort('User cancelled initialize'); + + await expectLater( + requestFuture, + throwsA( + predicate( + (error) => error.toString().contains('User cancelled initialize'), + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 20)); + + expect( + transport.sentMessages.whereType(), + isEmpty, + ); + expect(transport.sentMessages, hasLength(1)); + }); + test('enforces strict capabilities when enabled', () { // We avoid using a transport connection in this test and just verify the capability check directly final strictProtocol = TestProtocol( diff --git a/test/tool/spec_example_audit_test.dart b/test/tool/spec_example_audit_test.dart new file mode 100644 index 00000000..9ea6f47f --- /dev/null +++ b/test/tool/spec_example_audit_test.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + group('spec_example_audit', () { + late Directory examplesDir; + + setUp(() { + examplesDir = Directory.systemTemp.createTempSync( + 'mcp_spec_example_audit_test_', + ); + }); + + tearDown(() { + if (examplesDir.existsSync()) { + examplesDir.deleteSync(recursive: true); + } + }); + + test('accepts representative upstream example shapes', () async { + _writeExample( + examplesDir, + 'Tool', + 'tool-with-array-output-schema.json', + { + 'name': 'get_tags', + 'description': 'Returns tags', + 'inputSchema': { + 'type': 'object', + 'properties': {}, + }, + 'outputSchema': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + }, + ); + _writeExample( + examplesDir, + 'CallToolResultResponse', + 'call-tool-result-response.json', + { + 'jsonrpc': '2.0', + 'id': 'call-tool-example', + 'result': { + 'resultType': 'complete', + 'content': [ + {'type': 'text', 'text': 'ok'}, + ], + }, + }, + ); + _writeExample( + examplesDir, + 'MissingRequiredClientCapabilityError', + 'missing-elicitation-capability.json', + { + 'jsonrpc': '2.0', + 'id': 1, + 'error': { + 'code': -32003, + 'message': + 'Server requires the elicitation capability for this request', + 'data': { + 'requiredCapabilities': { + 'elicitation': {}, + }, + }, + }, + }, + ); + _writeExample( + examplesDir, + 'ListRootsRequest', + 'list-roots-request.json', + { + 'id': 'list-roots-example', + 'method': 'roots/list', + }, + ); + _writeExample( + examplesDir, + 'InputRequests', + 'elicitation-and-sampling-input-requests.json', + { + 'github_login': { + 'method': 'elicitation/create', + 'params': { + 'mode': 'form', + 'message': 'Please provide your GitHub username', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + }, + 'required': ['name'], + }, + }, + }, + 'capital_of_france': { + 'method': 'sampling/createMessage', + 'params': { + 'messages': [ + { + 'role': 'user', + 'content': { + 'type': 'text', + 'text': 'What is the capital of France?', + }, + }, + ], + 'maxTokens': 100, + }, + }, + }, + ); + + final result = await _runAudit(examplesDir); + + expect(result.exitCode, 0, reason: _processOutput(result)); + expect(result.stdout, contains('examples=5 parsed=5 missing=0')); + }); + + test('fails when an upstream example group has no parser mapping', + () async { + _writeExample( + examplesDir, + 'FutureSpecThing', + 'future.json', + {'example': true}, + ); + + final result = await _runAudit(examplesDir); + + expect(result.exitCode, 1); + expect(result.stdout, contains('missing parser groups:')); + expect(result.stdout, contains('FutureSpecThing: 1')); + }); + + test('fails when a known example no longer matches the typed parser', + () async { + _writeExample( + examplesDir, + 'CallToolResult', + 'missing-content.json', + {'resultType': 'complete'}, + ); + + final result = await _runAudit(examplesDir); + + expect(result.exitCode, 1); + expect(result.stdout, contains('failures:')); + expect(result.stdout, contains('CallToolResult/missing-content.json')); + expect(result.stdout, contains('CallToolResult.content is required')); + }); + }); +} + +Future _runAudit(Directory examplesDir) { + return Process.run( + Platform.resolvedExecutable, + ['run', 'tool/spec_example_audit.dart', examplesDir.path], + workingDirectory: Directory.current.path, + ); +} + +void _writeExample( + Directory root, + String group, + String name, + Map json, +) { + final directory = Directory(p.join(root.path, group))..createSync(); + File(p.join(directory.path, name)).writeAsStringSync(jsonEncode(json)); +} + +String _processOutput(ProcessResult result) { + return 'stdout:\n${result.stdout}\nstderr:\n${result.stderr}'; +} diff --git a/test/tool_schema_test.dart b/test/tool_schema_test.dart index b3e42154..14664f13 100644 --- a/test/tool_schema_test.dart +++ b/test/tool_schema_test.dart @@ -3,7 +3,7 @@ import 'package:test/test.dart'; void main() { group('Tool parameter header annotations', () { - test('primitive schemas preserve x-mcp-header round-trip', () { + test('schema objects preserve x-mcp-header round-trip', () { final schema = JsonSchema.object( properties: { 'region': JsonSchema.string(mcpHeader: 'Region'), diff --git a/test/types/subscriptions_test.dart b/test/types/subscriptions_test.dart index 1004bbca..83f18972 100644 --- a/test/types/subscriptions_test.dart +++ b/test/types/subscriptions_test.dart @@ -57,7 +57,7 @@ void main() { const capabilities = ServerCapabilities( extensions: {mcpTasksExtensionId: {}}, tools: ServerCapabilitiesTools(listChanged: true), - resources: ServerCapabilitiesResources(), + resources: ServerCapabilitiesResources(subscribe: true), ); final acknowledged = requested.acknowledgedBy(capabilities); @@ -66,6 +66,18 @@ void main() { 'resourceSubscriptions': ['file:///project/config.json'], 'taskIds': ['task-1'], }); + + final withoutResourceSubscribe = requested.acknowledgedBy( + const ServerCapabilities( + extensions: {mcpTasksExtensionId: {}}, + tools: ServerCapabilitiesTools(listChanged: true), + resources: ServerCapabilitiesResources(), + ), + ); + expect(withoutResourceSubscribe.toJson(), { + 'toolsListChanged': true, + 'taskIds': ['task-1'], + }); }); test('acknowledgedBy omits task filters without task extension support', @@ -125,6 +137,30 @@ void main() { ), isTrue, ); + expect( + const SubscriptionFilter( + resourceSubscriptions: ['file:///project'], + ).allowsNotification( + JsonRpcResourceUpdatedNotification( + updatedParams: const ResourceUpdatedNotification( + uri: 'file:///project/config.json', + ), + ), + ), + isTrue, + ); + expect( + const SubscriptionFilter( + resourceSubscriptions: ['file:///project'], + ).allowsNotification( + JsonRpcResourceUpdatedNotification( + updatedParams: const ResourceUpdatedNotification( + uri: 'file:///project-other/config.json', + ), + ), + ), + isFalse, + ); expect( acknowledged.allowsNotification( JsonRpcResourceUpdatedNotification( @@ -268,6 +304,46 @@ void main() { } }); + test('experimental completion list changed validates wrapper directly', () { + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + final valid = JsonRpcCompletionListChangedNotification.fromJson({ + 'jsonrpc': '2.0', + 'method': Method.notificationsExperimentalCompletionsListChanged, + 'params': { + '_meta': {McpMetaKey.subscriptionId: 'sub-1'}, + }, + }); + expect(valid.meta?[McpMetaKey.subscriptionId], 'sub-1'); + + for (final json in [ + { + 'jsonrpc': '1.0', + 'method': Method.notificationsExperimentalCompletionsListChanged, + }, + { + 'jsonrpc': '2.0', + // ignore: deprecated_member_use + 'method': Method.notificationsCompletionsListChanged, + }, + { + 'jsonrpc': '2.0', + 'method': Method.notificationsExperimentalCompletionsListChanged, + 'result': {'ok': true}, + }, + { + 'jsonrpc': '2.0', + 'method': Method.notificationsExperimentalCompletionsListChanged, + 'error': {'code': -32600, 'message': 'Invalid request'}, + }, + ]) { + expect( + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + () => JsonRpcCompletionListChangedNotification.fromJson(json), + throwsA(isA()), + ); + } + }); + test('serializes and parses subscription acknowledgments', () { final notification = JsonRpcSubscriptionsAcknowledgedNotification( acknowledgedParams: const SubscriptionsAcknowledgedNotification( diff --git a/test/types_edge_cases_test.dart b/test/types_edge_cases_test.dart index 13e7aaf4..4ba7c40d 100644 --- a/test/types_edge_cases_test.dart +++ b/test/types_edge_cases_test.dart @@ -140,6 +140,30 @@ void main() { ); } }); + + test('JsonRpcError validates JSON-RPC envelope fields directly', () { + for (final json in [ + { + 'jsonrpc': '1.0', + 'error': {'code': -32600, 'message': 'Bad version'}, + }, + { + 'jsonrpc': '2.0', + 'method': 'unexpected/request', + 'error': {'code': -32600, 'message': 'Bad kind'}, + }, + { + 'jsonrpc': '2.0', + 'result': {'ok': true}, + 'error': {'code': -32603, 'message': 'Internal error'}, + }, + ]) { + expect( + () => JsonRpcError.fromJson(json), + throwsA(isA()), + ); + } + }); }); group('JsonRpcCancelledNotification Edge Cases', () { @@ -196,18 +220,29 @@ void main() { expect(json.containsKey('reason'), isFalse); }); - test('allows omitted requestId per notification wire schema', () { - final parsed = JsonRpcCancelledNotification.fromJson({ - 'jsonrpc': '2.0', - 'method': 'notifications/cancelled', - 'params': {'reason': 'Task cancellation uses tasks/cancel'}, - }); + test('rejects omitted requestId per cancellation semantics', () { + expect( + () => JsonRpcCancelledNotification.fromJson({ + 'jsonrpc': '2.0', + 'method': 'notifications/cancelled', + 'params': {'reason': 'Task cancellation uses tasks/cancel'}, + }), + throwsA( + isA() + .having((e) => e.message, 'message', contains('requestId')), + ), + ); - expect(parsed.cancelParams.requestId, isNull); - expect(parsed.cancelParams.reason, 'Task cancellation uses tasks/cancel'); - expect(parsed.toJson()['params'], { - 'reason': 'Task cancellation uses tasks/cancel', - }); + expect( + () => const CancelledNotificationParams( + requestId: null, + reason: 'missing request id', + ).toJson(), + throwsA( + isA() + .having((e) => e.message, 'message', contains('requestId')), + ), + ); }); test('rejects malformed requestId wire values', () { @@ -891,6 +926,72 @@ void main() { ); }); + test('rejects message envelopes mixing method with response fields', () { + for (final json in [ + { + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'unknown/request', + 'result': {'ok': true}, + }, + { + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'unknown/request', + 'error': {'code': -32600, 'message': 'Invalid request'}, + }, + ]) { + expect( + () => JsonRpcMessage.fromJson(json), + throwsA( + isA() + .having((e) => e.message, 'message', contains('method')) + .having((e) => e.message, 'message', contains('result')) + .having((e) => e.message, 'message', contains('error')), + ), + ); + } + }); + + test('typed parsers reject response fields directly', () { + for (final parse in [ + () => JsonRpcPingRequest.fromJson({ + 'jsonrpc': '2.0', + 'id': 1, + 'method': Method.ping, + 'result': {'ok': true}, + }), + () => JsonRpcPingRequest.fromJson({ + 'jsonrpc': '2.0', + 'id': 1, + 'method': Method.ping, + 'error': {'code': -32600, 'message': 'Invalid request'}, + }), + () => JsonRpcProgressNotification.fromJson({ + 'jsonrpc': '2.0', + 'method': Method.notificationsProgress, + 'params': {'progressToken': 'p1', 'progress': 1}, + 'result': {'ok': true}, + }), + () => JsonRpcProgressNotification.fromJson({ + 'jsonrpc': '2.0', + 'method': Method.notificationsProgress, + 'params': {'progressToken': 'p1', 'progress': 1}, + 'error': {'code': -32600, 'message': 'Invalid request'}, + }), + ]) { + expect( + parse, + throwsA( + isA() + .having((e) => e.message, 'message', contains('method')) + .having((e) => e.message, 'message', contains('result')) + .having((e) => e.message, 'message', contains('error')), + ), + ); + } + }); + test('handles error with omitted id', () { final json = { 'jsonrpc': '2.0', diff --git a/test/types_test.dart b/test/types_test.dart index 1051f831..e6c1f3bf 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -609,6 +609,24 @@ void main() { }); group('ToolExecution Tests', () { + test('Tool serialization preserves execution by default', () { + final json = const Tool( + name: 'task-tool', + inputSchema: JsonObject(), + execution: ToolExecution(taskSupport: 'optional'), + ).toJson(); + + expect(json['execution'], {'taskSupport': 'optional'}); + expect( + const Tool( + name: 'task-tool', + inputSchema: JsonObject(), + execution: ToolExecution(taskSupport: 'optional'), + ).toJson(omitExecution: true), + isNot(contains('execution')), + ); + }); + test('rejects invalid taskSupport while parsing wire JSON', () { expect( () => ToolExecution.fromJson({'taskSupport': 'sometimes'}), @@ -2171,23 +2189,11 @@ void main() { expect(restored.defaultValue, equals('medium')); }); - // Removed test for invalid type because JsonSchema might handle unknown types differently or throw different error. - // But testing for 'type': 'unknown' should usually fail or be generic. - // JsonSchema.fromJson throws format exception for valid types mismatch, but unknown? - // Let's testing unknown type throwing exception. test('JsonSchema factory throws on invalid type', () { - // Assuming implementation throws for completely unknown type if strictly typed? - // Currently JsonSchema.fromJson handles known types. Fallback? - // Let's assume it might throw or return generic. - // Based on previous code, I'll keep expectation if it throws. - final json = {'type': 'unknown'}; - try { - JsonSchema.fromJson(json); - // If it doesn't throw, we might need to adjust test expectation or implementation. - // For now, removing this specific assertion if behavior is undefined. - } catch (e) { - expect(e, isA()); - } + expect( + () => JsonSchema.fromJson({'type': 'unknown'}), + throwsA(isA()), + ); }); }); diff --git a/tool/spec_example_audit.dart b/tool/spec_example_audit.dart new file mode 100644 index 00000000..b55352dc --- /dev/null +++ b/tool/spec_example_audit.dart @@ -0,0 +1,287 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:mcp_dart/mcp_dart.dart'; + +typedef Parser = void Function(Map json); + +JsonRpcResponse _response(Map json) { + final message = JsonRpcMessage.fromJson(json); + if (message is! JsonRpcResponse) { + throw FormatException('Expected JsonRpcResponse, got $message'); + } + return message; +} + +void _parseErrorDataOrWrapper(Map json) { + if (json.containsKey('error')) { + JsonRpcError.fromJson(json); + return; + } + JsonRpcErrorData.fromJson(json); +} + +void _parseRequestLike(Map json) { + if (json.containsKey('jsonrpc')) { + JsonRpcMessage.fromJson(json); + return; + } + + final method = json['method']; + if (method is! String) { + throw const FormatException('Expected request-like method'); + } + + final params = json['params']; + final paramsJson = params is Map ? Map.from(params) : null; + + switch (method) { + case Method.elicitationCreate: + if (paramsJson == null) { + throw const FormatException('elicitation/create params are required'); + } + ElicitRequest.fromJson( + paramsJson, + protocolVersion: latestDraftProtocolVersion, + ); + return; + case Method.samplingCreateMessage: + if (paramsJson == null) { + throw const FormatException( + 'sampling/createMessage params are required', + ); + } + CreateMessageRequest.fromJson(paramsJson); + return; + case Method.rootsList: + if (paramsJson != null && paramsJson.isNotEmpty) { + throw const FormatException('roots/list input request has no params'); + } + return; + default: + throw FormatException('No request-like parser for method $method'); + } +} + +void _parseJsonRpc(Map json) { + JsonRpcMessage.fromJson(json); +} + +void _parseSchema(Map json) { + JsonSchema.fromJson(json); +} + +void _parseInputResponses(Map json) { + InputResponse.mapFromJson(json, 'InputResponses'); +} + +final Map _parsers = { + 'AudioContent': (json) => AudioContent.fromJson(json), + 'BlobResourceContents': (json) => ResourceContents.fromJson(json), + 'BooleanSchema': _parseSchema, + 'CallToolRequest': _parseJsonRpc, + 'CallToolRequestParams': (json) => CallToolRequest.fromJson(json), + 'CallToolResult': (json) => CallToolResult.fromJson(json), + 'CallToolResultResponse': (json) { + CallToolResult.fromJson(_response(json).result); + }, + 'CancelledNotification': _parseJsonRpc, + 'CancelledNotificationParams': (json) { + CancelledNotification.fromJson(json); + }, + 'ClientCapabilities': (json) => ClientCapabilities.fromJson(json), + 'CompleteRequest': _parseJsonRpc, + 'CompleteRequestParams': (json) => CompleteRequest.fromJson(json), + 'CompleteResult': (json) => CompleteResult.fromJson(json), + 'CompleteResultResponse': (json) { + CompleteResult.fromJson(_response(json).result); + }, + 'CreateMessageRequest': _parseRequestLike, + 'CreateMessageRequestParams': (json) { + CreateMessageRequest.fromJson(json); + }, + 'CreateMessageResult': (json) => CreateMessageResult.fromJson(json), + 'DiscoverRequest': _parseJsonRpc, + 'DiscoverResult': (json) => DiscoverResult.fromJson(json), + 'DiscoverResultResponse': (json) { + DiscoverResult.fromJson(_response(json).result); + }, + 'ElicitRequest': _parseRequestLike, + 'ElicitRequestFormParams': (json) { + ElicitRequest.fromJson( + json, + protocolVersion: latestDraftProtocolVersion, + ); + }, + 'ElicitRequestURLParams': (json) { + ElicitRequest.fromJson( + json, + protocolVersion: latestDraftProtocolVersion, + ); + }, + 'ElicitResult': (json) => ElicitResult.fromJson(json), + 'ElicitationCompleteNotification': _parseJsonRpc, + 'EmbeddedResource': (json) => EmbeddedResource.fromJson(json), + 'GetPromptRequest': _parseJsonRpc, + 'GetPromptRequestParams': (json) => GetPromptRequest.fromJson(json), + 'GetPromptResult': (json) => GetPromptResult.fromJson(json), + 'GetPromptResultResponse': (json) { + GetPromptResult.fromJson(_response(json).result); + }, + 'ImageContent': (json) => ImageContent.fromJson(json), + 'InputRequiredResult': (json) => InputRequiredResult.fromJson(json), + 'InputRequests': (json) { + InputRequest.mapFromJson(json, 'InputRequests'); + }, + 'InputResponses': _parseInputResponses, + 'InternalError': _parseErrorDataOrWrapper, + 'InvalidParamsError': _parseErrorDataOrWrapper, + 'InvalidRequestError': _parseErrorDataOrWrapper, + 'ListPromptsRequest': _parseJsonRpc, + 'ListPromptsResult': (json) => ListPromptsResult.fromJson(json), + 'ListPromptsResultResponse': (json) { + ListPromptsResult.fromJson(_response(json).result); + }, + 'ListResourceTemplatesRequest': _parseJsonRpc, + 'ListResourceTemplatesResult': (json) { + ListResourceTemplatesResult.fromJson(json); + }, + 'ListResourceTemplatesResultResponse': (json) { + ListResourceTemplatesResult.fromJson(_response(json).result); + }, + 'ListResourcesRequest': _parseJsonRpc, + 'ListResourcesResult': (json) => ListResourcesResult.fromJson(json), + 'ListResourcesResultResponse': (json) { + ListResourcesResult.fromJson(_response(json).result); + }, + 'ListRootsRequest': _parseRequestLike, + 'ListRootsResult': (json) => ListRootsResult.fromJson(json), + 'ListToolsRequest': _parseJsonRpc, + 'ListToolsResult': (json) => ListToolsResult.fromJson(json), + 'ListToolsResultResponse': (json) { + ListToolsResult.fromJson(_response(json).result); + }, + 'LoggingMessageNotification': _parseJsonRpc, + 'LoggingMessageNotificationParams': (json) { + LoggingMessageNotification.fromJson(json); + }, + 'MethodNotFoundError': _parseErrorDataOrWrapper, + 'MissingRequiredClientCapabilityError': _parseErrorDataOrWrapper, + 'ModelPreferences': (json) => ModelPreferences.fromJson(json), + 'NumberSchema': _parseSchema, + 'PaginatedRequestParams': (json) => ListToolsRequest.fromJson(json), + 'ParseError': _parseErrorDataOrWrapper, + 'ProgressNotification': _parseJsonRpc, + 'ProgressNotificationParams': (json) { + ProgressNotification.fromJson(json); + }, + 'PromptListChangedNotification': _parseJsonRpc, + 'ReadResourceRequest': _parseJsonRpc, + 'ReadResourceResult': (json) => ReadResourceResult.fromJson(json), + 'ReadResourceResultResponse': (json) { + ReadResourceResult.fromJson(_response(json).result); + }, + 'Resource': (json) => Resource.fromJson(json), + 'ResourceLink': (json) => ResourceLink.fromJson(json), + 'ResourceListChangedNotification': _parseJsonRpc, + 'ResourceUpdatedNotification': _parseJsonRpc, + 'ResourceUpdatedNotificationParams': (json) { + ResourceUpdatedNotification.fromJson(json); + }, + 'Root': (json) => Root.fromJson(json), + 'SamplingMessage': (json) => SamplingMessage.fromJson(json), + 'ServerCapabilities': (json) => ServerCapabilities.fromJson(json), + 'StringSchema': _parseSchema, + 'SubscriptionsAcknowledgedNotification': _parseJsonRpc, + 'SubscriptionsListenRequest': _parseJsonRpc, + 'TextContent': (json) => TextContent.fromJson(json), + 'TextResourceContents': (json) => ResourceContents.fromJson(json), + 'TitledMultiSelectEnumSchema': _parseSchema, + 'TitledSingleSelectEnumSchema': _parseSchema, + 'Tool': (json) => Tool.fromJson(json), + 'ToolListChangedNotification': _parseJsonRpc, + 'ToolResultContent': (json) => SamplingContent.fromJson(json), + 'ToolUseContent': (json) => SamplingContent.fromJson(json), + 'UnsupportedProtocolVersionError': _parseErrorDataOrWrapper, + 'UntitledMultiSelectEnumSchema': _parseSchema, + 'UntitledSingleSelectEnumSchema': _parseSchema, +}; + +void main(List args) { + if (args.length != 1) { + stderr.writeln( + 'usage: dart run tool/spec_example_audit.dart ', + ); + exitCode = 64; + return; + } + + final root = Directory(args.single); + if (!root.existsSync()) { + stderr.writeln('examples directory does not exist: ${root.path}'); + exitCode = 66; + return; + } + + final files = root + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.json')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + + final failures = []; + final missing = {}; + var parsed = 0; + + for (final file in files) { + final relative = file.path.substring(root.path.length + 1); + final group = relative.split(Platform.pathSeparator).first; + final parser = _parsers[group]; + if (parser == null) { + missing[group] = (missing[group] ?? 0) + 1; + continue; + } + + try { + final decoded = jsonDecode(file.readAsStringSync()); + if (decoded is! Map) { + throw FormatException( + 'Expected object root, got ${decoded.runtimeType}', + ); + } + parser(Map.from(decoded)); + parsed++; + } catch (error, stackTrace) { + failures.add( + '$relative\n' + ' $error\n' + ' ${stackTrace.toString().split('\n').first}', + ); + } + } + + stdout.writeln( + 'examples=${files.length} parsed=$parsed ' + 'missing=${missing.values.fold(0, (sum, count) => sum + count)}', + ); + + if (missing.isNotEmpty) { + stdout.writeln('missing parser groups:'); + for (final entry in missing.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key))) { + stdout.writeln(' ${entry.key}: ${entry.value}'); + } + } + + if (failures.isNotEmpty) { + stdout.writeln('failures:'); + for (final failure in failures) { + stdout.writeln(failure); + } + } + + if (missing.isNotEmpty || failures.isNotEmpty) { + exitCode = 1; + } +} From 9a57c75095930692d4d65075703bdd74fbb5bd3b Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Tue, 2 Jun 2026 21:44:16 -0400 Subject: [PATCH 35/42] Add official conformance CI coverage --- .github/workflows/test_core.yml | 27 + CHANGELOG.md | 3 + lib/src/client/client.dart | 22 +- lib/src/client/streamable_https.dart | 372 ++++++++- lib/src/server/mcp_server.dart | 4 +- lib/src/server/server.dart | 14 +- lib/src/server/streamable_https.dart | 135 +++- lib/src/server/streamable_mcp_server.dart | 39 +- lib/src/shared/json_schema/json_schema.dart | 45 ++ .../json_schema/json_schema_validator.dart | 4 + lib/src/shared/protocol.dart | 34 + lib/src/types/elicitation.dart | 11 +- lib/src/types/json_rpc.dart | 5 + .../client_elicitation_defaults_test.dart | 22 +- test/client/client_tool_validation_test.dart | 4 +- test/client/streamable_https_test.dart | 4 +- .../2026_rc_client_expected_failures.txt | 7 + .../conformance/2026_rc_expected_failures.txt | 5 + test/conformance/README.md | 74 ++ test/conformance/mcp_2025_server.dart | 657 +++++++++++++++ test/conformance/mcp_2026_rc_client.dart | 764 ++++++++++++++++++ test/conformance/mcp_2026_rc_server.dart | 410 ++++++++++ .../run_2025_server_conformance.dart | 265 ++++++ .../run_2026_rc_client_conformance.dart | 347 ++++++++ .../run_2026_rc_server_conformance.dart | 432 ++++++++++ test/elicitation_test.dart | 17 +- test/interop/test_dart_server.dart | 5 + test/mcp_2025_11_25_test.dart | 12 +- test/mcp_2026_07_28_test.dart | 105 ++- test/server/streamable_mcp_server_test.dart | 242 ++++++ test/shared/json_schema_from_json_test.dart | 48 ++ 31 files changed, 4037 insertions(+), 98 deletions(-) create mode 100644 test/conformance/2026_rc_client_expected_failures.txt create mode 100644 test/conformance/2026_rc_expected_failures.txt create mode 100644 test/conformance/README.md create mode 100644 test/conformance/mcp_2025_server.dart create mode 100644 test/conformance/mcp_2026_rc_client.dart create mode 100644 test/conformance/mcp_2026_rc_server.dart create mode 100644 test/conformance/run_2025_server_conformance.dart create mode 100644 test/conformance/run_2026_rc_client_conformance.dart create mode 100644 test/conformance/run_2026_rc_server_conformance.dart diff --git a/.github/workflows/test_core.yml b/.github/workflows/test_core.yml index 82b20df7..9728e764 100644 --- a/.github/workflows/test_core.yml +++ b/.github/workflows/test_core.yml @@ -43,6 +43,33 @@ jobs: working-directory: packages/mcp_dart_cli run: dart run bin/mcp_dart.dart conformance --suite all --json + - name: Run official MCP 2025 server conformance + run: > + dart run test/conformance/run_2025_server_conformance.dart + --timeout-seconds 90 + --output-dir .dart_tool/conformance/ci_2025_server + + - name: Run official MCP 2025 client conformance + run: > + npx -y @modelcontextprotocol/conformance@0.2.0-alpha.1 client + --command "dart run test/conformance/mcp_2026_rc_client.dart" + --suite all + --spec-version 2025-11-25 + --verbose + -o .dart_tool/conformance/ci_2025_client + + - name: Run official MCP 2026 RC server conformance + run: > + dart run test/conformance/run_2026_rc_server_conformance.dart + --timeout-seconds 90 + --output-dir .dart_tool/conformance/ci_2026_server + + - name: Run official MCP 2026 RC client conformance + run: > + dart run test/conformance/run_2026_rc_client_conformance.dart + --timeout-seconds 90 + --output-dir .dart_tool/conformance/ci_2026_client + - name: Run interop test suite run: dart test -t interop diff --git a/CHANGELOG.md b/CHANGELOG.md index b89b447e..906138be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -259,6 +259,9 @@ - Accepted numeric `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`, and `default` values on JSON Schema `integer` schemas, matching the stable and MCP 2026 schema definitions. +- Preserved object-level JSON Schema 2020-12 keywords on `JsonObject` + round-trips and added official MCP conformance gates for stable 2025 and + 2026 RC client/server coverage in core CI. ## 2.2.0 diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index c97329a6..eb4f79d2 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -68,6 +68,20 @@ class McpSubscription { } } +ElicitResult _withElicitationDefaults( + ElicitResult result, + JsonSchema schema, +) { + final content = _deepCopy(result.content ?? const {}) + as Map; + _applyElicitationDefaults(schema, content); + return ElicitResult( + action: result.action, + content: content, + meta: result.meta, + ); +} + // Recursively applies default values from a JSON Schema to a data object. void _applyElicitationDefaults(JsonSchema schema, Map data) { if (schema is! JsonObject) return; @@ -207,17 +221,16 @@ class McpClient extends Protocol { "No elicit handler registered", ); } - final result = await onElicitRequest!(request.elicitParams); + var result = await onElicitRequest!(request.elicitParams); // Apply defaults if client supports it and it's a form elicitation if (request.elicitParams.isFormMode && result.action == 'accept' && - result.content is Map && request.elicitParams.requestedSchema != null && _capabilities.elicitation?.form?.applyDefaults == true) { - _applyElicitationDefaults( + result = _withElicitationDefaults( + result, request.elicitParams.requestedSchema!, - result.content!, ); } return result; @@ -1618,6 +1631,7 @@ class McpClient extends Protocol { bool _isToolParameterHeaderPrimitive(JsonSchema schema) { return schema is JsonString || + schema is JsonNumber || schema is JsonInteger || schema is JsonBoolean; } diff --git a/lib/src/client/streamable_https.dart b/lib/src/client/streamable_https.dart index 044f3c97..cfc6ca13 100644 --- a/lib/src/client/streamable_https.dart +++ b/lib/src/client/streamable_https.dart @@ -150,6 +150,8 @@ class StreamableHttpClientTransport final StreamableHttpReconnectionOptions _reconnectionOptions; bool _isClosed = false; _PendingOAuthAuthorization? _pendingOAuthAuthorization; + final Map _oauthRegistrations = {}; + final Set _oauthRequestedScopes = {}; @override void Function()? onclose; @@ -251,9 +253,15 @@ class StreamableHttpClientTransport ); } - final scope = challenge?.scope ?? - (provider.scopes.isEmpty ? null : provider.scopes.join(' ')) ?? - protectedResourceMetadata.scopesSupported?.join(' '); + final clientRegistration = await _resolveOAuthClientRegistration( + provider, + authorizationServerMetadata, + ); + final scope = _authorizationScope( + challenge, + provider, + protectedResourceMetadata, + ); final codeVerifier = _generatePkceCodeVerifier(); final codeChallenge = _generatePkceS256Challenge(codeVerifier); final state = _generateOAuthState(); @@ -262,7 +270,7 @@ class StreamableHttpClientTransport queryParameters: { ...authorizationEndpoint.queryParameters, 'response_type': 'code', - 'client_id': provider.clientId, + 'client_id': clientRegistration.clientId, 'redirect_uri': provider.redirectUri.toString(), 'code_challenge': codeChallenge, 'code_challenge_method': 'S256', @@ -284,15 +292,183 @@ class StreamableHttpClientTransport _pendingOAuthAuthorization = _PendingOAuthAuthorization( tokenEndpoint: tokenEndpoint, codeVerifier: codeVerifier, - clientId: provider.clientId, - clientSecret: provider.clientSecret, + clientId: clientRegistration.clientId, + clientSecret: clientRegistration.clientSecret, + tokenEndpointAuthMethod: clientRegistration.tokenEndpointAuthMethod, redirectUri: provider.redirectUri, resource: protectedResourceMetadata.resource, + issuer: authorizationServerMetadata.issuer.toString(), + state: state, + scope: scope, + authorizationResponseIssParameterSupported: authorizationServerMetadata + .authorizationResponseIssParameterSupported, ); return authorizationRequest; } + String? _authorizationScope( + OAuthBearerChallengeParameters? challenge, + OAuthAuthorizationCodeProvider provider, + OAuthProtectedResourceMetadataDocument protectedResourceMetadata, + ) { + final requestedScopes = {..._oauthRequestedScopes}; + final challengedScopes = _splitOAuthScopes(challenge?.scope); + if (challengedScopes.isNotEmpty) { + requestedScopes.addAll(challengedScopes); + return requestedScopes.join(' '); + } + + if (provider.scopes.isNotEmpty) { + requestedScopes.addAll(provider.scopes); + return requestedScopes.join(' '); + } + + final supportedScopes = protectedResourceMetadata.scopesSupported; + if (supportedScopes != null && supportedScopes.isNotEmpty) { + requestedScopes.addAll(supportedScopes); + return requestedScopes.join(' '); + } + + return null; + } + + List _splitOAuthScopes(String? scope) { + if (scope == null || scope.trim().isEmpty) { + return const []; + } + return scope + .split(RegExp(r'\s+')) + .where((value) => value.isNotEmpty) + .toList(); + } + + Future<_OAuthClientRegistration> _resolveOAuthClientRegistration( + OAuthAuthorizationCodeProvider provider, + OAuthAuthorizationServerMetadataDocument authorizationServerMetadata, + ) async { + final issuerKey = authorizationServerMetadata.issuer.toString(); + if (authorizationServerMetadata.clientIdMetadataDocumentSupported == true && + _isAbsoluteHttpUri(provider.clientId)) { + return _OAuthClientRegistration( + clientId: provider.clientId, + clientSecret: provider.clientSecret, + tokenEndpointAuthMethod: _selectTokenEndpointAuthMethod( + authorizationServerMetadata, + provider.clientSecret, + ), + ); + } + + final registrationEndpoint = + authorizationServerMetadata.registrationEndpoint; + if (registrationEndpoint != null) { + final existingRegistration = _oauthRegistrations[issuerKey]; + if (existingRegistration != null) { + return existingRegistration; + } + + final registration = await _registerOAuthClient( + provider, + authorizationServerMetadata, + registrationEndpoint, + ); + _oauthRegistrations[issuerKey] = registration; + return registration; + } + + return _OAuthClientRegistration( + clientId: provider.clientId, + clientSecret: provider.clientSecret, + tokenEndpointAuthMethod: _selectTokenEndpointAuthMethod( + authorizationServerMetadata, + provider.clientSecret, + ), + ); + } + + bool _isAbsoluteHttpUri(String value) { + final uri = Uri.tryParse(value); + return uri != null && + (uri.scheme == 'http' || uri.scheme == 'https') && + uri.host.isNotEmpty; + } + + Future<_OAuthClientRegistration> _registerOAuthClient( + OAuthAuthorizationCodeProvider provider, + OAuthAuthorizationServerMetadataDocument authorizationServerMetadata, + Uri registrationEndpoint, + ) async { + final tokenEndpointAuthMethod = _selectTokenEndpointAuthMethod( + authorizationServerMetadata, + provider.clientSecret, + ); + final response = await _httpClient.post( + registrationEndpoint, + headers: const { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: jsonEncode({ + 'client_name': provider.clientId, + 'redirect_uris': [provider.redirectUri.toString()], + 'grant_types': ['authorization_code', 'refresh_token'], + 'response_types': ['code'], + 'application_type': 'native', + 'token_endpoint_auth_method': tokenEndpointAuthMethod, + }), + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw UnauthorizedError( + 'Dynamic client registration failed with HTTP ${response.statusCode}', + ); + } + + final json = jsonDecode(response.body); + if (json is! Map) { + throw UnauthorizedError('Client registration response must be an object'); + } + final clientId = json['client_id']; + if (clientId is! String || clientId.isEmpty) { + throw UnauthorizedError('Client registration did not include client_id'); + } + final clientSecret = json['client_secret']; + final registeredAuthMethod = json['token_endpoint_auth_method']; + return _OAuthClientRegistration( + clientId: clientId, + clientSecret: clientSecret is String ? clientSecret : null, + tokenEndpointAuthMethod: registeredAuthMethod is String + ? registeredAuthMethod + : tokenEndpointAuthMethod, + ); + } + + String _selectTokenEndpointAuthMethod( + OAuthAuthorizationServerMetadataDocument metadata, + String? clientSecret, + ) { + final supportedMethods = + metadata.tokenEndpointAuthMethodsSupported ?? const ['none']; + if (clientSecret != null) { + if (supportedMethods.contains('client_secret_basic')) { + return 'client_secret_basic'; + } + if (supportedMethods.contains('client_secret_post')) { + return 'client_secret_post'; + } + } + if (supportedMethods.contains('none')) { + return 'none'; + } + if (supportedMethods.contains('client_secret_basic')) { + return 'client_secret_basic'; + } + if (supportedMethods.contains('client_secret_post')) { + return 'client_secret_post'; + } + return supportedMethods.isEmpty ? 'none' : supportedMethods.first; + } + Future _discoverProtectedResourceMetadata( OAuthBearerChallengeParameters? challenge, @@ -360,7 +536,28 @@ class StreamableHttpClientTransport 'Protected-resource metadata must be a JSON object', ); } - return OAuthProtectedResourceMetadataDocument.fromJson(json); + final metadata = OAuthProtectedResourceMetadataDocument.fromJson(json); + if (!_isProtectedResourceForEndpoint(metadata.resource)) { + throw UnauthorizedError( + 'Protected-resource metadata resource does not match server URL', + ); + } + return metadata; + } + + bool _isProtectedResourceForEndpoint(Uri resource) { + if (resource.fragment.isNotEmpty) { + return false; + } + if (resource.scheme != _url.scheme || + resource.host != _url.host || + resource.port != _url.port) { + return false; + } + + final resourcePath = resource.path.isEmpty ? '/' : resource.path; + final endpointPath = _url.path.isEmpty ? '/' : _url.path; + return resourcePath == endpointPath || resourcePath == '/'; } Future @@ -368,7 +565,13 @@ class StreamableHttpClientTransport final errors = []; for (final uri in _authorizationServerMetadataCandidates(issuer)) { try { - return await _fetchAuthorizationServerMetadata(uri); + final metadata = await _fetchAuthorizationServerMetadata(uri); + if (metadata.issuer.toString() != issuer.toString()) { + throw UnauthorizedError( + 'Authorization-server metadata issuer does not match $issuer', + ); + } + return metadata; } catch (error) { errors.add(error); } @@ -438,22 +641,52 @@ class StreamableHttpClientTransport String authorizationCode, _PendingOAuthAuthorization pendingAuthorization, ) async { + final headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }; + final body = { + 'grant_type': 'authorization_code', + 'code': authorizationCode, + 'redirect_uri': pendingAuthorization.redirectUri.toString(), + 'client_id': pendingAuthorization.clientId, + 'code_verifier': pendingAuthorization.codeVerifier, + 'resource': pendingAuthorization.resource.toString(), + }; + switch (pendingAuthorization.tokenEndpointAuthMethod) { + case 'client_secret_basic': + final clientSecret = pendingAuthorization.clientSecret; + if (clientSecret == null) { + throw UnauthorizedError( + 'Token endpoint requires client_secret_basic but no secret is available', + ); + } + headers['Authorization'] = _basicAuthorizationHeader( + pendingAuthorization.clientId, + clientSecret, + ); + break; + case 'client_secret_post': + final clientSecret = pendingAuthorization.clientSecret; + if (clientSecret == null) { + throw UnauthorizedError( + 'Token endpoint requires client_secret_post but no secret is available', + ); + } + body['client_secret'] = clientSecret; + break; + case 'none': + break; + default: + if (pendingAuthorization.clientSecret != null) { + body['client_secret'] = pendingAuthorization.clientSecret!; + } + } + final response = await _httpClient.post( pendingAuthorization.tokenEndpoint, - headers: const { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - }, - body: { - 'grant_type': 'authorization_code', - 'code': authorizationCode, - 'redirect_uri': pendingAuthorization.redirectUri.toString(), - 'client_id': pendingAuthorization.clientId, - if (pendingAuthorization.clientSecret != null) - 'client_secret': pendingAuthorization.clientSecret!, - 'code_verifier': pendingAuthorization.codeVerifier, - 'resource': pendingAuthorization.resource.toString(), - }, + headers: headers, + body: body, ); if (response.statusCode != 200) { throw UnauthorizedError( @@ -477,10 +710,17 @@ class StreamableHttpClientTransport expiresIn: _parseExpiresIn(json['expires_in']), scope: json['scope'] as String?, ); + _oauthRequestedScopes.addAll(_splitOAuthScopes(pendingAuthorization.scope)); + _oauthRequestedScopes.addAll(_splitOAuthScopes(tokens.scope)); await provider.saveTokens(tokens); return tokens; } + String _basicAuthorizationHeader(String clientId, String clientSecret) { + final credentials = base64Encode(utf8.encode('$clientId:$clientSecret')); + return 'Basic $credentials'; + } + int? _parseExpiresIn(Object? value) { if (value == null) { return null; @@ -649,6 +889,12 @@ class StreamableHttpClientTransport } return value.toString(); } + if (value is double) { + if (!value.isFinite) { + return null; + } + return value.toString(); + } return switch (value) { String() => value, @@ -1120,7 +1366,11 @@ class StreamableHttpClientTransport /// Call this method after the user has finished authorizing via their user agent and is redirected /// back to the MCP client application. This will exchange the authorization code for an access token, /// enabling the next connection attempt to successfully auth. - Future finishAuth(String authorizationCode) async { + Future finishAuth( + String authorizationCode, { + String? state, + String? issuer, + }) async { if (_authProvider == null) { throw UnauthorizedError("No auth provider"); } @@ -1129,6 +1379,11 @@ class StreamableHttpClientTransport final pendingAuthorization = _pendingOAuthAuthorization; if (authProvider is OAuthAuthorizationCodeProvider && pendingAuthorization != null) { + _validateOAuthAuthorizationRedirect( + pendingAuthorization, + state: state, + issuer: issuer, + ); await _exchangeAuthorizationCode( authProvider, authorizationCode, @@ -1148,6 +1403,30 @@ class StreamableHttpClientTransport } } + void _validateOAuthAuthorizationRedirect( + _PendingOAuthAuthorization pendingAuthorization, { + String? state, + String? issuer, + }) { + if (state != null && state != pendingAuthorization.state) { + throw UnauthorizedError('Authorization redirect state mismatch'); + } + + if (pendingAuthorization.authorizationResponseIssParameterSupported == + true && + (issuer == null || issuer.isEmpty)) { + throw UnauthorizedError( + 'Authorization response did not include required iss parameter', + ); + } + + if (issuer != null && issuer != pendingAuthorization.issuer) { + throw UnauthorizedError( + 'Authorization response issuer does not match authorization server', + ); + } + } + @override Future close() async { _isClosed = true; @@ -1344,7 +1623,8 @@ class StreamableHttpClientTransport response, StartSseOptions( onResumptionToken: onResumptionToken, - shouldReconnect: false, // Do not reconnect for POST responses + replayMessageId: message.id, + shouldReconnect: !isStatelessRequest, rejectServerRequests: isStatelessRequest, ), ); @@ -1662,14 +1942,22 @@ class OAuthAuthorizationServerMetadataDocument { final Uri issuer; final Uri? authorizationEndpoint; final Uri? tokenEndpoint; + final Uri? registrationEndpoint; final List? codeChallengeMethodsSupported; + final List? tokenEndpointAuthMethodsSupported; + final bool? clientIdMetadataDocumentSupported; + final bool? authorizationResponseIssParameterSupported; final Map additionalFields; const OAuthAuthorizationServerMetadataDocument({ required this.issuer, this.authorizationEndpoint, this.tokenEndpoint, + this.registrationEndpoint, this.codeChallengeMethodsSupported, + this.tokenEndpointAuthMethodsSupported, + this.clientIdMetadataDocumentSupported, + this.authorizationResponseIssParameterSupported, this.additionalFields = const {}, }); @@ -1691,15 +1979,29 @@ class OAuthAuthorizationServerMetadataDocument { ? Uri.parse(authorizationEndpoint) : null, tokenEndpoint: tokenEndpoint is String ? Uri.parse(tokenEndpoint) : null, + registrationEndpoint: json['registration_endpoint'] is String + ? Uri.parse(json['registration_endpoint'] as String) + : null, codeChallengeMethodsSupported: (json['code_challenge_methods_supported'] as List?)?.cast(), + tokenEndpointAuthMethodsSupported: + (json['token_endpoint_auth_methods_supported'] as List?) + ?.cast(), + clientIdMetadataDocumentSupported: + json['client_id_metadata_document_supported'] as bool?, + authorizationResponseIssParameterSupported: + json['authorization_response_iss_parameter_supported'] as bool?, additionalFields: Map.from(json) ..removeWhere( (key, value) => { 'issuer', 'authorization_endpoint', 'token_endpoint', + 'registration_endpoint', 'code_challenge_methods_supported', + 'token_endpoint_auth_methods_supported', + 'client_id_metadata_document_supported', + 'authorization_response_iss_parameter_supported', }.contains(key), ), ); @@ -1760,16 +2062,38 @@ class _PendingOAuthAuthorization { final String codeVerifier; final String clientId; final String? clientSecret; + final String tokenEndpointAuthMethod; final Uri redirectUri; final Uri resource; + final String issuer; + final String state; + final String? scope; + final bool? authorizationResponseIssParameterSupported; const _PendingOAuthAuthorization({ required this.tokenEndpoint, required this.codeVerifier, required this.clientId, required this.clientSecret, + required this.tokenEndpointAuthMethod, required this.redirectUri, required this.resource, + required this.issuer, + required this.state, + required this.scope, + required this.authorizationResponseIssParameterSupported, + }); +} + +class _OAuthClientRegistration { + final String clientId; + final String? clientSecret; + final String tokenEndpointAuthMethod; + + const _OAuthClientRegistration({ + required this.clientId, + required this.clientSecret, + required this.tokenEndpointAuthMethod, }); } diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index ceec896f..302c6ff5 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -143,13 +143,13 @@ class CompletableField { } /// Function signature for a tool implementation. -typedef ToolFunction = FutureOr Function( +typedef ToolFunction = FutureOr Function( Map args, RequestHandlerExtra extra, ); /// Legacy callback signature for tools (deprecated style). -typedef LegacyToolCallback = FutureOr Function({ +typedef LegacyToolCallback = FutureOr Function({ Map? args, RequestHandlerExtra? extra, }); diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 543222fc..b44fa6de 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -172,7 +172,7 @@ class Server extends Protocol { json_rpc.validateRequestMeta(meta, validateKeys: true); } on FormatException catch (error) { return McpError( - ErrorCode.invalidRequest.value, + ErrorCode.invalidParams.value, 'Invalid stateless request metadata.', error.message, ); @@ -181,7 +181,7 @@ class Server extends Protocol { final requestedVersion = meta?[McpMetaKey.protocolVersion]; if (requestedVersion is! String || requestedVersion.isEmpty) { return McpError( - ErrorCode.invalidRequest.value, + ErrorCode.invalidParams.value, 'Missing required request metadata: ${McpMetaKey.protocolVersion}', ); } @@ -190,7 +190,7 @@ class Server extends Protocol { } if (!isStatelessProtocolVersion(requestedVersion)) { return McpError( - ErrorCode.invalidRequest.value, + ErrorCode.invalidParams.value, 'server/discover and stateless requests require a stateless protocol version.', ); } @@ -198,7 +198,7 @@ class Server extends Protocol { final clientInfo = meta?[McpMetaKey.clientInfo]; if (clientInfo is! Map) { return McpError( - ErrorCode.invalidRequest.value, + ErrorCode.invalidParams.value, 'Missing required request metadata: ${McpMetaKey.clientInfo}', ); } @@ -206,7 +206,7 @@ class Server extends Protocol { final clientCapabilities = meta?[McpMetaKey.clientCapabilities]; if (clientCapabilities is! Map) { return McpError( - ErrorCode.invalidRequest.value, + ErrorCode.invalidParams.value, 'Missing required request metadata: ${McpMetaKey.clientCapabilities}', ); } @@ -216,7 +216,7 @@ class Server extends Protocol { ClientCapabilities.fromJson(clientCapabilities.cast()); } catch (error) { return McpError( - ErrorCode.invalidRequest.value, + ErrorCode.invalidParams.value, 'Invalid stateless request metadata.', error.toString(), ); @@ -225,7 +225,7 @@ class Server extends Protocol { final logLevel = meta?[McpMetaKey.logLevel]; if (logLevel != null && _parseLoggingLevel(logLevel) == null) { return McpError( - ErrorCode.invalidRequest.value, + ErrorCode.invalidParams.value, 'Invalid stateless request metadata: ${McpMetaKey.logLevel}', ); } diff --git a/lib/src/server/streamable_https.dart b/lib/src/server/streamable_https.dart index 101f497a..48a2c67b 100644 --- a/lib/src/server/streamable_https.dart +++ b/lib/src/server/streamable_https.dart @@ -5,6 +5,7 @@ import 'dart:typed_data'; import 'package:mcp_dart/src/shared/mcp_header_validation.dart'; import 'package:mcp_dart/src/shared/uuid.dart'; +import 'package:mcp_dart/src/types/json_rpc.dart' as json_rpc; import '../shared/transport.dart'; import '../types.dart'; @@ -161,6 +162,14 @@ class StreamableHTTPServerTransport RequestIdAwareTransport, IncomingRequestValidationAwareTransport, ToolParameterHeaderAwareTransport { + static const Set _statelessRemovedRequestMethods = { + Method.initialize, + Method.ping, + Method.loggingSetLevel, + Method.resourcesSubscribe, + Method.resourcesUnsubscribe, + }; + // when sessionId is not set (null), it means the transport is in stateless mode final String? Function()? _sessionIdGenerator; bool _started = false; @@ -266,7 +275,11 @@ class StreamableHTTPServerTransport return; } - if (!await _validateProtocolVersionHeader(req, req.response)) { + if (!await _validateProtocolVersionHeader( + req, + req.response, + parsedBody: parsedBody, + )) { return; } @@ -285,8 +298,9 @@ class StreamableHTTPServerTransport Future _validateProtocolVersionHeader( HttpRequest req, - HttpResponse res, - ) async { + HttpResponse res, { + dynamic parsedBody, + }) async { if (!_strictProtocolVersionHeaderValidation) { return true; } @@ -306,6 +320,7 @@ class StreamableHTTPServerTransport httpStatus: HttpStatus.badRequest, errorCode: ErrorCode.unsupportedProtocolVersion, message: 'Unsupported protocol version', + id: _requestIdFromParsedBody(parsedBody), data: { 'requested': requestedVersion, 'supported': supportedProtocolVersionsWithDraft, @@ -449,6 +464,65 @@ class StreamableHTTPServerTransport return null; } + RequestId? _requestIdFromParsedBody(dynamic parsedBody) { + if (parsedBody is! Map || !parsedBody.containsKey('id')) { + return null; + } + + try { + return json_rpc.parseRequestId(parsedBody['id']); + } catch (_) { + return null; + } + } + + RequestId? _rawRequestId(Map messageJson) { + if (!messageJson.containsKey('id')) { + return null; + } + try { + return json_rpc.parseRequestId(messageJson['id']); + } catch (_) { + return null; + } + } + + bool _isStatelessServerDiscoverJson( + HttpRequest req, + Map messageJson, + ) { + if (messageJson['method'] != Method.serverDiscover) { + return false; + } + + return _isStatelessRequestJson(req, messageJson); + } + + bool _isStatelessRemovedRequestJson( + HttpRequest req, + Map messageJson, + ) { + final method = messageJson['method']; + return method is String && + _statelessRemovedRequestMethods.contains(method) && + messageJson.containsKey('id') && + _isStatelessRequestJson(req, messageJson); + } + + bool _isStatelessRequestJson( + HttpRequest req, + Map messageJson, + ) { + final headerVersion = req.headers.value('mcp-protocol-version')?.trim(); + if (headerVersion != null && isStatelessProtocolVersion(headerVersion)) { + return true; + } + + final metadataVersion = _nestedMetadataProtocolVersion(messageJson); + return metadataVersion != null && + isStatelessProtocolVersion(metadataVersion); + } + bool _usesStatelessHttpValidation( HttpRequest req, List messages, @@ -799,14 +873,15 @@ class StreamableHTTPServerTransport final metadataVersion = _nestedMetadataProtocolVersion(messageJson); if (metadataVersion == null) { - await _writeHeaderMismatchResponse( - req.response, - message, - 'MCP-Protocol-Version header has no matching request _meta protocol version in params._meta', - ); - return false; - } - if (protocolHeader != metadataVersion) { + if (message is! JsonRpcServerDiscoverRequest) { + await _writeHeaderMismatchResponse( + req.response, + message, + 'MCP-Protocol-Version header has no matching request _meta protocol version in params._meta', + ); + return false; + } + } else if (protocolHeader != metadataVersion) { await _writeHeaderMismatchResponse( req.response, message, @@ -1369,9 +1444,37 @@ class StreamableHTTPServerTransport final messageJson = rawItem is Map ? rawItem : rawItem.cast(); + if (_isStatelessRemovedRequestJson(req, messageJson)) { + final method = messageJson['method'] as String; + await _writeJsonRpcErrorResponse( + req.response, + httpStatus: HttpStatus.notFound, + errorCode: ErrorCode.methodNotFound, + id: _rawRequestId(messageJson), + message: + '$method is not part of MCP stateless protocol versions.', + ); + return; + } messageJsons.add(messageJson); messages.add(JsonRpcMessage.fromJson(messageJson)); } catch (e) { + final messageJson = rawItem is Map + ? rawItem + : rawItem.cast(); + if (_isStatelessServerDiscoverJson(req, messageJson)) { + await _writeJsonRpcErrorResponse( + req.response, + httpStatus: HttpStatus.badRequest, + errorCode: ErrorCode.invalidParams, + id: _rawRequestId(messageJson), + message: 'Invalid params', + data: e.toString(), + ); + onerror?.call(e is Error ? e : StateError(e.toString())); + return; + } + await _writeJsonRpcErrorResponse( req.response, httpStatus: HttpStatus.badRequest, @@ -1827,7 +1930,7 @@ class StreamableHTTPServerTransport } } - if (_isJsonRpcResponse(message)) { + if (_isJsonRpcResponse(message) || _isJsonRpcError(message)) { if (!_requestToStreamMapping.containsKey(requestId)) { return; } @@ -1866,6 +1969,14 @@ class StreamableHTTPServerTransport final responses = relatedIds.map((id) => _requestResponseMap[id]!).toList(); + if (isStatelessResponse && + responses.length == 1 && + responses.single is JsonRpcError) { + final error = responses.single as JsonRpcError; + response!.statusCode = + _statelessHttpStatusForErrorCode(error.error.code); + } + headers.forEach((key, value) { response!.headers.set(key, value); }); diff --git a/lib/src/server/streamable_mcp_server.dart b/lib/src/server/streamable_mcp_server.dart index d0d711b1..fee32dd4 100644 --- a/lib/src/server/streamable_mcp_server.dart +++ b/lib/src/server/streamable_mcp_server.dart @@ -305,6 +305,10 @@ class StreamableMcpServer { /// If true, reject JSON-RPC batch payloads for Streamable HTTP POST requests. final bool rejectBatchJsonRpcPayloads; + /// If true, return JSON responses instead of SSE streams for request/response + /// interactions. + final bool enableJsonResponse; + final Set _defaultDnsRebindingAllowedHosts; HttpServer? _httpServer; @@ -326,6 +330,7 @@ class StreamableMcpServer { this.allowedOrigins, this.strictProtocolVersionHeaderValidation = true, this.rejectBatchJsonRpcPayloads = true, + this.enableJsonResponse = false, }) : _serverFactory = serverFactory, _defaultDnsRebindingAllowedHosts = { normalizeDnsHost(host), @@ -439,7 +444,7 @@ class StreamableMcpServer { try { if (request.method == 'POST') { await _handlePostRequest(request); - } else if (_isStatelessProtocolVersionRequest(request)) { + } else if (_requiresStatelessTransport(request)) { await _createStatelessTransport().handleRequest(request); } else if (request.method == 'GET') { await _handleGetRequest(request); @@ -486,7 +491,7 @@ class StreamableMcpServer { } catch (e) { if (sessionId != null && !_transports.containsKey(sessionId) && - !_isStatelessProtocolVersionRequest(request)) { + !_requiresStatelessTransport(request)) { await _respondWithJsonRpcError( request.response, httpStatus: HttpStatus.notFound, @@ -571,7 +576,7 @@ class StreamableMcpServer { } Future _handleGetRequest(HttpRequest request) async { - if (_isStatelessProtocolVersionRequest(request)) { + if (_requiresStatelessTransport(request)) { await _createStatelessTransport().handleRequest(request); return; } @@ -597,7 +602,7 @@ class StreamableMcpServer { } Future _handleDeleteRequest(HttpRequest request) async { - if (_isStatelessProtocolVersionRequest(request)) { + if (_requiresStatelessTransport(request)) { await _createStatelessTransport().handleRequest(request); return; } @@ -632,6 +637,7 @@ class StreamableMcpServer { enableDnsRebindingProtection: enableDnsRebindingProtection, allowedHosts: allowedHosts ?? {host}, allowedOrigins: allowedOrigins, + enableJsonResponse: enableJsonResponse, strictProtocolVersionHeaderValidation: strictProtocolVersionHeaderValidation, rejectBatchJsonRpcPayloads: rejectBatchJsonRpcPayloads, @@ -682,6 +688,7 @@ class StreamableMcpServer { enableDnsRebindingProtection: enableDnsRebindingProtection, allowedHosts: allowedHosts ?? {host}, allowedOrigins: allowedOrigins, + enableJsonResponse: enableJsonResponse, strictProtocolVersionHeaderValidation: strictProtocolVersionHeaderValidation, rejectBatchJsonRpcPayloads: rejectBatchJsonRpcPayloads, @@ -689,24 +696,36 @@ class StreamableMcpServer { ); } - bool _isStatelessProtocolVersionRequest(HttpRequest request) { + bool _requiresStatelessTransport(HttpRequest request) { final versionHeader = request.headers.value('mcp-protocol-version'); - return versionHeader != null && - isStatelessProtocolVersion(versionHeader.trim()); + if (versionHeader == null || versionHeader.trim().isEmpty) { + return false; + } + + final version = versionHeader.trim(); + return isStatelessProtocolVersion(version) || + strictProtocolVersionHeaderValidation && + !supportedProtocolVersionsWithDraft.contains(version); } bool _isStatelessRequest(HttpRequest request, dynamic body) { - if (_isStatelessProtocolVersionRequest(request)) { + if (_requiresStatelessTransport(request)) { return true; } if (body is Map) { final version = _bodyProtocolVersion(body); - return version != null && isStatelessProtocolVersion(version); + return version != null && + (isStatelessProtocolVersion(version) || + strictProtocolVersionHeaderValidation && + !supportedProtocolVersionsWithDraft.contains(version)); } if (body is List) { return body.whereType>().any((item) { final version = _bodyProtocolVersion(item); - return version != null && isStatelessProtocolVersion(version); + return version != null && + (isStatelessProtocolVersion(version) || + strictProtocolVersionHeaderValidation && + !supportedProtocolVersionsWithDraft.contains(version)); }); } return false; diff --git a/lib/src/shared/json_schema/json_schema.dart b/lib/src/shared/json_schema/json_schema.dart index 731b7825..29515e59 100644 --- a/lib/src/shared/json_schema/json_schema.dart +++ b/lib/src/shared/json_schema/json_schema.dart @@ -66,6 +66,10 @@ sealed class JsonSchema { return conjunctiveSchema; } + if (type == 'object') { + return JsonObject.fromJson(json); + } + if (json.containsKey('const')) { return JsonConst.fromJson(json); } @@ -117,6 +121,12 @@ sealed class JsonSchema { return null; } + if (json['type'] == 'object' && + primaryKeys.length == 1 && + _jsonSchemaCompositionKeys.contains(primaryKeys.single)) { + return null; + } + final siblingKeys = json.keys .where( (key) => @@ -206,6 +216,13 @@ sealed class JsonSchema { 'object', }; + static const Set _jsonSchemaCompositionKeys = { + 'allOf', + 'anyOf', + 'oneOf', + 'not', + }; + static bool _hasMcpHeaderOnNonPrimitiveSchema(Map json) { if (!json.containsKey('x-mcp-header')) { return false; @@ -923,11 +940,18 @@ class JsonObject extends JsonSchema { final Object? additionalProperties; final Map>? dependentRequired; + /// Object-level JSON Schema keywords not modeled by the typed convenience API. + /// + /// This preserves wire-level schema keywords such as `$schema`, `$defs`, + /// `allOf`, `if`, `then`, and `else` during parse/serialize round-trips. + final Map? extra; + const JsonObject({ this.properties, this.required, this.additionalProperties, this.dependentRequired, + this.extra, this.defaultValue, super.title, super.description, @@ -938,6 +962,7 @@ class JsonObject extends JsonSchema { this.required, this.additionalProperties, this.dependentRequired, + this.extra, this.defaultValue, super.title, super.description, @@ -967,6 +992,7 @@ class JsonObject extends JsonSchema { additionalProperties: parsedAdditionalProps, dependentRequired: (json['dependentRequired'] as Map?) ?.map((key, value) => MapEntry(key, (value as List).cast())), + extra: _jsonObjectExtra(json), title: json['title'] as String?, description: json['description'] as String?, defaultValue: json['default'] as Map?, @@ -989,10 +1015,28 @@ class JsonObject extends JsonSchema { ? (additionalProperties as JsonSchema).toJson() : additionalProperties, if (dependentRequired != null) 'dependentRequired': dependentRequired, + ...?extra, }; } } +Map? _jsonObjectExtra(Map json) { + final extra = Map.from(json) + ..removeWhere(_isKnownJsonObjectKey); + return extra.isEmpty ? null : Map.unmodifiable(extra); +} + +bool _isKnownJsonObjectKey(String key, dynamic value) { + return key == 'title' || + key == 'description' || + key == 'default' || + key == 'type' || + key == 'properties' || + key == 'required' || + key == 'additionalProperties' || + key == 'dependentRequired'; +} + /// A schema that accepts any value, potentially with additional constraints not captured by other types. class JsonAny extends JsonSchema { final Map properties; @@ -1218,6 +1262,7 @@ class JsonUnion extends JsonSchema { required: null, additionalProperties: null, dependentRequired: null, + extra: null, ) => 'object', _ => null, diff --git a/lib/src/shared/json_schema/json_schema_validator.dart b/lib/src/shared/json_schema/json_schema_validator.dart index 8264cc04..b3bbf745 100644 --- a/lib/src/shared/json_schema/json_schema_validator.dart +++ b/lib/src/shared/json_schema/json_schema_validator.dart @@ -317,6 +317,10 @@ extension JsonSchemaValidation on JsonSchema { _validate(apSchema, data[key], [...path, key]); } } + + if (schema.extra != null) { + _validateCompositionKeywords(schema.extra!, data, path); + } } void _validateEnum(JsonEnum schema, dynamic data, List path) { diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index d4717c86..0f8eb67d 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -128,6 +128,12 @@ class RequestHandlerExtra { /// Metadata from the original request. final Map? meta; + /// Client responses to MRTR input requests when retrying this request. + final InputResponses? inputResponses; + + /// Opaque MRTR state returned by the server and echoed by the client on retry. + final String? requestState; + /// MCP protocol version from the request metadata, when present. String? get protocolVersion { final value = meta?[McpMetaKey.protocolVersion]; @@ -194,6 +200,8 @@ class RequestHandlerExtra { this.sessionId, required this.requestId, this.meta, + this.inputResponses, + this.requestState, this.authInfo, this.requestInfo, this.taskId, @@ -533,6 +541,28 @@ abstract class Protocol { ) => isRecognizedResultType(resultType); + InputResponses? _inputResponsesFromRequest(JsonRpcRequest request) { + return switch (request) { + final JsonRpcCallToolRequest request => request.callParams.inputResponses, + final JsonRpcGetPromptRequest request => request.getParams.inputResponses, + final JsonRpcReadResourceRequest request => + request.readParams.inputResponses, + final JsonRpcUpdateTaskRequest request => + request.updateParams.inputResponses, + _ => null, + }; + } + + String? _requestStateFromRequest(JsonRpcRequest request) { + return switch (request) { + final JsonRpcCallToolRequest request => request.callParams.requestState, + final JsonRpcGetPromptRequest request => request.getParams.requestState, + final JsonRpcReadResourceRequest request => + request.readParams.requestState, + _ => null, + }; + } + bool _usesStatelessResultTypes(JsonRpcRequest request) { final requestProtocolVersion = request.meta?[McpMetaKey.protocolVersion]; if (requestProtocolVersion is String && @@ -1260,6 +1290,8 @@ abstract class Protocol { sessionId: _transport?.sessionId, requestId: request.id, meta: request.meta, + inputResponses: _inputResponsesFromRequest(request), + requestState: _requestStateFromRequest(request), sendNotification: (notification, {relatedTask}) { return _notificationWithRequestId( notification, @@ -1486,6 +1518,8 @@ abstract class Protocol { sessionId: requestSessionId, requestId: request.id, meta: request.meta, + inputResponses: _inputResponsesFromRequest(request), + requestState: _requestStateFromRequest(request), taskId: relatedTaskId, taskStore: _taskStore != null ? _RequestTaskStoreImpl( diff --git a/lib/src/types/elicitation.dart b/lib/src/types/elicitation.dart index d59e14ee..601e794e 100644 --- a/lib/src/types/elicitation.dart +++ b/lib/src/types/elicitation.dart @@ -818,10 +818,9 @@ Map? _normalizeElicitResultContent( normalized[entry.key] = value; continue; } - if (value is double && - value.isFinite && - value == value.truncateToDouble()) { - normalized[entry.key] = value.toInt(); + if (value is double && value.isFinite) { + normalized[entry.key] = + value == value.truncateToDouble() ? value.toInt() : value; continue; } if (value is List && value.every((item) => item is String)) { @@ -830,13 +829,13 @@ Map? _normalizeElicitResultContent( } if (formatException) { throw FormatException( - 'ElicitResult.content.${entry.key} must be string, integer, boolean, or string[]', + 'ElicitResult.content.${entry.key} must be string, number, boolean, or string[]', ); } throw ArgumentError.value( value, 'content.${entry.key}', - 'ElicitResult content values must be string, integer, boolean, or string[]', + 'ElicitResult content values must be string, number, boolean, or string[]', ); } return normalized; diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index fe4c3b11..50dc659d 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -15,6 +15,9 @@ import 'validation.dart'; /// The draft/RC MCP protocol version being prepared for the next major release. const draftProtocolVersion2026_07_28 = "2026-07-28"; +/// Upstream conformance-suite alias for the in-progress 2026 draft. +const draftProtocolVersion2026V1 = "DRAFT-2026-v1"; + /// The latest stable version of the Model Context Protocol supported. const stableProtocolVersion2025_11_25 = "2025-11-25"; @@ -36,12 +39,14 @@ const supportedProtocolVersions = [ /// Protocol versions supported by the 2026 RC development branch. const supportedProtocolVersionsWithDraft = [ latestDraftProtocolVersion, + draftProtocolVersion2026V1, ...supportedProtocolVersions, ]; /// Protocol versions that use per-request metadata instead of initialization. const statelessProtocolVersions = [ draftProtocolVersion2026_07_28, + draftProtocolVersion2026V1, ]; /// Returns true when [version] uses the 2026 stateless request model. diff --git a/test/client/client_elicitation_defaults_test.dart b/test/client/client_elicitation_defaults_test.dart index 29b0cf0e..5a7a30ae 100644 --- a/test/client/client_elicitation_defaults_test.dart +++ b/test/client/client_elicitation_defaults_test.dart @@ -56,6 +56,11 @@ class MockTransport extends Transport { Future start() async {} } +Map _lastElicitContent(MockTransport transport) { + final response = transport.sentMessages.whereType().last; + return response.result['content'] as Map; +} + void main() { group('Client - Elicitation Defaults', () { late Client client; @@ -111,11 +116,15 @@ void main() { const Duration(milliseconds: 10), ); // Allow microtasks to run - // Verify that defaults were applied to the `receivedContent` + // Verify that defaults were applied to the submitted response without + // mutating the callback-owned map. expect(receivedContent, isNotNull); - expect(receivedContent!['name'], equals('John Doe')); - expect(receivedContent!['age'], equals(30)); - expect(receivedContent!['addressStreet'], equals('Main St')); + expect(receivedContent, isEmpty); + + final submittedContent = _lastElicitContent(transport); + expect(submittedContent['name'], equals('John Doe')); + expect(submittedContent['age'], equals(30)); + expect(submittedContent['addressStreet'], equals('Main St')); }); test('does not override existing values with defaults', () async { @@ -151,7 +160,10 @@ void main() { receivedContent!['name'], equals('Jane Smith'), ); // Should retain existing - expect(receivedContent!['age'], equals(30)); // Default should be applied + + final submittedContent = _lastElicitContent(transport); + expect(submittedContent['name'], equals('Jane Smith')); + expect(submittedContent['age'], equals(30)); }); test('does not apply defaults if applyDefaults is false', () async { diff --git a/test/client/client_tool_validation_test.dart b/test/client/client_tool_validation_test.dart index e377c424..401a11b5 100644 --- a/test/client/client_tool_validation_test.dart +++ b/test/client/client_tool_validation_test.dart @@ -262,6 +262,7 @@ void main() { expect(result.tools.map((tool) => tool.name), [ 'valid_headers', + 'number_header', ]); expect(transport.toolParameterHeaderMappings, { 'valid_headers': { @@ -271,10 +272,11 @@ void main() { 'count': 'Count', '/auth/tenant': 'Tenant', }, + 'number_header': {'ratio': 'Ratio'}, }); expect( warnings.where((message) => message.contains('Rejecting tool')), - hasLength(6), + hasLength(5), ); }); diff --git a/test/client/streamable_https_test.dart b/test/client/streamable_https_test.dart index 133e7399..1a1ce4b4 100644 --- a/test/client/streamable_https_test.dart +++ b/test/client/streamable_https_test.dart @@ -1495,9 +1495,9 @@ void main() { '=?base64?${base64Encode(utf8.encode('Hello, 世界'))}?=', ); expect(capturedHeaders['limit'], '42'); - expect(capturedHeaders['rounded'], isNull); + expect(capturedHeaders['rounded'], '42.0'); expect(capturedHeaders['unsafe'], isNull); - expect(capturedHeaders['ratio'], isNull); + expect(capturedHeaders['ratio'], '1.5'); expect(capturedHeaders['dryRun'], 'false'); expect(capturedHeaders['text'], '=?base64?IHBhZGRlZCA=?='); expect(capturedHeaders['payload'], isNull); diff --git a/test/conformance/2026_rc_client_expected_failures.txt b/test/conformance/2026_rc_client_expected_failures.txt new file mode 100644 index 00000000..8db6db09 --- /dev/null +++ b/test/conformance/2026_rc_client_expected_failures.txt @@ -0,0 +1,7 @@ +# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.1 +# against the 2026 RC/DRAFT client suite. +# +# Keep this list scenario-based so the baseline is easy to review. When a +# scenario turns green, remove it from this file in the same PR as the fix. +# +# No expected client failures are currently tracked. diff --git a/test/conformance/2026_rc_expected_failures.txt b/test/conformance/2026_rc_expected_failures.txt new file mode 100644 index 00000000..4f802aca --- /dev/null +++ b/test/conformance/2026_rc_expected_failures.txt @@ -0,0 +1,5 @@ +# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.1 +# against the 2026 RC/DRAFT server suite. +# +# Keep this list scenario-based so the baseline is easy to review. When a +# scenario turns green, remove it from this file in the same PR as the fix. diff --git a/test/conformance/README.md b/test/conformance/README.md new file mode 100644 index 00000000..66ceb498 --- /dev/null +++ b/test/conformance/README.md @@ -0,0 +1,74 @@ +# MCP Conformance + +This directory contains conformance harnesses for stable MCP 2025-11-25 and the +unreleased MCP 2026 RC suite. These fixtures are intentionally separate from the +cross-SDK interop tests because the official conformance package calls +hard-coded diagnostic tools, prompts, and resources. + +## CI Coverage + +Core CI runs the official stable 2025 and 2026 RC client/server conformance +suites from `.github/workflows/test_core.yml`. The server suites use dedicated +fixtures because the official conformance package calls hard-coded diagnostic +tools, prompts, and resources. + +The 2026 suite still targets an RC/alpha spec package. If the official suite +changes before the spec is final, record intentional temporary gaps in +`2026_rc_expected_failures.txt` or `2026_rc_client_expected_failures.txt` so CI +distinguishes known RC churn from regressions. + +## Stable MCP 2025-11-25 + +Run the stable server suite from the repository root: + +```bash +dart run test/conformance/run_2025_server_conformance.dart +``` + +The runner starts `mcp_2025_server.dart`, runs +`@modelcontextprotocol/conformance@0.2.0-alpha.1 server --suite all +--spec-version 2025-11-25`, and writes artifacts under +`.dart_tool/conformance/2025_server/`. + +Run the stable client suite from the repository root: + +```bash +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.1 client \ + --command "dart run test/conformance/mcp_2026_rc_client.dart" \ + --suite all \ + --spec-version 2025-11-25 \ + --verbose \ + -o .dart_tool/conformance/2025_client +``` + +The stable client suite reuses the dual-stack conformance client fixture because +the fixture negotiates whichever protocol version the conformance scenario +server offers. + +## MCP 2026 RC + +Run the current server baseline from the repository root: + +```bash +dart run test/conformance/run_2026_rc_server_conformance.dart +``` + +The runner starts a local `StreamableMcpServer` with JSON stateless responses +enabled, runs the draft server scenarios from +`@modelcontextprotocol/conformance@0.2.0-alpha.1` one by one, and writes per-run +artifacts under `.dart_tool/conformance/2026_rc/`. + +Expected failures live in `2026_rc_expected_failures.txt`. When a scenario is +fixed, remove it from that file so the baseline remains useful. + +Run the current client baseline from the repository root: + +```bash +dart run test/conformance/run_2026_rc_client_conformance.dart +``` + +The client runner invokes `mcp_2026_rc_client.dart` against the conformance +package's scenario servers and writes per-run artifacts under +`.dart_tool/conformance/2026_rc_client/`. + +Client expected failures live in `2026_rc_client_expected_failures.txt`. diff --git a/test/conformance/mcp_2025_server.dart b/test/conformance/mcp_2025_server.dart new file mode 100644 index 00000000..21328934 --- /dev/null +++ b/test/conformance/mcp_2025_server.dart @@ -0,0 +1,657 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:mcp_dart/mcp_dart.dart'; + +const _png1x1 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='; +const _wavSilence = + 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAESsAACJWAAACABAAZGF0YQAAAAA='; + +/// Dedicated HTTP server fixture for the stable MCP 2025-11-25 conformance +/// suite. +/// +/// The conformance package calls hard-coded diagnostic tools, prompts, and +/// resources. Keep those names isolated here so the cross-SDK interop fixture +/// remains representative of a normal application server. +Future main(List args) async { + var host = 'localhost'; + var port = 0; + + for (var i = 0; i < args.length; i++) { + switch (args[i]) { + case '--host': + if (i + 1 < args.length) { + host = args[++i]; + } + case '--port': + if (i + 1 < args.length) { + final parsed = int.tryParse(args[++i]); + if (parsed != null) { + port = parsed; + } + } + case '--help': + _printUsage(); + return; + } + } + + final server = StreamableMcpServer( + serverFactory: (_) => _createConformanceServer(), + host: host, + port: port, + ); + + await server.start(); + stdout.writeln( + 'MCP 2025 conformance server listening on ' + 'http://$host:${server.boundPort}${server.path}', + ); + + await Future.any([ + ProcessSignal.sigint.watch().first, + ProcessSignal.sigterm.watch().first, + ]); + await server.stop(); +} + +McpServer _createConformanceServer() { + final server = McpServer( + const Implementation( + name: 'dart-2025-conformance-server', + version: '1.0.0', + ), + options: const McpServerOptions( + capabilities: ServerCapabilities( + logging: {}, + resources: ServerCapabilitiesResources( + subscribe: true, + listChanged: true, + ), + prompts: ServerCapabilitiesPrompts(listChanged: true), + tools: ServerCapabilitiesTools(listChanged: true), + completions: ServerCapabilitiesCompletions(), + ), + ), + ); + + _registerTools(server); + _registerResources(server); + _registerPrompts(server); + _registerResourceSubscriptions(server); + + return server; +} + +void _registerTools(McpServer server) { + server.registerTool( + 'test_simple_text', + description: 'Returns a simple text content block', + callback: (args, extra) async => _textResult( + 'This is a simple text response for testing.', + ), + ); + + server.registerTool( + 'test_image_content', + description: 'Returns image content', + callback: (args, extra) async => const CallToolResult( + content: [ImageContent(data: _png1x1, mimeType: 'image/png')], + ), + ); + + server.registerTool( + 'test_audio_content', + description: 'Returns audio content', + callback: (args, extra) async => const CallToolResult( + content: [AudioContent(data: _wavSilence, mimeType: 'audio/wav')], + ), + ); + + server.registerTool( + 'test_embedded_resource', + description: 'Returns an embedded resource content block', + callback: (args, extra) async => const CallToolResult( + content: [ + EmbeddedResource( + resource: TextResourceContents( + uri: 'test://embedded-resource', + mimeType: 'text/plain', + text: 'This is an embedded resource content.', + ), + ), + ], + ), + ); + + server.registerTool( + 'test_multiple_content_types', + description: 'Returns text, image, and embedded resource content', + callback: (args, extra) async => CallToolResult( + content: [ + const TextContent(text: 'Multiple content types test:'), + const ImageContent(data: _png1x1, mimeType: 'image/png'), + EmbeddedResource( + resource: TextResourceContents( + uri: 'test://mixed-content-resource', + mimeType: 'application/json', + text: jsonEncode({'test': 'data', 'value': 123}), + ), + ), + ], + ), + ); + + server.registerTool( + 'test_tool_with_logging', + description: 'Sends log messages during tool execution', + callback: (args, extra) async { + await _sendLog(server, extra, 'Tool execution started'); + await Future.delayed(const Duration(milliseconds: 50)); + await _sendLog(server, extra, 'Tool processing data'); + await Future.delayed(const Duration(milliseconds: 50)); + await _sendLog(server, extra, 'Tool execution completed'); + return _textResult('Tool execution completed'); + }, + ); + + server.registerTool( + 'test_error_handling', + description: 'Returns a tool error result', + callback: (args, extra) async => const CallToolResult( + isError: true, + content: [ + TextContent( + text: 'This tool intentionally returns an error for testing', + ), + ], + ), + ); + + server.registerTool( + 'test_tool_with_progress', + description: 'Sends progress notifications during tool execution', + callback: (args, extra) async { + for (final progress in const [0.0, 50.0, 100.0]) { + await extra.sendProgress(progress, total: 100.0); + if (progress != 100) { + await Future.delayed(const Duration(milliseconds: 50)); + } + } + return _textResult('Progress completed'); + }, + ); + + server.registerTool( + 'test_sampling', + description: 'Requests sampling from the client', + inputSchema: JsonSchema.object( + properties: { + 'prompt': JsonSchema.string(description: 'Prompt to send to the LLM'), + }, + required: ['prompt'], + ), + callback: (args, extra) async { + final prompt = args['prompt'] as String? ?? 'Test prompt for sampling'; + final result = await server.server.createMessage( + CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: prompt), + ), + ], + maxTokens: 100, + ), + ); + final text = result.contentBlocks + .whereType() + .map((content) => content.text) + .join('\n'); + return _textResult('LLM response: ${text.isEmpty ? result.model : text}'); + }, + ); + + server.registerTool( + 'test_elicitation', + description: 'Requests structured input from the client', + inputSchema: JsonSchema.object( + properties: { + 'message': + JsonSchema.string(description: 'Message to show to the user'), + }, + required: ['message'], + ), + callback: (args, extra) async { + final message = + args['message'] as String? ?? 'Please provide your information'; + final result = await server.server.elicitInput( + ElicitRequest.form( + message: message, + requestedSchema: JsonSchema.fromJson({ + 'type': 'object', + 'properties': { + 'username': { + 'type': 'string', + 'description': "User's response", + }, + 'email': { + 'type': 'string', + 'description': "User's email address", + }, + }, + 'required': ['username', 'email'], + }), + ), + ); + return _textResult('User response: ${jsonEncode(result.toJson())}'); + }, + ); + + server.registerTool( + 'json_schema_2020_12_tool', + description: 'Tool with JSON Schema 2020-12 features', + inputSchema: JsonObject.fromJson(_jsonSchema2020_12), + callback: (args, extra) async => _textResult('schema-ok'), + ); + + server.registerTool( + 'test_elicitation_sep1034_defaults', + description: 'Requests elicitation with primitive default values', + callback: (args, extra) async { + final result = await server.server.elicitInput( + ElicitRequest.form( + message: 'Please confirm default values', + requestedSchema: JsonSchema.fromJson(_elicitationDefaultsSchema), + ), + ); + return _textResult( + 'Elicitation completed: ${jsonEncode(result.toJson())}', + ); + }, + ); + + server.registerTool( + 'test_elicitation_sep1330_enums', + description: 'Requests elicitation with enum schemas', + callback: (args, extra) async { + final result = await server.server.elicitInput( + ElicitRequest.form( + message: 'Please choose enum values', + requestedSchema: JsonSchema.fromJson(_elicitationEnumSchema), + ), + ); + return _textResult( + 'Elicitation completed: ${jsonEncode(result.toJson())}', + ); + }, + ); +} + +void _registerResources(McpServer server) { + server.registerResource( + 'Static Text', + 'test://static-text', + (description: 'Static text resource', mimeType: 'text/plain'), + (uri, extra) async => ReadResourceResult( + contents: [ + TextResourceContents( + uri: uri.toString(), + mimeType: 'text/plain', + text: 'This is a static text resource for conformance testing.', + ), + ], + ), + ); + + server.registerResource( + 'Static Binary', + 'test://static-binary', + (description: 'Static binary resource', mimeType: 'image/png'), + (uri, extra) async => ReadResourceResult( + contents: [ + BlobResourceContents( + uri: uri.toString(), + mimeType: 'image/png', + blob: _png1x1, + ), + ], + ), + ); + + server.registerResource( + 'Watched Resource', + 'test://watched-resource', + (description: 'Subscribable resource', mimeType: 'text/plain'), + (uri, extra) async => ReadResourceResult( + contents: [ + TextResourceContents( + uri: uri.toString(), + mimeType: 'text/plain', + text: 'Watched resource content', + ), + ], + ), + ); + + server.registerResourceTemplate( + 'Template Data', + ResourceTemplateRegistration( + 'test://template/{id}/data', + listCallback: null, + ), + (description: 'Template resource', mimeType: 'application/json'), + (uri, variables, extra) async { + final id = variables['id'] ?? ''; + return ReadResourceResult( + contents: [ + TextResourceContents( + uri: uri.toString(), + mimeType: 'application/json', + text: jsonEncode({ + 'id': id, + 'templateTest': true, + 'data': 'Data for ID: $id', + }), + ), + ], + ); + }, + ); +} + +void _registerPrompts(McpServer server) { + server.registerPrompt( + 'test_simple_prompt', + description: 'Simple conformance prompt', + callback: (args, extra) async => const GetPromptResult( + messages: [ + PromptMessage( + role: PromptMessageRole.user, + content: TextContent(text: 'This is a simple prompt for testing.'), + ), + ], + ), + ); + + server.registerPrompt( + 'test_prompt_with_arguments', + description: 'Conformance prompt with arguments', + argsSchema: { + 'arg1': const PromptArgumentDefinition( + description: 'First test argument', + required: true, + completable: CompletableField( + def: CompletableDef( + complete: _completeTestArg, + ), + ), + ), + 'arg2': const PromptArgumentDefinition( + description: 'Second test argument', + required: true, + ), + }, + callback: (args, extra) async { + final arg1 = args?['arg1'] ?? ''; + final arg2 = args?['arg2'] ?? ''; + return GetPromptResult( + messages: [ + PromptMessage( + role: PromptMessageRole.user, + content: TextContent( + text: "Prompt with arguments: arg1='$arg1', arg2='$arg2'", + ), + ), + ], + ); + }, + ); + + server.registerPrompt( + 'test_prompt_with_embedded_resource', + description: 'Conformance prompt with embedded resource', + argsSchema: { + 'resourceUri': const PromptArgumentDefinition( + description: 'URI of the resource to embed', + required: true, + ), + }, + callback: (args, extra) async { + final resourceUri = args?['resourceUri'] ?? 'test://example-resource'; + return GetPromptResult( + messages: [ + PromptMessage( + role: PromptMessageRole.user, + content: EmbeddedResource( + resource: TextResourceContents( + uri: resourceUri, + mimeType: 'text/plain', + text: 'Embedded resource content for testing.', + ), + ), + ), + const PromptMessage( + role: PromptMessageRole.user, + content: TextContent( + text: 'Please process the embedded resource above.', + ), + ), + ], + ); + }, + ); + + server.registerPrompt( + 'test_prompt_with_image', + description: 'Conformance prompt with image content', + callback: (args, extra) async => const GetPromptResult( + messages: [ + PromptMessage( + role: PromptMessageRole.user, + content: ImageContent(data: _png1x1, mimeType: 'image/png'), + ), + PromptMessage( + role: PromptMessageRole.user, + content: TextContent(text: 'Please analyze the image above.'), + ), + ], + ), + ); +} + +void _registerResourceSubscriptions(McpServer server) { + final subscribedUris = {}; + + server.server.setRequestHandler( + Method.resourcesSubscribe, + (request, extra) async { + subscribedUris.add(request.subParams.uri); + return const EmptyResult(); + }, + (id, params, meta) => JsonRpcSubscribeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.resourcesSubscribe, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + + server.server.setRequestHandler( + Method.resourcesUnsubscribe, + (request, extra) async { + subscribedUris.remove(request.unsubParams.uri); + return const EmptyResult(); + }, + (id, params, meta) => JsonRpcUnsubscribeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.resourcesUnsubscribe, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); +} + +Future _sendLog( + McpServer server, + RequestHandlerExtra extra, + String message, +) { + return server.sendLoggingMessage( + LoggingMessageNotification( + level: LoggingLevel.info, + logger: 'conformance', + data: message, + ), + sessionId: extra.sessionId, + requestMeta: extra.meta, + ); +} + +List _completeTestArg(String value) { + return ['testValue1', 'testOption'] + .where((candidate) => candidate.startsWith(value)) + .toList(); +} + +CallToolResult _textResult(String text) { + return CallToolResult(content: [TextContent(text: text)]); +} + +const _jsonSchema2020_12 = { + r'$schema': 'https://json-schema.org/draft/2020-12/schema', + 'type': 'object', + r'$defs': { + 'address': { + r'$anchor': 'addressDef', + 'type': 'object', + 'properties': { + 'street': {'type': 'string'}, + 'city': {'type': 'string'}, + }, + }, + }, + 'properties': { + 'name': {'type': 'string'}, + 'address': {r'$ref': '#/\$defs/address'}, + 'contactMethod': { + 'type': 'string', + 'enum': ['phone', 'email'], + }, + 'phone': {'type': 'string'}, + 'email': {'type': 'string'}, + }, + 'allOf': [ + { + 'anyOf': [ + { + 'required': ['phone'], + }, + { + 'required': ['email'], + }, + ], + }, + ], + 'if': { + 'properties': { + 'contactMethod': {'const': 'phone'}, + }, + 'required': ['contactMethod'], + }, + 'then': { + 'required': ['phone'], + }, + 'else': { + 'required': ['email'], + }, + 'additionalProperties': false, +}; + +const _elicitationDefaultsSchema = { + 'type': 'object', + 'properties': { + 'name': { + 'type': 'string', + 'default': 'John Doe', + }, + 'age': { + 'type': 'integer', + 'default': 30, + }, + 'score': { + 'type': 'number', + 'default': 95.5, + }, + 'status': { + 'type': 'string', + 'enum': ['active', 'inactive', 'pending'], + 'default': 'active', + }, + 'verified': { + 'type': 'boolean', + 'default': true, + }, + }, + 'required': ['name', 'age', 'score', 'status', 'verified'], +}; + +const _elicitationEnumSchema = { + 'type': 'object', + 'properties': { + 'untitledSingle': { + 'type': 'string', + 'enum': ['option1', 'option2', 'option3'], + }, + 'titledSingle': { + 'type': 'string', + 'oneOf': [ + {'const': 'value1', 'title': 'First Option'}, + {'const': 'value2', 'title': 'Second Option'}, + ], + }, + 'legacyEnum': { + 'type': 'string', + 'enum': ['opt1', 'opt2', 'opt3'], + 'enumNames': ['Option One', 'Option Two', 'Option Three'], + }, + 'untitledMulti': { + 'type': 'array', + 'items': { + 'type': 'string', + 'enum': ['option1', 'option2', 'option3'], + }, + }, + 'titledMulti': { + 'type': 'array', + 'items': { + 'anyOf': [ + {'const': 'value1', 'title': 'First Choice'}, + {'const': 'value2', 'title': 'Second Choice'}, + ], + }, + }, + }, + 'required': [ + 'untitledSingle', + 'titledSingle', + 'legacyEnum', + 'untitledMulti', + 'titledMulti', + ], +}; + +void _printUsage() { + stdout.writeln(''' +Usage: dart run test/conformance/mcp_2025_server.dart [options] + +Options: + --host Host to bind, default: localhost. + --port Port to bind, default: 0. + --help Show this help. +'''); +} diff --git a/test/conformance/mcp_2026_rc_client.dart b/test/conformance/mcp_2026_rc_client.dart new file mode 100644 index 00000000..5ac61078 --- /dev/null +++ b/test/conformance/mcp_2026_rc_client.dart @@ -0,0 +1,764 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:mcp_dart/mcp_dart.dart'; + +const _clientInfo = Implementation( + name: 'mcp-dart-2026-rc-conformance-client', + version: '0.0.0', +); + +Future main(List args) async { + if (args.isEmpty || args.contains('--help')) { + _printUsage(); + return; + } + + final serverUrl = Uri.parse(args.last); + final scenario = Platform.environment['MCP_CONFORMANCE_SCENARIO']; + final protocolVersion = + Platform.environment['MCP_CONFORMANCE_PROTOCOL_VERSION'] ?? + draftProtocolVersion2026V1; + final context = _readContext(); + + switch (scenario) { + case 'initialize': + await _withClient(serverUrl, protocolVersion: latestProtocolVersion); + case 'tools_call': + await _withClient( + serverUrl, + protocolVersion: latestProtocolVersion, + action: (client) async { + await client.listTools(); + await client.callTool( + const CallToolRequest( + name: 'add_numbers', + arguments: {'a': 2, 'b': 3}, + ), + ); + }, + ); + case 'elicitation-sep1034-client-defaults': + await _runElicitationDefaults(serverUrl); + case 'request-metadata': + await _runRequestMetadata(serverUrl, protocolVersion); + case 'sep-2322-client-request-state': + await _runMrtrRequestState(serverUrl, protocolVersion); + case 'http-standard-headers': + await _runStandardHeaders(serverUrl, protocolVersion); + case 'http-custom-headers': + await _runCustomHeaders(serverUrl, protocolVersion, context); + case 'http-invalid-tool-headers': + await _runInvalidToolHeaders(serverUrl, protocolVersion); + case 'json-schema-ref-no-deref': + await _runSchemaRefNoDeref(serverUrl, latestProtocolVersion); + case 'sse-retry': + await _withClient( + serverUrl, + protocolVersion: latestProtocolVersion, + action: (client) async { + await client.listTools(); + await client.callTool( + const CallToolRequest(name: 'test_reconnection'), + options: const RequestOptions(timeout: Duration(seconds: 5)), + ); + }, + ); + default: + if (scenario != null && scenario.startsWith('auth/')) { + await _runAuthScenario(serverUrl, protocolVersion, scenario, context); + } else { + stderr.writeln('Unsupported conformance client scenario: $scenario'); + } + } + exit(0); +} + +const _draftCapabilities = ClientCapabilities( + roots: ClientCapabilitiesRoots(listChanged: true), + sampling: ClientCapabilitiesSampling(tools: true), + elicitation: ClientElicitation( + form: ClientElicitationForm(applyDefaults: true), + ), +); + +Map _readContext() { + final raw = Platform.environment['MCP_CONFORMANCE_CONTEXT']; + if (raw == null || raw.isEmpty) { + return const {}; + } + final decoded = jsonDecode(raw); + return decoded is Map ? decoded : const {}; +} + +Future _withClient( + Uri serverUrl, { + required String protocolVersion, + ClientCapabilities capabilities = const ClientCapabilities(), + Future Function(McpClient client)? action, +}) async { + final transport = StreamableHttpClientTransport(serverUrl); + final client = McpClient( + _clientInfo, + options: McpClientOptions( + capabilities: capabilities, + protocolVersion: protocolVersion, + useServerDiscover: isStatelessProtocolVersion(protocolVersion), + ), + ); + if (capabilities.roots != null) { + client.setRequestHandler( + Method.rootsList, + (request, extra) async => ListRootsResult( + roots: [Root(uri: Directory.current.uri.toString(), name: 'workspace')], + ), + (id, params, meta) => JsonRpcListRootsRequest(id: id, meta: meta), + ); + } + client.onSamplingRequest = (params) async { + final firstText = params.messages + .expand((message) => message.contentBlocks) + .whereType() + .map((content) => content.text) + .firstOrNull; + return CreateMessageResult( + role: SamplingMessageRole.assistant, + model: 'mcp-dart-conformance-model', + content: SamplingTextContent(text: firstText ?? 'ok'), + ); + }; + client.onElicitRequest = (params) async { + final content = {}; + return ElicitResult(action: 'accept', content: content); + }; + + try { + await client.connect(transport); + await action?.call(client); + } finally { + await client.close(); + } +} + +Future _runElicitationDefaults(Uri serverUrl) async { + await _withClient( + serverUrl, + protocolVersion: latestProtocolVersion, + capabilities: const ClientCapabilities( + elicitation: ClientElicitation( + form: ClientElicitationForm(applyDefaults: true), + ), + ), + action: (client) async { + await client.listTools(); + await client.callTool( + const CallToolRequest(name: 'test_client_elicitation_defaults'), + ); + }, + ); +} + +Future _runMrtrRequestState(Uri serverUrl, String protocolVersion) async { + final client = _RawStatelessClient(serverUrl, protocolVersion); + await client.callToolResolvingInputRequired('test_mrtr_echo_state'); + await client.callToolResolvingInputRequired('test_mrtr_no_state'); + await client.callTool('test_mrtr_unrelated'); + await client.callToolResolvingInputRequired('test_mrtr_no_result_type'); +} + +Future _runRequestMetadata(Uri serverUrl, String protocolVersion) async { + await _RawStatelessClient( + serverUrl, + latestDraftProtocolVersion, + ).request(Method.serverDiscover, const {}); + await _RawStatelessClient( + serverUrl, + protocolVersion, + ).request(Method.serverDiscover, const {}); +} + +Future _runStandardHeaders(Uri serverUrl, String protocolVersion) async { + final transport = await _startedTransport(serverUrl, protocolVersion); + try { + await transport.send( + JsonRpcInitializeRequest( + id: 1, + initParams: InitializeRequest( + protocolVersion: protocolVersion, + capabilities: _draftCapabilities, + clientInfo: _clientInfo, + ), + ), + ); + await transport.send(const JsonRpcInitializedNotification()); + await transport.send(const JsonRpcListToolsRequest(id: 2)); + await transport.send( + JsonRpcCallToolRequest( + id: 3, + params: const CallToolRequest(name: 'test_headers').toJson(), + ), + ); + await transport.send(JsonRpcListResourcesRequest(id: 4)); + await transport.send( + JsonRpcReadResourceRequest( + id: 5, + readParams: const ReadResourceRequest( + uri: 'file:///path/to/file%20name.txt', + ), + ), + ); + await transport.send(JsonRpcListPromptsRequest(id: 6)); + await transport.send( + JsonRpcGetPromptRequest( + id: 7, + getParams: const GetPromptRequest(name: 'test_prompt'), + ), + ); + } finally { + await transport.close(); + } +} + +Future _runCustomHeaders( + Uri serverUrl, + String protocolVersion, + Map context, +) async { + final transport = await _startedTransport(serverUrl, protocolVersion); + transport.setToolParameterHeaderMappings(const { + 'test_custom_headers': { + 'region': 'Region', + 'priority': 'Priority', + 'verbose': 'Verbose', + 'debug': 'Debug', + 'empty_val': 'EmptyVal', + 'method_val': 'Method', + 'float_val': 'FloatVal', + 'non_ascii_val': 'NonAscii', + 'whitespace_val': 'Whitespace', + 'leading_space_val': 'LeadingSpace', + 'trailing_space_val': 'TrailingSpace', + 'internal_space_val': 'InternalSpace', + 'control_char_val': 'ControlChar', + 'crlf_val': 'CrLf', + 'tab_val': 'Tab', + }, + 'test_custom_headers_null': { + 'region': 'Region', + 'priority': 'Priority', + 'verbose': 'Verbose', + }, + }); + + try { + await transport.send( + JsonRpcInitializeRequest( + id: 1, + initParams: InitializeRequest( + protocolVersion: protocolVersion, + capabilities: _draftCapabilities, + clientInfo: _clientInfo, + ), + ), + ); + await transport.send(const JsonRpcInitializedNotification()); + await transport.send(const JsonRpcListToolsRequest(id: 2)); + + final toolCalls = context['toolCalls']; + if (toolCalls is List) { + var id = 3; + for (final call in toolCalls.whereType()) { + final name = call['name']; + if (name is! String) { + continue; + } + final arguments = call['arguments']; + await transport.send( + JsonRpcCallToolRequest( + id: id++, + params: CallToolRequest( + name: name, + arguments: arguments is Map + ? arguments.cast() + : const {}, + ).toJson(), + ), + ); + } + } + } finally { + await transport.close(); + } +} + +Future _runInvalidToolHeaders( + Uri serverUrl, + String protocolVersion, +) async { + final transport = await _startedTransport(serverUrl, protocolVersion); + transport.setToolParameterHeaderMappings(const { + 'valid_tool': {'region': 'Region'}, + }); + try { + await transport.send( + JsonRpcInitializeRequest( + id: 1, + initParams: InitializeRequest( + protocolVersion: protocolVersion, + capabilities: _draftCapabilities, + clientInfo: _clientInfo, + ), + ), + ); + await transport.send(const JsonRpcInitializedNotification()); + await transport.send(const JsonRpcListToolsRequest(id: 2)); + await transport.send( + JsonRpcCallToolRequest( + id: 3, + params: const CallToolRequest( + name: 'valid_tool', + arguments: {'region': 'us-west1'}, + ).toJson(), + ), + ); + } finally { + await transport.close(); + } +} + +Future _runSchemaRefNoDeref( + Uri serverUrl, + String protocolVersion, +) async { + final transport = await _startedTransport(serverUrl, protocolVersion); + try { + await transport.send( + JsonRpcInitializeRequest( + id: 1, + initParams: InitializeRequest( + protocolVersion: protocolVersion, + capabilities: _draftCapabilities, + clientInfo: _clientInfo, + ), + ), + ); + await transport.send(const JsonRpcInitializedNotification()); + await transport.send(const JsonRpcListToolsRequest(id: 2)); + } finally { + await transport.close(); + } +} + +Future _runAuthScenario( + Uri serverUrl, + String protocolVersion, + String scenario, + Map context, +) async { + final provider = _ConformanceOAuthProvider(scenario, context); + final client = _RawOAuthClient(serverUrl, latestProtocolVersion, provider); + const allowClientErrorScenarios = { + 'auth/resource-mismatch', + 'auth/scope-retry-limit', + 'auth/iss-supported-missing', + 'auth/iss-wrong-issuer', + 'auth/iss-unexpected', + 'auth/iss-normalized', + 'auth/metadata-issuer-mismatch', + }; + + try { + await client.start(); + await client.initialize(); + + switch (scenario) { + case 'auth/authorization-server-migration': + await client.callTool('test-tool'); + await client.callTool('test-tool'); + case 'auth/scope-step-up': + await client.listTools(); + await client.callTool('test-tool'); + case 'auth/scope-retry-limit': + try { + await client.listTools(maxAuthAttempts: 2); + } catch (_) { + // The scenario only needs to observe a bounded number of auth + // retries; the server intentionally never grants the scope. + } + default: + await client.listTools(); + await client.callTool('test-tool'); + } + } catch (error) { + if (!allowClientErrorScenarios.contains(scenario)) { + rethrow; + } + } finally { + await client.close(); + } +} + +Future _startedTransport( + Uri serverUrl, + String protocolVersion, +) async { + final transport = StreamableHttpClientTransport(serverUrl); + transport.protocolVersion = protocolVersion; + await transport.start(); + return transport; +} + +class _RawStatelessClient { + final Uri serverUrl; + final String protocolVersion; + final HttpClient _httpClient = HttpClient(); + var _nextId = 1; + + _RawStatelessClient(this.serverUrl, this.protocolVersion); + + Future> callTool( + String name, { + Map arguments = const {}, + InputResponses? inputResponses, + String? requestState, + }) { + return request( + Method.toolsCall, + { + 'name': name, + 'arguments': arguments, + if (inputResponses != null) + 'inputResponses': InputResponse.mapToJson(inputResponses), + if (requestState != null) 'requestState': requestState, + }, + ); + } + + Future> callToolResolvingInputRequired( + String name, + ) async { + InputResponses? inputResponses; + String? requestState; + for (var attempt = 0; attempt < 4; attempt++) { + final response = await callTool( + name, + inputResponses: inputResponses, + requestState: requestState, + ); + final result = response['result']; + if (result is! Map || + result['resultType'] != resultTypeInputRequired) { + return response; + } + + final inputRequired = InputRequiredResult.fromJson(result); + inputResponses = _resolveInputRequests(inputRequired.inputRequests); + requestState = inputRequired.requestState; + } + throw StateError('Exceeded input_required retries for $name'); + } + + InputResponses? _resolveInputRequests(InputRequests? inputRequests) { + if (inputRequests == null) { + return null; + } + return { + for (final entry in inputRequests.entries) + entry.key: InputResponse.fromResult(_resolveInputRequest(entry.value)), + }; + } + + BaseResultData _resolveInputRequest(InputRequest request) { + return switch (request.method) { + Method.elicitationCreate => const ElicitResult( + action: 'accept', + content: {'confirmed': true}, + ), + Method.samplingCreateMessage => const CreateMessageResult( + role: SamplingMessageRole.assistant, + model: 'mcp-dart-conformance-model', + content: SamplingTextContent(text: 'ok'), + ), + Method.rootsList => ListRootsResult( + roots: [Root(uri: Directory.current.uri.toString())], + ), + _ => + throw UnsupportedError('Unsupported input request ${request.method}'), + }; + } + + Future> request( + String method, + Map params, + ) async { + final id = _nextId++; + final request = await _httpClient.postUrl(serverUrl); + request.headers.contentType = ContentType.json; + request.headers.set(HttpHeaders.acceptHeader, 'application/json'); + request.headers.set('MCP-Protocol-Version', protocolVersion); + request.headers.set('Mcp-Method', method); + final name = _mcpName(method, params); + if (name != null) { + request.headers.set('Mcp-Name', name); + } + request.write( + jsonEncode({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': method, + 'params': { + ...params, + '_meta': buildProtocolRequestMeta( + protocolVersion: protocolVersion, + clientInfo: _clientInfo, + clientCapabilities: _draftCapabilities, + ), + }, + }), + ); + final response = await request.close(); + final body = await response.transform(utf8.decoder).join(); + if (body.isEmpty) { + return const {}; + } + final decoded = jsonDecode(body); + if (decoded is! Map) { + throw FormatException('Expected JSON object response, got $decoded'); + } + return decoded; + } + + String? _mcpName(String method, Map params) { + return switch (method) { + Method.toolsCall => params['name'] as String?, + Method.promptsGet => params['name'] as String?, + Method.resourcesRead => params['uri'] as String?, + _ => null, + }; + } +} + +void _printUsage() { + stdout.writeln( + 'Usage: dart run test/conformance/mcp_2026_rc_client.dart ', + ); +} + +class _AuthorizationRedirect { + final String code; + final String? state; + final String? issuer; + + const _AuthorizationRedirect({ + required this.code, + required this.state, + required this.issuer, + }); +} + +class _ConformanceOAuthProvider implements OAuthAuthorizationCodeProvider { + static const _clientMetadataDocumentUrl = + 'https://conformance-test.local/client-metadata.json'; + + final String scenario; + final Map context; + OAuthTokens? _tokens; + _AuthorizationRedirect? _redirect; + + _ConformanceOAuthProvider(this.scenario, this.context); + + @override + String get clientId { + final contextClientId = context['client_id']; + if (contextClientId is String && contextClientId.isNotEmpty) { + return contextClientId; + } + if (scenario == 'auth/basic-cimd') { + return _clientMetadataDocumentUrl; + } + return 'mcp-dart-conformance-client'; + } + + @override + Uri get redirectUri => Uri.parse('http://127.0.0.1/oauth/callback'); + + @override + String? get clientSecret { + final contextClientSecret = context['client_secret']; + return contextClientSecret is String ? contextClientSecret : null; + } + + @override + List get scopes => const []; + + @override + Future tokens() async => _tokens; + + @override + Future redirectToAuthorization() async { + throw UnauthorizedError('Authorization-code redirect is required'); + } + + @override + Future redirectToAuthorizationUrl(Uri authorizationUri) async { + _redirect = await _performAuthorizationRedirect(authorizationUri); + } + + @override + Future saveTokens(OAuthTokens tokens) async { + _tokens = tokens; + } + + _AuthorizationRedirect takeRedirect() { + final redirect = _redirect; + if (redirect == null) { + throw UnauthorizedError('Authorization redirect did not return a code'); + } + _redirect = null; + return redirect; + } + + Future<_AuthorizationRedirect> _performAuthorizationRedirect(Uri uri) async { + final httpClient = HttpClient(); + try { + final request = await httpClient.getUrl(uri); + request.followRedirects = false; + final response = await request.close(); + await response.drain(); + final location = response.headers.value(HttpHeaders.locationHeader); + if (location == null || location.isEmpty) { + throw UnauthorizedError( + 'Authorization endpoint did not redirect with a code', + ); + } + final redirectUri = uri.resolve(location); + final code = redirectUri.queryParameters['code']; + if (code == null || code.isEmpty) { + throw UnauthorizedError('Authorization redirect did not include code'); + } + return _AuthorizationRedirect( + code: code, + state: redirectUri.queryParameters['state'], + issuer: redirectUri.queryParameters['iss'], + ); + } finally { + httpClient.close(force: true); + } + } +} + +class _RawOAuthClient { + final Uri serverUrl; + final String protocolVersion; + final _ConformanceOAuthProvider authProvider; + late final StreamableHttpClientTransport transport; + final Map> _pending = {}; + var _nextId = 1; + + _RawOAuthClient(this.serverUrl, this.protocolVersion, this.authProvider); + + Future start() async { + transport = StreamableHttpClientTransport( + serverUrl, + opts: StreamableHttpClientTransportOptions(authProvider: authProvider), + ); + transport.protocolVersion = protocolVersion; + transport.onmessage = (message) { + switch (message) { + case JsonRpcResponse(:final id): + _pending.remove(id)?.complete(message); + case JsonRpcError(:final id) when id != null: + _pending.remove(id)?.complete(message); + default: + break; + } + }; + await transport.start(); + } + + Future close() => transport.close(); + + Future initialize() async { + final id = _nextId++; + await _request( + JsonRpcInitializeRequest( + id: id, + initParams: InitializeRequest( + protocolVersion: protocolVersion, + capabilities: _draftCapabilities, + clientInfo: _clientInfo, + ), + ), + ); + await transport.send(const JsonRpcInitializedNotification()); + } + + Future> listTools({ + int maxAuthAttempts = 4, + }) { + return _request( + JsonRpcListToolsRequest(id: _nextId++), + maxAuthAttempts: maxAuthAttempts, + ); + } + + Future> callTool( + String name, { + int maxAuthAttempts = 4, + }) { + return _request( + JsonRpcCallToolRequest( + id: _nextId++, + params: CallToolRequest(name: name).toJson(), + ), + maxAuthAttempts: maxAuthAttempts, + ); + } + + Future> _request( + JsonRpcRequest request, { + int maxAuthAttempts = 4, + }) async { + var authAttempts = 0; + while (true) { + final completer = Completer(); + _pending[request.id] = completer; + try { + await transport.send(request); + } on UnauthorizedError { + _pending.remove(request.id); + if (authAttempts >= maxAuthAttempts) { + rethrow; + } + authAttempts += 1; + await _finishAuth(); + continue; + } catch (_) { + _pending.remove(request.id); + rethrow; + } + + final message = await completer.future.timeout( + const Duration(seconds: 8), + ); + switch (message) { + case JsonRpcResponse(:final result): + return result; + case JsonRpcError(:final error): + throw McpError(error.code, error.message, error.data); + default: + throw StateError('Unexpected response message $message'); + } + } + } + + Future _finishAuth() async { + final redirect = authProvider.takeRedirect(); + await transport.finishAuth( + redirect.code, + state: redirect.state, + issuer: redirect.issuer, + ); + } +} diff --git a/test/conformance/mcp_2026_rc_server.dart b/test/conformance/mcp_2026_rc_server.dart new file mode 100644 index 00000000..14cee0b6 --- /dev/null +++ b/test/conformance/mcp_2026_rc_server.dart @@ -0,0 +1,410 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:mcp_dart/mcp_dart.dart'; + +import '../interop/test_dart_server.dart' as interop; + +/// Dedicated HTTP server fixture for the MCP 2026 RC conformance package. +/// +/// This deliberately starts from the existing cross-SDK interop server and +/// enables JSON stateless responses. Conformance-specific diagnostic tools can +/// be added here without changing the stable interop fixture. +Future main(List args) async { + var host = 'localhost'; + var port = 0; + + for (var i = 0; i < args.length; i++) { + switch (args[i]) { + case '--host': + if (i + 1 < args.length) { + host = args[++i]; + } + case '--port': + if (i + 1 < args.length) { + final parsed = int.tryParse(args[++i]); + if (parsed != null) { + port = parsed; + } + } + case '--help': + _printUsage(); + return; + } + } + + final server = StreamableMcpServer( + serverFactory: (_) => _createConformanceServer(), + host: host, + port: port, + enableJsonResponse: true, + ); + + await server.start(); + stdout.writeln( + 'MCP 2026 RC conformance server listening on ' + 'http://$host:${server.boundPort}${server.path}', + ); + + await Future.any([ + ProcessSignal.sigint.watch().first, + ProcessSignal.sigterm.watch().first, + ]); + await server.stop(); +} + +McpServer _createConformanceServer() { + final server = interop.createServer(); + + server.registerTool( + 'a_header_probe', + description: 'No-op tool for HTTP header conformance checks', + callback: (args, extra) async => const CallToolResult(content: []), + ); + + _registerInputRequiredDiagnostics(server); + + return server; +} + +void _registerInputRequiredDiagnostics(McpServer server) { + server.registerTool( + 'test_input_required_result_elicitation', + description: 'Exercises an elicitation InputRequiredResult retry flow', + callback: (args, extra) async { + final content = _acceptedContent(extra.inputResponses, 'user_name'); + final name = content?['name']; + if (name is String) { + return _textResult('Hello, $name!'); + } + + return InputRequiredResult( + inputRequests: { + 'user_name': _elicitationInput( + message: 'What is your name?', + properties: {'name': JsonSchema.string()}, + required: ['name'], + ), + }, + ); + }, + ); + + server.registerTool( + 'test_input_required_result_sampling', + description: 'Exercises a sampling InputRequiredResult retry flow', + callback: (args, extra) async { + final answer = _samplingText(extra.inputResponses, 'capital_question'); + if (answer != null) { + return _textResult(answer); + } + + return InputRequiredResult( + inputRequests: { + 'capital_question': _samplingInput( + 'What is the capital of France?', + maxTokens: 100, + ), + }, + ); + }, + ); + + server.registerTool( + 'test_input_required_result_list_roots', + description: 'Exercises a roots/list InputRequiredResult retry flow', + callback: (args, extra) async { + final roots = _roots(extra.inputResponses, 'client_roots'); + if (roots != null) { + return _textResult('Received ${roots.length} roots.'); + } + + return InputRequiredResult( + inputRequests: { + 'client_roots': InputRequest.listRoots(params: const {}), + }, + ); + }, + ); + + server.registerTool( + 'test_input_required_result_request_state', + description: 'Exercises requestState echo validation', + callback: (args, extra) async { + const state = 'request-state-v1'; + final content = _acceptedContent(extra.inputResponses, 'confirm'); + if (content != null) { + _requireRequestState(extra.requestState, state); + return _textResult('state-ok'); + } + + return InputRequiredResult( + requestState: state, + inputRequests: { + 'confirm': _elicitationInput( + message: 'Please confirm', + properties: {'ok': JsonSchema.boolean()}, + required: ['ok'], + ), + }, + ); + }, + ); + + server.registerTool( + 'test_input_required_result_multiple_inputs', + description: 'Exercises multiple simultaneous InputRequiredResult requests', + callback: (args, extra) async { + const state = 'multiple-inputs-v1'; + final responses = extra.inputResponses; + final user = _acceptedContent(responses, 'user_name'); + final greeting = _samplingText(responses, 'greeting'); + final roots = _roots(responses, 'client_roots'); + if (user != null && greeting != null && roots != null) { + _requireRequestState(extra.requestState, state); + return _textResult( + 'Hello ${user['name'] ?? 'there'}: $greeting (${roots.length} roots)', + ); + } + + return InputRequiredResult( + requestState: state, + inputRequests: { + 'user_name': _elicitationInput( + message: 'What is your name?', + properties: {'name': JsonSchema.string()}, + required: ['name'], + ), + 'greeting': _samplingInput('Generate a greeting', maxTokens: 50), + 'client_roots': InputRequest.listRoots(params: const {}), + }, + ); + }, + ); + + server.registerTool( + 'test_input_required_result_multi_round', + description: 'Exercises a multi-round InputRequiredResult flow', + callback: (args, extra) async { + switch (extra.requestState) { + case null: + return InputRequiredResult( + requestState: 'multi-round-1', + inputRequests: { + 'step1': _elicitationInput( + message: 'Step 1: What is your name?', + properties: {'name': JsonSchema.string()}, + required: ['name'], + ), + }, + ); + case 'multi-round-1': + if (_acceptedContent(extra.inputResponses, 'step1') == null) { + return InputRequiredResult( + requestState: 'multi-round-1', + inputRequests: { + 'step1': _elicitationInput( + message: 'Step 1: What is your name?', + properties: {'name': JsonSchema.string()}, + required: ['name'], + ), + }, + ); + } + return InputRequiredResult( + requestState: 'multi-round-2', + inputRequests: { + 'step2': _elicitationInput( + message: 'Step 2: What is your favorite color?', + properties: {'color': JsonSchema.string()}, + required: ['color'], + ), + }, + ); + case 'multi-round-2': + if (_acceptedContent(extra.inputResponses, 'step2') == null) { + return InputRequiredResult( + requestState: 'multi-round-2', + inputRequests: { + 'step2': _elicitationInput( + message: 'Step 2: What is your favorite color?', + properties: {'color': JsonSchema.string()}, + required: ['color'], + ), + }, + ); + } + return _textResult('multi-round complete'); + default: + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid requestState', + ); + } + }, + ); + + server.registerTool( + 'test_input_required_result_tampered_state', + description: 'Rejects modified requestState values', + callback: (args, extra) async { + const state = 'tamper-proof-state-v1'; + final content = _acceptedContent(extra.inputResponses, 'confirm'); + if (content != null) { + _requireRequestState(extra.requestState, state); + return _textResult('tamper state accepted'); + } + + return InputRequiredResult( + requestState: state, + inputRequests: { + 'confirm': _elicitationInput( + message: 'Please confirm', + properties: {'ok': JsonSchema.boolean()}, + required: ['ok'], + ), + }, + ); + }, + ); + + server.registerTool( + 'test_input_required_result_capabilities', + description: 'Only emits input requests supported by client capabilities', + callback: (args, extra) async { + final capabilities = extra.clientCapabilities; + final inputRequests = {}; + if (capabilities?.sampling != null) { + inputRequests['sampling'] = _samplingInput( + 'Generate a capability-safe response', + maxTokens: 50, + ); + } + if (capabilities?.elicitation != null) { + inputRequests['elicitation'] = _elicitationInput( + message: 'Provide context', + properties: {'context': JsonSchema.string()}, + required: ['context'], + ); + } + if (capabilities?.roots != null) { + inputRequests['roots'] = InputRequest.listRoots(params: const {}); + } + if (inputRequests.isEmpty) { + return _textResult('No declared input capabilities.'); + } + + return InputRequiredResult(inputRequests: inputRequests); + }, + ); + + server.registerPrompt( + 'test_input_required_result_prompt', + description: 'Exercises InputRequiredResult from prompts/get', + callback: (args, extra) async { + final content = _acceptedContent(extra?.inputResponses, 'user_context'); + final context = content?['context']; + if (context is String) { + return GetPromptResult( + messages: [ + PromptMessage( + role: PromptMessageRole.user, + content: TextContent(text: 'Use this context: $context'), + ), + ], + ); + } + + return InputRequiredResult( + inputRequests: { + 'user_context': _elicitationInput( + message: 'What context should the prompt use?', + properties: {'context': JsonSchema.string()}, + required: ['context'], + ), + }, + ); + }, + ); +} + +InputRequest _elicitationInput({ + required String message, + required Map properties, + required List required, +}) { + return InputRequest.elicit( + ElicitRequest.form( + message: message, + requestedSchema: JsonSchema.object( + properties: properties, + required: required, + ), + ), + ); +} + +InputRequest _samplingInput(String text, {required int maxTokens}) { + return InputRequest.createMessage( + CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: text), + ), + ], + maxTokens: maxTokens, + ), + ); +} + +CallToolResult _textResult(String text) { + return CallToolResult(content: [TextContent(text: text)]); +} + +Map? _acceptedContent( + InputResponses? responses, + String key, +) { + final response = responses?[key]?.toJson(); + if (response == null || response['action'] != 'accept') { + return null; + } + final content = response['content']; + if (content is! Map) { + return null; + } + return content.cast(); +} + +String? _samplingText(InputResponses? responses, String key) { + final response = responses?[key]?.toJson(); + final content = response?['content']; + if (content is Map && content['type'] == 'text') { + final text = content['text']; + return text is String ? text : null; + } + return null; +} + +List? _roots(InputResponses? responses, String key) { + final response = responses?[key]?.toJson(); + final roots = response?['roots']; + return roots is List ? roots : null; +} + +void _requireRequestState(String? actual, String expected) { + if (actual != expected) { + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid requestState', + ); + } +} + +void _printUsage() { + stdout.writeln( + 'Usage: dart run test/conformance/mcp_2026_rc_server.dart ' + '[--host localhost] [--port 33125]', + ); +} diff --git a/test/conformance/run_2025_server_conformance.dart b/test/conformance/run_2025_server_conformance.dart new file mode 100644 index 00000000..ed58e366 --- /dev/null +++ b/test/conformance/run_2025_server_conformance.dart @@ -0,0 +1,265 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +const _defaultConformancePackage = + '@modelcontextprotocol/conformance@0.2.0-alpha.1'; +const _defaultTimeout = Duration(seconds: 60); + +Future main(List args) async { + final options = _Options.parse(args); + if (options.help) { + _printUsage(); + return; + } + + final outputRoot = await _createOutputRoot(options.outputDir); + + Process? serverProcess; + var serverOutputSubscriptions = >[]; + late final Uri serverUrl; + try { + if (options.url == null) { + final port = options.port ?? await _findFreePort(); + serverUrl = Uri.parse('http://localhost:$port/mcp'); + serverProcess = await Process.start( + Platform.resolvedExecutable, + [ + 'test/conformance/mcp_2025_server.dart', + '--port', + '$port', + ], + workingDirectory: Directory.current.path, + ); + serverOutputSubscriptions = _pipeServerOutput(serverProcess); + await _waitForPort('localhost', port); + } else { + serverUrl = Uri.parse(options.url!); + } + + stdout.writeln('2025 conformance URL: $serverUrl'); + stdout.writeln('Conformance package: ${options.conformancePackage}'); + stdout.writeln('Output: ${outputRoot.path}'); + stdout.writeln(''); + + final result = await _runConformance( + serverUrl: serverUrl, + outputRoot: outputRoot, + conformancePackage: options.conformancePackage, + scenario: options.scenario, + timeout: options.timeout, + ); + + exitCode = result.exitCode ?? 1; + if (result.timedOut) { + stdout.writeln('Timed out after ${options.timeout.inSeconds}s.'); + exitCode = 1; + } + } finally { + if (serverProcess != null) { + await _stopProcess(serverProcess); + for (final subscription in serverOutputSubscriptions) { + unawaited(subscription.cancel()); + } + } + } + + exit(exitCode); +} + +Future _createOutputRoot(String? outputDir) async { + final root = outputDir == null + ? Directory( + '.dart_tool/conformance/2025_server/' + '${DateTime.now().toUtc().toIso8601String().replaceAll(':', '-')}', + ) + : Directory(outputDir); + await root.create(recursive: true); + return root; +} + +Future _findFreePort() async { + final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final port = socket.port; + await socket.close(); + return port; +} + +List> _pipeServerOutput(Process process) { + // ignore: cancel_subscriptions + final stdoutSubscription = process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) => stdout.writeln('[server] $line')); + // ignore: cancel_subscriptions + final stderrSubscription = process.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) => stderr.writeln('[server] $line')); + return [stdoutSubscription, stderrSubscription]; +} + +Future _stopProcess(Process process) async { + process.kill(ProcessSignal.sigterm); + try { + await process.exitCode.timeout(const Duration(seconds: 3)); + return; + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + } + await process.exitCode.timeout( + const Duration(seconds: 3), + onTimeout: () => -1, + ); +} + +Future _waitForPort(String host, int port) async { + final deadline = DateTime.now().add(const Duration(seconds: 15)); + while (DateTime.now().isBefore(deadline)) { + try { + final socket = await Socket.connect( + host, + port, + timeout: const Duration(milliseconds: 300), + ); + await socket.close(); + return; + } catch (_) { + await Future.delayed(const Duration(milliseconds: 150)); + } + } + throw StateError('Timed out waiting for $host:$port'); +} + +Future<_RunResult> _runConformance({ + required Uri serverUrl, + required Directory outputRoot, + required String conformancePackage, + required String? scenario, + required Duration timeout, +}) async { + final process = await Process.start( + 'npx', + [ + '-y', + conformancePackage, + 'server', + '--url', + serverUrl.toString(), + '--suite', + 'all', + '--spec-version', + '2025-11-25', + if (scenario != null) ...[ + '--scenario', + scenario, + ], + '--verbose', + '-o', + outputRoot.path, + ], + workingDirectory: Directory.current.path, + ); + + final stdoutDone = process.stdout.listen(stdout.add).asFuture(); + final stderrDone = process.stderr.listen(stderr.add).asFuture(); + + try { + final code = await process.exitCode.timeout(timeout); + await Future.wait([stdoutDone, stderrDone]); + return _RunResult(exitCode: code, timedOut: false); + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + await Future.wait([ + stdoutDone.catchError((_) {}), + stderrDone.catchError((_) {}), + ]); + return const _RunResult(exitCode: null, timedOut: true); + } +} + +void _printUsage() { + stdout.writeln(''' +Usage: dart run test/conformance/run_2025_server_conformance.dart [options] + +Options: + --scenario Run one scenario instead of the full suite. + --url Use an already-running server. + --port Port for the local fixture server. + --output-dir Directory for conformance artifacts. + --conformance-package Conformance npm package. + --timeout-seconds Overall conformance command timeout. + --help Show this help. +'''); +} + +class _Options { + final String? scenario; + final String? url; + final int? port; + final String? outputDir; + final String conformancePackage; + final Duration timeout; + final bool help; + + const _Options({ + required this.scenario, + required this.url, + required this.port, + required this.outputDir, + required this.conformancePackage, + required this.timeout, + required this.help, + }); + + factory _Options.parse(List args) { + String? scenario; + String? url; + int? port; + String? outputDir; + var conformancePackage = _defaultConformancePackage; + var timeout = _defaultTimeout; + var help = false; + + for (var i = 0; i < args.length; i++) { + switch (args[i]) { + case '--scenario': + scenario = args[++i]; + case '--url': + url = args[++i]; + case '--port': + port = int.parse(args[++i]); + case '--output-dir': + outputDir = args[++i]; + case '--conformance-package': + conformancePackage = args[++i]; + case '--timeout-seconds': + timeout = Duration(seconds: int.parse(args[++i])); + case '--help': + help = true; + default: + throw ArgumentError('Unknown argument: ${args[i]}'); + } + } + + return _Options( + scenario: scenario, + url: url, + port: port, + outputDir: outputDir, + conformancePackage: conformancePackage, + timeout: timeout, + help: help, + ); + } +} + +class _RunResult { + final int? exitCode; + final bool timedOut; + + const _RunResult({ + required this.exitCode, + required this.timedOut, + }); +} diff --git a/test/conformance/run_2026_rc_client_conformance.dart b/test/conformance/run_2026_rc_client_conformance.dart new file mode 100644 index 00000000..7006e509 --- /dev/null +++ b/test/conformance/run_2026_rc_client_conformance.dart @@ -0,0 +1,347 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +const _defaultConformancePackage = + '@modelcontextprotocol/conformance@0.2.0-alpha.1'; +const _defaultTimeout = Duration(seconds: 30); + +const _draftClientScenarios = [ + 'initialize', + 'tools_call', + 'elicitation-sep1034-client-defaults', + 'sse-retry', + 'request-metadata', + 'auth/metadata-default', + 'auth/metadata-var1', + 'auth/metadata-var2', + 'auth/metadata-var3', + 'auth/basic-cimd', + 'auth/scope-from-www-authenticate', + 'auth/scope-from-scopes-supported', + 'auth/scope-omitted-when-undefined', + 'auth/scope-step-up', + 'auth/scope-retry-limit', + 'auth/token-endpoint-auth-basic', + 'auth/token-endpoint-auth-post', + 'auth/token-endpoint-auth-none', + 'auth/pre-registration', + 'auth/resource-mismatch', + 'auth/offline-access-scope', + 'auth/offline-access-not-supported', + 'auth/authorization-server-migration', + 'auth/iss-supported', + 'auth/iss-not-advertised', + 'auth/iss-supported-missing', + 'auth/iss-wrong-issuer', + 'auth/iss-unexpected', + 'auth/iss-normalized', + 'auth/metadata-issuer-mismatch', + 'sep-2322-client-request-state', + 'http-standard-headers', + 'http-custom-headers', + 'http-invalid-tool-headers', + 'json-schema-ref-no-deref', +]; + +Future main(List args) async { + final options = _Options.parse(args); + if (options.help) { + _printUsage(); + return; + } + + final expectedFailures = await _readExpectedFailures( + options.expectedFailuresPath, + ); + final outputRoot = await _createOutputRoot(options.outputDir); + final scenarios = + options.scenario == null ? _draftClientScenarios : [options.scenario!]; + + stdout.writeln('Conformance package: ${options.conformancePackage}'); + stdout.writeln('Output: ${outputRoot.path}'); + stdout.writeln(''); + + final results = <_ScenarioResult>[]; + for (final scenario in scenarios) { + final result = await _runScenario( + scenario: scenario, + outputRoot: outputRoot, + conformancePackage: options.conformancePackage, + timeout: options.timeout, + ); + results.add(result); + _printScenarioResult(result, expectedFailures); + } + + await _writeSummary(outputRoot, results, expectedFailures); + final unexpectedFailures = results + .where( + (result) => + !result.passed && !expectedFailures.contains(result.scenario), + ) + .toList(); + final unexpectedPasses = results + .where( + (result) => result.passed && expectedFailures.contains(result.scenario), + ) + .toList(); + + stdout.writeln(''); + stdout.writeln( + 'Summary: ${results.where((result) => result.passed).length} passed, ' + '${results.where((result) => !result.passed).length} failed/timeout.', + ); + + if (unexpectedFailures.isNotEmpty) { + stdout.writeln('Unexpected failures:'); + for (final result in unexpectedFailures) { + stdout.writeln(' - ${result.scenario} (${result.status})'); + } + } + if (unexpectedPasses.isNotEmpty) { + stdout.writeln('Unexpected passes; remove these from expected failures:'); + for (final result in unexpectedPasses) { + stdout.writeln(' - ${result.scenario}'); + } + } + + exitCode = unexpectedFailures.isEmpty && unexpectedPasses.isEmpty ? 0 : 1; + exit(exitCode); +} + +Future> _readExpectedFailures(String path) async { + final file = File(path); + if (!await file.exists()) { + return const {}; + } + + final entries = {}; + for (final line in await file.readAsLines()) { + final trimmed = line.trim(); + if (trimmed.isEmpty || trimmed.startsWith('#')) { + continue; + } + entries.add(trimmed); + } + return entries; +} + +Future _createOutputRoot(String? outputDir) async { + final root = outputDir == null + ? Directory( + '.dart_tool/conformance/2026_rc_client/' + '${DateTime.now().toUtc().toIso8601String().replaceAll(':', '-')}', + ) + : Directory(outputDir); + await root.create(recursive: true); + return root; +} + +Future<_ScenarioResult> _runScenario({ + required String scenario, + required Directory outputRoot, + required String conformancePackage, + required Duration timeout, +}) async { + final outputDir = Directory('${outputRoot.path}/${_sanitize(scenario)}'); + await outputDir.create(recursive: true); + + final process = await Process.start( + 'npx', + [ + '-y', + conformancePackage, + 'client', + '--command', + 'dart run test/conformance/mcp_2026_rc_client.dart', + '--scenario', + scenario, + '--spec-version', + 'DRAFT-2026-v1', + '--verbose', + '-o', + outputDir.path, + ], + workingDirectory: Directory.current.path, + ); + + final stdoutBuffer = StringBuffer(); + final stderrBuffer = StringBuffer(); + final stdoutDone = process.stdout + .transform(utf8.decoder) + .listen(stdoutBuffer.write) + .asFuture(); + final stderrDone = process.stderr + .transform(utf8.decoder) + .listen(stderrBuffer.write) + .asFuture(); + + try { + final code = await process.exitCode.timeout(timeout); + await Future.wait([stdoutDone, stderrDone]); + return _ScenarioResult( + scenario: scenario, + exitCode: code, + timedOut: false, + stdout: stdoutBuffer.toString(), + stderr: stderrBuffer.toString(), + ); + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + await Future.wait([ + stdoutDone.catchError((_) {}), + stderrDone.catchError((_) {}), + ]); + return _ScenarioResult( + scenario: scenario, + exitCode: null, + timedOut: true, + stdout: stdoutBuffer.toString(), + stderr: stderrBuffer.toString(), + ); + } +} + +String _sanitize(String scenario) { + return scenario.replaceAll(RegExp(r'[^a-zA-Z0-9_.-]+'), '_'); +} + +void _printScenarioResult( + _ScenarioResult result, + Set expectedFailures, +) { + final expected = expectedFailures.contains(result.scenario); + final label = result.passed + ? expected + ? 'UNEXPECTED PASS' + : 'PASS' + : expected + ? 'EXPECTED FAIL' + : 'FAIL'; + stdout.writeln('${label.padRight(18)} ${result.scenario}'); +} + +Future _writeSummary( + Directory outputRoot, + List<_ScenarioResult> results, + Set expectedFailures, +) async { + final summary = { + 'package': _defaultConformancePackage, + 'expectedFailures': expectedFailures.toList()..sort(), + 'results': [ + for (final result in results) + { + 'scenario': result.scenario, + 'status': result.status, + 'exitCode': result.exitCode, + 'timedOut': result.timedOut, + }, + ], + }; + await File('${outputRoot.path}/summary.json').writeAsString( + '${const JsonEncoder.withIndent(' ').convert(summary)}\n', + ); +} + +void _printUsage() { + stdout.writeln(''' +Usage: dart run test/conformance/run_2026_rc_client_conformance.dart [options] + +Options: + --scenario Run one scenario instead of the full draft list. + --expected-failures Expected-failure list. + --output-dir Directory for conformance artifacts. + --conformance-package Conformance npm package. + --timeout-seconds Per-scenario timeout. + --help Show this help. +'''); +} + +class _ScenarioResult { + final String scenario; + final int? exitCode; + final bool timedOut; + final String stdout; + final String stderr; + + const _ScenarioResult({ + required this.scenario, + required this.exitCode, + required this.timedOut, + required this.stdout, + required this.stderr, + }); + + bool get passed => !timedOut && exitCode == 0; + String get status => timedOut ? 'timeout' : 'exit ${exitCode ?? 'unknown'}'; +} + +class _Options { + final String? scenario; + final String expectedFailuresPath; + final String? outputDir; + final String conformancePackage; + final Duration timeout; + final bool help; + + const _Options({ + required this.scenario, + required this.expectedFailuresPath, + required this.outputDir, + required this.conformancePackage, + required this.timeout, + required this.help, + }); + + static _Options parse(List args) { + String? scenario; + var expectedFailuresPath = + 'test/conformance/2026_rc_client_expected_failures.txt'; + String? outputDir; + var conformancePackage = _defaultConformancePackage; + var timeout = _defaultTimeout; + var help = false; + + for (var i = 0; i < args.length; i++) { + switch (args[i]) { + case '--scenario': + if (i + 1 < args.length) { + scenario = args[++i]; + } + case '--expected-failures': + if (i + 1 < args.length) { + expectedFailuresPath = args[++i]; + } + case '--output-dir': + if (i + 1 < args.length) { + outputDir = args[++i]; + } + case '--conformance-package': + if (i + 1 < args.length) { + conformancePackage = args[++i]; + } + case '--timeout-seconds': + if (i + 1 < args.length) { + final seconds = int.tryParse(args[++i]); + if (seconds != null) { + timeout = Duration(seconds: seconds); + } + } + case '--help': + case '-h': + help = true; + } + } + + return _Options( + scenario: scenario, + expectedFailuresPath: expectedFailuresPath, + outputDir: outputDir, + conformancePackage: conformancePackage, + timeout: timeout, + help: help, + ); + } +} diff --git a/test/conformance/run_2026_rc_server_conformance.dart b/test/conformance/run_2026_rc_server_conformance.dart new file mode 100644 index 00000000..c5e08eff --- /dev/null +++ b/test/conformance/run_2026_rc_server_conformance.dart @@ -0,0 +1,432 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +const _defaultConformancePackage = + '@modelcontextprotocol/conformance@0.2.0-alpha.1'; +const _defaultTimeout = Duration(seconds: 25); + +const _draftServerScenarios = [ + 'server-stateless', + 'caching', + 'sep-2164-resource-not-found', + 'http-header-validation', + 'http-custom-header-server-validation', + 'input-required-result-missing-input-response', + 'input-required-result-basic-elicitation', + 'input-required-result-basic-sampling', + 'input-required-result-basic-list-roots', + 'input-required-result-request-state', + 'input-required-result-multiple-input-requests', + 'input-required-result-multi-round', + 'input-required-result-non-tool-request', + 'input-required-result-result-type', + 'input-required-result-tampered-state', + 'input-required-result-capability-check', + 'input-required-result-unsupported-methods', + 'input-required-result-ignore-extra-params', + 'input-required-result-validate-input', +]; + +Future main(List args) async { + final options = _Options.parse(args); + if (options.help) { + _printUsage(); + return; + } + + final expectedFailures = await _readExpectedFailures( + options.expectedFailuresPath, + ); + final outputRoot = await _createOutputRoot(options.outputDir); + final scenarios = + options.scenario == null ? _draftServerScenarios : [options.scenario!]; + + Process? serverProcess; + var serverOutputSubscriptions = >[]; + late final Uri serverUrl; + try { + if (options.url == null) { + final port = options.port ?? await _findFreePort(); + serverUrl = Uri.parse('http://localhost:$port/mcp'); + serverProcess = await Process.start( + Platform.resolvedExecutable, + [ + 'test/conformance/mcp_2026_rc_server.dart', + '--port', + '$port', + ], + workingDirectory: Directory.current.path, + ); + serverOutputSubscriptions = _pipeServerOutput(serverProcess); + await _waitForPort('localhost', port); + } else { + serverUrl = Uri.parse(options.url!); + } + + stdout.writeln('2026 RC conformance URL: $serverUrl'); + stdout.writeln('Conformance package: ${options.conformancePackage}'); + stdout.writeln('Output: ${outputRoot.path}'); + stdout.writeln(''); + + final results = <_ScenarioResult>[]; + for (final scenario in scenarios) { + final result = await _runScenario( + scenario: scenario, + serverUrl: serverUrl, + outputRoot: outputRoot, + conformancePackage: options.conformancePackage, + timeout: options.timeout, + ); + results.add(result); + _printScenarioResult(result, expectedFailures); + } + + await _writeSummary(outputRoot, results, expectedFailures); + final unexpectedFailures = results + .where( + (result) => + !result.passed && !expectedFailures.contains(result.scenario), + ) + .toList(); + final unexpectedPasses = results + .where( + (result) => + result.passed && expectedFailures.contains(result.scenario), + ) + .toList(); + + stdout.writeln(''); + stdout.writeln( + 'Summary: ${results.where((result) => result.passed).length} passed, ' + '${results.where((result) => !result.passed).length} failed/timeout.', + ); + + if (unexpectedFailures.isNotEmpty) { + stdout.writeln('Unexpected failures:'); + for (final result in unexpectedFailures) { + stdout.writeln(' - ${result.scenario} (${result.status})'); + } + } + if (unexpectedPasses.isNotEmpty) { + stdout.writeln('Unexpected passes; remove these from expected failures:'); + for (final result in unexpectedPasses) { + stdout.writeln(' - ${result.scenario}'); + } + } + + exitCode = unexpectedFailures.isEmpty && unexpectedPasses.isEmpty ? 0 : 1; + } finally { + if (serverProcess != null) { + await _stopProcess(serverProcess); + for (final subscription in serverOutputSubscriptions) { + unawaited(subscription.cancel()); + } + } + } + + exit(exitCode); +} + +Future> _readExpectedFailures(String path) async { + final file = File(path); + if (!await file.exists()) { + return const {}; + } + + final entries = {}; + for (final line in await file.readAsLines()) { + final trimmed = line.trim(); + if (trimmed.isEmpty || trimmed.startsWith('#')) { + continue; + } + entries.add(trimmed); + } + return entries; +} + +Future _createOutputRoot(String? outputDir) async { + final root = outputDir == null + ? Directory( + '.dart_tool/conformance/2026_rc/' + '${DateTime.now().toUtc().toIso8601String().replaceAll(':', '-')}', + ) + : Directory(outputDir); + await root.create(recursive: true); + return root; +} + +Future _findFreePort() async { + final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final port = socket.port; + await socket.close(); + return port; +} + +List> _pipeServerOutput(Process process) { + // ignore: cancel_subscriptions + final stdoutSubscription = process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) => stdout.writeln('[server] $line')); + // ignore: cancel_subscriptions + final stderrSubscription = process.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) => stderr.writeln('[server] $line')); + return [stdoutSubscription, stderrSubscription]; +} + +Future _stopProcess(Process process) async { + process.kill(ProcessSignal.sigterm); + try { + await process.exitCode.timeout(const Duration(seconds: 3)); + return; + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + } + await process.exitCode.timeout( + const Duration(seconds: 3), + onTimeout: () => -1, + ); +} + +Future _waitForPort(String host, int port) async { + final deadline = DateTime.now().add(const Duration(seconds: 15)); + while (DateTime.now().isBefore(deadline)) { + try { + final socket = await Socket.connect( + host, + port, + timeout: const Duration(milliseconds: 300), + ); + await socket.close(); + return; + } catch (_) { + await Future.delayed(const Duration(milliseconds: 150)); + } + } + throw StateError('Timed out waiting for $host:$port'); +} + +Future<_ScenarioResult> _runScenario({ + required String scenario, + required Uri serverUrl, + required Directory outputRoot, + required String conformancePackage, + required Duration timeout, +}) async { + final outputDir = Directory('${outputRoot.path}/${_sanitize(scenario)}'); + await outputDir.create(recursive: true); + + final process = await Process.start( + 'npx', + [ + '-y', + conformancePackage, + 'server', + '--url', + serverUrl.toString(), + '--suite', + 'draft', + '--scenario', + scenario, + '--verbose', + '-o', + outputDir.path, + ], + workingDirectory: Directory.current.path, + ); + + final stdoutBuffer = StringBuffer(); + final stderrBuffer = StringBuffer(); + final stdoutDone = process.stdout + .transform(utf8.decoder) + .listen(stdoutBuffer.write) + .asFuture(); + final stderrDone = process.stderr + .transform(utf8.decoder) + .listen(stderrBuffer.write) + .asFuture(); + + try { + final code = await process.exitCode.timeout(timeout); + await Future.wait([stdoutDone, stderrDone]); + return _ScenarioResult( + scenario: scenario, + exitCode: code, + timedOut: false, + stdout: stdoutBuffer.toString(), + stderr: stderrBuffer.toString(), + ); + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + await process.exitCode; + return _ScenarioResult( + scenario: scenario, + exitCode: null, + timedOut: true, + stdout: stdoutBuffer.toString(), + stderr: stderrBuffer.toString(), + ); + } +} + +void _printScenarioResult( + _ScenarioResult result, + Set expectedFailures, +) { + final expected = expectedFailures.contains(result.scenario); + final marker = result.passed + ? expected + ? 'UNEXPECTED PASS' + : 'PASS' + : expected + ? 'EXPECTED ${result.status.toUpperCase()}' + : 'FAIL'; + stdout.writeln('${marker.padRight(18)} ${result.scenario}'); +} + +Future _writeSummary( + Directory outputRoot, + List<_ScenarioResult> results, + Set expectedFailures, +) async { + final summary = { + 'expectedFailures': expectedFailures.toList()..sort(), + 'results': [ + for (final result in results) + { + 'scenario': result.scenario, + 'status': result.status, + 'exitCode': result.exitCode, + 'expectedFailure': expectedFailures.contains(result.scenario), + }, + ], + }; + await File('${outputRoot.path}/summary.json').writeAsString( + const JsonEncoder.withIndent(' ').convert(summary), + ); +} + +String _sanitize(String value) { + return value.replaceAll(RegExp('[^A-Za-z0-9_.-]'), '_'); +} + +void _printUsage() { + stdout.writeln( + 'Usage: dart run test/conformance/run_2026_rc_server_conformance.dart ' + '[--url http://localhost:33125/mcp] [--scenario scenario-name] ' + '[--timeout-seconds 25]', + ); +} + +class _ScenarioResult { + final String scenario; + final int? exitCode; + final bool timedOut; + final String stdout; + final String stderr; + + const _ScenarioResult({ + required this.scenario, + required this.exitCode, + required this.timedOut, + required this.stdout, + required this.stderr, + }); + + bool get passed => !timedOut && exitCode == 0; + + String get status { + if (timedOut) { + return 'timeout'; + } + if (exitCode == 0) { + return 'pass'; + } + return 'exit-$exitCode'; + } +} + +class _Options { + final bool help; + final String? url; + final int? port; + final String? scenario; + final String? outputDir; + final String expectedFailuresPath; + final String conformancePackage; + final Duration timeout; + + const _Options({ + required this.help, + required this.url, + required this.port, + required this.scenario, + required this.outputDir, + required this.expectedFailuresPath, + required this.conformancePackage, + required this.timeout, + }); + + factory _Options.parse(List args) { + var help = false; + String? url; + int? port; + String? scenario; + String? outputDir; + var expectedFailuresPath = 'test/conformance/2026_rc_expected_failures.txt'; + var conformancePackage = _defaultConformancePackage; + var timeout = _defaultTimeout; + + for (var i = 0; i < args.length; i++) { + switch (args[i]) { + case '--help': + help = true; + case '--url': + if (i + 1 < args.length) { + url = args[++i]; + } + case '--port': + if (i + 1 < args.length) { + port = int.tryParse(args[++i]); + } + case '--scenario': + if (i + 1 < args.length) { + scenario = args[++i]; + } + case '--output-dir': + if (i + 1 < args.length) { + outputDir = args[++i]; + } + case '--expected-failures': + if (i + 1 < args.length) { + expectedFailuresPath = args[++i]; + } + case '--conformance-package': + if (i + 1 < args.length) { + conformancePackage = args[++i]; + } + case '--timeout-seconds': + if (i + 1 < args.length) { + final seconds = int.tryParse(args[++i]); + if (seconds != null && seconds > 0) { + timeout = Duration(seconds: seconds); + } + } + } + } + + return _Options( + help: help, + url: url, + port: port, + scenario: scenario, + outputDir: outputDir, + expectedFailuresPath: expectedFailuresPath, + conformancePackage: conformancePackage, + timeout: timeout, + ); + } +} diff --git a/test/elicitation_test.dart b/test/elicitation_test.dart index 7595c5cc..1787cb41 100644 --- a/test/elicitation_test.dart +++ b/test/elicitation_test.dart @@ -1290,6 +1290,7 @@ void main() { 'content': { 'text': 'value', 'count': 3.0, + 'ratio': 0.5, 'confirmed': true, 'selections': ['a', 'b'], }, @@ -1297,6 +1298,7 @@ void main() { }); expect(parsed.toJson()['content'], containsPair('count', 3)); + expect(parsed.toJson()['content'], containsPair('ratio', 0.5)); expect(parsed.toJson()['_meta'], containsPair('trace', 'abc')); expect( @@ -1315,15 +1317,6 @@ void main() { }), throwsA(isA()), ); - expect( - () => ElicitResult.fromJson({ - 'action': 'accept', - 'content': { - 'ratio': 0.5, - }, - }), - throwsA(isA()), - ); expect( () => ElicitResult.fromJson({ 'action': 'decline', @@ -1343,13 +1336,13 @@ void main() { throwsA(isA()), ); expect( - () => const ElicitResult( + const ElicitResult( action: 'accept', content: { 'ratio': 0.5, }, - ).toJson(), - throwsA(isA()), + ).toJson()['content'], + containsPair('ratio', 0.5), ); expect( const ElicitResult( diff --git a/test/interop/test_dart_server.dart b/test/interop/test_dart_server.dart index 8eab4e08..4366cb70 100644 --- a/test/interop/test_dart_server.dart +++ b/test/interop/test_dart_server.dart @@ -536,6 +536,7 @@ void main(List args) async { // Parse args var transportType = 'stdio'; + var enableJsonResponse = false; int? port; for (var i = 0; i < args.length; i++) { @@ -545,6 +546,9 @@ void main(List args) async { if (args[i] == '--port' && i + 1 < args.length) { port = int.tryParse(args[i + 1]); } + if (args[i] == '--json-response') { + enableJsonResponse = true; + } } // Start Server @@ -560,6 +564,7 @@ void main(List args) async { final transport = StreamableMcpServer( serverFactory: (sessionId) => createServer(), port: port, + enableJsonResponse: enableJsonResponse, ); await transport.start(); // Keep alive? StreamableMcpServer listens on http diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index f59dbcad..3859ccd8 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1730,22 +1730,22 @@ void main() { throwsA(isA()), ); expect( - () => ElicitResult.fromJson({ + ElicitResult.fromJson({ 'action': 'accept', 'content': { 'fractional': 1.5, }, - }), - throwsA(isA()), + }).content, + containsPair('fractional', 1.5), ); expect( - () => const ElicitResult( + const ElicitResult( action: 'accept', content: { 'fractional': 1.5, }, - ).toJson(), - throwsA(isA()), + ).toJson()['content'], + containsPair('fractional', 1.5), ); expect( () => URLElicitationRequiredErrorData.fromJson({ diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index cf85fe73..dbea0fe1 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -333,7 +333,12 @@ void main() { supportedProtocolVersionsWithDraft, contains(draftProtocolVersion2026_07_28), ); + expect( + supportedProtocolVersionsWithDraft, + contains(draftProtocolVersion2026V1), + ); expect(isStatelessProtocolVersion(draftProtocolVersion2026_07_28), true); + expect(isStatelessProtocolVersion(draftProtocolVersion2026V1), true); expect(isStatelessProtocolVersion(latestProtocolVersion), false); }); @@ -680,11 +685,11 @@ void main() { throwsA(isA()), ); expect( - () => ElicitResult.fromJson({ + ElicitResult.fromJson({ 'action': 'accept', 'content': {'score': 1.5}, - }), - throwsA(isA()), + }).content, + containsPair('score', 1.5), ); expect( () => const ElicitResult( @@ -694,11 +699,11 @@ void main() { throwsA(isA()), ); expect( - () => const ElicitResult( + const ElicitResult( action: 'accept', content: {'score': 1.5}, - ).toJson(), - throwsA(isA()), + ).toJson()['content'], + containsPair('score', 1.5), ); }); @@ -3399,6 +3404,92 @@ void main() { expect(response.result['requestState'], 'retry-state'); }); + test('stateless registerTool receives input responses and request state', + () async { + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + server.registerTool( + 'needs_input', + callback: (args, extra) { + final response = extra.inputResponses?['profile']; + if (response == null) { + expect(extra.requestState, isNull); + return InputRequiredResult( + inputRequests: { + 'profile': InputRequest.elicit( + ElicitRequest.form( + message: 'Enter profile details', + requestedSchema: JsonSchema.object( + properties: {'name': JsonSchema.string()}, + required: ['name'], + ), + ), + ), + }, + requestState: 'state-1', + ); + } + + expect(extra.requestState, 'state-1'); + final responseJson = response.toJson(); + final content = responseJson['content'] as Map; + return CallToolResult( + content: [TextContent(text: 'Hello ${content['name']}')], + ); + }, + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcCallToolRequest( + id: 'call-1', + params: const CallToolRequest(name: 'needs_input').toJson(), + meta: _clientMeta( + clientCapabilities: const ClientCapabilities( + elicitation: ClientElicitation.formOnly(), + ), + ), + ), + ); + await _pump(); + + final inputRequired = transport.sentMessages.single as JsonRpcResponse; + expect(inputRequired.result['resultType'], resultTypeInputRequired); + expect(inputRequired.result['requestState'], 'state-1'); + expect(inputRequired.result['inputRequests'], contains('profile')); + + transport.sentMessages.clear(); + transport.receive( + JsonRpcCallToolRequest( + id: 'call-2', + params: CallToolRequest( + name: 'needs_input', + inputResponses: { + 'profile': InputResponse.fromResult( + const ElicitResult( + action: 'accept', + content: {'name': 'Alice'}, + ), + ), + }, + requestState: 'state-1', + ).toJson(), + meta: _clientMeta( + clientCapabilities: const ClientCapabilities( + elicitation: ClientElicitation.formOnly(), + ), + ), + ), + ); + await _pump(); + + final completed = transport.sentMessages.single as JsonRpcResponse; + expect(completed.result['resultType'], resultTypeComplete); + expect(completed.result['content'][0]['text'], 'Hello Alice'); + }); + test('stateless input required requests require client capabilities', () async { final server = Server( @@ -4160,7 +4251,7 @@ void main() { .having( (error) => error.code, 'code', - ErrorCode.invalidRequest.value, + ErrorCode.invalidParams.value, ) .having( (error) => error.data, diff --git a/test/server/streamable_mcp_server_test.dart b/test/server/streamable_mcp_server_test.dart index 60f32ec6..369375eb 100644 --- a/test/server/streamable_mcp_server_test.dart +++ b/test/server/streamable_mcp_server_test.dart @@ -362,6 +362,248 @@ void main() { expect(messages.single['result']['tools'][0]['name'], 'echo'); }); + test('can return JSON responses for stateless requests', () async { + await server.stop(); + server = StreamableMcpServer( + serverFactory: (sessionId) { + final mcpServer = McpServer( + const Implementation(name: 'JsonStatelessServer', version: '1.0.0'), + ); + mcpServer.registerTool( + 'echo', + inputSchema: const ToolInputSchema(), + callback: (args, extra) async => const CallToolResult(content: []), + ); + return mcpServer; + }, + host: host, + port: port, + enableJsonResponse: true, + ); + await server.start(); + + final response = await http.post( + Uri.parse(baseUrl), + body: jsonEncode( + JsonRpcListToolsRequest(id: 3, meta: statelessMeta()).toJson(), + ), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.toolsList, + }, + ); + + expect(response.statusCode, HttpStatus.ok); + expect(response.headers['content-type'], startsWith('application/json')); + final message = jsonDecode(response.body) as Map; + expect(message['id'], 3); + expect(message['result']['tools'][0]['name'], 'echo'); + }); + + test('can return JSON errors for stateless request handlers', () async { + await server.stop(); + server = StreamableMcpServer( + serverFactory: (sessionId) { + final mcpServer = McpServer( + const Implementation(name: 'JsonStatelessServer', version: '1.0.0'), + ); + mcpServer.registerTool( + 'echo', + inputSchema: const ToolInputSchema(), + callback: (args, extra) async => const CallToolResult(content: []), + ); + return mcpServer; + }, + host: host, + port: port, + enableJsonResponse: true, + ); + await server.start(); + + final response = await http.post( + Uri.parse(baseUrl), + body: jsonEncode( + JsonRpcCallToolRequest( + id: 4, + params: const { + 'name': 'missing_tool', + 'arguments': {}, + }, + meta: statelessMeta(), + ).toJson(), + ), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.toolsCall, + 'Mcp-Name': 'missing_tool', + }, + ); + + expect(response.statusCode, HttpStatus.badRequest); + expect(response.headers['content-type'], startsWith('application/json')); + final message = jsonDecode(response.body) as Map; + expect(message['id'], 4); + expect(message['error']['code'], ErrorCode.invalidParams.value); + expect(message['error']['message'], contains('missing_tool')); + }); + + test('rejects unsupported stateless version before session routing', + () async { + await server.stop(); + server = StreamableMcpServer( + serverFactory: (sessionId) { + return McpServer( + const Implementation(name: 'VersionServer', version: '1.0.0'), + ); + }, + host: host, + port: port, + enableJsonResponse: true, + ); + await server.start(); + + final meta = statelessMeta()..[McpMetaKey.protocolVersion] = 'v999.0.0'; + final response = await http.post( + Uri.parse(baseUrl), + body: jsonEncode({ + 'jsonrpc': jsonRpcVersion, + 'id': 'discover-unsupported', + 'method': Method.serverDiscover, + 'params': {'_meta': meta}, + }), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'MCP-Protocol-Version': 'v999.0.0', + 'Mcp-Method': Method.serverDiscover, + }, + ); + + expect(response.statusCode, HttpStatus.badRequest); + final message = jsonDecode(response.body) as Map; + expect( + message['error']['code'], + ErrorCode.unsupportedProtocolVersion.value, + ); + expect(message['id'], 'discover-unsupported'); + expect(message['error']['data']['requested'], 'v999.0.0'); + }); + + test('preserves id for malformed stateless server discover metadata', + () async { + await server.stop(); + server = StreamableMcpServer( + serverFactory: (sessionId) { + return McpServer( + const Implementation(name: 'DiscoverServer', version: '1.0.0'), + ); + }, + host: host, + port: port, + enableJsonResponse: true, + ); + await server.start(); + + Future> postDiscover( + Map body, + ) async { + final response = await http.post( + Uri.parse(baseUrl), + body: jsonEncode(body), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'MCP-Protocol-Version': draftProtocolVersion2026V1, + 'Mcp-Method': Method.serverDiscover, + }, + ); + + expect(response.statusCode, HttpStatus.badRequest); + return jsonDecode(response.body) as Map; + } + + var message = await postDiscover({ + 'jsonrpc': jsonRpcVersion, + 'id': 101, + 'method': Method.serverDiscover, + 'params': {}, + }); + expect(message['id'], 101); + expect(message['error']['code'], ErrorCode.invalidParams.value); + + message = await postDiscover({ + 'jsonrpc': jsonRpcVersion, + 'id': 102, + 'method': Method.serverDiscover, + 'params': {'_meta': {}}, + }); + expect(message['id'], 102); + expect(message['error']['code'], ErrorCode.invalidParams.value); + expect( + message['error']['message'], + contains(McpMetaKey.protocolVersion), + ); + }); + + test('rejects removed stateless request methods before legacy parsing', + () async { + await server.stop(); + server = StreamableMcpServer( + serverFactory: (sessionId) { + return McpServer( + const Implementation(name: 'RemovedMethodsServer', version: '1.0'), + ); + }, + host: host, + port: port, + enableJsonResponse: true, + ); + await server.start(); + + final methods = [ + Method.initialize, + Method.ping, + Method.loggingSetLevel, + Method.resourcesSubscribe, + Method.resourcesUnsubscribe, + ]; + for (var i = 0; i < methods.length; i++) { + final method = methods[i]; + final id = 200 + i; + final response = await http.post( + Uri.parse(baseUrl), + body: jsonEncode({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': method, + 'params': { + '_meta': statelessMeta(), + if (method == Method.loggingSetLevel) 'level': 'info', + if (method == Method.resourcesSubscribe || + method == Method.resourcesUnsubscribe) + 'uri': 'file:///tmp/example.txt', + }, + }), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'MCP-Protocol-Version': draftProtocolVersion2026V1, + 'Mcp-Method': method, + }, + ); + + expect(response.statusCode, HttpStatus.notFound); + final message = jsonDecode(response.body) as Map; + expect(message['id'], id); + expect(message['error']['code'], ErrorCode.methodNotFound.value); + expect(message['error']['message'], contains(method)); + } + }); + test('handles 2026 stateless request with unknown session ID', () async { await server.stop(); server = StreamableMcpServer( diff --git a/test/shared/json_schema_from_json_test.dart b/test/shared/json_schema_from_json_test.dart index 0311e777..153a9d71 100644 --- a/test/shared/json_schema_from_json_test.dart +++ b/test/shared/json_schema_from_json_test.dart @@ -308,6 +308,54 @@ void main() { }); }); + test('preserves object-level JSON Schema extension keywords', () { + final json = { + r'$schema': 'https://json-schema.org/draft/2020-12/schema', + 'type': 'object', + r'$defs': { + 'address': { + r'$anchor': 'addressDef', + 'type': 'object', + }, + }, + 'properties': { + 'contactMethod': { + 'type': 'string', + 'enum': ['phone', 'email'], + }, + }, + 'allOf': [ + { + 'anyOf': [ + { + 'required': ['phone'], + }, + { + 'required': ['email'], + }, + ], + }, + ], + 'if': { + 'properties': { + 'contactMethod': {'const': 'phone'}, + }, + }, + 'then': { + 'required': ['phone'], + }, + 'else': { + 'required': ['email'], + }, + 'additionalProperties': false, + }; + + final schema = JsonSchema.fromJson(json); + + expect(schema, isA()); + expect(schema.toJson(), json); + }); + test('parses object schema with additionalProperties as schema', () { final json = { 'type': 'object', From 3f52eaf919e6c27aa0126fc2f87a614f98327a18 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Tue, 2 Jun 2026 22:00:04 -0400 Subject: [PATCH 36/42] Align header conformance with 2026 RC --- CHANGELOG.md | 5 ++--- lib/src/client/client.dart | 2 +- lib/src/client/streamable_https.dart | 4 ++++ lib/src/server/mcp_server.dart | 5 +++-- lib/src/shared/json_schema/json_schema.dart | 5 ++--- .../mcp_dart_cli/lib/src/conformance_runner.dart | 14 +++++++------- .../test/src/conformance_command_test.dart | 2 +- test/server/mcp_server_test.dart | 14 ++++---------- 8 files changed, 24 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 906138be..aa38f139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,9 +38,8 @@ - Synced nested 2026 `x-mcp-header` mappings into Streamable HTTP transports using JSON Pointer selectors for nested tool arguments. - Limited 2026 Streamable HTTP `x-mcp-header` mirroring to string, boolean, - and JavaScript-safe integer argument values; fractional numbers and unsafe - integers are omitted, and `number` schemas are rejected from advertised - header mappings. + finite number, and JavaScript-safe integer argument values; unsafe integers + and non-finite numbers are omitted. - Returned HTTP 404 with JSON-RPC `Method not found` for unsupported or removed 2026 stateless Streamable HTTP request methods before opening response streams. diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index eb4f79d2..f793745c 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -1596,7 +1596,7 @@ class McpClient extends Protocol { if (!_isToolParameterHeaderPrimitive(entry.value)) { return 'parameter "$parameterName" uses x-mcp-header on a schema that ' - 'is not string, integer, or boolean'; + 'is not string, number, integer, or boolean'; } mappings[_toolParameterHeaderSelector(parameterPath)] = rawHeader; diff --git a/lib/src/client/streamable_https.dart b/lib/src/client/streamable_https.dart index cfc6ca13..0065070f 100644 --- a/lib/src/client/streamable_https.dart +++ b/lib/src/client/streamable_https.dart @@ -893,6 +893,10 @@ class StreamableHttpClientTransport if (!value.isFinite) { return null; } + if (value == value.truncateToDouble() && + (value < _minSafeHeaderInteger || value > _maxSafeHeaderInteger)) { + return null; + } return value.toString(); } diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index 302c6ff5..751d43cd 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -1302,8 +1302,8 @@ class McpServer { if (!_isToolParameterHeaderPrimitive(entry.value)) { return 'Ignoring x-mcp-header mapping for tool "$toolName" parameter ' - '"$parameterName": only string, integer, and boolean schemas can ' - 'be mirrored.'; + '"$parameterName": only string, number, integer, and boolean ' + 'schemas can be mirrored.'; } mappings[_toolParameterHeaderSelector(parameterPath)] = rawHeader; @@ -1338,6 +1338,7 @@ class McpServer { bool _isToolParameterHeaderPrimitive(JsonSchema schema) { return schema is JsonString || + schema is JsonNumber || schema is JsonInteger || schema is JsonBoolean; } diff --git a/lib/src/shared/json_schema/json_schema.dart b/lib/src/shared/json_schema/json_schema.dart index 29515e59..258298b8 100644 --- a/lib/src/shared/json_schema/json_schema.dart +++ b/lib/src/shared/json_schema/json_schema.dart @@ -597,9 +597,8 @@ class JsonNumber extends JsonSchema { /// MCP `x-mcp-header` extension metadata. /// - /// This is preserved for schema round-tripping. MCP 2026 stateless - /// Streamable HTTP header mirroring only accepts string, integer, and boolean - /// schemas, so number schemas carrying this metadata are not mirrored. + /// MCP 2026 stateless Streamable HTTP clients mirror finite number argument + /// values into `Mcp-Param-*` headers when this metadata is present. final String? mcpHeader; const JsonNumber({ diff --git a/packages/mcp_dart_cli/lib/src/conformance_runner.dart b/packages/mcp_dart_cli/lib/src/conformance_runner.dart index fff05f62..7658bf6c 100644 --- a/packages/mcp_dart_cli/lib/src/conformance_runner.dart +++ b/packages/mcp_dart_cli/lib/src/conformance_runner.dart @@ -365,10 +365,10 @@ class ConformanceRunner { ), _ConformanceCase( suite: _specSuite, - name: 'stateless-http.omits-invalid-numeric-parameter-headers', + name: 'stateless-http.omits-unsafe-numeric-parameter-headers', description: - 'Omits fractional and unsafe integer x-mcp-header values while preserving safe integers.', - check: _omitsInvalidNumericParameterHeaders, + 'Mirrors finite numeric x-mcp-header values while omitting unsafe integers.', + check: _omitsUnsafeNumericParameterHeaders, ), _ConformanceCase( suite: _specSuite, @@ -1418,7 +1418,7 @@ Future _statelessRequestsRequireCompleteRequestMeta() async { _expectSingleError( transport.sentMessages, id: scenario.id, - code: ErrorCode.invalidRequest.value, + code: ErrorCode.invalidParams.value, messageContains: scenario.missing, ); transport.sentMessages.clear(); @@ -2411,7 +2411,7 @@ Future _validatesStatelessHttpParameterHeaders() async { } } -Future _omitsInvalidNumericParameterHeaders() async { +Future _omitsUnsafeNumericParameterHeaders() async { final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final receivedHeaders = Completer>(); final responseMessage = Completer(); @@ -2485,9 +2485,9 @@ Future _omitsInvalidNumericParameterHeaders() async { 'Expected safe integer header 42, got ${headers['limit']}.', ); } - if (headers['ratio'] != null) { + if (headers['ratio'] != '1.5') { throw StateError( - 'Expected fractional number header to be omitted, got ' + 'Expected fractional number header 1.5, got ' "${headers['ratio']}.", ); } diff --git a/packages/mcp_dart_cli/test/src/conformance_command_test.dart b/packages/mcp_dart_cli/test/src/conformance_command_test.dart index 4faf8729..aaa821e0 100644 --- a/packages/mcp_dart_cli/test/src/conformance_command_test.dart +++ b/packages/mcp_dart_cli/test/src/conformance_command_test.dart @@ -66,7 +66,7 @@ void main() { 'stateless-http.rejects-batch-payloads', 'stateless-http.task-requests-require-name-header', 'stateless-http.validates-parameter-headers', - 'stateless-http.omits-invalid-numeric-parameter-headers', + 'stateless-http.omits-unsafe-numeric-parameter-headers', 'stateless-http.encodes-parameter-header-values', 'stateless-http.accepts-response-posts', 'stateless-http.task-subscription-requires-client-capability', diff --git a/test/server/mcp_server_test.dart b/test/server/mcp_server_test.dart index a11519f5..fbbb9896 100644 --- a/test/server/mcp_server_test.dart +++ b/test/server/mcp_server_test.dart @@ -139,6 +139,7 @@ void main() { properties: { 'dryRun': JsonBoolean(mcpHeader: 'Dry-Run'), 'region': JsonString(mcpHeader: 'Region'), + 'ratio': JsonNumber(mcpHeader: 'Ratio'), 'auth': JsonObject( properties: { 'tenant': JsonString(mcpHeader: 'Tenant'), @@ -159,6 +160,7 @@ void main() { 'header-tool': { 'dryRun': 'Dry-Run', 'region': 'Region', + 'ratio': 'Ratio', '/auth/tenant': 'Tenant', }, }, @@ -177,6 +179,7 @@ void main() { final properties = inputSchema['properties'] as Map; final authProperties = (properties['auth'] as Map)['properties'] as Map; expect((properties['region'] as Map)['x-mcp-header'], 'Region'); + expect((properties['ratio'] as Map)['x-mcp-header'], 'Ratio'); expect((authProperties['tenant'] as Map)['x-mcp-header'], 'Tenant'); }); @@ -242,15 +245,6 @@ void main() { ), callback: (args, extra) async => const CallToolResult(content: []), ); - server.registerTool( - 'number-header-tool', - inputSchema: const ToolInputSchema( - properties: { - 'value': JsonNumber(mcpHeader: 'Value'), - }, - ), - callback: (args, extra) async => const CallToolResult(content: []), - ); server.registerTool( 'duplicate-header-tool', inputSchema: const ToolInputSchema( @@ -285,7 +279,7 @@ void main() { final response = transport.sentMessages.last as JsonRpcResponse; final tools = response.result['tools'] as List; - expect(tools, hasLength(6)); + expect(tools, hasLength(5)); for (final tool in tools.cast()) { expect(_containsMcpHeader(tool['inputSchema']), isFalse); } From 44333da6b5276dd8a6869339be1b29479f41e057 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Tue, 2 Jun 2026 22:02:21 -0400 Subject: [PATCH 37/42] Use Node 24 for conformance CI --- .github/workflows/test_cli.yml | 2 +- .github/workflows/test_core.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test_cli.yml b/.github/workflows/test_cli.yml index 4300cf84..3433123d 100644 --- a/.github/workflows/test_cli.yml +++ b/.github/workflows/test_cli.yml @@ -44,7 +44,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' - name: Set up Python uses: actions/setup-python@v6 diff --git a/.github/workflows/test_core.yml b/.github/workflows/test_core.yml index 9728e764..af71ddd3 100644 --- a/.github/workflows/test_core.yml +++ b/.github/workflows/test_core.yml @@ -22,7 +22,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' - name: Install TS Interop dependencies working-directory: test/interop/ts From 5be81d6eb50ea5c042f1d97c5afbdd6765427594 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Wed, 3 Jun 2026 09:05:53 -0400 Subject: [PATCH 38/42] Add typed protocol profile opt-in --- CHANGELOG.md | 8 +- README.md | 27 ++- doc/client-guide.md | 20 +++ doc/mcp-2026-rc.md | 93 ++++++++++ doc/server-guide.md | 22 +++ lib/src/client/client.dart | 59 ++++-- lib/src/server/server.dart | 42 ++++- lib/src/types/json_rpc.dart | 65 +++++++ test/client/client_test.dart | 2 +- test/client/streamable_https_test.dart | 1 - test/conformance/mcp_2026_rc_server.dart | 6 +- test/interop/test_dart_server.dart | 3 +- test/mcp_2026_07_28_test.dart | 189 +++++++++++++++++--- test/server/mcp_server_test.dart | 25 +++ test/server/output_validation_test.dart | 27 +++ test/server/streamable_https_test.dart | 3 + test/server/streamable_mcp_server_test.dart | 16 ++ 17 files changed, 561 insertions(+), 47 deletions(-) create mode 100644 doc/mcp-2026-rc.md diff --git a/CHANGELOG.md b/CHANGELOG.md index aa38f139..7cc51a45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,11 @@ - Added server-side `server/discover` handling before legacy initialization and initial stateless request validation for per-request protocol version, client identity, and client capability metadata. -- Added opt-in client discovery via `McpClientOptions(useServerDiscover: true)` - while keeping the stable `initialize` flow as the default until the 2026 - stateless transport and MRTR implementation is complete. +- Added explicit protocol profiles via + `McpClientOptions(protocol: McpProtocol.preview2026)` and + `McpServerOptions(protocol: McpProtocol.preview2026)` while keeping the stable + `initialize` flow as the default. The lower-level `protocolVersion` and + `useServerDiscover` options remain available for interoperability testing. - Added 2026 cacheable result support for `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, and `resources/read`, including stateless server defaults for `resultType`, `ttlMs`, and `cacheScope` while diff --git a/README.md b/README.md index cbf64d12..bdf9aefd 100644 --- a/README.md +++ b/README.md @@ -87,10 +87,34 @@ Use this comparison as a starting point, not a permanent verdict: both packages ## Model Context Protocol Version -The current version of the protocol is `2025-11-25`. This library is designed to be compatible with this version, and any future updates will be made to ensure continued compatibility. +The default protocol profile is MCP `2025-11-25`. This library is designed to +be compatible with this version, and any future updates will preserve an +explicit stable profile. It's also backward compatible with previous versions including `2025-06-18`, `2025-03-26`, `2024-11-05`, and `2024-10-07`. +MCP `2026-07-28` RC support is available behind an explicit preview profile: + +```dart +final client = McpClient( + const Implementation(name: 'my-client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), +); + +final server = McpServer( + const Implementation(name: 'my-server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), +); +``` + +Use the preview profile while the spec is still an RC. See the +[MCP 2026 RC transition guide](https://github.com/leehack/mcp_dart/blob/main/doc/mcp-2026-rc.md) +for opt-in behavior, fallback rules, and 2026-only APIs. + ## Documentation ### Getting Started @@ -112,6 +136,7 @@ It's also backward compatible with previous versions including `2025-06-18`, `20 - 🧪 **[SDK Interoperability Matrix](https://github.com/leehack/mcp_dart/blob/main/doc/interoperability.md)** - Verified Dart/TypeScript and documented cross-SDK scenarios - ✅ **[MCP 2025-11-25 Spec Coverage Matrix](https://github.com/leehack/mcp_dart/blob/main/doc/spec-coverage-2025-11-25.md)** - Auditable coverage map with CLI conformance cases and known gaps +- 🧭 **[MCP 2026 RC Transition Guide](https://github.com/leehack/mcp_dart/blob/main/doc/mcp-2026-rc.md)** - Opt-in profile, fallback behavior, and draft-only APIs - 🔒 **[Transport Security Recipes](https://github.com/leehack/mcp_dart/blob/main/doc/transports.md#dns-rebinding-protection)** - Host/Origin allowlists, OAuth layering, and compatibility-toggle trade-offs - 📱 **[Flutter Recipes](https://github.com/leehack/mcp_dart/blob/main/doc/flutter-recipes.md)** - Flutter Web, mobile, and desktop host/client guidance - 🔁 **[Migration Cookbooks](https://github.com/leehack/mcp_dart/blob/main/doc/migration-cookbooks.md)** - TypeScript SDK, `dart_mcp`, stdio-to-HTTP, and version migration paths diff --git a/doc/client-guide.md b/doc/client-guide.md index a9d4fed9..c13e6b2e 100644 --- a/doc/client-guide.md +++ b/doc/client-guide.md @@ -60,6 +60,26 @@ final client = McpClient( // Set up handlers after client creation if needed ``` +### Protocol Profile + +Clients use the stable MCP `2025-11-25` profile by default. Opt into MCP +`2026-07-28` RC behavior with the preview profile: + +```dart +final client = McpClient( + const Implementation(name: 'my-client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), +); +``` + +`McpClientOptions(protocol: McpProtocol.preview2026)` enables +`server/discover` and stateless request metadata, then falls back to the stable +`initialize` flow when discovery is not available. Use +`McpClientOptions(protocol: McpProtocol.require2026)` when fallback should be +treated as an error. + ## Client Capabilities Declare what your client supports: diff --git a/doc/mcp-2026-rc.md b/doc/mcp-2026-rc.md new file mode 100644 index 00000000..58ea430b --- /dev/null +++ b/doc/mcp-2026-rc.md @@ -0,0 +1,93 @@ +# MCP 2026 RC Transition Guide + +`mcp_dart` defaults to the latest stable MCP specification, currently +`2025-11-25`. MCP `2026-07-28` RC support is available through explicit +protocol profiles so applications can adopt the draft without changing stable +deployments. + +## Client opt-in + +Use the preview profile when you want the client to prefer MCP `2026-07-28` RC +and fall back to stable MCP servers when discovery is unavailable: + +```dart +final client = McpClient( + const Implementation(name: 'my-client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), +); +``` + +`McpClientOptions(protocol: McpProtocol.preview2026)` enables +`server/discover`, sends the 2026 stateless request metadata, and falls back to +the legacy `initialize` flow when the peer looks like a stable-only MCP server. + +Use the strict profile for conformance tests or deployments where fallback is +not acceptable: + +```dart +final client = McpClient( + const Implementation(name: 'my-client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.require2026, + ), +); +``` + +## Server opt-in + +Use the server preview profile to advertise and accept MCP `2026-07-28` RC +stateless requests: + +```dart +final server = McpServer( + const Implementation(name: 'my-server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), +); +``` + +`McpServerOptions()` remains stable by default and does not advertise draft +stateless protocol versions. + +## Profile summary + +| Profile | Default? | Client behavior | Server behavior | +| ------- | -------- | --------------- | --------------- | +| `McpProtocol.stable` | Yes | Uses stable `initialize` | Advertises stable protocol versions | +| `McpProtocol.preview2026` | No | Tries `server/discover`, then falls back to `initialize` | Advertises stable and 2026 RC protocol versions | +| `McpProtocol.require2026` | No | Requires 2026 RC discovery | Advertises only stateless 2026 RC protocol versions | + +## Low-level overrides + +The existing low-level options remain available for advanced callers: + +```dart +final client = McpClient( + const Implementation(name: 'my-client', version: '1.0.0'), + options: const McpClientOptions( + protocolVersion: draftProtocolVersion2026_07_28, + useServerDiscover: true, + ), +); +``` + +Prefer the `protocol` profile unless you need to target a specific protocol +version for tests or interoperability debugging. + +## 2026-only API areas + +The following features are MCP `2026-07-28` RC behavior and should be used only +after opting into a 2026 profile: + +- `server/discover` negotiation and stateless per-request metadata. +- `subscriptions/listen` stateless notification streams. +- Multi-result tool/resource/prompt flows such as `input_required`. +- MCP Tasks extension flows using `io.modelcontextprotocol/tasks`. +- Non-object `structuredContent` values and broader tool `outputSchema` shapes. +- Stateless result metadata such as `resultType`, `ttlMs`, and `cacheScope`. + +The RC API surface may still change before the official spec release. Keep +applications on the stable profile unless they specifically need RC behavior. diff --git a/doc/server-guide.md b/doc/server-guide.md index dd88d84a..4f97744b 100644 --- a/doc/server-guide.md +++ b/doc/server-guide.md @@ -63,6 +63,28 @@ final server = McpServer( ); ``` +### Protocol Profile + +Servers use the stable MCP `2025-11-25` profile by default. Opt into MCP +`2026-07-28` RC behavior with the preview profile: + +```dart +final server = McpServer( + const Implementation(name: 'my-server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), +); +``` + +`McpServerOptions(protocol: McpProtocol.preview2026)` advertises and accepts +2026 RC stateless protocol versions, including `server/discover`. Use +`McpServerOptions(protocol: McpProtocol.require2026)` when the server should +reject stable initialization. + ## Server Capabilities The server automatically advertises its capabilities based on what you register: diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index f793745c..863ede6b 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -15,23 +15,62 @@ class McpClientOptions extends ProtocolOptions { /// Capabilities to advertise as being supported by this client. final ClientCapabilities? capabilities; - /// Preferred protocol version for `server/discover` negotiation. - final String protocolVersion; + /// High-level protocol compatibility profile. + /// + /// Defaults to [McpProtocol.stable], which uses MCP 2025-11-25 behavior. + /// Set this to [McpProtocol.preview2026] to opt into MCP 2026-07-28 RC + /// negotiation with stable fallback. + final McpProtocol protocol; + + final String? _protocolVersion; + + /// Preferred protocol version for negotiation. + /// + /// When omitted, this is derived from [protocol]. Passing this explicitly is + /// a low-level override; most callers should prefer [protocol]. + String get protocolVersion { + final protocolVersion = _protocolVersion; + if (protocolVersion != null) { + return protocolVersion; + } + if (protocol == McpProtocol.stable && _useServerDiscover == true) { + return latestDraftProtocolVersion; + } + return protocol.preferredProtocolVersion; + } + + final bool? _useServerDiscover; /// Whether [McpClient.connect] should probe with `server/discover` first. - final bool useServerDiscover; + /// + /// When omitted, this is derived from [protocol]. Stable clients use the + /// legacy `initialize` flow by default; 2026 preview clients probe with + /// `server/discover`. + bool get useServerDiscover => + _useServerDiscover ?? protocol.useServerDiscoverByDefault; /// Whether a failed `server/discover` probe should fall back to the legacy /// `initialize` handshake when the peer looks like a pre-discovery server. - final bool allowLegacyInitializationFallback; + final bool? _allowLegacyInitializationFallback; + + /// Whether a failed `server/discover` probe should fall back to `initialize`. + /// + /// When omitted, this is derived from [protocol]. [McpProtocol.require2026] + /// disables fallback. + bool get allowLegacyInitializationFallback => + _allowLegacyInitializationFallback ?? + protocol.allowLegacyInitializationFallbackByDefault; const McpClientOptions({ super.enforceStrictCapabilities, this.capabilities, - this.protocolVersion = latestDraftProtocolVersion, - this.useServerDiscover = true, - this.allowLegacyInitializationFallback = true, - }); + this.protocol = McpProtocol.stable, + String? protocolVersion, + bool? useServerDiscover, + bool? allowLegacyInitializationFallback, + }) : _protocolVersion = protocolVersion, + _useServerDiscover = useServerDiscover, + _allowLegacyInitializationFallback = allowLegacyInitializationFallback; } /// Deprecated alias for [McpClientOptions]. @@ -191,8 +230,8 @@ class McpClient extends Protocol { McpClient(this._clientInfo, {McpClientOptions? options}) : _capabilities = options?.capabilities ?? const ClientCapabilities(), _preferredProtocolVersion = - options?.protocolVersion ?? latestDraftProtocolVersion, - _useServerDiscover = options?.useServerDiscover ?? true, + options?.protocolVersion ?? latestProtocolVersion, + _useServerDiscover = options?.useServerDiscover ?? false, _allowLegacyInitializationFallback = options?.allowLegacyInitializationFallback ?? true, super(options) { diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index b44fa6de..ab42a824 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -23,6 +23,17 @@ class McpServerOptions extends ProtocolOptions { /// Optional instructions describing how to use the server and its features. final String? instructions; + /// High-level protocol compatibility profile. + /// + /// Defaults to [McpProtocol.stable], which advertises stable MCP versions and + /// keeps MCP 2026 RC stateless behavior disabled unless explicitly requested. + /// Set this to [McpProtocol.preview2026] to enable draft-only stateless + /// methods such as `server/discover`. + final McpProtocol protocol; + + /// Protocol versions this server advertises and accepts for this profile. + List get supportedVersions => protocol.supportedVersions; + const McpServerOptions({ super.enforceStrictCapabilities, super.taskStore, @@ -31,6 +42,7 @@ class McpServerOptions extends ProtocolOptions { super.maxTaskQueueSize, this.capabilities, this.instructions, + this.protocol = McpProtocol.stable, }); } @@ -53,6 +65,7 @@ class Server extends Protocol { ServerCapabilities _capabilities; final String? _instructions; final Implementation _serverInfo; + final List _supportedVersions; /// Map of session IDs to their configured logging level. final Map _loggingLevels = {}; @@ -97,6 +110,8 @@ class Server extends Protocol { Server(this._serverInfo, {McpServerOptions? options}) : _capabilities = options?.capabilities ?? const ServerCapabilities(), _instructions = options?.instructions, + _supportedVersions = + options?.supportedVersions ?? McpProtocol.stable.supportedVersions, super(options) { setRequestHandler( Method.serverDiscover, @@ -160,7 +175,7 @@ class Server extends Protocol { ErrorCode.unsupportedProtocolVersion.value, 'Unsupported protocol version', { - 'supported': supportedProtocolVersionsWithDraft, + 'supported': _supportedVersions, 'requested': requestedVersion, }, ); @@ -185,7 +200,7 @@ class Server extends Protocol { 'Missing required request metadata: ${McpMetaKey.protocolVersion}', ); } - if (!supportedProtocolVersionsWithDraft.contains(requestedVersion)) { + if (!_supportedVersions.contains(requestedVersion)) { return _unsupportedProtocolVersionError(requestedVersion); } if (!isStatelessProtocolVersion(requestedVersion)) { @@ -732,8 +747,7 @@ class Server extends Protocol { final requestedProtocolVersion = request.meta?[McpMetaKey.protocolVersion]; if (requestedProtocolVersion is String && - !supportedProtocolVersionsWithDraft - .contains(requestedProtocolVersion)) { + !_supportedVersions.contains(requestedProtocolVersion)) { return _unsupportedProtocolVersionError(requestedProtocolVersion); } if (requestedProtocolVersion is String && @@ -752,6 +766,9 @@ class Server extends Protocol { } if (request.method == Method.initialize) { + if (!_supportsLegacyInitialization) { + return _unsupportedProtocolVersionError(latestProtocolVersion); + } if (_lifecycleState != _ServerLifecycleState.uninitialized) { return McpError( ErrorCode.invalidRequest.value, @@ -1053,9 +1070,14 @@ class Server extends Protocol { _clientCapabilities = params.capabilities; _clientVersion = params.clientInfo; - final protocolVersion = supportedProtocolVersions.contains(requestedVersion) + final stableSupportedVersions = _supportedVersions + .where((version) => !isStatelessProtocolVersion(version)) + .toList(); + final protocolVersion = stableSupportedVersions.contains(requestedVersion) ? requestedVersion - : latestProtocolVersion; + : stableSupportedVersions.isNotEmpty + ? stableSupportedVersions.first + : latestProtocolVersion; return InitializeResult( protocolVersion: protocolVersion, @@ -1065,6 +1087,12 @@ class Server extends Protocol { ); } + bool get _supportsLegacyInitialization { + return _supportedVersions.any( + (version) => !isStatelessProtocolVersion(version), + ); + } + ServerCapabilities _discoveryCapabilities() { final json = getCapabilities().toJson(); json.remove('tasks'); @@ -1074,7 +1102,7 @@ class Server extends Protocol { /// Handles the client's `server/discover` request. Future _onDiscover() async { return DiscoverResult( - supportedVersions: supportedProtocolVersionsWithDraft, + supportedVersions: _supportedVersions, capabilities: _discoveryCapabilities(), serverInfo: _serverInfo, instructions: _instructions, diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index 50dc659d..68a1609f 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -27,6 +27,71 @@ const latestProtocolVersion = stableProtocolVersion2025_11_25; /// The latest draft/RC protocol version implemented behind opt-in paths. const latestDraftProtocolVersion = draftProtocolVersion2026_07_28; +/// High-level MCP protocol compatibility profiles. +/// +/// The SDK defaults to [stable], which keeps the 2025 initialization flow and +/// avoids draft-only behavior. Use [preview2026] to prefer the 2026 RC while +/// falling back to stable MCP servers where possible. Use [require2026] when a +/// peer must support the 2026 RC stateless protocol. +enum McpProtocol { + /// Stable MCP behavior using the latest released specification. + /// + /// This is the default SDK profile and currently targets MCP 2025-11-25. + stable, + + /// Prefer the MCP 2026-07-28 RC when a peer supports it. + /// + /// This profile enables draft-only behavior such as `server/discover`, + /// stateless request metadata, and stateless result types, while allowing + /// fallback to the stable `initialize` flow for older peers. + preview2026, + + /// Require the MCP 2026-07-28 RC stateless protocol. + /// + /// This profile is intended for conformance tests and deployments where + /// connecting to older MCP servers would be a configuration error. + require2026; + + /// Preferred protocol version for outgoing negotiation. + String get preferredProtocolVersion { + return switch (this) { + McpProtocol.stable => latestProtocolVersion, + McpProtocol.preview2026 || + McpProtocol.require2026 => + latestDraftProtocolVersion, + }; + } + + /// Protocol versions this profile advertises or accepts. + List get supportedVersions { + return switch (this) { + McpProtocol.stable => supportedProtocolVersions, + McpProtocol.preview2026 => supportedProtocolVersionsWithDraft, + McpProtocol.require2026 => statelessProtocolVersions, + }; + } + + /// Whether clients should probe with `server/discover` by default. + bool get useServerDiscoverByDefault { + return switch (this) { + McpProtocol.stable => false, + McpProtocol.preview2026 || McpProtocol.require2026 => true, + }; + } + + /// Whether failed discovery should fall back to legacy initialization. + bool get allowLegacyInitializationFallbackByDefault { + return switch (this) { + McpProtocol.stable || McpProtocol.preview2026 => true, + McpProtocol.require2026 => false, + }; + } + + /// Whether this profile advertises support for stateless MCP versions. + bool get supportsStatelessProtocol => + supportedProtocolVersions.any(isStatelessProtocolVersion); +} + /// List of supported Model Context Protocol versions. const supportedProtocolVersions = [ latestProtocolVersion, diff --git a/test/client/client_test.dart b/test/client/client_test.dart index 78c0ac29..62dbbffd 100644 --- a/test/client/client_test.dart +++ b/test/client/client_test.dart @@ -112,7 +112,7 @@ void main() { transport.sentMessages .whereType() .map((message) => message.method), - containsAllInOrder([Method.serverDiscover, Method.initialize]), + [Method.initialize], ); // Verify that an initialized notification was sent diff --git a/test/client/streamable_https_test.dart b/test/client/streamable_https_test.dart index 1a1ce4b4..f819f6be 100644 --- a/test/client/streamable_https_test.dart +++ b/test/client/streamable_https_test.dart @@ -332,7 +332,6 @@ void main() { expect(initializeCount, 1); expect(initializedNotificationCount, 1); expect(capturedSessionHeaders, [ - null, preconfiguredSessionId, preconfiguredSessionId, ]); diff --git a/test/conformance/mcp_2026_rc_server.dart b/test/conformance/mcp_2026_rc_server.dart index 14cee0b6..737c657b 100644 --- a/test/conformance/mcp_2026_rc_server.dart +++ b/test/conformance/mcp_2026_rc_server.dart @@ -54,7 +54,11 @@ Future main(List args) async { } McpServer _createConformanceServer() { - final server = interop.createServer(); + final server = interop.createServer( + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), + ); server.registerTool( 'a_header_probe', diff --git a/test/interop/test_dart_server.dart b/test/interop/test_dart_server.dart index 4366cb70..a1d406dc 100644 --- a/test/interop/test_dart_server.dart +++ b/test/interop/test_dart_server.dart @@ -2,10 +2,11 @@ import 'dart:convert'; import 'dart:io'; import 'package:mcp_dart/mcp_dart.dart'; -McpServer createServer() { +McpServer createServer({McpServerOptions? options}) { // Define Server final server = McpServer( const Implementation(name: 'dart-test-server', version: '1.0.0'), + options: options, ); const metadataIcon = ImageContent( data: 'iVBORw0KGgo=', diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index dbea0fe1..5802836f 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -2251,6 +2251,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(listChanged: true), resources: ServerCapabilitiesResources(subscribe: true), @@ -2317,6 +2318,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(listChanged: true), ), @@ -2363,6 +2365,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(listChanged: true), ), @@ -2428,6 +2431,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( prompts: ServerCapabilitiesPrompts(), resources: ServerCapabilitiesResources(), @@ -2537,6 +2541,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( extensions: {mcpTasksExtensionId: {}}, ), @@ -2588,6 +2593,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( extensions: {mcpTasksExtensionId: {}}, ), @@ -2710,6 +2716,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: const ServerCapabilities( extensions: {mcpTasksExtensionId: {}}, ), @@ -2762,6 +2769,9 @@ void main() { () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); var handlerCalled = false; server.experimental.onGetTask((taskId, extra) async { @@ -2801,6 +2811,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( extensions: {mcpTasksExtensionId: {}}, ), @@ -2849,6 +2860,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tasks: ServerCapabilitiesTasks(list: true), extensions: {mcpTasksExtensionId: {}}, @@ -2900,6 +2912,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tasks: ServerCapabilitiesTasks( list: true, @@ -2933,6 +2946,9 @@ void main() { test('stateless tools/call ignores legacy task parameter', () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); server.registerTool( 'echo', @@ -2964,6 +2980,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), extensions: {mcpTasksExtensionId: {}}, @@ -3016,6 +3033,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), extensions: {mcpTasksExtensionId: {}}, @@ -3092,6 +3110,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), ), ); @@ -3140,6 +3159,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), extensions: {mcpTasksExtensionId: {}}, @@ -3191,6 +3211,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), extensions: {mcpTasksExtensionId: {}}, @@ -3262,6 +3283,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), extensions: {mcpTasksExtensionId: {}}, @@ -3313,6 +3335,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), extensions: {mcpTasksExtensionId: {}}, @@ -3372,6 +3395,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), ), ); @@ -3408,6 +3432,9 @@ void main() { () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); server.registerTool( 'needs_input', @@ -3495,6 +3522,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), ), ); @@ -3641,6 +3669,9 @@ void main() { test('stateless prompts/get permits input required results', () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); server.registerPrompt( 'needs_input', @@ -3667,6 +3698,9 @@ void main() { test('stateless resources/read permits input required results', () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); server.registerResource( 'needs_input', @@ -3697,6 +3731,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities(prompts: ServerCapabilitiesPrompts()), ), @@ -3733,6 +3768,9 @@ void main() { () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); final handler = CompletedTaskHandler(); server.experimental.registerToolTask( @@ -3762,6 +3800,9 @@ void main() { test('stateless tools/list omits legacy task execution metadata', () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); server.registerTool( 'echo', @@ -3786,6 +3827,7 @@ void main() { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), ), @@ -3826,6 +3868,9 @@ void main() { test('stateless tools/list returns tools sorted by name', () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); for (final name in ['zeta', 'alpha', 'middle']) { server.registerTool( @@ -3851,6 +3896,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tasks: ServerCapabilitiesTasks(), ), @@ -3878,6 +3924,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), ), @@ -3906,6 +3953,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), ), @@ -3954,6 +4002,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: const ServerCapabilities( tools: ServerCapabilitiesTools(), extensions: {mcpTasksExtensionId: {}}, @@ -4003,6 +4052,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), ), @@ -4124,6 +4174,9 @@ void main() { () async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); final transport = RecordingTransport(); await server.connect(transport); @@ -4148,6 +4201,9 @@ void main() { test('server rejects malformed stateless request metadata', () { final server = Server( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); McpError? validateToolRequest(Map? meta) { @@ -4282,6 +4338,9 @@ void main() { test('server rejects core RPCs removed from stateless MCP', () async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); final transport = RecordingTransport(); await server.connect(transport); @@ -4338,6 +4397,9 @@ void main() { test('server rejects notifications removed from stateless MCP', () async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); final errors = []; server.onerror = errors.add; @@ -4382,6 +4444,7 @@ void main() { server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( logging: {}, tools: ServerCapabilitiesTools(), @@ -4439,6 +4502,7 @@ void main() { server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( logging: {}, tools: ServerCapabilitiesTools(), @@ -4473,11 +4537,14 @@ void main() { expect(transport.sentMessages.single, isA()); }); - test('client defaults to server/discover and sends stateless metadata', + test('preview client uses server/discover and sends stateless metadata', () async { final transport = DiscoveringClientTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); await client.connect(transport); @@ -4509,6 +4576,9 @@ void main() { final transport = DiscoveringClientTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); await client.connect(transport); @@ -4542,11 +4612,10 @@ void main() { expect(transport.sentMessages, hasLength(sentBeforeCall)); }); - test('client can opt out of discovery for legacy initialization', () async { + test('client uses legacy initialization by default', () async { final transport = LegacyFallbackTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: false), ); await client.connect(transport); @@ -4594,6 +4663,9 @@ void main() { final transport = LegacyFallbackTransport(discoveryError: error); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); await client.connect(transport); @@ -4614,7 +4686,10 @@ void main() { final transport = DiscoveringClientTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); transport.sentMessages.clear(); @@ -4698,7 +4773,10 @@ void main() { final transport = DiscoveringClientTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); transport.sentMessages.clear(); @@ -4759,6 +4837,7 @@ void main() { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: const McpClientOptions( + protocol: McpProtocol.preview2026, capabilities: ClientCapabilities(roots: ClientCapabilitiesRoots()), useServerDiscover: true, ), @@ -4782,7 +4861,10 @@ void main() { final transport = DiscoveringClientTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); transport.sentMessages.clear(); @@ -4859,6 +4941,7 @@ void main() { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: const McpClientOptions( + protocol: McpProtocol.preview2026, capabilities: ClientCapabilities( elicitation: ClientElicitation.formOnly(), roots: ClientCapabilitiesRoots(), @@ -4956,6 +5039,7 @@ void main() { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: McpClientOptions( + protocol: McpProtocol.preview2026, capabilities: ClientCapabilities( extensions: withMcpTasksExtension(null), ), @@ -5069,6 +5153,7 @@ void main() { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: McpClientOptions( + protocol: McpProtocol.preview2026, capabilities: ClientCapabilities( elicitation: const ClientElicitation.formOnly(), extensions: withMcpTasksExtension(null), @@ -5193,6 +5278,7 @@ void main() { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: McpClientOptions( + protocol: McpProtocol.preview2026, capabilities: ClientCapabilities( elicitation: const ClientElicitation.formOnly(), extensions: withMcpTasksExtension(null), @@ -5248,6 +5334,9 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); await client.connect(transport); @@ -5322,6 +5411,9 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); await client.connect(transport); transport.sentMessages.clear(); @@ -5360,7 +5452,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5469,7 +5564,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5520,7 +5618,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5569,7 +5670,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5626,7 +5730,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5682,7 +5789,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5725,7 +5835,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5773,7 +5886,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5805,7 +5921,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5842,7 +5961,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5874,7 +5996,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5947,7 +6072,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5985,7 +6113,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -6039,7 +6170,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -6060,7 +6194,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -6077,7 +6214,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await expectLater( @@ -6101,6 +6241,7 @@ void main() { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: const McpClientOptions( + protocol: McpProtocol.preview2026, protocolVersion: '1900-01-01', useServerDiscover: true, ), @@ -6170,6 +6311,7 @@ void main() { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: McpClientOptions( + protocol: McpProtocol.preview2026, protocolVersion: scenario.requested, useServerDiscover: true, ), @@ -6200,6 +6342,9 @@ void main() { final transport = LegacyFallbackTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); await client.connect(transport); diff --git a/test/server/mcp_server_test.dart b/test/server/mcp_server_test.dart index fbbb9896..54515504 100644 --- a/test/server/mcp_server_test.dart +++ b/test/server/mcp_server_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:mcp_dart/src/server/mcp_server.dart'; +import 'package:mcp_dart/src/server/server.dart'; import 'package:mcp_dart/src/shared/transport.dart'; import 'package:mcp_dart/src/types.dart'; import 'package:test/test.dart'; @@ -133,6 +134,12 @@ void main() { test('connect syncs tool parameter header mappings to transports', () async { + server = McpServer( + const Implementation(name: 'test-server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), + ); server.registerTool( 'header-tool', inputSchema: const ToolInputSchema( @@ -216,6 +223,12 @@ void main() { }); test('invalid tool parameter header metadata is not synced', () async { + server = McpServer( + const Implementation(name: 'test-server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), + ); server.registerTool( 'non-string-header-tool', inputSchema: ToolInputSchema( @@ -287,6 +300,12 @@ void main() { test('invalid stateless header metadata is stripped from nested schemas', () async { + server = McpServer( + const Implementation(name: 'test-server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), + ); server.registerTool( 'nested-header-tool', inputSchema: ToolInputSchema.fromJson({ @@ -719,6 +738,12 @@ void main() { }); test('stateless resource miss uses 2026 invalid params error', () async { + server = McpServer( + const Implementation(name: 'test-server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), + ); server.registerResource( 'Known Resource', 'test://known', diff --git a/test/server/output_validation_test.dart b/test/server/output_validation_test.dart index 36eb82ac..4799381c 100644 --- a/test/server/output_validation_test.dart +++ b/test/server/output_validation_test.dart @@ -87,6 +87,15 @@ void main() { }); test('non-object output schema validates for MCP 2026 calls', () async { + mcpServer = McpServer( + const Implementation(name: 'TestServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), + ); mcpServer.registerTool( 'array_tool', outputSchema: JsonSchema.array(items: JsonSchema.string()), @@ -113,6 +122,15 @@ void main() { }); test('non-object output schema validation failures are rejected', () async { + mcpServer = McpServer( + const Implementation(name: 'TestServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), + ); mcpServer.registerTool( 'invalid_array_tool', outputSchema: JsonSchema.array(items: JsonSchema.string()), @@ -162,6 +180,15 @@ void main() { }); test('MCP 2026 tools/list includes non-object output schemas', () async { + mcpServer = McpServer( + const Implementation(name: 'TestServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), + ); mcpServer.registerTool( 'array_tool', outputSchema: JsonSchema.array(items: JsonSchema.string()), diff --git a/test/server/streamable_https_test.dart b/test/server/streamable_https_test.dart index 327a9e81..cce1f479 100644 --- a/test/server/streamable_https_test.dart +++ b/test/server/streamable_https_test.dart @@ -3187,6 +3187,9 @@ void main() { ); final server = Server( const Implementation(name: 'StatelessServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); addTearDown(server.close); await server.connect(transport); diff --git a/test/server/streamable_mcp_server_test.dart b/test/server/streamable_mcp_server_test.dart index 369375eb..7cc6083e 100644 --- a/test/server/streamable_mcp_server_test.dart +++ b/test/server/streamable_mcp_server_test.dart @@ -330,6 +330,9 @@ void main() { serverFactory: (sessionId) { final mcpServer = McpServer( const Implementation(name: 'StatelessServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); mcpServer.registerTool( 'echo', @@ -368,6 +371,9 @@ void main() { serverFactory: (sessionId) { final mcpServer = McpServer( const Implementation(name: 'JsonStatelessServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); mcpServer.registerTool( 'echo', @@ -408,6 +414,9 @@ void main() { serverFactory: (sessionId) { final mcpServer = McpServer( const Implementation(name: 'JsonStatelessServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); mcpServer.registerTool( 'echo', @@ -610,6 +619,9 @@ void main() { serverFactory: (sessionId) { final mcpServer = McpServer( const Implementation(name: 'StatelessServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); mcpServer.registerTool( 'echo', @@ -653,6 +665,7 @@ void main() { final mcpServer = McpServer( const Implementation(name: 'StatelessServer', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), extensions: {mcpTasksExtensionId: {}}, @@ -777,6 +790,9 @@ void main() { serverFactory: (sessionId) { final mcpServer = McpServer( const Implementation(name: 'StatelessServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); mcpServer.registerTool( 'echo', From dee1e7e52d431f2a877e37e74f81f228d2c672ee Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Wed, 3 Jun 2026 10:24:51 -0400 Subject: [PATCH 39/42] Fix CLI conformance profile fixtures --- .../lib/src/conformance_runner.dart | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/mcp_dart_cli/lib/src/conformance_runner.dart b/packages/mcp_dart_cli/lib/src/conformance_runner.dart index 7658bf6c..425b5cae 100644 --- a/packages/mcp_dart_cli/lib/src/conformance_runner.dart +++ b/packages/mcp_dart_cli/lib/src/conformance_runner.dart @@ -1303,6 +1303,7 @@ Future _serverDiscoverReturnsDraftCapabilities() async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), ), @@ -1354,6 +1355,9 @@ Future _rejectsUnsupportedStatelessProtocolVersion() async { final transport = _ConformanceTransport(); final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); await server.connect(transport); @@ -1403,6 +1407,9 @@ Future _statelessRequestsRequireCompleteRequestMeta() async { final transport = _ConformanceTransport(); final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); await server.connect(transport); @@ -1509,6 +1516,7 @@ Future _httpModernProtocolErrorsRetryDiscovery() async { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: const McpClientOptions( + protocol: McpProtocol.preview2026, protocolVersion: '1900-01-01', useServerDiscover: true, ), @@ -1599,6 +1607,7 @@ Future _httpModernMissingCapabilityErrorsDoNotFallback() async { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: const McpClientOptions( + protocol: McpProtocol.preview2026, protocolVersion: _draftProtocolVersion2026_07_28, useServerDiscover: true, ), @@ -1684,6 +1693,9 @@ Future _initializeNegotiatesStatefulProtocolVersion() async { final clientTransport = _ConformanceTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); final connectFuture = client.connect(clientTransport); await _settle(); @@ -1730,6 +1742,7 @@ Future _statelessDoesNotInferInitializeExtensions() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( extensions: >{ _tasksExtensionId: {}, @@ -2100,6 +2113,7 @@ Future _taskRequestsRequireStatelessHttpNameHeader() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( extensions: >{ _tasksExtensionId: {}, @@ -2833,6 +2847,7 @@ Future _taskSubscriptionRequiresClientCapability() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( extensions: >{ _tasksExtensionId: {}, @@ -2916,6 +2931,7 @@ Future _relatedTaskUsesExplicitIdAcrossTransports() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( extensions: >{ _tasksExtensionId: {}, @@ -3022,6 +3038,7 @@ Future _statelessIgnoresLegacyTaskParameter() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), tasks: ServerCapabilitiesTasks( @@ -3097,6 +3114,9 @@ Future _statelessClientRejectsLegacyTaskOptions() async { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); await client.connect(transport); @@ -3140,6 +3160,7 @@ Future _statelessAddsResultTypeAndCacheDefaults() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( prompts: ServerCapabilitiesPrompts(), resources: ServerCapabilitiesResources(), @@ -3297,6 +3318,7 @@ Future _statelessToolsListReturnsDeterministicOrder() async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), ), @@ -3355,6 +3377,7 @@ Future _statelessToolsListOmitsLegacyExecution() async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), ), @@ -3448,6 +3471,9 @@ Future _missingResourceErrorCodeByVersion() async { final statelessTransport = _ConformanceTransport(); final statelessServer = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); statelessServer.registerResource( 'Known Resource', @@ -3498,6 +3524,9 @@ Future _statelessRejectsUnrecognizedResultType() async { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); try { @@ -3538,6 +3567,7 @@ Future _mrtrInputRequiredSupportedRequests() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( prompts: ServerCapabilitiesPrompts(), resources: ServerCapabilitiesResources(), @@ -3647,6 +3677,7 @@ Future _mrtrRejectsUnsupportedInputRequiredResults() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), ), ); @@ -3688,6 +3719,7 @@ Future _mrtrInputRequestsRequireClientCapabilities() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), ), ); @@ -3863,6 +3895,7 @@ Future _callToolResultCannotSpoofTaskResult() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), extensions: >{ @@ -3945,6 +3978,9 @@ Future _taskResultRequiresClientExtension() async { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); try { @@ -3982,6 +4018,9 @@ Future _rejectsRemovedStatelessCoreRpcs() async { // ignore: deprecated_member_use final server = Server( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); await server.connect(transport); @@ -4117,6 +4156,7 @@ Future _statelessLoggingRequiresRequestLogLevel() async { server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( logging: {}, tools: ServerCapabilitiesTools(), @@ -4209,6 +4249,7 @@ Future _taskLifecycleMethodsAllowResumedClientCapability() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( extensions: >{ _tasksExtensionId: {}, @@ -4387,6 +4428,7 @@ McpServerOptions _mcpServerOptionsWithTaskStore({ { #capabilities: capabilities, #taskStore: taskStore, + #protocol: McpProtocol.preview2026, }, ) as McpServerOptions; } @@ -4399,6 +4441,7 @@ Future _subscriptionTaskIdsRequireClientCapability() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( extensions: >{ _tasksExtensionId: {}, @@ -4768,6 +4811,9 @@ Future _unadvertisedPeerMethodsUseMethodNotFound() async { ); final statelessClient = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); await statelessClient.connect(statelessClientTransport); statelessClientTransport.sentMessages.clear(); @@ -4937,6 +4983,7 @@ Future _statelessOmitsLegacyTaskCapabilities() async { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: const McpClientOptions( + protocol: McpProtocol.preview2026, capabilities: clientCapabilities, useServerDiscover: true, ), @@ -4978,7 +5025,10 @@ Future _statelessOmitsLegacyTaskCapabilities() async { // ignore: deprecated_member_use final server = Server( const Implementation(name: 'server', version: '1.0.0'), - options: const McpServerOptions(capabilities: serverCapabilities), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + capabilities: serverCapabilities, + ), ); await server.connect(serverTransport); serverTransport.emit( @@ -5655,6 +5705,7 @@ Future _advertisesDraftProtocolVersion() async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities(), ), ); From 7f44ddbd6bb94c6d160f53d96ee482c48748123b Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Wed, 3 Jun 2026 11:52:20 -0400 Subject: [PATCH 40/42] Prepare 2026 draft dev release --- .github/workflows/publish.yml | 1 + CHANGELOG.md | 8 +- README.md | 13 +- doc/client-guide.md | 4 +- doc/getting-started.md | 2 +- doc/mcp-2026-rc.md | 51 +++-- doc/quick-reference.md | 2 +- doc/server-guide.md | 5 +- doc/spec-coverage-2025-11-25.md | 2 +- doc/tools.md | 2 +- lib/src/client/client.dart | 10 +- lib/src/client/task_client.dart | 9 +- lib/src/server/mcp_server.dart | 95 ++++++-- lib/src/server/mcp_ui.dart | 10 +- lib/src/server/server.dart | 6 +- lib/src/shared/json_schema/json_schema.dart | 206 +++++++++++++----- .../json_schema/json_schema_validator.dart | 24 +- lib/src/types.dart | 1 + lib/src/types/content.dart | 6 +- lib/src/types/initialization.dart | 5 +- lib/src/types/json_rpc.dart | 36 +-- lib/src/types/json_value.dart | 90 ++++++++ lib/src/types/sampling.dart | 48 +++- lib/src/types/tools.dart | 94 ++++++-- packages/mcp_dart_cli/CHANGELOG.md | 7 + packages/mcp_dart_cli/README.md | 19 ++ .../lib/src/inspect_server_command.dart | 2 +- packages/mcp_dart_cli/lib/src/version.dart | 2 +- packages/mcp_dart_cli/pubspec.yaml | 8 +- packages/mcp_dart_cli/pubspec_overrides.yaml | 3 + .../fixtures/dart_mcp_project/pubspec.yaml | 2 +- pubspec.yaml | 2 +- test/client/client_tool_validation_test.dart | 8 +- test/conformance/README.md | 24 +- test/server/output_validation_test.dart | 22 +- test/shared/json_schema_from_json_test.dart | 18 +- test/shared/json_schema_validator_test.dart | 15 +- test/tool_schema_test.dart | 10 +- test/types/sampling_test.dart | 16 +- tool/validate_cli_publish.dart | 205 +++++++++++++++++ 40 files changed, 861 insertions(+), 232 deletions(-) create mode 100644 lib/src/types/json_value.dart create mode 100644 packages/mcp_dart_cli/pubspec_overrides.yaml create mode 100644 tool/validate_cli_publish.dart diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 33c1e50b..80ab9c2d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -43,6 +43,7 @@ jobs: rm -rf "$PUBLISH_ROOT" mkdir -p "$PUBLISH_ROOT" rsync -a --exclude .git --exclude .dart_tool --exclude pubspec.lock ./ "$PUBLISH_ROOT/" + rm -f "$PUBLISH_ROOT/packages/mcp_dart_cli/pubspec_overrides.yaml" echo "working_directory=$PUBLISH_ROOT/${{ steps.package-info.outputs.working_directory }}" >> "$GITHUB_OUTPUT" else echo "working_directory=${{ steps.package-info.outputs.working_directory }}" >> "$GITHUB_OUTPUT" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cc51a45..50a2fe4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ -## Unreleased +## 2.3.0-dev.0 -### MCP 2026-07-28 RC +### MCP 2026-07-28 draft/RC - Started the MCP 2026-07-28 RC development line with opt-in protocol constants, stateless request metadata helpers, and `server/discover` request @@ -13,6 +13,10 @@ `McpServerOptions(protocol: McpProtocol.preview2026)` while keeping the stable `initialize` flow as the default. The lower-level `protocolVersion` and `useServerDiscover` options remain available for interoperability testing. +- Kept stable public tool result APIs object-rooted while adding explicit + draft-only APIs for non-object values: `JsonValue`, + `CallToolResult.fromStructuredArray()`, `structuredContentJson`, and + server `outputJsonSchema`. - Added 2026 cacheable result support for `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, and `resources/read`, including stateless server defaults for `resultType`, `ttlMs`, and `cacheScope` while diff --git a/README.md b/README.md index bdf9aefd..2aaf94cb 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - mcp_dart: ^2.2.0 + mcp_dart: ^2.3.0-dev.0 ``` Then install dependencies: @@ -93,7 +93,8 @@ explicit stable profile. It's also backward compatible with previous versions including `2025-06-18`, `2025-03-26`, `2024-11-05`, and `2024-10-07`. -MCP `2026-07-28` RC support is available behind an explicit preview profile: +MCP `2026-07-28` draft/RC support is available behind an explicit preview +profile: ```dart final client = McpClient( @@ -111,9 +112,9 @@ final server = McpServer( ); ``` -Use the preview profile while the spec is still an RC. See the -[MCP 2026 RC transition guide](https://github.com/leehack/mcp_dart/blob/main/doc/mcp-2026-rc.md) -for opt-in behavior, fallback rules, and 2026-only APIs. +Use the preview profile while the spec is still a draft/RC. See the +[MCP 2026-07-28 draft/RC transition guide](https://github.com/leehack/mcp_dart/blob/main/doc/mcp-2026-rc.md) +for opt-in behavior, fallback rules, and draft-only APIs. ## Documentation @@ -136,7 +137,7 @@ for opt-in behavior, fallback rules, and 2026-only APIs. - 🧪 **[SDK Interoperability Matrix](https://github.com/leehack/mcp_dart/blob/main/doc/interoperability.md)** - Verified Dart/TypeScript and documented cross-SDK scenarios - ✅ **[MCP 2025-11-25 Spec Coverage Matrix](https://github.com/leehack/mcp_dart/blob/main/doc/spec-coverage-2025-11-25.md)** - Auditable coverage map with CLI conformance cases and known gaps -- 🧭 **[MCP 2026 RC Transition Guide](https://github.com/leehack/mcp_dart/blob/main/doc/mcp-2026-rc.md)** - Opt-in profile, fallback behavior, and draft-only APIs +- 🧭 **[MCP 2026-07-28 Draft/RC Transition Guide](https://github.com/leehack/mcp_dart/blob/main/doc/mcp-2026-rc.md)** - Opt-in profile, fallback behavior, and draft-only APIs - 🔒 **[Transport Security Recipes](https://github.com/leehack/mcp_dart/blob/main/doc/transports.md#dns-rebinding-protection)** - Host/Origin allowlists, OAuth layering, and compatibility-toggle trade-offs - 📱 **[Flutter Recipes](https://github.com/leehack/mcp_dart/blob/main/doc/flutter-recipes.md)** - Flutter Web, mobile, and desktop host/client guidance - 🔁 **[Migration Cookbooks](https://github.com/leehack/mcp_dart/blob/main/doc/migration-cookbooks.md)** - TypeScript SDK, `dart_mcp`, stdio-to-HTTP, and version migration paths diff --git a/doc/client-guide.md b/doc/client-guide.md index c13e6b2e..12460aee 100644 --- a/doc/client-guide.md +++ b/doc/client-guide.md @@ -63,7 +63,7 @@ final client = McpClient( ### Protocol Profile Clients use the stable MCP `2025-11-25` profile by default. Opt into MCP -`2026-07-28` RC behavior with the preview profile: +`2026-07-28` draft/RC behavior with the preview profile: ```dart final client = McpClient( @@ -178,7 +178,7 @@ final result = await client.callTool( ### Task-Augmented Tool Calls -For MCP 2026 stateless servers that advertise the +For MCP `2026-07-28` draft/RC stateless servers that advertise the `io.modelcontextprotocol/tasks` extension, task creation is server-directed. Call `client.callTool()` normally, or call `TaskClient.callToolStream()` without the legacy `task` argument; the client follows `resultType: "task"` with diff --git a/doc/getting-started.md b/doc/getting-started.md index b562e467..413a910a 100644 --- a/doc/getting-started.md +++ b/doc/getting-started.md @@ -8,7 +8,7 @@ Add the MCP Dart SDK to your `pubspec.yaml`: ```yaml dependencies: - mcp_dart: ^2.2.0 + mcp_dart: ^2.3.0-dev.0 ``` Then run: diff --git a/doc/mcp-2026-rc.md b/doc/mcp-2026-rc.md index 58ea430b..3f5b91e8 100644 --- a/doc/mcp-2026-rc.md +++ b/doc/mcp-2026-rc.md @@ -1,14 +1,14 @@ -# MCP 2026 RC Transition Guide +# MCP 2026-07-28 Draft/RC Transition Guide `mcp_dart` defaults to the latest stable MCP specification, currently -`2025-11-25`. MCP `2026-07-28` RC support is available through explicit +`2025-11-25`. MCP `2026-07-28` draft/RC support is available through explicit protocol profiles so applications can adopt the draft without changing stable deployments. ## Client opt-in -Use the preview profile when you want the client to prefer MCP `2026-07-28` RC -and fall back to stable MCP servers when discovery is unavailable: +Use the preview profile when you want the client to prefer MCP `2026-07-28` +draft/RC and fall back to stable MCP servers when discovery is unavailable: ```dart final client = McpClient( @@ -20,8 +20,9 @@ final client = McpClient( ``` `McpClientOptions(protocol: McpProtocol.preview2026)` enables -`server/discover`, sends the 2026 stateless request metadata, and falls back to -the legacy `initialize` flow when the peer looks like a stable-only MCP server. +`server/discover`, sends the `2026-07-28` draft/RC stateless request metadata, +and falls back to the legacy `initialize` flow when the peer looks like a +stable-only MCP server. Use the strict profile for conformance tests or deployments where fallback is not acceptable: @@ -37,7 +38,7 @@ final client = McpClient( ## Server opt-in -Use the server preview profile to advertise and accept MCP `2026-07-28` RC +Use the server preview profile to advertise and accept MCP `2026-07-28` draft/RC stateless requests: ```dart @@ -57,8 +58,8 @@ stateless protocol versions. | Profile | Default? | Client behavior | Server behavior | | ------- | -------- | --------------- | --------------- | | `McpProtocol.stable` | Yes | Uses stable `initialize` | Advertises stable protocol versions | -| `McpProtocol.preview2026` | No | Tries `server/discover`, then falls back to `initialize` | Advertises stable and 2026 RC protocol versions | -| `McpProtocol.require2026` | No | Requires 2026 RC discovery | Advertises only stateless 2026 RC protocol versions | +| `McpProtocol.preview2026` | No | Tries `server/discover`, then falls back to `initialize` | Advertises stable and `2026-07-28` draft/RC protocol versions | +| `McpProtocol.require2026` | No | Requires `2026-07-28` draft/RC discovery | Advertises only stateless `2026-07-28` draft/RC protocol versions | ## Low-level overrides @@ -77,17 +78,37 @@ final client = McpClient( Prefer the `protocol` profile unless you need to target a specific protocol version for tests or interoperability debugging. -## 2026-only API areas +## 2026-07-28 Draft-Only API Areas -The following features are MCP `2026-07-28` RC behavior and should be used only -after opting into a 2026 profile: +The following features are MCP `2026-07-28` draft/RC behavior and should be +used only after opting into a `2026-07-28` profile: - `server/discover` negotiation and stateless per-request metadata. - `subscriptions/listen` stateless notification streams. - Multi-result tool/resource/prompt flows such as `input_required`. - MCP Tasks extension flows using `io.modelcontextprotocol/tasks`. -- Non-object `structuredContent` values and broader tool `outputSchema` shapes. +- Non-object `structuredContent` values via `JsonValue` and broader server + `outputJsonSchema` shapes. - Stateless result metadata such as `resultType`, `ttlMs`, and `cacheScope`. -The RC API surface may still change before the official spec release. Keep -applications on the stable profile unless they specifically need RC behavior. +For non-object tool results, keep the stable object-root APIs for stable MCP +callers and use the explicitly named draft APIs: + +```dart +server.registerTool( + 'array-result', + outputJsonSchema: JsonSchema.array(items: JsonSchema.string()), + callback: (args, extra) { + return CallToolResult.fromStructuredArray(['alpha', 'beta']); + }, +); + +final result = await client.callTool( + const CallToolRequest(name: 'array-result'), +); +final items = result.structuredContentJson?.asArray; +``` + +The draft/RC API surface may still change before the official spec release. +Keep applications on the stable profile unless they specifically need draft +behavior. diff --git a/doc/quick-reference.md b/doc/quick-reference.md index b68aeedc..2644cf37 100644 --- a/doc/quick-reference.md +++ b/doc/quick-reference.md @@ -7,7 +7,7 @@ Fast lookup guide for common MCP Dart SDK operations. ```yaml # pubspec.yaml dependencies: - mcp_dart: ^2.2.0 + mcp_dart: ^2.3.0-dev.0 ``` ```bash diff --git a/doc/server-guide.md b/doc/server-guide.md index 4f97744b..be8ae899 100644 --- a/doc/server-guide.md +++ b/doc/server-guide.md @@ -66,7 +66,7 @@ final server = McpServer( ### Protocol Profile Servers use the stable MCP `2025-11-25` profile by default. Opt into MCP -`2026-07-28` RC behavior with the preview profile: +`2026-07-28` draft/RC behavior with the preview profile: ```dart final server = McpServer( @@ -81,7 +81,8 @@ final server = McpServer( ``` `McpServerOptions(protocol: McpProtocol.preview2026)` advertises and accepts -2026 RC stateless protocol versions, including `server/discover`. Use +`2026-07-28` draft/RC stateless protocol versions, including +`server/discover`. Use `McpServerOptions(protocol: McpProtocol.require2026)` when the server should reject stable initialization. diff --git a/doc/spec-coverage-2025-11-25.md b/doc/spec-coverage-2025-11-25.md index fa9a21f4..cc561264 100644 --- a/doc/spec-coverage-2025-11-25.md +++ b/doc/spec-coverage-2025-11-25.md @@ -25,7 +25,7 @@ cd ../../.. dart test -t interop ``` -For MCP 2026 RC/final release audits, also run the upstream +For MCP `2026-07-28` draft/RC or final release audits, also run the upstream machine-readable example corpus through the checked-in typed parsers after extracting the upstream `modelcontextprotocol` archive: diff --git a/doc/tools.md b/doc/tools.md index 88a24a1c..a2f57938 100644 --- a/doc/tools.md +++ b/doc/tools.md @@ -514,7 +514,7 @@ final server = McpServer( ``` Clients that call task-augmented tools can use `TaskClient.callToolStream()`. -With MCP 2026 stateless servers that advertise +With MCP `2026-07-28` draft/RC stateless servers that advertise `io.modelcontextprotocol/tasks`, omit the legacy `task` argument; task creation is server-directed and the client follows the extension polling flow transparently. diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 863ede6b..e899bcc9 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -18,8 +18,8 @@ class McpClientOptions extends ProtocolOptions { /// High-level protocol compatibility profile. /// /// Defaults to [McpProtocol.stable], which uses MCP 2025-11-25 behavior. - /// Set this to [McpProtocol.preview2026] to opt into MCP 2026-07-28 RC - /// negotiation with stable fallback. + /// Set this to [McpProtocol.preview2026] to opt into MCP `2026-07-28` + /// draft/RC negotiation with stable fallback. final McpProtocol protocol; final String? _protocolVersion; @@ -44,8 +44,8 @@ class McpClientOptions extends ProtocolOptions { /// Whether [McpClient.connect] should probe with `server/discover` first. /// /// When omitted, this is derived from [protocol]. Stable clients use the - /// legacy `initialize` flow by default; 2026 preview clients probe with - /// `server/discover`. + /// legacy `initialize` flow by default; `2026-07-28` draft/RC preview + /// clients probe with `server/discover`. bool get useServerDiscover => _useServerDiscover ?? protocol.useServerDiscoverByDefault; @@ -1475,7 +1475,7 @@ class McpClient extends Protocol { final outputSchema = _cachedToolOutputSchemas[params.name]; if (outputSchema != null && !result.isError) { try { - outputSchema.validate(result.structuredContent); + outputSchema.validate(result.structuredContentJson?.toJson()); } catch (e) { throw McpError( ErrorCode.invalidParams.value, diff --git a/lib/src/client/task_client.dart b/lib/src/client/task_client.dart index 14a628d1..ce395a99 100644 --- a/lib/src/client/task_client.dart +++ b/lib/src/client/task_client.dart @@ -57,10 +57,11 @@ class TaskClient { /// and long-running tasks (yielding [TaskCreatedMessage], multiple /// [TaskStatusMessage]s, and finally [TaskResultMessage]). /// - /// For MCP 2026 stateless sessions with the `io.modelcontextprotocol/tasks` - /// extension, task creation is server-directed and [task] must be omitted. - /// The call is routed through [McpClient.callTool], which transparently - /// follows the extension polling flow and yields the final tool result. + /// For MCP `2026-07-28` draft/RC stateless sessions with the + /// `io.modelcontextprotocol/tasks` extension, task creation is + /// server-directed and [task] must be omitted. The call is routed through + /// [McpClient.callTool], which transparently follows the extension polling + /// flow and yields the final tool result. /// /// For MCP 2025-11-25 legacy tasks, [task] is used for task augmentation. /// Pass task creation parameters (e.g., `{'ttl': 60000, 'pollInterval': 50}`) diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index 751d43cd..49b71356 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -67,21 +67,34 @@ JsonSchema? _outputSchemaForProtocol( return schema; } - // MCP 2025-11-25 restricts tool output schemas to object roots. MCP 2026 - // allows any JSON Schema, so omit non-object schemas for stable callers. + // MCP 2025-11-25 restricts tool output schemas to object roots. MCP + // 2026-07-28 draft/RC allows any JSON Schema, so omit non-object schemas for + // stable callers. if (schema.toJson()['type'] == 'object') { return schema; } return null; } +JsonSchema? _resolveToolOutputJsonSchema( + ToolOutputSchema? outputSchema, + JsonSchema? outputJsonSchema, +) { + if (outputSchema != null && outputJsonSchema != null) { + throw ArgumentError( + 'Specify only one of outputSchema or outputJsonSchema.', + ); + } + return outputJsonSchema ?? outputSchema; +} + CallToolResult _toolResultForProtocol( CallToolResult result, String? protocolVersion, ) { if (_isDraft2026Request(protocolVersion) || !result.hasStructuredContent || - _isStableStructuredContentValue(result.structuredContent)) { + _isStableStructuredContentValue(result.structuredContentJson?.toJson())) { return result; } @@ -278,7 +291,7 @@ CallToolResult _withRelatedTaskMeta(CallToolResult result, String taskId) { return CallToolResult( content: result.content, isError: result.isError, - structuredContent: result.structuredContent, + structuredContentJson: result.structuredContentJson, hasStructuredContent: result.hasStructuredContent, meta: meta, extra: result.extra, @@ -576,8 +589,18 @@ abstract class RegisteredTool { /// The input schema for the tool. ToolInputSchema? get inputSchema; - /// The output schema for the tool. - JsonSchema? get outputSchema; + /// The object-root output schema for stable MCP `2025-11-25` tool results. + /// + /// MCP `2026-07-28` draft/RC allows non-object JSON Schema roots. Use + /// [outputJsonSchema] for that wire-level schema. + ToolOutputSchema? get outputSchema; + + /// The wire-level output schema for this tool. + /// + /// This may be any JSON Schema when the server is using the explicit + /// MCP `2026-07-28` draft/RC profile. Stable MCP `2025-11-25` callers only + /// receive this schema when it has an object root. + JsonSchema? get outputJsonSchema; /// Annotations for the tool. ToolAnnotations? get annotations; @@ -606,7 +629,8 @@ abstract class RegisteredTool { String? title, String? description, ToolInputSchema? inputSchema, - JsonSchema? outputSchema, + ToolOutputSchema? outputSchema, + JsonSchema? outputJsonSchema, ToolAnnotations? annotations, ToolExecution? execution, ToolCallback? callback, @@ -623,8 +647,7 @@ class _RegisteredToolImpl implements RegisteredTool { String? description; @override ToolInputSchema? inputSchema; - @override - JsonSchema? outputSchema; + JsonSchema? _outputJsonSchema; @override ToolAnnotations? annotations; final ImageContent? icon; @@ -644,16 +667,28 @@ class _RegisteredToolImpl implements RegisteredTool { this.title, this.description, this.inputSchema, - this.outputSchema, + JsonSchema? outputJsonSchema, this.annotations, this.icon, this.meta, this.execution, required this.callback, - }) { + }) : _outputJsonSchema = outputJsonSchema { _server._registeredTools[name] = this; } + @override + ToolOutputSchema? get outputSchema { + final schema = _outputJsonSchema; + if (schema is JsonObject) { + return schema; + } + return null; + } + + @override + JsonSchema? get outputJsonSchema => _outputJsonSchema; + Tool toTool({ bool includeExecution = true, ToolInputSchema? inputSchemaOverride, @@ -665,7 +700,7 @@ class _RegisteredToolImpl implements RegisteredTool { description: description, inputSchema: inputSchemaOverride ?? inputSchema ?? const ToolInputSchema(), - outputSchema: _outputSchemaForProtocol(outputSchema, protocolVersion), + outputSchema: _outputSchemaForProtocol(outputJsonSchema, protocolVersion), annotations: annotations, icon: icon, icons: _iconsFromLegacyImage(icon), @@ -689,7 +724,8 @@ class _RegisteredToolImpl implements RegisteredTool { String? title, String? description, ToolInputSchema? inputSchema, - JsonSchema? outputSchema, + ToolOutputSchema? outputSchema, + JsonSchema? outputJsonSchema, ToolAnnotations? annotations, ToolExecution? execution, ToolCallback? callback, @@ -706,7 +742,11 @@ class _RegisteredToolImpl implements RegisteredTool { if (title != null) this.title = title; if (description != null) this.description = description; if (inputSchema != null) this.inputSchema = inputSchema; - if (outputSchema != null) this.outputSchema = outputSchema; + final nextOutputJsonSchema = + _resolveToolOutputJsonSchema(outputSchema, outputJsonSchema); + if (nextOutputJsonSchema != null) { + _outputJsonSchema = nextOutputJsonSchema; + } if (annotations != null) this.annotations = annotations; if (execution != null) this.execution = execution; if (callback != null) this.callback = callback; @@ -847,7 +887,8 @@ class ExperimentalMcpServerTasks { String? title, String? description, ToolInputSchema? inputSchema, - JsonSchema? outputSchema, + ToolOutputSchema? outputSchema, + JsonSchema? outputJsonSchema, ToolAnnotations? annotations, Map? meta, ToolExecution? execution, @@ -898,6 +939,7 @@ class ExperimentalMcpServerTasks { description: description, inputSchema: inputSchema, outputSchema: outputSchema, + outputJsonSchema: outputJsonSchema, annotations: annotations, meta: meta, execution: effectiveExecution, @@ -1611,11 +1653,12 @@ class McpServer { ); } - if (registeredTool.outputSchema != null && result is CallToolResult) { + if (registeredTool.outputJsonSchema != null && + result is CallToolResult) { if (result.isError != true) { try { - registeredTool.outputSchema!.validate( - result.structuredContent, + registeredTool.outputJsonSchema!.validate( + result.structuredContentJson?.toJson(), ); } catch (e) { throw McpError( @@ -2052,7 +2095,9 @@ class McpServer { /// [title] is a human-readable title. /// [description] explains what the tool does. /// [inputSchema] defines the expected arguments. - /// [outputSchema] defines the expected result structure. + /// [outputSchema] defines the stable object-root result structure. + /// [outputJsonSchema] defines an MCP `2026-07-28` draft/RC result structure + /// whose JSON Schema root may be any valid JSON Schema type. /// [annotations] provides additional metadata. /// [callback] is the function executed when the tool is called. RegisteredTool registerTool( @@ -2060,7 +2105,8 @@ class McpServer { String? title, String? description, ToolInputSchema? inputSchema, - JsonSchema? outputSchema, + ToolOutputSchema? outputSchema, + JsonSchema? outputJsonSchema, ToolAnnotations? annotations, Map? meta, required ToolFunction callback, @@ -2071,6 +2117,7 @@ class McpServer { description: description, inputSchema: inputSchema, outputSchema: outputSchema, + outputJsonSchema: outputJsonSchema, annotations: annotations, meta: meta, execution: const ToolExecution(taskSupport: 'forbidden'), @@ -2084,7 +2131,8 @@ class McpServer { String? title, String? description, ToolInputSchema? inputSchema, - JsonSchema? outputSchema, + ToolOutputSchema? outputSchema, + JsonSchema? outputJsonSchema, ToolAnnotations? annotations, Map? meta, ToolExecution? execution, @@ -2100,7 +2148,8 @@ class McpServer { title: title, description: description, inputSchema: inputSchema, - outputSchema: outputSchema, + outputJsonSchema: + _resolveToolOutputJsonSchema(outputSchema, outputJsonSchema), annotations: annotations, meta: meta, execution: execution, @@ -2227,7 +2276,7 @@ class McpServer { ), ) : null), - outputSchema: toolOutputSchema ?? + outputJsonSchema: toolOutputSchema ?? (outputSchemaProperties != null ? ToolOutputSchema( properties: outputSchemaProperties.map( diff --git a/lib/src/server/mcp_ui.dart b/lib/src/server/mcp_ui.dart index bf69e4c2..90a58671 100644 --- a/lib/src/server/mcp_ui.dart +++ b/lib/src/server/mcp_ui.dart @@ -10,7 +10,13 @@ class McpUiAppToolConfig { final String? title; final String? description; final ToolInputSchema? inputSchema; - final JsonSchema? outputSchema; + + /// Stable MCP `2025-11-25` object-root output schema. + final ToolOutputSchema? outputSchema; + + /// MCP `2026-07-28` draft/RC output schema with any JSON Schema root. + final JsonSchema? outputJsonSchema; + final ToolAnnotations? annotations; final Map meta; @@ -19,6 +25,7 @@ class McpUiAppToolConfig { this.description, this.inputSchema, this.outputSchema, + this.outputJsonSchema, this.annotations, required this.meta, }); @@ -90,6 +97,7 @@ RegisteredTool registerAppTool( description: config.description, inputSchema: config.inputSchema, outputSchema: config.outputSchema, + outputJsonSchema: config.outputJsonSchema, annotations: config.annotations, meta: normalizedMeta, callback: callback, diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index ab42a824..1809540d 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -26,9 +26,9 @@ class McpServerOptions extends ProtocolOptions { /// High-level protocol compatibility profile. /// /// Defaults to [McpProtocol.stable], which advertises stable MCP versions and - /// keeps MCP 2026 RC stateless behavior disabled unless explicitly requested. - /// Set this to [McpProtocol.preview2026] to enable draft-only stateless - /// methods such as `server/discover`. + /// keeps MCP `2026-07-28` draft/RC stateless behavior disabled unless + /// explicitly requested. Set this to [McpProtocol.preview2026] to enable + /// draft-only stateless methods such as `server/discover`. final McpProtocol protocol; /// Protocol versions this server advertises and accepts for this profile. diff --git a/lib/src/shared/json_schema/json_schema.dart b/lib/src/shared/json_schema/json_schema.dart index 258298b8..8b028197 100644 --- a/lib/src/shared/json_schema/json_schema.dart +++ b/lib/src/shared/json_schema/json_schema.dart @@ -13,6 +13,29 @@ int? _readOptionalInteger(Object? value, String field) { throw FormatException('$field must be an integer'); } +num? _readOptionalFiniteNumber(Object? value, String field) { + if (value == null) { + return null; + } + if (value is num && value.isFinite) { + return value; + } + throw FormatException('$field must be a finite JSON number'); +} + +int? _integerApiValue(num? value) { + if (value == null) { + return null; + } + if (value is int) { + return value; + } + if (value.isFinite && value == value.truncateToDouble()) { + return value.toInt(); + } + return null; +} + /// A builder for creating JSON Schemas in a type-safe way. sealed class JsonSchema { final String? title; @@ -20,8 +43,8 @@ sealed class JsonSchema { /// The default value for this schema. /// - /// The type of this value depends on the schema type (e.g., [String] for [JsonString], - /// [num] for [JsonNumber] and [JsonInteger], etc.). + /// The type of this value depends on the schema type (e.g., [String] for + /// [JsonString], [num] for [JsonNumber], [int] for [JsonInteger], etc.). dynamic get defaultValue; const JsonSchema({this.title, this.description}); @@ -302,14 +325,14 @@ sealed class JsonSchema { /// Creates an integer schema. static JsonInteger integer({ - num? minimum, - num? maximum, - num? exclusiveMinimum, - num? exclusiveMaximum, - num? multipleOf, + int? minimum, + int? maximum, + int? exclusiveMinimum, + int? exclusiveMaximum, + int? multipleOf, String? title, String? description, - num? defaultValue, + int? defaultValue, String? mcpHeader, }) { return JsonInteger( @@ -597,8 +620,9 @@ class JsonNumber extends JsonSchema { /// MCP `x-mcp-header` extension metadata. /// - /// MCP 2026 stateless Streamable HTTP clients mirror finite number argument - /// values into `Mcp-Param-*` headers when this metadata is present. + /// MCP `2026-07-28` draft/RC stateless Streamable HTTP clients mirror finite + /// number argument values into `Mcp-Param-*` headers when this metadata is + /// present. final String? mcpHeader; const JsonNumber({ @@ -675,60 +699,143 @@ class JsonInteger extends JsonSchema { final bool _hasDefault; final bool _hasMcpHeader; final Object? _rawMcpHeader; - final num? minimum; - final num? maximum; - final num? exclusiveMinimum; - final num? exclusiveMaximum; - final num? multipleOf; + final num? _minimum; + final num? _maximum; + final num? _exclusiveMinimum; + final num? _exclusiveMaximum; + final num? _multipleOf; + final num? _defaultValue; + + /// The stable Dart API value for the JSON Schema `minimum` constraint. + /// + /// This is `null` when a parsed wire schema uses a fractional numeric value. + /// Use [minimumJson] when validating or reserializing raw JSON Schema data. + int? get minimum => _integerApiValue(_minimum); + + /// The stable Dart API value for the JSON Schema `maximum` constraint. + /// + /// This is `null` when a parsed wire schema uses a fractional numeric value. + /// Use [maximumJson] when validating or reserializing raw JSON Schema data. + int? get maximum => _integerApiValue(_maximum); + + /// The stable Dart API value for the JSON Schema `exclusiveMinimum` + /// constraint. + /// + /// This is `null` when a parsed wire schema uses a fractional numeric value. + /// Use [exclusiveMinimumJson] when validating or reserializing raw JSON Schema + /// data. + int? get exclusiveMinimum => _integerApiValue(_exclusiveMinimum); + + /// The stable Dart API value for the JSON Schema `exclusiveMaximum` + /// constraint. + /// + /// This is `null` when a parsed wire schema uses a fractional numeric value. + /// Use [exclusiveMaximumJson] when validating or reserializing raw JSON Schema + /// data. + int? get exclusiveMaximum => _integerApiValue(_exclusiveMaximum); + + /// The stable Dart API value for the JSON Schema `multipleOf` constraint. + /// + /// This is `null` when a parsed wire schema uses a fractional numeric value. + /// Use [multipleOfJson] when validating or reserializing raw JSON Schema data. + int? get multipleOf => _integerApiValue(_multipleOf); + + /// Raw JSON Schema `minimum` constraint as parsed from the wire. + num? get minimumJson => _minimum; + + /// Raw JSON Schema `maximum` constraint as parsed from the wire. + num? get maximumJson => _maximum; + + /// Raw JSON Schema `exclusiveMinimum` constraint as parsed from the wire. + num? get exclusiveMinimumJson => _exclusiveMinimum; + + /// Raw JSON Schema `exclusiveMaximum` constraint as parsed from the wire. + num? get exclusiveMaximumJson => _exclusiveMaximum; + + /// Raw JSON Schema `multipleOf` constraint as parsed from the wire. + num? get multipleOfJson => _multipleOf; + + /// Raw JSON Schema `default` value as parsed from the wire. + num? get defaultValueJson => _defaultValue; /// MCP `x-mcp-header` extension for mirroring this parameter into HTTP. final String? mcpHeader; const JsonInteger({ - this.minimum, - this.maximum, - this.exclusiveMinimum, - this.exclusiveMaximum, - this.multipleOf, - this.defaultValue, + int? minimum, + int? maximum, + int? exclusiveMinimum, + int? exclusiveMaximum, + int? multipleOf, + int? defaultValue, super.title, super.description, this.mcpHeader, - }) : _hasDefault = defaultValue != null, + }) : _minimum = minimum, + _maximum = maximum, + _exclusiveMinimum = exclusiveMinimum, + _exclusiveMaximum = exclusiveMaximum, + _multipleOf = multipleOf, + _defaultValue = defaultValue, + _hasDefault = defaultValue != null, _hasMcpHeader = mcpHeader != null, _rawMcpHeader = mcpHeader; const JsonInteger._({ - this.minimum, - this.maximum, - this.exclusiveMinimum, - this.exclusiveMaximum, - this.multipleOf, - this.defaultValue, + num? minimum, + num? maximum, + num? exclusiveMinimum, + num? exclusiveMaximum, + num? multipleOf, + num? defaultValue, super.title, super.description, this.mcpHeader, required Object? rawMcpHeader, required bool hasDefault, required bool hasMcpHeader, - }) : _hasDefault = hasDefault, + }) : _minimum = minimum, + _maximum = maximum, + _exclusiveMinimum = exclusiveMinimum, + _exclusiveMaximum = exclusiveMaximum, + _multipleOf = multipleOf, + _defaultValue = defaultValue, + _hasDefault = hasDefault, _hasMcpHeader = hasMcpHeader, _rawMcpHeader = rawMcpHeader; @override - final num? defaultValue; + int? get defaultValue => _integerApiValue(_defaultValue); factory JsonInteger.fromJson(Map json) { final rawMcpHeader = json['x-mcp-header']; return JsonInteger._( - minimum: json['minimum'] as num?, - maximum: json['maximum'] as num?, - exclusiveMinimum: json['exclusiveMinimum'] as num?, - exclusiveMaximum: json['exclusiveMaximum'] as num?, - multipleOf: json['multipleOf'] as num?, + minimum: _readOptionalFiniteNumber( + json['minimum'], + 'JsonInteger.minimum', + ), + maximum: _readOptionalFiniteNumber( + json['maximum'], + 'JsonInteger.maximum', + ), + exclusiveMinimum: _readOptionalFiniteNumber( + json['exclusiveMinimum'], + 'JsonInteger.exclusiveMinimum', + ), + exclusiveMaximum: _readOptionalFiniteNumber( + json['exclusiveMaximum'], + 'JsonInteger.exclusiveMaximum', + ), + multipleOf: _readOptionalFiniteNumber( + json['multipleOf'], + 'JsonInteger.multipleOf', + ), title: json['title'] as String?, description: json['description'] as String?, - defaultValue: json['default'] as num?, + defaultValue: _readOptionalFiniteNumber( + json['default'], + 'JsonInteger.default', + ), mcpHeader: rawMcpHeader is String ? rawMcpHeader : null, rawMcpHeader: rawMcpHeader, hasDefault: json.containsKey('default'), @@ -741,13 +848,15 @@ class JsonInteger extends JsonSchema { return { if (title != null) 'title': title, if (description != null) 'description': description, - if (_hasDefault) 'default': defaultValue, + if (_hasDefault) 'default': defaultValueJson, 'type': 'integer', - if (minimum != null) 'minimum': minimum, - if (maximum != null) 'maximum': maximum, - if (exclusiveMinimum != null) 'exclusiveMinimum': exclusiveMinimum, - if (exclusiveMaximum != null) 'exclusiveMaximum': exclusiveMaximum, - if (multipleOf != null) 'multipleOf': multipleOf, + if (minimumJson != null) 'minimum': minimumJson, + if (maximumJson != null) 'maximum': maximumJson, + if (exclusiveMinimumJson != null) + 'exclusiveMinimum': exclusiveMinimumJson, + if (exclusiveMaximumJson != null) + 'exclusiveMaximum': exclusiveMaximumJson, + if (multipleOfJson != null) 'multipleOf': multipleOfJson, if (_hasMcpHeader) 'x-mcp-header': _rawMcpHeader, }; } @@ -1239,13 +1348,12 @@ class JsonUnion extends JsonSchema { multipleOf: null, ) => 'number', - JsonInteger( - minimum: null, - maximum: null, - exclusiveMinimum: null, - exclusiveMaximum: null, - multipleOf: null, - ) => + JsonInteger() + when schema.minimumJson == null && + schema.maximumJson == null && + schema.exclusiveMinimumJson == null && + schema.exclusiveMaximumJson == null && + schema.multipleOfJson == null => 'integer', JsonBoolean _ => 'boolean', JsonNull _ => 'null', diff --git a/lib/src/shared/json_schema/json_schema_validator.dart b/lib/src/shared/json_schema/json_schema_validator.dart index b3bbf745..38bdad82 100644 --- a/lib/src/shared/json_schema/json_schema_validator.dart +++ b/lib/src/shared/json_schema/json_schema_validator.dart @@ -159,38 +159,40 @@ extension JsonSchemaValidation on JsonSchema { ); } - if (schema.minimum != null && data < schema.minimum!) { + if (schema.minimumJson != null && data < schema.minimumJson!) { throw JsonSchemaValidationException( - 'Value must be >= ${schema.minimum}', + 'Value must be >= ${schema.minimumJson}', path, ); } - if (schema.maximum != null && data > schema.maximum!) { + if (schema.maximumJson != null && data > schema.maximumJson!) { throw JsonSchemaValidationException( - 'Value must be <= ${schema.maximum}', + 'Value must be <= ${schema.maximumJson}', path, ); } - if (schema.exclusiveMinimum != null && data <= schema.exclusiveMinimum!) { + if (schema.exclusiveMinimumJson != null && + data <= schema.exclusiveMinimumJson!) { throw JsonSchemaValidationException( - 'Value must be > ${schema.exclusiveMinimum}', + 'Value must be > ${schema.exclusiveMinimumJson}', path, ); } - if (schema.exclusiveMaximum != null && data >= schema.exclusiveMaximum!) { + if (schema.exclusiveMaximumJson != null && + data >= schema.exclusiveMaximumJson!) { throw JsonSchemaValidationException( - 'Value must be < ${schema.exclusiveMaximum}', + 'Value must be < ${schema.exclusiveMaximumJson}', path, ); } - if (schema.multipleOf != null) { - if ((data % schema.multipleOf!).abs() > 1e-10) { + if (schema.multipleOfJson != null) { + if ((data % schema.multipleOfJson!).abs() > 1e-10) { throw JsonSchemaValidationException( - 'Value must be multiple of ${schema.multipleOf}', + 'Value must be multiple of ${schema.multipleOfJson}', path, ); } diff --git a/lib/src/types.dart b/lib/src/types.dart index a8147418..efed2f4c 100644 --- a/lib/src/types.dart +++ b/lib/src/types.dart @@ -1,4 +1,5 @@ export 'types/content.dart'; +export 'types/json_value.dart'; export 'types/resources.dart'; export 'types/subscriptions.dart'; export 'types/prompts.dart'; diff --git a/lib/src/types/content.dart b/lib/src/types/content.dart index ba828516..39194249 100644 --- a/lib/src/types/content.dart +++ b/lib/src/types/content.dart @@ -305,9 +305,9 @@ class BlobResourceContents extends ResourceContents { /// Represents unknown or passthrough resource content types. /// -/// Stable MCP and MCP 2026 wire results require either text or blob content. -/// This class is retained for source compatibility, but serialization rejects -/// it because no current protocol result shape references bare +/// Stable MCP and MCP `2026-07-28` draft/RC wire results require either text or +/// blob content. This class is retained for source compatibility, but +/// serialization rejects it because no current protocol result shape references bare /// `ResourceContents`. class UnknownResourceContents extends ResourceContents { const UnknownResourceContents({ diff --git a/lib/src/types/initialization.dart b/lib/src/types/initialization.dart index 352c49b0..ee6ab405 100644 --- a/lib/src/types/initialization.dart +++ b/lib/src/types/initialization.dart @@ -952,8 +952,9 @@ class ServerCapabilitiesPrompts { class ServerCapabilitiesResources { /// Whether the server supports resource update subscriptions. /// - /// MCP 2025 uses `resources/subscribe` and `resources/unsubscribe`; MCP 2026 - /// uses `subscriptions/listen` with `resourceSubscriptions`. + /// MCP 2025 uses `resources/subscribe` and `resources/unsubscribe`; MCP + /// `2026-07-28` draft/RC uses `subscriptions/listen` with + /// `resourceSubscriptions`. final bool? subscribe; /// Whether the server supports `notifications/resources/list_changed`. diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index 68a1609f..e45cca09 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -30,23 +30,24 @@ const latestDraftProtocolVersion = draftProtocolVersion2026_07_28; /// High-level MCP protocol compatibility profiles. /// /// The SDK defaults to [stable], which keeps the 2025 initialization flow and -/// avoids draft-only behavior. Use [preview2026] to prefer the 2026 RC while -/// falling back to stable MCP servers where possible. Use [require2026] when a -/// peer must support the 2026 RC stateless protocol. +/// avoids draft-only behavior. Use [preview2026] to prefer MCP `2026-07-28` +/// draft/RC while falling back to stable MCP servers where possible. Use +/// [require2026] when a peer must support the `2026-07-28` draft/RC stateless +/// protocol. enum McpProtocol { /// Stable MCP behavior using the latest released specification. /// /// This is the default SDK profile and currently targets MCP 2025-11-25. stable, - /// Prefer the MCP 2026-07-28 RC when a peer supports it. + /// Prefer MCP `2026-07-28` draft/RC when a peer supports it. /// /// This profile enables draft-only behavior such as `server/discover`, /// stateless request metadata, and stateless result types, while allowing /// fallback to the stable `initialize` flow for older peers. preview2026, - /// Require the MCP 2026-07-28 RC stateless protocol. + /// Require the MCP `2026-07-28` draft/RC stateless protocol. /// /// This profile is intended for conformance tests and deployments where /// connecting to older MCP servers would be a configuration error. @@ -101,7 +102,7 @@ const supportedProtocolVersions = [ "2024-10-07", ]; -/// Protocol versions supported by the 2026 RC development branch. +/// Protocol versions supported by the `2026-07-28` draft/RC development branch. const supportedProtocolVersionsWithDraft = [ latestDraftProtocolVersion, draftProtocolVersion2026V1, @@ -114,7 +115,8 @@ const statelessProtocolVersions = [ draftProtocolVersion2026V1, ]; -/// Returns true when [version] uses the 2026 stateless request model. +/// Returns true when [version] uses the `2026-07-28` draft/RC stateless request +/// model. bool isStatelessProtocolVersion(String version) => statelessProtocolVersions.contains(version); @@ -132,7 +134,8 @@ String? negotiateProtocolVersion( return null; } -/// MCP-reserved `_meta` keys used by the 2026 stateless request model. +/// MCP-reserved `_meta` keys used by the `2026-07-28` draft/RC stateless +/// request model. class McpMetaKey { static const protocolVersion = 'io.modelcontextprotocol/protocolVersion'; static const clientInfo = 'io.modelcontextprotocol/clientInfo'; @@ -144,7 +147,8 @@ class McpMetaKey { const McpMetaKey._(); } -/// Builds request metadata required by the 2026 stateless request model. +/// Builds request metadata required by the `2026-07-28` draft/RC stateless +/// request model. Map buildProtocolRequestMeta({ required String protocolVersion, required Implementation clientInfo, @@ -333,11 +337,11 @@ final _metaNamePattern = RegExp( r'^(?:[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?)?$', ); -/// Validates an MCP 2026 `_meta` key name. +/// Validates an MCP `2026-07-28` draft/RC `_meta` key name. /// -/// MCP 2026 constrains metadata keys to an optional dot-separated prefix -/// followed by `/`, plus a name segment. Earlier protocol versions did not -/// define this grammar, so callers choose when to enforce it. +/// MCP `2026-07-28` draft/RC constrains metadata keys to an optional +/// dot-separated prefix followed by `/`, plus a name segment. Earlier protocol +/// versions did not define this grammar, so callers choose when to enforce it. void validateMetaKeyName(String key, {String fieldName = '_meta'}) { final slashIndex = key.indexOf('/'); final prefix = slashIndex == -1 ? null : key.substring(0, slashIndex); @@ -369,8 +373,8 @@ void validateMetaKeyName(String key, {String fieldName = '_meta'}) { /// Validates request metadata that can affect protocol behavior. /// /// `_meta.progressToken` is an MCP wire token and must be a string or integer -/// when present. [validateKeys] opts in to the MCP 2026 `_meta` -/// key-name grammar without changing stable/legacy request parsing. +/// when present. [validateKeys] opts in to the MCP `2026-07-28` draft/RC +/// `_meta` key-name grammar without changing stable/legacy request parsing. Map? validateRequestMeta( Map? meta, { bool validateKeys = false, @@ -1049,7 +1053,7 @@ void _validateInputResponse(Map json) { void _rejectInputResponseMeta(Map json, String resultName) { if (json.containsKey('_meta')) { throw FormatException( - 'InputResponse $resultName must not include _meta in MCP 2026', + 'InputResponse $resultName must not include _meta in MCP 2026-07-28', ); } } diff --git a/lib/src/types/json_value.dart b/lib/src/types/json_value.dart new file mode 100644 index 00000000..2e0d424a --- /dev/null +++ b/lib/src/types/json_value.dart @@ -0,0 +1,90 @@ +import 'validation.dart'; + +/// A validated JSON value. +/// +/// This represents the MCP `2026-07-28` draft/RC cases where protocol fields +/// may carry any JSON value instead of only an object. Prefer the typed +/// constructors such as [JsonValue.object], [JsonValue.array], or +/// [JsonValue.nullValue] at public API boundaries. +final class JsonValue { + final Object? _value; + + const JsonValue._(this._value); + + /// A JSON `null` value. + static const JsonValue nullValue = JsonValue._(null); + + /// Creates a JSON value from decoded JSON data. + factory JsonValue.fromJson(Object? value) { + return JsonValue._(readJsonValue(value, 'JsonValue')); + } + + /// Creates a JSON object value. + factory JsonValue.object(Map value) { + return JsonValue.fromJson(value); + } + + /// Creates a JSON array value. + factory JsonValue.array(List value) { + return JsonValue.fromJson(value); + } + + /// Creates a JSON string value. + factory JsonValue.string(String value) { + return JsonValue._(value); + } + + /// Creates a JSON number value. + factory JsonValue.number(num value) { + return JsonValue.fromJson(value); + } + + /// Creates a JSON boolean value. + factory JsonValue.boolean(bool value) { + return JsonValue._(value); + } + + /// Returns this value as a JSON object, or `null` for non-object values. + Map? get asObject { + final value = _value; + if (value is! Map) { + return null; + } + return readJsonObject(value, 'JsonValue'); + } + + /// Returns this value as a JSON array, or `null` for non-array values. + List? get asArray { + final value = _value; + if (value is! List) { + return null; + } + return List.unmodifiable( + value.map((item) => readJsonValue(item, 'JsonValue[]')), + ); + } + + /// Returns this value as a JSON string, or `null` for non-string values. + String? get asString { + final value = _value; + return value is String ? value : null; + } + + /// Returns this value as a JSON number, or `null` for non-number values. + num? get asNumber { + final value = _value; + return value is num ? value : null; + } + + /// Returns this value as a JSON boolean, or `null` for non-boolean values. + bool? get asBoolean { + final value = _value; + return value is bool ? value : null; + } + + /// Whether this value is JSON `null`. + bool get isNull => _value == null; + + /// Returns decoded JSON suitable for wire serialization. + Object? toJson() => readJsonValue(_value, 'JsonValue'); +} diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index 609942c7..87bfbbee 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -1,4 +1,5 @@ import 'content.dart'; +import 'json_value.dart'; import 'json_rpc.dart'; import 'tasks.dart'; import 'tools.dart'; @@ -390,7 +391,7 @@ sealed class SamplingContent { 'content': c.contentBlocks.map((item) => item.toJson()).toList(), if (c.hasStructuredContent) 'structuredContent': readJsonValue( - c.structuredContent, + c.structuredContentJson?.toJson(), 'SamplingToolResultContent.structuredContent', ), if (c.isError != null) 'isError': c.isError, @@ -545,7 +546,34 @@ class SamplingToolUseContent extends SamplingContent { class SamplingToolResultContent extends SamplingContent { final String toolUseId; final dynamic content; - final Object? structuredContent; + final Map? _structuredContent; + final JsonValue? _structuredContentValue; + + /// Object-root structured content returned by the tool. + /// + /// Stable MCP `2025-11-25` tool results use an object here. When working + /// with MCP `2026-07-28` draft/RC peers, use [structuredContentJson] to read + /// non-object JSON values such as arrays, strings, numbers, booleans, or an + /// explicit JSON `null`. + Map? get structuredContent { + return _structuredContentValue?.asObject ?? _structuredContent; + } + + /// Structured content returned by an MCP `2026-07-28` draft/RC tool result. + /// + /// This exposes the wire-level JSON value and may be an object, array, + /// string, number, boolean, or null. Use [hasStructuredContent] to distinguish + /// an omitted field from an explicit JSON `null`. + JsonValue? get structuredContentJson { + if (!hasStructuredContent) { + return null; + } + return _structuredContentValue ?? + (structuredContent == null + ? JsonValue.nullValue + : JsonValue.object(structuredContent!)); + } + final bool hasStructuredContent; final bool? isError; final Map? meta; @@ -553,12 +581,15 @@ class SamplingToolResultContent extends SamplingContent { const SamplingToolResultContent({ required this.toolUseId, required this.content, - this.structuredContent, + Map? structuredContent, + JsonValue? structuredContentJson, bool? hasStructuredContent, this.isError, this.meta, - }) : hasStructuredContent = - hasStructuredContent ?? structuredContent != null, + }) : hasStructuredContent = hasStructuredContent ?? + (structuredContentJson != null || structuredContent != null), + _structuredContent = structuredContent, + _structuredContentValue = structuredContentJson, super(type: 'tool_result'); /// Normalized content blocks for tool results. @@ -576,11 +607,8 @@ class SamplingToolResultContent extends SamplingContent { 'SamplingToolResultContent.toolUseId', ), content: _parseToolResultWireContent(json['content']), - structuredContent: json.containsKey('structuredContent') - ? readJsonValue( - json['structuredContent'], - 'SamplingToolResultContent.structuredContent', - ) + structuredContentJson: json.containsKey('structuredContent') + ? JsonValue.fromJson(json['structuredContent']) : null, hasStructuredContent: json.containsKey('structuredContent'), isError: readOptionalBool( diff --git a/lib/src/types/tools.dart b/lib/src/types/tools.dart index 9a48acea..5b1a7ad8 100644 --- a/lib/src/types/tools.dart +++ b/lib/src/types/tools.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import '../shared/json_schema/json_schema.dart'; import 'content.dart'; +import 'json_value.dart'; import 'json_rpc.dart'; import 'validation.dart'; @@ -11,8 +12,8 @@ typedef ToolInputSchema = JsonObject; /// Legacy alias for object-root tool output schemas. /// -/// MCP 2026-07-28 allows [Tool.outputSchema] to be any JSON Schema. Use -/// [JsonSchema] directly when the output schema root is not an object. +/// MCP `2026-07-28` draft/RC allows [Tool.outputSchema] to be any JSON Schema. +/// Use [JsonSchema] directly when the output schema root is not an object. typedef ToolOutputSchema = JsonObject; void _expectJsonRpcMethod( @@ -420,11 +421,33 @@ class CallToolResult implements BaseResultData { /// Whether the tool call returned an error. final bool isError; - /// Structured content returned by the tool. + final Map? _structuredContent; + final JsonValue? _structuredContentValue; + + /// Object-root structured content returned by the tool. /// - /// MCP 2026-07-28 allows any JSON value: object, array, string, number, - /// boolean, or null. - final Object? structuredContent; + /// Stable MCP `2025-11-25` tool results use an object here. When working + /// with MCP `2026-07-28` draft/RC peers, use [structuredContentJson] to read + /// non-object JSON values such as arrays, strings, numbers, booleans, or an + /// explicit JSON `null`. + Map? get structuredContent { + return _structuredContentValue?.asObject ?? _structuredContent; + } + + /// Structured content returned by an MCP `2026-07-28` draft/RC tool call. + /// + /// This exposes the wire-level JSON value and may be an object, array, + /// string, number, boolean, or null. Use [hasStructuredContent] to distinguish + /// an omitted field from an explicit JSON `null`. + JsonValue? get structuredContentJson { + if (!hasStructuredContent) { + return null; + } + return _structuredContentValue ?? + (structuredContent == null + ? JsonValue.nullValue + : JsonValue.object(structuredContent!)); + } /// Whether [structuredContent] was explicitly present. /// @@ -441,30 +464,68 @@ class CallToolResult implements BaseResultData { const CallToolResult({ required this.content, this.isError = false, - this.structuredContent, + Map? structuredContent, + JsonValue? structuredContentJson, bool? hasStructuredContent, this.meta, this.extra, - }) : hasStructuredContent = hasStructuredContent ?? structuredContent != null; + }) : _structuredContent = structuredContent, + _structuredContentValue = structuredContentJson, + hasStructuredContent = hasStructuredContent ?? + (structuredContentJson != null || structuredContent != null); /// Creates a result from a list of content items. factory CallToolResult.fromContent(List content) { return CallToolResult(content: content); } - /// Creates a result from arbitrary structured JSON data. + /// Creates a result from object-root structured content. /// /// Automatically populates [content] with a JSON-serialized version of /// [content] for backward compatibility with clients that do not support /// [structuredContent]. - factory CallToolResult.fromStructuredContent(Object? content) { + factory CallToolResult.fromStructuredContent(Map content) { + return CallToolResult.fromStructuredValue(JsonValue.object(content)); + } + + /// Creates a result from arbitrary MCP `2026-07-28` draft/RC structured JSON. + /// + /// This may be any JSON value, including arrays and explicit JSON `null`. + /// Stable MCP `2025-11-25` callers receive only the JSON-serialized + /// [content] fallback when the structured value is not an object. + factory CallToolResult.fromStructuredValue(JsonValue content) { return CallToolResult( - content: [TextContent(text: jsonEncode(content))], - structuredContent: content, + content: [TextContent(text: jsonEncode(content.toJson()))], + structuredContentJson: content, hasStructuredContent: true, ); } + /// Creates a result from MCP `2026-07-28` draft/RC array structured content. + factory CallToolResult.fromStructuredArray(List content) { + return CallToolResult.fromStructuredValue(JsonValue.array(content)); + } + + /// Creates a result from MCP `2026-07-28` draft/RC string structured content. + factory CallToolResult.fromStructuredString(String content) { + return CallToolResult.fromStructuredValue(JsonValue.string(content)); + } + + /// Creates a result from MCP `2026-07-28` draft/RC number structured content. + factory CallToolResult.fromStructuredNumber(num content) { + return CallToolResult.fromStructuredValue(JsonValue.number(content)); + } + + /// Creates a result from MCP `2026-07-28` draft/RC boolean structured content. + factory CallToolResult.fromStructuredBoolean(bool content) { + return CallToolResult.fromStructuredValue(JsonValue.boolean(content)); + } + + /// Creates a result from MCP `2026-07-28` draft/RC null structured content. + factory CallToolResult.fromStructuredNull() { + return CallToolResult.fromStructuredValue(JsonValue.nullValue); + } + factory CallToolResult.fromJson(Map json) { final knownKeys = {'content', 'isError', '_meta', 'structuredContent'}; final extra = Map.from(json) @@ -483,11 +544,8 @@ class CallToolResult implements BaseResultData { ], isError: readOptionalBool(json['isError'], 'CallToolResult.isError') ?? false, - structuredContent: json.containsKey('structuredContent') - ? readJsonValue( - json['structuredContent'], - 'CallToolResult.structuredContent', - ) + structuredContentJson: json.containsKey('structuredContent') + ? JsonValue.fromJson(json['structuredContent']) : null, hasStructuredContent: json.containsKey('structuredContent'), meta: readOptionalJsonObject(json['_meta'], 'CallToolResult._meta'), @@ -502,7 +560,7 @@ class CallToolResult implements BaseResultData { if (isError) 'isError': isError, if (hasStructuredContent) 'structuredContent': readJsonValue( - structuredContent, + structuredContentJson?.toJson(), 'CallToolResult.structuredContent', ), if (meta != null) '_meta': readJsonObject(meta, 'CallToolResult._meta'), diff --git a/packages/mcp_dart_cli/CHANGELOG.md b/packages/mcp_dart_cli/CHANGELOG.md index f1334eb3..561f34ce 100644 --- a/packages/mcp_dart_cli/CHANGELOG.md +++ b/packages/mcp_dart_cli/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.2.0-dev.0 + +- Prepare the CLI for the MCP `2026-07-28` draft/RC SDK dev line with a + dependency on `mcp_dart ^2.3.0-dev.0`. +- Keep the local monorepo SDK override in `pubspec_overrides.yaml` so published + CLI pubspec metadata does not expose path overrides. + ## 0.1.9 - Add `mcp_dart inspect-server` for structured MCP server inspection reports diff --git a/packages/mcp_dart_cli/README.md b/packages/mcp_dart_cli/README.md index e0ec836e..0f2df668 100644 --- a/packages/mcp_dart_cli/README.md +++ b/packages/mcp_dart_cli/README.md @@ -441,6 +441,25 @@ To run the tests for this package: dart test ``` +## Release Validation + +The CLI package lives under `packages/`, while the root SDK package excludes +that directory from its own pub archive. Run CLI publish validation from an +exported tree outside the monorepo git/.pubignore context: + +```bash +dart run tool/validate_cli_publish.dart +``` + +Before the matching `mcp_dart` SDK dev package is published, this uses +`pubspec_overrides.yaml` so the CLI can validate against the local SDK checkout. +After publishing the SDK package, validate the CLI against the pub.dev SDK +version: + +```bash +dart run tool/validate_cli_publish.dart --published-sdk +``` + ## Contributing Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to this project. diff --git a/packages/mcp_dart_cli/lib/src/inspect_server_command.dart b/packages/mcp_dart_cli/lib/src/inspect_server_command.dart index 5cac12cd..c9f4c2b0 100644 --- a/packages/mcp_dart_cli/lib/src/inspect_server_command.dart +++ b/packages/mcp_dart_cli/lib/src/inspect_server_command.dart @@ -588,7 +588,7 @@ class McpServerInspector { } try { - tool.outputSchema!.validate(result.structuredContent); + tool.outputSchema!.validate(result.structuredContentJson?.toJson()); checks.pass( 'tools.output-schema.${tool.name}', 'Structured output for ${tool.name} matched its outputSchema.', diff --git a/packages/mcp_dart_cli/lib/src/version.dart b/packages/mcp_dart_cli/lib/src/version.dart index b8e13b9f..20611918 100644 --- a/packages/mcp_dart_cli/lib/src/version.dart +++ b/packages/mcp_dart_cli/lib/src/version.dart @@ -1 +1 @@ -const packageVersion = '0.1.9'; +const packageVersion = '0.2.0-dev.0'; diff --git a/packages/mcp_dart_cli/pubspec.yaml b/packages/mcp_dart_cli/pubspec.yaml index 71e16ce6..fbc8dcc6 100644 --- a/packages/mcp_dart_cli/pubspec.yaml +++ b/packages/mcp_dart_cli/pubspec.yaml @@ -1,6 +1,6 @@ name: mcp_dart_cli description: Command-line tools for creating, serving, inspecting, and testing Dart Model Context Protocol (MCP) servers. -version: 0.1.9 +version: 0.2.0-dev.0 repository: https://github.com/leehack/mcp_dart homepage: https://github.com/leehack/mcp_dart/tree/main/packages/mcp_dart_cli issue_tracker: https://github.com/leehack/mcp_dart/issues @@ -27,15 +27,11 @@ dependencies: stream_transform: ^2.1.1 watcher: ^1.2.0 yaml: ^3.1.3 - mcp_dart: ^2.2.0 + mcp_dart: ^2.3.0-dev.0 mason_logger: ^0.3.3 meta: ^1.17.0 pub_updater: ^0.5.0 -dependency_overrides: - mcp_dart: - path: ../../ - dev_dependencies: build_runner: ^2.10.4 lints: ^6.0.0 diff --git a/packages/mcp_dart_cli/pubspec_overrides.yaml b/packages/mcp_dart_cli/pubspec_overrides.yaml new file mode 100644 index 00000000..fb7b9613 --- /dev/null +++ b/packages/mcp_dart_cli/pubspec_overrides.yaml @@ -0,0 +1,3 @@ +dependency_overrides: + mcp_dart: + path: ../../ diff --git a/packages/mcp_dart_cli/test/fixtures/dart_mcp_project/pubspec.yaml b/packages/mcp_dart_cli/test/fixtures/dart_mcp_project/pubspec.yaml index ffc40105..ae18d1d5 100644 --- a/packages/mcp_dart_cli/test/fixtures/dart_mcp_project/pubspec.yaml +++ b/packages/mcp_dart_cli/test/fixtures/dart_mcp_project/pubspec.yaml @@ -9,7 +9,7 @@ environment: dependencies: args: ^2.6.0 logging: ^1.3.0 - mcp_dart: ^2.2.0 + mcp_dart: ^2.3.0-dev.0 mcp_dart_cli: path: ../../.. diff --git a/pubspec.yaml b/pubspec.yaml index 7cdfae8d..cfaad5fa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: mcp_dart description: Dart and Flutter SDK for building Model Context Protocol (MCP) servers, clients, hosts, and AI tools. -version: 2.2.0 +version: 2.3.0-dev.0 repository: https://github.com/leehack/mcp_dart homepage: https://github.com/leehack/mcp_dart issue_tracker: https://github.com/leehack/mcp_dart/issues diff --git a/test/client/client_tool_validation_test.dart b/test/client/client_tool_validation_test.dart index 401a11b5..0970d7a5 100644 --- a/test/client/client_tool_validation_test.dart +++ b/test/client/client_tool_validation_test.dart @@ -74,15 +74,15 @@ class MockTransport extends Transport _respond( JsonRpcResponse( id: message.id, - result: CallToolResult.fromStructuredContent(['alpha', 'beta']) - .toJson(), + result: + CallToolResult.fromStructuredArray(['alpha', 'beta']).toJson(), ), ); } else if (name == 'broken_array_tool') { _respond( JsonRpcResponse( id: message.id, - result: CallToolResult.fromStructuredContent(['alpha', 1]).toJson(), + result: CallToolResult.fromStructuredArray(['alpha', 1]).toJson(), ), ); } else if (name == 'broken_tool') { @@ -300,7 +300,7 @@ void main() { const CallToolRequest(name: 'array_tool'), ); - expect(result.structuredContent, equals(['alpha', 'beta'])); + expect(result.structuredContentJson?.toJson(), equals(['alpha', 'beta'])); }); test('throws when non-object tool output validation fails', () async { diff --git a/test/conformance/README.md b/test/conformance/README.md index 66ceb498..35032fe3 100644 --- a/test/conformance/README.md +++ b/test/conformance/README.md @@ -1,21 +1,21 @@ # MCP Conformance -This directory contains conformance harnesses for stable MCP 2025-11-25 and the -unreleased MCP 2026 RC suite. These fixtures are intentionally separate from the -cross-SDK interop tests because the official conformance package calls -hard-coded diagnostic tools, prompts, and resources. +This directory contains conformance harnesses for stable MCP `2025-11-25` and +the unreleased MCP `2026-07-28` draft/RC suite. These fixtures are intentionally +separate from the cross-SDK interop tests because the official conformance +package calls hard-coded diagnostic tools, prompts, and resources. ## CI Coverage -Core CI runs the official stable 2025 and 2026 RC client/server conformance -suites from `.github/workflows/test_core.yml`. The server suites use dedicated -fixtures because the official conformance package calls hard-coded diagnostic -tools, prompts, and resources. +Core CI runs the official stable `2025-11-25` and `2026-07-28` draft/RC +client/server conformance suites from `.github/workflows/test_core.yml`. The +server suites use dedicated fixtures because the official conformance package +calls hard-coded diagnostic tools, prompts, and resources. -The 2026 suite still targets an RC/alpha spec package. If the official suite -changes before the spec is final, record intentional temporary gaps in +The 2026 suite still targets a draft/RC alpha spec package. If the official +suite changes before the spec is final, record intentional temporary gaps in `2026_rc_expected_failures.txt` or `2026_rc_client_expected_failures.txt` so CI -distinguishes known RC churn from regressions. +distinguishes known draft/RC churn from regressions. ## Stable MCP 2025-11-25 @@ -45,7 +45,7 @@ The stable client suite reuses the dual-stack conformance client fixture because the fixture negotiates whichever protocol version the conformance scenario server offers. -## MCP 2026 RC +## MCP 2026-07-28 Draft/RC Run the current server baseline from the repository root: diff --git a/test/server/output_validation_test.dart b/test/server/output_validation_test.dart index 4799381c..455f3805 100644 --- a/test/server/output_validation_test.dart +++ b/test/server/output_validation_test.dart @@ -98,9 +98,9 @@ void main() { ); mcpServer.registerTool( 'array_tool', - outputSchema: JsonSchema.array(items: JsonSchema.string()), + outputJsonSchema: JsonSchema.array(items: JsonSchema.string()), callback: (args, extra) async { - return CallToolResult.fromStructuredContent(['alpha', 'beta']); + return CallToolResult.fromStructuredArray(['alpha', 'beta']); }, ); @@ -118,7 +118,7 @@ void main() { expect(response, isA()); final successResponse = response as JsonRpcResponse; final result = CallToolResult.fromJson(successResponse.result); - expect(result.structuredContent, equals(['alpha', 'beta'])); + expect(result.structuredContentJson?.toJson(), equals(['alpha', 'beta'])); }); test('non-object output schema validation failures are rejected', () async { @@ -133,9 +133,9 @@ void main() { ); mcpServer.registerTool( 'invalid_array_tool', - outputSchema: JsonSchema.array(items: JsonSchema.string()), + outputJsonSchema: JsonSchema.array(items: JsonSchema.string()), callback: (args, extra) async { - return CallToolResult.fromStructuredContent(['alpha', 1]); + return CallToolResult.fromStructuredArray(['alpha', 1]); }, ); @@ -159,9 +159,9 @@ void main() { test('stable tools/list omits non-object output schemas', () async { mcpServer.registerTool( 'array_tool', - outputSchema: JsonSchema.array(items: JsonSchema.string()), + outputJsonSchema: JsonSchema.array(items: JsonSchema.string()), callback: (args, extra) async { - return CallToolResult.fromStructuredContent(['alpha', 'beta']); + return CallToolResult.fromStructuredArray(['alpha', 'beta']); }, ); @@ -191,9 +191,9 @@ void main() { ); mcpServer.registerTool( 'array_tool', - outputSchema: JsonSchema.array(items: JsonSchema.string()), + outputJsonSchema: JsonSchema.array(items: JsonSchema.string()), callback: (args, extra) async { - return CallToolResult.fromStructuredContent(['alpha', 'beta']); + return CallToolResult.fromStructuredArray(['alpha', 'beta']); }, ); @@ -219,9 +219,9 @@ void main() { test('stable tool calls omit non-object structured content', () async { mcpServer.registerTool( 'array_tool', - outputSchema: JsonSchema.array(items: JsonSchema.string()), + outputJsonSchema: JsonSchema.array(items: JsonSchema.string()), callback: (args, extra) async { - return CallToolResult.fromStructuredContent(['alpha', 'beta']); + return CallToolResult.fromStructuredArray(['alpha', 'beta']); }, ); diff --git a/test/shared/json_schema_from_json_test.dart b/test/shared/json_schema_from_json_test.dart index 153a9d71..77ccd5b9 100644 --- a/test/shared/json_schema_from_json_test.dart +++ b/test/shared/json_schema_from_json_test.dart @@ -218,12 +218,18 @@ void main() { final schema = JsonSchema.fromJson(json); expect(schema, isA()); final s = schema as JsonInteger; - expect(s.minimum, 1.5); - expect(s.maximum, 10.5); - expect(s.exclusiveMinimum, 0.5); - expect(s.exclusiveMaximum, 11.5); - expect(s.multipleOf, 0.5); - expect(s.defaultValue, 2.0); + expect(s.minimum, isNull); + expect(s.maximum, isNull); + expect(s.exclusiveMinimum, isNull); + expect(s.exclusiveMaximum, isNull); + expect(s.multipleOf, isNull); + expect(s.defaultValue, 2); + expect(s.minimumJson, 1.5); + expect(s.maximumJson, 10.5); + expect(s.exclusiveMinimumJson, 0.5); + expect(s.exclusiveMaximumJson, 11.5); + expect(s.multipleOfJson, 0.5); + expect(s.defaultValueJson, 2.0); expect(s.toJson(), json); }); diff --git a/test/shared/json_schema_validator_test.dart b/test/shared/json_schema_validator_test.dart index ec46f11f..f766efdc 100644 --- a/test/shared/json_schema_validator_test.dart +++ b/test/shared/json_schema_validator_test.dart @@ -160,7 +160,10 @@ void main() { }); test('validates exclusiveMinimum', () { - final schema = JsonSchema.integer(exclusiveMinimum: 5.5); + final schema = JsonSchema.fromJson({ + 'type': 'integer', + 'exclusiveMinimum': 5.5, + }); schema.validate(6); expect( () => schema.validate(5), @@ -169,7 +172,10 @@ void main() { }); test('validates exclusiveMaximum', () { - final schema = JsonSchema.integer(exclusiveMaximum: 10.5); + final schema = JsonSchema.fromJson({ + 'type': 'integer', + 'exclusiveMaximum': 10.5, + }); schema.validate(9); schema.validate(10); expect( @@ -179,7 +185,10 @@ void main() { }); test('validates multipleOf', () { - final schema = JsonSchema.integer(multipleOf: 1.5); + final schema = JsonSchema.fromJson({ + 'type': 'integer', + 'multipleOf': 1.5, + }); schema.validate(3); schema.validate(6); expect( diff --git a/test/tool_schema_test.dart b/test/tool_schema_test.dart index 14664f13..d127adef 100644 --- a/test/tool_schema_test.dart +++ b/test/tool_schema_test.dart @@ -212,24 +212,26 @@ void main() { ]; for (final value in values) { - final result = CallToolResult.fromStructuredContent(value); + final result = CallToolResult.fromStructuredValue( + JsonValue.fromJson(value), + ); final json = result.toJson(); expect(json['structuredContent'], equals(value)); final parsed = CallToolResult.fromJson(json); expect(parsed.hasStructuredContent, isTrue); - expect(parsed.structuredContent, equals(value)); + expect(parsed.structuredContentJson?.toJson(), equals(value)); } - final nullResult = CallToolResult.fromStructuredContent(null); + final nullResult = CallToolResult.fromStructuredNull(); final nullJson = nullResult.toJson(); expect(nullJson.containsKey('structuredContent'), isTrue); expect(nullJson['structuredContent'], isNull); final parsedNull = CallToolResult.fromJson(nullJson); expect(parsedNull.hasStructuredContent, isTrue); - expect(parsedNull.structuredContent, isNull); + expect(parsedNull.structuredContentJson?.toJson(), isNull); }); test('Tool JSON object fields reject non-JSON Dart map values', () { diff --git a/test/types/sampling_test.dart b/test/types/sampling_test.dart index 477edae9..d4a553c0 100644 --- a/test/types/sampling_test.dart +++ b/test/types/sampling_test.dart @@ -1,4 +1,5 @@ import 'package:mcp_dart/src/types/content.dart'; +import 'package:mcp_dart/src/types/json_value.dart'; import 'package:mcp_dart/src/types/json_rpc.dart'; import 'package:mcp_dart/src/types/sampling.dart'; import 'package:test/test.dart'; @@ -493,12 +494,12 @@ void main() { }); test('toJson preserves arbitrary structured JSON values', () { - const content = SamplingToolResultContent( + final content = SamplingToolResultContent( toolUseId: 'res1', content: [ - TextContent(text: 'array result'), + const TextContent(text: 'array result'), ], - structuredContent: ['alpha', 'beta'], + structuredContentJson: JsonValue.array(['alpha', 'beta']), ); final json = content.toJson(); expect(json['structuredContent'], equals(['alpha', 'beta'])); @@ -508,7 +509,7 @@ void main() { content: [ TextContent(text: 'null result'), ], - structuredContent: null, + structuredContentJson: JsonValue.nullValue, hasStructuredContent: true, ); final nullJson = nullContent.toJson(); @@ -549,7 +550,10 @@ void main() { expect(content, isA()); final result = content as SamplingToolResultContent; expect(result.hasStructuredContent, isTrue); - expect(result.structuredContent, equals(['alpha', 'beta'])); + expect( + result.structuredContentJson?.toJson(), + equals(['alpha', 'beta']), + ); final nullJson = { 'type': 'tool_result', @@ -562,7 +566,7 @@ void main() { final nullContent = SamplingContent.fromJson(nullJson) as SamplingToolResultContent; expect(nullContent.hasStructuredContent, isTrue); - expect(nullContent.structuredContent, isNull); + expect(nullContent.structuredContentJson?.toJson(), isNull); }); test('rejects malformed tool result wire fields', () { diff --git a/tool/validate_cli_publish.dart b/tool/validate_cli_publish.dart new file mode 100644 index 00000000..f4d2a2a4 --- /dev/null +++ b/tool/validate_cli_publish.dart @@ -0,0 +1,205 @@ +import 'dart:async'; +import 'dart:io'; + +Future main(List args) async { + final options = _Options.parse(args); + if (options.showHelp) { + _printUsage(); + return; + } + + final repoRoot = _repoRoot(); + final outputRoot = options.outputPath == null + ? Directory.systemTemp.createTempSync('mcp_dart_cli_publish_') + : Directory(options.outputPath!).absolute; + + if (outputRoot.existsSync()) { + if (outputRoot.listSync().isNotEmpty) { + stderr.writeln('Output directory must be empty: ${outputRoot.path}'); + exitCode = 64; + return; + } + } else { + outputRoot.createSync(recursive: true); + } + + _ensureOutputOutsideRepo(repoRoot, outputRoot); + _copyDirectory(repoRoot, outputRoot); + + final cliDir = Directory( + _join(outputRoot.path, ['packages', 'mcp_dart_cli']), + ); + + if (options.usePublishedSdk) { + final overrides = File(_join(cliDir.path, ['pubspec_overrides.yaml'])); + if (overrides.existsSync()) { + overrides.deleteSync(); + } + } + + stdout.writeln('Exported CLI publish tree to ${cliDir.path}'); + + if (!options.runDryRun) { + stdout.writeln('Run: cd ${cliDir.path} && dart pub publish --dry-run'); + return; + } + + await _run(['dart', 'pub', 'get'], workingDirectory: cliDir.path); + await _run( + ['dart', 'pub', 'publish', '--dry-run'], + workingDirectory: cliDir.path, + ); +} + +Directory _repoRoot() { + final script = File(Platform.script.toFilePath()); + return script.parent.parent.absolute; +} + +void _ensureOutputOutsideRepo(Directory repoRoot, Directory outputRoot) { + final repoPath = _normalized(repoRoot.path); + final outputPath = _normalized(outputRoot.path); + if (outputPath == repoPath || outputPath.startsWith('$repoPath/')) { + stderr.writeln( + 'Output directory must be outside the repository so parent .pubignore ' + 'files do not affect the nested CLI package archive.', + ); + exit(64); + } +} + +void _copyDirectory(Directory source, Directory target) { + for (final entity in source.listSync(followLinks: false)) { + final name = _basename(entity.path); + if (_excludedNames.contains(name)) { + continue; + } + + final targetPath = _join(target.path, [name]); + if (entity is Directory) { + final nextTarget = Directory(targetPath)..createSync(); + _copyDirectory(entity, nextTarget); + } else if (entity is File) { + entity.copySync(targetPath); + } else if (entity is Link) { + final link = Link(targetPath); + link.createSync(entity.targetSync(), recursive: true); + } + } +} + +Future _run( + List command, { + required String workingDirectory, +}) async { + stdout.writeln('Running: ${command.join(' ')}'); + final process = await Process.start( + command.first, + command.sublist(1), + workingDirectory: workingDirectory, + runInShell: Platform.isWindows, + ); + final stdoutDone = stdout.addStream(process.stdout); + final stderrDone = stderr.addStream(process.stderr); + final code = await process.exitCode; + await Future.wait([stdoutDone, stderrDone]); + + if (code != 0) { + exit(code); + } +} + +String _join(String first, List rest) { + var result = first; + for (final part in rest) { + if (result.endsWith(Platform.pathSeparator)) { + result = '$result$part'; + } else { + result = '$result${Platform.pathSeparator}$part'; + } + } + return result; +} + +String _basename(String path) { + final normalized = path.replaceAll('\\', '/'); + return normalized.substring(normalized.lastIndexOf('/') + 1); +} + +String _normalized(String path) { + return Directory(path).absolute.path.replaceAll('\\', '/'); +} + +void _printUsage() { + stdout.writeln(''' +Usage: dart run tool/validate_cli_publish.dart [options] + +Exports packages/mcp_dart_cli outside the monorepo git/.pubignore context and +runs dart pub publish --dry-run from that exported package. + +Options: + --output Export to an empty directory outside the repository. + --published-sdk Remove pubspec_overrides.yaml so the CLI resolves the + SDK version from pub.dev. Use after publishing mcp_dart. + --no-dry-run Export only; print the publish command. + --help Print this help. +'''); +} + +const _excludedNames = { + '.dart_tool', + '.git', + 'build', + 'coverage', + 'pubspec.lock', +}; + +class _Options { + final String? outputPath; + final bool runDryRun; + final bool usePublishedSdk; + final bool showHelp; + + const _Options({ + required this.outputPath, + required this.runDryRun, + required this.usePublishedSdk, + required this.showHelp, + }); + + factory _Options.parse(List args) { + String? outputPath; + var runDryRun = true; + var usePublishedSdk = false; + var showHelp = false; + + for (var i = 0; i < args.length; i += 1) { + final arg = args[i]; + switch (arg) { + case '--output': + if (i + 1 >= args.length) { + stderr.writeln('--output requires a directory path.'); + exit(64); + } + outputPath = args[++i]; + case '--published-sdk': + usePublishedSdk = true; + case '--no-dry-run': + runDryRun = false; + case '--help': + case '-h': + showHelp = true; + default: + stderr.writeln('Unknown option: $arg'); + exit(64); + } + } + + return _Options( + outputPath: outputPath, + runDryRun: runDryRun, + usePublishedSdk: usePublishedSdk, + showHelp: showHelp, + ); + } +} From d2d4a10222c369da87dfa82fa7dafda94f63eb11 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Wed, 3 Jun 2026 13:35:54 -0400 Subject: [PATCH 41/42] Document deprecated public APIs --- lib/src/server/mcp_server.dart | 15 ++++++++++++--- lib/src/server/sse.dart | 8 +++++++- lib/src/server/sse_server_manager.dart | 6 +++++- lib/src/types/json_rpc.dart | 8 ++++++++ lib/src/types/tools.dart | 2 ++ 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index 49b71356..bda5198a 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -161,7 +161,8 @@ typedef ToolFunction = FutureOr Function( RequestHandlerExtra extra, ); -/// Legacy callback signature for tools (deprecated style). +/// Legacy callback signature for the deprecated [McpServer.tool] helper. +@Deprecated('Use ToolFunction with registerTool instead') typedef LegacyToolCallback = FutureOr Function({ Map? args, RequestHandlerExtra? extra, @@ -2240,16 +2241,24 @@ class McpServer { ); } - /// Registers a tool the client can invoke. - /// Registers a tool the client can invoke. + /// Deprecated helper that registers a tool the client can invoke. + /// + /// Use [registerTool] with [ToolFunction]. The + /// [inputSchemaProperties] and [outputSchemaProperties] parameters are + /// deprecated compatibility shims for [toolInputSchema] and + /// [toolOutputSchema]. @Deprecated('Use registerTool instead') RegisteredTool tool( String name, { String? description, ToolInputSchema? toolInputSchema, ToolOutputSchema? toolOutputSchema, + + /// Deprecated schema-property map shim. Use [toolInputSchema]. @Deprecated('Use toolInputSchema instead') Map? inputSchemaProperties, + + /// Deprecated schema-property map shim. Use [toolOutputSchema]. @Deprecated('Use toolOutputSchema instead') Map? outputSchemaProperties, ToolAnnotations? annotations, diff --git a/lib/src/server/sse.dart b/lib/src/server/sse.dart index 47808731..66b49b85 100644 --- a/lib/src/server/sse.dart +++ b/lib/src/server/sse.dart @@ -13,7 +13,12 @@ final _logger = Logger("mcp_dart.server.sse"); /// Maximum size for incoming POST message bodies. const int _maximumMessageSize = 4 * 1024 * 1024; // 4MB in bytes -/// Server transport for SSE: sends messages over a persistent SSE connection +/// Legacy server transport for SSE. +/// +/// Prefer `StreamableHTTPServerTransport` for new servers. This transport is +/// retained for backward compatibility with existing SSE integrations. +/// +/// Sends messages over a persistent SSE connection /// ([HttpResponse]) and receives messages from separate HTTP POST requests /// handled by [handlePostMessage]. /// @@ -21,6 +26,7 @@ const int _maximumMessageSize = 4 * 1024 * 1024; // 4MB in bytes /// `HttpServer` or frameworks like Shelf/Alfred). The `start` method manages /// the SSE response stream, while `handlePostMessage` should be called from /// the server's routing logic for the designated message endpoint. +@Deprecated('Use StreamableHTTPServerTransport instead') class SseServerTransport implements Transport { StringConversionSink? _sink; final HttpResponse _sseResponse; diff --git a/lib/src/server/sse_server_manager.dart b/lib/src/server/sse_server_manager.dart index a427cf26..d081bd79 100644 --- a/lib/src/server/sse_server_manager.dart +++ b/lib/src/server/sse_server_manager.dart @@ -8,7 +8,11 @@ import 'sse.dart'; final _logger = Logger("mcp_dart.server.sse.manager"); -/// Manages Server-Sent Events (SSE) connections and routes HTTP requests. +/// Legacy manager for Server-Sent Events (SSE) connections. +/// +/// Prefer `StreamableHTTPServerTransport` for new servers. This manager is +/// retained for backward compatibility with existing SSE integrations. +@Deprecated('Use StreamableHTTPServerTransport instead') class SseServerManager { /// Map to store active SSE transports, keyed by session ID. final Map activeSseTransports = {}; diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index e45cca09..dc255007 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -211,6 +211,11 @@ class Method { "notifications/prompts/list_changed"; static const notificationsToolsListChanged = "notifications/tools/list_changed"; + + /// Deprecated completion list-change notification method. + /// + /// Stable MCP `2025-11-25` does not include this method. Use + /// [notificationsExperimentalCompletionsListChanged] for extension behavior. @Deprecated( 'notifications/completions/list_changed is not part of stable MCP 2025-11-25. ' 'Use notifications/experimental/completions/list_changed for extension behavior.', @@ -1179,6 +1184,9 @@ class JsonRpcListToolsRequest extends JsonRpcRequest { super.meta, }) : super(method: Method.toolsList); + /// Deprecated typed-params constructor retained for compatibility. + /// + /// Prefer passing `params?.toJson()` to [JsonRpcListToolsRequest]. @Deprecated( 'Use JsonRpcListToolsRequest(id: ..., params: params?.toJson(), meta: meta) instead.', ) diff --git a/lib/src/types/tools.dart b/lib/src/types/tools.dart index 5b1a7ad8..81cd392c 100644 --- a/lib/src/types/tools.dart +++ b/lib/src/types/tools.dart @@ -294,6 +294,7 @@ class ListToolsRequest { }; } +/// Deprecated alias for [ListToolsRequest]. @Deprecated('Use [ListToolsRequest] instead.') typedef ListToolsRequestParams = ListToolsRequest; @@ -362,6 +363,7 @@ class ListToolsResult implements CacheableResultData { } } +/// Deprecated alias for [CallToolRequest]. @Deprecated('Use [CallToolRequest] instead.') typedef CallToolRequestParams = CallToolRequest; From 0c5ca391ed6e0dc6a56f5d7f9081c67097125432 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Wed, 3 Jun 2026 13:43:45 -0400 Subject: [PATCH 42/42] Wait for both Codecov uploads --- codecov.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codecov.yml b/codecov.yml index e4f16e86..1c4e1af1 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,3 +1,7 @@ +codecov: + notify: + after_n_builds: 2 + coverage: status: project: