From 7348727c098347882cbef6683c3c31de297adf3b Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 08:20:35 -0400 Subject: [PATCH] Consolidate MCP 2026 feature foundation --- CHANGELOG.md | 14 + lib/src/client/client.dart | 270 +++- lib/src/client/streamable_https.dart | 157 ++- lib/src/server/mcp_server.dart | 54 +- lib/src/server/server.dart | 276 ++++- lib/src/server/streamable_https.dart | 328 ++++- lib/src/server/streamable_mcp_server.dart | 112 +- lib/src/shared/json_schema/json_schema.dart | 113 +- lib/src/shared/protocol.dart | 31 + lib/src/shared/transport.dart | 12 + lib/src/types.dart | 1 + lib/src/types/initialization.dart | 99 ++ lib/src/types/json_rpc.dart | 355 +++++- lib/src/types/prompts.dart | 25 +- lib/src/types/resources.dart | 31 +- lib/src/types/subscriptions.dart | 250 ++++ lib/src/types/tasks.dart | 321 +++++ lib/src/types/tools.dart | 19 + lib/src/types/validation.dart | 10 + test/client/client_test.dart | 10 + test/client/client_tool_validation_test.dart | 162 ++- test/client/streamable_https_test.dart | 265 ++++ test/mcp_2026_07_28_test.dart | 1158 ++++++++++++++++++ test/server/server_test.dart | 7 +- test/server/streamable_https_test.dart | 338 +++++ test/server/streamable_mcp_server_test.dart | 107 +- test/tool_schema_test.dart | 41 + test/types/subscriptions_test.dart | 178 +++ test/types/tasks_extension_test.dart | 182 +++ 29 files changed, 4834 insertions(+), 92 deletions(-) create mode 100644 lib/src/types/subscriptions.dart create mode 100644 test/mcp_2026_07_28_test.dart create mode 100644 test/types/subscriptions_test.dart create mode 100644 test/types/tasks_extension_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 2016f9ed..a8283e69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## Unreleased + +### MCP 2026-07-28 RC + +- Started the MCP 2026-07-28 RC development line with opt-in protocol + constants, stateless request metadata helpers, and `server/discover` request + and result types. +- 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. + ## 2.2.0 ### Documentation diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index e5ec39a4..88e18b21 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -12,9 +12,25 @@ 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. + 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. + final bool allowLegacyInitializationFallback; + const McpClientOptions({ super.enforceStrictCapabilities, this.capabilities, + this.protocolVersion = latestDraftProtocolVersion, + this.useServerDiscover = false, + this.allowLegacyInitializationFallback = true, }); } @@ -69,12 +85,18 @@ class McpClient extends Protocol { Implementation? _serverVersion; ClientCapabilities _capabilities; final Implementation _clientInfo; + final String _preferredProtocolVersion; + final bool _useServerDiscover; + final bool _allowLegacyInitializationFallback; String? _instructions; Future? _sessionRefresh; + String? _negotiatedProtocolVersion; + bool _usesStatelessProtocol = false; bool _sentInitialized = false; final Map _cachedToolOutputSchemas = {}; final Set _cachedRequiredTaskTools = {}; + final ToolParameterHeaderMappings _cachedToolParameterHeaders = {}; /// Callback for handling elicitation requests from the server. /// @@ -99,6 +121,11 @@ class McpClient extends Protocol { /// - [options]: Optional configuration settings including client capabilities. McpClient(this._clientInfo, {McpClientOptions? options}) : _capabilities = options?.capabilities ?? const ClientCapabilities(), + _preferredProtocolVersion = + options?.protocolVersion ?? latestDraftProtocolVersion, + _useServerDiscover = options?.useServerDiscover ?? false, + _allowLegacyInitializationFallback = + options?.allowLegacyInitializationFallback ?? true, super(options) { // Register elicit handler if any elicitation mode is advertised. if (_capabilities.elicitation != null) { @@ -209,6 +236,7 @@ class McpClient extends Protocol { Future _initializeSession(Transport transport) async { _sentInitialized = false; + _usesStatelessProtocol = false; final initParams = InitializeRequest( protocolVersion: latestProtocolVersion, @@ -236,6 +264,7 @@ class McpClient extends Protocol { _serverCapabilities = result.capabilities; _serverVersion = result.serverInfo; _instructions = result.instructions; + _negotiatedProtocolVersion = result.protocolVersion; if (transport is ProtocolVersionAwareTransport) { (transport as ProtocolVersionAwareTransport).protocolVersion = @@ -256,6 +285,66 @@ class McpClient extends Protocol { ); } + Map _statelessRequestMeta(Map? meta) { + return buildProtocolRequestMeta( + protocolVersion: _negotiatedProtocolVersion ?? _preferredProtocolVersion, + clientInfo: _clientInfo, + clientCapabilities: _capabilities, + meta: meta, + ); + } + + Future discoverServer() async { + final activeTransport = transport; + final ProtocolVersionAwareTransport? versionedTransport = + activeTransport is ProtocolVersionAwareTransport + ? activeTransport as ProtocolVersionAwareTransport + : null; + versionedTransport?.protocolVersion = _preferredProtocolVersion; + + final result = await super.request( + JsonRpcServerDiscoverRequest( + id: -1, + meta: buildProtocolRequestMeta( + protocolVersion: _preferredProtocolVersion, + clientInfo: _clientInfo, + clientCapabilities: _capabilities, + ), + ), + (json) => DiscoverResult.fromJson(json), + ); + + final protocolVersion = negotiateProtocolVersion( + result.supportedVersions, + localSupportedVersions: supportedProtocolVersionsWithDraft, + ); + if (protocolVersion == null) { + throw McpError( + ErrorCode.unsupportedProtocolVersion.value, + "Server does not support a compatible MCP protocol version.", + { + 'supported': result.supportedVersions, + 'requested': _preferredProtocolVersion, + }, + ); + } + + _serverCapabilities = result.capabilities; + _serverVersion = result.serverInfo; + _instructions = result.instructions; + _negotiatedProtocolVersion = protocolVersion; + _usesStatelessProtocol = isStatelessProtocolVersion(protocolVersion); + _sentInitialized = true; + + versionedTransport?.protocolVersion = protocolVersion; + + _logger.debug( + "MCP Server Discovered. Server: ${result.serverInfo.name} ${result.serverInfo.version}, Protocol: $protocolVersion", + ); + + return result; + } + /// Connects to the server using the given [transport]. /// /// Initiates the MCP initialization handshake and processes the result. @@ -264,6 +353,23 @@ class McpClient extends Protocol { await super.connect(transport); try { + if (_useServerDiscover) { + try { + await discoverServer(); + return; + } catch (error) { + final canFallback = _allowLegacyInitializationFallback && + error is McpError && + error.code == ErrorCode.methodNotFound.value; + if (!canFallback) { + rethrow; + } + _logger.debug( + "server/discover not available; falling back to initialize.", + ); + } + } + await _initializeSession(transport); } catch (error) { _logger.error("MCP Client Initialization Failed: $error"); @@ -279,15 +385,26 @@ class McpClient extends Protocol { RequestOptions? options, int? relatedRequestId, ]) async { + final outboundRequest = + _usesStatelessProtocol && requestData.method != Method.serverDiscover + ? JsonRpcRequest( + id: requestData.id, + method: requestData.method, + params: requestData.params, + meta: _statelessRequestMeta(requestData.meta), + ) + : requestData; + try { return await super.request( - requestData, + outboundRequest, resultFactory, options, relatedRequestId, ); } catch (error) { - if (error is! StaleSessionError || requestData.method == 'initialize') { + if (error is! StaleSessionError || + outboundRequest.method == 'initialize') { rethrow; } @@ -316,7 +433,7 @@ class McpClient extends Protocol { } return await super.request( - requestData, + outboundRequest, resultFactory, options, relatedRequestId, @@ -333,6 +450,9 @@ class McpClient extends Protocol { /// Gets the server's instructions provided during initialization, if any. String? getInstructions() => _instructions; + /// Gets the negotiated protocol version after connection. + String? getProtocolVersion() => _negotiatedProtocolVersion; + @override McpError? validateIncomingRequest(JsonRpcRequest request) { if (_sentInitialized || request.method == Method.ping) { @@ -397,11 +517,29 @@ class McpClient extends Protocol { supported = serverCaps.resources?.subscribe ?? false; requiredCapability = 'resources.subscribe'; break; + case Method.subscriptionsListen: + supported = true; + break; case Method.toolsCall: case Method.toolsList: supported = serverCaps.tools != null; requiredCapability = 'tools'; break; + case Method.tasksGet: + case Method.tasksCancel: + supported = + serverCaps.tasks != null || serverCaps.supportsTasksExtension; + requiredCapability = 'tasks or $mcpTasksExtensionId'; + break; + case Method.tasksUpdate: + supported = serverCaps.supportsTasksExtension; + requiredCapability = mcpTasksExtensionId; + break; + case Method.tasksList: + case Method.tasksResult: + supported = serverCaps.tasks != null; + requiredCapability = 'tasks'; + break; case Method.completionComplete: supported = serverCaps.completions != null; requiredCapability = 'completions'; @@ -670,16 +808,40 @@ class McpClient extends Protocol { options, ); - _cacheToolMetadata(result.tools); + final tools = _cacheToolMetadata(result.tools); - return result; + if (identical(tools, result.tools)) { + return result; + } + + return ListToolsResult( + tools: tools, + nextCursor: result.nextCursor, + meta: result.meta, + ); } - void _cacheToolMetadata(List tools) { + List _cacheToolMetadata(List tools) { _cachedToolOutputSchemas.clear(); _cachedRequiredTaskTools.clear(); + _cachedToolParameterHeaders.clear(); + + var filtered = false; + final validTools = []; for (final tool in tools) { + final headerValidation = _validateToolParameterHeaders(tool); + if (headerValidation.rejectionReason != null) { + filtered = true; + _logger.warn( + 'Rejecting tool "${tool.name}" from tools/list: ' + '${headerValidation.rejectionReason}', + ); + continue; + } + + validTools.add(tool); + if (tool.outputSchema != null) { _cachedToolOutputSchemas[tool.name] = tool.outputSchema!; } @@ -687,7 +849,92 @@ class McpClient extends Protocol { if (tool.execution?.taskSupport == 'required') { _cachedRequiredTaskTools.add(tool.name); } + + if (headerValidation.mappings.isNotEmpty) { + _cachedToolParameterHeaders[tool.name] = headerValidation.mappings; + } + } + + final activeTransport = transport; + final headerAwareTransport = + activeTransport is ToolParameterHeaderAwareTransport + ? activeTransport as ToolParameterHeaderAwareTransport + : null; + if (headerAwareTransport != null) { + headerAwareTransport.setToolParameterHeaderMappings( + _cachedToolParameterHeaders, + ); } + + return filtered ? validTools : tools; + } + + _ToolParameterHeaderValidation _validateToolParameterHeaders(Tool tool) { + final inputSchema = tool.inputSchema; + final properties = + inputSchema is JsonObject ? inputSchema.properties : null; + if (properties == null || properties.isEmpty) { + return const _ToolParameterHeaderValidation.valid({}); + } + + final mappings = {}; + final seenHeaders = {}; + for (final entry in properties.entries) { + final propertyJson = entry.value.toJson(); + if (!propertyJson.containsKey('x-mcp-header')) { + continue; + } + + final rawHeader = propertyJson['x-mcp-header']; + if (rawHeader is! String) { + return _ToolParameterHeaderValidation.invalid( + 'parameter "${entry.key}" has a non-string x-mcp-header value', + ); + } + + if (rawHeader.isEmpty) { + return _ToolParameterHeaderValidation.invalid( + 'parameter "${entry.key}" has an empty x-mcp-header value', + ); + } + + if (!_isValidMcpHeaderNameSuffix(rawHeader)) { + return _ToolParameterHeaderValidation.invalid( + 'parameter "${entry.key}" has invalid x-mcp-header value ' + '"$rawHeader"', + ); + } + + final normalizedHeader = rawHeader.toLowerCase(); + if (!seenHeaders.add(normalizedHeader)) { + return _ToolParameterHeaderValidation.invalid( + 'x-mcp-header value "$rawHeader" is not unique', + ); + } + + if (!_isToolParameterHeaderPrimitive(entry.value)) { + return _ToolParameterHeaderValidation.invalid( + 'parameter "${entry.key}" uses x-mcp-header on a non-primitive type', + ); + } + + mappings[entry.key] = rawHeader; + } + + return _ToolParameterHeaderValidation.valid(mappings); + } + + bool _isValidMcpHeaderNameSuffix(String value) { + return value.codeUnits.every( + (unit) => unit >= 0x21 && unit <= 0x7E && unit != 0x3A, + ); + } + + bool _isToolParameterHeaderPrimitive(JsonSchema schema) { + return schema is JsonString || + schema is JsonNumber || + schema is JsonInteger || + schema is JsonBoolean; } /// Sends a `notifications/roots/list_changed` notification to the server. @@ -700,3 +947,14 @@ class McpClient extends Protocol { /// Deprecated alias for [McpClient]. @Deprecated('Use McpClient instead') typedef Client = McpClient; + +class _ToolParameterHeaderValidation { + final Map mappings; + final String? rejectionReason; + + const _ToolParameterHeaderValidation.valid(this.mappings) + : rejectionReason = null; + + const _ToolParameterHeaderValidation.invalid(this.rejectionReason) + : mappings = const {}; +} diff --git a/lib/src/client/streamable_https.dart b/lib/src/client/streamable_https.dart index 0763ce97..3129167d 100644 --- a/lib/src/client/streamable_https.dart +++ b/lib/src/client/streamable_https.dart @@ -127,13 +127,17 @@ class StreamableHttpClientTransportOptions { /// It will connect to a server using HTTP POST for sending messages and HTTP GET with Server-Sent Events /// for receiving messages. class StreamableHttpClientTransport - implements Transport, ProtocolVersionAwareTransport { + implements + Transport, + ProtocolVersionAwareTransport, + ToolParameterHeaderAwareTransport { StreamController? _abortController; final Uri _url; final Map? _requestInit; final OAuthClientProvider? _authProvider; String? _sessionId; String? _protocolVersion; + ToolParameterHeaderMappings _toolParameterHeaderMappings = const {}; int _sessionGeneration = 0; bool _staleSessionDetected = false; final StreamableHttpReconnectionOptions _reconnectionOptions; @@ -526,6 +530,146 @@ class StreamableHttpClientTransport return headers; } + Map _headersForMessage(JsonRpcMessage message) { + final headers = {}; + final protocolVersion = _protocolVersion ?? _protocolVersionFrom(message); + if (protocolVersion != null) { + headers['MCP-Protocol-Version'] = protocolVersion; + } + + if (protocolVersion == null || + !isStatelessProtocolVersion(protocolVersion)) { + return headers; + } + + final method = _methodFrom(message); + if (method == null) { + return headers; + } + + headers['Mcp-Method'] = method; + + final params = _paramsFrom(message); + final name = _standardNameHeaderValue(method, params); + if (name != null) { + headers['Mcp-Name'] = name; + } + + if (method == Method.toolsCall && name != null) { + headers.addAll(_toolParameterHeaders(name, params)); + } + + return headers; + } + + Map _toolParameterHeaders( + String toolName, + Map? params, + ) { + final mappings = _toolParameterHeaderMappings[toolName]; + final arguments = params?['arguments']; + if (mappings == null || arguments is! Map) { + return const {}; + } + + final argumentMap = arguments.cast(); + final headers = {}; + for (final entry in mappings.entries) { + if (!argumentMap.containsKey(entry.key)) { + continue; + } + + final value = _toolParameterHeaderString(argumentMap[entry.key]); + if (value == null) { + continue; + } + + headers['Mcp-Param-${entry.value}'] = + _encodeToolParameterHeaderValue(value); + } + return headers; + } + + String? _toolParameterHeaderString(Object? value) { + return switch (value) { + String() => value, + num() => value.toString(), + bool() => value.toString(), + _ => null, + }; + } + + String _encodeToolParameterHeaderValue(String value) { + if (_isPlainToolParameterHeaderValue(value)) { + return value; + } + + return '=?base64?${base64Encode(utf8.encode(value))}?='; + } + + bool _isPlainToolParameterHeaderValue(String value) { + return value.trim() == value && + value.codeUnits.every( + (unit) => unit == 0x09 || (unit >= 0x20 && unit <= 0x7E), + ); + } + + String? _methodFrom(JsonRpcMessage message) { + if (message is JsonRpcRequest) { + return message.method; + } + if (message is JsonRpcNotification) { + return message.method; + } + return null; + } + + Map? _paramsFrom(JsonRpcMessage message) { + if (message is JsonRpcRequest) { + return message.params; + } + if (message is JsonRpcNotification) { + return message.params; + } + return null; + } + + Map? _metaFrom(JsonRpcMessage message) { + if (message is JsonRpcRequest) { + return message.meta; + } + if (message is JsonRpcNotification) { + return message.meta; + } + return null; + } + + String? _protocolVersionFrom(JsonRpcMessage message) { + final version = _metaFrom(message)?[McpMetaKey.protocolVersion]; + return version is String ? version : null; + } + + String? _standardNameHeaderValue( + String method, + Map? params, + ) { + if (params == null) { + return null; + } + + final nameField = switch (method) { + Method.toolsCall => params['name'], + Method.resourcesRead => params['uri'], + Method.promptsGet => params['name'], + Method.tasksCancel || + Method.tasksGet || + Method.tasksUpdate => + params['taskId'], + _ => null, + }; + return nameField is String ? nameField : null; + } + String? _clearStaleSession() { final staleSessionId = _sessionId; _sessionId = null; @@ -972,6 +1116,7 @@ class StreamableHttpClientTransport } final headers = await _commonHeaders(); + headers.addAll(_headersForMessage(message)); final requestSessionId = headers['mcp-session-id']; headers['content-type'] = 'application/json'; headers['accept'] = 'application/json, text/event-stream'; @@ -1127,6 +1272,16 @@ class StreamableHttpClientTransport _protocolVersion = value; } + @override + void setToolParameterHeaderMappings( + ToolParameterHeaderMappings mappings, + ) { + _toolParameterHeaderMappings = { + for (final entry in mappings.entries) + entry.key: Map.unmodifiable(Map.from(entry.value)), + }; + } + /// Terminates the current session by sending a DELETE request to the server. /// /// Clients that no longer need a particular session diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index 1a2b7c36..1acb5f76 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -592,7 +592,7 @@ class _RegisteredToolImpl implements RegisteredTool { _server._registeredTools[name] = this; } - Tool toTool() { + Tool toTool({bool includeExecution = true}) { return Tool( name: name, title: title, @@ -602,7 +602,7 @@ class _RegisteredToolImpl implements RegisteredTool { annotations: annotations, icon: icon, icons: _iconsFromLegacyImage(icon), - execution: execution, + execution: includeExecution ? execution : null, meta: meta, ); } @@ -1156,12 +1156,22 @@ class McpServer { server.setRequestHandler( Method.toolsList, - (request, extra) async => ListToolsResult( - tools: _registeredTools.values - .where((t) => t.enabled) - .map((e) => e.toTool()) - .toList(), - ), + (request, extra) async { + final protocolVersion = request.meta?[McpMetaKey.protocolVersion]; + final includeLegacyTaskExecution = protocolVersion is! String || + !isStatelessProtocolVersion(protocolVersion); + + return ListToolsResult( + tools: _registeredTools.values + .where((t) => t.enabled) + .map( + (e) => e.toTool( + includeExecution: includeLegacyTaskExecution, + ), + ) + .toList(), + ); + }, (id, params, meta) => JsonRpcListToolsRequest.fromJson({ 'id': id, 'params': params, @@ -1201,7 +1211,10 @@ class McpServer { } try { - final isTaskRequest = request.isTaskAugmented; + final protocolVersion = request.meta?[McpMetaKey.protocolVersion]; + final isStatelessRequest = protocolVersion is String && + isStatelessProtocolVersion(protocolVersion); + final isTaskRequest = !isStatelessRequest && request.isTaskAugmented; final taskSupport = registeredTool.execution?.taskSupport ?? 'forbidden'; @@ -1220,14 +1233,23 @@ class McpServer { dynamic result; if (taskSupport == 'required') { if (!isTaskRequest) { - throw McpError( - ErrorCode.methodNotFound.value, - "Tool '$toolName' requires task augmentation (taskSupport: 'required')", - ); + if (isStatelessRequest) { + result = await _handleAutomaticTaskPolling( + registeredTool, + toolArgs, + extra, + ); + } else { + throw McpError( + ErrorCode.methodNotFound.value, + "Tool '$toolName' requires task augmentation (taskSupport: 'required')", + ); + } + } else { + final InterfaceToolCallback taskHandler = + registeredTool.callback as InterfaceToolCallback; + result = await taskHandler.handler.createTask(toolArgs, extra); } - final InterfaceToolCallback taskHandler = - registeredTool.callback as InterfaceToolCallback; - result = await taskHandler.handler.createTask(toolArgs, extra); } else if (taskSupport == 'optional') { if (!isTaskRequest) { // Ensure we have a task handler for automatic polling (checked above, but safe cast) diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 507e6ac1..48e80db5 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -73,6 +73,12 @@ class Server extends Protocol { : _capabilities = options?.capabilities ?? const ServerCapabilities(), _instructions = options?.instructions, super(options) { + setRequestHandler( + Method.serverDiscover, + (request, extra) async => _onDiscover(), + (id, params, meta) => JsonRpcServerDiscoverRequest(id: id, meta: meta), + ); + setRequestHandler( Method.initialize, (request, extra) async => _oninitialize(request.initParams), @@ -118,8 +124,221 @@ class Server extends Protocol { _loggingLevels.clear(); } + McpError _unsupportedProtocolVersionError(String requestedVersion) { + return McpError( + ErrorCode.unsupportedProtocolVersion.value, + 'Unsupported protocol version', + { + 'supported': supportedProtocolVersionsWithDraft, + 'requested': requestedVersion, + }, + ); + } + + McpError? _validateStatelessRequestMetadata(JsonRpcRequest request) { + final meta = request.meta; + final requestedVersion = meta?[McpMetaKey.protocolVersion]; + if (requestedVersion is! String || requestedVersion.isEmpty) { + return McpError( + ErrorCode.invalidRequest.value, + 'Missing required request metadata: ${McpMetaKey.protocolVersion}', + ); + } + if (!supportedProtocolVersionsWithDraft.contains(requestedVersion)) { + return _unsupportedProtocolVersionError(requestedVersion); + } + if (!isStatelessProtocolVersion(requestedVersion)) { + return McpError( + ErrorCode.invalidRequest.value, + 'server/discover and stateless requests require a stateless protocol version.', + ); + } + + final clientInfo = meta?[McpMetaKey.clientInfo]; + if (clientInfo is! Map) { + return McpError( + ErrorCode.invalidRequest.value, + 'Missing required request metadata: ${McpMetaKey.clientInfo}', + ); + } + + final clientCapabilities = meta?[McpMetaKey.clientCapabilities]; + if (clientCapabilities is! Map) { + return McpError( + ErrorCode.invalidRequest.value, + 'Missing required request metadata: ${McpMetaKey.clientCapabilities}', + ); + } + + try { + Implementation.fromJson(clientInfo.cast()); + ClientCapabilities.fromJson(clientCapabilities.cast()); + } catch (error) { + return McpError( + ErrorCode.invalidRequest.value, + 'Invalid stateless request metadata.', + error.toString(), + ); + } + + return null; + } + + ({ClientCapabilities? capabilities, McpError? error}) + _clientCapabilitiesForRequest(JsonRpcRequest request) { + final clientCapabilitiesValue = + request.meta?[McpMetaKey.clientCapabilities]; + try { + final clientCapabilities = clientCapabilitiesValue is Map + ? ClientCapabilities.fromJson( + clientCapabilitiesValue.cast(), + ) + : _clientCapabilities; + return (capabilities: clientCapabilities, error: null); + } catch (error) { + return ( + capabilities: null, + error: McpError( + ErrorCode.invalidRequest.value, + 'Invalid request client capabilities metadata.', + error.toString(), + ), + ); + } + } + + McpError _missingTasksExtensionCapabilityError() { + return McpError( + ErrorCode.missingRequiredClientCapability.value, + 'Missing required client capability', + { + 'requiredCapabilities': { + 'extensions': {mcpTasksExtensionId: {}}, + }, + }, + ); + } + + bool _isStatelessRequest(JsonRpcRequest request) { + final requestedProtocolVersion = request.meta?[McpMetaKey.protocolVersion]; + return requestedProtocolVersion is String && + isStatelessProtocolVersion(requestedProtocolVersion); + } + + McpError? _validateDraftTaskMethods(JsonRpcRequest request) { + if (!_isStatelessRequest(request)) { + return null; + } + + switch (request.method) { + case Method.tasksList: + case Method.tasksResult: + return McpError( + ErrorCode.methodNotFound.value, + '${request.method} is not part of the MCP Tasks extension.', + ); + } + + return null; + } + + 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)); + + if (!requiresTasksExtension) { + return null; + } + + final parsed = _clientCapabilitiesForRequest(request); + if (parsed.error != null) { + return parsed.error; + } + + if (parsed.capabilities?.supportsTasksExtension ?? false) { + return null; + } + + return _missingTasksExtensionCapabilityError(); + } + + void _assertTasksExtensionClientCapability(JsonRpcRequest request) { + final parsed = _clientCapabilitiesForRequest(request); + if (parsed.error != null) { + throw parsed.error!; + } + if (!(parsed.capabilities?.supportsTasksExtension ?? false)) { + throw _missingTasksExtensionCapabilityError(); + } + } + + McpError? _validateRequestTaskSemantics(JsonRpcRequest request) { + final removedMethodError = _validateDraftTaskMethods(request); + if (removedMethodError != null) { + return removedMethodError; + } + + final extensionCapabilityError = + _validateTasksExtensionCapabilities(request); + if (extensionCapabilityError != null) { + return extensionCapabilityError; + } + + return null; + } + + bool _allowsToolCallResult(BaseResultData result, JsonRpcRequest request) { + if (result is CallToolResult) { + return true; + } + if (result is InputRequiredResult && _isStatelessRequest(request)) { + return true; + } + if (result is CreateTaskExtensionResult && _isStatelessRequest(request)) { + _assertTasksExtensionClientCapability(request); + return true; + } + + return false; + } + + bool _isLegacyTaskAugmentedRequest(JsonRpcCallToolRequest request) { + if (_isStatelessRequest(request)) { + return false; + } + return request.isTaskAugmented; + } + @override McpError? validateIncomingRequest(JsonRpcRequest request) { + if (request.method == Method.serverDiscover) { + final metadataError = _validateStatelessRequestMetadata(request); + if (metadataError != null) { + return metadataError; + } + return null; + } + + final requestedProtocolVersion = request.meta?[McpMetaKey.protocolVersion]; + if (requestedProtocolVersion is String && + !supportedProtocolVersionsWithDraft + .contains(requestedProtocolVersion)) { + return _unsupportedProtocolVersionError(requestedProtocolVersion); + } + if (requestedProtocolVersion is String && + isStatelessProtocolVersion(requestedProtocolVersion)) { + final metadataError = _validateStatelessRequestMetadata(request); + if (metadataError != null) { + return metadataError; + } + return _validateRequestTaskSemantics(request); + } + if (request.method == Method.initialize) { if (_lifecycleState != _ServerLifecycleState.uninitialized) { return McpError( @@ -148,7 +367,7 @@ class Server extends Protocol { ); } - return null; + return _validateRequestTaskSemantics(request); } @override @@ -266,8 +485,10 @@ class Server extends Protocol { // Run the original handler final result = await handler(request, extra); - // Validate the result based on whether it's a task-augmented request - if (request is JsonRpcCallToolRequest && request.isTaskAugmented) { + // Validate the result based on whether it's a legacy task-augmented + // request. The stateless task extension ignores the old `task` hint. + if (request is JsonRpcCallToolRequest && + _isLegacyTaskAugmentedRequest(request)) { if (result is! CreateTaskResult) { throw McpError( ErrorCode.invalidParams.value, @@ -275,7 +496,7 @@ class Server extends Protocol { ); } } else { - if (result is! CallToolResult) { + if (!_allowsToolCallResult(result, request)) { throw McpError( ErrorCode.invalidParams.value, "Invalid tools/call result: Expected CallToolResult", @@ -310,6 +531,22 @@ class Server extends Protocol { ); } + ServerCapabilities _discoveryCapabilities() { + final json = getCapabilities().toJson(); + json.remove('tasks'); + return ServerCapabilities.fromJson(json); + } + + /// Handles the client's `server/discover` request. + Future _onDiscover() async { + return DiscoverResult( + supportedVersions: supportedProtocolVersionsWithDraft, + capabilities: _discoveryCapabilities(), + serverInfo: _serverInfo, + instructions: _instructions, + ); + } + /// Gets the client's reported capabilities, available after initialization. ClientCapabilities? getClientCapabilities() => _clientCapabilities; @@ -423,6 +660,14 @@ class Server extends Protocol { } break; + case Method.notificationsTasks: + if (!_capabilities.supportsTasksExtension) { + throw StateError( + "Server does not support the $mcpTasksExtensionId extension (required for sending $method)", + ); + } + break; + case Method.notificationsElicitationComplete: if (!(_clientCapabilities?.elicitation?.url != null)) { throw StateError( @@ -433,6 +678,7 @@ class Server extends Protocol { case Method.notificationsCancelled: case Method.notificationsProgress: + case Method.notificationsSubscriptionsAcknowledged: break; default: @@ -445,9 +691,11 @@ class Server extends Protocol { @override void assertRequestHandlerCapability(String method) { switch (method) { + case Method.serverDiscover: case Method.initialize: case Method.ping: case Method.completionComplete: + case Method.subscriptionsListen: break; case Method.loggingSetLevel: @@ -496,8 +744,6 @@ class Server extends Protocol { break; case Method.tasksList: - case Method.tasksCancel: - case Method.tasksGet: case Method.tasksResult: if (!(_capabilities.tasks != null)) { throw StateError( @@ -506,6 +752,24 @@ class Server extends Protocol { } break; + case Method.tasksCancel: + case Method.tasksGet: + if (!(_capabilities.tasks != null || + _capabilities.supportsTasksExtension)) { + throw StateError( + "Server setup error: Cannot handle '$method' without 'tasks' capability or '$mcpTasksExtensionId' extension", + ); + } + break; + + case Method.tasksUpdate: + if (!_capabilities.supportsTasksExtension) { + throw StateError( + "Server setup error: Cannot handle '$method' without '$mcpTasksExtensionId' extension", + ); + } + break; + default: _logger.info( "Setting request handler for potentially custom method '$method'. Ensure server capabilities match.", diff --git a/lib/src/server/streamable_https.dart b/lib/src/server/streamable_https.dart index 1422f554..079dc7c6 100644 --- a/lib/src/server/streamable_https.dart +++ b/lib/src/server/streamable_https.dart @@ -238,6 +238,9 @@ class StreamableHTTPServerTransport if (req.method == "POST") { await _handlePostRequest(req, parsedBody); + } else if (_isStatelessProtocolVersionRequest(req) && + (req.method == "GET" || req.method == "DELETE")) { + await _handleStatelessUnsupportedRequest(req.response); } else if (req.method == "GET") { await _handleGetRequest(req); } else if (req.method == "DELETE") { @@ -261,23 +264,39 @@ class StreamableHTTPServerTransport } final requestedVersion = versionHeader.trim(); - if (supportedProtocolVersions.contains(requestedVersion)) { + if (supportedProtocolVersionsWithDraft.contains(requestedVersion)) { return true; } await _writeJsonRpcErrorResponse( res, httpStatus: HttpStatus.badRequest, - errorCode: ErrorCode.invalidRequest, - message: 'Invalid MCP-Protocol-Version header', + errorCode: ErrorCode.unsupportedProtocolVersion, + message: 'Unsupported protocol version', data: { 'requested': requestedVersion, - 'supported': supportedProtocolVersions, + 'supported': supportedProtocolVersionsWithDraft, }, ); return false; } + bool _isStatelessProtocolVersionRequest(HttpRequest req) { + final versionHeader = req.headers.value('mcp-protocol-version'); + return versionHeader != null && + isStatelessProtocolVersion(versionHeader.trim()); + } + + bool _isValidHeaderValue(String value) { + if (value.trim() != value) { + return false; + } + + return value.codeUnits.every( + (unit) => unit == 0x09 || unit == 0x20 || unit >= 0x21 && unit <= 0x7E, + ); + } + bool _isValidVisibleAsciiToken(String value) { if (value.isEmpty) { return false; @@ -304,13 +323,14 @@ class StreamableHTTPServerTransport required int httpStatus, required ErrorCode errorCode, required String message, + RequestId? id, Object? data, }) async { response.statusCode = httpStatus; response.write( jsonEncode( JsonRpcError( - id: null, + id: id, error: JsonRpcErrorData( code: errorCode.value, message: message, @@ -322,6 +342,270 @@ class StreamableHTTPServerTransport await _safeClose(response); } + Future _writeHeaderMismatchResponse( + HttpResponse response, + JsonRpcMessage message, + String detail, + ) { + return _writeJsonRpcErrorResponse( + response, + httpStatus: HttpStatus.badRequest, + errorCode: ErrorCode.headerMismatch, + id: message is JsonRpcRequest ? message.id : null, + message: 'Header mismatch: $detail', + ); + } + + String? _metadataProtocolVersion(JsonRpcMessage message) { + if (message is JsonRpcRequest) { + final version = message.meta?[McpMetaKey.protocolVersion]; + return version is String ? version : null; + } + if (message is JsonRpcNotification) { + final version = message.meta?[McpMetaKey.protocolVersion]; + return version is String ? version : null; + } + return null; + } + + bool _usesStatelessHttpValidation( + HttpRequest req, + List messages, + ) { + final headerVersion = req.headers.value('mcp-protocol-version')?.trim(); + if (headerVersion != null && isStatelessProtocolVersion(headerVersion)) { + return true; + } + + return messages.any((message) { + final version = _metadataProtocolVersion(message); + return version != null && isStatelessProtocolVersion(version); + }); + } + + String? _messageMethod(JsonRpcMessage message) { + if (message is JsonRpcRequest) { + return message.method; + } + if (message is JsonRpcNotification) { + return message.method; + } + return null; + } + + Map? _messageParams(JsonRpcMessage message) { + if (message is JsonRpcRequest) { + return message.params; + } + if (message is JsonRpcNotification) { + return message.params; + } + return null; + } + + String? _requiredNameHeaderValue(JsonRpcMessage message) { + final method = _messageMethod(message); + final params = _messageParams(message); + if (params == null) { + return null; + } + + final value = switch (method) { + Method.toolsCall => params['name'], + Method.resourcesRead => params['uri'], + Method.promptsGet => params['name'], + Method.tasksCancel || + Method.tasksGet || + Method.tasksUpdate => + params['taskId'], + _ => null, + }; + return value is String ? value : null; + } + + String? _decodeMcpParamHeaderValue(String value) { + if (value.startsWith('=?base64?') && value.endsWith('?=')) { + final encoded = value.substring('=?base64?'.length, value.length - 2); + try { + return utf8.decode(base64Decode(encoded)); + } catch (_) { + return null; + } + } + + return _isValidHeaderValue(value) ? value : null; + } + + String? _primitiveHeaderString(Object? value) { + return switch (value) { + null => null, + String() => value, + num() => value.toString(), + bool() => value.toString(), + _ => null, + }; + } + + Future _validateMcpParamHeaders( + HttpRequest req, + HttpResponse res, + JsonRpcMessage message, + ) async { + final params = _messageParams(message); + final arguments = params?['arguments']; + if (arguments is! Map) { + return true; + } + final argumentMap = arguments.cast(); + + final headerNames = []; + req.headers.forEach((name, values) { + headerNames.add(name); + }); + + for (final headerName in headerNames) { + const prefix = 'mcp-param-'; + if (!headerName.toLowerCase().startsWith(prefix)) { + continue; + } + + final headerSuffix = headerName.substring(prefix.length); + if (headerSuffix.isEmpty || + !headerSuffix.codeUnits.every( + (unit) => unit >= 0x21 && unit <= 0x7E && unit != 0x3A, + )) { + await _writeHeaderMismatchResponse( + res, + message, + "$headerName header name is malformed", + ); + return false; + } + + if (!argumentMap.containsKey(headerSuffix)) { + continue; + } + + final headerValue = req.headers.value(headerName); + final decodedValue = + headerValue == null ? null : _decodeMcpParamHeaderValue(headerValue); + final bodyValue = _primitiveHeaderString(argumentMap[headerSuffix]); + if (decodedValue == null || decodedValue != bodyValue) { + await _writeHeaderMismatchResponse( + res, + message, + "$headerName header value does not match body argument '$headerSuffix'", + ); + return false; + } + } + + return true; + } + + Future _validateStatelessHttpHeaders( + HttpRequest req, + List messages, + ) async { + if (!_usesStatelessHttpValidation(req, messages)) { + return true; + } + + if (messages.length != 1) { + await _writeJsonRpcErrorResponse( + req.response, + httpStatus: HttpStatus.badRequest, + errorCode: ErrorCode.invalidRequest, + message: + 'Invalid Request: stateless MCP POST body must contain one JSON-RPC message', + ); + return false; + } + + final message = messages.single; + final protocolHeader = req.headers.value('mcp-protocol-version')?.trim(); + if (protocolHeader == null || protocolHeader.isEmpty) { + await _writeHeaderMismatchResponse( + req.response, + message, + 'MCP-Protocol-Version header is required', + ); + return false; + } + if (!_isValidHeaderValue(protocolHeader)) { + await _writeHeaderMismatchResponse( + req.response, + message, + 'MCP-Protocol-Version header value is malformed', + ); + return false; + } + + final metadataVersion = _metadataProtocolVersion(message); + if (metadataVersion == null) { + await _writeHeaderMismatchResponse( + req.response, + message, + 'MCP-Protocol-Version header has no matching request _meta protocol version', + ); + return false; + } + if (protocolHeader != metadataVersion) { + await _writeHeaderMismatchResponse( + req.response, + message, + "MCP-Protocol-Version header value '$protocolHeader' does not match body value '$metadataVersion'", + ); + return false; + } + + final method = _messageMethod(message); + if (method == null) { + return true; + } + + final methodHeader = req.headers.value('mcp-method'); + if (methodHeader == null || methodHeader.isEmpty) { + await _writeHeaderMismatchResponse( + req.response, + message, + 'Mcp-Method header is required', + ); + return false; + } + if (methodHeader != method) { + await _writeHeaderMismatchResponse( + req.response, + message, + "Mcp-Method header value '$methodHeader' does not match body value '$method'", + ); + return false; + } + + final requiredName = _requiredNameHeaderValue(message); + if (requiredName != null) { + final nameHeader = req.headers.value('mcp-name'); + if (nameHeader == null || nameHeader.isEmpty) { + await _writeHeaderMismatchResponse( + req.response, + message, + 'Mcp-Name header is required', + ); + return false; + } + if (nameHeader != requiredName) { + await _writeHeaderMismatchResponse( + req.response, + message, + "Mcp-Name header value '$nameHeader' does not match body value '$requiredName'", + ); + return false; + } + } + + return _validateMcpParamHeaders(req, req.response, message); + } + bool _isStandaloneSseStreamId(StreamId streamId) { return streamId == _legacyStandaloneSseStreamId || streamId.startsWith(_standaloneSseStreamIdPrefix); @@ -655,6 +939,23 @@ class StreamableHTTPServerTransport await _safeClose(res); } + Future _handleStatelessUnsupportedRequest(HttpResponse res) async { + res.statusCode = HttpStatus.methodNotAllowed; + res.headers.set(HttpHeaders.allowHeader, "POST"); + res.write( + jsonEncode( + JsonRpcError( + id: null, + error: JsonRpcErrorData( + code: ErrorCode.connectionClosed.value, + message: 'Method not allowed for stateless MCP requests.', + ), + ).toJson(), + ), + ); + await _safeClose(res); + } + /// Handles POST requests containing JSON-RPC messages Future _handlePostRequest(HttpRequest req, [dynamic parsedBody]) async { try { @@ -776,9 +1077,14 @@ class StreamableHTTPServerTransport } } + if (!await _validateStatelessHttpHeaders(req, messages)) { + return; + } + // Check if this is an initialization request // https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle/ final isInitializationRequest = messages.any(_isInitializeRequest); + final isStatelessRequest = messages.any(_isStatelessJsonRpcRequest); if (isInitializationRequest) { final requestSessionId = req.headers.value('mcp-session-id'); @@ -868,6 +1174,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 && !await _validateSession(req, req.response)) { return; } @@ -1225,7 +1532,16 @@ class StreamableHTTPServerTransport /// Checks if a message is an initialize request bool _isInitializeRequest(JsonRpcMessage message) { if (message is JsonRpcRequest) { - return message.method == "initialize"; + return message.method == Method.initialize; + } + return false; + } + + /// Checks if a message uses the stateless 2026 protocol metadata. + bool _isStatelessJsonRpcRequest(JsonRpcMessage message) { + if (message is JsonRpcRequest) { + final version = message.meta?[McpMetaKey.protocolVersion]; + return version is String && isStatelessProtocolVersion(version); } return false; } diff --git a/lib/src/server/streamable_mcp_server.dart b/lib/src/server/streamable_mcp_server.dart index a88361c8..a7ad4c8a 100644 --- a/lib/src/server/streamable_mcp_server.dart +++ b/lib/src/server/streamable_mcp_server.dart @@ -455,15 +455,6 @@ class StreamableMcpServer { // To support the routing logic (new vs existing session), we must read it here. final sessionId = request.headers.value('mcp-session-id'); - if (sessionId != null && !_transports.containsKey(sessionId)) { - await _respondWithJsonRpcError( - request.response, - httpStatus: HttpStatus.notFound, - errorCode: ErrorCode.connectionClosed, - message: 'Session not found', - ); - return; - } final bodyBytes = await _collectBytes(request); final bodyString = utf8.decode(bodyBytes); @@ -471,6 +462,17 @@ class StreamableMcpServer { try { body = jsonDecode(bodyString); } catch (e) { + if (sessionId != null && + !_transports.containsKey(sessionId) && + !_isStatelessProtocolVersionRequest(request)) { + await _respondWithJsonRpcError( + request.response, + httpStatus: HttpStatus.notFound, + errorCode: ErrorCode.connectionClosed, + message: 'Session not found', + ); + return; + } await _respondWithJsonRpcError( request.response, httpStatus: HttpStatus.badRequest, @@ -501,9 +503,28 @@ class StreamableMcpServer { return; } + final isStatelessRequest = _isStatelessRequest(request, body); + if (sessionId != null && + !_transports.containsKey(sessionId) && + !isStatelessRequest) { + await _respondWithJsonRpcError( + request.response, + httpStatus: HttpStatus.notFound, + errorCode: ErrorCode.connectionClosed, + message: 'Session not found', + ); + return; + } + StreamableHTTPServerTransport? transport; - if (sessionId != null) { + if (isStatelessRequest) { + transport = _createStatelessTransport(); + final server = _serverFactory(''); + await server.connect(transport); + await transport.handleRequest(request, body); + return; + } else if (sessionId != null) { transport = _transports[sessionId]!; } else if (_isInitializeRequest(body)) { // New initialization request @@ -528,6 +549,11 @@ class StreamableMcpServer { } Future _handleGetRequest(HttpRequest request) async { + if (_isStatelessProtocolVersionRequest(request)) { + await _createStatelessTransport().handleRequest(request); + return; + } + final sessionId = request.headers.value('mcp-session-id'); if (sessionId == null) { request.response @@ -549,6 +575,11 @@ class StreamableMcpServer { } Future _handleDeleteRequest(HttpRequest request) async { + if (_isStatelessProtocolVersionRequest(request)) { + await _createStatelessTransport().handleRequest(request); + return; + } + final sessionId = request.headers.value('mcp-session-id'); if (sessionId == null) { request.response @@ -621,6 +652,67 @@ class StreamableMcpServer { return transport; } + StreamableHTTPServerTransport _createStatelessTransport() { + return StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + eventStore: eventStore, + enableDnsRebindingProtection: enableDnsRebindingProtection, + allowedHosts: allowedHosts ?? {host}, + allowedOrigins: allowedOrigins, + strictProtocolVersionHeaderValidation: + strictProtocolVersionHeaderValidation, + rejectBatchJsonRpcPayloads: rejectBatchJsonRpcPayloads, + ), + ); + } + + bool _isStatelessProtocolVersionRequest(HttpRequest request) { + final versionHeader = request.headers.value('mcp-protocol-version'); + return versionHeader != null && + isStatelessProtocolVersion(versionHeader.trim()); + } + + bool _isStatelessRequest(HttpRequest request, dynamic body) { + if (_isStatelessProtocolVersionRequest(request)) { + return true; + } + if (body is Map) { + final version = _bodyProtocolVersion(body); + return version != null && isStatelessProtocolVersion(version); + } + if (body is List) { + return body.whereType>().any((item) { + final version = _bodyProtocolVersion(item); + return version != null && isStatelessProtocolVersion(version); + }); + } + return false; + } + + String? _bodyProtocolVersion(Map body) { + final topLevelMeta = body['_meta']; + if (topLevelMeta is Map) { + final version = topLevelMeta[McpMetaKey.protocolVersion]; + if (version is String) { + return version; + } + } + + final params = body['params']; + if (params is Map) { + final meta = params['_meta']; + if (meta is Map) { + final version = meta[McpMetaKey.protocolVersion]; + if (version is String) { + return version; + } + } + } + + return null; + } + bool _isInitializeRequest(dynamic body) { if (body is Map && body.containsKey('method') && diff --git a/lib/src/shared/json_schema/json_schema.dart b/lib/src/shared/json_schema/json_schema.dart index 2db2ae39..da48f924 100644 --- a/lib/src/shared/json_schema/json_schema.dart +++ b/lib/src/shared/json_schema/json_schema.dart @@ -19,6 +19,10 @@ sealed class JsonSchema { } static JsonSchema _fromJson(Map json) { + if (_hasMcpHeaderOnNonPrimitiveSchema(json)) { + return JsonAny.fromJson(json); + } + if (JsonEnum._canParse(json)) { return JsonEnum.fromJson(json); } @@ -172,6 +176,19 @@ sealed class JsonSchema { 'object', }; + static bool _hasMcpHeaderOnNonPrimitiveSchema(Map json) { + if (!json.containsKey('x-mcp-header')) { + return false; + } + + return !const { + 'string', + 'number', + 'integer', + 'boolean', + }.contains(json['type']); + } + static bool _hasOnlyAnnotationAnd( Map json, Set keys, @@ -195,6 +212,7 @@ sealed class JsonSchema { String? title, String? description, String? defaultValue, + String? mcpHeader, }) { return JsonString( minLength: minLength, @@ -206,6 +224,7 @@ sealed class JsonSchema { title: title, description: description, defaultValue: defaultValue, + mcpHeader: mcpHeader, ); } @@ -219,6 +238,7 @@ sealed class JsonSchema { String? title, String? description, num? defaultValue, + String? mcpHeader, }) { return JsonNumber( minimum: minimum, @@ -229,6 +249,7 @@ sealed class JsonSchema { title: title, description: description, defaultValue: defaultValue, + mcpHeader: mcpHeader, ); } @@ -242,6 +263,7 @@ sealed class JsonSchema { String? title, String? description, int? defaultValue, + String? mcpHeader, }) { return JsonInteger( minimum: minimum, @@ -252,6 +274,7 @@ sealed class JsonSchema { title: title, description: description, defaultValue: defaultValue, + mcpHeader: mcpHeader, ); } @@ -260,11 +283,13 @@ sealed class JsonSchema { String? title, String? description, bool? defaultValue, + String? mcpHeader, }) { return JsonBoolean( title: title, description: description, defaultValue: defaultValue, + mcpHeader: mcpHeader, ); } @@ -417,6 +442,8 @@ sealed class JsonSchema { /// A schema for string values. class JsonString extends JsonSchema { final bool _hasDefault; + final bool _hasMcpHeader; + final Object? _rawMcpHeader; final int? minLength; final int? maxLength; final String? pattern; @@ -427,6 +454,9 @@ class JsonString extends JsonSchema { /// Non-standard according to JSON schema 2020-12. final List? enumNames; + /// MCP `x-mcp-header` extension for mirroring this parameter into HTTP. + final String? mcpHeader; + const JsonString({ this.minLength, this.maxLength, @@ -437,7 +467,10 @@ class JsonString extends JsonSchema { super.title, super.description, this.defaultValue, - }) : _hasDefault = defaultValue != null; + this.mcpHeader, + }) : _hasDefault = defaultValue != null, + _hasMcpHeader = mcpHeader != null, + _rawMcpHeader = mcpHeader; const JsonString._({ this.minLength, @@ -449,13 +482,19 @@ class JsonString extends JsonSchema { super.title, super.description, this.defaultValue, + this.mcpHeader, + required Object? rawMcpHeader, required bool hasDefault, - }) : _hasDefault = hasDefault; + required bool hasMcpHeader, + }) : _hasDefault = hasDefault, + _hasMcpHeader = hasMcpHeader, + _rawMcpHeader = rawMcpHeader; @override final String? defaultValue; factory JsonString.fromJson(Map json) { + final rawMcpHeader = json['x-mcp-header']; return JsonString._( minLength: json['minLength'] as int?, maxLength: json['maxLength'] as int?, @@ -467,7 +506,10 @@ class JsonString extends JsonSchema { title: json['title'] as String?, description: json['description'] as String?, defaultValue: json['default'] as String?, + mcpHeader: rawMcpHeader is String ? rawMcpHeader : null, + rawMcpHeader: rawMcpHeader, hasDefault: json.containsKey('default'), + hasMcpHeader: json.containsKey('x-mcp-header'), ); } @@ -484,6 +526,7 @@ class JsonString extends JsonSchema { if (format != null) 'format': format, if (enumValues != null) 'enum': enumValues, if (enumNames != null) 'enumNames': enumNames, + if (_hasMcpHeader) 'x-mcp-header': _rawMcpHeader, }; } } @@ -491,12 +534,17 @@ class JsonString extends JsonSchema { /// A schema for number values. class JsonNumber 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; + /// MCP `x-mcp-header` extension for mirroring this parameter into HTTP. + final String? mcpHeader; + const JsonNumber({ this.minimum, this.maximum, @@ -506,7 +554,10 @@ class JsonNumber extends JsonSchema { this.defaultValue, super.title, super.description, - }) : _hasDefault = defaultValue != null; + this.mcpHeader, + }) : _hasDefault = defaultValue != null, + _hasMcpHeader = mcpHeader != null, + _rawMcpHeader = mcpHeader; const JsonNumber._({ this.minimum, @@ -517,13 +568,19 @@ class JsonNumber extends JsonSchema { this.defaultValue, super.title, super.description, + this.mcpHeader, + required Object? rawMcpHeader, required bool hasDefault, - }) : _hasDefault = hasDefault; + required bool hasMcpHeader, + }) : _hasDefault = hasDefault, + _hasMcpHeader = hasMcpHeader, + _rawMcpHeader = rawMcpHeader; @override final num? defaultValue; factory JsonNumber.fromJson(Map json) { + final rawMcpHeader = json['x-mcp-header']; return JsonNumber._( minimum: json['minimum'] as num?, maximum: json['maximum'] as num?, @@ -533,7 +590,10 @@ class JsonNumber extends JsonSchema { title: json['title'] as String?, description: json['description'] as String?, defaultValue: json['default'] as num?, + mcpHeader: rawMcpHeader is String ? rawMcpHeader : null, + rawMcpHeader: rawMcpHeader, hasDefault: json.containsKey('default'), + hasMcpHeader: json.containsKey('x-mcp-header'), ); } @@ -549,6 +609,7 @@ class JsonNumber extends JsonSchema { if (exclusiveMinimum != null) 'exclusiveMinimum': exclusiveMinimum, if (exclusiveMaximum != null) 'exclusiveMaximum': exclusiveMaximum, if (multipleOf != null) 'multipleOf': multipleOf, + if (_hasMcpHeader) 'x-mcp-header': _rawMcpHeader, }; } } @@ -556,12 +617,17 @@ class JsonNumber extends JsonSchema { /// A schema for integer values. 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; + /// MCP `x-mcp-header` extension for mirroring this parameter into HTTP. + final String? mcpHeader; + const JsonInteger({ this.minimum, this.maximum, @@ -571,7 +637,10 @@ class JsonInteger extends JsonSchema { this.defaultValue, super.title, super.description, - }) : _hasDefault = defaultValue != null; + this.mcpHeader, + }) : _hasDefault = defaultValue != null, + _hasMcpHeader = mcpHeader != null, + _rawMcpHeader = mcpHeader; const JsonInteger._({ this.minimum, @@ -582,13 +651,19 @@ class JsonInteger extends JsonSchema { this.defaultValue, super.title, super.description, + this.mcpHeader, + required Object? rawMcpHeader, required bool hasDefault, - }) : _hasDefault = hasDefault; + required bool hasMcpHeader, + }) : _hasDefault = hasDefault, + _hasMcpHeader = hasMcpHeader, + _rawMcpHeader = rawMcpHeader; @override final int? defaultValue; factory JsonInteger.fromJson(Map json) { + final rawMcpHeader = json['x-mcp-header']; return JsonInteger._( minimum: json['minimum'] as int?, maximum: json['maximum'] as int?, @@ -598,7 +673,10 @@ class JsonInteger extends JsonSchema { title: json['title'] as String?, description: json['description'] as String?, defaultValue: json['default'] as int?, + mcpHeader: rawMcpHeader is String ? rawMcpHeader : null, + rawMcpHeader: rawMcpHeader, hasDefault: json.containsKey('default'), + hasMcpHeader: json.containsKey('x-mcp-header'), ); } @@ -614,6 +692,7 @@ class JsonInteger extends JsonSchema { if (exclusiveMinimum != null) 'exclusiveMinimum': exclusiveMinimum, if (exclusiveMaximum != null) 'exclusiveMaximum': exclusiveMaximum, if (multipleOf != null) 'multipleOf': multipleOf, + if (_hasMcpHeader) 'x-mcp-header': _rawMcpHeader, }; } } @@ -621,29 +700,46 @@ class JsonInteger extends JsonSchema { /// A schema for boolean values. class JsonBoolean extends JsonSchema { final bool _hasDefault; + final bool _hasMcpHeader; + final Object? _rawMcpHeader; + + /// MCP `x-mcp-header` extension for mirroring this parameter into HTTP. + final String? mcpHeader; const JsonBoolean({ this.defaultValue, super.title, super.description, - }) : _hasDefault = defaultValue != null; + this.mcpHeader, + }) : _hasDefault = defaultValue != null, + _hasMcpHeader = mcpHeader != null, + _rawMcpHeader = mcpHeader; const JsonBoolean._({ this.defaultValue, super.title, super.description, + this.mcpHeader, + required Object? rawMcpHeader, required bool hasDefault, - }) : _hasDefault = hasDefault; + required bool hasMcpHeader, + }) : _hasDefault = hasDefault, + _hasMcpHeader = hasMcpHeader, + _rawMcpHeader = rawMcpHeader; @override final bool? defaultValue; factory JsonBoolean.fromJson(Map json) { + final rawMcpHeader = json['x-mcp-header']; return JsonBoolean._( title: json['title'] as String?, description: json['description'] as String?, defaultValue: json['default'] as bool?, + mcpHeader: rawMcpHeader is String ? rawMcpHeader : null, + rawMcpHeader: rawMcpHeader, hasDefault: json.containsKey('default'), + hasMcpHeader: json.containsKey('x-mcp-header'), ); } @@ -654,6 +750,7 @@ class JsonBoolean extends JsonSchema { if (description != null) 'description': description, if (_hasDefault) 'default': defaultValue, 'type': 'boolean', + if (_hasMcpHeader) 'x-mcp-header': _rawMcpHeader, }; } } diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index d1ddadaf..36ca048f 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -197,6 +197,37 @@ class RequestHandlerExtra { await sendNotification(notification); } + + /// Sends the required first acknowledgment for a `subscriptions/listen` stream. + Future sendSubscriptionAcknowledged( + SubscriptionFilter notifications, + ) { + return sendSubscriptionNotification( + JsonRpcSubscriptionsAcknowledgedNotification( + acknowledgedParams: SubscriptionsAcknowledgedNotification( + notifications: notifications, + ), + ), + ); + } + + /// Sends a notification on a `subscriptions/listen` stream with subscription metadata. + Future sendSubscriptionNotification( + JsonRpcNotification notification, + ) { + final meta = { + ...?notification.meta, + McpMetaKey.subscriptionId: requestId, + }; + + return sendNotification( + JsonRpcNotification( + method: notification.method, + params: notification.params, + meta: meta, + ), + ); + } } /// Internal class holding timeout state for a request. diff --git a/lib/src/shared/transport.dart b/lib/src/shared/transport.dart index d4206006..3e8cf588 100644 --- a/lib/src/shared/transport.dart +++ b/lib/src/shared/transport.dart @@ -92,3 +92,15 @@ abstract class ProtocolVersionAwareTransport { /// Updates the negotiated MCP protocol version. set protocolVersion(String? value); } + +/// Maps tool names to argument names and their `Mcp-Param-*` header suffixes. +typedef ToolParameterHeaderMappings = Map>; + +/// Optional capability for transports that can mirror tool arguments into +/// stateless HTTP headers. +abstract class ToolParameterHeaderAwareTransport { + /// Updates the currently advertised tool parameter header mappings. + void setToolParameterHeaderMappings( + ToolParameterHeaderMappings mappings, + ); +} diff --git a/lib/src/types.dart b/lib/src/types.dart index eb7e814a..e9087c0e 100644 --- a/lib/src/types.dart +++ b/lib/src/types.dart @@ -1,5 +1,6 @@ export 'types/content.dart'; export 'types/resources.dart'; +export 'types/subscriptions.dart'; export 'types/prompts.dart'; export 'types/tools.dart'; export 'types/tasks.dart'; diff --git a/lib/src/types/initialization.dart b/lib/src/types/initialization.dart index bd0f472a..784c0330 100644 --- a/lib/src/types/initialization.dart +++ b/lib/src/types/initialization.dart @@ -37,6 +37,19 @@ Map? _serializeCapabilityObject(bool? declared) { return null; } +/// MCP Tasks extension identifier. +const mcpTasksExtensionId = 'io.modelcontextprotocol/tasks'; + +/// Returns [extensions] with the MCP Tasks extension capability declared. +Map> withMcpTasksExtension([ + Map>? extensions, +]) { + return { + ...?extensions, + mcpTasksExtensionId: {}, + }; +} + /// Describes an MCP implementation (client or server). class Implementation { /// The name of the implementation. @@ -429,6 +442,10 @@ class ClientCapabilities { if (tasks != null) 'tasks': tasks!.toJson(), if (extensions != null) 'extensions': extensions, }; + + /// Whether the MCP Tasks extension is declared. + bool get supportsTasksExtension => + extensions?.containsKey(mcpTasksExtensionId) ?? false; } /// Parameters for the `initialize` request. @@ -491,6 +508,21 @@ class JsonRpcInitializeRequest extends JsonRpcRequest { } } +/// Request sent by a 2026 client to discover server protocol support. +class JsonRpcServerDiscoverRequest extends JsonRpcRequest { + JsonRpcServerDiscoverRequest({ + required super.id, + super.meta, + }) : super(method: Method.serverDiscover); + + factory JsonRpcServerDiscoverRequest.fromJson(Map json) { + return JsonRpcServerDiscoverRequest( + id: parseRequestId(json['id']), + meta: extractRequestMeta(json), + ); + } +} + /// Describes capabilities related to elicitation > form mode for the server. class ServerElicitationForm { const ServerElicitationForm(); @@ -829,6 +861,10 @@ class ServerCapabilities { if (tasks != null) 'tasks': tasks!.toJson(), if (extensions != null) 'extensions': extensions, }; + + /// Whether the MCP Tasks extension is declared. + bool get supportsTasksExtension => + extensions?.containsKey(mcpTasksExtensionId) ?? false; } /// Result data for a successful `initialize` request. @@ -882,6 +918,69 @@ class InitializeResult implements BaseResultData { }; } +/// Result data for a successful `server/discover` request. +class DiscoverResult implements BaseResultData { + /// Result discriminator used by the 2026 result model. + final String resultType; + + /// Protocol versions supported by the server. + final List supportedVersions; + + /// Capabilities the server supports. + final ServerCapabilities capabilities; + + /// Information about the server implementation. + final Implementation serverInfo; + + /// Instructions describing how to use the server and its features. + final String? instructions; + + /// Optional metadata. + @override + final Map? meta; + + const DiscoverResult({ + this.resultType = 'complete', + required this.supportedVersions, + required this.capabilities, + required this.serverInfo, + this.instructions, + this.meta, + }); + + factory DiscoverResult.fromJson(Map json) { + final supportedVersions = json['supportedVersions']; + if (supportedVersions is! List) { + throw const FormatException( + 'Missing or invalid supportedVersions for discover result', + ); + } + + return DiscoverResult( + resultType: json['resultType'] as String? ?? 'complete', + supportedVersions: supportedVersions.cast(), + capabilities: ServerCapabilities.fromJson( + json['capabilities'] as Map, + ), + serverInfo: Implementation.fromJson( + json['serverInfo'] as Map, + ), + instructions: json['instructions'] as String?, + meta: json['_meta'] as Map?, + ); + } + + @override + Map toJson() => { + 'resultType': resultType, + 'supportedVersions': supportedVersions, + 'capabilities': capabilities.toJson(), + 'serverInfo': serverInfo.toJson(), + if (instructions != null) 'instructions': instructions, + if (meta != null) '_meta': meta, + }; +} + /// Notification sent from the client to the server after initialization is finished. class JsonRpcInitializedNotification extends JsonRpcNotification { const JsonRpcInitializedNotification() diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index a4096924..80c8fab2 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -8,10 +8,21 @@ import 'logging.dart'; import 'sampling.dart'; import 'completion.dart'; import 'roots.dart'; +import 'subscriptions.dart'; import 'tasks.dart'; +import 'validation.dart'; -/// The latest version of the Model Context Protocol supported. -const latestProtocolVersion = "2025-11-25"; +/// The draft/RC MCP protocol version being prepared for the next major release. +const draftProtocolVersion2026_07_28 = "2026-07-28"; + +/// The latest stable version of the Model Context Protocol supported. +const stableProtocolVersion2025_11_25 = "2025-11-25"; + +/// The latest stable version of the Model Context Protocol supported. +const latestProtocolVersion = stableProtocolVersion2025_11_25; + +/// The latest draft/RC protocol version implemented behind opt-in paths. +const latestDraftProtocolVersion = draftProtocolVersion2026_07_28; /// List of supported Model Context Protocol versions. const supportedProtocolVersions = [ @@ -22,11 +33,70 @@ const supportedProtocolVersions = [ "2024-10-07", ]; +/// Protocol versions supported by the 2026 RC development branch. +const supportedProtocolVersionsWithDraft = [ + latestDraftProtocolVersion, + ...supportedProtocolVersions, +]; + +/// Protocol versions that use per-request metadata instead of initialization. +const statelessProtocolVersions = [ + draftProtocolVersion2026_07_28, +]; + +/// Returns true when [version] uses the 2026 stateless request model. +bool isStatelessProtocolVersion(String version) => + statelessProtocolVersions.contains(version); + +/// Selects the first locally preferred version supported by a peer. +String? negotiateProtocolVersion( + Iterable peerSupportedVersions, { + Iterable localSupportedVersions = supportedProtocolVersionsWithDraft, +}) { + final peerVersions = peerSupportedVersions.toSet(); + for (final version in localSupportedVersions) { + if (peerVersions.contains(version)) { + return version; + } + } + return null; +} + +/// MCP-reserved `_meta` keys used by the 2026 stateless request model. +class McpMetaKey { + static const protocolVersion = 'io.modelcontextprotocol/protocolVersion'; + static const clientInfo = 'io.modelcontextprotocol/clientInfo'; + static const clientCapabilities = + 'io.modelcontextprotocol/clientCapabilities'; + static const logLevel = 'io.modelcontextprotocol/logLevel'; + static const subscriptionId = 'io.modelcontextprotocol/subscriptionId'; + + const McpMetaKey._(); +} + +/// Builds request metadata required by the 2026 stateless request model. +Map buildProtocolRequestMeta({ + required String protocolVersion, + required Implementation clientInfo, + required ClientCapabilities clientCapabilities, + Map? meta, + Object? logLevel, +}) { + return { + ...?meta, + McpMetaKey.protocolVersion: protocolVersion, + McpMetaKey.clientInfo: clientInfo.toJson(), + McpMetaKey.clientCapabilities: clientCapabilities.toJson(), + if (logLevel != null) McpMetaKey.logLevel: logLevel, + }; +} + /// JSON-RPC protocol version string. const jsonRpcVersion = "2.0"; /// Standard MCP JSON-RPC methods. class Method { + static const serverDiscover = "server/discover"; static const initialize = "initialize"; static const ping = "ping"; static const resourcesList = "resources/list"; @@ -34,6 +104,7 @@ class Method { static const resourcesTemplatesList = "resources/templates/list"; static const resourcesSubscribe = "resources/subscribe"; static const resourcesUnsubscribe = "resources/unsubscribe"; + static const subscriptionsListen = "subscriptions/listen"; static const promptsList = "prompts/list"; static const promptsGet = "prompts/get"; static const elicitationCreate = "elicitation/create"; @@ -47,6 +118,7 @@ class Method { static const tasksCancel = "tasks/cancel"; static const tasksGet = "tasks/get"; static const tasksResult = "tasks/result"; + static const tasksUpdate = "tasks/update"; static const notificationsInitialized = "notifications/initialized"; static const notificationsCancelled = "notifications/cancelled"; @@ -55,6 +127,8 @@ class Method { "notifications/resources/list_changed"; static const notificationsResourcesUpdated = "notifications/resources/updated"; + static const notificationsSubscriptionsAcknowledged = + "notifications/subscriptions/acknowledged"; static const notificationsPromptsListChanged = "notifications/prompts/list_changed"; static const notificationsToolsListChanged = @@ -71,6 +145,7 @@ class Method { static const notificationsRootsListChanged = "notifications/roots/list_changed"; static const notificationsTasksStatus = "notifications/tasks/status"; + static const notificationsTasks = "notifications/tasks"; static const notificationsElicitationComplete = "notifications/elicitation/complete"; @@ -185,6 +260,7 @@ sealed class JsonRpcMessage { if (hasId) { return switch (method) { + Method.serverDiscover => JsonRpcServerDiscoverRequest.fromJson(json), Method.initialize => JsonRpcInitializeRequest.fromJson(json), Method.ping => JsonRpcPingRequest.fromJson(json), Method.resourcesList => JsonRpcListResourcesRequest.fromJson(json), @@ -194,6 +270,8 @@ sealed class JsonRpcMessage { Method.resourcesSubscribe => JsonRpcSubscribeRequest.fromJson(json), Method.resourcesUnsubscribe => JsonRpcUnsubscribeRequest.fromJson(json), + Method.subscriptionsListen => + JsonRpcSubscriptionsListenRequest.fromJson(json), Method.promptsList => JsonRpcListPromptsRequest.fromJson(json), Method.promptsGet => JsonRpcGetPromptRequest.fromJson(json), Method.elicitationCreate => JsonRpcElicitRequest.fromJson(json), @@ -209,6 +287,7 @@ sealed class JsonRpcMessage { Method.tasksCancel => JsonRpcCancelTaskRequest.fromJson(json), Method.tasksGet => JsonRpcGetTaskRequest.fromJson(json), Method.tasksResult => JsonRpcTaskResultRequest.fromJson(json), + Method.tasksUpdate => JsonRpcUpdateTaskRequest.fromJson(json), _ => JsonRpcRequest( id: parseRequestId(json['id']), method: method, @@ -231,6 +310,8 @@ sealed class JsonRpcMessage { JsonRpcResourceListChangedNotification.fromJson(json), Method.notificationsResourcesUpdated => JsonRpcResourceUpdatedNotification.fromJson(json), + Method.notificationsSubscriptionsAcknowledged => + JsonRpcSubscriptionsAcknowledgedNotification.fromJson(json), Method.notificationsPromptsListChanged => JsonRpcPromptListChangedNotification.fromJson(json), Method.notificationsToolsListChanged => @@ -245,6 +326,7 @@ sealed class JsonRpcMessage { JsonRpcRootsListChangedNotification.fromJson(json), Method.notificationsTasksStatus => JsonRpcTaskStatusNotification.fromJson(json), + Method.notificationsTasks => JsonRpcTaskNotification.fromJson(json), Method.notificationsElicitationComplete => JsonRpcElicitationCompleteNotification.fromJson(json), _ => JsonRpcNotification( @@ -369,6 +451,18 @@ enum ErrorCode { connectionClosed(-32000), requestTimeout(-32001), + /// HTTP request metadata headers do not match the JSON-RPC body. + /// + /// This is the MCP 2026-07-28 meaning of the shared -32001 server-error + /// code. [requestTimeout] is retained for older SDK behavior. + headerMismatch(-32001), + + /// Required per-request client capabilities were not declared. + missingRequiredClientCapability(-32003), + + /// The requested protocol version is unsupported by the receiver. + unsupportedProtocolVersion(-32004), + /// URL mode elicitation is required before the request can be processed. /// The error data contains elicitations that must be completed. urlElicitationRequired(-32042), @@ -446,6 +540,263 @@ abstract class BaseResultData { Map toJson(); } +/// Result type for completed MCP requests. +const resultTypeComplete = 'complete'; + +/// Result type for MCP multi round-trip requests needing more input. +const resultTypeInputRequired = 'input_required'; + +/// Result type for MCP task extension task creation results. +const resultTypeTask = 'task'; + +/// Map of server-assigned input request keys to requested inputs. +typedef InputRequests = Map; + +/// Map of server-assigned input request keys to client responses. +typedef InputResponses = Map; + +/// A server-to-client request embedded in an MRTR `InputRequiredResult`. +class InputRequest { + /// Request method. Must be one of the MRTR-supported server request methods. + final String method; + + /// Request params, when present. + final Map? params; + + const InputRequest._({required this.method, this.params}); + + /// Creates an embedded `elicitation/create` input request. + factory InputRequest.elicit(ElicitRequest params) { + return InputRequest._( + method: Method.elicitationCreate, + params: params.toJson(), + ); + } + + /// Creates an embedded `sampling/createMessage` input request. + factory InputRequest.createMessage(CreateMessageRequest params) { + return InputRequest._( + method: Method.samplingCreateMessage, + params: params.toJson(), + ); + } + + /// Creates an embedded `roots/list` input request. + factory InputRequest.listRoots({Map? params}) { + return InputRequest._( + method: Method.rootsList, + params: params, + ); + } + + factory InputRequest.fromJson(Map json) { + final method = json['method']; + if (method is! String) { + throw const FormatException('InputRequest.method is required'); + } + + switch (method) { + case Method.elicitationCreate: + final params = _readRequiredJsonObject( + json['params'], + 'InputRequest.params', + ); + ElicitRequest.fromJson(params); + return InputRequest._(method: method, params: params); + case Method.samplingCreateMessage: + final params = _readRequiredJsonObject( + json['params'], + 'InputRequest.params', + ); + CreateMessageRequest.fromJson(params); + return InputRequest._(method: method, params: params); + case Method.rootsList: + return InputRequest._( + method: method, + params: _readOptionalJsonObject( + json['params'], + 'InputRequest.params', + ), + ); + default: + throw const FormatException( + 'InputRequest.method must be one of ' + '${Method.elicitationCreate}, ${Method.samplingCreateMessage}, ' + 'or ${Method.rootsList}', + ); + } + } + + /// Parses an input request map. + static InputRequests? mapFromJson(Object? value, String field) { + if (value == null) { + return null; + } + final json = _readRequiredJsonObject(value, field); + return json.map( + (key, value) => MapEntry( + key, + InputRequest.fromJson(_readRequiredJsonObject(value, '$field.$key')), + ), + ); + } + + /// Converts an input request map to JSON. + static Map mapToJson(InputRequests requests) { + return requests.map( + (key, value) => MapEntry(key, value.toJson()), + ); + } + + /// The typed params for an embedded `elicitation/create` request. + ElicitRequest get elicitParams { + if (method != Method.elicitationCreate || params == null) { + throw StateError('InputRequest is not an elicitation/create request'); + } + return ElicitRequest.fromJson(params!); + } + + /// The typed params for an embedded `sampling/createMessage` request. + CreateMessageRequest get createMessageParams { + if (method != Method.samplingCreateMessage || params == null) { + throw StateError('InputRequest is not a sampling/createMessage request'); + } + return CreateMessageRequest.fromJson(params!); + } + + Map toJson() => { + 'method': method, + if (params != null) 'params': params, + }; +} + +/// A client response to an MRTR [InputRequest]. +class InputResponse { + /// Raw result object for the embedded request. + final Map value; + + const InputResponse.raw(this.value); + + /// Creates an input response from a typed MCP result. + factory InputResponse.fromResult(BaseResultData result) { + return InputResponse.raw(result.toJson()); + } + + factory InputResponse.fromJson(Map json) { + return InputResponse.raw(Map.from(json)); + } + + /// Parses an input response map. + static InputResponses? mapFromJson(Object? value, String field) { + if (value == null) { + return null; + } + final json = _readRequiredJsonObject(value, field); + return json.map( + (key, value) => MapEntry( + key, + InputResponse.fromJson(_readRequiredJsonObject(value, '$field.$key')), + ), + ); + } + + /// Converts an input response map to JSON. + static Map mapToJson(InputResponses responses) { + return responses.map( + (key, value) => MapEntry(key, value.toJson()), + ); + } + + Map toJson() => Map.from(value); +} + +/// Result returned when a request needs extra client input before retry. +class InputRequiredResult implements BaseResultData { + /// Server-to-client requests the client must fulfill before retry. + final InputRequests? inputRequests; + + /// Opaque server state to echo exactly on retry. + final String? requestState; + + /// Optional metadata. + @override + final Map? meta; + + const InputRequiredResult({ + this.inputRequests, + this.requestState, + this.meta, + }) : assert( + inputRequests != null || requestState != null, + 'InputRequiredResult requires inputRequests or requestState', + ); + + factory InputRequiredResult.fromJson(Map json) { + if (json['resultType'] != resultTypeInputRequired) { + throw const FormatException( + 'InputRequiredResult.resultType must be input_required', + ); + } + + final inputRequests = InputRequest.mapFromJson( + json['inputRequests'], + 'InputRequiredResult.inputRequests', + ); + final requestState = readOptionalString( + json['requestState'], + 'InputRequiredResult.requestState', + ); + if (inputRequests == null && requestState == null) { + throw const FormatException( + 'InputRequiredResult requires inputRequests or requestState', + ); + } + + return InputRequiredResult( + inputRequests: inputRequests, + requestState: requestState, + meta: _readOptionalJsonObject(json['_meta'], 'InputRequiredResult._meta'), + ); + } + + @override + Map toJson() { + if (inputRequests == null && requestState == null) { + throw StateError( + 'InputRequiredResult requires inputRequests or requestState', + ); + } + + return { + 'resultType': resultTypeInputRequired, + if (inputRequests != null) + 'inputRequests': InputRequest.mapToJson(inputRequests!), + if (requestState != null) 'requestState': requestState, + if (meta != null) '_meta': meta, + }; + } +} + +Map _readRequiredJsonObject(Object? value, String field) { + 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'); +} + +Map? _readOptionalJsonObject(Object? value, String field) { + if (value == null) { + return null; + } + return _readRequiredJsonObject(value, field); +} + /// Custom error class for MCP specific errors. class McpError extends Error { /// The error code (typically from [ErrorCode] or custom). diff --git a/lib/src/types/prompts.dart b/lib/src/types/prompts.dart index 0dd49aa6..59375ce3 100644 --- a/lib/src/types/prompts.dart +++ b/lib/src/types/prompts.dart @@ -1,5 +1,6 @@ import '../types.dart'; import 'json_rpc.dart'; +import 'validation.dart'; /// Describes an argument accepted by a prompt template. class PromptArgument { @@ -186,7 +187,18 @@ class GetPromptRequest { /// Arguments to use for templating the prompt. final Map? arguments; - const GetPromptRequest({required this.name, this.arguments}); + /// Client responses to MRTR input requests when retrying this prompt request. + final InputResponses? inputResponses; + + /// Opaque MRTR state returned by the server and echoed on retry. + final String? requestState; + + const GetPromptRequest({ + required this.name, + this.arguments, + this.inputResponses, + this.requestState, + }); factory GetPromptRequest.fromJson(Map json) => GetPromptRequest( @@ -194,11 +206,22 @@ class GetPromptRequest { arguments: (json['arguments'] as Map?)?.map( (k, v) => MapEntry(k, v as String), ), + inputResponses: InputResponse.mapFromJson( + json['inputResponses'], + 'GetPromptRequest.inputResponses', + ), + requestState: readOptionalString( + json['requestState'], + 'GetPromptRequest.requestState', + ), ); Map toJson() => { 'name': name, if (arguments != null) 'arguments': arguments, + if (inputResponses != null) + 'inputResponses': InputResponse.mapToJson(inputResponses!), + if (requestState != null) 'requestState': requestState, }; } diff --git a/lib/src/types/resources.dart b/lib/src/types/resources.dart index 158a7e76..650c84d4 100644 --- a/lib/src/types/resources.dart +++ b/lib/src/types/resources.dart @@ -393,12 +393,37 @@ class ReadResourceRequest { /// The URI of the resource to read. final String uri; - const ReadResourceRequest({required this.uri}); + /// Client responses to MRTR input requests when retrying this read request. + final InputResponses? inputResponses; + + /// Opaque MRTR state returned by the server and echoed on retry. + final String? requestState; + + const ReadResourceRequest({ + required this.uri, + this.inputResponses, + this.requestState, + }); factory ReadResourceRequest.fromJson(Map json) => - ReadResourceRequest(uri: json['uri'] as String); + ReadResourceRequest( + uri: json['uri'] as String, + inputResponses: InputResponse.mapFromJson( + json['inputResponses'], + 'ReadResourceRequest.inputResponses', + ), + requestState: readOptionalString( + json['requestState'], + 'ReadResourceRequest.requestState', + ), + ); - Map toJson() => {'uri': uri}; + Map toJson() => { + 'uri': uri, + if (inputResponses != null) + 'inputResponses': InputResponse.mapToJson(inputResponses!), + if (requestState != null) 'requestState': requestState, + }; } /// Request sent from client to read a specific resource. diff --git a/lib/src/types/subscriptions.dart b/lib/src/types/subscriptions.dart new file mode 100644 index 00000000..5680f052 --- /dev/null +++ b/lib/src/types/subscriptions.dart @@ -0,0 +1,250 @@ +import 'initialization.dart'; +import 'json_rpc.dart'; + +/// Notification filter requested by `subscriptions/listen`. +class SubscriptionFilter { + /// Subscribe to `notifications/tools/list_changed`. + final bool? toolsListChanged; + + /// Subscribe to `notifications/prompts/list_changed`. + final bool? promptsListChanged; + + /// Subscribe to `notifications/resources/list_changed`. + final bool? resourcesListChanged; + + /// Subscribe to `notifications/resources/updated` for the given URIs. + final List? resourceSubscriptions; + + /// Subscribe to `notifications/tasks` for the given task ids. + final List? taskIds; + + const SubscriptionFilter({ + this.toolsListChanged, + this.promptsListChanged, + this.resourcesListChanged, + this.resourceSubscriptions, + this.taskIds, + }); + + factory SubscriptionFilter.fromJson(Map json) { + return SubscriptionFilter( + toolsListChanged: _readOptionalBool( + json['toolsListChanged'], + 'SubscriptionFilter.toolsListChanged', + ), + promptsListChanged: _readOptionalBool( + json['promptsListChanged'], + 'SubscriptionFilter.promptsListChanged', + ), + resourcesListChanged: _readOptionalBool( + json['resourcesListChanged'], + 'SubscriptionFilter.resourcesListChanged', + ), + resourceSubscriptions: _readOptionalStringList( + json['resourceSubscriptions'], + 'SubscriptionFilter.resourceSubscriptions', + ), + taskIds: _readOptionalStringList( + json['taskIds'], + 'SubscriptionFilter.taskIds', + ), + ); + } + + /// Returns the subset this server can honor from this requested filter. + SubscriptionFilter acknowledgedBy(ServerCapabilities capabilities) { + return SubscriptionFilter( + toolsListChanged: + toolsListChanged == true && (capabilities.tools?.listChanged ?? false) + ? true + : null, + promptsListChanged: promptsListChanged == true && + (capabilities.prompts?.listChanged ?? false) + ? true + : null, + resourcesListChanged: resourcesListChanged == true && + (capabilities.resources?.listChanged ?? false) + ? true + : null, + resourceSubscriptions: + resourceSubscriptions != null && capabilities.resources != null + ? List.unmodifiable(resourceSubscriptions!) + : null, + taskIds: taskIds != null && capabilities.supportsTasksExtension + ? List.unmodifiable(taskIds!) + : null, + ); + } + + Map toJson() => { + if (toolsListChanged != null) 'toolsListChanged': toolsListChanged, + if (promptsListChanged != null) + 'promptsListChanged': promptsListChanged, + if (resourcesListChanged != null) + 'resourcesListChanged': resourcesListChanged, + if (resourceSubscriptions != null) + 'resourceSubscriptions': resourceSubscriptions, + if (taskIds != null) 'taskIds': taskIds, + }; +} + +/// Parameters for a `subscriptions/listen` request. +class SubscriptionsListenRequest { + /// Notifications the client opts into on this stream. + final SubscriptionFilter notifications; + + 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', + ); + } + + return SubscriptionsListenRequest( + notifications: SubscriptionFilter.fromJson( + notifications.cast(), + ), + ); + } + + Map toJson() => { + 'notifications': notifications.toJson(), + }; +} + +/// Request sent by a client to open a long-lived notification stream. +class JsonRpcSubscriptionsListenRequest extends JsonRpcRequest { + /// The listen request parameters. + final SubscriptionsListenRequest listenParams; + + JsonRpcSubscriptionsListenRequest({ + required super.id, + required this.listenParams, + super.meta, + }) : super( + method: Method.subscriptionsListen, + params: listenParams.toJson(), + ); + + factory JsonRpcSubscriptionsListenRequest.fromJson( + Map json, + ) { + final paramsMap = json['params'] as Map?; + if (paramsMap == null) { + throw const FormatException( + 'Missing params for subscriptions/listen request', + ); + } + + return JsonRpcSubscriptionsListenRequest( + id: parseRequestId(json['id']), + listenParams: SubscriptionsListenRequest.fromJson(paramsMap), + meta: extractRequestMeta(json), + ); + } +} + +/// Parameters for `notifications/subscriptions/acknowledged`. +class SubscriptionsAcknowledgedNotification { + /// The subset of the requested filter the server agreed to honor. + final SubscriptionFilter notifications; + + const SubscriptionsAcknowledgedNotification({required this.notifications}); + + factory SubscriptionsAcknowledgedNotification.fromJson( + Map json, + ) { + final notifications = json['notifications']; + if (notifications is! Map) { + throw const FormatException( + 'SubscriptionsAcknowledgedNotification.notifications is required', + ); + } + + return SubscriptionsAcknowledgedNotification( + notifications: SubscriptionFilter.fromJson( + notifications.cast(), + ), + ); + } + + Map toJson() => { + 'notifications': notifications.toJson(), + }; +} + +/// Notification acknowledging a `subscriptions/listen` stream. +class JsonRpcSubscriptionsAcknowledgedNotification extends JsonRpcNotification { + /// The acknowledgment parameters. + final SubscriptionsAcknowledgedNotification acknowledgedParams; + + JsonRpcSubscriptionsAcknowledgedNotification({ + required this.acknowledgedParams, + super.meta, + }) : super( + method: Method.notificationsSubscriptionsAcknowledged, + params: acknowledgedParams.toJson(), + ); + + factory JsonRpcSubscriptionsAcknowledgedNotification.fromJson( + Map json, + ) { + final paramsMap = json['params'] as Map?; + if (paramsMap == null) { + throw const FormatException( + 'Missing params for subscriptions acknowledged notification', + ); + } + + return JsonRpcSubscriptionsAcknowledgedNotification( + acknowledgedParams: + SubscriptionsAcknowledgedNotification.fromJson(paramsMap), + meta: _readOptionalJsonObject( + paramsMap['_meta'], + 'SubscriptionsAcknowledgedNotification._meta', + ), + ); + } +} + +bool? _readOptionalBool(Object? value, String field) { + if (value == null) { + return null; + } + if (value is bool) { + return value; + } + throw FormatException('$field must be a boolean'); +} + +List? _readOptionalStringList(Object? value, String field) { + if (value == null) { + return null; + } + if (value is! List) { + throw FormatException('$field must be an array'); + } + if (value.any((item) => item is! String)) { + throw FormatException('$field must contain only strings'); + } + return value.cast(); +} + +Map? _readOptionalJsonObject(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'); +} diff --git a/lib/src/types/tasks.dart b/lib/src/types/tasks.dart index fe7ce012..a9a6fca7 100644 --- a/lib/src/types/tasks.dart +++ b/lib/src/types/tasks.dart @@ -366,6 +366,71 @@ class JsonRpcTaskResultRequest extends JsonRpcRequest { } } +/// Parameters for the MCP Tasks extension `tasks/update` request. +class UpdateTaskRequest { + /// The ID of the task to update. + final String taskId; + + /// Responses to outstanding task input requests. + final InputResponses inputResponses; + + const UpdateTaskRequest({ + required this.taskId, + required this.inputResponses, + }); + + factory UpdateTaskRequest.fromJson(Map json) { + final inputResponses = InputResponse.mapFromJson( + json['inputResponses'], + 'UpdateTaskRequest.inputResponses', + ); + if (inputResponses == null) { + throw const FormatException( + 'UpdateTaskRequest.inputResponses is required', + ); + } + + return UpdateTaskRequest( + taskId: _readRequiredTaskString( + json, + 'taskId', + owner: 'UpdateTaskRequest', + ), + inputResponses: inputResponses, + ); + } + + Map toJson() => { + 'taskId': taskId, + 'inputResponses': InputResponse.mapToJson(inputResponses), + }; +} + +/// Request sent by a client to provide input for a task. +class JsonRpcUpdateTaskRequest extends JsonRpcRequest { + /// The update parameters. + final UpdateTaskRequest updateParams; + + JsonRpcUpdateTaskRequest({ + required super.id, + required this.updateParams, + super.meta, + }) : 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 meta = extractRequestMeta(json); + return JsonRpcUpdateTaskRequest( + id: parseRequestId(json['id']), + updateParams: UpdateTaskRequest.fromJson(paramsMap), + meta: meta, + ); + } +} + /// Parameters for task creation when augmenting requests. class TaskCreation { /// Requested duration in milliseconds to retain task from creation. @@ -433,6 +498,219 @@ class TaskErrorMessage extends TaskStreamMessage { const TaskErrorMessage(this.error) : super('error'); } +/// Task state shape used by the MCP Tasks extension. +class TaskExtensionTask { + /// Unique identifier for the task. + final String taskId; + + /// Current state of the task execution. + final TaskStatus status; + + /// Optional human-readable message describing the current state. + final String? statusMessage; + + /// ISO 8601 timestamp when the task was created. + final String createdAt; + + /// ISO 8601 timestamp when the task was last updated. + final String lastUpdatedAt; + + /// Time in milliseconds from creation before task may be deleted. + final int? ttlMs; + + /// Suggested time in milliseconds between status checks. + final int? pollIntervalMs; + + /// Outstanding input requests when [status] is `input_required`. + final InputRequests? inputRequests; + + /// Final result when [status] is `completed`. + final Map? result; + + /// JSON-RPC error when [status] is `failed`. + final JsonRpcErrorData? error; + + const TaskExtensionTask({ + required this.taskId, + required this.status, + required this.createdAt, + required this.lastUpdatedAt, + required this.ttlMs, + this.statusMessage, + this.pollIntervalMs, + this.inputRequests, + this.result, + this.error, + }); + + factory TaskExtensionTask.fromJson(Map json) { + return TaskExtensionTask( + taskId: _readRequiredTaskString( + json, + 'taskId', + owner: 'TaskExtensionTask', + ), + status: TaskStatusName.fromString( + _readRequiredTaskString(json, 'status', owner: 'TaskExtensionTask'), + ), + statusMessage: _readOptionalTaskString( + json, + 'statusMessage', + owner: 'TaskExtensionTask', + ), + createdAt: _readRequiredTaskString( + json, + 'createdAt', + owner: 'TaskExtensionTask', + ), + lastUpdatedAt: _readRequiredTaskString( + json, + 'lastUpdatedAt', + owner: 'TaskExtensionTask', + ), + ttlMs: _readTaskInt( + json, + 'ttlMs', + requiredField: true, + owner: 'TaskExtensionTask', + ), + pollIntervalMs: _readTaskInt( + json, + 'pollIntervalMs', + owner: 'TaskExtensionTask', + ), + inputRequests: InputRequest.mapFromJson( + json['inputRequests'], + 'TaskExtensionTask.inputRequests', + ), + result: _readOptionalJsonObject( + json['result'], + 'TaskExtensionTask.result', + ), + error: json['error'] == null + ? null + : JsonRpcErrorData.fromJson( + _readRequiredJsonObject( + json['error'], + 'TaskExtensionTask.error', + ), + ), + ); + } + + 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': result, + if (error != null) 'error': error!.toJson(), + }; +} + +/// `resultType: "task"` response from the MCP Tasks extension. +class CreateTaskExtensionResult implements BaseResultData { + /// The created task state. + final TaskExtensionTask task; + + /// Optional metadata. + @override + final Map? meta; + + const CreateTaskExtensionResult({required this.task, this.meta}); + + factory CreateTaskExtensionResult.fromJson(Map json) { + if (json['resultType'] != resultTypeTask) { + throw const FormatException( + 'CreateTaskExtensionResult.resultType must be task', + ); + } + return CreateTaskExtensionResult( + task: TaskExtensionTask.fromJson(json), + meta: _readOptionalJsonObject( + json['_meta'], + 'CreateTaskExtensionResult._meta', + ), + ); + } + + @override + Map toJson() => { + ...task.toJson(resultType: resultTypeTask), + if (meta != null) '_meta': meta, + }; +} + +/// `tasks/get` result from the MCP Tasks extension. +class GetTaskExtensionResult implements BaseResultData { + /// The current task state. + final TaskExtensionTask task; + + /// Optional metadata. + @override + final Map? meta; + + const GetTaskExtensionResult({required this.task, this.meta}); + + factory GetTaskExtensionResult.fromJson(Map json) { + if (json['resultType'] != resultTypeComplete) { + throw const FormatException( + 'GetTaskExtensionResult.resultType must be complete', + ); + } + return GetTaskExtensionResult( + task: TaskExtensionTask.fromJson(json), + meta: _readOptionalJsonObject( + json['_meta'], + 'GetTaskExtensionResult._meta', + ), + ); + } + + @override + Map toJson() => { + ...task.toJson(resultType: resultTypeComplete), + if (meta != null) '_meta': meta, + }; +} + +/// Empty `tasks/update` or `tasks/cancel` acknowledgement result. +class TaskExtensionAcknowledgementResult implements BaseResultData { + /// Optional metadata. + @override + final Map? meta; + + const TaskExtensionAcknowledgementResult({this.meta}); + + factory TaskExtensionAcknowledgementResult.fromJson( + Map json, + ) { + if (json['resultType'] != resultTypeComplete) { + throw const FormatException( + 'TaskExtensionAcknowledgementResult.resultType must be complete', + ); + } + return TaskExtensionAcknowledgementResult( + meta: _readOptionalJsonObject( + json['_meta'], + 'TaskExtensionAcknowledgementResult._meta', + ), + ); + } + + @override + Map toJson() => { + 'resultType': resultTypeComplete, + if (meta != null) '_meta': meta, + }; +} + /// Parameters for the `notifications/tasks/status` notification. class TaskStatusNotification { /// The ID of the task. @@ -568,6 +846,49 @@ class JsonRpcTaskStatusNotification extends JsonRpcNotification { } } +/// `notifications/tasks` notification from the MCP Tasks extension. +class JsonRpcTaskNotification extends JsonRpcNotification { + /// The task state carried by the notification. + final TaskExtensionTask task; + + JsonRpcTaskNotification({required this.task, super.meta}) + : 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"); + } + return JsonRpcTaskNotification( + task: TaskExtensionTask.fromJson(paramsMap), + meta: _readOptionalJsonObject( + paramsMap['_meta'], + 'JsonRpcTaskNotification._meta', + ), + ); + } +} + +Map _readRequiredJsonObject(Object? value, String field) { + 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'); +} + +Map? _readOptionalJsonObject(Object? value, String field) { + if (value == null) { + return null; + } + return _readRequiredJsonObject(value, field); +} + /// Deprecated alias for [ListTasksRequest]. @Deprecated('Use ListTasksRequest instead') typedef ListTasksRequestParams = ListTasksRequest; diff --git a/lib/src/types/tools.dart b/lib/src/types/tools.dart index b9b3e657..9394c44d 100644 --- a/lib/src/types/tools.dart +++ b/lib/src/types/tools.dart @@ -320,9 +320,17 @@ class CallToolRequest { /// The arguments to pass to the tool. final Map arguments; + /// Client responses to MRTR input requests when retrying this tool call. + final InputResponses? inputResponses; + + /// Opaque MRTR state returned by the server and echoed on retry. + final String? requestState; + const CallToolRequest({ required this.name, this.arguments = const {}, + this.inputResponses, + this.requestState, }); factory CallToolRequest.fromJson(Map json) { @@ -332,12 +340,23 @@ class CallToolRequest { arguments: arguments == null ? const {} : (arguments as Map).cast(), + inputResponses: InputResponse.mapFromJson( + json['inputResponses'], + 'CallToolRequest.inputResponses', + ), + requestState: readOptionalString( + json['requestState'], + 'CallToolRequest.requestState', + ), ); } Map toJson() => { 'name': name, 'arguments': arguments, + if (inputResponses != null) + 'inputResponses': InputResponse.mapToJson(inputResponses!), + if (requestState != null) 'requestState': requestState, }; } diff --git a/lib/src/types/validation.dart b/lib/src/types/validation.dart index 3fe8f320..89f2bc8f 100644 --- a/lib/src/types/validation.dart +++ b/lib/src/types/validation.dart @@ -33,3 +33,13 @@ int? readOptionalInteger(Object? value, String field) { } throw FormatException('$field must be an integer'); } + +String? readOptionalString(Object? value, String field) { + if (value == null) { + return null; + } + if (value is String) { + return value; + } + throw FormatException('$field must be a string'); +} diff --git a/test/client/client_test.dart b/test/client/client_test.dart index cbfb5d23..3638cc89 100644 --- a/test/client/client_test.dart +++ b/test/client/client_test.dart @@ -167,6 +167,10 @@ void main() { () => client.assertCapabilityForMethod("tools/call"), returnsNormally, ); + expect( + () => client.assertCapabilityForMethod(Method.subscriptionsListen), + returnsNormally, + ); // Create a client with limited capabilities final limitedClient = Client(clientInfo); @@ -187,6 +191,12 @@ void main() { () => limitedClient.assertCapabilityForMethod("prompts/list"), throwsA(isA()), ); + expect( + () => limitedClient.assertCapabilityForMethod( + Method.subscriptionsListen, + ), + returnsNormally, + ); }); test('assertCapabilityForMethod throws if client not initialized', () { diff --git a/test/client/client_tool_validation_test.dart b/test/client/client_tool_validation_test.dart index 2da0c311..493aab93 100644 --- a/test/client/client_tool_validation_test.dart +++ b/test/client/client_tool_validation_test.dart @@ -3,15 +3,19 @@ import 'dart:async'; import 'package:mcp_dart/mcp_dart.dart'; import 'package:test/test.dart'; -class MockTransport extends Transport { +class MockTransport extends Transport + implements ToolParameterHeaderAwareTransport { final List sentMessages = []; ServerCapabilities serverCapabilities; + List advertisedTools; + ToolParameterHeaderMappings toolParameterHeaderMappings = const {}; MockTransport({ this.serverCapabilities = const ServerCapabilities( tools: ServerCapabilitiesTools(), ), - }); + List? advertisedTools, + }) : advertisedTools = advertisedTools ?? _defaultAdvertisedTools(); @override String? get sessionId => null; @@ -41,35 +45,7 @@ class MockTransport extends Transport { _respond( JsonRpcResponse( id: message.id, - result: ListToolsResult( - tools: [ - Tool( - name: 'validated_tool', - inputSchema: JsonSchema.object(properties: {}), - outputSchema: ToolOutputSchema( - properties: { - 'result': JsonSchema.string(), - }, - required: ['result'], - ), - ), - Tool( - name: 'broken_tool', // Tool that returns invalid data - inputSchema: const ToolInputSchema(), - outputSchema: ToolOutputSchema( - properties: { - 'result': JsonSchema.string(), - }, - required: ['result'], - ), - ), - const Tool( - name: 'task_required_tool', - inputSchema: ToolInputSchema(), - execution: ToolExecution(taskSupport: 'required'), - ), - ], - ).toJson(), + result: ListToolsResult(tools: advertisedTools).toJson(), ), ); } else if (message is JsonRpcRequest && @@ -104,6 +80,46 @@ class MockTransport extends Transport { @override Future start() async {} + + @override + void setToolParameterHeaderMappings( + ToolParameterHeaderMappings mappings, + ) { + toolParameterHeaderMappings = { + for (final entry in mappings.entries) + entry.key: Map.unmodifiable(Map.from(entry.value)), + }; + } + + static List _defaultAdvertisedTools() { + return [ + Tool( + name: 'validated_tool', + inputSchema: JsonSchema.object(properties: {}), + outputSchema: ToolOutputSchema( + properties: { + 'result': JsonSchema.string(), + }, + required: ['result'], + ), + ), + Tool( + name: 'broken_tool', // Tool that returns invalid data + inputSchema: const ToolInputSchema(), + outputSchema: ToolOutputSchema( + properties: { + 'result': JsonSchema.string(), + }, + required: ['result'], + ), + ), + const Tool( + name: 'task_required_tool', + inputSchema: ToolInputSchema(), + execution: ToolExecution(taskSupport: 'required'), + ), + ]; + } } void main() { @@ -118,6 +134,90 @@ void main() { ); }); + test('listTools filters invalid x-mcp-header definitions', () async { + final warnings = []; + setMcpLogHandler((loggerName, level, message) { + if (level == LogLevel.warn) { + warnings.add(message); + } + }); + addTearDown(resetMcpLogHandler); + + transport = MockTransport( + advertisedTools: [ + Tool( + name: 'valid_headers', + inputSchema: JsonSchema.object( + properties: { + 'region': JsonSchema.string(mcpHeader: 'Region'), + 'limit': JsonSchema.number(mcpHeader: 'Limit'), + 'dryRun': JsonSchema.boolean(mcpHeader: 'Dry-Run'), + 'count': JsonSchema.integer(mcpHeader: 'Count'), + }, + ), + ), + Tool( + name: 'duplicate_headers', + inputSchema: JsonSchema.object( + properties: { + 'primary': JsonSchema.string(mcpHeader: 'Region'), + 'secondary': JsonSchema.string(mcpHeader: 'region'), + }, + ), + ), + Tool( + name: 'empty_header', + inputSchema: JsonSchema.object( + properties: { + 'region': JsonSchema.string(mcpHeader: ''), + }, + ), + ), + Tool.fromJson({ + 'name': 'non_string_header', + 'inputSchema': { + 'type': 'object', + 'properties': { + 'region': { + 'type': 'string', + 'x-mcp-header': 1, + }, + }, + }, + }), + Tool.fromJson({ + 'name': 'object_header', + 'inputSchema': { + 'type': 'object', + 'properties': { + 'payload': { + 'type': 'object', + 'x-mcp-header': 'Payload', + }, + }, + }, + }), + ], + ); + + await client.connect(transport); + final result = await client.listTools(); + + expect(result.tools.map((tool) => tool.name), ['valid_headers']); + expect(transport.toolParameterHeaderMappings, { + 'valid_headers': { + 'region': 'Region', + 'limit': 'Limit', + 'dryRun': 'Dry-Run', + 'count': 'Count', + }, + }); + expect( + warnings.where((message) => message.contains('Rejecting tool')), + hasLength(4), + ); + }); + test('validates tool output schema successfully', () async { await client.connect(transport); await client.listTools(); diff --git a/test/client/streamable_https_test.dart b/test/client/streamable_https_test.dart index d07662fe..da40e2e3 100644 --- a/test/client/streamable_https_test.dart +++ b/test/client/streamable_https_test.dart @@ -81,6 +81,12 @@ class DiscoveryOAuthClientProvider implements OAuthAuthorizationCodeProvider { } } +Map _statelessMeta() => buildProtocolRequestMeta( + protocolVersion: draftProtocolVersion2026_07_28, + clientInfo: const Implementation(name: 'TestClient', version: '1.0.0'), + clientCapabilities: const ClientCapabilities(), + ); + void main() { late HttpServer testServer; late int serverPort; @@ -1054,6 +1060,265 @@ void main() { expect(response.result['echo']['data'], equals('test-data')); }); + test('send adds 2026 stateless HTTP metadata headers', () async { + final capturedHeaders = {}; + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + capturedHeaders['protocolVersion'] = + request.headers.value('mcp-protocol-version'); + capturedHeaders['method'] = request.headers.value('mcp-method'); + capturedHeaders['name'] = request.headers.value('mcp-name'); + await request.drain(); + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write( + jsonEncode( + const JsonRpcResponse( + id: 1, + result: {'content': []}, + ).toJson(), + ), + ); + await request.response.close(); + }); + + transport = StreamableHttpClientTransport( + Uri.parse('http://localhost:${server.port}/mcp'), + )..protocolVersion = draftProtocolVersion2026_07_28; + await transport.start(); + + final completer = Completer(); + transport.onmessage = completer.complete; + + await transport.send( + JsonRpcCallToolRequest( + id: 1, + params: const { + 'name': 'echo', + 'arguments': {'message': 'hello'}, + }, + meta: _statelessMeta(), + ), + ); + await completer.future.timeout(const Duration(seconds: 5)); + + expect( + capturedHeaders['protocolVersion'], + draftProtocolVersion2026_07_28, + ); + expect(capturedHeaders['method'], Method.toolsCall); + expect(capturedHeaders['name'], 'echo'); + }); + + test('send maps 2026 stateless headers for standard request types', + () async { + final capturedHeaders = >[]; + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + capturedHeaders.add({ + 'method': request.headers.value('mcp-method'), + 'name': request.headers.value('mcp-name'), + }); + final body = jsonDecode(await utf8.decodeStream(request)) + as Map; + final id = body['id']; + if (id == null) { + request.response.statusCode = HttpStatus.accepted; + } else { + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write( + jsonEncode( + JsonRpcResponse(id: id, result: const {}).toJson(), + ), + ); + } + await request.response.close(); + }); + + transport = StreamableHttpClientTransport( + Uri.parse('http://localhost:${server.port}/mcp'), + )..protocolVersion = draftProtocolVersion2026_07_28; + await transport.start(); + + final responses = []; + transport.onmessage = responses.add; + + await transport.send( + JsonRpcReadResourceRequest( + id: 1, + readParams: const ReadResourceRequest(uri: 'file:///notes.md'), + meta: _statelessMeta(), + ), + ); + await transport.send( + JsonRpcGetPromptRequest( + id: 2, + getParams: const GetPromptRequest(name: 'summarize'), + meta: _statelessMeta(), + ), + ); + await transport.send( + JsonRpcNotification( + method: Method.notificationsCancelled, + params: const {'requestId': 1}, + meta: _statelessMeta(), + ), + ); + + await Future.delayed(const Duration(milliseconds: 50)); + + expect(responses, hasLength(2)); + expect(capturedHeaders, hasLength(3)); + expect(capturedHeaders[0], { + 'method': Method.resourcesRead, + 'name': 'file:///notes.md', + }); + expect(capturedHeaders[1], { + 'method': Method.promptsGet, + 'name': 'summarize', + }); + expect(capturedHeaders[2], { + 'method': Method.notificationsCancelled, + 'name': null, + }); + }); + + test('send adds task id as 2026 stateless task name header', () async { + final capturedHeaders = {}; + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + capturedHeaders['protocolVersion'] = + request.headers.value('mcp-protocol-version'); + capturedHeaders['method'] = request.headers.value('mcp-method'); + capturedHeaders['name'] = request.headers.value('mcp-name'); + await request.drain(); + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write( + jsonEncode( + const JsonRpcResponse( + id: 1, + result: {'resultType': resultTypeComplete}, + ).toJson(), + ), + ); + await request.response.close(); + }); + + transport = StreamableHttpClientTransport( + Uri.parse('http://localhost:${server.port}/mcp'), + )..protocolVersion = draftProtocolVersion2026_07_28; + await transport.start(); + + final completer = Completer(); + transport.onmessage = completer.complete; + + await transport.send( + JsonRpcUpdateTaskRequest( + id: 1, + updateParams: const UpdateTaskRequest( + taskId: 'task-1', + inputResponses: {}, + ), + meta: _statelessMeta(), + ), + ); + await completer.future.timeout(const Duration(seconds: 5)); + + expect( + capturedHeaders['protocolVersion'], + draftProtocolVersion2026_07_28, + ); + expect(capturedHeaders['method'], Method.tasksUpdate); + expect(capturedHeaders['name'], 'task-1'); + }); + + test('send mirrors mapped tool parameters into 2026 stateless headers', + () async { + final capturedHeaders = {}; + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + capturedHeaders['region'] = request.headers.value('mcp-param-region'); + capturedHeaders['greeting'] = + request.headers.value('mcp-param-greeting'); + capturedHeaders['limit'] = request.headers.value('mcp-param-limit'); + capturedHeaders['dryRun'] = request.headers.value('mcp-param-dry-run'); + capturedHeaders['text'] = request.headers.value('mcp-param-text'); + capturedHeaders['payload'] = request.headers.value('mcp-param-payload'); + await request.drain(); + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write( + jsonEncode( + const JsonRpcResponse( + id: 1, + result: {'content': []}, + ).toJson(), + ), + ); + await request.response.close(); + }); + + transport = StreamableHttpClientTransport( + Uri.parse('http://localhost:${server.port}/mcp'), + ) + ..protocolVersion = draftProtocolVersion2026_07_28 + ..setToolParameterHeaderMappings( + { + 'execute_sql': { + 'region': 'Region', + 'greeting': 'Greeting', + 'limit': 'Limit', + 'dryRun': 'Dry-Run', + 'text': 'Text', + 'payload': 'Payload', + }, + }, + ); + await transport.start(); + + final completer = Completer(); + transport.onmessage = completer.complete; + + await transport.send( + JsonRpcCallToolRequest( + id: 1, + params: const { + 'name': 'execute_sql', + 'arguments': { + 'region': 'us-west1', + 'greeting': 'Hello, 世界', + 'limit': 42, + 'dryRun': false, + 'text': ' padded ', + 'payload': {'nested': true}, + }, + }, + meta: _statelessMeta(), + ), + ); + await completer.future.timeout(const Duration(seconds: 5)); + + expect(capturedHeaders['region'], 'us-west1'); + expect( + capturedHeaders['greeting'], + '=?base64?${base64Encode(utf8.encode('Hello, 世界'))}?=', + ); + expect(capturedHeaders['limit'], '42'); + expect(capturedHeaders['dryRun'], 'false'); + expect(capturedHeaders['text'], '=?base64?IHBhZGRlZCA=?='); + expect(capturedHeaders['payload'], isNull); + }); + test('send with initialized notification triggers SSE establishment', () async { transport = StreamableHttpClientTransport(serverUrl); diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart new file mode 100644 index 00000000..65b8ed42 --- /dev/null +++ b/test/mcp_2026_07_28_test.dart @@ -0,0 +1,1158 @@ +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/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 { + final List sentMessages = []; + bool started = false; + bool closed = false; + + @override + String? get sessionId => null; + + @override + Future close() async { + closed = true; + onclose?.call(); + } + + @override + Future send(JsonRpcMessage message, {int? relatedRequestId}) async { + sentMessages.add(message); + } + + @override + Future start() async { + started = true; + } + + void receive(JsonRpcMessage message) { + onmessage?.call(message); + } +} + +class DiscoveringClientTransport extends Transport + implements ProtocolVersionAwareTransport { + DiscoveringClientTransport({ + this.discoverVersions = const [draftProtocolVersion2026_07_28], + }); + + final List discoverVersions; + final List sentMessages = []; + + @override + String? protocolVersion; + + @override + String? get sessionId => null; + + @override + Future close() async { + onclose?.call(); + } + + @override + Future send(JsonRpcMessage message, {int? relatedRequestId}) async { + sentMessages.add(message); + + if (message is JsonRpcRequest && message.method == Method.serverDiscover) { + onmessage?.call( + JsonRpcResponse( + id: message.id, + result: DiscoverResult( + supportedVersions: discoverVersions, + capabilities: const ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + serverInfo: const Implementation(name: 'server', version: '1.0.0'), + ).toJson(), + ), + ); + return; + } + + if (message is JsonRpcRequest && message.method == Method.toolsList) { + onmessage?.call( + JsonRpcResponse( + id: message.id, + result: const ListToolsResult(tools: []).toJson(), + ), + ); + } + } + + @override + Future start() async {} +} + +class LegacyFallbackTransport extends Transport + implements ProtocolVersionAwareTransport { + final List sentMessages = []; + + @override + String? protocolVersion; + + @override + String? get sessionId => null; + + @override + Future close() async { + onclose?.call(); + } + + @override + Future send(JsonRpcMessage message, {int? relatedRequestId}) async { + sentMessages.add(message); + + 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; + } + + if (message is JsonRpcRequest && message.method == Method.initialize) { + onmessage?.call( + JsonRpcResponse( + id: message.id, + result: const InitializeResult( + protocolVersion: stableProtocolVersion2025_11_25, + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + serverInfo: Implementation(name: 'server', version: '1.0.0'), + ).toJson(), + ), + ); + } + } + + @override + Future start() async {} +} + +class CompletedTaskHandler extends CancelTaskResultHandler { + @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', + ), + ); + + @override + Future getTask(String taskId, RequestHandlerExtra? extra) async => Task( + taskId: taskId, + status: TaskStatus.completed, + ttl: null, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:01:00Z', + ); + + @override + Future cancelTaskWithResult( + String taskId, + RequestHandlerExtra? extra, + ) => + getTask(taskId, extra); + + @override + Future getTaskResult( + String taskId, + RequestHandlerExtra? extra, + ) async => + const CallToolResult( + content: [TextContent(text: 'task complete')], + ); +} + +Map _clientMeta({ + String? protocolVersion, + ClientCapabilities clientCapabilities = const ClientCapabilities(), +}) { + return buildProtocolRequestMeta( + protocolVersion: protocolVersion ?? draftProtocolVersion2026_07_28, + clientInfo: const Implementation(name: 'client', version: '1.0.0'), + clientCapabilities: clientCapabilities, + ); +} + +Future _pump() => Future.delayed(Duration.zero); + +void main() { + group('MCP 2026-07-28 RC protocol foundation', () { + test('defines draft protocol version separately from stable default', () { + expect(latestProtocolVersion, stableProtocolVersion2025_11_25); + expect(latestDraftProtocolVersion, draftProtocolVersion2026_07_28); + expect( + supportedProtocolVersionsWithDraft, + contains(draftProtocolVersion2026_07_28), + ); + expect(isStatelessProtocolVersion(draftProtocolVersion2026_07_28), true); + expect(isStatelessProtocolVersion(latestProtocolVersion), false); + }); + + test('builds stateless request metadata without dropping caller metadata', + () { + final meta = buildProtocolRequestMeta( + protocolVersion: draftProtocolVersion2026_07_28, + clientInfo: const Implementation(name: 'client', version: '1.0.0'), + clientCapabilities: const ClientCapabilities(), + meta: const {'caller': 'value'}, + logLevel: 'debug', + ); + + expect(meta['caller'], 'value'); + expect( + meta[McpMetaKey.protocolVersion], + draftProtocolVersion2026_07_28, + ); + expect(meta[McpMetaKey.clientInfo], { + 'name': 'client', + 'version': '1.0.0', + }); + expect(meta[McpMetaKey.clientCapabilities], {}); + expect(meta[McpMetaKey.logLevel], 'debug'); + }); + + test('serializes server/discover request and result', () { + final request = JsonRpcServerDiscoverRequest( + id: 'discover-1', + meta: _clientMeta(), + ); + + final requestJson = request.toJson(); + expect(requestJson['method'], Method.serverDiscover); + expect( + requestJson['params']['_meta'][McpMetaKey.protocolVersion], + draftProtocolVersion2026_07_28, + ); + expect( + requestJson['params']['_meta'][McpMetaKey.clientCapabilities], + {}, + ); + + final result = const DiscoverResult( + supportedVersions: [draftProtocolVersion2026_07_28], + capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), + serverInfo: Implementation(name: 'server', version: '1.0.0'), + instructions: 'Use the tools.', + ); + final resultJson = result.toJson(); + expect(resultJson['resultType'], 'complete'); + expect(resultJson['supportedVersions'], [draftProtocolVersion2026_07_28]); + expect(resultJson['capabilities'], {'tools': {}}); + expect( + DiscoverResult.fromJson(resultJson).instructions, + 'Use the tools.', + ); + }); + + test('serializes MRTR input required results', () { + final result = InputRequiredResult( + inputRequests: { + 'github_login': InputRequest.elicit( + ElicitRequest.form( + message: 'Please provide your GitHub username', + requestedSchema: JsonSchema.object( + properties: {'name': JsonSchema.string()}, + required: ['name'], + ), + ), + ), + 'capital_of_france': InputRequest.createMessage( + const CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent( + text: 'What is the capital of France?', + ), + ), + ], + maxTokens: 100, + ), + ), + 'roots': InputRequest.listRoots(), + }, + requestState: 'AEAD-protected blob', + meta: const {'trace': 'abc'}, + ); + + final json = result.toJson(); + expect(json['resultType'], resultTypeInputRequired); + expect(json['requestState'], 'AEAD-protected blob'); + expect(json['_meta'], {'trace': 'abc'}); + expect( + json['inputRequests']['github_login']['method'], + Method.elicitationCreate, + ); + expect( + json['inputRequests']['capital_of_france']['method'], + Method.samplingCreateMessage, + ); + expect(json['inputRequests']['roots'], {'method': Method.rootsList}); + + final parsed = InputRequiredResult.fromJson(json); + expect(parsed.requestState, 'AEAD-protected blob'); + expect( + parsed.inputRequests!['github_login']!.elicitParams.message, + 'Please provide your GitHub username', + ); + expect( + parsed + .inputRequests!['capital_of_france']!.createMessageParams.maxTokens, + 100, + ); + }); + + test('serializes MRTR retry fields on supported client requests', () { + final inputResponses = { + 'github_login': InputResponse.fromResult( + const ElicitResult( + action: 'accept', + content: {'name': 'octocat'}, + ), + ), + 'roots': InputResponse.fromResult( + ListRootsResult(roots: [Root(uri: 'file:///repo')]), + ), + }; + + final toolRequest = CallToolRequest( + name: 'deploy', + arguments: const {'service': 'api'}, + inputResponses: inputResponses, + requestState: 'opaque-state', + ); + final toolJson = toolRequest.toJson(); + expect(toolJson['inputResponses']['github_login']['action'], 'accept'); + expect(toolJson['requestState'], 'opaque-state'); + + final parsedToolRequest = CallToolRequest.fromJson(toolJson); + expect(parsedToolRequest.requestState, 'opaque-state'); + expect( + parsedToolRequest.inputResponses!['roots']!.toJson()['roots'][0]['uri'], + 'file:///repo', + ); + + final promptJson = GetPromptRequest( + name: 'summary', + inputResponses: inputResponses, + requestState: 'prompt-state', + ).toJson(); + expect(promptJson['inputResponses']['github_login']['content'], { + 'name': 'octocat', + }); + expect( + GetPromptRequest.fromJson(promptJson).requestState, + 'prompt-state', + ); + + final resourceJson = ReadResourceRequest( + uri: 'file:///repo/README.md', + inputResponses: inputResponses, + requestState: 'resource-state', + ).toJson(); + expect( + resourceJson['inputResponses']['roots']['roots'][0]['uri'], + 'file:///repo', + ); + expect( + ReadResourceRequest.fromJson(resourceJson).requestState, + 'resource-state', + ); + }); + + test('rejects malformed MRTR wire shapes', () { + expect( + () => InputRequiredResult.fromJson( + const {'resultType': resultTypeInputRequired}, + ), + throwsFormatException, + ); + expect( + () => InputRequiredResult.fromJson( + const { + 'resultType': resultTypeInputRequired, + 'requestState': 1, + }, + ), + throwsFormatException, + ); + expect( + () => InputRequiredResult.fromJson( + const { + 'resultType': resultTypeInputRequired, + 'requestState': 'state', + '_meta': false, + }, + ), + throwsFormatException, + ); + expect( + () => InputRequiredResult.fromJson( + const { + 'resultType': resultTypeInputRequired, + 'inputRequests': { + 'unsupported': {'method': Method.toolsCall}, + }, + }, + ), + throwsFormatException, + ); + expect( + () => CallToolRequest.fromJson( + const {'name': 'deploy', 'requestState': 1}, + ), + throwsFormatException, + ); + expect( + () => ReadResourceRequest.fromJson( + const { + 'uri': 'file:///repo/README.md', + 'inputResponses': {'roots': []}, + }, + ), + throwsFormatException, + ); + }); + + test('server acknowledges subscriptions/listen with subscription id', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(listChanged: true), + resources: ServerCapabilitiesResources(), + ), + ), + ); + server.setRequestHandler( + Method.subscriptionsListen, + (request, extra) async { + await extra.sendSubscriptionAcknowledged( + request.listenParams.notifications.acknowledgedBy( + const ServerCapabilities( + tools: ServerCapabilitiesTools(listChanged: true), + resources: ServerCapabilitiesResources(), + ), + ), + ); + return const EmptyResult(); + }, + (id, params, meta) => JsonRpcSubscriptionsListenRequest( + id: id, + listenParams: SubscriptionsListenRequest.fromJson(params!), + meta: meta, + ), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcSubscriptionsListenRequest( + id: 'sub-1', + listenParams: const SubscriptionsListenRequest( + notifications: SubscriptionFilter( + toolsListChanged: true, + promptsListChanged: true, + resourceSubscriptions: ['file:///project/config.json'], + ), + ), + meta: _clientMeta(), + ), + ); + await _pump(); + + final acknowledged = JsonRpcMessage.fromJson( + transport.sentMessages.first.toJson(), + ) as JsonRpcSubscriptionsAcknowledgedNotification; + expect( + acknowledged.method, + Method.notificationsSubscriptionsAcknowledged, + ); + expect(acknowledged.meta?[McpMetaKey.subscriptionId], 'sub-1'); + expect( + acknowledged.acknowledgedParams.notifications.toJson(), + { + 'toolsListChanged': true, + 'resourceSubscriptions': ['file:///project/config.json'], + }, + ); + expect(transport.sentMessages.last, isA()); + }); + + test('server rejects task subscriptions without task extension capability', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ); + server.setRequestHandler( + Method.subscriptionsListen, + (request, extra) async => const EmptyResult(), + (id, params, meta) => JsonRpcSubscriptionsListenRequest( + id: id, + listenParams: SubscriptionsListenRequest.fromJson(params!), + meta: meta, + ), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcSubscriptionsListenRequest( + id: 'sub-task', + listenParams: const SubscriptionsListenRequest( + notifications: SubscriptionFilter(taskIds: ['task-1']), + ), + meta: _clientMeta(), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect( + response.error.code, + 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, + ); + }); + + test('server rejects removed legacy task methods in stateless protocol', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tasks: ServerCapabilitiesTasks(list: true), + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ); + final transport = RecordingTransport(); + await server.connect(transport); + final taskExtensionMeta = _clientMeta( + clientCapabilities: const ClientCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + ); + + transport + ..receive( + JsonRpcListTasksRequest(id: 'list-tasks', meta: taskExtensionMeta), + ) + ..receive( + JsonRpcTaskResultRequest( + id: 'task-result', + resultParams: const TaskResultRequest(taskId: 'task-1'), + meta: taskExtensionMeta, + ), + ); + await _pump(); + + final errors = transport.sentMessages.cast(); + expect( + errors.map((response) => response.error.code), + everyElement(ErrorCode.methodNotFound.value), + ); + expect(errors.first.error.message, contains('MCP Tasks extension')); + }); + + test('server/discover omits legacy task capabilities', () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tasks: ServerCapabilitiesTasks( + list: true, + requests: ServerCapabilitiesTasksRequests( + tools: ServerCapabilitiesTasksTools( + call: ServerCapabilitiesTasksToolsCall(), + ), + ), + ), + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcServerDiscoverRequest(id: 'discover-1', meta: _clientMeta()), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + final capabilities = response.result['capabilities'] as Map; + expect(capabilities, isNot(contains('tasks'))); + expect( + (capabilities['extensions'] as Map)[mcpTasksExtensionId], + isEmpty, + ); + }); + + test('stateless tools/call ignores legacy task parameter', () async { + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + server.registerTool( + 'echo', + callback: (args, extra) => const CallToolResult( + content: [TextContent(text: 'ok')], + ), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcCallToolRequest( + id: 'call-1', + params: { + ...const CallToolRequest(name: 'echo').toJson(), + 'task': {'ttl': 1000}, + }, + meta: _clientMeta(), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + expect(response.result['content'][0]['text'], 'ok'); + }); + + test('stateless tools/call permits extension task creation results', + () 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({ + 'id': id, + '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 JsonRpcResponse; + expect(response.result['resultType'], resultTypeTask); + expect(response.result['taskId'], 'task-1'); + }); + + test( + 'stateless tools/call rejects task extension result without capability', + () 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({ + 'id': id, + '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(), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect( + response.error.code, + ErrorCode.missingRequiredClientCapability.value, + ); + }); + + test('stateless tools/call permits input required results', () 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 InputRequiredResult(requestState: 'retry-state'), + (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'id': id, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcCallToolRequest( + id: 'call-1', + params: const CallToolRequest(name: 'needs-input').toJson(), + meta: _clientMeta(), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + expect(response.result['resultType'], resultTypeInputRequired); + expect(response.result['requestState'], 'retry-state'); + }); + + test('stateless required legacy task tool resolves to final result', + () async { + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + server.experimental.registerToolTask( + 'long', + handler: CompletedTaskHandler(), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcCallToolRequest( + id: 'call-1', + params: const CallToolRequest(name: 'long').toJson(), + meta: _clientMeta(), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + expect(response.result['content'][0]['text'], 'task complete'); + }); + + test('stateless tools/list omits legacy task execution metadata', () async { + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + server.registerTool( + 'echo', + callback: (args, extra) => const CallToolResult( + content: [TextContent(text: 'ok')], + ), + ); + 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'))); + }); + + test('tasks/update handler requires task extension capability', () { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tasks: ServerCapabilitiesTasks(), + ), + ), + ); + + expect( + () => server.setRequestHandler( + Method.tasksUpdate, + (request, extra) async => const TaskExtensionAcknowledgementResult(), + (id, params, meta) => JsonRpcUpdateTaskRequest.fromJson({ + 'id': id, + 'params': params, + if (meta != null) '_meta': meta, + }), + ), + 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(), + ), + ), + ); + 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())); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + final tools = response.result['tools'] as List; + expect(tools.single['name'], 'echo'); + }); + + test('server returns unsupported protocol version for stateless metadata', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcListToolsRequest( + id: 1, + meta: _clientMeta(protocolVersion: '1900-01-01'), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect(response.error.code, ErrorCode.unsupportedProtocolVersion.value); + expect(response.error.data['requested'], '1900-01-01'); + expect( + response.error.data['supported'], + contains(draftProtocolVersion2026_07_28), + ); + }); + + test('server rejects malformed stateless request metadata', () { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + ); + + McpError? validateToolRequest(Map? meta) { + return server.validateIncomingRequest( + JsonRpcListToolsRequest(id: 1, meta: meta), + ); + } + + McpError? validateDiscoverRequest(Map? meta) { + return server.validateIncomingRequest( + JsonRpcServerDiscoverRequest(id: 1, meta: meta), + ); + } + + expect( + validateDiscoverRequest(const {}), + isA().having( + (error) => error.message, + 'message', + contains(McpMetaKey.protocolVersion), + ), + ); + expect( + validateDiscoverRequest( + _clientMeta(protocolVersion: stableProtocolVersion2025_11_25), + ), + isA().having( + (error) => error.message, + 'message', + contains('stateless protocol version'), + ), + ); + expect( + validateToolRequest({ + McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, + McpMetaKey.clientCapabilities: {}, + }), + isA().having( + (error) => error.message, + 'message', + contains(McpMetaKey.clientInfo), + ), + ); + expect( + validateToolRequest({ + McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, + McpMetaKey.clientInfo: { + 'name': 'client', + 'version': '1.0.0', + }, + }), + isA().having( + (error) => error.message, + 'message', + contains(McpMetaKey.clientCapabilities), + ), + ); + expect( + validateToolRequest({ + McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, + McpMetaKey.clientInfo: {'name': 1}, + McpMetaKey.clientCapabilities: {}, + }), + isA().having( + (error) => error.message, + 'message', + contains('Invalid stateless request metadata.'), + ), + ); + }); + + test('client can opt in 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); + + expect(client.getProtocolVersion(), draftProtocolVersion2026_07_28); + expect(transport.protocolVersion, draftProtocolVersion2026_07_28); + expect( + (transport.sentMessages.single as JsonRpcRequest).method, + Method.serverDiscover, + ); + + await client.listTools(); + + final listRequest = transport.sentMessages.last as JsonRpcRequest; + expect(listRequest.method, Method.toolsList); + expect( + listRequest.meta?[McpMetaKey.protocolVersion], + draftProtocolVersion2026_07_28, + ); + expect(listRequest.meta?[McpMetaKey.clientInfo], { + 'name': 'client', + 'version': '1.0.0', + }); + expect(listRequest.meta?[McpMetaKey.clientCapabilities], {}); + }); + + test('client rejects discovery when no compatible version is offered', + () async { + final transport = DiscoveringClientTransport( + discoverVersions: const ['1900-01-01'], + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + + await expectLater( + client.connect(transport), + throwsA( + isA().having( + (error) => error.code, + 'code', + ErrorCode.unsupportedProtocolVersion.value, + ), + ), + ); + }); + + test('client falls back to initialize when discovery is unavailable', + () async { + final transport = LegacyFallbackTransport(); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + + await client.connect(transport); + + expect(client.getProtocolVersion(), stableProtocolVersion2025_11_25); + expect(transport.protocolVersion, stableProtocolVersion2025_11_25); + expect( + transport.sentMessages + .whereType() + .map((message) => message.method), + containsAllInOrder([Method.serverDiscover, Method.initialize]), + ); + expect( + transport.sentMessages.whereType(), + isEmpty, + ); + expect( + transport.sentMessages.whereType().last.method, + Method.notificationsInitialized, + ); + }); + }); +} diff --git a/test/server/server_test.dart b/test/server/server_test.dart index 89dccaa3..7b5e6dda 100644 --- a/test/server/server_test.dart +++ b/test/server/server_test.dart @@ -964,7 +964,8 @@ void _addCriticalPathTests() { ); }); - test('initialize, ping, completion/complete always allowed', () { + test('initialize, ping, completion/complete, subscriptions/listen allowed', + () { server = Server( const Implementation(name: 'TestServer', version: '1.0.0'), // No special capabilities @@ -982,6 +983,10 @@ void _addCriticalPathTests() { () => server.assertRequestHandlerCapability('completion/complete'), returnsNormally, ); + expect( + () => server.assertRequestHandlerCapability(Method.subscriptionsListen), + returnsNormally, + ); }); test('custom request handler logs info but does not throw', () { diff --git a/test/server/streamable_https_test.dart b/test/server/streamable_https_test.dart index 343d3ab1..9d9b5650 100644 --- a/test/server/streamable_https_test.dart +++ b/test/server/streamable_https_test.dart @@ -148,6 +148,12 @@ List> _decodeSseJsonMessages(String body) { return messages; } +Map _statelessMeta() => buildProtocolRequestMeta( + protocolVersion: draftProtocolVersion2026_07_28, + clientInfo: const Implementation(name: 'TestClient', version: '1.0.0'), + clientCapabilities: const ClientCapabilities(), + ); + class _SseEvent { final String? id; final String? event; @@ -1785,6 +1791,307 @@ void main() { await transport.close(); }); + test('2026 stateless HTTP validates required protocol header', () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => "unused-session-id", + enableJsonResponse: true, + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + + final response = await HttpClient() + .postUrl( + Uri.parse('$serverUrlBase/mcp'), + ) + .then((request) async { + request.headers + ..contentType = ContentType.json + ..set( + HttpHeaders.acceptHeader, + 'application/json, text/event-stream', + ) + ..set('Mcp-Method', Method.toolsList); + request.write( + jsonEncode(JsonRpcListToolsRequest(id: 1, meta: _statelessMeta())), + ); + return request.close(); + }); + + expect(response.statusCode, HttpStatus.badRequest); + final body = + jsonDecode(await utf8.decodeStream(response)) as Map; + expect(body['id'], 1); + expect(body['error']['code'], ErrorCode.headerMismatch.value); + expect( + body['error']['message'], + contains('MCP-Protocol-Version header is required'), + ); + }); + + test('2026 stateless HTTP rejects mismatched method and name headers', + () 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')); + 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( + JsonRpcCallToolRequest( + id: 2, + params: const { + 'name': 'echo', + 'arguments': {'message': 'hello'}, + }, + meta: _statelessMeta(), + ), + ), + ); + + final response = await request.close(); + + expect(response.statusCode, HttpStatus.badRequest); + final body = + jsonDecode(await utf8.decodeStream(response)) as Map; + expect(body['id'], 2); + expect(body['error']['code'], ErrorCode.headerMismatch.value); + expect(body['error']['message'], contains('Mcp-Name header value')); + }); + + test('2026 stateless HTTP requires task id name header for task requests', + () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => "unused-session-id", + enableJsonResponse: true, + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + + transport.onmessage = (message) { + if (message is JsonRpcUpdateTaskRequest) { + unawaited( + transport.send( + JsonRpcResponse( + id: message.id, + result: const TaskExtensionAcknowledgementResult().toJson(), + ), + ), + ); + } + }; + + 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); + request.write( + jsonEncode( + JsonRpcUpdateTaskRequest( + id: 4, + updateParams: const UpdateTaskRequest( + taskId: 'task-1', + inputResponses: {}, + ), + meta: _statelessMeta(), + ), + ), + ); + + final response = await request.close(); + + expect(response.statusCode, HttpStatus.badRequest); + final body = + 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')); + }); + + test('2026 stateless HTTP accepts matching standard and parameter headers', + () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => "unused-session-id", + enableJsonResponse: true, + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + + transport.onmessage = (message) { + if (message is JsonRpcCallToolRequest) { + unawaited( + transport.send( + JsonRpcResponse( + id: message.id, + result: const CallToolResult(content: []).toJson(), + ), + ), + ); + } + }; + + 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.toolsCall) + ..set('Mcp-Name', 'execute') + ..set('Mcp-Param-region', 'us-east1'); + request.write( + jsonEncode( + JsonRpcCallToolRequest( + id: 3, + params: const { + 'name': 'execute', + 'arguments': {'region': 'us-east1'}, + }, + meta: _statelessMeta(), + ), + ), + ); + + final response = await request.close(); + + expect(response.statusCode, HttpStatus.ok); + expect(response.headers.value('mcp-session-id'), isNull); + final body = + jsonDecode(await utf8.decodeStream(response)) as Map; + expect(body['id'], 3); + expect(body['result']['content'], isEmpty); + }); + + test('2026 stateless HTTP rejects malformed routing headers', () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => "unused-session-id", + enableJsonResponse: true, + rejectBatchJsonRpcPayloads: false, + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + + Future> postJson( + Object body, { + Map headers = const {}, + }) 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', + ); + headers.forEach(request.headers.set); + request.write(jsonEncode(body)); + + final response = await request.close(); + expect(response.statusCode, HttpStatus.badRequest); + return jsonDecode(await utf8.decodeStream(response)) + as Map; + } + + var body = await postJson( + const JsonRpcListToolsRequest(id: 4).toJson(), + headers: { + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.toolsList, + }, + ); + expect( + body['error']['message'], + contains('no matching request _meta protocol version'), + ); + + body = await postJson( + JsonRpcListToolsRequest(id: 5, meta: _statelessMeta()).toJson(), + headers: { + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + }, + ); + expect(body['error']['message'], contains('Mcp-Method header')); + + body = await postJson( + JsonRpcCallToolRequest( + id: 6, + params: const { + 'name': 'execute', + 'arguments': {'count': 2, 'enabled': true}, + }, + meta: _statelessMeta(), + ).toJson(), + headers: { + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.toolsCall, + 'Mcp-Name': 'execute', + 'Mcp-Param-count': '2', + 'Mcp-Param-enabled': 'false', + }, + ); + expect(body['id'], 6); + expect(body['error']['message'], contains('mcp-param-enabled')); + + body = await postJson( + JsonRpcCallToolRequest( + id: 7, + params: const { + 'name': 'execute', + 'arguments': {}, + }, + meta: _statelessMeta(), + ).toJson(), + headers: { + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.toolsCall, + }, + ); + expect(body['id'], 7); + expect(body['error']['message'], contains('Mcp-Name header')); + + body = await postJson( + [ + JsonRpcListToolsRequest(id: 8, meta: _statelessMeta()).toJson(), + JsonRpcListToolsRequest(id: 9, meta: _statelessMeta()).toJson(), + ], + headers: { + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + }, + ); + expect(body['error']['message'], contains('must contain one')); + }); + test('stateless mode allows initialization with session header', () async { final transport = StreamableHTTPServerTransport( options: StreamableHTTPServerTransportOptions( @@ -1845,6 +2152,37 @@ void main() { expect(transport.sessionId, isNull); }); + test('2026 stateless GET requests return method not allowed', () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + + final client = HttpClient(); + addTearDown(() => client.close(force: true)); + final request = await client.getUrl(Uri.parse('$serverUrlBase/mcp')); + request.headers.set( + 'MCP-Protocol-Version', + draftProtocolVersion2026_07_28, + ); + + final response = await request.close(); + final body = + jsonDecode(await utf8.decodeStream(response)) as Map; + + expect(response.statusCode, HttpStatus.methodNotAllowed); + expect(response.headers.value(HttpHeaders.allowHeader), 'POST'); + expect(body['error']['code'], ErrorCode.connectionClosed.value); + expect( + body['error']['message'], + 'Method not allowed for stateless MCP requests.', + ); + }); + test('close cleans up all resources', () async { final transport = StreamableHTTPServerTransport( options: StreamableHTTPServerTransportOptions( diff --git a/test/server/streamable_mcp_server_test.dart b/test/server/streamable_mcp_server_test.dart index f056b01d..96cd1951 100644 --- a/test/server/streamable_mcp_server_test.dart +++ b/test/server/streamable_mcp_server_test.dart @@ -65,6 +65,21 @@ Future<_SseEvent> _readSseJsonEvent(StreamIterator lines) async { } } +List> _decodeSseJsonMessages(String body) { + final messages = >[]; + for (final event in body.trim().split('\n\n')) { + final data = event + .split('\n') + .where((line) => line.startsWith('data: ')) + .map((line) => line.substring('data: '.length)) + .join('\n'); + if (data.isNotEmpty) { + messages.add(jsonDecode(data) as Map); + } + } + return messages; +} + void main() { test('OAuthBearerChallenge builds insufficient-scope challenge', () { final challenge = OAuthBearerChallenge.insufficientScope( @@ -101,6 +116,12 @@ void main() { ); }); + Map statelessMeta() => buildProtocolRequestMeta( + protocolVersion: draftProtocolVersion2026_07_28, + clientInfo: const Implementation(name: 'Client', version: '1.0'), + clientCapabilities: const ClientCapabilities(), + ); + group('StreamableMcpServer', () { late StreamableMcpServer server; final port = 8081; @@ -281,6 +302,90 @@ void main() { expect(res.statusCode, HttpStatus.badRequest); }); + test('handles 2026 stateless request without 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: 1, 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['mcp-session-id'], isNull); + final messages = _decodeSseJsonMessages(response.body); + expect(messages.single['result']['tools'][0]['name'], 'echo'); + }); + + test('detects 2026 stateless requests from body metadata', () async { + final response = await http.post( + Uri.parse(baseUrl), + body: jsonEncode( + JsonRpcListToolsRequest(id: 10, meta: statelessMeta()).toJson(), + ), + 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 GET and DELETE without a session ID', () async { + final client = HttpClient(); + addTearDown(() => client.close(force: true)); + + final getRequest = await client.getUrl(Uri.parse(baseUrl)); + getRequest.headers.set( + 'MCP-Protocol-Version', + draftProtocolVersion2026_07_28, + ); + final getResponse = await getRequest.close(); + expect(getResponse.statusCode, HttpStatus.methodNotAllowed); + expect(getResponse.headers.value(HttpHeaders.allowHeader), 'POST'); + await getResponse.drain(); + + final deleteRequest = await client.deleteUrl(Uri.parse(baseUrl)); + deleteRequest.headers.set( + 'MCP-Protocol-Version', + draftProtocolVersion2026_07_28, + ); + final deleteResponse = await deleteRequest.close(); + expect(deleteResponse.statusCode, HttpStatus.methodNotAllowed); + expect(deleteResponse.headers.value(HttpHeaders.allowHeader), 'POST'); + await deleteResponse.drain(); + }); + test('rejects unsupported MCP-Protocol-Version header by default', () async { final initRequest = JsonRpcRequest( @@ -305,7 +410,7 @@ void main() { expect(res.statusCode, HttpStatus.badRequest); final body = jsonDecode(res.body) as Map; - expect(body['error']['code'], ErrorCode.invalidRequest.value); + expect(body['error']['code'], ErrorCode.unsupportedProtocolVersion.value); }); test( diff --git a/test/tool_schema_test.dart b/test/tool_schema_test.dart index 75ec54ce..42499cc2 100644 --- a/test/tool_schema_test.dart +++ b/test/tool_schema_test.dart @@ -2,6 +2,47 @@ import 'package:mcp_dart/mcp_dart.dart'; import 'package:test/test.dart'; void main() { + group('Tool parameter header annotations', () { + test('primitive schemas preserve x-mcp-header round-trip', () { + final schema = JsonSchema.object( + properties: { + 'region': JsonSchema.string(mcpHeader: 'Region'), + 'limit': JsonSchema.number(mcpHeader: 'Limit'), + 'count': JsonSchema.integer(mcpHeader: 'Count'), + 'dryRun': JsonSchema.boolean(mcpHeader: 'Dry-Run'), + }, + ); + + final json = schema.toJson(); + final properties = json['properties'] as Map; + expect(properties['region']['x-mcp-header'], 'Region'); + expect(properties['limit']['x-mcp-header'], 'Limit'); + expect(properties['count']['x-mcp-header'], 'Count'); + expect(properties['dryRun']['x-mcp-header'], 'Dry-Run'); + + final parsed = JsonSchema.fromJson(json) as JsonObject; + final parsedProperties = parsed.properties!; + expect((parsedProperties['region'] as JsonString).mcpHeader, 'Region'); + expect((parsedProperties['limit'] as JsonNumber).mcpHeader, 'Limit'); + expect((parsedProperties['count'] as JsonInteger).mcpHeader, 'Count'); + expect( + (parsedProperties['dryRun'] as JsonBoolean).mcpHeader, + 'Dry-Run', + ); + expect(parsed.toJson(), json); + }); + + test('non-primitive x-mcp-header annotations remain visible', () { + final schema = JsonSchema.fromJson({ + 'type': 'object', + 'x-mcp-header': 'Payload', + }); + + expect(schema, isA()); + expect(schema.toJson()['x-mcp-header'], 'Payload'); + }); + }); + group('Tool Schema Required Fields Tests', () { test('ToolInputSchema preserves required fields during serialization', () { final schema = JsonObject( diff --git a/test/types/subscriptions_test.dart b/test/types/subscriptions_test.dart new file mode 100644 index 00000000..6a10ed35 --- /dev/null +++ b/test/types/subscriptions_test.dart @@ -0,0 +1,178 @@ +import 'package:mcp_dart/src/types.dart'; +import 'package:test/test.dart'; + +void main() { + group('SubscriptionFilter', () { + test('serializes and parses requested notification filters', () { + const filter = SubscriptionFilter( + toolsListChanged: true, + promptsListChanged: false, + resourceSubscriptions: ['file:///project/config.json'], + taskIds: ['task-1'], + ); + + final json = filter.toJson(); + expect(json['toolsListChanged'], isTrue); + expect(json['promptsListChanged'], isFalse); + expect(json['resourceSubscriptions'], ['file:///project/config.json']); + expect(json['taskIds'], ['task-1']); + expect(json.containsKey('resourcesListChanged'), isFalse); + + final parsed = SubscriptionFilter.fromJson(json); + expect(parsed.toolsListChanged, isTrue); + expect(parsed.promptsListChanged, isFalse); + expect(parsed.resourceSubscriptions, ['file:///project/config.json']); + expect(parsed.taskIds, ['task-1']); + }); + + test('acknowledgedBy returns only supported requested filters', () { + const requested = SubscriptionFilter( + toolsListChanged: true, + promptsListChanged: true, + resourcesListChanged: true, + resourceSubscriptions: ['file:///project/config.json'], + taskIds: ['task-1'], + ); + const capabilities = ServerCapabilities( + extensions: {mcpTasksExtensionId: {}}, + tools: ServerCapabilitiesTools(listChanged: true), + resources: ServerCapabilitiesResources(), + ); + + final acknowledged = requested.acknowledgedBy(capabilities); + expect(acknowledged.toJson(), { + 'toolsListChanged': true, + 'resourceSubscriptions': ['file:///project/config.json'], + 'taskIds': ['task-1'], + }); + }); + + test('acknowledgedBy omits task filters without task extension support', + () { + const requested = SubscriptionFilter(taskIds: ['task-1']); + + final acknowledged = requested.acknowledgedBy(const ServerCapabilities()); + expect(acknowledged.toJson(), isEmpty); + }); + + test('rejects malformed filters', () { + expect( + () => SubscriptionFilter.fromJson( + const {'toolsListChanged': 'yes'}, + ), + throwsFormatException, + ); + expect( + () => SubscriptionFilter.fromJson( + const { + 'resourceSubscriptions': [1], + }, + ), + throwsFormatException, + ); + expect( + () => SubscriptionFilter.fromJson( + const { + 'taskIds': [1], + }, + ), + throwsFormatException, + ); + }); + }); + + group('JsonRpcSubscriptionsListenRequest', () { + test('serializes and parses subscriptions/listen requests', () { + final request = JsonRpcSubscriptionsListenRequest( + id: 'sub-1', + listenParams: const SubscriptionsListenRequest( + notifications: SubscriptionFilter( + toolsListChanged: true, + resourceSubscriptions: ['file:///project/config.json'], + ), + ), + meta: const { + McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, + }, + ); + + final json = request.toJson(); + expect(json['method'], Method.subscriptionsListen); + expect(json['params']['notifications']['toolsListChanged'], isTrue); + expect( + json['params']['_meta'][McpMetaKey.protocolVersion], + draftProtocolVersion2026_07_28, + ); + + final parsed = JsonRpcMessage.fromJson(json); + expect(parsed, isA()); + final listen = parsed as JsonRpcSubscriptionsListenRequest; + expect(listen.id, 'sub-1'); + expect(listen.listenParams.notifications.toolsListChanged, isTrue); + expect( + listen.meta?[McpMetaKey.protocolVersion], + draftProtocolVersion2026_07_28, + ); + }); + + test('rejects missing notifications', () { + expect( + () => JsonRpcSubscriptionsListenRequest.fromJson( + const { + 'id': 1, + 'method': Method.subscriptionsListen, + 'params': {}, + }, + ), + throwsFormatException, + ); + }); + }); + + group('JsonRpcSubscriptionsAcknowledgedNotification', () { + test('serializes and parses subscription acknowledgments', () { + final notification = JsonRpcSubscriptionsAcknowledgedNotification( + acknowledgedParams: const SubscriptionsAcknowledgedNotification( + notifications: SubscriptionFilter(toolsListChanged: true), + ), + meta: const {McpMetaKey.subscriptionId: 'sub-1'}, + ); + + final json = notification.toJson(); + expect(json['method'], Method.notificationsSubscriptionsAcknowledged); + expect(json['params']['notifications']['toolsListChanged'], isTrue); + expect(json['params']['_meta'][McpMetaKey.subscriptionId], 'sub-1'); + + final parsed = JsonRpcMessage.fromJson(json); + expect(parsed, isA()); + final acknowledged = + parsed as JsonRpcSubscriptionsAcknowledgedNotification; + expect( + acknowledged.acknowledgedParams.notifications.toolsListChanged, + isTrue, + ); + expect(acknowledged.meta?[McpMetaKey.subscriptionId], 'sub-1'); + }); + + test('rejects malformed acknowledgments', () { + expect( + () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson( + const {'method': Method.notificationsSubscriptionsAcknowledged}, + ), + throwsFormatException, + ); + expect( + () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson( + const { + 'method': Method.notificationsSubscriptionsAcknowledged, + 'params': { + 'notifications': {'toolsListChanged': true}, + '_meta': false, + }, + }, + ), + throwsFormatException, + ); + }); + }); +} diff --git a/test/types/tasks_extension_test.dart b/test/types/tasks_extension_test.dart new file mode 100644 index 00000000..bc1c466f --- /dev/null +++ b/test/types/tasks_extension_test.dart @@ -0,0 +1,182 @@ +import 'package:mcp_dart/src/types.dart'; +import 'package:test/test.dart'; + +void main() { + group('MCP Tasks extension capabilities', () { + test('declares task extension support', () { + final extensions = withMcpTasksExtension({ + 'example/extension': {'enabled': true}, + }); + + expect(extensions[mcpTasksExtensionId], {}); + expect(extensions['example/extension'], {'enabled': true}); + expect( + ClientCapabilities(extensions: extensions).supportsTasksExtension, + isTrue, + ); + expect( + ServerCapabilities(extensions: extensions).supportsTasksExtension, + isTrue, + ); + }); + }); + + group('Task extension wire types', () { + test('serializes create task results with flat resultType task shape', () { + const result = CreateTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:00Z', + ttlMs: 60000, + pollIntervalMs: 5000, + ), + meta: {'trace': 'abc'}, + ); + + final json = result.toJson(); + expect(json['resultType'], resultTypeTask); + expect(json['taskId'], 'task-1'); + expect(json['ttlMs'], 60000); + expect(json['pollIntervalMs'], 5000); + expect(json['_meta'], {'trace': 'abc'}); + + final parsed = CreateTaskExtensionResult.fromJson(json); + expect(parsed.task.status, TaskStatus.working); + expect(parsed.task.ttlMs, 60000); + }); + + test('serializes tasks/get input required results', () { + final result = GetTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.inputRequired, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:01:00Z', + ttlMs: null, + inputRequests: { + 'approval': InputRequest.elicit( + ElicitRequest.form( + message: 'Approve deployment?', + requestedSchema: JsonSchema.object( + properties: {'approved': JsonSchema.boolean()}, + required: ['approved'], + ), + ), + ), + }, + ), + ); + + final json = result.toJson(); + expect(json['resultType'], resultTypeComplete); + expect(json['status'], 'input_required'); + expect(json['ttlMs'], isNull); + expect( + json['inputRequests']['approval']['method'], + Method.elicitationCreate, + ); + + final parsed = GetTaskExtensionResult.fromJson(json); + expect( + parsed.task.inputRequests!['approval']!.elicitParams.message, + 'Approve deployment?', + ); + }); + + test('serializes tasks/update requests with input responses', () { + final request = JsonRpcUpdateTaskRequest( + id: 7, + updateParams: UpdateTaskRequest( + taskId: 'task-1', + inputResponses: { + 'approval': InputResponse.fromResult( + const ElicitResult( + action: 'accept', + content: {'approved': true}, + ), + ), + }, + ), + ); + + final json = request.toJson(); + expect(json['method'], Method.tasksUpdate); + expect(json['params']['taskId'], 'task-1'); + expect(json['params']['inputResponses']['approval']['action'], 'accept'); + + final parsed = JsonRpcMessage.fromJson(json) as JsonRpcUpdateTaskRequest; + expect(parsed.updateParams.taskId, 'task-1'); + expect( + parsed.updateParams.inputResponses['approval']!.toJson()['content'], + {'approved': true}, + ); + }); + + test('serializes task update and cancel acknowledgements', () { + const result = TaskExtensionAcknowledgementResult( + meta: {'trace': 'abc'}, + ); + + final json = result.toJson(); + expect(json['resultType'], resultTypeComplete); + expect(json['_meta'], {'trace': 'abc'}); + + final parsed = TaskExtensionAcknowledgementResult.fromJson(json); + expect(parsed.meta, {'trace': 'abc'}); + }); + + test('serializes notifications/tasks with detailed task state', () { + final notification = JsonRpcTaskNotification( + task: const TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.completed, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:02:00Z', + ttlMs: 60000, + result: { + 'content': [ + {'type': 'text', 'text': 'done'}, + ], + }, + ), + meta: const {McpMetaKey.subscriptionId: 'sub-1'}, + ); + + final json = notification.toJson(); + expect(json['method'], Method.notificationsTasks); + expect(json['params']['resultType'], isNull); + expect(json['params']['result']['content'][0]['text'], 'done'); + expect(json['params']['_meta'][McpMetaKey.subscriptionId], 'sub-1'); + + final parsed = JsonRpcMessage.fromJson(json) as JsonRpcTaskNotification; + expect(parsed.task.status, TaskStatus.completed); + expect(parsed.task.result!['content'][0]['text'], 'done'); + }); + + test('rejects malformed task extension payloads', () { + expect( + () => CreateTaskExtensionResult.fromJson( + const { + 'resultType': resultTypeComplete, + 'taskId': 'task-1', + }, + ), + throwsFormatException, + ); + expect( + () => UpdateTaskRequest.fromJson( + const {'taskId': 'task-1'}, + ), + throwsFormatException, + ); + expect( + () => TaskExtensionAcknowledgementResult.fromJson( + const {'resultType': resultTypeInputRequired}, + ), + throwsFormatException, + ); + }); + }); +}