From 7348727c098347882cbef6683c3c31de297adf3b Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 08:20:35 -0400 Subject: [PATCH 01/68] 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, + ); + }); + }); +} From f3542a8ec2722dd03f094e1f75096e904bfa5e07 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 08:20:43 -0400 Subject: [PATCH 02/68] Consolidate MCP 2026 transport enforcement --- CHANGELOG.md | 58 + lib/src/client/client.dart | 448 +++++- lib/src/client/streamable_https.dart | 135 +- lib/src/server/mcp_server.dart | 275 +++- lib/src/server/server.dart | 229 ++- lib/src/server/streamable_https.dart | 803 +++++++--- lib/src/server/streamable_mcp_server.dart | 2 + lib/src/shared/mcp_header_validation.dart | 31 + lib/src/shared/protocol.dart | 264 +++- lib/src/shared/transport.dart | 17 +- lib/src/types/completion.dart | 4 +- lib/src/types/initialization.dart | 4 +- lib/src/types/json_rpc.dart | 17 + lib/src/types/prompts.dart | 43 +- lib/src/types/resources.dart | 109 +- lib/src/types/roots.dart | 4 +- lib/src/types/subscriptions.dart | 55 + lib/src/types/tools.dart | 37 +- lib/src/types/validation.dart | 45 + test/client/client_tool_validation_test.dart | 26 +- test/client/streamable_https_test.dart | 185 +++ test/mcp_2026_07_28_test.dart | 1464 +++++++++++++++++- test/server/mcp_server_test.dart | 292 +++- test/server/streamable_https_test.dart | 865 ++++++++++- test/server/streamable_mcp_server_test.dart | 13 +- test/shared/mcp_header_validation_test.dart | 50 + test/types/subscriptions_test.dart | 222 +++ 27 files changed, 5331 insertions(+), 366 deletions(-) create mode 100644 lib/src/shared/mcp_header_validation.dart create mode 100644 test/shared/mcp_header_validation_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index a8283e69..8dee8ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,64 @@ - Added opt-in client discovery via `McpClientOptions(useServerDiscover: true)` while keeping the stable `initialize` flow as the default until the 2026 stateless transport and MRTR implementation is complete. +- Added 2026 cacheable result support for `tools/list`, `prompts/list`, + `resources/list`, `resources/templates/list`, and `resources/read`, including + stateless server defaults for `resultType`, `ttlMs`, and `cacheScope` while + keeping legacy result serialization unchanged unless cache hints are set. +- Rejected core RPCs removed from stateless MCP 2026 requests + (`initialize`, `ping`, `logging/setLevel`, `resources/subscribe`, + `resources/unsubscribe`, `notifications/initialized`, and + `notifications/roots/list_changed`) while preserving legacy session behavior. +- Synced registered tool `x-mcp-header` metadata into Streamable HTTP server + transports so 2026 stateless `tools/call` requests reject missing or + mismatched `Mcp-Param-*` argument headers. +- Rejected `x-mcp-header` usage on JSON Schema `number` parameters, keeping + mirrored tool headers limited to string, JavaScript-safe integer, and boolean + parameters. +- Removed `Mcp-Session-Id` from 2026 stateless Streamable HTTP requests by + stripping it from client sends and ignoring it on stateless server POSTs. +- Enforced 2026 stateless Streamable HTTP POST-only behavior by skipping + legacy client GET/DELETE session paths and returning `Allow: POST` for + stateless non-POST server requests. +- Rejected server-initiated JSON-RPC requests on 2026 stateless Streamable HTTP + response streams so client input is routed through MRTR input-required + results instead. +- Escaped sentinel-shaped `Mcp-Param-*` values with Base64 encoding so literal + values beginning with `=?base64?` and ending with `?=` round-trip correctly. +- Synced nested 2026 `x-mcp-header` mappings into Streamable HTTP transports + using JSON Pointer selectors for nested tool arguments. +- Returned HTTP 404 with JSON-RPC `Method not found` for unsupported or removed + 2026 stateless Streamable HTTP request methods before opening response + streams. +- Treated client closure of a 2026 stateless Streamable HTTP SSE response stream + as cancellation of that pending request. +- Sorted 2026 stateless high-level `tools/list` responses by tool name for + deterministic list results while preserving legacy registration-order output. +- Gated 2026 stateless task extension methods on advertised server extension + support and rejected legacy task result shapes on extension `tasks/get`, + `tasks/update`, and `tasks/cancel` handlers. +- Added request-scoped stateless logging gating via + `io.modelcontextprotocol/logLevel` metadata so 2026 log notifications are + emitted only when the current request opts in. +- Rejected unrecognized 2026 stateless response `resultType` values on the + client while keeping absent `resultType` compatible with stable result parsing. +- Added `X-Accel-Buffering: no` to Streamable HTTP SSE responses and marked + JSON-RPC error bodies with `Content-Type: application/json`. +- Tightened `x-mcp-header` and `Mcp-Param-*` suffix validation to RFC 9110 + HTTP field-name token syntax. +- Removed invalid `x-mcp-header` annotations from 2026 stateless `tools/list` + responses when the server has already rejected those header mappings. +- Rejected server-initiated JSON-RPC requests received on 2026 stateless + Streamable HTTP client response streams; servers must use MRTR + `input_required` results instead. +- Enforced `subscriptions/listen` stream ordering and filters for 2026 + subscription notifications. +- Retried `server/discover` with an advertised compatible stateless protocol + version after `UnsupportedProtocolVersionError` instead of falling back to + legacy initialization. +- Added client-side `subscriptions/listen` handles that correlate stream + notifications by `io.modelcontextprotocol/subscriptionId`, validate the + acknowledgment, and cancel long-lived streams with `notifications/cancelled`. ## 2.2.0 diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 88e18b21..1932338c 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -1,6 +1,8 @@ import 'dart:async'; + import 'package:mcp_dart/src/shared/json_schema/json_schema_validator.dart'; import 'package:mcp_dart/src/shared/logging.dart'; +import 'package:mcp_dart/src/shared/mcp_header_validation.dart'; import 'package:mcp_dart/src/shared/protocol.dart'; import 'package:mcp_dart/src/shared/transport.dart'; import 'package:mcp_dart/src/types.dart'; @@ -38,6 +40,36 @@ class McpClientOptions extends ProtocolOptions { @Deprecated('Use McpClientOptions instead') typedef ClientOptions = McpClientOptions; +/// Handle for an active `subscriptions/listen` stream opened by [McpClient]. +class McpSubscription { + final void Function([Object? reason]) _cancel; + + /// JSON-RPC request ID that identifies this subscription stream. + final int id; + + /// Acknowledgment sent as the first message on the subscription stream. + final Future acknowledged; + + /// Notifications delivered on this subscription stream after acknowledgment. + final Stream notifications; + + /// Completes when the `subscriptions/listen` request ends. + final Future done; + + McpSubscription._({ + required this.id, + required this.acknowledged, + required this.notifications, + required this.done, + required void Function([Object? reason]) cancel, + }) : _cancel = cancel; + + /// Cancels this subscription stream. + void cancel([Object? reason]) { + _cancel(reason); + } +} + // Recursively applies default values from a JSON Schema to a data object. void _applyElicitationDefaults(JsonSchema schema, Map data) { if (schema is! JsonObject) return; @@ -97,6 +129,7 @@ class McpClient extends Protocol { final Map _cachedToolOutputSchemas = {}; final Set _cachedRequiredTaskTools = {}; final ToolParameterHeaderMappings _cachedToolParameterHeaders = {}; + final Map _activeSubscriptions = {}; /// Callback for handling elicitation requests from the server. /// @@ -294,19 +327,56 @@ class McpClient extends Protocol { ); } - Future discoverServer() async { + String? _retryableDiscoveryProtocolVersion( + McpError error, + String attemptedVersion, + ) { + if (error.code != ErrorCode.unsupportedProtocolVersion.value) { + return null; + } + + final data = error.data; + if (data is! Map) { + return null; + } + + final supported = data['supported']; + if (supported is! Iterable) { + return null; + } + + final advertisedVersions = []; + for (final version in supported) { + if (version is String) { + advertisedVersions.add(version); + } + } + + final retryVersion = negotiateProtocolVersion( + advertisedVersions, + localSupportedVersions: statelessProtocolVersions, + ); + if (retryVersion == null || retryVersion == attemptedVersion) { + return null; + } + return retryVersion; + } + + Future _discoverServerWithVersion( + String protocolVersion, + ) async { final activeTransport = transport; final ProtocolVersionAwareTransport? versionedTransport = activeTransport is ProtocolVersionAwareTransport ? activeTransport as ProtocolVersionAwareTransport : null; - versionedTransport?.protocolVersion = _preferredProtocolVersion; + versionedTransport?.protocolVersion = protocolVersion; final result = await super.request( JsonRpcServerDiscoverRequest( id: -1, meta: buildProtocolRequestMeta( - protocolVersion: _preferredProtocolVersion, + protocolVersion: protocolVersion, clientInfo: _clientInfo, clientCapabilities: _capabilities, ), @@ -314,17 +384,17 @@ class McpClient extends Protocol { (json) => DiscoverResult.fromJson(json), ); - final protocolVersion = negotiateProtocolVersion( + final negotiatedProtocolVersion = negotiateProtocolVersion( result.supportedVersions, localSupportedVersions: supportedProtocolVersionsWithDraft, ); - if (protocolVersion == null) { + if (negotiatedProtocolVersion == null) { throw McpError( ErrorCode.unsupportedProtocolVersion.value, "Server does not support a compatible MCP protocol version.", { 'supported': result.supportedVersions, - 'requested': _preferredProtocolVersion, + 'requested': protocolVersion, }, ); } @@ -332,19 +402,45 @@ class McpClient extends Protocol { _serverCapabilities = result.capabilities; _serverVersion = result.serverInfo; _instructions = result.instructions; - _negotiatedProtocolVersion = protocolVersion; - _usesStatelessProtocol = isStatelessProtocolVersion(protocolVersion); + _negotiatedProtocolVersion = negotiatedProtocolVersion; + _usesStatelessProtocol = isStatelessProtocolVersion( + negotiatedProtocolVersion, + ); _sentInitialized = true; - versionedTransport?.protocolVersion = protocolVersion; + versionedTransport?.protocolVersion = negotiatedProtocolVersion; _logger.debug( - "MCP Server Discovered. Server: ${result.serverInfo.name} ${result.serverInfo.version}, Protocol: $protocolVersion", + "MCP Server Discovered. Server: ${result.serverInfo.name} ${result.serverInfo.version}, Protocol: $negotiatedProtocolVersion", ); return result; } + Future discoverServer() async { + try { + return await _discoverServerWithVersion(_preferredProtocolVersion); + } catch (error) { + if (error is! McpError) { + rethrow; + } + + final retryVersion = _retryableDiscoveryProtocolVersion( + error, + _preferredProtocolVersion, + ); + if (retryVersion == null) { + rethrow; + } + + _logger.debug( + "server/discover rejected protocol $_preferredProtocolVersion; " + "retrying with $retryVersion.", + ); + return await _discoverServerWithVersion(retryVersion); + } + } + /// Connects to the server using the given [transport]. /// /// Initiates the MCP initialization handshake and processes the result. @@ -453,6 +549,16 @@ class McpClient extends Protocol { /// Gets the negotiated protocol version after connection. String? getProtocolVersion() => _negotiatedProtocolVersion; + @override + bool isRecognizedResultType(String resultType) { + if (super.isRecognizedResultType(resultType)) { + return true; + } + + return resultType == resultTypeTask && + (_serverCapabilities?.supportsTasksExtension ?? false); + } + @override McpError? validateIncomingRequest(JsonRpcRequest request) { if (_sentInitialized || request.method == Method.ping) { @@ -484,6 +590,31 @@ class McpClient extends Protocol { } } + @override + void onIncomingNotificationAccepted(JsonRpcNotification notification) { + final subscriptionId = notification.meta?[McpMetaKey.subscriptionId]; + if (subscriptionId is! int && subscriptionId is! String) { + return; + } + + final activeSubscription = _activeSubscriptions[subscriptionId]; + activeSubscription?.handleNotification(notification); + } + + @override + void onConnectionClosed() { + final subscriptions = List<_ClientSubscriptionState>.from( + _activeSubscriptions.values, + ); + _activeSubscriptions.clear(); + for (final subscription in subscriptions) { + subscription.fail( + McpError(ErrorCode.connectionClosed.value, 'Connection closed'), + StackTrace.current, + ); + } + } + @override void assertCapabilityForMethod(String method) { final serverCaps = _serverCapabilities; @@ -762,6 +893,47 @@ class McpClient extends Protocol { return request(req, (json) => const EmptyResult(), options); } + /// Opens a `subscriptions/listen` stream and demultiplexes notifications. + McpSubscription listenSubscriptions(SubscriptionsListenRequest params) { + if (transport == null) { + throw StateError('Not connected to a transport.'); + } + + final requestId = reserveRequestId(); + final abortController = BasicAbortController(); + final state = _ClientSubscriptionState( + id: requestId, + requestedNotifications: params.notifications, + abortController: abortController, + onClose: () => _activeSubscriptions.remove(requestId), + ); + _activeSubscriptions[requestId] = state; + + final requestData = JsonRpcSubscriptionsListenRequest( + id: requestId, + listenParams: params, + meta: _usesStatelessProtocol ? _statelessRequestMeta(null) : null, + ); + final requestDone = super.requestWithReservedId( + requestId, + requestData, + (json) => const EmptyResult(), + RequestOptions( + signal: abortController.signal, + timeoutEnabled: false, + ), + ); + state.trackRequest(requestDone); + + return McpSubscription._( + id: requestId, + acknowledged: state.acknowledged, + notifications: state.notifications, + done: state.done, + cancel: state.cancel, + ); + } + /// Sends a `tools/call` request to invoke a tool on the server. Future callTool( CallToolRequest params, { @@ -879,60 +1051,104 @@ class McpClient extends Protocol { final mappings = {}; final seenHeaders = {}; + final rejectionReason = _collectToolParameterHeaderMappings( + properties: properties, + path: const [], + mappings: mappings, + seenHeaders: seenHeaders, + ); + + if (rejectionReason != null) { + return _ToolParameterHeaderValidation.invalid(rejectionReason); + } + + return _ToolParameterHeaderValidation.valid(mappings); + } + + String? _collectToolParameterHeaderMappings({ + required Map properties, + required List path, + required Map mappings, + required Set seenHeaders, + }) { for (final entry in properties.entries) { + final parameterPath = [...path, entry.key]; + final parameterName = _toolParameterHeaderParameterName(parameterPath); final propertyJson = entry.value.toJson(); if (!propertyJson.containsKey('x-mcp-header')) { + if (entry.value is JsonObject) { + final childProperties = (entry.value as JsonObject).properties; + if (childProperties != null && childProperties.isNotEmpty) { + final rejectionReason = _collectToolParameterHeaderMappings( + properties: childProperties, + path: parameterPath, + mappings: mappings, + seenHeaders: seenHeaders, + ); + if (rejectionReason != null) { + return rejectionReason; + } + } + } 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', - ); + return 'parameter "$parameterName" has a non-string x-mcp-header value'; } if (rawHeader.isEmpty) { - return _ToolParameterHeaderValidation.invalid( - 'parameter "${entry.key}" has an empty x-mcp-header value', - ); + return 'parameter "$parameterName" has an empty x-mcp-header value'; } if (!_isValidMcpHeaderNameSuffix(rawHeader)) { - return _ToolParameterHeaderValidation.invalid( - 'parameter "${entry.key}" has invalid x-mcp-header value ' - '"$rawHeader"', - ); + return 'parameter "$parameterName" 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', - ); + return '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', - ); + return 'parameter "$parameterName" uses x-mcp-header on a schema that ' + 'is not string, integer, or boolean'; } - mappings[entry.key] = rawHeader; + mappings[_toolParameterHeaderSelector(parameterPath)] = rawHeader; } - return _ToolParameterHeaderValidation.valid(mappings); + return null; + } + + String _toolParameterHeaderSelector(List path) { + if (path.length == 1) { + return path.single; + } + + return '/${path.map(_escapeJsonPointerSegment).join('/')}'; + } + + String _toolParameterHeaderParameterName(List path) { + if (path.length == 1) { + return path.single; + } + + return _toolParameterHeaderSelector(path); + } + + String _escapeJsonPointerSegment(String segment) { + return segment.replaceAll('~', '~0').replaceAll('/', '~1'); } bool _isValidMcpHeaderNameSuffix(String value) { - return value.codeUnits.every( - (unit) => unit >= 0x21 && unit <= 0x7E && unit != 0x3A, - ); + return isValidMcpHeaderNameSuffix(value); } bool _isToolParameterHeaderPrimitive(JsonSchema schema) { return schema is JsonString || - schema is JsonNumber || schema is JsonInteger || schema is JsonBoolean; } @@ -948,6 +1164,174 @@ class McpClient extends Protocol { @Deprecated('Use McpClient instead') typedef Client = McpClient; +class _ClientSubscriptionState { + final int id; + final SubscriptionFilter requestedNotifications; + final BasicAbortController abortController; + final void Function() onClose; + final StreamController _notifications = + StreamController.broadcast(); + final Completer _acknowledged = + Completer(); + final Completer _done = Completer(); + + SubscriptionFilter? _acknowledgedNotifications; + bool _closed = false; + bool _localCancellation = false; + + _ClientSubscriptionState({ + required this.id, + required this.requestedNotifications, + required this.abortController, + required this.onClose, + }); + + Future get acknowledged => + _acknowledged.future; + + Stream get notifications => _notifications.stream; + + Future get done => _done.future; + + void handleNotification(JsonRpcNotification notification) { + if (_closed) { + return; + } + + if (_acknowledgedNotifications == null) { + if (notification.method != + Method.notificationsSubscriptionsAcknowledged) { + fail( + McpError( + ErrorCode.invalidRequest.value, + 'Subscription $id received ${notification.method} before ' + '${Method.notificationsSubscriptionsAcknowledged}.', + ), + StackTrace.current, + ); + return; + } + + final acknowledgedParams = + (notification as JsonRpcSubscriptionsAcknowledgedNotification) + .acknowledgedParams; + final acknowledgedNotifications = acknowledgedParams.notifications; + if (!acknowledgedNotifications.isSubsetOf(requestedNotifications)) { + fail( + McpError( + ErrorCode.invalidRequest.value, + 'Subscription $id acknowledged notifications that were not ' + 'requested.', + ), + StackTrace.current, + ); + return; + } + + _acknowledgedNotifications = acknowledgedNotifications; + if (!_acknowledged.isCompleted) { + _acknowledged.complete(acknowledgedParams); + } + return; + } + + final acknowledgedNotifications = _acknowledgedNotifications!; + if (!acknowledgedNotifications.allowsNotification(notification)) { + fail( + McpError( + ErrorCode.invalidRequest.value, + '${notification.method} was not requested or acknowledged for ' + 'subscription $id.', + ), + StackTrace.current, + ); + return; + } + + _notifications.add(notification); + } + + void trackRequest(Future requestDone) { + requestDone.then( + complete, + onError: (Object error, StackTrace stackTrace) { + if (_localCancellation) { + complete(const EmptyResult()); + } else { + fail(error, stackTrace, abort: false); + } + }, + ); + } + + void cancel([Object? reason]) { + if (_closed) { + return; + } + + _localCancellation = true; + if (!_acknowledged.isCompleted) { + _acknowledged.completeError(AbortError(reason), StackTrace.current); + } + abortController.abort(reason); + complete(const EmptyResult()); + } + + void complete(EmptyResult result) { + if (_closed) { + return; + } + + final missingAcknowledgment = _acknowledgedNotifications == null && + !_localCancellation && + !abortController.signal.aborted; + if (missingAcknowledgment) { + fail( + McpError( + ErrorCode.invalidRequest.value, + 'Subscription $id completed before ' + '${Method.notificationsSubscriptionsAcknowledged}.', + ), + StackTrace.current, + abort: false, + ); + return; + } + + _closed = true; + onClose(); + if (!_done.isCompleted) { + _done.complete(result); + } + _notifications.close(); + } + + void fail( + Object error, + StackTrace stackTrace, { + bool abort = true, + }) { + if (_closed) { + return; + } + + _closed = true; + onClose(); + if (abort && !abortController.signal.aborted) { + abortController.abort(error); + } + if (!_acknowledged.isCompleted) { + _acknowledged.completeError(error, stackTrace); + } + if (!_done.isCompleted) { + _done.completeError(error, stackTrace); + } + _notifications + ..addError(error, stackTrace) + ..close(); + } +} + class _ToolParameterHeaderValidation { final Map mappings; final String? rejectionReason; diff --git a/lib/src/client/streamable_https.dart b/lib/src/client/streamable_https.dart index 3129167d..2db94a0b 100644 --- a/lib/src/client/streamable_https.dart +++ b/lib/src/client/streamable_https.dart @@ -16,6 +16,9 @@ const _defaultStreamableHttpReconnectionOptions = maxRetries: 2, ); +const int _maxSafeHeaderInteger = 9007199254740991; +const int _minSafeHeaderInteger = -9007199254740991; + /// Error thrown for Streamable HTTP issues class StreamableHttpError extends Error { /// HTTP status code if applicable @@ -48,11 +51,15 @@ class StartSseOptions { /// Default is true. final bool shouldReconnect; + /// Whether JSON-RPC requests received on this stream should be rejected. + final bool rejectServerRequests; + const StartSseOptions({ this.resumptionToken, this.onResumptionToken, this.replayMessageId, this.shouldReconnect = true, + this.rejectServerRequests = false, }); } @@ -575,11 +582,12 @@ class StreamableHttpClientTransport final argumentMap = arguments.cast(); final headers = {}; for (final entry in mappings.entries) { - if (!argumentMap.containsKey(entry.key)) { + final argument = _toolParameterHeaderArgument(argumentMap, entry.key); + if (!argument.exists) { continue; } - final value = _toolParameterHeaderString(argumentMap[entry.key]); + final value = _toolParameterHeaderString(argument.value); if (value == null) { continue; } @@ -590,15 +598,69 @@ class StreamableHttpClientTransport return headers; } + ({bool exists, Object? value}) _toolParameterHeaderArgument( + Map arguments, + String selector, + ) { + if (!selector.startsWith('/')) { + return ( + exists: arguments.containsKey(selector), + value: arguments[selector], + ); + } + + Object? current = arguments; + for (final segment in _jsonPointerSegments(selector)) { + if (current is! Map || !current.containsKey(segment)) { + return (exists: false, value: null); + } + current = current[segment]; + } + return (exists: true, value: current); + } + + Iterable _jsonPointerSegments(String selector) { + if (selector == '/') { + return const ['']; + } + return selector + .substring(1) + .split('/') + .map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~')); + } + String? _toolParameterHeaderString(Object? value) { + final integer = _safeHeaderInteger(value); + if (integer != null) { + return integer.toString(); + } + return switch (value) { String() => value, - num() => value.toString(), bool() => value.toString(), _ => null, }; } + int? _safeHeaderInteger(Object? value) { + if (value is int) { + if (value < _minSafeHeaderInteger || value > _maxSafeHeaderInteger) { + return null; + } + return value; + } + + if (value is double && + value.isFinite && + value.truncateToDouble() == value && + value >= _minSafeHeaderInteger && + value <= _maxSafeHeaderInteger) { + return value.toInt(); + } + + return null; + } + String _encodeToolParameterHeaderValue(String value) { if (_isPlainToolParameterHeaderValue(value)) { return value; @@ -608,12 +670,17 @@ class StreamableHttpClientTransport } bool _isPlainToolParameterHeaderValue(String value) { - return value.trim() == value && + return !_isBase64ToolParameterHeaderSentinel(value) && + value.trim() == value && value.codeUnits.every( (unit) => unit == 0x09 || (unit >= 0x20 && unit <= 0x7E), ); } + bool _isBase64ToolParameterHeaderSentinel(String value) { + return value.startsWith('=?base64?') && value.endsWith('?='); + } + String? _methodFrom(JsonRpcMessage message) { if (message is JsonRpcRequest) { return message.method; @@ -683,6 +750,11 @@ class StreamableHttpClientTransport } Future _startOrAuthSse(StartSseOptions options) async { + if (_protocolVersion != null && + isStatelessProtocolVersion(_protocolVersion!)) { + return; + } + final resumptionToken = options.resumptionToken; try { // Try to open an initial SSE stream with GET to listen for server messages @@ -874,9 +946,15 @@ class StreamableHttpClientTransport result: message.result, meta: message.meta, ); - onmessage?.call(newMessage); + _dispatchReceivedMessage( + newMessage, + rejectServerRequests: options.rejectServerRequests, + ); } else { - onmessage?.call(message); + _dispatchReceivedMessage( + message, + rejectServerRequests: options.rejectServerRequests, + ); } } catch (error) { if (error is Error) { @@ -999,6 +1077,25 @@ class StreamableHttpClientTransport }); } + void _dispatchReceivedMessage( + JsonRpcMessage message, { + required bool rejectServerRequests, + }) { + if (rejectServerRequests && message is JsonRpcRequest) { + onerror?.call( + McpError( + ErrorCode.invalidRequest.value, + 'Server-initiated JSON-RPC requests are not supported on 2026 ' + 'stateless MCP response streams; return input_required with ' + 'inputRequests instead.', + ), + ); + return; + } + + onmessage?.call(message); + } + @override Future start() async { if (_abortController != null) { @@ -1117,6 +1214,12 @@ class StreamableHttpClientTransport final headers = await _commonHeaders(); headers.addAll(_headersForMessage(message)); + final protocolVersion = _protocolVersion ?? _protocolVersionFrom(message); + final isStatelessRequest = protocolVersion != null && + isStatelessProtocolVersion(protocolVersion); + if (isStatelessRequest) { + headers.remove('mcp-session-id'); + } final requestSessionId = headers['mcp-session-id']; headers['content-type'] = 'application/json'; headers['accept'] = 'application/json, text/event-stream'; @@ -1174,7 +1277,7 @@ class StreamableHttpClientTransport // Handle session ID received from successful stateful responses. final sessionId = response.headers['mcp-session-id']; - if (sessionId != null) { + if (sessionId != null && !isStatelessRequest) { _sessionId = sessionId; _staleSessionDetected = false; } @@ -1226,6 +1329,7 @@ class StreamableHttpClientTransport StartSseOptions( onResumptionToken: onResumptionToken, shouldReconnect: false, // Do not reconnect for POST responses + rejectServerRequests: isStatelessRequest, ), ); } else if (contentType?.contains('application/json') ?? false) { @@ -1236,11 +1340,17 @@ class StreamableHttpClientTransport if (data is List) { for (final item in data) { final msg = JsonRpcMessage.fromJson(item); - onmessage?.call(msg); + _dispatchReceivedMessage( + msg, + rejectServerRequests: isStatelessRequest, + ); } } else { final msg = JsonRpcMessage.fromJson(data); - onmessage?.call(msg); + _dispatchReceivedMessage( + msg, + rejectServerRequests: isStatelessRequest, + ); } } else { throw StreamableHttpError( @@ -1292,6 +1402,13 @@ class StreamableHttpClientTransport /// The server MAY respond with HTTP 405 Method Not Allowed, indicating that /// the server does not allow clients to terminate sessions. Future terminateSession() async { + if (_protocolVersion != null && + isStatelessProtocolVersion(_protocolVersion!)) { + _sessionId = null; + _staleSessionDetected = false; + return; + } + if (_sessionId == null) { return; // No session to terminate } diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index 1acb5f76..2d88362e 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -1,7 +1,8 @@ import 'dart:async'; -import 'package:mcp_dart/src/shared/json_schema/json_schema_validator.dart'; +import 'package:mcp_dart/src/shared/json_schema/json_schema_validator.dart'; import 'package:mcp_dart/src/shared/logging.dart'; +import 'package:mcp_dart/src/shared/mcp_header_validation.dart'; import 'package:mcp_dart/src/shared/protocol.dart'; import 'package:mcp_dart/src/shared/task_interfaces.dart'; import 'package:mcp_dart/src/shared/tool_name_validation.dart'; @@ -592,12 +593,16 @@ class _RegisteredToolImpl implements RegisteredTool { _server._registeredTools[name] = this; } - Tool toTool({bool includeExecution = true}) { + Tool toTool({ + bool includeExecution = true, + ToolInputSchema? inputSchemaOverride, + }) { return Tool( name: name, title: title, description: description, - inputSchema: inputSchema ?? const ToolInputSchema(), + inputSchema: + inputSchemaOverride ?? inputSchema ?? const ToolInputSchema(), outputSchema: outputSchema, annotations: annotations, icon: icon, @@ -972,6 +977,7 @@ class McpServer { /// Connects the server to a communication [transport]. Future connect(Transport transport) async { + _syncToolParameterHeaderMappings(transport); return await server.connect(transport); } @@ -984,11 +990,20 @@ class McpServer { bool get isConnected => server.transport != null; /// Sends a logging message to the client, if connected. + /// + /// For stateless MCP requests, pass [requestMeta] from + /// [RequestHandlerExtra.meta] so log notifications honor the request-scoped + /// `io.modelcontextprotocol/logLevel` opt-in. Future sendLoggingMessage( LoggingMessageNotification params, { String? sessionId, + Map? requestMeta, }) async { - return server.sendLoggingMessage(params, sessionId: sessionId); + return server.sendLoggingMessage( + params, + sessionId: sessionId, + requestMeta: requestMeta, + ); } /// Sets the error handler for the server. @@ -1026,10 +1041,245 @@ class McpServer { /// Notifies clients that the list of available tools has changed. void sendToolListChanged() { if (server.transport != null) { + _syncToolParameterHeaderMappings(); server.sendToolListChanged(); } } + void _syncToolParameterHeaderMappings([Transport? target]) { + final activeTransport = target ?? server.transport; + final headerAwareTransport = + activeTransport is ToolParameterHeaderAwareTransport + ? activeTransport as ToolParameterHeaderAwareTransport + : null; + if (headerAwareTransport != null) { + headerAwareTransport.setToolParameterHeaderMappings( + _buildToolParameterHeaderMappings(), + ); + } + } + + ToolParameterHeaderMappings _buildToolParameterHeaderMappings() { + final mappings = >{}; + + for (final tool in _registeredTools.values) { + if (!tool.enabled) { + continue; + } + + final toolMappings = _toolParameterHeaderMappingsFor(tool); + if (toolMappings.isNotEmpty) { + mappings[tool.name] = toolMappings; + } + } + + return mappings; + } + + ToolInputSchema _toolInputSchemaForStatelessList( + _RegisteredToolImpl tool, + ) { + final inputSchema = tool.inputSchema ?? const ToolInputSchema(); + final properties = inputSchema.properties; + if (properties == null || properties.isEmpty) { + return inputSchema; + } + + final ignoredReason = _collectToolParameterHeaderMappings( + toolName: tool.name, + properties: properties, + path: const [], + mappings: {}, + seenHeaders: {}, + ); + if (ignoredReason == null) { + return inputSchema; + } + + return _stripToolParameterHeaderMetadata(inputSchema); + } + + ToolInputSchema _stripToolParameterHeaderMetadata(ToolInputSchema schema) { + return JsonSchema.fromJson( + _stripToolParameterHeaderMetadataFromJson(schema.toJson()), + ) as ToolInputSchema; + } + + Map _stripToolParameterHeaderMetadataFromJson( + Map json, + ) { + final stripped = Map.from(json)..remove('x-mcp-header'); + + final properties = stripped['properties']; + if (properties is Map) { + final strippedProperties = {}; + for (final entry in properties.entries) { + final propertyName = entry.key as String; + final propertyValue = entry.value; + strippedProperties[propertyName] = propertyValue is Map + ? _stripToolParameterHeaderMetadataFromJson( + Map.from(propertyValue), + ) + : propertyValue; + } + stripped['properties'] = strippedProperties; + } + + final items = stripped['items']; + if (items is Map) { + stripped['items'] = _stripToolParameterHeaderMetadataFromJson( + Map.from(items), + ); + } + + final additionalProperties = stripped['additionalProperties']; + if (additionalProperties is Map) { + stripped['additionalProperties'] = + _stripToolParameterHeaderMetadataFromJson( + Map.from(additionalProperties), + ); + } + + for (final keyword in const ['allOf', 'anyOf', 'oneOf']) { + final schemas = stripped[keyword]; + if (schemas is List) { + stripped[keyword] = [ + for (final schema in schemas) + if (schema is Map) + _stripToolParameterHeaderMetadataFromJson( + Map.from(schema), + ) + else + schema, + ]; + } + } + + final notSchema = stripped['not']; + if (notSchema is Map) { + stripped['not'] = _stripToolParameterHeaderMetadataFromJson( + Map.from(notSchema), + ); + } + + return stripped; + } + + Map _toolParameterHeaderMappingsFor( + _RegisteredToolImpl tool, + ) { + final properties = tool.inputSchema?.properties; + if (properties == null || properties.isEmpty) { + return const {}; + } + + final mappings = {}; + final seenHeaders = {}; + final ignoredReason = _collectToolParameterHeaderMappings( + toolName: tool.name, + properties: properties, + path: const [], + mappings: mappings, + seenHeaders: seenHeaders, + ); + + if (ignoredReason != null) { + _logger.warn(ignoredReason); + return const {}; + } + + return mappings; + } + + String? _collectToolParameterHeaderMappings({ + required String toolName, + required Map properties, + required List path, + required Map mappings, + required Set seenHeaders, + }) { + for (final entry in properties.entries) { + final parameterPath = [...path, entry.key]; + final parameterName = _toolParameterHeaderParameterName(parameterPath); + final propertyJson = entry.value.toJson(); + if (!propertyJson.containsKey('x-mcp-header')) { + if (entry.value is JsonObject) { + final childProperties = (entry.value as JsonObject).properties; + if (childProperties != null && childProperties.isNotEmpty) { + final ignoredReason = _collectToolParameterHeaderMappings( + toolName: toolName, + properties: childProperties, + path: parameterPath, + mappings: mappings, + seenHeaders: seenHeaders, + ); + if (ignoredReason != null) { + return ignoredReason; + } + } + } + continue; + } + + final rawHeader = propertyJson['x-mcp-header']; + if (rawHeader is! String || rawHeader.isEmpty) { + return 'Ignoring x-mcp-header mapping for tool "$toolName" parameter ' + '"$parameterName": value must be a non-empty string.'; + } + + if (!_isValidMcpHeaderNameSuffix(rawHeader)) { + return 'Ignoring x-mcp-header mapping for tool "$toolName" parameter ' + '"$parameterName": "$rawHeader" is not a valid Mcp-Param suffix.'; + } + + final normalizedHeader = rawHeader.toLowerCase(); + if (!seenHeaders.add(normalizedHeader)) { + return 'Ignoring x-mcp-header mappings for tool "$toolName": ' + '"$rawHeader" is not unique.'; + } + + if (!_isToolParameterHeaderPrimitive(entry.value)) { + return 'Ignoring x-mcp-header mapping for tool "$toolName" parameter ' + '"$parameterName": only string, integer, and boolean schemas can ' + 'be mirrored.'; + } + + mappings[_toolParameterHeaderSelector(parameterPath)] = rawHeader; + } + + return null; + } + + String _toolParameterHeaderSelector(List path) { + if (path.length == 1) { + return path.single; + } + + return '/${path.map(_escapeJsonPointerSegment).join('/')}'; + } + + String _toolParameterHeaderParameterName(List path) { + if (path.length == 1) { + return path.single; + } + + return _toolParameterHeaderSelector(path); + } + + String _escapeJsonPointerSegment(String segment) { + return segment.replaceAll('~', '~0').replaceAll('/', '~1'); + } + + bool _isValidMcpHeaderNameSuffix(String value) { + return isValidMcpHeaderNameSuffix(value); + } + + bool _isToolParameterHeaderPrimitive(JsonSchema schema) { + return schema is JsonString || + schema is JsonInteger || + schema is JsonBoolean; + } + /// Notifies clients that the list of available prompts has changed. void sendPromptListChanged() { if (server.transport != null) { @@ -1158,15 +1408,22 @@ class McpServer { Method.toolsList, (request, extra) async { final protocolVersion = request.meta?[McpMetaKey.protocolVersion]; - final includeLegacyTaskExecution = protocolVersion is! String || - !isStatelessProtocolVersion(protocolVersion); + final isStatelessRequest = protocolVersion is String && + isStatelessProtocolVersion(protocolVersion); + final includeLegacyTaskExecution = !isStatelessRequest; + final tools = _registeredTools.values.where((t) => t.enabled).toList(); + if (isStatelessRequest) { + tools.sort((a, b) => a.name.compareTo(b.name)); + } return ListToolsResult( - tools: _registeredTools.values - .where((t) => t.enabled) + tools: tools .map( - (e) => e.toTool( + (tool) => tool.toTool( includeExecution: includeLegacyTaskExecution, + inputSchemaOverride: isStatelessRequest + ? _toolInputSchemaForStatelessList(tool) + : null, ), ) .toList(), diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 48e80db5..04bd268c 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -64,6 +64,19 @@ class Server extends Protocol { LoggingLevel.emergency: 7, }; + static const Set _statelessRemovedRequestMethods = { + Method.initialize, + Method.ping, + Method.loggingSetLevel, + Method.resourcesSubscribe, + Method.resourcesUnsubscribe, + }; + + static const Set _statelessRemovedNotificationMethods = { + Method.notificationsInitialized, + Method.notificationsRootsListChanged, + }; + /// Callback to be notified when the server is fully initialized. void Function()? oninitialized; @@ -181,6 +194,14 @@ class Server extends Protocol { ); } + final logLevel = meta?[McpMetaKey.logLevel]; + if (logLevel != null && _parseLoggingLevel(logLevel) == null) { + return McpError( + ErrorCode.invalidRequest.value, + 'Invalid stateless request metadata: ${McpMetaKey.logLevel}', + ); + } + return null; } @@ -225,6 +246,82 @@ class Server extends Protocol { isStatelessProtocolVersion(requestedProtocolVersion); } + bool _isStatelessNotification(JsonRpcNotification notification) { + final requestedProtocolVersion = + notification.meta?[McpMetaKey.protocolVersion]; + return requestedProtocolVersion is String && + isStatelessProtocolVersion(requestedProtocolVersion); + } + + LoggingLevel? _parseLoggingLevel(Object? value) { + if (value is LoggingLevel) { + return value; + } + if (value is String) { + for (final level in LoggingLevel.values) { + if (level.name == value) { + return level; + } + } + } + return null; + } + + bool _allowsStatelessLogging( + LoggingLevel messageLevel, + Map? requestMeta, + ) { + if (!_isStatelessMeta(requestMeta)) { + return true; + } + + final requestedLevel = _parseLoggingLevel( + requestMeta?[McpMetaKey.logLevel], + ); + if (requestedLevel == null) { + return false; + } + + return _logLevelSeverity[messageLevel]! >= + _logLevelSeverity[requestedLevel]!; + } + + bool _isStatelessMeta(Map? requestMeta) { + final requestedProtocolVersion = requestMeta?[McpMetaKey.protocolVersion]; + return requestedProtocolVersion is String && + isStatelessProtocolVersion(requestedProtocolVersion); + } + + McpError? _validateStatelessRemovedRequestMethod(JsonRpcRequest request) { + if (!_isStatelessRequest(request)) { + return null; + } + if (!_statelessRemovedRequestMethods.contains(request.method)) { + return null; + } + + return McpError( + ErrorCode.methodNotFound.value, + '${request.method} is not part of MCP stateless protocol versions.', + ); + } + + McpError? _validateStatelessRemovedNotificationMethod( + JsonRpcNotification notification, + ) { + if (!_isStatelessNotification(notification)) { + return null; + } + if (!_statelessRemovedNotificationMethods.contains(notification.method)) { + return null; + } + + return McpError( + ErrorCode.methodNotFound.value, + '${notification.method} is not part of MCP stateless protocol versions.', + ); + } + McpError? _validateDraftTaskMethods(JsonRpcRequest request) { if (!_isStatelessRequest(request)) { return null; @@ -242,6 +339,27 @@ class Server extends Protocol { return null; } + McpError? _validateServerTasksExtensionSupport(JsonRpcRequest request) { + if (!_isStatelessRequest(request)) { + return null; + } + + switch (request.method) { + case Method.tasksGet: + case Method.tasksCancel: + case Method.tasksUpdate: + if (_capabilities.supportsTasksExtension) { + return null; + } + return McpError( + ErrorCode.methodNotFound.value, + '${request.method} requires server support for $mcpTasksExtensionId.', + ); + } + + return null; + } + McpError? _validateTasksExtensionCapabilities(JsonRpcRequest request) { final requiresTasksExtension = (request is JsonRpcSubscriptionsListenRequest && @@ -283,6 +401,11 @@ class Server extends Protocol { return removedMethodError; } + final serverExtensionError = _validateServerTasksExtensionSupport(request); + if (serverExtensionError != null) { + return serverExtensionError; + } + final extensionCapabilityError = _validateTasksExtensionCapabilities(request); if (extensionCapabilityError != null) { @@ -307,6 +430,33 @@ class Server extends Protocol { return false; } + bool _allowsTaskExtensionResult( + BaseResultData result, + JsonRpcRequest request, + ) { + if (!_isStatelessRequest(request)) { + return true; + } + + return switch (request.method) { + Method.tasksGet => result is GetTaskExtensionResult, + Method.tasksCancel || + Method.tasksUpdate => + result is TaskExtensionAcknowledgementResult || result is EmptyResult, + _ => true, + }; + } + + String _expectedTaskExtensionResult(String method) { + return switch (method) { + Method.tasksGet => 'GetTaskExtensionResult', + Method.tasksCancel || + Method.tasksUpdate => + 'TaskExtensionAcknowledgementResult or EmptyResult', + _ => 'valid MCP Tasks extension result', + }; + } + bool _isLegacyTaskAugmentedRequest(JsonRpcCallToolRequest request) { if (_isStatelessRequest(request)) { return false; @@ -336,6 +486,12 @@ class Server extends Protocol { if (metadataError != null) { return metadataError; } + final removedMethodError = _validateStatelessRemovedRequestMethod( + request, + ); + if (removedMethodError != null) { + return removedMethodError; + } return _validateRequestTaskSemantics(request); } @@ -372,6 +528,12 @@ class Server extends Protocol { @override McpError? validateIncomingNotification(JsonRpcNotification notification) { + final removedMethodError = + _validateStatelessRemovedNotificationMethod(notification); + if (removedMethodError != null) { + return removedMethodError; + } + switch (notification.method) { case Method.notificationsCancelled: case Method.notificationsProgress: @@ -436,6 +598,45 @@ class Server extends Protocol { } } + bool _requiresCacheableResult(String method) { + return switch (method) { + Method.toolsList || + Method.promptsList || + Method.resourcesList || + Method.resourcesTemplatesList || + Method.resourcesRead => + true, + _ => false, + }; + } + + @override + Map serializeIncomingResult( + JsonRpcRequest request, + BaseResultData result, + ) { + final json = super.serializeIncomingResult(request, result); + if (!_isStatelessRequest(request)) { + return json; + } + + json.putIfAbsent('resultType', () => resultTypeComplete); + if (_requiresCacheableResult(request.method)) { + json.putIfAbsent( + 'ttlMs', + () => result is CacheableResultData ? result.ttlMs ?? 0 : 0, + ); + json.putIfAbsent( + 'cacheScope', + () => result is CacheableResultData + ? result.cacheScope ?? CacheScope.private + : CacheScope.private, + ); + } + + return json; + } + @override void onConnectionClosed() { _resetSessionState(); @@ -506,6 +707,24 @@ class Server extends Protocol { return result; } + super.setRequestHandler(method, wrappedHandler, requestFactory); + } else if (method == Method.tasksGet || + method == Method.tasksCancel || + method == Method.tasksUpdate) { + Future wrappedHandler( + ReqT request, + RequestHandlerExtra extra, + ) async { + final result = await handler(request, extra); + if (!_allowsTaskExtensionResult(result, request)) { + throw McpError( + ErrorCode.invalidParams.value, + "Invalid ${request.method} result for MCP Tasks extension: Expected ${_expectedTaskExtensionResult(request.method)}", + ); + } + return result; + } + super.setRequestHandler(method, wrappedHandler, requestFactory); } else { super.setRequestHandler(method, handler, requestFactory); @@ -988,12 +1207,20 @@ class Server extends Protocol { } /// Sends a `notifications/message` (logging) notification to the client. + /// + /// For stateless MCP requests, pass [requestMeta] from + /// [RequestHandlerExtra.meta] so log notifications honor the request-scoped + /// `io.modelcontextprotocol/logLevel` opt-in. Future sendLoggingMessage( LoggingMessageNotification params, { String? sessionId, + Map? requestMeta, }) async { if (_capabilities.logging != null) { - if (!_isMessageIgnored(params.level, sessionId)) { + final statelessLogContext = _isStatelessMeta(requestMeta); + if (_allowsStatelessLogging(params.level, requestMeta) && + (statelessLogContext || + !_isMessageIgnored(params.level, sessionId))) { final notif = JsonRpcLoggingMessageNotification(logParams: params); return notification(notif); } diff --git a/lib/src/server/streamable_https.dart b/lib/src/server/streamable_https.dart index 079dc7c6..5f540921 100644 --- a/lib/src/server/streamable_https.dart +++ b/lib/src/server/streamable_https.dart @@ -3,12 +3,17 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; +import 'package:mcp_dart/src/shared/mcp_header_validation.dart'; import 'package:mcp_dart/src/shared/uuid.dart'; import '../shared/transport.dart'; import '../types.dart'; import 'dns_rebinding_protection.dart'; +const int _maxSafeHeaderInteger = 9007199254740991; +const int _minSafeHeaderInteger = -9007199254740991; +const String _xAccelBufferingHeader = 'X-Accel-Buffering'; + /// ID for SSE streams typedef StreamId = String; @@ -153,7 +158,11 @@ class StreamableHTTPServerTransportOptions { /// - Session ID is only included in initialization responses /// - No session validation is performed class StreamableHTTPServerTransport - implements Transport, RequestIdAwareTransport { + implements + Transport, + RequestIdAwareTransport, + IncomingRequestValidationAwareTransport, + ToolParameterHeaderAwareTransport { // when sessionId is not set (null), it means the transport is in stateless mode final String? Function()? _sessionIdGenerator; bool _started = false; @@ -163,6 +172,8 @@ class StreamableHTTPServerTransport final Set _ownedStreamIds = {}; final Map _requestToStreamMapping = {}; final Map _requestResponseMap = {}; + final Set _statelessRequestIds = {}; + final Map _responseStreamSockets = {}; bool _initialized = false; bool _terminated = false; final bool _enableJsonResponse; @@ -176,6 +187,9 @@ class StreamableHTTPServerTransport final bool _strictProtocolVersionHeaderValidation; final bool _rejectBatchJsonRpcPayloads; final int _maxReplayedEvents; + ToolParameterHeaderMappings _toolParameterHeaderMappings = const {}; + McpError? Function(JsonRpcRequest request)? _incomingRequestValidator; + bool Function(String method)? _isRequestMethodSupported; static const JsonRpcNotification _ssePrimingMessage = JsonRpcNotification(method: 'notifications/experimental/sse/priming'); @@ -216,6 +230,28 @@ class StreamableHTTPServerTransport _started = true; } + @override + void setToolParameterHeaderMappings( + ToolParameterHeaderMappings mappings, + ) { + _toolParameterHeaderMappings = { + for (final entry in mappings.entries) + entry.key: Map.unmodifiable(Map.from(entry.value)), + }; + } + + @override + void setIncomingRequestValidator( + McpError? Function(JsonRpcRequest request) validator, + ) { + _incomingRequestValidator = validator; + } + + @override + void setRequestMethodSupported(bool Function(String method) isSupported) { + _isRequestMethodSupported = isSupported; + } + /// Handles an incoming HTTP request, whether GET or POST Future handleRequest(HttpRequest req, [dynamic parsedBody]) async { req.response.bufferOutput = false; @@ -238,8 +274,7 @@ class StreamableHTTPServerTransport if (req.method == "POST") { await _handlePostRequest(req, parsedBody); - } else if (_isStatelessProtocolVersionRequest(req) && - (req.method == "GET" || req.method == "DELETE")) { + } else if (_isStatelessProtocolVersionRequest(req)) { await _handleStatelessUnsupportedRequest(req.response); } else if (req.method == "GET") { await _handleGetRequest(req); @@ -309,6 +344,15 @@ class StreamableHTTPServerTransport return _isValidVisibleAsciiToken(sessionId); } + Map _sseResponseHeaders() { + return { + HttpHeaders.contentTypeHeader: 'text/event-stream; charset=utf-8', + HttpHeaders.cacheControlHeader: 'no-cache, no-transform', + HttpHeaders.connectionHeader: 'keep-alive', + _xAccelBufferingHeader: 'no', + }; + } + void _validateSseEventId(EventId eventId) { if (!_isValidVisibleAsciiToken(eventId)) { throw StateError( @@ -325,14 +369,33 @@ class StreamableHTTPServerTransport required String message, RequestId? id, Object? data, + }) { + return _writeJsonRpcErrorCodeResponse( + response, + httpStatus: httpStatus, + errorCode: errorCode.value, + message: message, + id: id, + data: data, + ); + } + + Future _writeJsonRpcErrorCodeResponse( + HttpResponse response, { + required int httpStatus, + required int errorCode, + required String message, + RequestId? id, + Object? data, }) async { response.statusCode = httpStatus; + response.headers.contentType = ContentType.json; response.write( jsonEncode( JsonRpcError( id: id, error: JsonRpcErrorData( - code: errorCode.value, + code: errorCode, message: message, data: data, ), @@ -342,6 +405,14 @@ class StreamableHTTPServerTransport await _safeClose(response); } + int _statelessHttpStatusForErrorCode(int code) { + if (code == ErrorCode.methodNotFound.value) { + return HttpStatus.notFound; + } + + return HttpStatus.badRequest; + } + Future _writeHeaderMismatchResponse( HttpResponse response, JsonRpcMessage message, @@ -437,64 +508,212 @@ class StreamableHTTPServerTransport } String? _primitiveHeaderString(Object? value) { + final integer = _safeHeaderInteger(value); + if (integer != null) { + return integer.toString(); + } + return switch (value) { null => null, String() => value, - num() => value.toString(), bool() => value.toString(), _ => null, }; } + int? _safeHeaderInteger(Object? value) { + if (value is int) { + if (value < _minSafeHeaderInteger || value > _maxSafeHeaderInteger) { + return null; + } + return value; + } + + if (value is double && + value.isFinite && + value.truncateToDouble() == value && + value >= _minSafeHeaderInteger && + value <= _maxSafeHeaderInteger) { + return value.toInt(); + } + + return null; + } + + bool _headerValueMatchesPrimitive(Object? bodyValue, String headerValue) { + final integer = _safeHeaderInteger(bodyValue); + if (integer != null) { + final headerInteger = _safeHeaderInteger(num.tryParse(headerValue)); + return headerInteger != null && headerInteger == integer; + } + + final value = _primitiveHeaderString(bodyValue); + return value != null && headerValue == value; + } + + String? _toolName(JsonRpcMessage message) { + if (_messageMethod(message) != Method.toolsCall) { + return null; + } + + final name = _messageParams(message)?['name']; + return name is String ? name : null; + } + Future _validateMcpParamHeaders( HttpRequest req, HttpResponse res, JsonRpcMessage message, ) async { + final headers = {}; + req.headers.forEach((name, values) { + const prefix = 'mcp-param-'; + final lowerName = name.toLowerCase(); + if (!lowerName.startsWith(prefix)) { + return; + } + + final headerSuffix = name.substring(prefix.length); + if (!isValidMcpHeaderNameSuffix(headerSuffix)) { + headers[lowerName] = _McpParamHeader.invalidName( + name: name, + suffix: headerSuffix, + ); + return; + } + + final headerValue = req.headers.value(name); + final decodedValue = + headerValue == null ? null : _decodeMcpParamHeaderValue(headerValue); + if (decodedValue == null) { + headers[lowerName] = _McpParamHeader.invalidValue( + name: name, + suffix: headerSuffix, + ); + return; + } + + headers[headerSuffix.toLowerCase()] = _McpParamHeader( + name: name, + suffix: headerSuffix, + value: decodedValue, + ); + }); + + _McpParamHeader? invalidHeader; + for (final header in headers.values) { + if (header.validationError != null) { + invalidHeader = header; + break; + } + } + if (invalidHeader != null) { + final messageText = + invalidHeader.validationError == _McpParamHeaderValidationError.name + ? '${invalidHeader.name} header name is malformed' + : '${invalidHeader.name} header value is malformed'; + await _writeHeaderMismatchResponse(res, message, messageText); + return false; + } + final params = _messageParams(message); final arguments = params?['arguments']; if (arguments is! Map) { - return true; + if (headers.isEmpty) { + return true; + } + await _writeHeaderMismatchResponse( + res, + message, + '${headers.values.first.name} header has no matching body arguments', + ); + return false; } + final argumentMap = arguments.cast(); + final consumedHeaders = {}; + final toolName = _toolName(message); + final headerMappings = + toolName == null ? null : _toolParameterHeaderMappings[toolName]; + + if (headerMappings != null) { + for (final entry in headerMappings.entries) { + final argumentName = entry.key; + final headerSuffix = entry.value; + final header = headers[headerSuffix.toLowerCase()]; + final argument = _toolParameterHeaderArgument(argumentMap, entry.key); + final hasArgument = argument.exists; + final bodyArgument = argument.value; + final bodyValue = + hasArgument ? _primitiveHeaderString(bodyArgument) : null; + + if (!hasArgument || bodyValue == null) { + if (header != null) { + await _writeHeaderMismatchResponse( + res, + message, + '${header.name} header has no matching primitive body argument ' + "'$argumentName'", + ); + return false; + } + continue; + } - final headerNames = []; - req.headers.forEach((name, values) { - headerNames.add(name); - }); + if (header == null) { + await _writeHeaderMismatchResponse( + res, + message, + 'Mcp-Param-$headerSuffix header is required for body argument ' + "'$argumentName'", + ); + return false; + } - for (final headerName in headerNames) { - const prefix = 'mcp-param-'; - if (!headerName.toLowerCase().startsWith(prefix)) { + consumedHeaders.add(header.suffix.toLowerCase()); + if (!_headerValueMatchesPrimitive(bodyArgument, header.value!)) { + await _writeHeaderMismatchResponse( + res, + message, + '${header.name} header value does not match body argument ' + "'$argumentName'", + ); + return false; + } + } + } + + final argumentNamesByLowercase = {}; + for (final argumentName in argumentMap.keys) { + argumentNamesByLowercase.putIfAbsent( + argumentName.toLowerCase(), + () => argumentName, + ); + } + + for (final header in headers.values) { + if (consumedHeaders.contains(header.suffix.toLowerCase())) { continue; } - final headerSuffix = headerName.substring(prefix.length); - if (headerSuffix.isEmpty || - !headerSuffix.codeUnits.every( - (unit) => unit >= 0x21 && unit <= 0x7E && unit != 0x3A, - )) { + final argumentName = argumentMap.containsKey(header.suffix) + ? header.suffix + : argumentNamesByLowercase[header.suffix.toLowerCase()]; + if (argumentName == null) { await _writeHeaderMismatchResponse( res, message, - "$headerName header name is malformed", + '${header.name} header has no matching body argument', ); 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) { + final bodyArgument = argumentMap[argumentName]; + if (!_headerValueMatchesPrimitive(bodyArgument, header.value!)) { await _writeHeaderMismatchResponse( res, message, - "$headerName header value does not match body argument '$headerSuffix'", + "${header.name} header value does not match body argument '$argumentName'", ); return false; } @@ -503,6 +722,37 @@ class StreamableHTTPServerTransport return true; } + ({bool exists, Object? value}) _toolParameterHeaderArgument( + Map arguments, + String selector, + ) { + if (!selector.startsWith('/')) { + return ( + exists: arguments.containsKey(selector), + value: arguments[selector], + ); + } + + Object? current = arguments; + for (final segment in _jsonPointerSegments(selector)) { + if (current is! Map || !current.containsKey(segment)) { + return (exists: false, value: null); + } + current = current[segment]; + } + return (exists: true, value: current); + } + + Iterable _jsonPointerSegments(String selector) { + if (selector == '/') { + return const ['']; + } + return selector + .substring(1) + .split('/') + .map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~')); + } + Future _validateStatelessHttpHeaders( HttpRequest req, List messages, @@ -558,7 +808,6 @@ class StreamableHTTPServerTransport ); return false; } - final method = _messageMethod(message); if (method == null) { return true; @@ -603,7 +852,39 @@ class StreamableHTTPServerTransport } } - return _validateMcpParamHeaders(req, req.response, message); + if (!await _validateMcpParamHeaders(req, req.response, message)) { + return false; + } + + if (message is JsonRpcRequest) { + final validationError = _incomingRequestValidator?.call(message); + if (validationError != null) { + await _writeJsonRpcErrorCodeResponse( + req.response, + httpStatus: _statelessHttpStatusForErrorCode(validationError.code), + errorCode: validationError.code, + id: message.id, + message: validationError.message, + data: validationError.data, + ); + return false; + } + + final isRequestMethodSupported = _isRequestMethodSupported; + if (isRequestMethodSupported != null && + !isRequestMethodSupported(message.method)) { + await _writeJsonRpcErrorResponse( + req.response, + httpStatus: HttpStatus.notFound, + errorCode: ErrorCode.methodNotFound, + id: message.id, + message: 'Method not found: ${message.method}', + ); + return false; + } + } + + return true; } bool _isStandaloneSseStreamId(StreamId streamId) { @@ -681,20 +962,12 @@ class StreamableHTTPServerTransport // The client MUST include an Accept header, listing text/event-stream as a supported content type. final acceptedMediaTypes = _parseAcceptedMediaTypes(req); if (!_acceptsMediaType(acceptedMediaTypes, 'text/event-stream')) { - req.response - ..statusCode = HttpStatus.notAcceptable - ..write( - jsonEncode( - JsonRpcError( - id: null, - error: JsonRpcErrorData( - code: ErrorCode.connectionClosed.value, - message: 'Not Acceptable: Client must accept text/event-stream', - ), - ).toJson(), - ), - ); - await _safeClose(req.response); + await _writeJsonRpcErrorResponse( + req.response, + httpStatus: HttpStatus.notAcceptable, + errorCode: ErrorCode.connectionClosed, + message: 'Not Acceptable: Client must accept text/event-stream', + ); return; } @@ -716,11 +989,7 @@ class StreamableHTTPServerTransport // The server MUST either return Content-Type: text/event-stream in response to this HTTP GET, // or else return HTTP 405 Method Not Allowed - final headers = { - HttpHeaders.contentTypeHeader: "text/event-stream; charset=utf-8", - HttpHeaders.cacheControlHeader: "no-cache, no-transform", - HttpHeaders.connectionHeader: "keep-alive", - }; + final headers = _sseResponseHeaders(); // After initialization, always include the session ID if we have one if (sessionId != null) { @@ -730,6 +999,7 @@ class StreamableHTTPServerTransport // We need to send headers immediately as messages will arrive much later, // otherwise the client will just wait for the first message req.response.statusCode = HttpStatus.ok; + req.response.bufferOutput = false; headers.forEach((key, value) { req.response.headers.set(key, value); }); @@ -786,11 +1056,7 @@ class StreamableHTTPServerTransport return; } - final headers = { - HttpHeaders.contentTypeHeader: "text/event-stream; charset=utf-8", - HttpHeaders.cacheControlHeader: "no-cache, no-transform", - HttpHeaders.connectionHeader: "keep-alive", - }; + final headers = _sseResponseHeaders(); if (sessionId != null) { headers["mcp-session-id"] = sessionId!; @@ -859,6 +1125,14 @@ class StreamableHTTPServerTransport } } + Future _safeCloseSocket(Socket socket) async { + try { + await socket.close().timeout(const Duration(milliseconds: 100)); + } catch (e) { + socket.destroy(); + } + } + /// Writes an event to the SSE stream with proper formatting Future _writeSSEEvent( HttpResponse res, @@ -882,6 +1156,41 @@ class StreamableHTTPServerTransport } } + Future _detachSseSocket( + HttpRequest req, + Map headers, + ) async { + final socket = await req.response.detachSocket(writeHeaders: false); + final responseHeaders = { + ...headers, + HttpHeaders.connectionHeader: 'close', + }; + final responseHead = StringBuffer('HTTP/1.1 200 OK\r\n'); + responseHeaders.forEach((key, value) { + responseHead.write('$key: $value\r\n'); + }); + responseHead.write('\r\n'); + socket.add(utf8.encode(responseHead.toString())); + await socket.flush(); + return socket; + } + + Future _writeSSEEventToSocket( + Socket socket, + JsonRpcMessage message, + ) async { + try { + var eventData = "event: message\n"; + eventData += "data: ${jsonEncode(message.toJson())}\n\n"; + + socket.add(utf8.encode(eventData)); + await socket.flush(); + return true; + } catch (e) { + return false; + } + } + Future _writeSSEPrimingEvent( HttpResponse res, EventId eventId, @@ -896,6 +1205,16 @@ class StreamableHTTPServerTransport } } + Future _writeSSECommentToSocket(Socket socket) async { + try { + socket.add(utf8.encode(':\n\n')); + await socket.flush(); + return true; + } catch (e) { + return false; + } + } + Future _primeSseStream(StreamId streamId, HttpResponse res) async { try { final store = _eventStore; @@ -921,39 +1240,33 @@ class StreamableHTTPServerTransport } } + Future _primeSseSocket(Socket socket) async { + final sent = await _writeSSECommentToSocket(socket); + if (!sent) { + onerror?.call(StateError('Failed to send initial SSE comment')); + } + return sent; + } + /// Handles unsupported requests (PUT, PATCH, etc.) Future _handleUnsupportedRequest(HttpResponse res) async { - res.statusCode = HttpStatus.methodNotAllowed; res.headers.set(HttpHeaders.allowHeader, "GET, POST, DELETE"); - res.write( - jsonEncode( - JsonRpcError( - id: null, - error: JsonRpcErrorData( - code: ErrorCode.connectionClosed.value, - message: 'Method not allowed.', - ), - ).toJson(), - ), + await _writeJsonRpcErrorResponse( + res, + httpStatus: HttpStatus.methodNotAllowed, + errorCode: ErrorCode.connectionClosed, + message: 'Method not allowed.', ); - 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 _writeJsonRpcErrorResponse( + res, + httpStatus: HttpStatus.methodNotAllowed, + errorCode: ErrorCode.connectionClosed, + message: 'Method not allowed for stateless MCP requests.', ); - await _safeClose(res); } /// Handles POST requests containing JSON-RPC messages @@ -964,39 +1277,25 @@ class StreamableHTTPServerTransport // The client MUST include an Accept header, listing both application/json and text/event-stream as supported content types. if (!_acceptsMediaType(acceptedMediaTypes, 'application/json') || !_acceptsMediaType(acceptedMediaTypes, 'text/event-stream')) { - req.response.statusCode = HttpStatus.notAcceptable; - req.response.write( - jsonEncode( - JsonRpcError( - id: null, - error: JsonRpcErrorData( - code: ErrorCode.connectionClosed.value, - message: - 'Not Acceptable: Client must accept both application/json and text/event-stream', - ), - ).toJson(), - ), + await _writeJsonRpcErrorResponse( + req.response, + httpStatus: HttpStatus.notAcceptable, + errorCode: ErrorCode.connectionClosed, + message: + 'Not Acceptable: Client must accept both application/json and text/event-stream', ); - await _safeClose(req.response); return; } final contentType = req.headers.contentType?.value ?? ''; if (!contentType.contains("application/json")) { - req.response.statusCode = HttpStatus.unsupportedMediaType; - req.response.write( - jsonEncode( - JsonRpcError( - id: null, - error: JsonRpcErrorData( - code: ErrorCode.connectionClosed.value, - message: - 'Unsupported Media Type: Content-Type must be application/json', - ), - ).toJson(), - ), + await _writeJsonRpcErrorResponse( + req.response, + httpStatus: HttpStatus.unsupportedMediaType, + errorCode: ErrorCode.connectionClosed, + message: + 'Unsupported Media Type: Content-Type must be application/json', ); - await _safeClose(req.response); return; } @@ -1103,36 +1402,22 @@ class StreamableHTTPServerTransport return; } - req.response.statusCode = HttpStatus.badRequest; - req.response.write( - jsonEncode( - JsonRpcError( - id: null, - error: JsonRpcErrorData( - code: ErrorCode.invalidRequest.value, - message: 'Invalid Request: Server already initialized', - ), - ).toJson(), - ), + await _writeJsonRpcErrorResponse( + req.response, + httpStatus: HttpStatus.badRequest, + errorCode: ErrorCode.invalidRequest, + message: 'Invalid Request: Server already initialized', ); - await _safeClose(req.response); return; } if (messages.length > 1) { - req.response.statusCode = HttpStatus.badRequest; - req.response.write( - jsonEncode( - JsonRpcError( - id: null, - error: JsonRpcErrorData( - code: ErrorCode.invalidRequest.value, - message: - 'Invalid Request: Only one initialization request is allowed', - ), - ).toJson(), - ), + await _writeJsonRpcErrorResponse( + req.response, + httpStatus: HttpStatus.badRequest, + errorCode: ErrorCode.invalidRequest, + message: + 'Invalid Request: Only one initialization request is allowed', ); - await _safeClose(req.response); return; } @@ -1200,22 +1485,24 @@ class StreamableHTTPServerTransport // The default behavior is to use SSE streaming // but in some cases server will return JSON responses final streamId = generateUUID(); + Socket? responseSocket; if (!_enableJsonResponse) { - final headers = { - HttpHeaders.contentTypeHeader: "text/event-stream; charset=utf-8", - HttpHeaders.cacheControlHeader: "no-cache", - HttpHeaders.connectionHeader: "keep-alive", - }; + final headers = _sseResponseHeaders(); // After initialization, always include the session ID if we have one if (sessionId != null) { headers["mcp-session-id"] = sessionId!; } - req.response.statusCode = HttpStatus.ok; - headers.forEach((key, value) { - req.response.headers.set(key, value); - }); + if (isStatelessRequest) { + responseSocket = await _detachSseSocket(req, headers); + } else { + req.response.statusCode = HttpStatus.ok; + req.response.bufferOutput = false; + headers.forEach((key, value) { + req.response.headers.set(key, value); + }); + } } // Store the response for this request to send messages back through this connection @@ -1224,30 +1511,70 @@ class StreamableHTTPServerTransport if (_isJsonRpcRequest(message)) { final reqId = (message as JsonRpcRequest).id; _ownedStreamIds.add(streamId); - _streamMapping[streamId] = req.response; + if (responseSocket == null) { + _streamMapping[streamId] = req.response; + } else { + _responseStreamSockets[streamId] = responseSocket; + } _requestToStreamMapping[reqId] = streamId; + if (_isStatelessJsonRpcRequest(message)) { + _statelessRequestIds.add(reqId); + } } } - if (!_enableJsonResponse && - !await _primeSseStream(streamId, req.response)) { + final ssePrimed = _enableJsonResponse || + (responseSocket == null + ? await _primeSseStream( + streamId, + req.response, + ) + : await _primeSseSocket( + responseSocket, + )); + if (!ssePrimed) { _streamMapping.remove(streamId); + _responseStreamSockets.remove(streamId)?.destroy(); _ownedStreamIds.remove(streamId); for (final message in messages) { if (_isJsonRpcRequest(message)) { final reqId = (message as JsonRpcRequest).id; _requestToStreamMapping.remove(reqId); _requestResponseMap.remove(reqId); + _statelessRequestIds.remove(reqId); } } await _safeClose(req.response); return; } + var responseDoneHandled = false; + void handleResponseDone() { + if (responseDoneHandled) { + return; + } + responseDoneHandled = true; + if (_enableJsonResponse) { + _streamMapping.remove(streamId); + } else { + _handleResponseStreamClosed(streamId); + } + } + // Set up close handler for client disconnects - req.response.done.then((_) { - _streamMapping.remove(streamId); - }); + if (responseSocket == null) { + req.response.done.then( + (_) => handleResponseDone(), + onError: (Object _, StackTrace __) => handleResponseDone(), + ); + } else { + responseSocket.listen( + null, + onDone: handleResponseDone, + onError: (Object _, StackTrace __) => handleResponseDone(), + cancelOnError: true, + ); + } // Handle each message for (final message in messages) { @@ -1308,19 +1635,12 @@ class StreamableHTTPServerTransport Future _validateSession(HttpRequest req, HttpResponse res) async { if (!_initialized) { // If the server has not been initialized yet, reject all requests - res.statusCode = HttpStatus.badRequest; - res.write( - jsonEncode( - JsonRpcError( - id: null, - error: JsonRpcErrorData( - code: ErrorCode.connectionClosed.value, - message: 'Bad Request: Server not initialized', - ), - ).toJson(), - ), + await _writeJsonRpcErrorResponse( + res, + httpStatus: HttpStatus.badRequest, + errorCode: ErrorCode.connectionClosed, + message: 'Bad Request: Server not initialized', ); - await _safeClose(res); return false; } @@ -1334,35 +1654,21 @@ class StreamableHTTPServerTransport if (requestSessionId == null) { // Non-initialization requests without a session ID should return 400 Bad Request - res.statusCode = HttpStatus.badRequest; - res.write( - jsonEncode( - JsonRpcError( - id: null, - error: JsonRpcErrorData( - code: ErrorCode.connectionClosed.value, - message: 'Bad Request: Mcp-Session-Id header is required', - ), - ).toJson(), - ), + await _writeJsonRpcErrorResponse( + res, + httpStatus: HttpStatus.badRequest, + errorCode: ErrorCode.connectionClosed, + message: 'Bad Request: Mcp-Session-Id header is required', ); - await _safeClose(res); return false; } else if (_terminated || requestSessionId != sessionId) { // Reject terminated or invalid session IDs with 404 Not Found. - res.statusCode = HttpStatus.notFound; - res.write( - jsonEncode( - JsonRpcError( - id: null, - error: JsonRpcErrorData( - code: ErrorCode.connectionClosed.value, - message: 'Session not found', - ), - ).toJson(), - ), + await _writeJsonRpcErrorResponse( + res, + httpStatus: HttpStatus.notFound, + errorCode: ErrorCode.connectionClosed, + message: 'Session not found', ); - await _safeClose(res); return false; } @@ -1385,6 +1691,11 @@ class StreamableHTTPServerTransport for (final response in responses) { await _safeClose(response); } + final sockets = _responseStreamSockets.values.toList(); + for (final socket in sockets) { + await _safeCloseSocket(socket); + } + _responseStreamSockets.clear(); _streamMapping.clear(); _standaloneSseStreamIds.clear(); _standaloneSseResponses.clear(); @@ -1393,6 +1704,7 @@ class StreamableHTTPServerTransport // Clear any pending responses _requestResponseMap.clear(); _requestToStreamMapping.clear(); // Also clear this map + _statelessRequestIds.clear(); onclose?.call(); } @@ -1412,6 +1724,15 @@ class StreamableHTTPServerTransport requestId = _getMessageId(message); } + if (message is JsonRpcRequest && + requestId != null && + _statelessRequestIds.contains(requestId)) { + throw StateError( + "Cannot send JSON-RPC requests on stateless MCP response streams; " + "return an InputRequiredResult for client input instead.", + ); + } + // Check if this message should be sent on the standalone SSE stream (no request ID) // Ignore notifications from tools (which have relatedRequestId set) // Those will be sent via dedicated response SSE streams @@ -1456,25 +1777,52 @@ class StreamableHTTPServerTransport } final response = _streamMapping[streamId]; + final responseSocket = _responseStreamSockets[streamId]; + final isStatelessRequestStream = + requestId != null && _statelessRequestIds.contains(requestId); if (!_enableJsonResponse) { + if (response == null && responseSocket == null) { + if (isStatelessRequestStream) { + _handleResponseStreamClosed(streamId); + return; + } + } + // For SSE responses, generate event ID if event store is provided String? eventId; - if (_eventStore != null) { + if (_eventStore != null && !isStatelessRequestStream) { eventId = await _eventStore!.storeEvent(streamId, message); } - if (response != null) { + if (responseSocket != null) { + final sent = await _writeSSEEventToSocket( + responseSocket, + message, + ); + if (!sent && isStatelessRequestStream) { + _handleResponseStreamClosed(streamId); + return; + } + } else if (response != null) { // Write the event to the response stream - await _writeSSEEvent(response, message, eventId); + final sent = await _writeSSEEvent(response, message, eventId); + if (!sent && isStatelessRequestStream) { + _handleResponseStreamClosed(streamId); + return; + } } } if (_isJsonRpcResponse(message)) { + if (!_requestToStreamMapping.containsKey(requestId)) { + return; + } + _requestResponseMap[requestId] = message; final relatedIds = _requestToStreamMapping.entries - .where((entry) => _streamMapping[entry.value] == response) + .where((entry) => entry.value == streamId) .map((entry) => entry.key) .toList(); @@ -1484,7 +1832,7 @@ class StreamableHTTPServerTransport ); if (allResponsesReady) { - if (response == null) { + if (response == null && responseSocket == null) { throw StateError( "No connection established for request ID: $requestId", ); @@ -1504,31 +1852,79 @@ class StreamableHTTPServerTransport relatedIds.map((id) => _requestResponseMap[id]!).toList(); headers.forEach((key, value) { - response.headers.set(key, value); + response!.headers.set(key, value); }); if (responses.length == 1) { - response.write(jsonEncode(responses[0].toJson())); + response!.write(jsonEncode(responses[0].toJson())); } else { - response.write( + response!.write( jsonEncode(responses.map((r) => r.toJson()).toList()), ); } await _safeClose(response); + } else if (responseSocket != null) { + await _safeCloseSocket(responseSocket); } else { // End the SSE stream - await _safeClose(response); + await _safeClose(response!); } // Clean up + _responseStreamSockets.remove(streamId); for (final id in relatedIds) { _requestResponseMap.remove(id); _requestToStreamMapping.remove(id); + _statelessRequestIds.remove(id); } } } } + void _handleResponseStreamClosed(StreamId streamId) { + _responseStreamSockets.remove(streamId)?.destroy(); + final relatedIds = _requestToStreamMapping.entries + .where((entry) => entry.value == streamId) + .map((entry) => entry.key) + .toList(); + + _streamMapping.remove(streamId); + + final statelessIds = relatedIds + .where((requestId) => _statelessRequestIds.contains(requestId)) + .toList(); + if (statelessIds.isEmpty) { + return; + } + + for (final requestId in statelessIds) { + if (_requestResponseMap.containsKey(requestId)) { + continue; + } + + try { + onmessage?.call( + JsonRpcCancelledNotification( + cancelParams: CancelledNotification( + requestId: requestId, + reason: 'SSE response stream closed by client', + ), + ), + ); + } catch (error) { + onerror?.call( + error is Error ? error : StateError(error.toString()), + ); + } + } + + for (final requestId in statelessIds) { + _requestResponseMap.remove(requestId); + _requestToStreamMapping.remove(requestId); + _statelessRequestIds.remove(requestId); + } + } + /// Checks if a message is an initialize request bool _isInitializeRequest(JsonRpcMessage message) { if (message is JsonRpcRequest) { @@ -1571,3 +1967,30 @@ class StreamableHTTPServerTransport return null; } } + +enum _McpParamHeaderValidationError { name, value } + +class _McpParamHeader { + final String name; + final String suffix; + final String? value; + final _McpParamHeaderValidationError? validationError; + + const _McpParamHeader({ + required this.name, + required this.suffix, + required String this.value, + }) : validationError = null; + + const _McpParamHeader.invalidName({ + required this.name, + required this.suffix, + }) : value = null, + validationError = _McpParamHeaderValidationError.name; + + const _McpParamHeader.invalidValue({ + required this.name, + required this.suffix, + }) : value = null, + validationError = _McpParamHeaderValidationError.value; +} diff --git a/lib/src/server/streamable_mcp_server.dart b/lib/src/server/streamable_mcp_server.dart index a7ad4c8a..0200c682 100644 --- a/lib/src/server/streamable_mcp_server.dart +++ b/lib/src/server/streamable_mcp_server.dart @@ -419,6 +419,8 @@ class StreamableMcpServer { try { if (request.method == 'POST') { await _handlePostRequest(request); + } else if (_isStatelessProtocolVersionRequest(request)) { + await _createStatelessTransport().handleRequest(request); } else if (request.method == 'GET') { await _handleGetRequest(request); } else if (request.method == 'DELETE') { diff --git a/lib/src/shared/mcp_header_validation.dart b/lib/src/shared/mcp_header_validation.dart new file mode 100644 index 00000000..88ccd65b --- /dev/null +++ b/lib/src/shared/mcp_header_validation.dart @@ -0,0 +1,31 @@ +/// Validates an MCP parameter header suffix against RFC 9110 field-name token +/// syntax. +bool isValidMcpHeaderNameSuffix(String value) { + return value.isNotEmpty && value.codeUnits.every(isHttpFieldNameTokenChar); +} + +/// Returns whether [unit] is an RFC 9110 HTTP field-name token character. +bool isHttpFieldNameTokenChar(int unit) { + return unit >= 0x30 && unit <= 0x39 || + unit >= 0x41 && unit <= 0x5A || + unit >= 0x61 && unit <= 0x7A || + switch (unit) { + 0x21 || + 0x23 || + 0x24 || + 0x25 || + 0x26 || + 0x27 || + 0x2A || + 0x2B || + 0x2D || + 0x2E || + 0x5E || + 0x5F || + 0x60 || + 0x7C || + 0x7E => + true, + _ => false, + }; +} diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 36ca048f..7948820e 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -12,6 +12,12 @@ final _logger = Logger("mcp_dart.shared.protocol"); bool _isProgressToken(Object? token) => token is int || token is String; final _lastProgressByExtra = Expando(); +final _subscriptionStateByExtra = Expando<_SubscriptionStreamState>(); + +class _SubscriptionStreamState { + bool acknowledgmentSent = false; + SubscriptionFilter acknowledgedNotifications = const SubscriptionFilter(); +} /// Callback for progress notifications. typedef ProgressCallback = void Function(Progress progress); @@ -72,6 +78,12 @@ class RequestOptions { /// Maximum total time to wait for a response. final Duration? maxTotalTimeout; + /// Whether this request should use protocol-level timeout handling. + /// + /// Long-lived requests such as `subscriptions/listen` can disable this and + /// rely on explicit cancellation or transport closure instead. + final bool timeoutEnabled; + /// Augments the request with task creation parameters. final TaskCreation? task; @@ -85,6 +97,7 @@ class RequestOptions { this.timeout, this.resetTimeoutOnProgress = false, this.maxTotalTimeout, + this.timeoutEnabled = true, this.task, this.relatedTask, }); @@ -152,6 +165,16 @@ class RequestHandlerExtra { this.closeStandaloneSSEStream, }); + _SubscriptionStreamState get _activeSubscriptionState => + (_subscriptionStateByExtra[this] ??= _SubscriptionStreamState()); + + void _validateSubscriptionNotification(JsonRpcNotification notification) { + _recordOrValidateSubscriptionNotification( + _activeSubscriptionState, + notification, + ); + } + /// Sends a progress notification for the current request. /// /// This method automatically retrieves the `progressToken` from the request metadata. @@ -215,19 +238,71 @@ class RequestHandlerExtra { Future sendSubscriptionNotification( JsonRpcNotification notification, ) { - final meta = { - ...?notification.meta, - McpMetaKey.subscriptionId: requestId, - }; + final subscriptionNotification = + _withSubscriptionId(notification, requestId); - return sendNotification( - JsonRpcNotification( - method: notification.method, - params: notification.params, - meta: meta, - ), + _validateSubscriptionNotification(subscriptionNotification); + + return sendNotification(subscriptionNotification); + } +} + +JsonRpcNotification _withSubscriptionId( + JsonRpcNotification notification, + RequestId requestId, +) { + final meta = { + ...?notification.meta, + McpMetaKey.subscriptionId: requestId, + }; + return JsonRpcNotification( + method: notification.method, + params: notification.params, + meta: meta, + ); +} + +void _recordOrValidateSubscriptionNotification( + _SubscriptionStreamState state, + JsonRpcNotification notification, +) { + if (notification.method == Method.notificationsSubscriptionsAcknowledged) { + state + ..acknowledgmentSent = true + ..acknowledgedNotifications = + _acknowledgedSubscriptionFilter(notification); + return; + } + + if (!state.acknowledgmentSent) { + throw McpError( + ErrorCode.invalidRequest.value, + 'subscriptions/listen streams must send ' + '${Method.notificationsSubscriptionsAcknowledged} before ' + '${notification.method}.', + ); + } + + if (!state.acknowledgedNotifications.allowsNotification(notification)) { + throw McpError( + ErrorCode.invalidRequest.value, + '${notification.method} was not requested or acknowledged for this ' + 'subscriptions/listen stream.', + ); + } +} + +SubscriptionFilter _acknowledgedSubscriptionFilter( + JsonRpcNotification notification, +) { + final params = notification.params; + if (params == null) { + throw const FormatException( + 'subscriptions acknowledged notification params are required', ); } + + return SubscriptionsAcknowledgedNotification.fromJson(params).notifications; } /// Internal class holding timeout state for a request. @@ -399,6 +474,50 @@ abstract class Protocol { } } + /// Returns whether [resultType] is recognized for result parsing. + @protected + bool isRecognizedResultType(String resultType) { + return resultType == resultTypeComplete || + resultType == resultTypeInputRequired; + } + + bool _usesStatelessResultTypes(JsonRpcRequest request) { + final requestProtocolVersion = request.meta?[McpMetaKey.protocolVersion]; + if (requestProtocolVersion is String && + isStatelessProtocolVersion(requestProtocolVersion)) { + return true; + } + + final Object? activeTransport = _transport; + if (activeTransport is! ProtocolVersionAwareTransport) { + return false; + } + + final transportProtocolVersion = activeTransport.protocolVersion; + return transportProtocolVersion != null && + isStatelessProtocolVersion(transportProtocolVersion); + } + + void _validateResponseResultType( + JsonRpcRequest request, + Map resultJson, + ) { + if (!_usesStatelessResultTypes(request)) { + return; + } + + final resultType = resultJson['resultType']; + if (resultType == null) { + return; + } + if (resultType is! String) { + throw const FormatException('MCP resultType must be a string'); + } + if (!isRecognizedResultType(resultType)) { + throw FormatException('Unrecognized MCP resultType "$resultType"'); + } + } + void _registerTaskHandlers() { setRequestHandler( Method.tasksGet, @@ -506,6 +625,15 @@ abstract class Protocol { throw StateError("Protocol already connected to a transport."); } _transport = transport; + if (transport is IncomingRequestValidationAwareTransport) { + final validationAwareTransport = + transport as IncomingRequestValidationAwareTransport; + validationAwareTransport.setIncomingRequestValidator( + validateIncomingRequest, + ); + validationAwareTransport + .setRequestMethodSupported(_supportsRequestMethod); + } _transport!.onclose = _onclose; _transport!.onerror = _onerror; _transport!.onmessage = (message) { @@ -542,6 +670,9 @@ abstract class Protocol { } } + bool _supportsRequestMethod(String method) => + _requestHandlers.containsKey(method) || fallbackRequestHandler != null; + /// Gets the currently attached transport, or null if not connected. Transport? get transport => _transport; @@ -886,6 +1017,10 @@ abstract class Protocol { @protected void onIncomingRequestAccepted(JsonRpcRequest request) {} + /// Subclass hook called after an incoming notification has passed validation. + @protected + void onIncomingNotificationAccepted(JsonRpcNotification notification) {} + /// Subclass hook called after an incoming request handler has completed and /// its response has been sent or enqueued. @protected @@ -899,6 +1034,14 @@ abstract class Protocol { @protected void onIncomingRequestFailed(JsonRpcRequest request, Object error) {} + /// Converts a handler result into the JSON object sent on the wire. + @protected + Map serializeIncomingResult( + JsonRpcRequest request, + BaseResultData result, + ) => + result.toJson(); + /// Subclass hook called after protocol-owned state has been cleared for a /// closed transport. @protected @@ -912,6 +1055,8 @@ abstract class Protocol { return; } + onIncomingNotificationAccepted(notification); + if (notification is JsonRpcTaskStatusNotification) { _onTaskStatusNotification(notification); } @@ -1073,6 +1218,9 @@ abstract class Protocol { final abortController = BasicAbortController(); _requestHandlerAbortControllers[request.id] = abortController; + final subscriptionState = request is JsonRpcSubscriptionsListenRequest + ? _SubscriptionStreamState() + : null; final extra = RequestHandlerExtra( signal: abortController.signal, @@ -1090,12 +1238,22 @@ abstract class Protocol { : null, taskRequestedTtl: (request.params?['task'] as Map?)?['ttl'] as int?, - sendNotification: (notification, {relatedTask}) => - _notificationWithRequestId( - notification, - relatedTask: relatedTask, - relatedRequestId: request.id, - ), + sendNotification: (notification, {relatedTask}) { + var outgoingNotification = notification; + if (subscriptionState != null) { + outgoingNotification = _withSubscriptionId(notification, request.id); + _recordOrValidateSubscriptionNotification( + subscriptionState, + outgoingNotification, + ); + } + + return _notificationWithRequestId( + outgoingNotification, + relatedTask: relatedTask, + relatedRequestId: request.id, + ); + }, sendRequest: ( JsonRpcRequest req, T Function(Map) resultFactory, @@ -1107,6 +1265,7 @@ abstract class Protocol { timeout: options.timeout, resetTimeoutOnProgress: options.resetTimeoutOnProgress, maxTotalTimeout: options.maxTotalTimeout, + timeoutEnabled: options.timeoutEnabled, task: options.task, relatedTask: options.relatedTask ?? (relatedTaskId != null @@ -1121,6 +1280,9 @@ abstract class Protocol { ); }, ); + if (subscriptionState != null) { + _subscriptionStateByExtra[extra] = subscriptionState; + } // If task creation is requested, check capability if (extra.taskRequestedTtl != null || @@ -1159,7 +1321,7 @@ abstract class Protocol { final response = JsonRpcResponse( id: request.id, - result: result.toJson(), + result: serializeIncomingResult(request, result), meta: _mergeRelatedTaskMeta(result.meta, relatedTaskJson), ); @@ -1384,11 +1546,35 @@ abstract class Protocol { ); } + /// Reserves an outgoing integer request ID for APIs that need to correlate + /// side-channel data before the response arrives. + @protected + int reserveRequestId() => _requestMessageId++; + + /// Sends a request using a previously reserved outgoing integer request ID. + @protected + Future requestWithReservedId( + int requestId, + JsonRpcRequest requestData, + T Function(Map resultJson) resultFactory, [ + RequestOptions? options, + RequestId? relatedRequestId, + ]) { + return _requestWithRequestId( + requestData, + resultFactory, + options, + relatedRequestId, + requestId, + ); + } + Future _requestWithRequestId( JsonRpcRequest requestData, T Function(Map resultJson) resultFactory, [ RequestOptions? options, RequestId? relatedRequestId, + int? reservedRequestId, ]) { if (_transport == null) { return Future.error(StateError("Not connected to a transport.")); @@ -1411,7 +1597,7 @@ abstract class Protocol { return Future.error(e); } - final messageId = _requestMessageId++; + final messageId = reservedRequestId ?? _requestMessageId++; final completer = Completer(); Error? capturedError; Object? progressToken; @@ -1572,27 +1758,29 @@ abstract class Protocol { taskRequestState?.abortSubscription = abortSubscription; } - final timeoutDuration = options?.timeout ?? defaultRequestTimeout; - final maxTotalTimeoutDuration = options?.maxTotalTimeout; - void timeoutHandler() { - cancel( - McpError( - ErrorCode.requestTimeout.value, - "Request $messageId timed out after $timeoutDuration", - {'timeout': timeoutDuration.inMilliseconds}, - ), - fromTimeout: true, + if (options?.timeoutEnabled ?? true) { + final timeoutDuration = options?.timeout ?? defaultRequestTimeout; + final maxTotalTimeoutDuration = options?.maxTotalTimeout; + void timeoutHandler() { + cancel( + McpError( + ErrorCode.requestTimeout.value, + "Request $messageId timed out after $timeoutDuration", + {'timeout': timeoutDuration.inMilliseconds}, + ), + fromTimeout: true, + ); + } + + _setupTimeout( + messageId, + timeoutDuration, + maxTotalTimeoutDuration, + options?.resetTimeoutOnProgress ?? false, + timeoutHandler, ); } - _setupTimeout( - messageId, - timeoutDuration, - maxTotalTimeoutDuration, - options?.resetTimeoutOnProgress ?? false, - timeoutHandler, - ); - // Queue request if related to a task if (options?.relatedTask != null) { final relatedTaskId = options!.relatedTask!.taskId; @@ -1654,8 +1842,10 @@ abstract class Protocol { Object? taskCancellationError; late final T result; try { + final resultJson = response.toJson()['result'] as Map; + _validateResponseResultType(jsonrpcRequest, resultJson); result = resultFactory( - response.toJson()['result'] as Map, + resultJson, ); final state = taskRequestState; if (state != null) { diff --git a/lib/src/shared/transport.dart b/lib/src/shared/transport.dart index 3e8cf588..cf6df255 100644 --- a/lib/src/shared/transport.dart +++ b/lib/src/shared/transport.dart @@ -93,7 +93,10 @@ abstract class ProtocolVersionAwareTransport { set protocolVersion(String? value); } -/// Maps tool names to argument names and their `Mcp-Param-*` header suffixes. +/// Maps tool names to argument selectors and their `Mcp-Param-*` header suffixes. +/// +/// Top-level arguments use their argument name as the selector. Nested +/// arguments use JSON Pointer selectors such as `/auth/tenant`. typedef ToolParameterHeaderMappings = Map>; /// Optional capability for transports that can mirror tool arguments into @@ -104,3 +107,15 @@ abstract class ToolParameterHeaderAwareTransport { ToolParameterHeaderMappings mappings, ); } + +/// Optional capability for transports that can validate incoming requests +/// before committing transport-level response details. +abstract class IncomingRequestValidationAwareTransport { + /// Supplies the protocol-level request validator. + void setIncomingRequestValidator( + McpError? Function(JsonRpcRequest request) validator, + ); + + /// Supplies a live request-method support predicate. + void setRequestMethodSupported(bool Function(String method) isSupported); +} diff --git a/lib/src/types/completion.dart b/lib/src/types/completion.dart index 25cdb88b..1841ff94 100644 --- a/lib/src/types/completion.dart +++ b/lib/src/types/completion.dart @@ -260,13 +260,13 @@ class CompleteResult implements BaseResultData { 'Stable MCP 2025-11-25 does not define completion list-changed notifications.', ) class JsonRpcCompletionListChangedNotification extends JsonRpcNotification { - const JsonRpcCompletionListChangedNotification() + const JsonRpcCompletionListChangedNotification({super.meta}) : super(method: Method.notificationsExperimentalCompletionsListChanged); factory JsonRpcCompletionListChangedNotification.fromJson( Map json, ) => - const JsonRpcCompletionListChangedNotification(); + JsonRpcCompletionListChangedNotification(meta: extractRequestMeta(json)); } /// Deprecated alias for [CompleteRequest]. diff --git a/lib/src/types/initialization.dart b/lib/src/types/initialization.dart index 784c0330..e3208639 100644 --- a/lib/src/types/initialization.dart +++ b/lib/src/types/initialization.dart @@ -983,11 +983,11 @@ class DiscoverResult implements BaseResultData { /// Notification sent from the client to the server after initialization is finished. class JsonRpcInitializedNotification extends JsonRpcNotification { - const JsonRpcInitializedNotification() + const JsonRpcInitializedNotification({super.meta}) : super(method: Method.notificationsInitialized); factory JsonRpcInitializedNotification.fromJson(Map json) => - const JsonRpcInitializedNotification(); + JsonRpcInitializedNotification(meta: extractRequestMeta(json)); } /// Deprecated alias for [InitializeRequest]. diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index 80c8fab2..5fe7b407 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -540,6 +540,23 @@ abstract class BaseResultData { Map toJson(); } +/// Result data that carries MCP cache freshness hints. +abstract class CacheableResultData implements BaseResultData { + /// How long, in milliseconds, the client may consider this result fresh. + int? get ttlMs; + + /// Intended cache visibility: [CacheScope.public] or [CacheScope.private]. + String? get cacheScope; +} + +/// Allowed cache scopes for MCP cacheable results. +class CacheScope { + static const public = 'public'; + static const private = 'private'; + + const CacheScope._(); +} + /// Result type for completed MCP requests. const resultTypeComplete = 'complete'; diff --git a/lib/src/types/prompts.dart b/lib/src/types/prompts.dart index 59375ce3..0e4ed466 100644 --- a/lib/src/types/prompts.dart +++ b/lib/src/types/prompts.dart @@ -143,18 +143,32 @@ class JsonRpcListPromptsRequest extends JsonRpcRequest { } /// Result data for a successful `prompts/list` request. -class ListPromptsResult implements BaseResultData { +class ListPromptsResult implements CacheableResultData { /// The list of prompts/templates found. final List prompts; /// Opaque token for pagination. final Cursor? nextCursor; + /// How long, in milliseconds, the client may consider this result fresh. + @override + final int? ttlMs; + + /// Intended cache visibility: `public` or `private`. + @override + final String? cacheScope; + /// Optional metadata. @override final Map? meta; - const ListPromptsResult({required this.prompts, this.nextCursor, this.meta}); + const ListPromptsResult({ + required this.prompts, + this.nextCursor, + this.ttlMs, + this.cacheScope, + this.meta, + }); factory ListPromptsResult.fromJson(Map json) { final meta = json['_meta'] as Map?; @@ -167,16 +181,27 @@ class ListPromptsResult implements BaseResultData { .map((p) => Prompt.fromJson(p as Map)) .toList(), nextCursor: json['nextCursor'] as String?, + ttlMs: readOptionalTtlMs(json['ttlMs'], 'ListPromptsResult.ttlMs'), + cacheScope: readOptionalCacheScope( + json['cacheScope'], + 'ListPromptsResult.cacheScope', + ), meta: meta, ); } @override - Map toJson() => { - 'prompts': prompts.map((p) => p.toJson()).toList(), - if (nextCursor != null) 'nextCursor': nextCursor, - if (meta != null) '_meta': meta, - }; + Map toJson() { + validateTtlMs(ttlMs, 'ListPromptsResult.ttlMs'); + validateCacheScope(cacheScope, 'ListPromptsResult.cacheScope'); + return { + 'prompts': prompts.map((p) => p.toJson()).toList(), + if (nextCursor != null) 'nextCursor': nextCursor, + if (ttlMs != null) 'ttlMs': ttlMs, + if (cacheScope != null) 'cacheScope': cacheScope, + if (meta != null) '_meta': meta, + }; + } } /// Parameters for the `prompts/get` request. @@ -318,13 +343,13 @@ class GetPromptResult implements BaseResultData { /// Notification from server indicating the list of available prompts has changed. class JsonRpcPromptListChangedNotification extends JsonRpcNotification { - const JsonRpcPromptListChangedNotification() + const JsonRpcPromptListChangedNotification({super.meta}) : super(method: Method.notificationsPromptsListChanged); factory JsonRpcPromptListChangedNotification.fromJson( Map json, ) => - const JsonRpcPromptListChangedNotification(); + JsonRpcPromptListChangedNotification(meta: extractRequestMeta(json)); } /// Deprecated alias for [ListPromptsRequest]. diff --git a/lib/src/types/resources.dart b/lib/src/types/resources.dart index 650c84d4..a5c3baee 100644 --- a/lib/src/types/resources.dart +++ b/lib/src/types/resources.dart @@ -261,13 +261,21 @@ class JsonRpcListResourcesRequest extends JsonRpcRequest { } /// Result data for a successful `resources/list` request. -class ListResourcesResult implements BaseResultData { +class ListResourcesResult implements CacheableResultData { /// The list of resources found. final List resources; /// Opaque token for pagination, indicating more results might be available. final Cursor? nextCursor; + /// How long, in milliseconds, the client may consider this result fresh. + @override + final int? ttlMs; + + /// Intended cache visibility: `public` or `private`. + @override + final String? cacheScope; + /// Optional metadata. @override final Map? meta; @@ -276,6 +284,8 @@ class ListResourcesResult implements BaseResultData { const ListResourcesResult({ required this.resources, this.nextCursor, + this.ttlMs, + this.cacheScope, this.meta, }); @@ -291,17 +301,28 @@ class ListResourcesResult implements BaseResultData { .map((e) => Resource.fromJson(e as Map)) .toList(), nextCursor: json['nextCursor'] as String?, + ttlMs: readOptionalTtlMs(json['ttlMs'], 'ListResourcesResult.ttlMs'), + cacheScope: readOptionalCacheScope( + json['cacheScope'], + 'ListResourcesResult.cacheScope', + ), meta: meta, ); } /// Converts to JSON (excluding meta). @override - Map toJson() => { - 'resources': resources.map((r) => r.toJson()).toList(), - if (nextCursor != null) 'nextCursor': nextCursor, - if (meta != null) '_meta': meta, - }; + Map toJson() { + validateTtlMs(ttlMs, 'ListResourcesResult.ttlMs'); + validateCacheScope(cacheScope, 'ListResourcesResult.cacheScope'); + return { + 'resources': resources.map((r) => r.toJson()).toList(), + if (nextCursor != null) 'nextCursor': nextCursor, + if (ttlMs != null) 'ttlMs': ttlMs, + if (cacheScope != null) 'cacheScope': cacheScope, + if (meta != null) '_meta': meta, + }; + } } /// Parameters for the `resources/templates/list` request. Includes pagination. @@ -347,19 +368,29 @@ class JsonRpcListResourceTemplatesRequest extends JsonRpcRequest { } /// Result data for a successful `resources/templates/list` request. -class ListResourceTemplatesResult implements BaseResultData { +class ListResourceTemplatesResult implements CacheableResultData { /// The list of resource templates found. final List resourceTemplates; /// Opaque token for pagination. final Cursor? nextCursor; + /// How long, in milliseconds, the client may consider this result fresh. + @override + final int? ttlMs; + + /// Intended cache visibility: `public` or `private`. + @override + final String? cacheScope; + @override final Map? meta; const ListResourceTemplatesResult({ required this.resourceTemplates, this.nextCursor, + this.ttlMs, + this.cacheScope, this.meta, }); @@ -376,16 +407,30 @@ class ListResourceTemplatesResult implements BaseResultData { .map((e) => ResourceTemplate.fromJson(e as Map)) .toList(), nextCursor: json['nextCursor'] as String?, + ttlMs: readOptionalTtlMs( + json['ttlMs'], + 'ListResourceTemplatesResult.ttlMs', + ), + cacheScope: readOptionalCacheScope( + json['cacheScope'], + 'ListResourceTemplatesResult.cacheScope', + ), meta: meta, ); } @override - Map toJson() => { - 'resourceTemplates': resourceTemplates.map((t) => t.toJson()).toList(), - if (nextCursor != null) 'nextCursor': nextCursor, - if (meta != null) '_meta': meta, - }; + Map toJson() { + validateTtlMs(ttlMs, 'ListResourceTemplatesResult.ttlMs'); + validateCacheScope(cacheScope, 'ListResourceTemplatesResult.cacheScope'); + return { + 'resourceTemplates': resourceTemplates.map((t) => t.toJson()).toList(), + if (nextCursor != null) 'nextCursor': nextCursor, + if (ttlMs != null) 'ttlMs': ttlMs, + if (cacheScope != null) 'cacheScope': cacheScope, + if (meta != null) '_meta': meta, + }; + } } /// Parameters for the `resources/read` request. @@ -452,14 +497,27 @@ class JsonRpcReadResourceRequest extends JsonRpcRequest { } /// Result data for a successful `resources/read` request. -class ReadResourceResult implements BaseResultData { +class ReadResourceResult implements CacheableResultData { /// The contents of the resource (can be multiple parts). final List contents; + /// How long, in milliseconds, the client may consider this result fresh. + @override + final int? ttlMs; + + /// Intended cache visibility: `public` or `private`. + @override + final String? cacheScope; + @override final Map? meta; - const ReadResourceResult({required this.contents, this.meta}); + const ReadResourceResult({ + required this.contents, + this.ttlMs, + this.cacheScope, + this.meta, + }); factory ReadResourceResult.fromJson(Map json) { final meta = json['_meta'] as Map?; @@ -471,26 +529,37 @@ class ReadResourceResult implements BaseResultData { contents: contents .map((e) => ResourceContents.fromJson(e as Map)) .toList(), + ttlMs: readOptionalTtlMs(json['ttlMs'], 'ReadResourceResult.ttlMs'), + cacheScope: readOptionalCacheScope( + json['cacheScope'], + 'ReadResourceResult.cacheScope', + ), meta: meta, ); } @override - Map toJson() => { - 'contents': contents.map((c) => c.toJson()).toList(), - if (meta != null) '_meta': meta, - }; + Map toJson() { + validateTtlMs(ttlMs, 'ReadResourceResult.ttlMs'); + validateCacheScope(cacheScope, 'ReadResourceResult.cacheScope'); + return { + 'contents': contents.map((c) => c.toJson()).toList(), + if (ttlMs != null) 'ttlMs': ttlMs, + if (cacheScope != null) 'cacheScope': cacheScope, + if (meta != null) '_meta': meta, + }; + } } /// Notification from server indicating the list of available resources has changed. class JsonRpcResourceListChangedNotification extends JsonRpcNotification { - const JsonRpcResourceListChangedNotification() + const JsonRpcResourceListChangedNotification({super.meta}) : super(method: Method.notificationsResourcesListChanged); factory JsonRpcResourceListChangedNotification.fromJson( Map json, ) => - const JsonRpcResourceListChangedNotification(); + JsonRpcResourceListChangedNotification(meta: extractRequestMeta(json)); } /// Parameters for the `resources/subscribe` request. diff --git a/lib/src/types/roots.dart b/lib/src/types/roots.dart index 3bcf9261..e329142d 100644 --- a/lib/src/types/roots.dart +++ b/lib/src/types/roots.dart @@ -82,11 +82,11 @@ class ListRootsResult implements BaseResultData { /// Notification from client indicating the list of roots has changed. class JsonRpcRootsListChangedNotification extends JsonRpcNotification { - const JsonRpcRootsListChangedNotification() + const JsonRpcRootsListChangedNotification({super.meta}) : super(method: Method.notificationsRootsListChanged); factory JsonRpcRootsListChangedNotification.fromJson( Map json, ) => - const JsonRpcRootsListChangedNotification(); + JsonRpcRootsListChangedNotification(meta: extractRequestMeta(json)); } diff --git a/lib/src/types/subscriptions.dart b/lib/src/types/subscriptions.dart index 5680f052..bcdecd66 100644 --- a/lib/src/types/subscriptions.dart +++ b/lib/src/types/subscriptions.dart @@ -76,6 +76,50 @@ class SubscriptionFilter { ); } + /// Whether this filter is a subset of [requested]. + bool isSubsetOf(SubscriptionFilter requested) { + if (toolsListChanged == true && requested.toolsListChanged != true) { + return false; + } + if (promptsListChanged == true && requested.promptsListChanged != true) { + return false; + } + if (resourcesListChanged == true && + requested.resourcesListChanged != true) { + return false; + } + if (!_stringListSubsetOf( + resourceSubscriptions, + requested.resourceSubscriptions, + )) { + return false; + } + if (!_stringListSubsetOf(taskIds, requested.taskIds)) { + return false; + } + return true; + } + + /// Whether this acknowledged filter allows [notification]. + bool allowsNotification(JsonRpcNotification notification) { + switch (notification.method) { + case Method.notificationsToolsListChanged: + return toolsListChanged == true; + case Method.notificationsPromptsListChanged: + return promptsListChanged == true; + case Method.notificationsResourcesListChanged: + return resourcesListChanged == true; + case Method.notificationsResourcesUpdated: + final uri = notification.params?['uri']; + return uri is String && (resourceSubscriptions?.contains(uri) ?? false); + case Method.notificationsTasks: + final taskId = notification.params?['taskId']; + return taskId is String && (taskIds?.contains(taskId) ?? false); + default: + return false; + } + } + Map toJson() => { if (toolsListChanged != null) 'toolsListChanged': toolsListChanged, if (promptsListChanged != null) @@ -233,6 +277,17 @@ List? _readOptionalStringList(Object? value, String field) { return value.cast(); } +bool _stringListSubsetOf(List? subset, List? superset) { + if (subset == null || subset.isEmpty) { + return true; + } + final allowed = superset?.toSet(); + if (allowed == null) { + return false; + } + return subset.every(allowed.contains); +} + Map? _readOptionalJsonObject(Object? value, String field) { if (value == null) { return null; diff --git a/lib/src/types/tools.dart b/lib/src/types/tools.dart index 9394c44d..57da2dc2 100644 --- a/lib/src/types/tools.dart +++ b/lib/src/types/tools.dart @@ -271,13 +271,21 @@ class ListToolsRequest { typedef ListToolsRequestParams = ListToolsRequest; /// The server's response to a [ListToolsRequest]. -class ListToolsResult implements BaseResultData { +class ListToolsResult implements CacheableResultData { /// A list of tools. final List tools; /// An opaque token for pagination. final String? nextCursor; + /// How long, in milliseconds, the client may consider this result fresh. + @override + final int? ttlMs; + + /// Intended cache visibility: `public` or `private`. + @override + final String? cacheScope; + /// Optional metadata. @override final Map? meta; @@ -285,6 +293,8 @@ class ListToolsResult implements BaseResultData { const ListToolsResult({ required this.tools, this.nextCursor, + this.ttlMs, + this.cacheScope, this.meta, }); @@ -297,16 +307,27 @@ class ListToolsResult implements BaseResultData { tools: tools.map((e) => Tool.fromJson(e as Map)).toList(), nextCursor: json['nextCursor'] as String?, + ttlMs: readOptionalTtlMs(json['ttlMs'], 'ListToolsResult.ttlMs'), + cacheScope: readOptionalCacheScope( + json['cacheScope'], + 'ListToolsResult.cacheScope', + ), meta: json['_meta'] as Map?, ); } @override - Map toJson() => { - 'tools': tools.map((e) => e.toJson()).toList(), - if (nextCursor != null) 'nextCursor': nextCursor, - if (meta != null) '_meta': meta, - }; + Map toJson() { + validateTtlMs(ttlMs, 'ListToolsResult.ttlMs'); + validateCacheScope(cacheScope, 'ListToolsResult.cacheScope'); + return { + 'tools': tools.map((e) => e.toJson()).toList(), + if (nextCursor != null) 'nextCursor': nextCursor, + if (ttlMs != null) 'ttlMs': ttlMs, + if (cacheScope != null) 'cacheScope': cacheScope, + if (meta != null) '_meta': meta, + }; + } } @Deprecated('Use [CallToolRequest] instead.') @@ -435,13 +456,13 @@ class CallToolResult implements BaseResultData { /// Notification from server indicating the list of available tools has changed. class JsonRpcToolListChangedNotification extends JsonRpcNotification { - const JsonRpcToolListChangedNotification() + const JsonRpcToolListChangedNotification({super.meta}) : super(method: Method.notificationsToolsListChanged); factory JsonRpcToolListChangedNotification.fromJson( Map json, ) => - const JsonRpcToolListChangedNotification(); + JsonRpcToolListChangedNotification(meta: extractRequestMeta(json)); } void _validateObjectRootSchema( diff --git a/lib/src/types/validation.dart b/lib/src/types/validation.dart index 89f2bc8f..ee29376b 100644 --- a/lib/src/types/validation.dart +++ b/lib/src/types/validation.dart @@ -43,3 +43,48 @@ String? readOptionalString(Object? value, String field) { } throw FormatException('$field must be a string'); } + +int? readOptionalTtlMs(Object? value, String field) { + final ttlMs = readOptionalInteger(value, field); + if (ttlMs == null) { + return null; + } + return ttlMs < 0 ? 0 : ttlMs; +} + +void validateTtlMs(int? value, String field) { + if (value == null) { + return; + } + if (value < 0) { + throw ArgumentError.value( + value, + field, + 'must be greater than or equal to 0', + ); + } +} + +String? readOptionalCacheScope(Object? value, String field) { + final scope = readOptionalString(value, field); + if (scope == null) { + return null; + } + if (scope == 'public' || scope == 'private') { + return scope; + } + throw FormatException('$field must be either "public" or "private"'); +} + +void validateCacheScope(String? value, String field) { + if (value == null) { + return; + } + if (value != 'public' && value != 'private') { + throw ArgumentError.value( + value, + field, + 'must be either "public" or "private"', + ); + } +} diff --git a/test/client/client_tool_validation_test.dart b/test/client/client_tool_validation_test.dart index 493aab93..13879310 100644 --- a/test/client/client_tool_validation_test.dart +++ b/test/client/client_tool_validation_test.dart @@ -150,9 +150,22 @@ void main() { inputSchema: JsonSchema.object( properties: { 'region': JsonSchema.string(mcpHeader: 'Region'), - 'limit': JsonSchema.number(mcpHeader: 'Limit'), + 'limit': JsonSchema.integer(mcpHeader: 'Limit'), 'dryRun': JsonSchema.boolean(mcpHeader: 'Dry-Run'), 'count': JsonSchema.integer(mcpHeader: 'Count'), + 'auth': JsonSchema.object( + properties: { + 'tenant': JsonSchema.string(mcpHeader: 'Tenant'), + }, + ), + }, + ), + ), + Tool( + name: 'number_header', + inputSchema: JsonSchema.object( + properties: { + 'ratio': JsonSchema.number(mcpHeader: 'Ratio'), }, ), ), @@ -173,6 +186,14 @@ void main() { }, ), ), + Tool( + name: 'separator_header', + inputSchema: JsonSchema.object( + properties: { + 'region': JsonSchema.string(mcpHeader: 'Bad/Header'), + }, + ), + ), Tool.fromJson({ 'name': 'non_string_header', 'inputSchema': { @@ -210,11 +231,12 @@ void main() { 'limit': 'Limit', 'dryRun': 'Dry-Run', 'count': 'Count', + '/auth/tenant': 'Tenant', }, }); expect( warnings.where((message) => message.contains('Rejecting tool')), - hasLength(4), + hasLength(6), ); }); diff --git a/test/client/streamable_https_test.dart b/test/client/streamable_https_test.dart index da40e2e3..cfe885ab 100644 --- a/test/client/streamable_https_test.dart +++ b/test/client/streamable_https_test.dart @@ -1069,9 +1069,11 @@ void main() { request.headers.value('mcp-protocol-version'); capturedHeaders['method'] = request.headers.value('mcp-method'); capturedHeaders['name'] = request.headers.value('mcp-name'); + capturedHeaders['session'] = request.headers.value('mcp-session-id'); await request.drain(); request.response ..statusCode = HttpStatus.ok + ..headers.set('mcp-session-id', 'ignored-stateless-session') ..headers.contentType = ContentType.json ..write( jsonEncode( @@ -1086,6 +1088,9 @@ void main() { transport = StreamableHttpClientTransport( Uri.parse('http://localhost:${server.port}/mcp'), + opts: const StreamableHttpClientTransportOptions( + sessionId: 'legacy-session', + ), )..protocolVersion = draftProtocolVersion2026_07_28; await transport.start(); @@ -1110,6 +1115,8 @@ void main() { ); expect(capturedHeaders['method'], Method.toolsCall); expect(capturedHeaders['name'], 'echo'); + expect(capturedHeaders['session'], isNull); + expect(transport.sessionId, 'legacy-session'); }); test('send maps 2026 stateless headers for standard request types', @@ -1240,6 +1247,94 @@ void main() { expect(capturedHeaders['name'], 'task-1'); }); + test('stateless SSE responses reject server-initiated requests', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + await request.drain(); + final serverRequest = const JsonRpcRequest( + id: 99, + method: Method.rootsList, + ); + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType('text', 'event-stream') + ..write('data: ${jsonEncode(serverRequest.toJson())}\n\n'); + await request.response.close(); + }); + + transport = StreamableHttpClientTransport( + Uri.parse('http://localhost:${server.port}/mcp'), + )..protocolVersion = draftProtocolVersion2026_07_28; + await transport.start(); + + final errorCompleter = Completer(); + final messages = []; + transport + ..onmessage = messages.add + ..onerror = (error) { + if (!errorCompleter.isCompleted) { + errorCompleter.complete(error); + } + }; + + await transport.send( + JsonRpcListToolsRequest(id: 1, meta: _statelessMeta()), + ); + + final error = await errorCompleter.future.timeout( + const Duration(seconds: 5), + ); + expect(error, isA()); + expect((error as McpError).code, ErrorCode.invalidRequest.value); + expect(error.message, contains('input_required')); + expect(messages, isEmpty); + }); + + test('stateless JSON responses reject server-initiated requests', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + await request.drain(); + final serverRequest = const JsonRpcRequest( + id: 99, + method: Method.rootsList, + ); + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write(jsonEncode([serverRequest.toJson()])); + await request.response.close(); + }); + + transport = StreamableHttpClientTransport( + Uri.parse('http://localhost:${server.port}/mcp'), + )..protocolVersion = draftProtocolVersion2026_07_28; + await transport.start(); + + final errorCompleter = Completer(); + final messages = []; + transport + ..onmessage = messages.add + ..onerror = (error) { + if (!errorCompleter.isCompleted) { + errorCompleter.complete(error); + } + }; + + await transport.send( + JsonRpcListToolsRequest(id: 1, meta: _statelessMeta()), + ); + + final error = await errorCompleter.future.timeout( + const Duration(seconds: 5), + ); + expect(error, isA()); + expect((error as McpError).code, ErrorCode.invalidRequest.value); + expect(error.message, contains('inputRequests')); + expect(messages, isEmpty); + }); + test('send mirrors mapped tool parameters into 2026 stateless headers', () async { final capturedHeaders = {}; @@ -1250,9 +1345,15 @@ void main() { capturedHeaders['greeting'] = request.headers.value('mcp-param-greeting'); capturedHeaders['limit'] = request.headers.value('mcp-param-limit'); + capturedHeaders['rounded'] = request.headers.value('mcp-param-rounded'); + capturedHeaders['unsafe'] = request.headers.value('mcp-param-unsafe'); + capturedHeaders['ratio'] = request.headers.value('mcp-param-ratio'); 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'); + capturedHeaders['sentinel'] = + request.headers.value('mcp-param-sentinel'); + capturedHeaders['tenant'] = request.headers.value('mcp-param-tenant'); await request.drain(); request.response ..statusCode = HttpStatus.ok @@ -1278,9 +1379,14 @@ void main() { 'region': 'Region', 'greeting': 'Greeting', 'limit': 'Limit', + 'rounded': 'Rounded', + 'unsafe': 'Unsafe', + 'ratio': 'Ratio', 'dryRun': 'Dry-Run', 'text': 'Text', 'payload': 'Payload', + 'sentinel': 'Sentinel', + '/auth/tenant': 'Tenant', }, }, ); @@ -1298,9 +1404,14 @@ void main() { 'region': 'us-west1', 'greeting': 'Hello, ไธ–็•Œ', 'limit': 42, + 'rounded': 42.0, + 'unsafe': 9007199254740992, + 'ratio': 1.5, 'dryRun': false, 'text': ' padded ', 'payload': {'nested': true}, + 'sentinel': '=?base64?YWJj?=', + 'auth': {'tenant': 'acme'}, }, }, meta: _statelessMeta(), @@ -1314,9 +1425,17 @@ void main() { '=?base64?${base64Encode(utf8.encode('Hello, ไธ–็•Œ'))}?=', ); expect(capturedHeaders['limit'], '42'); + expect(capturedHeaders['rounded'], '42'); + expect(capturedHeaders['unsafe'], isNull); + expect(capturedHeaders['ratio'], isNull); expect(capturedHeaders['dryRun'], 'false'); expect(capturedHeaders['text'], '=?base64?IHBhZGRlZCA=?='); expect(capturedHeaders['payload'], isNull); + expect( + capturedHeaders['sentinel'], + '=?base64?${base64Encode(utf8.encode('=?base64?YWJj?='))}?=', + ); + expect(capturedHeaders['tenant'], 'acme'); }); test('send with initialized notification triggers SSE establishment', @@ -1990,6 +2109,72 @@ void main() { expect(true, isTrue); }); + test('stateless protocol does not send DELETE for session termination', + () async { + var deleteRequests = 0; + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + if (request.method == 'DELETE') { + deleteRequests += 1; + } + request.response.statusCode = HttpStatus.ok; + await request.response.close(); + }); + + transport = StreamableHttpClientTransport( + Uri.parse('http://localhost:${server.port}/mcp'), + opts: const StreamableHttpClientTransportOptions( + sessionId: 'legacy-session', + ), + ); + transport.protocolVersion = draftProtocolVersion2026_07_28; + await transport.start(); + + await transport.terminateSession(); + + expect(deleteRequests, 0); + expect(transport.sessionId, isNull); + }); + + test('stateless protocol does not open legacy GET SSE after initialized', + () async { + var getRequests = 0; + var postRequests = 0; + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + if (request.method == 'GET') { + getRequests += 1; + request.response.statusCode = HttpStatus.methodNotAllowed; + await request.response.close(); + return; + } + + if (request.method == 'POST') { + postRequests += 1; + request.response.statusCode = HttpStatus.accepted; + await request.response.close(); + return; + } + + request.response.statusCode = HttpStatus.methodNotAllowed; + await request.response.close(); + }); + + transport = StreamableHttpClientTransport( + Uri.parse('http://localhost:${server.port}/mcp'), + ); + transport.protocolVersion = draftProtocolVersion2026_07_28; + await transport.start(); + + await transport.send(const JsonRpcInitializedNotification()); + await Future.delayed(const Duration(milliseconds: 100)); + + expect(postRequests, 1); + expect(getRequests, 0); + }); + test('handles error callback configuration', () async { transport = StreamableHttpClientTransport(serverUrl); diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 65b8ed42..84458e85 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -42,9 +42,19 @@ class DiscoveringClientTransport extends Transport implements ProtocolVersionAwareTransport { DiscoveringClientTransport({ this.discoverVersions = const [draftProtocolVersion2026_07_28], + this.unsupportedDiscoverProtocolVersions = const [], + this.unsupportedDiscoverData, + this.capabilities = const ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + this.toolsListResult = const {'tools': []}, }); final List discoverVersions; + final List unsupportedDiscoverProtocolVersions; + final Object? unsupportedDiscoverData; + final ServerCapabilities capabilities; + final Map toolsListResult; final List sentMessages = []; @override @@ -63,14 +73,34 @@ class DiscoveringClientTransport extends Transport sentMessages.add(message); if (message is JsonRpcRequest && message.method == Method.serverDiscover) { + final requestedProtocolVersion = + message.meta?[McpMetaKey.protocolVersion]; + if (unsupportedDiscoverProtocolVersions.contains( + requestedProtocolVersion, + )) { + onmessage?.call( + JsonRpcError( + id: message.id, + error: JsonRpcErrorData( + code: ErrorCode.unsupportedProtocolVersion.value, + message: 'Unsupported protocol version', + data: unsupportedDiscoverData ?? + { + 'supported': discoverVersions, + 'requested': requestedProtocolVersion, + }, + ), + ), + ); + return; + } + onmessage?.call( JsonRpcResponse( id: message.id, result: DiscoverResult( supportedVersions: discoverVersions, - capabilities: const ServerCapabilities( - tools: ServerCapabilitiesTools(), - ), + capabilities: capabilities, serverInfo: const Implementation(name: 'server', version: '1.0.0'), ).toJson(), ), @@ -82,7 +112,7 @@ class DiscoveringClientTransport extends Transport onmessage?.call( JsonRpcResponse( id: message.id, - result: const ListToolsResult(tools: []).toJson(), + result: toolsListResult, ), ); } @@ -94,6 +124,11 @@ class DiscoveringClientTransport extends Transport class LegacyFallbackTransport extends Transport implements ProtocolVersionAwareTransport { + LegacyFallbackTransport({ + this.toolsListResult = const {'tools': []}, + }); + + final Map toolsListResult; final List sentMessages = []; @override @@ -137,6 +172,16 @@ class LegacyFallbackTransport extends Transport ).toJson(), ), ); + return; + } + + if (message is JsonRpcRequest && message.method == Method.toolsList) { + onmessage?.call( + JsonRpcResponse( + id: message.id, + result: toolsListResult, + ), + ); } } @@ -189,11 +234,13 @@ class CompletedTaskHandler extends CancelTaskResultHandler { Map _clientMeta({ String? protocolVersion, ClientCapabilities clientCapabilities = const ClientCapabilities(), + Object? logLevel, }) { return buildProtocolRequestMeta( protocolVersion: protocolVersion ?? draftProtocolVersion2026_07_28, clientInfo: const Implementation(name: 'client', version: '1.0.0'), clientCapabilities: clientCapabilities, + logLevel: logLevel, ); } @@ -268,6 +315,91 @@ void main() { ); }); + test('serializes cacheable result hints without changing legacy defaults', + () { + final toolsJson = const ListToolsResult( + tools: [], + ttlMs: 300000, + cacheScope: CacheScope.public, + ).toJson(); + expect(toolsJson['ttlMs'], 300000); + expect(toolsJson['cacheScope'], CacheScope.public); + expect(toolsJson, isNot(contains('resultType'))); + final parsedTools = ListToolsResult.fromJson(toolsJson); + expect(parsedTools.ttlMs, 300000); + expect(parsedTools.cacheScope, CacheScope.public); + + final promptsJson = const ListPromptsResult( + prompts: [], + ttlMs: 600000, + cacheScope: CacheScope.private, + ).toJson(); + expect(ListPromptsResult.fromJson(promptsJson).ttlMs, 600000); + expect( + ListPromptsResult.fromJson(promptsJson).cacheScope, + CacheScope.private, + ); + + final resourcesJson = const ListResourcesResult( + resources: [], + ttlMs: 120000, + cacheScope: CacheScope.public, + ).toJson(); + expect(ListResourcesResult.fromJson(resourcesJson).ttlMs, 120000); + expect( + ListResourcesResult.fromJson(resourcesJson).cacheScope, + CacheScope.public, + ); + + final templatesJson = const ListResourceTemplatesResult( + resourceTemplates: [], + ttlMs: 30000, + cacheScope: CacheScope.public, + ).toJson(); + expect( + ListResourceTemplatesResult.fromJson(templatesJson).ttlMs, + 30000, + ); + expect( + ListResourceTemplatesResult.fromJson(templatesJson).cacheScope, + CacheScope.public, + ); + + final readJson = const ReadResourceResult( + contents: [TextResourceContents(uri: 'file:///a.txt', text: 'a')], + ttlMs: 60000, + cacheScope: CacheScope.private, + ).toJson(); + expect(ReadResourceResult.fromJson(readJson).ttlMs, 60000); + expect( + ReadResourceResult.fromJson(readJson).cacheScope, + CacheScope.private, + ); + + expect(const ListToolsResult(tools: []).toJson(), {'tools': []}); + expect( + ListToolsResult.fromJson(const {'tools': [], 'ttlMs': -1}).ttlMs, + 0, + ); + expect( + () => ListToolsResult.fromJson( + const {'tools': [], 'cacheScope': 'shared'}, + ), + throwsFormatException, + ); + expect( + () => const ListToolsResult(tools: [], ttlMs: -1).toJson(), + throwsArgumentError, + ); + expect( + () => const ListToolsResult( + tools: [], + cacheScope: 'shared', + ).toJson(), + throwsArgumentError, + ); + }); + test('serializes MRTR input required results', () { final result = InputRequiredResult( inputRequests: { @@ -504,6 +636,216 @@ void main() { expect(transport.sentMessages.last, isA()); }); + test('server rejects subscription notifications before acknowledgment', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(listChanged: true), + ), + ), + ); + server.setRequestHandler( + Method.subscriptionsListen, + (request, extra) async { + await extra.sendNotification( + const JsonRpcToolListChangedNotification(), + ); + 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), + ), + meta: _clientMeta(), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect(response.error.code, ErrorCode.invalidRequest.value); + expect( + response.error.message, + contains(Method.notificationsSubscriptionsAcknowledged), + ); + }); + + test('server tags direct subscription notifications with subscription id', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(listChanged: true), + ), + ), + ); + server.setRequestHandler( + Method.subscriptionsListen, + (request, extra) async { + await extra.sendSubscriptionAcknowledged( + request.listenParams.notifications.acknowledgedBy( + const ServerCapabilities( + tools: ServerCapabilitiesTools(listChanged: true), + ), + ), + ); + await extra.sendNotification( + const JsonRpcToolListChangedNotification(), + ); + 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), + ), + meta: _clientMeta(), + ), + ); + await _pump(); + + expect(transport.sentMessages, hasLength(3)); + expect( + transport.sentMessages.take(2).map((message) => message.toJson()), + everyElement( + containsPair( + 'params', + containsPair( + '_meta', + containsPair(McpMetaKey.subscriptionId, 'sub-1'), + ), + ), + ), + ); + expect( + (transport.sentMessages[1] as JsonRpcNotification).method, + Method.notificationsToolsListChanged, + ); + expect(transport.sentMessages.last, isA()); + }); + + test('stateless server responses add complete result and cache defaults', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + prompts: ServerCapabilitiesPrompts(), + resources: ServerCapabilitiesResources(), + tools: ServerCapabilitiesTools(), + ), + ), + ); + server.setRequestHandler( + Method.toolsList, + (request, extra) async => const ListToolsResult( + tools: [], + ttlMs: 300000, + cacheScope: CacheScope.public, + ), + (id, params, meta) => JsonRpcListToolsRequest.fromJson({ + 'id': id, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + server.setRequestHandler( + Method.promptsList, + (request, extra) async => const ListPromptsResult(prompts: []), + (id, params, meta) => JsonRpcListPromptsRequest.fromJson({ + 'id': id, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + server.setRequestHandler( + Method.resourcesList, + (request, extra) async => const ListResourcesResult(resources: []), + (id, params, meta) => JsonRpcListResourcesRequest.fromJson({ + 'id': id, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + server.setRequestHandler( + Method.resourcesTemplatesList, + (request, extra) async => + const ListResourceTemplatesResult(resourceTemplates: []), + (id, params, meta) => JsonRpcListResourceTemplatesRequest.fromJson({ + 'id': id, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + server.setRequestHandler( + Method.resourcesRead, + (request, extra) async => const ReadResourceResult( + contents: [TextResourceContents(uri: 'file:///a.txt', text: 'a')], + ), + (id, params, meta) => JsonRpcReadResourceRequest.fromJson({ + 'id': id, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + final requests = [ + JsonRpcListToolsRequest(id: 'tools', meta: _clientMeta()), + JsonRpcListPromptsRequest(id: 'prompts', meta: _clientMeta()), + JsonRpcListResourcesRequest(id: 'resources', meta: _clientMeta()), + JsonRpcListResourceTemplatesRequest( + id: 'templates', + meta: _clientMeta(), + ), + JsonRpcReadResourceRequest( + id: 'read', + readParams: const ReadResourceRequest(uri: 'file:///a.txt'), + meta: _clientMeta(), + ), + ]; + for (final request in requests) { + transport.receive(request); + await _pump(); + } + + final responses = transport.sentMessages.cast().toList(); + final tools = responses[0].result; + expect(tools['resultType'], resultTypeComplete); + expect(tools['ttlMs'], 300000); + expect(tools['cacheScope'], CacheScope.public); + + for (final response in responses.skip(1)) { + expect(response.result['resultType'], resultTypeComplete); + expect(response.result['ttlMs'], 0); + expect(response.result['cacheScope'], CacheScope.private); + } + }); + test('server rejects task subscriptions without task extension capability', () async { final server = Server( @@ -601,17 +943,56 @@ void main() { ); }); - test('server rejects removed legacy task methods in stateless protocol', + test('server handles task extension methods with 2026 result shapes', () async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( capabilities: ServerCapabilities( - tasks: ServerCapabilitiesTasks(list: true), extensions: {mcpTasksExtensionId: {}}, ), ), ); + server.setRequestHandler( + Method.tasksGet, + (request, extra) async => const GetTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.completed, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:01:00Z', + ttlMs: 60000, + result: { + 'content': [ + {'type': 'text', 'text': 'done'}, + ], + }, + ), + ), + (id, params, meta) => JsonRpcGetTaskRequest.fromJson({ + 'id': id, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + server.setRequestHandler( + Method.tasksCancel, + (request, extra) async => const TaskExtensionAcknowledgementResult(), + (id, params, meta) => JsonRpcCancelTaskRequest.fromJson({ + 'id': id, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + server.setRequestHandler( + Method.tasksUpdate, + (request, extra) async => const EmptyResult(), + (id, params, meta) => JsonRpcUpdateTaskRequest.fromJson({ + 'id': id, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); final transport = RecordingTransport(); await server.connect(transport); final taskExtensionMeta = _clientMeta( @@ -622,34 +1003,174 @@ void main() { transport ..receive( - JsonRpcListTasksRequest(id: 'list-tasks', meta: taskExtensionMeta), + JsonRpcGetTaskRequest( + id: 'get-task', + getParams: const GetTaskRequest(taskId: 'task-1'), + meta: taskExtensionMeta, + ), ) ..receive( - JsonRpcTaskResultRequest( - id: 'task-result', - resultParams: const TaskResultRequest(taskId: 'task-1'), + JsonRpcCancelTaskRequest( + id: 'cancel-task', + cancelParams: const CancelTaskRequest(taskId: 'task-1'), + meta: taskExtensionMeta, + ), + ) + ..receive( + JsonRpcUpdateTaskRequest( + id: 'update-task', + updateParams: const UpdateTaskRequest( + taskId: 'task-1', + inputResponses: {}, + ), 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')); + final responses = transport.sentMessages.cast().toList(); + expect(responses, hasLength(3)); + expect(responses[0].result['resultType'], resultTypeComplete); + expect(responses[0].result['taskId'], 'task-1'); + expect(responses[0].result['ttlMs'], 60000); + expect(responses[0].result, isNot(contains('ttl'))); + expect(responses[1].result, {'resultType': resultTypeComplete}); + expect(responses[2].result, {'resultType': resultTypeComplete}); }); - test('server/discover omits legacy task capabilities', () async { - final server = Server( + test('server does not expose legacy task handlers as task extension', + () async { + final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), - options: const McpServerOptions( - capabilities: ServerCapabilities( - tasks: ServerCapabilitiesTasks( - list: true, - requests: ServerCapabilitiesTasksRequests( - tools: ServerCapabilitiesTasksTools( + ); + var handlerCalled = false; + server.experimental.onGetTask((taskId, extra) async { + handlerCalled = true; + return Task( + taskId: taskId, + status: TaskStatus.completed, + ttl: null, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:01:00Z', + ); + }); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcGetTaskRequest( + id: 'get-task', + getParams: const GetTaskRequest(taskId: 'task-1'), + meta: _clientMeta( + clientCapabilities: const ClientCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect(response.error.code, ErrorCode.methodNotFound.value); + expect(response.error.message, contains(mcpTasksExtensionId)); + expect(handlerCalled, isFalse); + }); + + test('stateless task extension handlers reject legacy result shapes', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ); + server.setRequestHandler( + Method.tasksGet, + (request, extra) async => Task( + taskId: request.getParams.taskId, + status: TaskStatus.completed, + ttl: null, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:01:00Z', + ), + (id, params, meta) => JsonRpcGetTaskRequest.fromJson({ + 'id': id, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcGetTaskRequest( + id: 'get-task', + getParams: const GetTaskRequest(taskId: 'task-1'), + meta: _clientMeta( + clientCapabilities: const ClientCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect(response.error.code, ErrorCode.invalidParams.value); + expect(response.error.message, contains('GetTaskExtensionResult')); + }); + + 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(), ), ), @@ -882,6 +1403,30 @@ void main() { expect(tool, isNot(contains('execution'))); }); + test('stateless tools/list returns tools sorted by name', () async { + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + for (final name in ['zeta', 'alpha', 'middle']) { + server.registerTool( + name, + 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 tools = response.result['tools'] as List; + expect(tools.map((tool) => tool['name']), ['alpha', 'middle', 'zeta']); + }); + test('tasks/update handler requires task extension capability', () { final server = Server( const Implementation(name: 'server', version: '1.0.0'), @@ -1071,6 +1616,208 @@ void main() { contains('Invalid stateless request metadata.'), ), ); + expect( + validateToolRequest(_clientMeta(logLevel: 'verbose')), + isA().having( + (error) => error.message, + 'message', + contains(McpMetaKey.logLevel), + ), + ); + }); + + test('server rejects core RPCs removed from stateless MCP', () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + final removedRequests = [ + JsonRpcRequest( + id: 1, + method: Method.initialize, + params: const { + 'protocolVersion': draftProtocolVersion2026_07_28, + 'capabilities': {}, + 'clientInfo': {'name': 'client', 'version': '1.0.0'}, + }, + meta: _clientMeta(), + ), + JsonRpcRequest( + id: 2, + method: Method.ping, + meta: _clientMeta(), + ), + JsonRpcRequest( + id: 3, + method: Method.loggingSetLevel, + params: const {'level': 'info'}, + meta: _clientMeta(), + ), + JsonRpcRequest( + id: 4, + method: Method.resourcesSubscribe, + params: const {'uri': 'file:///tmp/example.txt'}, + meta: _clientMeta(), + ), + JsonRpcRequest( + id: 5, + method: Method.resourcesUnsubscribe, + params: const {'uri': 'file:///tmp/example.txt'}, + meta: _clientMeta(), + ), + ]; + + for (final request in removedRequests) { + transport.sentMessages.clear(); + + transport.receive(request); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect(response.id, request.id); + expect(response.error.code, ErrorCode.methodNotFound.value); + expect(response.error.message, contains(request.method)); + } + }); + + test('server rejects notifications removed from stateless MCP', () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + ); + final errors = []; + server.onerror = errors.add; + final transport = RecordingTransport(); + await server.connect(transport); + + final initialized = JsonRpcMessage.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + 'params': {'_meta': _clientMeta()}, + }) as JsonRpcNotification; + final rootsListChanged = JsonRpcMessage.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsRootsListChanged, + 'params': {'_meta': _clientMeta()}, + }) as JsonRpcNotification; + + expect( + initialized.meta?[McpMetaKey.protocolVersion], + draftProtocolVersion2026_07_28, + ); + expect( + rootsListChanged.meta?[McpMetaKey.protocolVersion], + draftProtocolVersion2026_07_28, + ); + + for (final notification in [initialized, rootsListChanged]) { + errors.clear(); + + transport.receive(notification); + await _pump(); + + final error = errors.single as McpError; + expect(error.code, ErrorCode.methodNotFound.value); + expect(error.message, contains(notification.method)); + } + expect(transport.sentMessages, isEmpty); + }); + + test('server gates stateless logging by request metadata', () async { + late Server server; + server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + logging: {}, + tools: ServerCapabilitiesTools(), + ), + ), + ); + server.setRequestHandler( + Method.toolsList, + (request, extra) async { + await server.sendLoggingMessage( + const LoggingMessageNotification( + level: LoggingLevel.debug, + data: 'skip', + ), + requestMeta: extra.meta, + ); + await server.sendLoggingMessage( + const LoggingMessageNotification( + level: LoggingLevel.warning, + data: 'emit', + ), + requestMeta: extra.meta, + ); + return const ListToolsResult(tools: []); + }, + (id, params, meta) => JsonRpcListToolsRequest( + id: id, + params: params, + meta: meta, + ), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcListToolsRequest( + id: 1, + meta: _clientMeta(logLevel: 'warning'), + ), + ); + await _pump(); + + expect(transport.sentMessages, hasLength(2)); + final loggingNotification = + transport.sentMessages.first as JsonRpcNotification; + expect(loggingNotification.method, Method.notificationsMessage); + expect(loggingNotification.params?['level'], LoggingLevel.warning.name); + expect(loggingNotification.params?['data'], 'emit'); + expect(transport.sentMessages.last, isA()); + }); + + test('server does not send stateless logging without request logLevel', + () async { + late Server server; + server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + logging: {}, + tools: ServerCapabilitiesTools(), + ), + ), + ); + server.setRequestHandler( + Method.toolsList, + (request, extra) async { + await server.sendLoggingMessage( + const LoggingMessageNotification( + level: LoggingLevel.error, + data: 'skip', + ), + requestMeta: extra.meta, + ); + return const ListToolsResult(tools: []); + }, + (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(); + + expect(transport.sentMessages, hasLength(1)); + expect(transport.sentMessages.single, isA()); }); test('client can opt in to server/discover and sends stateless metadata', @@ -1105,28 +1852,671 @@ void main() { expect(listRequest.meta?[McpMetaKey.clientCapabilities], {}); }); - test('client rejects discovery when no compatible version is offered', - () async { - final transport = DiscoveringClientTransport( - discoverVersions: const ['1900-01-01'], - ); + test('client listenSubscriptions requires a connected transport', () { 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, + expect( + () => client.listenSubscriptions( + const SubscriptionsListenRequest( + notifications: SubscriptionFilter(toolsListChanged: true), ), ), + throwsStateError, ); }); + test('client listenSubscriptions demultiplexes by subscription id', + () async { + final transport = DiscoveringClientTransport( + capabilities: const ServerCapabilities( + tools: ServerCapabilitiesTools(listChanged: true), + resources: ServerCapabilitiesResources(), + ), + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + await client.connect(transport); + + final toolsSubscription = client.listenSubscriptions( + const SubscriptionsListenRequest( + notifications: SubscriptionFilter(toolsListChanged: true), + ), + ); + final resourcesSubscription = client.listenSubscriptions( + const SubscriptionsListenRequest( + notifications: SubscriptionFilter( + resourceSubscriptions: ['file:///project/config.json'], + ), + ), + ); + await _pump(); + + final listenRequests = transport.sentMessages + .whereType() + .where((message) => message.method == Method.subscriptionsListen) + .toList(); + expect(listenRequests, hasLength(2)); + expect(listenRequests[0].id, toolsSubscription.id); + expect(listenRequests[1].id, resourcesSubscription.id); + expect( + listenRequests[0].meta?[McpMetaKey.protocolVersion], + draftProtocolVersion2026_07_28, + ); + expect(listenRequests[0].params?['notifications'], { + 'toolsListChanged': true, + }); + + transport.onmessage?.call( + JsonRpcSubscriptionsAcknowledgedNotification( + acknowledgedParams: const SubscriptionsAcknowledgedNotification( + notifications: SubscriptionFilter( + resourceSubscriptions: ['file:///project/config.json'], + ), + ), + meta: {McpMetaKey.subscriptionId: resourcesSubscription.id}, + ), + ); + transport.onmessage?.call( + JsonRpcSubscriptionsAcknowledgedNotification( + acknowledgedParams: const SubscriptionsAcknowledgedNotification( + notifications: SubscriptionFilter(toolsListChanged: true), + ), + meta: {McpMetaKey.subscriptionId: toolsSubscription.id}, + ), + ); + + final toolsAcknowledged = await toolsSubscription.acknowledged; + final resourcesAcknowledged = await resourcesSubscription.acknowledged; + expect(toolsAcknowledged.notifications.toolsListChanged, isTrue); + expect( + resourcesAcknowledged.notifications.resourceSubscriptions, + ['file:///project/config.json'], + ); + + final toolNotification = toolsSubscription.notifications.first; + final resourceNotification = resourcesSubscription.notifications.first; + transport.onmessage?.call( + JsonRpcToolListChangedNotification( + meta: {McpMetaKey.subscriptionId: toolsSubscription.id}, + ), + ); + transport.onmessage?.call( + JsonRpcResourceUpdatedNotification( + updatedParams: const ResourceUpdatedNotification( + uri: 'file:///project/config.json', + ), + meta: {McpMetaKey.subscriptionId: resourcesSubscription.id}, + ), + ); + + expect( + (await toolNotification).method, + Method.notificationsToolsListChanged, + ); + expect( + (await resourceNotification).method, + Method.notificationsResourcesUpdated, + ); + + toolsSubscription.cancel('done'); + resourcesSubscription.cancel('done'); + await expectLater(toolsSubscription.done, completes); + await expectLater(resourcesSubscription.done, completes); + await _pump(); + + final cancellations = + transport.sentMessages.whereType(); + expect( + cancellations + .map((notification) => notification.cancelParams.requestId), + containsAll([toolsSubscription.id, resourcesSubscription.id]), + ); + }); + + test('client subscription rejects notifications before acknowledgment', + () async { + final transport = DiscoveringClientTransport( + capabilities: const ServerCapabilities( + tools: ServerCapabilitiesTools(listChanged: true), + ), + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + await client.connect(transport); + + final subscription = client.listenSubscriptions( + const SubscriptionsListenRequest( + notifications: SubscriptionFilter(toolsListChanged: true), + ), + ); + subscription.notifications.listen(null, onError: (_) {}); + await _pump(); + + final acknowledgedExpectation = expectLater( + subscription.acknowledged, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains(Method.notificationsSubscriptionsAcknowledged), + ), + ), + ); + final doneExpectation = expectLater( + subscription.done, + throwsA(isA()), + ); + + transport.onmessage?.call( + JsonRpcToolListChangedNotification( + meta: {McpMetaKey.subscriptionId: subscription.id}, + ), + ); + + await acknowledgedExpectation; + await doneExpectation; + await _pump(); + + final cancellation = transport.sentMessages + .whereType() + .single; + expect(cancellation.cancelParams.requestId, subscription.id); + }); + + test('client subscription fails when the connection closes', () async { + final transport = DiscoveringClientTransport( + capabilities: const ServerCapabilities( + tools: ServerCapabilitiesTools(listChanged: true), + ), + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + await client.connect(transport); + + final subscription = client.listenSubscriptions( + const SubscriptionsListenRequest( + notifications: SubscriptionFilter(toolsListChanged: true), + ), + ); + subscription.notifications.listen(null, onError: (_) {}); + await _pump(); + + final acknowledgedExpectation = expectLater( + subscription.acknowledged, + throwsA( + isA().having( + (error) => error.code, + 'code', + ErrorCode.connectionClosed.value, + ), + ), + ); + final doneExpectation = expectLater( + subscription.done, + throwsA( + isA().having( + (error) => error.message, + 'message', + 'Connection closed', + ), + ), + ); + + await transport.close(); + + await acknowledgedExpectation; + await doneExpectation; + }); + + test('client subscription rejects acknowledgments outside requested filter', + () async { + final transport = DiscoveringClientTransport( + capabilities: const ServerCapabilities( + tools: ServerCapabilitiesTools(listChanged: true), + prompts: ServerCapabilitiesPrompts(listChanged: true), + ), + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + await client.connect(transport); + + final subscription = client.listenSubscriptions( + const SubscriptionsListenRequest( + notifications: SubscriptionFilter(toolsListChanged: true), + ), + ); + subscription.notifications.listen(null, onError: (_) {}); + await _pump(); + + final acknowledgedExpectation = expectLater( + subscription.acknowledged, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('not requested'), + ), + ), + ); + final doneExpectation = expectLater( + subscription.done, + throwsA(isA()), + ); + + transport.onmessage?.call( + JsonRpcNotification( + method: Method.notificationsSubscriptionsAcknowledged, + params: const { + 'notifications': {'promptsListChanged': true}, + }, + meta: {McpMetaKey.subscriptionId: subscription.id}, + ), + ); + + await acknowledgedExpectation; + await doneExpectation; + await _pump(); + + final cancellation = transport.sentMessages + .whereType() + .single; + expect(cancellation.cancelParams.requestId, subscription.id); + }); + + test('client subscription rejects unacknowledged notification types', + () async { + final transport = DiscoveringClientTransport( + capabilities: const ServerCapabilities( + tools: ServerCapabilitiesTools(listChanged: true), + prompts: ServerCapabilitiesPrompts(listChanged: true), + ), + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + await client.connect(transport); + + final subscription = client.listenSubscriptions( + const SubscriptionsListenRequest( + notifications: SubscriptionFilter(toolsListChanged: true), + ), + ); + subscription.notifications.listen(null, onError: (_) {}); + await _pump(); + + transport.onmessage?.call( + JsonRpcSubscriptionsAcknowledgedNotification( + acknowledgedParams: const SubscriptionsAcknowledgedNotification( + notifications: SubscriptionFilter(toolsListChanged: true), + ), + meta: {McpMetaKey.subscriptionId: subscription.id}, + ), + ); + await subscription.acknowledged; + + final doneExpectation = expectLater( + subscription.done, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains(Method.notificationsPromptsListChanged), + ), + ), + ); + + transport.onmessage?.call( + JsonRpcPromptListChangedNotification( + meta: {McpMetaKey.subscriptionId: subscription.id}, + ), + ); + + await doneExpectation; + await _pump(); + + final cancellation = transport.sentMessages + .whereType() + .single; + expect(cancellation.cancelParams.requestId, subscription.id); + }); + + test('client subscription cancel before ack completes done', () async { + final transport = DiscoveringClientTransport( + capabilities: const ServerCapabilities( + tools: ServerCapabilitiesTools(listChanged: true), + ), + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + await client.connect(transport); + + final subscription = client.listenSubscriptions( + const SubscriptionsListenRequest( + notifications: SubscriptionFilter(toolsListChanged: true), + ), + ); + await _pump(); + + final acknowledgedExpectation = expectLater( + subscription.acknowledged, + throwsA( + isA().having( + (error) => error.reason, + 'reason', + 'user cancelled', + ), + ), + ); + + subscription.cancel('user cancelled'); + + await acknowledgedExpectation; + await expectLater(subscription.done, completes); + await _pump(); + + final cancellation = transport.sentMessages + .whereType() + .single; + expect(cancellation.cancelParams.requestId, subscription.id); + }); + + test('client subscription rejects completion before acknowledgment', + () async { + final transport = DiscoveringClientTransport( + capabilities: const ServerCapabilities( + tools: ServerCapabilitiesTools(listChanged: true), + ), + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + await client.connect(transport); + + final subscription = client.listenSubscriptions( + const SubscriptionsListenRequest( + notifications: SubscriptionFilter(toolsListChanged: true), + ), + ); + subscription.notifications.listen(null, onError: (_) {}); + await _pump(); + + final acknowledgedExpectation = expectLater( + subscription.acknowledged, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('completed before'), + ), + ), + ); + final doneExpectation = expectLater( + subscription.done, + throwsA(isA()), + ); + + transport.onmessage?.call( + JsonRpcResponse( + id: subscription.id, + result: const EmptyResult().toJson(), + ), + ); + + await acknowledgedExpectation; + await doneExpectation; + }); + + test('client rejects unrecognized stateless resultType values', () async { + final transport = DiscoveringClientTransport( + toolsListResult: const { + 'resultType': 'future_extension', + 'tools': [], + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + + await client.connect(transport); + + await expectLater( + client.listTools(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.internalError.value, + ) + .having( + (error) => error.message, + 'message', + contains('Failed to parse result for ${Method.toolsList}'), + ) + .having( + (error) => error.data.toString(), + 'data', + contains('Unrecognized MCP resultType "future_extension"'), + ), + ), + ); + }); + + test('client rejects non-string stateless resultType values', () async { + final transport = DiscoveringClientTransport( + toolsListResult: const { + 'resultType': 42, + 'tools': [], + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + + await client.connect(transport); + + await expectLater( + client.listTools(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.internalError.value, + ) + .having( + (error) => error.data.toString(), + 'data', + contains('MCP resultType must be a string'), + ), + ), + ); + }); + + test('client accepts advertised task extension resultType values', + () async { + final transport = DiscoveringClientTransport( + capabilities: ServerCapabilities( + tools: const ServerCapabilitiesTools(), + extensions: withMcpTasksExtension(null), + ), + toolsListResult: const { + 'resultType': resultTypeTask, + 'tools': [], + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + + await client.connect(transport); + + final result = await client.listTools(); + expect(result.tools, isEmpty); + }); + + test('stable client sessions do not validate future resultType values', + () async { + final transport = LegacyFallbackTransport( + toolsListResult: const { + 'resultType': 'future_extension', + 'tools': [], + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + + await client.connect(transport); + + final result = await client.listTools(); + expect(client.getProtocolVersion(), stableProtocolVersion2025_11_25); + expect(result.tools, isEmpty); + }); + + 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 retries discovery with advertised compatible stateless version', + () async { + final transport = DiscoveringClientTransport( + unsupportedDiscoverProtocolVersions: const ['1900-01-01'], + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocolVersion: '1900-01-01', + useServerDiscover: true, + ), + ); + + await client.connect(transport); + + final discoverRequests = transport.sentMessages + .whereType() + .where((message) => message.method == Method.serverDiscover) + .toList(); + expect(discoverRequests, hasLength(2)); + expect( + discoverRequests.map( + (request) => request.meta?[McpMetaKey.protocolVersion], + ), + ['1900-01-01', draftProtocolVersion2026_07_28], + ); + expect(client.getProtocolVersion(), draftProtocolVersion2026_07_28); + expect(transport.protocolVersion, draftProtocolVersion2026_07_28); + expect( + transport.sentMessages.whereType().map( + (message) => message.method, + ), + isNot(contains(Method.initialize)), + ); + }); + + for (final scenario in [ + ( + name: 'malformed error data', + requested: '1900-01-01', + discoverVersions: const [draftProtocolVersion2026_07_28], + data: 'not-an-object', + ), + ( + name: 'missing supported versions', + requested: '1900-01-01', + discoverVersions: const [draftProtocolVersion2026_07_28], + data: const {'requested': '1900-01-01'}, + ), + ( + name: 'no compatible stateless version', + requested: '1900-01-01', + discoverVersions: const ['1900-01-01'], + data: null, + ), + ( + name: 'advertised version matches rejected request', + requested: draftProtocolVersion2026_07_28, + discoverVersions: const [draftProtocolVersion2026_07_28], + data: const { + 'supported': [draftProtocolVersion2026_07_28], + 'requested': draftProtocolVersion2026_07_28, + }, + ), + ]) { + test( + 'client does not fall back to initialize after unsupported discovery ' + '${scenario.name}', + () async { + final transport = DiscoveringClientTransport( + discoverVersions: scenario.discoverVersions, + unsupportedDiscoverProtocolVersions: [scenario.requested], + unsupportedDiscoverData: scenario.data, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: McpClientOptions( + protocolVersion: scenario.requested, + useServerDiscover: true, + ), + ); + + await expectLater( + client.connect(transport), + throwsA( + isA().having( + (error) => error.code, + 'code', + ErrorCode.unsupportedProtocolVersion.value, + ), + ), + ); + expect( + transport.sentMessages.whereType().map( + (message) => message.method, + ), + isNot(contains(Method.initialize)), + ); + }, + ); + } + test('client falls back to initialize when discovery is unavailable', () async { final transport = LegacyFallbackTransport(); diff --git a/test/server/mcp_server_test.dart b/test/server/mcp_server_test.dart index 8936c39d..9c38661e 100644 --- a/test/server/mcp_server_test.dart +++ b/test/server/mcp_server_test.dart @@ -6,8 +6,10 @@ import 'package:mcp_dart/src/types.dart'; import 'package:test/test.dart'; /// Mock transport for McpServer tests -class McpServerTestTransport implements Transport { +class McpServerTestTransport + implements Transport, ToolParameterHeaderAwareTransport { final List sentMessages = []; + final List toolParameterHeaderMappings = []; bool _closed = false; @override @@ -38,6 +40,13 @@ class McpServerTestTransport implements Transport { sentMessages.add(message); } + @override + void setToolParameterHeaderMappings( + ToolParameterHeaderMappings mappings, + ) { + toolParameterHeaderMappings.add(mappings); + } + @override Future start() async { if (_closed) throw StateError('Cannot start closed transport'); @@ -57,6 +66,23 @@ class McpServerTestTransport implements Transport { } } +Map _statelessMeta() => buildProtocolRequestMeta( + protocolVersion: draftProtocolVersion2026_07_28, + clientInfo: const Implementation(name: 'test-client', version: '1.0.0'), + clientCapabilities: const ClientCapabilities(), + ); + +bool _containsMcpHeader(Object? value) { + if (value is Map) { + return value.containsKey('x-mcp-header') || + value.values.any(_containsMcpHeader); + } + if (value is Iterable) { + return value.any(_containsMcpHeader); + } + return false; +} + void main() { group('McpServer Tool Registration', () { late McpServer server; @@ -105,6 +131,270 @@ void main() { expect(tools.first['name'], equals('test-tool')); }); + test('connect syncs tool parameter header mappings to transports', + () async { + server.registerTool( + 'header-tool', + inputSchema: const ToolInputSchema( + properties: { + 'dryRun': JsonBoolean(mcpHeader: 'Dry-Run'), + 'region': JsonString(mcpHeader: 'Region'), + 'auth': JsonObject( + properties: { + 'tenant': JsonString(mcpHeader: 'Tenant'), + }, + ), + }, + ), + callback: (args, extra) async => const CallToolResult(content: []), + ); + + await server.connect(transport); + + expect(transport.toolParameterHeaderMappings, isNotEmpty); + expect( + transport.toolParameterHeaderMappings.last, + equals( + const { + 'header-tool': { + 'dryRun': 'Dry-Run', + 'region': 'Region', + '/auth/tenant': 'Tenant', + }, + }, + ), + ); + + transport.receiveMessage( + JsonRpcListToolsRequest(id: 1, meta: _statelessMeta()), + ); + await Future.delayed(const Duration(milliseconds: 100)); + + final response = transport.sentMessages.last as JsonRpcResponse; + final tools = response.result['tools'] as List; + final tool = tools.single as Map; + final inputSchema = tool['inputSchema'] as Map; + final properties = inputSchema['properties'] as Map; + final authProperties = (properties['auth'] as Map)['properties'] as Map; + expect((properties['region'] as Map)['x-mcp-header'], 'Region'); + expect((authProperties['tenant'] as Map)['x-mcp-header'], 'Tenant'); + }); + + test('tool updates refresh parameter header mappings on transports', + () async { + final registeredTool = server.registerTool( + 'header-tool', + callback: (args, extra) async => const CallToolResult(content: []), + ); + await server.connect(transport); + transport.toolParameterHeaderMappings.clear(); + + registeredTool.update( + inputSchema: const ToolInputSchema( + properties: { + 'dryRun': JsonBoolean(mcpHeader: 'Dry-Run'), + }, + ), + ); + + expect(transport.toolParameterHeaderMappings, isNotEmpty); + expect( + transport.toolParameterHeaderMappings.last, + equals( + const { + 'header-tool': {'dryRun': 'Dry-Run'}, + }, + ), + ); + + registeredTool.disable(); + + expect(transport.toolParameterHeaderMappings.last, isEmpty); + }); + + test('invalid tool parameter header metadata is not synced', () async { + server.registerTool( + 'non-string-header-tool', + inputSchema: ToolInputSchema( + properties: { + 'value': JsonSchema.fromJson( + {'type': 'string', 'x-mcp-header': 1}, + ), + }, + ), + callback: (args, extra) async => const CallToolResult(content: []), + ); + server.registerTool( + 'invalid-header-tool', + inputSchema: const ToolInputSchema( + properties: { + 'value': JsonString(mcpHeader: 'Bad:Header'), + }, + ), + callback: (args, extra) async => const CallToolResult(content: []), + ); + server.registerTool( + 'separator-header-tool', + inputSchema: const ToolInputSchema( + properties: { + 'value': JsonString(mcpHeader: 'Bad/Header'), + }, + ), + callback: (args, extra) async => const CallToolResult(content: []), + ); + server.registerTool( + 'number-header-tool', + inputSchema: const ToolInputSchema( + properties: { + 'value': JsonNumber(mcpHeader: 'Value'), + }, + ), + callback: (args, extra) async => const CallToolResult(content: []), + ); + server.registerTool( + 'duplicate-header-tool', + inputSchema: const ToolInputSchema( + properties: { + 'primary': JsonString(mcpHeader: 'Region'), + 'secondary': JsonString(mcpHeader: 'region'), + }, + ), + callback: (args, extra) async => const CallToolResult(content: []), + ); + server.registerTool( + 'non-primitive-header-tool', + inputSchema: ToolInputSchema( + properties: { + 'value': JsonSchema.fromJson( + {'type': 'object', 'x-mcp-header': 'Value'}, + ), + }, + ), + callback: (args, extra) async => const CallToolResult(content: []), + ); + + await server.connect(transport); + + expect(transport.toolParameterHeaderMappings, isNotEmpty); + expect(transport.toolParameterHeaderMappings.last, isEmpty); + + transport.receiveMessage( + JsonRpcListToolsRequest(id: 1, meta: _statelessMeta()), + ); + await Future.delayed(const Duration(milliseconds: 100)); + + final response = transport.sentMessages.last as JsonRpcResponse; + final tools = response.result['tools'] as List; + expect(tools, hasLength(6)); + for (final tool in tools.cast()) { + expect(_containsMcpHeader(tool['inputSchema']), isFalse); + } + }); + + test('invalid stateless header metadata is stripped from nested schemas', + () async { + server.registerTool( + 'nested-header-tool', + inputSchema: ToolInputSchema.fromJson({ + 'type': 'object', + 'properties': { + 'invalidArray': { + 'type': 'array', + 'x-mcp-header': 'Invalid', + 'items': { + 'type': 'string', + 'x-mcp-header': 'Item', + }, + }, + 'objectMap': { + 'type': 'object', + 'additionalProperties': { + 'type': 'string', + 'x-mcp-header': 'Additional', + }, + }, + 'combined': { + 'allOf': [ + true, + { + 'type': 'string', + 'x-mcp-header': 'All', + }, + ], + 'anyOf': [ + false, + { + 'type': 'integer', + 'x-mcp-header': 'Any', + }, + ], + 'oneOf': [ + { + 'type': 'boolean', + 'x-mcp-header': 'One', + }, + ], + 'not': { + 'type': 'string', + 'x-mcp-header': 'Not', + }, + }, + 'literalData': { + 'default': { + 'x-mcp-header': 'not schema metadata', + }, + }, + 'preservedAny': { + 'properties': { + 'flag': true, + }, + }, + }, + }), + callback: (args, extra) async => const CallToolResult(content: []), + ); + + await server.connect(transport); + + transport.receiveMessage( + JsonRpcListToolsRequest(id: 1, meta: _statelessMeta()), + ); + await Future.delayed(const Duration(milliseconds: 100)); + + final response = transport.sentMessages.last as JsonRpcResponse; + final tools = response.result['tools'] as List; + final tool = tools.single as Map; + final inputSchema = tool['inputSchema'] as Map; + final properties = inputSchema['properties'] as Map; + final invalidArray = properties['invalidArray'] as Map; + final objectMap = properties['objectMap'] as Map; + final combined = properties['combined'] as Map; + final allOf = combined['allOf'] as List; + final anyOf = combined['anyOf'] as List; + final oneOf = combined['oneOf'] as List; + final literalData = properties['literalData'] as Map; + final preservedAny = properties['preservedAny'] as Map; + + expect(invalidArray.containsKey('x-mcp-header'), isFalse); + expect( + (invalidArray['items'] as Map).containsKey('x-mcp-header'), + isFalse, + ); + expect( + (objectMap['additionalProperties'] as Map).containsKey('x-mcp-header'), + isFalse, + ); + expect((allOf[1] as Map).containsKey('x-mcp-header'), isFalse); + expect((anyOf[1] as Map).containsKey('x-mcp-header'), isFalse); + expect((oneOf.single as Map).containsKey('x-mcp-header'), isFalse); + expect((combined['not'] as Map).containsKey('x-mcp-header'), isFalse); + expect( + (literalData['default'] as Map)['x-mcp-header'], + 'not schema metadata', + ); + expect(((preservedAny['properties'] as Map)['flag'] as bool), isTrue); + }); + test('registerTool can be updated', () async { final registeredTool = server.registerTool( 'updatable-tool', diff --git a/test/server/streamable_https_test.dart b/test/server/streamable_https_test.dart index 9d9b5650..8c288071 100644 --- a/test/server/streamable_https_test.dart +++ b/test/server/streamable_https_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:mcp_dart/src/server/server.dart'; import 'package:mcp_dart/src/server/streamable_https.dart'; import 'package:mcp_dart/src/shared/uuid.dart'; import 'package:mcp_dart/src/types.dart'; @@ -316,6 +317,105 @@ void main() { // Helper to manually trigger initialization of the transport + test('JSON-RPC preflight errors advertise JSON content type', () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => 'preflight-session-id', + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + + Future send( + String method, { + Map headers = const {}, + Object? body, + ContentType? contentType, + }) async { + final client = HttpClient(); + addTearDown(() => client.close(force: true)); + final request = await client.openUrl( + method, + Uri.parse('$serverUrlBase/mcp'), + ); + request.headers.contentType = contentType; + headers.forEach(request.headers.set); + if (body != null) { + request.write(jsonEncode(body)); + } + return request.close(); + } + + final getWithoutSseAccept = await send( + 'GET', + headers: {HttpHeaders.acceptHeader: 'application/json'}, + ); + expect(getWithoutSseAccept.statusCode, HttpStatus.notAcceptable); + expect( + getWithoutSseAccept.headers.contentType?.mimeType, + 'application/json', + ); + expect( + await utf8.decodeStream(getWithoutSseAccept), + contains('Client must accept text/event-stream'), + ); + + final unsupportedMethod = await send('PUT'); + expect(unsupportedMethod.statusCode, HttpStatus.methodNotAllowed); + expect( + unsupportedMethod.headers.contentType?.mimeType, + 'application/json', + ); + expect( + unsupportedMethod.headers.value(HttpHeaders.allowHeader), + 'GET, POST, DELETE', + ); + expect( + await utf8.decodeStream(unsupportedMethod), + contains('Method not allowed.'), + ); + + final postWithoutSseAccept = await send( + 'POST', + headers: {HttpHeaders.acceptHeader: 'application/json'}, + contentType: ContentType.json, + body: const JsonRpcRequest(id: 1, method: 'ping').toJson(), + ); + expect(postWithoutSseAccept.statusCode, HttpStatus.notAcceptable); + expect( + postWithoutSseAccept.headers.contentType?.mimeType, + 'application/json', + ); + expect( + await utf8.decodeStream(postWithoutSseAccept), + contains( + 'Client must accept both application/json and text/event-stream', + ), + ); + + final postWithWrongContentType = await send( + 'POST', + headers: { + HttpHeaders.acceptHeader: 'application/json, text/event-stream', + }, + contentType: ContentType.text, + body: const JsonRpcRequest(id: 2, method: 'ping').toJson(), + ); + expect( + postWithWrongContentType.statusCode, + HttpStatus.unsupportedMediaType, + ); + expect( + postWithWrongContentType.headers.contentType?.mimeType, + 'application/json', + ); + expect( + await utf8.decodeStream(postWithWrongContentType), + contains('Content-Type must be application/json'), + ); + }); + test('initialization with stateful session management', () async { // Create a new transport with session management final transport = StreamableHTTPServerTransport( @@ -495,6 +595,7 @@ void main() { ), ); expect(initResponse.statusCode, HttpStatus.ok); + expect(initResponse.headers.value('X-Accel-Buffering'), 'no'); final initMessages = _decodeSseJsonMessages( await utf8.decodeStream(initResponse), ); @@ -511,6 +612,7 @@ void main() { response.headers.contentType?.mimeType, 'text/event-stream', ); + expect(response.headers.value('X-Accel-Buffering'), 'no'); final messages = _decodeSseJsonMessages(await utf8.decodeStream(response)); @@ -706,6 +808,182 @@ void main() { expect(body, contains('Session not found')); }); + test('stateful session JSON-RPC errors advertise JSON content type', + () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => 'json-error-session-id', + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + + transport.onmessage = (message) { + if (message is JsonRpcRequest && message.method == 'initialize') { + unawaited( + transport.send( + JsonRpcResponse( + id: message.id, + result: const { + 'protocolVersion': latestProtocolVersion, + 'capabilities': {}, + 'serverInfo': { + 'name': 'JsonErrorServer', + 'version': '1.0.0', + }, + }, + ), + ), + ); + } + }; + + Future postJsonRpc( + JsonRpcMessage message, { + String? sessionId, + }) 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', + ); + if (sessionId != null) { + request.headers.set('mcp-session-id', sessionId); + } + request.write(jsonEncode(message.toJson())); + return request.close(); + } + + final initialize = await postJsonRpc( + JsonRpcRequest( + id: 1, + method: 'initialize', + params: const InitializeRequestParams( + protocolVersion: latestProtocolVersion, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'Client', version: '1.0'), + ).toJson(), + ), + ); + expect(initialize.statusCode, HttpStatus.ok); + expect( + initialize.headers.value('mcp-session-id'), + 'json-error-session-id', + ); + await initialize.drain(); + + final missingSession = await postJsonRpc( + const JsonRpcRequest(id: 2, method: 'ping'), + ); + expect(missingSession.statusCode, HttpStatus.badRequest); + expect(missingSession.headers.contentType?.mimeType, 'application/json'); + expect( + await utf8.decodeStream(missingSession), + contains('Mcp-Session-Id header is required'), + ); + + final invalidSession = await postJsonRpc( + const JsonRpcRequest(id: 3, method: 'ping'), + sessionId: 'wrong-session-id', + ); + expect(invalidSession.statusCode, HttpStatus.notFound); + expect(invalidSession.headers.contentType?.mimeType, 'application/json'); + expect( + await utf8.decodeStream(invalidSession), + contains('Session not found'), + ); + }); + + test('initialization JSON-RPC errors advertise JSON content type', + () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => 'duplicate-init-session-id', + rejectBatchJsonRpcPayloads: false, + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + + transport.onmessage = (message) { + if (message is JsonRpcRequest && message.method == 'initialize') { + unawaited( + transport.send( + JsonRpcResponse( + id: message.id, + result: const { + 'protocolVersion': latestProtocolVersion, + 'capabilities': {}, + 'serverInfo': { + 'name': 'DuplicateInitServer', + 'version': '1.0.0', + }, + }, + ), + ), + ); + } + }; + + Future postJson(Object body) async { + final client = HttpClient(); + addTearDown(() => client.close(force: true)); + final request = await client.postUrl(Uri.parse('$serverUrlBase/mcp')); + request.headers + ..contentType = ContentType.json + ..set( + HttpHeaders.acceptHeader, + 'application/json, text/event-stream', + ); + request.write(jsonEncode(body)); + return request.close(); + } + + Map initializeBody(int id) { + return JsonRpcRequest( + id: id, + method: 'initialize', + params: const InitializeRequestParams( + protocolVersion: latestProtocolVersion, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'Client', version: '1.0'), + ).toJson(), + ).toJson(); + } + + final duplicateBatch = await postJson([ + initializeBody(1), + initializeBody(2), + ]); + expect(duplicateBatch.statusCode, HttpStatus.badRequest); + expect(duplicateBatch.headers.contentType?.mimeType, 'application/json'); + expect( + await utf8.decodeStream(duplicateBatch), + contains('Only one initialization request is allowed'), + ); + + final initialize = await postJson(initializeBody(3)); + expect(initialize.statusCode, HttpStatus.ok); + await initialize.drain(); + + final alreadyInitialized = await postJson(initializeBody(4)); + expect(alreadyInitialized.statusCode, HttpStatus.badRequest); + expect( + alreadyInitialized.headers.contentType?.mimeType, + 'application/json', + ); + expect( + await utf8.decodeStream(alreadyInitialized), + contains('Server already initialized'), + ); + }); + test('rejects generated session IDs outside visible ASCII', () async { final invalidSessionIds = [ '', @@ -1262,6 +1540,7 @@ void main() { final first = await firstFuture.timeout(const Duration(seconds: 3)); expect(first.statusCode, HttpStatus.ok); expect(first.headers.contentType?.mimeType, 'text/event-stream'); + expect(first.headers.value('X-Accel-Buffering'), 'no'); final firstLines = StreamIterator( first.transform(utf8.decoder).transform(const LineSplitter()), ); @@ -1280,6 +1559,7 @@ void main() { final second = await secondFuture.timeout(const Duration(seconds: 3)); expect(second.statusCode, HttpStatus.ok); expect(second.headers.contentType?.mimeType, 'text/event-stream'); + expect(second.headers.value('X-Accel-Buffering'), 'no'); final secondLines = StreamIterator( second.transform(utf8.decoder).transform(const LineSplitter()), ); @@ -1390,6 +1670,7 @@ void main() { final stream = await streamFuture.timeout(const Duration(seconds: 3)); expect(stream.statusCode, HttpStatus.ok); + expect(stream.headers.value('X-Accel-Buffering'), 'no'); final lines = StreamIterator( stream.transform(utf8.decoder).transform(const LineSplitter()), ); @@ -1417,6 +1698,7 @@ void main() { final otherStream = await otherStreamFuture.timeout(const Duration(seconds: 3)); expect(otherStream.statusCode, HttpStatus.ok); + expect(otherStream.headers.value('X-Accel-Buffering'), 'no'); final otherLines = StreamIterator( otherStream.transform(utf8.decoder).transform(const LineSplitter()), ); @@ -1430,6 +1712,7 @@ void main() { final replay = await openGetSse(sessionId, lastEventId: first.id); expect(replay.statusCode, HttpStatus.ok); + expect(replay.headers.value('X-Accel-Buffering'), 'no'); final replayLines = StreamIterator( replay.transform(utf8.decoder).transform(const LineSplitter()), ); @@ -1821,6 +2104,7 @@ void main() { }); expect(response.statusCode, HttpStatus.badRequest); + expect(response.headers.contentType?.mimeType, 'application/json'); final body = jsonDecode(await utf8.decodeStream(response)) as Map; expect(body['id'], 1); @@ -1931,6 +2215,53 @@ void main() { expect(body['error']['message'], contains('Mcp-Name header is required')); }); + test('2026 stateless HTTP ignores session header', () 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 JsonRpcListToolsRequest) { + unawaited( + transport.send( + JsonRpcResponse( + id: message.id, + result: const ListToolsResult(tools: []).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.toolsList) + ..set('Mcp-Session-Id', 'legacy-session'); + request.write( + jsonEncode(JsonRpcListToolsRequest(id: 6, 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'], 6); + expect(body['result']['tools'], isEmpty); + }); + test('2026 stateless HTTP accepts matching standard and parameter headers', () async { final transport = StreamableHTTPServerTransport( @@ -1965,14 +2296,18 @@ void main() { ..set('MCP-Protocol-Version', draftProtocolVersion2026_07_28) ..set('Mcp-Method', Method.toolsCall) ..set('Mcp-Name', 'execute') - ..set('Mcp-Param-region', 'us-east1'); + ..set('Mcp-Param-region', 'us-east1') + ..set('Mcp-Param-dryRun', 'false'); request.write( jsonEncode( JsonRpcCallToolRequest( id: 3, params: const { 'name': 'execute', - 'arguments': {'region': 'us-east1'}, + 'arguments': { + 'region': 'us-east1', + 'dryRun': false, + }, }, meta: _statelessMeta(), ), @@ -1989,6 +2324,366 @@ void main() { expect(body['result']['content'], isEmpty); }); + test('2026 stateless HTTP rejects server requests on response streams', + () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + + final sendError = Completer(); + transport.onmessage = (message) { + if (message is JsonRpcListToolsRequest) { + final requestSend = transport.send( + const JsonRpcListRootsRequest(id: 99), + relatedRequestId: message.id, + ); + unawaited( + requestSend.then( + (_) {}, + onError: (Object error) { + if (!sendError.isCompleted) { + sendError.complete(error); + } + }, + ), + ); + unawaited( + transport.send( + JsonRpcResponse( + id: message.id, + result: const ListToolsResult(tools: []).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.toolsList); + request.write( + jsonEncode( + JsonRpcListToolsRequest(id: 10, meta: _statelessMeta()).toJson(), + ), + ); + + final response = await request.close(); + final messages = + _decodeSseJsonMessages(await utf8.decodeStream(response)); + final error = await sendError.future.timeout( + const Duration(seconds: 1), + ); + + expect(response.statusCode, HttpStatus.ok); + expect(response.headers.value('X-Accel-Buffering'), 'no'); + expect(messages, hasLength(1)); + expect(messages.single['id'], 10); + expect(messages.single['result']['tools'], isEmpty); + expect(error, isA()); + expect( + error.toString(), + contains('stateless MCP response streams'), + ); + }); + + test('2026 stateless HTTP cancels pending request when SSE response closes', + () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + + final receivedRequest = Completer(); + final cancellation = Completer(); + transport.onmessage = (message) { + if (message is JsonRpcListToolsRequest) { + if (!receivedRequest.isCompleted) { + receivedRequest.complete(message); + } + return; + } + + if (message is JsonRpcCancelledNotification) { + if (!cancellation.isCompleted) { + cancellation.complete(message); + } + } + }; + + final body = jsonEncode( + JsonRpcListToolsRequest(id: 11, meta: _statelessMeta()).toJson(), + ); + final bodyBytes = utf8.encode(body); + final socket = + await Socket.connect(InternetAddress.loopbackIPv4, serverPort); + addTearDown(socket.destroy); + final responseBytes = []; + final responseStarted = Completer(); + final socketSubscription = socket.listen((chunk) { + responseBytes.addAll(chunk); + final responseText = latin1.decode(responseBytes, allowInvalid: true); + if (!responseStarted.isCompleted && responseText.contains('\r\n\r\n')) { + responseStarted.complete(responseText); + } + }); + + socket.add( + utf8.encode( + 'POST /mcp HTTP/1.1\r\n' + 'Host: localhost:$serverPort\r\n' + 'Content-Type: application/json\r\n' + 'Accept: application/json, text/event-stream\r\n' + 'MCP-Protocol-Version: $draftProtocolVersion2026_07_28\r\n' + 'Mcp-Method: ${Method.toolsList}\r\n' + 'Content-Length: ${bodyBytes.length}\r\n' + '\r\n', + ), + ); + socket.add(bodyBytes); + await socket.flush(); + + final responseText = await responseStarted.future.timeout( + const Duration(seconds: 3), + ); + expect(responseText, contains('200 OK')); + expect(responseText.toLowerCase(), contains('text/event-stream')); + expect(responseText.toLowerCase(), contains('x-accel-buffering: no')); + expect( + (await receivedRequest.future.timeout(const Duration(seconds: 3))).id, + 11, + ); + + socket.destroy(); + await socketSubscription.cancel(); + final notification = await cancellation.future.timeout( + const Duration(seconds: 3), + ); + expect(notification.cancelParams.requestId, 11); + expect( + notification.cancelParams.reason, + contains('SSE response stream closed'), + ); + }); + + test('2026 stateless HTTP validates mapped tool parameter headers', + () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => "unused-session-id", + enableJsonResponse: true, + ), + ); + transport.setToolParameterHeaderMappings( + const { + 'execute': { + 'dryRun': 'Dry-Run', + 'rounded': 'Rounded', + 'ratio': 'Ratio', + 'region': 'Region', + 'sentinel': 'Sentinel', + '/location/zone': 'Zone', + }, + }, + ); + 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(), + ), + ), + ); + } + }; + + Future<(int, Map)> postToolCall({ + required int id, + required Map headers, + Map arguments = const { + 'dryRun': false, + 'region': 'us-east1', + }, + }) 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') + ..set('MCP-Protocol-Version', draftProtocolVersion2026_07_28) + ..set('Mcp-Method', Method.toolsCall) + ..set('Mcp-Name', 'execute'); + headers.forEach(request.headers.set); + request.write( + jsonEncode( + JsonRpcCallToolRequest( + id: id, + params: { + 'name': 'execute', + 'arguments': arguments, + }, + meta: _statelessMeta(), + ), + ), + ); + + final response = await request.close(); + return ( + response.statusCode, + jsonDecode(await utf8.decodeStream(response)) as Map, + ); + } + + var (statusCode, body) = await postToolCall( + id: 30, + headers: const { + 'Mcp-Param-Region': 'us-east1', + }, + ); + expect(statusCode, HttpStatus.badRequest); + expect(body['id'], 30); + expect(body['error']['code'], ErrorCode.headerMismatch.value); + expect( + body['error']['message'], + contains('Mcp-Param-Dry-Run header is required'), + ); + + (statusCode, body) = await postToolCall( + id: 31, + headers: const { + 'Mcp-Param-Dry-Run': 'true', + 'Mcp-Param-Region': 'us-east1', + }, + ); + expect(statusCode, HttpStatus.badRequest); + expect(body['id'], 31); + expect( + body['error']['message'], + contains("body argument 'dryRun'"), + ); + + (statusCode, body) = await postToolCall( + id: 32, + arguments: const { + 'dryRun': {'nested': true}, + 'region': 'us-east1', + }, + headers: const { + 'Mcp-Param-Dry-Run': 'false', + 'Mcp-Param-Region': 'us-east1', + }, + ); + expect(statusCode, HttpStatus.badRequest); + expect(body['id'], 32); + expect( + body['error']['message'], + contains('no matching primitive body argument'), + ); + + (statusCode, body) = await postToolCall( + id: 34, + arguments: const { + 'dryRun': false, + 'ratio': 1.5, + 'region': 'us-east1', + }, + headers: const { + 'Mcp-Param-Dry-Run': 'false', + 'Mcp-Param-Ratio': '1.5', + 'Mcp-Param-Region': 'us-east1', + }, + ); + expect(statusCode, HttpStatus.badRequest); + expect(body['id'], 34); + expect( + body['error']['message'], + contains('no matching primitive body argument'), + ); + + (statusCode, body) = await postToolCall( + id: 35, + arguments: const { + 'dryRun': false, + 'rounded': 42.0, + 'region': 'us-east1', + }, + headers: const { + 'Mcp-Param-Dry-Run': 'false', + 'Mcp-Param-Rounded': '42.0', + 'Mcp-Param-Region': 'us-east1', + }, + ); + expect(statusCode, HttpStatus.ok); + expect(body['id'], 35); + expect(body['result']['content'], isEmpty); + + (statusCode, body) = await postToolCall( + id: 36, + arguments: const { + 'dryRun': false, + 'region': 'us-east1', + 'sentinel': '=?base64?YWJj?=', + }, + headers: { + 'Mcp-Param-Dry-Run': 'false', + 'Mcp-Param-Region': 'us-east1', + 'Mcp-Param-Sentinel': + '=?base64?${base64Encode(utf8.encode('=?base64?YWJj?='))}?=', + }, + ); + expect(statusCode, HttpStatus.ok); + expect(body['id'], 36); + expect(body['result']['content'], isEmpty); + + (statusCode, body) = await postToolCall( + id: 37, + arguments: const { + 'dryRun': false, + 'region': 'us-east1', + 'location': {'zone': 'us-east1-b'}, + }, + headers: const { + 'Mcp-Param-Dry-Run': 'false', + 'Mcp-Param-Region': 'us-east1', + 'Mcp-Param-Zone': 'us-east1-b', + }, + ); + expect(statusCode, HttpStatus.ok); + expect(body['id'], 37); + expect(body['result']['content'], isEmpty); + + (statusCode, body) = await postToolCall( + id: 33, + headers: const { + 'Mcp-Param-Dry-Run': 'false', + 'Mcp-Param-Region': 'us-east1', + }, + ); + expect(statusCode, HttpStatus.ok); + expect(body['id'], 33); + expect(body['result']['content'], isEmpty); + }); + test('2026 stateless HTTP rejects malformed routing headers', () async { final transport = StreamableHTTPServerTransport( options: StreamableHTTPServerTransportOptions( @@ -2063,6 +2758,85 @@ void main() { expect(body['id'], 6); expect(body['error']['message'], contains('mcp-param-enabled')); + body = await postJson( + JsonRpcCallToolRequest( + id: 16, + params: const { + 'name': 'execute', + 'arguments': {'region': 'us-east1'}, + }, + meta: _statelessMeta(), + ).toJson(), + headers: { + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.toolsCall, + 'Mcp-Name': 'execute', + 'Mcp-Param-': 'us-east1', + }, + ); + expect(body['id'], 16); + expect(body['error']['message'], contains('header name is malformed')); + + body = await postJson( + JsonRpcCallToolRequest( + id: 17, + params: const { + 'name': 'execute', + 'arguments': {'region': 'us-east1'}, + }, + meta: _statelessMeta(), + ).toJson(), + headers: { + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.toolsCall, + 'Mcp-Name': 'execute', + 'Mcp-Param-region': '=?base64?%%%?=', + }, + ); + expect(body['id'], 17); + expect(body['error']['message'], contains('header value is malformed')); + + body = await postJson( + JsonRpcCallToolRequest( + id: 18, + params: const {'name': 'execute'}, + meta: _statelessMeta(), + ).toJson(), + headers: { + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.toolsCall, + 'Mcp-Name': 'execute', + 'Mcp-Param-region': 'us-east1', + }, + ); + expect(body['id'], 18); + expect( + body['error']['message'], + contains('header has no matching body arguments'), + ); + + body = await postJson( + JsonRpcCallToolRequest( + id: 19, + params: const { + 'name': 'execute', + 'arguments': {'region': 'us-east1'}, + }, + meta: _statelessMeta(), + ).toJson(), + headers: { + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.toolsCall, + 'Mcp-Name': 'execute', + 'Mcp-Param-zone': 'us-east1-b', + }, + ); + expect(body['id'], 19); + expect( + body['error']['message'], + contains('header has no matching body argument'), + ); + body = await postJson( JsonRpcCallToolRequest( id: 7, @@ -2092,6 +2866,66 @@ void main() { expect(body['error']['message'], contains('must contain one')); }); + test('2026 stateless HTTP returns 404 for method not found', () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => "unused-session-id", + ), + ); + final server = Server( + const Implementation(name: 'StatelessServer', version: '1.0.0'), + ); + addTearDown(server.close); + await server.connect(transport); + transports['/mcp'] = transport; + + Future> postStatelessRequest( + JsonRpcRequest message, + ) async { + final client = HttpClient(); + addTearDown(() => client.close(force: true)); + final request = await client.postUrl(Uri.parse('$serverUrlBase/mcp')); + request.headers + ..contentType = ContentType.json + ..set( + HttpHeaders.acceptHeader, + 'application/json, text/event-stream', + ) + ..set('MCP-Protocol-Version', draftProtocolVersion2026_07_28) + ..set('Mcp-Method', message.method); + request.write(jsonEncode(message.toJson())); + + final response = await request.close(); + expect(response.statusCode, HttpStatus.notFound); + final body = jsonDecode(await utf8.decodeStream(response)) + as Map; + expect(body['id'], message.id); + expect(body['error']['code'], ErrorCode.methodNotFound.value); + return body; + } + + var body = await postStatelessRequest( + JsonRpcRequest( + id: 20, + method: 'experimental/unknown', + meta: _statelessMeta(), + ), + ); + expect(body['error']['message'], contains('experimental/unknown')); + + body = await postStatelessRequest( + JsonRpcRequest( + id: 21, + method: Method.ping, + meta: _statelessMeta(), + ), + ); + expect( + body['error']['message'], + contains('not part of MCP stateless protocol versions'), + ); + }); + test('stateless mode allows initialization with session header', () async { final transport = StreamableHTTPServerTransport( options: StreamableHTTPServerTransportOptions( @@ -2152,7 +2986,8 @@ void main() { expect(transport.sessionId, isNull); }); - test('2026 stateless GET requests return method not allowed', () async { + test('2026 stateless non-POST requests return method not allowed', + () async { final transport = StreamableHTTPServerTransport( options: StreamableHTTPServerTransportOptions( sessionIdGenerator: () => null, @@ -2175,12 +3010,36 @@ void main() { jsonDecode(await utf8.decodeStream(response)) as Map; expect(response.statusCode, HttpStatus.methodNotAllowed); + expect(response.headers.contentType?.mimeType, 'application/json'); 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.', ); + + final patchRequest = await client.openUrl( + 'PATCH', + Uri.parse('$serverUrlBase/mcp'), + ); + patchRequest.headers.set( + 'MCP-Protocol-Version', + draftProtocolVersion2026_07_28, + ); + + final patchResponse = await patchRequest.close(); + final patchBody = jsonDecode( + await utf8.decodeStream(patchResponse), + ) as Map; + + expect(patchResponse.statusCode, HttpStatus.methodNotAllowed); + expect(patchResponse.headers.contentType?.mimeType, 'application/json'); + expect(patchResponse.headers.value(HttpHeaders.allowHeader), 'POST'); + expect(patchBody['error']['code'], ErrorCode.connectionClosed.value); + expect( + patchBody['error']['message'], + 'Method not allowed for stateless MCP requests.', + ); }); test('close cleans up all resources', () async { diff --git a/test/server/streamable_mcp_server_test.dart b/test/server/streamable_mcp_server_test.dart index 96cd1951..7d4e60ad 100644 --- a/test/server/streamable_mcp_server_test.dart +++ b/test/server/streamable_mcp_server_test.dart @@ -361,7 +361,8 @@ void main() { ); }); - test('routes 2026 stateless GET and DELETE without a session ID', () async { + test('routes 2026 stateless non-POST methods without a session ID', + () async { final client = HttpClient(); addTearDown(() => client.close(force: true)); @@ -384,6 +385,16 @@ void main() { expect(deleteResponse.statusCode, HttpStatus.methodNotAllowed); expect(deleteResponse.headers.value(HttpHeaders.allowHeader), 'POST'); await deleteResponse.drain(); + + final patchRequest = await client.openUrl('PATCH', Uri.parse(baseUrl)); + patchRequest.headers.set( + 'MCP-Protocol-Version', + draftProtocolVersion2026_07_28, + ); + final patchResponse = await patchRequest.close(); + expect(patchResponse.statusCode, HttpStatus.methodNotAllowed); + expect(patchResponse.headers.value(HttpHeaders.allowHeader), 'POST'); + await patchResponse.drain(); }); test('rejects unsupported MCP-Protocol-Version header by default', diff --git a/test/shared/mcp_header_validation_test.dart b/test/shared/mcp_header_validation_test.dart new file mode 100644 index 00000000..a01f279a --- /dev/null +++ b/test/shared/mcp_header_validation_test.dart @@ -0,0 +1,50 @@ +import 'package:mcp_dart/src/shared/mcp_header_validation.dart'; +import 'package:test/test.dart'; + +void main() { + group('isValidMcpHeaderNameSuffix', () { + test('accepts RFC 9110 field-name token characters', () { + expect( + isValidMcpHeaderNameSuffix("AZaz09!#\$%&'*+-.^_`|~"), + isTrue, + ); + }); + + test('rejects empty and separator characters', () { + expect(isValidMcpHeaderNameSuffix(''), isFalse); + + for (final separator in [ + '"', + '(', + ')', + ',', + '/', + ':', + ';', + '<', + '=', + '>', + '?', + '@', + '[', + r'\', + ']', + '{', + '}', + ]) { + expect( + isValidMcpHeaderNameSuffix('Bad${separator}Header'), + isFalse, + reason: 'separator $separator must be rejected', + ); + } + }); + + test('rejects whitespace, control characters, and non-ASCII characters', + () { + for (final value in ['Bad Header', 'Bad\tHeader', 'Bad\nHeader', 'Bรคd']) { + expect(isValidMcpHeaderNameSuffix(value), isFalse); + } + }); + }); +} diff --git a/test/types/subscriptions_test.dart b/test/types/subscriptions_test.dart index 6a10ed35..0eec7c22 100644 --- a/test/types/subscriptions_test.dart +++ b/test/types/subscriptions_test.dart @@ -1,6 +1,27 @@ +import 'package:mcp_dart/src/shared/protocol.dart'; import 'package:mcp_dart/src/types.dart'; import 'package:test/test.dart'; +Future _unusedRequest( + JsonRpcRequest request, + T Function(Map resultJson) resultFactory, + RequestOptions options, +) { + throw StateError('Unexpected request from subscription helper test'); +} + +RequestHandlerExtra _subscriptionExtra(List sent) { + final abort = BasicAbortController(); + return RequestHandlerExtra( + signal: abort.signal, + requestId: 'sub-1', + sendNotification: (notification, {relatedTask}) async { + sent.add(notification); + }, + sendRequest: _unusedRequest, + ); +} + void main() { group('SubscriptionFilter', () { test('serializes and parses requested notification filters', () { @@ -55,6 +76,73 @@ void main() { expect(acknowledged.toJson(), isEmpty); }); + test('checks acknowledged subsets and allowed notifications', () { + const requested = SubscriptionFilter( + toolsListChanged: true, + resourceSubscriptions: [ + 'file:///project/config.json', + 'file:///project/other.json', + ], + ); + const acknowledged = SubscriptionFilter( + toolsListChanged: true, + resourceSubscriptions: ['file:///project/config.json'], + ); + + expect(acknowledged.isSubsetOf(requested), isTrue); + expect( + const SubscriptionFilter(promptsListChanged: true).isSubsetOf( + requested, + ), + isFalse, + ); + expect( + const SubscriptionFilter(resourcesListChanged: true).isSubsetOf( + requested, + ), + isFalse, + ); + expect( + const SubscriptionFilter( + resourceSubscriptions: ['file:///project/missing.json'], + ).isSubsetOf(requested), + isFalse, + ); + + expect( + acknowledged.allowsNotification( + const JsonRpcToolListChangedNotification(), + ), + isTrue, + ); + expect( + acknowledged.allowsNotification( + JsonRpcResourceUpdatedNotification( + updatedParams: const ResourceUpdatedNotification( + uri: 'file:///project/config.json', + ), + ), + ), + isTrue, + ); + expect( + acknowledged.allowsNotification( + JsonRpcResourceUpdatedNotification( + updatedParams: const ResourceUpdatedNotification( + uri: 'file:///project/missing.json', + ), + ), + ), + isFalse, + ); + expect( + acknowledged.allowsNotification( + const JsonRpcPromptListChangedNotification(), + ), + isFalse, + ); + }); + test('rejects malformed filters', () { expect( () => SubscriptionFilter.fromJson( @@ -130,6 +218,29 @@ void main() { }); group('JsonRpcSubscriptionsAcknowledgedNotification', () { + test('preserves subscription metadata on list changed notifications', () { + for (final notification in [ + const JsonRpcToolListChangedNotification( + meta: {McpMetaKey.subscriptionId: 'sub-1'}, + ), + const JsonRpcPromptListChangedNotification( + meta: {McpMetaKey.subscriptionId: 'sub-1'}, + ), + const JsonRpcResourceListChangedNotification( + meta: {McpMetaKey.subscriptionId: 'sub-1'}, + ), + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + const JsonRpcCompletionListChangedNotification( + meta: {McpMetaKey.subscriptionId: 'sub-1'}, + ), + ]) { + final parsed = JsonRpcMessage.fromJson(notification.toJson()) + as JsonRpcNotification; + + expect(parsed.meta?[McpMetaKey.subscriptionId], 'sub-1'); + } + }); + test('serializes and parses subscription acknowledgments', () { final notification = JsonRpcSubscriptionsAcknowledgedNotification( acknowledgedParams: const SubscriptionsAcknowledgedNotification( @@ -175,4 +286,115 @@ void main() { ); }); }); + + group('RequestHandlerExtra subscription helpers', () { + test('require acknowledgment before stream notifications', () async { + final sent = []; + final extra = _subscriptionExtra(sent); + + expect( + () => extra.sendSubscriptionNotification( + const JsonRpcToolListChangedNotification(), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains(Method.notificationsSubscriptionsAcknowledged), + ), + ), + ); + expect(sent, isEmpty); + }); + + test('allow only acknowledged notification filters', () async { + final sent = []; + final extra = _subscriptionExtra(sent); + + await extra.sendSubscriptionAcknowledged( + const SubscriptionFilter( + toolsListChanged: true, + resourcesListChanged: true, + resourceSubscriptions: ['file:///project/config.json'], + taskIds: ['task-1'], + ), + ); + expect(sent.single.method, Method.notificationsSubscriptionsAcknowledged); + sent.clear(); + + await extra.sendSubscriptionNotification( + const JsonRpcToolListChangedNotification(), + ); + await extra.sendSubscriptionNotification( + JsonRpcResourceUpdatedNotification( + updatedParams: const ResourceUpdatedNotification( + uri: 'file:///project/config.json', + ), + ), + ); + await extra.sendSubscriptionNotification( + const JsonRpcResourceListChangedNotification(), + ); + await extra.sendSubscriptionNotification( + JsonRpcTaskNotification( + task: const TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:01:00Z', + ttlMs: 300000, + ), + ), + ); + + expect(sent, hasLength(4)); + expect( + sent.map( + (notification) => notification.meta?[McpMetaKey.subscriptionId], + ), + everyElement('sub-1'), + ); + + expect( + () => extra.sendSubscriptionNotification( + const JsonRpcPromptListChangedNotification(), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('not requested or acknowledged'), + ), + ), + ); + expect( + () => extra.sendSubscriptionNotification( + JsonRpcResourceUpdatedNotification( + updatedParams: const ResourceUpdatedNotification( + uri: 'file:///project/other.json', + ), + ), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('not requested or acknowledged'), + ), + ), + ); + expect( + () => extra.sendSubscriptionNotification( + const JsonRpcNotification(method: 'notifications/custom'), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('not requested or acknowledged'), + ), + ), + ); + }); + }); } From e9b94c77e9c923ccdbb8465e482114cdef0cb3a7 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 08:20:48 -0400 Subject: [PATCH 03/68] Consolidate MCP 2026 result semantics --- CHANGELOG.md | 23 ++ lib/src/server/mcp_server.dart | 100 +++++- lib/src/server/mcp_ui.dart | 2 +- lib/src/server/server.dart | 201 +++++++++++- lib/src/types.dart | 1 + lib/src/types/elicitation.dart | 42 ++- lib/src/types/json_rpc.dart | 92 +++++- lib/src/types/sampling.dart | 23 +- lib/src/types/tools.dart | 49 ++- lib/src/types/validation.dart | 33 +- .../__brick__/lib/mcp/tools/base_tool.dart | 3 +- .../lib/mcp/tools/calculator_tool.dart | 2 +- test/client/client_tool_validation_test.dart | 57 +++- test/elicitation_test.dart | 56 ++++ test/interop/ts/src/client.ts | 7 +- test/mcp_2026_07_28_test.dart | 310 +++++++++++++++++- test/server/mcp_server_test.dart | 57 ++++ test/server/output_validation_test.dart | 139 +++++++- test/tool_schema_test.dart | 48 +++ test/types/sampling_test.dart | 53 +++ test/types_edge_cases_test.dart | 1 + 21 files changed, 1236 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dee8ca8..c9e88d61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,29 @@ - Added client-side `subscriptions/listen` handles that correlate stream notifications by `io.modelcontextprotocol/subscriptionId`, validate the acknowledgment, and cancel long-lived streams with `notifications/cancelled`. +- Allowed MCP 2026 tool `outputSchema` declarations to use any JSON Schema and + `structuredContent` results to carry any JSON value, while omitting non-object + structured output from stable 2025 responses. +- Allowed MCP 2026 `prompts/get` and `resources/read` handlers to return + `InputRequiredResult`, and rejected MRTR input-required results on unsupported + request methods. +- Rejected MCP 2026 MRTR `inputRequests` whose embedded client request type is + not declared in the caller's per-request client capabilities. +- Returned version-appropriate resource-not-found errors from high-level + `resources/read` handlers: stable 2025 uses legacy `-32002`, while MCP 2026 + stateless requests use `-32602` with the missing `uri` in error data. +- Enforced MCP 2026 `_meta` key-name grammar on stateless request metadata and + the 2026 request metadata builder while preserving legacy metadata parsing. +- Rejected negative cacheable-result `ttlMs` values during parsing instead of + clamping malformed wire values to zero. +- Validated MRTR `inputResponses` as `CreateMessageResult`, `ListRootsResult`, + or `ElicitResult` instead of accepting arbitrary result objects. +- Rejected non-integer numeric `ElicitResult.content` values to match the + stable and MCP 2026 schemas. +- Rejected form elicitation schemas that provide legacy `enumNames` without the + required string `enum`. +- Rejected `ElicitResult.content` when the result action is `decline` or + `cancel`. ## 2.2.0 diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index 2d88362e..a24b4cbc 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -46,6 +46,66 @@ List? _iconsFromLegacyImage(ImageContent? image) { ]; } +bool _isDraft2026Request(String? protocolVersion) => + protocolVersion != null && isStatelessProtocolVersion(protocolVersion); + +bool _isStableStructuredContentValue(Object? value) { + if (value is Map) { + return true; + } + if (value is Map) { + return value.keys.every((key) => key is String); + } + return false; +} + +JsonSchema? _outputSchemaForProtocol( + JsonSchema? schema, + String? protocolVersion, +) { + if (schema == null || _isDraft2026Request(protocolVersion)) { + return schema; + } + + // MCP 2025-11-25 restricts tool output schemas to object roots. MCP 2026 + // allows any JSON Schema, so omit non-object schemas for stable callers. + if (schema.toJson()['type'] == 'object') { + return schema; + } + return null; +} + +CallToolResult _toolResultForProtocol( + CallToolResult result, + String? protocolVersion, +) { + if (_isDraft2026Request(protocolVersion) || + !result.hasStructuredContent || + _isStableStructuredContentValue(result.structuredContent)) { + return result; + } + + return CallToolResult( + content: result.content, + isError: result.isError, + meta: result.meta, + extra: result.extra, + ); +} + +McpError _resourceNotFoundErrorForProtocol( + String uri, + String? protocolVersion, +) { + return McpError( + _isDraft2026Request(protocolVersion) + ? ErrorCode.invalidParams.value + : ErrorCode.resourceNotFound.value, + 'Resource not found', + {'uri': uri}, + ); +} + /// Definition for a completable argument. class CompletableDef { /// The callback to invoke to get completion suggestions. @@ -110,7 +170,7 @@ final class InterfaceToolCallback extends ToolCallback { } /// Callback signature for prompts. -typedef PromptCallback = FutureOr Function( +typedef PromptCallback = FutureOr Function( Map? args, RequestHandlerExtra? extra, ); @@ -149,13 +209,13 @@ typedef ListResourcesCallback = FutureOr Function( ); /// Callback to read a specific resource. -typedef ReadResourceCallback = FutureOr Function( +typedef ReadResourceCallback = FutureOr Function( Uri uri, RequestHandlerExtra extra, ); /// Callback to read a resource template. -typedef ReadResourceTemplateCallback = FutureOr Function( +typedef ReadResourceTemplateCallback = FutureOr Function( Uri uri, TemplateVariables variables, RequestHandlerExtra extra, @@ -219,6 +279,7 @@ CallToolResult _withRelatedTaskMeta(CallToolResult result, String taskId) { content: result.content, isError: result.isError, structuredContent: result.structuredContent, + hasStructuredContent: result.hasStructuredContent, meta: meta, extra: result.extra, ); @@ -516,7 +577,7 @@ abstract class RegisteredTool { ToolInputSchema? get inputSchema; /// The output schema for the tool. - ToolOutputSchema? get outputSchema; + JsonSchema? get outputSchema; /// Annotations for the tool. ToolAnnotations? get annotations; @@ -545,7 +606,7 @@ abstract class RegisteredTool { String? title, String? description, ToolInputSchema? inputSchema, - ToolOutputSchema? outputSchema, + JsonSchema? outputSchema, ToolAnnotations? annotations, ToolExecution? execution, ToolCallback? callback, @@ -563,7 +624,7 @@ class _RegisteredToolImpl implements RegisteredTool { @override ToolInputSchema? inputSchema; @override - ToolOutputSchema? outputSchema; + JsonSchema? outputSchema; @override ToolAnnotations? annotations; final ImageContent? icon; @@ -596,6 +657,7 @@ class _RegisteredToolImpl implements RegisteredTool { Tool toTool({ bool includeExecution = true, ToolInputSchema? inputSchemaOverride, + String? protocolVersion, }) { return Tool( name: name, @@ -603,7 +665,7 @@ class _RegisteredToolImpl implements RegisteredTool { description: description, inputSchema: inputSchemaOverride ?? inputSchema ?? const ToolInputSchema(), - outputSchema: outputSchema, + outputSchema: _outputSchemaForProtocol(outputSchema, protocolVersion), annotations: annotations, icon: icon, icons: _iconsFromLegacyImage(icon), @@ -627,7 +689,7 @@ class _RegisteredToolImpl implements RegisteredTool { String? title, String? description, ToolInputSchema? inputSchema, - ToolOutputSchema? outputSchema, + JsonSchema? outputSchema, ToolAnnotations? annotations, ToolExecution? execution, ToolCallback? callback, @@ -785,7 +847,7 @@ class ExperimentalMcpServerTasks { String? title, String? description, ToolInputSchema? inputSchema, - ToolOutputSchema? outputSchema, + JsonSchema? outputSchema, ToolAnnotations? annotations, Map? meta, ToolExecution? execution, @@ -1424,6 +1486,8 @@ class McpServer { inputSchemaOverride: isStatelessRequest ? _toolInputSchemaForStatelessList(tool) : null, + protocolVersion: + protocolVersion is String ? protocolVersion : null, ), ) .toList(), @@ -1551,6 +1615,13 @@ class McpServer { } } + if (result is CallToolResult) { + return _toolResultForProtocol( + result, + protocolVersion is String ? protocolVersion : null, + ); + } + return result; } catch (error) { _logger.warn("Error executing tool '$toolName': $error"); @@ -1751,9 +1822,10 @@ class McpServer { return await Future.value(entry.readCallback(uri, vars, extra)); } } - throw McpError( - ErrorCode.invalidParams.value, - "Resource not found: $uriString", + final protocolVersion = extra.meta?[McpMetaKey.protocolVersion]; + throw _resourceNotFoundErrorForProtocol( + uriString, + protocolVersion is String ? protocolVersion : null, ); }, (id, params, meta) => JsonRpcReadResourceRequest.fromJson({ @@ -1963,7 +2035,7 @@ class McpServer { String? title, String? description, ToolInputSchema? inputSchema, - ToolOutputSchema? outputSchema, + JsonSchema? outputSchema, ToolAnnotations? annotations, Map? meta, required ToolFunction callback, @@ -1987,7 +2059,7 @@ class McpServer { String? title, String? description, ToolInputSchema? inputSchema, - ToolOutputSchema? outputSchema, + JsonSchema? outputSchema, ToolAnnotations? annotations, Map? meta, ToolExecution? execution, diff --git a/lib/src/server/mcp_ui.dart b/lib/src/server/mcp_ui.dart index 9c1e86c4..bf69e4c2 100644 --- a/lib/src/server/mcp_ui.dart +++ b/lib/src/server/mcp_ui.dart @@ -10,7 +10,7 @@ class McpUiAppToolConfig { final String? title; final String? description; final ToolInputSchema? inputSchema; - final ToolOutputSchema? outputSchema; + final JsonSchema? outputSchema; final ToolAnnotations? annotations; final Map meta; diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 04bd268c..e5da1fdb 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -4,6 +4,7 @@ import 'package:mcp_dart/src/shared/json_schema/json_schema_validator.dart'; import 'package:mcp_dart/src/shared/logging.dart'; import 'package:mcp_dart/src/shared/protocol.dart'; import 'package:mcp_dart/src/types.dart'; +import 'package:mcp_dart/src/types/json_rpc.dart' as json_rpc; final _logger = Logger("mcp_dart.server"); @@ -77,6 +78,12 @@ class Server extends Protocol { Method.notificationsRootsListChanged, }; + static const Set _inputRequiredResultMethods = { + Method.toolsCall, + Method.promptsGet, + Method.resourcesRead, + }; + /// Callback to be notified when the server is fully initialized. void Function()? oninitialized; @@ -150,6 +157,16 @@ class Server extends Protocol { McpError? _validateStatelessRequestMetadata(JsonRpcRequest request) { final meta = request.meta; + try { + json_rpc.validateRequestMeta(meta, validateKeys: true); + } on FormatException catch (error) { + return McpError( + ErrorCode.invalidRequest.value, + 'Invalid stateless request metadata.', + error.message, + ); + } + final requestedVersion = meta?[McpMetaKey.protocolVersion]; if (requestedVersion is! String || requestedVersion.isEmpty) { return McpError( @@ -240,6 +257,22 @@ class Server extends Protocol { ); } + McpError _missingInputRequestClientCapabilityError( + String inputRequestKey, + String method, + Map requiredCapabilities, + ) { + return McpError( + ErrorCode.missingRequiredClientCapability.value, + 'Missing required client capability for input request', + { + 'inputRequest': inputRequestKey, + 'method': method, + 'requiredCapabilities': requiredCapabilities, + }, + ); + } + bool _isStatelessRequest(JsonRpcRequest request) { final requestedProtocolVersion = request.meta?[McpMetaKey.protocolVersion]; return requestedProtocolVersion is String && @@ -395,6 +428,80 @@ class Server extends Protocol { } } + McpError? _validateInputRequiredClientCapabilities( + InputRequiredResult result, + JsonRpcRequest request, + ) { + final inputRequests = result.inputRequests; + if (inputRequests == null || inputRequests.isEmpty) { + return null; + } + + final parsed = _clientCapabilitiesForRequest(request); + if (parsed.error != null) { + return parsed.error; + } + + for (final entry in inputRequests.entries) { + final requiredCapabilities = _missingCapabilitiesForInputRequest( + entry.value, + parsed.capabilities, + ); + if (requiredCapabilities != null) { + return _missingInputRequestClientCapabilityError( + entry.key, + entry.value.method, + requiredCapabilities, + ); + } + } + + return null; + } + + Map? _missingCapabilitiesForInputRequest( + InputRequest inputRequest, + ClientCapabilities? capabilities, + ) { + switch (inputRequest.method) { + case Method.elicitationCreate: + final elicitParams = inputRequest.elicitParams; + final requiredMode = elicitParams.isUrlMode ? 'url' : 'form'; + final elicitation = capabilities?.elicitation; + final supportsMode = requiredMode == 'url' + ? elicitation?.url != null + : elicitation?.form != null; + if (!supportsMode) { + return { + 'elicitation': { + requiredMode: {}, + }, + }; + } + return null; + case Method.samplingCreateMessage: + final createParams = inputRequest.createMessageParams; + final sampling = capabilities?.sampling; + if (sampling == null) { + return {'sampling': {}}; + } + if ((createParams.tools != null || createParams.toolChoice != null) && + !sampling.tools) { + return { + 'sampling': {'tools': {}}, + }; + } + return null; + case Method.rootsList: + if (capabilities?.roots == null) { + return {'roots': {}}; + } + return null; + default: + return null; + } + } + McpError? _validateRequestTaskSemantics(JsonRpcRequest request) { final removedMethodError = _validateDraftTaskMethods(request); if (removedMethodError != null) { @@ -419,7 +526,7 @@ class Server extends Protocol { if (result is CallToolResult) { return true; } - if (result is InputRequiredResult && _isStatelessRequest(request)) { + if (_allowsInputRequiredResult(result, request)) { return true; } if (result is CreateTaskExtensionResult && _isStatelessRequest(request)) { @@ -430,6 +537,55 @@ class Server extends Protocol { return false; } + bool _allowsPromptGetResult(BaseResultData result, JsonRpcRequest request) { + return result is GetPromptResult || + _allowsInputRequiredResult(result, request); + } + + bool _allowsResourceReadResult( + BaseResultData result, + JsonRpcRequest request, + ) { + return result is ReadResourceResult || + _allowsInputRequiredResult(result, request); + } + + bool _allowsInputRequiredResult( + BaseResultData result, + JsonRpcRequest request, + ) { + if (result is! InputRequiredResult || + !_isStatelessRequest(request) || + !_inputRequiredResultMethods.contains(request.method)) { + return false; + } + + final capabilityError = _validateInputRequiredClientCapabilities( + result, + request, + ); + if (capabilityError != null) { + throw capabilityError; + } + + return true; + } + + void _validateUnsupportedInputRequiredResult( + BaseResultData result, + JsonRpcRequest request, + ) { + if (result is! InputRequiredResult) { + return; + } + + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid ${request.method} result: InputRequiredResult is only supported ' + 'by ${_inputRequiredResultMethods.join(', ')} in MCP stateless requests.', + ); + } + bool _allowsTaskExtensionResult( BaseResultData result, JsonRpcRequest request, @@ -707,6 +863,38 @@ class Server extends Protocol { return result; } + super.setRequestHandler(method, wrappedHandler, requestFactory); + } else if (method == Method.promptsGet) { + Future wrappedHandler( + ReqT request, + RequestHandlerExtra extra, + ) async { + final result = await handler(request, extra); + if (!_allowsPromptGetResult(result, request)) { + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid prompts/get result: Expected GetPromptResult', + ); + } + return result; + } + + super.setRequestHandler(method, wrappedHandler, requestFactory); + } else if (method == Method.resourcesRead) { + Future wrappedHandler( + ReqT request, + RequestHandlerExtra extra, + ) async { + final result = await handler(request, extra); + if (!_allowsResourceReadResult(result, request)) { + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid resources/read result: Expected ReadResourceResult', + ); + } + return result; + } + super.setRequestHandler(method, wrappedHandler, requestFactory); } else if (method == Method.tasksGet || method == Method.tasksCancel || @@ -727,7 +915,16 @@ class Server extends Protocol { super.setRequestHandler(method, wrappedHandler, requestFactory); } else { - super.setRequestHandler(method, handler, requestFactory); + Future wrappedHandler( + ReqT request, + RequestHandlerExtra extra, + ) async { + final result = await handler(request, extra); + _validateUnsupportedInputRequiredResult(result, request); + return result; + } + + super.setRequestHandler(method, wrappedHandler, requestFactory); } } diff --git a/lib/src/types.dart b/lib/src/types.dart index e9087c0e..7f392fc4 100644 --- a/lib/src/types.dart +++ b/lib/src/types.dart @@ -9,6 +9,7 @@ export 'types/json_rpc.dart' extractRequestMeta, parseProgressToken, parseRequestId, + validateMetaKeyName, validateRequestMeta; export 'types/mcp_ui.dart'; export 'types/misc.dart'; diff --git a/lib/src/types/elicitation.dart b/lib/src/types/elicitation.dart index e32f668e..7543311c 100644 --- a/lib/src/types/elicitation.dart +++ b/lib/src/types/elicitation.dart @@ -267,9 +267,16 @@ class ElicitResult implements BaseResultData { throw FormatException('Invalid elicitation action: $action'); } + final content = _parseElicitResultContent(json['content']); + _validateElicitResultContentForAction( + action, + content, + formatException: true, + ); + return ElicitResult( action: action, - content: _parseElicitResultContent(json['content']), + content: content, url: json['url'] as String?, elicitationId: json['elicitationId'] as String?, meta: (json['_meta'] as Map?)?.cast(), @@ -278,9 +285,11 @@ class ElicitResult implements BaseResultData { @override Map toJson() { + final resultAction = action; + _validateElicitResultContentForAction(resultAction, content); _validateElicitResultContent(content); return { - 'action': action, + 'action': resultAction, if (content != null) 'content': content, if (meta != null) '_meta': meta, }; @@ -533,6 +542,9 @@ void _validateStringOrSingleEnumSchema( (enumNames is! List || enumNames.any((value) => value is! String))) { throw FormatException('$context.enumNames must be a string array.'); } + if (enumNames != null && enumValues == null) { + throw FormatException('$context.enumNames requires enum.'); + } final format = json['format']; if (format != null && !const {'email', 'uri', 'date', 'date-time'}.contains(format)) { @@ -616,7 +628,7 @@ void _validateElicitResultContent( } for (final entry in content.entries) { final value = entry.value; - if (value is String || value is num || value is bool) { + if (value is String || value is int || value is bool) { continue; } if (value is List && value.every((item) => item is String)) { @@ -624,17 +636,37 @@ void _validateElicitResultContent( } if (formatException) { throw FormatException( - 'ElicitResult.content.${entry.key} must be string, number, boolean, or string[]', + 'ElicitResult.content.${entry.key} must be string, integer, boolean, or string[]', ); } throw ArgumentError.value( value, 'content.${entry.key}', - 'ElicitResult content values must be string, number, boolean, or string[]', + 'ElicitResult content values must be string, integer, boolean, or string[]', ); } } +void _validateElicitResultContentForAction( + String action, + Map? content, { + bool formatException = false, +}) { + if (content == null || action == 'accept') { + return; + } + if (formatException) { + throw const FormatException( + 'ElicitResult.content is only allowed when action is accept.', + ); + } + throw ArgumentError.value( + content, + 'content', + 'ElicitResult.content is only allowed when action is accept.', + ); +} + void _validateUrlElicitations( List elicitations, { bool formatException = false, diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index 5fe7b407..b2098250 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -82,6 +82,7 @@ Map buildProtocolRequestMeta({ Map? meta, Object? logLevel, }) { + validateRequestMeta(meta, validateKeys: true); return { ...?meta, McpMetaKey.protocolVersion: protocolVersion, @@ -203,12 +204,66 @@ RequestId? _parseErrorResponseId(Map json) { return parseRequestId(json['id']); } +final _metaPrefixLabelPattern = RegExp( + r'^[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?$', +); +final _metaNamePattern = RegExp( + r'^(?:[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?)?$', +); + +/// Validates an MCP 2026 `_meta` key name. +/// +/// MCP 2026 constrains metadata keys to an optional dot-separated prefix +/// followed by `/`, plus a name segment. Earlier protocol versions did not +/// define this grammar, so callers choose when to enforce it. +void validateMetaKeyName(String key, {String fieldName = '_meta'}) { + final slashIndex = key.indexOf('/'); + final prefix = slashIndex == -1 ? null : key.substring(0, slashIndex); + final name = slashIndex == -1 ? key : key.substring(slashIndex + 1); + + if (prefix != null) { + if (prefix.isEmpty) { + throw FormatException( + 'Invalid $fieldName key "$key": prefix must not be empty', + ); + } + final labels = prefix.split('.'); + for (final label in labels) { + if (!_metaPrefixLabelPattern.hasMatch(label)) { + throw FormatException( + 'Invalid $fieldName key "$key": invalid prefix label "$label"', + ); + } + } + } + + if (!_metaNamePattern.hasMatch(name)) { + throw FormatException( + 'Invalid $fieldName key "$key": invalid name segment "$name"', + ); + } +} + /// Validates request metadata that can affect protocol behavior. /// /// `_meta.progressToken` is an MCP wire token and must be a string or integer -/// when present. Other `_meta` fields are preserved without interpretation. -Map? validateRequestMeta(Map? meta) { - if (meta != null && meta.containsKey('progressToken')) { +/// when present. [validateKeys] opts in to the MCP 2026 `_meta` key-name +/// grammar without changing stable/legacy request parsing. +Map? validateRequestMeta( + Map? meta, { + bool validateKeys = false, +}) { + if (meta == null) { + return null; + } + + if (validateKeys) { + for (final key in meta.keys) { + validateMetaKeyName(key); + } + } + + if (meta.containsKey('progressToken')) { parseProgressToken( meta['progressToken'], fieldName: '_meta.progressToken', @@ -457,6 +512,9 @@ enum ErrorCode { /// code. [requestTimeout] is retained for older SDK behavior. headerMismatch(-32001), + /// Resource not found in stable MCP 2025-11-25. + resourceNotFound(-32002), + /// Required per-request client capabilities were not declared. missingRequiredClientCapability(-32003), @@ -700,6 +758,12 @@ class InputResponse { } factory InputResponse.fromJson(Map json) { + if (!_isValidInputResponse(json)) { + throw const FormatException( + 'InputResponse must be a CreateMessageResult, ListRootsResult, ' + 'or ElicitResult', + ); + } return InputResponse.raw(Map.from(json)); } @@ -727,6 +791,28 @@ class InputResponse { Map toJson() => Map.from(value); } +bool _isValidInputResponse(Map json) { + return _canParseInputResponse(CreateMessageResult.fromJson, json) || + _canParseInputResponse(ListRootsResult.fromJson, json) || + _canParseInputResponse(ElicitResult.fromJson, json); +} + +bool _canParseInputResponse( + BaseResultData Function(Map json) parser, + Map json, +) { + try { + parser(json); + return true; + } on FormatException { + return false; + } on ArgumentError { + return false; + } on TypeError { + return false; + } +} + /// 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. diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index 1e4d8059..9903bdda 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -310,8 +310,11 @@ sealed class SamplingContent { final SamplingToolResultContent c => { 'toolUseId': c.toolUseId, 'content': c.contentBlocks.map((item) => item.toJson()).toList(), - if (c.structuredContent != null) - 'structuredContent': c.structuredContent, + if (c.hasStructuredContent) + 'structuredContent': readJsonValue( + c.structuredContent, + 'SamplingToolResultContent.structuredContent', + ), if (c.isError != null) 'isError': c.isError, if (c.meta != null) '_meta': c.meta, }, @@ -431,7 +434,8 @@ class SamplingToolUseContent extends SamplingContent { class SamplingToolResultContent extends SamplingContent { final String toolUseId; final dynamic content; - final Map? structuredContent; + final Object? structuredContent; + final bool hasStructuredContent; final bool? isError; final Map? meta; @@ -439,9 +443,12 @@ class SamplingToolResultContent extends SamplingContent { required this.toolUseId, required this.content, this.structuredContent, + bool? hasStructuredContent, this.isError, this.meta, - }) : super(type: 'tool_result'); + }) : hasStructuredContent = + hasStructuredContent ?? structuredContent != null, + super(type: 'tool_result'); /// Normalized content blocks for tool results. List get contentBlocks => _parseToolResultContent(content); @@ -454,7 +461,13 @@ class SamplingToolResultContent extends SamplingContent { return SamplingToolResultContent( toolUseId: json['toolUseId'] as String, content: _parseToolResultWireContent(json['content']), - structuredContent: _asJsonObjectOrNull(json['structuredContent']), + structuredContent: json.containsKey('structuredContent') + ? readJsonValue( + json['structuredContent'], + 'SamplingToolResultContent.structuredContent', + ) + : null, + hasStructuredContent: json.containsKey('structuredContent'), isError: json['isError'] as bool?, meta: _asJsonObjectOrNull(json['_meta']), ); diff --git a/lib/src/types/tools.dart b/lib/src/types/tools.dart index 57da2dc2..519f5b6b 100644 --- a/lib/src/types/tools.dart +++ b/lib/src/types/tools.dart @@ -9,7 +9,10 @@ import 'validation.dart'; /// Legacy alias for [JsonObject] used as tool input schema. typedef ToolInputSchema = JsonObject; -/// Legacy alias for [JsonObject] used as tool output schema. +/// Legacy alias for object-root tool output schemas. +/// +/// MCP 2026-07-28 allows [Tool.outputSchema] to be any JSON Schema. Use +/// [JsonSchema] directly when the output schema root is not an object. typedef ToolOutputSchema = JsonObject; /// Additional properties describing a Tool to clients. @@ -149,7 +152,7 @@ class Tool { /// JSON Schema defining the tool's input parameters. final JsonSchema inputSchema; - /// JSON Schema defining the tool's output parameters. + /// JSON Schema defining the tool's structured output. final JsonSchema? outputSchema; /// Optional additional properties describing the tool. @@ -197,13 +200,6 @@ class Tool { _readOptionalJsonObject(json['outputSchema'], 'Tool.outputSchema'); final outputSchema = outputSchemaJson == null ? null : JsonSchema.fromJson(outputSchemaJson); - if (outputSchema != null) { - _validateObjectRootSchema( - outputSchema, - 'Tool.outputSchema', - formatException: true, - ); - } return Tool( name: json['name'] as String, @@ -231,9 +227,6 @@ class Tool { Map toJson() { _validateObjectRootSchema(inputSchema, 'Tool.inputSchema'); - if (outputSchema != null) { - _validateObjectRootSchema(outputSchema!, 'Tool.outputSchema'); - } return { 'name': name, @@ -390,7 +383,15 @@ class CallToolResult implements BaseResultData { final bool isError; /// Structured content returned by the tool. - final Map? structuredContent; + /// + /// MCP 2026-07-28 allows any JSON value: object, array, string, number, + /// boolean, or null. + final Object? structuredContent; + + /// Whether [structuredContent] was explicitly present. + /// + /// This distinguishes an omitted field from an explicit JSON `null`. + final bool hasStructuredContent; /// Optional metadata. @override @@ -403,24 +404,26 @@ class CallToolResult implements BaseResultData { required this.content, this.isError = false, this.structuredContent, + bool? hasStructuredContent, this.meta, this.extra, - }); + }) : hasStructuredContent = hasStructuredContent ?? structuredContent != null; /// Creates a result from a list of content items. factory CallToolResult.fromContent(List content) { return CallToolResult(content: content); } - /// Creates a result from arbitrary structured data. + /// Creates a result from arbitrary structured JSON data. /// /// Automatically populates [content] with a JSON-serialized version of /// [content] for backward compatibility with clients that do not support /// [structuredContent]. - factory CallToolResult.fromStructuredContent(Map content) { + factory CallToolResult.fromStructuredContent(Object? content) { return CallToolResult( content: [TextContent(text: jsonEncode(content))], structuredContent: content, + hasStructuredContent: true, ); } @@ -438,7 +441,13 @@ class CallToolResult implements BaseResultData { .map((e) => Content.fromJson(e as Map)) .toList(), isError: json['isError'] as bool? ?? false, - structuredContent: json['structuredContent'] as Map?, + structuredContent: json.containsKey('structuredContent') + ? readJsonValue( + json['structuredContent'], + 'CallToolResult.structuredContent', + ) + : null, + hasStructuredContent: json.containsKey('structuredContent'), meta: json['_meta'] as Map?, extra: extra.isEmpty ? null : extra, ); @@ -448,7 +457,11 @@ class CallToolResult implements BaseResultData { Map toJson() => { 'content': content.map((e) => e.toJson()).toList(), if (isError) 'isError': isError, - if (structuredContent != null) 'structuredContent': structuredContent, + if (hasStructuredContent) + 'structuredContent': readJsonValue( + structuredContent, + 'CallToolResult.structuredContent', + ), if (meta != null) '_meta': meta, ...?extra, }; diff --git a/lib/src/types/validation.dart b/lib/src/types/validation.dart index ee29376b..ee101058 100644 --- a/lib/src/types/validation.dart +++ b/lib/src/types/validation.dart @@ -49,7 +49,10 @@ int? readOptionalTtlMs(Object? value, String field) { if (ttlMs == null) { return null; } - return ttlMs < 0 ? 0 : ttlMs; + if (ttlMs < 0) { + throw FormatException('$field must be greater than or equal to 0'); + } + return ttlMs; } void validateTtlMs(int? value, String field) { @@ -88,3 +91,31 @@ void validateCacheScope(String? value, String field) { ); } } + +Object? readJsonValue(Object? value, String field) { + if (value == null || value is String || value is bool) { + return value; + } + if (value is num) { + if (!value.isFinite) { + throw FormatException('$field must be a finite JSON number'); + } + return value; + } + if (value is List) { + return value.map((item) => readJsonValue(item, '$field[]')).toList(); + } + if (value is Map) { + if (value.keys.any((key) => key is! String)) { + throw FormatException('$field must be a JSON object with string keys'); + } + return { + for (final entry in value.entries) + entry.key as String: readJsonValue( + entry.value, + '$field.${entry.key}', + ), + }; + } + throw FormatException('$field must be a JSON value'); +} diff --git a/packages/templates/simple/__brick__/lib/mcp/tools/base_tool.dart b/packages/templates/simple/__brick__/lib/mcp/tools/base_tool.dart index 5003afdb..a6780e0b 100644 --- a/packages/templates/simple/__brick__/lib/mcp/tools/base_tool.dart +++ b/packages/templates/simple/__brick__/lib/mcp/tools/base_tool.dart @@ -25,8 +25,7 @@ abstract class BaseTool { ToolInputSchema get inputSchema; /// Optional JSON schema defining the output format. - /// Use [JsonSchema.object()] to create an object schema. - ToolOutputSchema? get outputSchema => null; + JsonSchema? get outputSchema => null; /// Optional tool annotations with hints about tool behavior. /// Includes readOnlyHint, destructiveHint, idempotentHint, etc. diff --git a/packages/templates/simple/__brick__/lib/mcp/tools/calculator_tool.dart b/packages/templates/simple/__brick__/lib/mcp/tools/calculator_tool.dart index 76d5c5e2..71cc2ab3 100644 --- a/packages/templates/simple/__brick__/lib/mcp/tools/calculator_tool.dart +++ b/packages/templates/simple/__brick__/lib/mcp/tools/calculator_tool.dart @@ -20,7 +20,7 @@ class CalculatorTool extends BaseTool { ); @override - ToolOutputSchema? get outputSchema => ToolOutputSchema( + JsonSchema? get outputSchema => ToolOutputSchema( properties: { 'result': JsonSchema.number(description: 'The sum of a and b'), }, diff --git a/test/client/client_tool_validation_test.dart b/test/client/client_tool_validation_test.dart index 13879310..252263ed 100644 --- a/test/client/client_tool_validation_test.dart +++ b/test/client/client_tool_validation_test.dart @@ -59,6 +59,21 @@ class MockTransport extends Transport .toJson(), ), ); + } else if (name == 'array_tool') { + _respond( + JsonRpcResponse( + id: message.id, + result: CallToolResult.fromStructuredContent(['alpha', 'beta']) + .toJson(), + ), + ); + } else if (name == 'broken_array_tool') { + _respond( + JsonRpcResponse( + id: message.id, + result: CallToolResult.fromStructuredContent(['alpha', 1]).toJson(), + ), + ); } else if (name == 'broken_tool') { // Returns data that violates the schema (missing 'result') _respond( @@ -113,6 +128,16 @@ class MockTransport extends Transport required: ['result'], ), ), + Tool( + name: 'array_tool', + inputSchema: const ToolInputSchema(), + outputSchema: JsonSchema.array(items: JsonSchema.string()), + ), + Tool( + name: 'broken_array_tool', + inputSchema: const ToolInputSchema(), + outputSchema: JsonSchema.array(items: JsonSchema.string()), + ), const Tool( name: 'task_required_tool', inputSchema: ToolInputSchema(), @@ -248,7 +273,37 @@ void main() { const CallToolRequest(name: 'validated_tool'), ); - expect(result.structuredContent?['result'], equals('success')); + final structured = result.structuredContent as Map; + expect(structured['result'], equals('success')); + }); + + test('validates non-object tool output schemas successfully', () async { + await client.connect(transport); + await client.listTools(); + + final result = await client.callTool( + const CallToolRequest(name: 'array_tool'), + ); + + expect(result.structuredContent, equals(['alpha', 'beta'])); + }); + + test('throws when non-object tool output validation fails', () async { + await client.connect(transport); + await client.listTools(); + + expect( + () => client.callTool( + const CallToolRequest(name: 'broken_array_tool'), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Structured content does not match'), + ), + ), + ); }); test('throws when tool output validation fails', () async { diff --git a/test/elicitation_test.dart b/test/elicitation_test.dart index f8877e39..08580a46 100644 --- a/test/elicitation_test.dart +++ b/test/elicitation_test.dart @@ -945,6 +945,26 @@ void main() { ), throwsA(isA()), ); + expect( + () => ElicitRequestParams.fromJson( + requestWithProperty('value', { + 'type': 'string', + 'enumNames': ['Ok'], + }), + ), + throwsA(isA()), + ); + expect( + () => const ElicitRequestParams.form( + message: 'Bad enum names', + requestedSchema: JsonObject( + properties: { + 'value': JsonString(enumNames: ['Ok']), + }, + ), + ).toJson(), + throwsA(isA()), + ); expect( () => ElicitRequestParams.fromJson( requestWithProperty('value', { @@ -1022,6 +1042,24 @@ void main() { }), throwsA(isA()), ); + expect( + () => ElicitResult.fromJson({ + 'action': 'accept', + 'content': { + 'ratio': 0.5, + }, + }), + throwsA(isA()), + ); + expect( + () => ElicitResult.fromJson({ + 'action': 'decline', + 'content': { + 'name': 'Alice', + }, + }), + throwsA(isA()), + ); expect( () => const ElicitResult( action: 'accept', @@ -1031,6 +1069,24 @@ void main() { ).toJson(), throwsA(isA()), ); + expect( + () => const ElicitResult( + action: 'accept', + content: { + 'ratio': 0.5, + }, + ).toJson(), + throwsA(isA()), + ); + expect( + () => const ElicitResult( + action: 'cancel', + content: { + 'name': 'Alice', + }, + ).toJson(), + throwsA(isA()), + ); }); test('URLElicitationRequiredErrorData validates URL-only entries', () { diff --git a/test/interop/ts/src/client.ts b/test/interop/ts/src/client.ts index fa048f97..11717777 100644 --- a/test/interop/ts/src/client.ts +++ b/test/interop/ts/src/client.ts @@ -238,15 +238,10 @@ async function assertRawDartServerWireShapes(client: Client): Promise { ); } if (tool.outputSchema !== undefined) { - const outputSchema = requireRecord( + requireRecord( tool.outputSchema, 'raw tool outputSchema' ); - if (outputSchema.type !== 'object') { - throw new Error( - `tool outputSchema was not object-root: ${JSON.stringify(tool)}` - ); - } } } diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 84458e85..498f65a7 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -234,12 +234,14 @@ class CompletedTaskHandler extends CancelTaskResultHandler { Map _clientMeta({ String? protocolVersion, ClientCapabilities clientCapabilities = const ClientCapabilities(), + Map? meta, Object? logLevel, }) { return buildProtocolRequestMeta( protocolVersion: protocolVersion ?? draftProtocolVersion2026_07_28, clientInfo: const Implementation(name: 'client', version: '1.0.0'), clientCapabilities: clientCapabilities, + meta: meta, logLevel: logLevel, ); } @@ -265,11 +267,15 @@ void main() { protocolVersion: draftProtocolVersion2026_07_28, clientInfo: const Implementation(name: 'client', version: '1.0.0'), clientCapabilities: const ClientCapabilities(), - meta: const {'caller': 'value'}, + meta: const { + 'caller': 'value', + 'com.example.trace/id': 'trace-1', + }, logLevel: 'debug', ); expect(meta['caller'], 'value'); + expect(meta['com.example.trace/id'], 'trace-1'); expect( meta[McpMetaKey.protocolVersion], draftProtocolVersion2026_07_28, @@ -282,6 +288,35 @@ void main() { expect(meta[McpMetaKey.logLevel], 'debug'); }); + test('rejects invalid 2026 request metadata keys during construction', () { + for (final key in [ + '/name', + '1bad/name', + 'bad prefix/value', + 'com.example./name', + 'com.example/name_', + ]) { + expect( + () => buildProtocolRequestMeta( + protocolVersion: draftProtocolVersion2026_07_28, + clientInfo: const Implementation( + name: 'client', + version: '1.0.0', + ), + clientCapabilities: const ClientCapabilities(), + meta: {key: 'value'}, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains(key), + ), + ), + ); + } + }); + test('serializes server/discover request and result', () { final request = JsonRpcServerDiscoverRequest( id: 'discover-1', @@ -378,8 +413,8 @@ void main() { expect(const ListToolsResult(tools: []).toJson(), {'tools': []}); expect( - ListToolsResult.fromJson(const {'tools': [], 'ttlMs': -1}).ttlMs, - 0, + () => ListToolsResult.fromJson(const {'tools': [], 'ttlMs': -1}), + throwsFormatException, ); expect( () => ListToolsResult.fromJson( @@ -568,6 +603,17 @@ void main() { ), throwsFormatException, ); + expect( + () => ReadResourceRequest.fromJson( + const { + 'uri': 'file:///repo/README.md', + 'inputResponses': { + 'unknown': {'unexpected': true}, + }, + }, + ), + throwsFormatException, + ); }); test('server acknowledges subscriptions/listen with subscription id', @@ -1356,6 +1402,241 @@ void main() { expect(response.result['requestState'], 'retry-state'); }); + test('stateless input required requests require client capabilities', + () 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 { + final inputRequest = switch (request.callParams.name) { + 'needs-form' => InputRequest.elicit( + ElicitRequest.form( + message: 'Enter name', + requestedSchema: JsonSchema.object( + properties: {'name': JsonSchema.string()}, + required: ['name'], + ), + ), + ), + 'needs-url' => InputRequest.elicit( + const ElicitRequest.url( + message: 'Open browser', + url: 'https://example.com/authorize', + elicitationId: 'auth-1', + ), + ), + 'needs-roots' => InputRequest.listRoots(), + 'needs-sampling-tools' => InputRequest.createMessage( + const CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Search'), + ), + ], + maxTokens: 16, + tools: [ + Tool(name: 'lookup', inputSchema: JsonObject()), + ], + ), + ), + _ => throw StateError('Unknown tool ${request.callParams.name}'), + }; + + return InputRequiredResult( + inputRequests: {request.callParams.name: inputRequest}, + ); + }, + (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'id': id, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + final missingCapabilityCases = [ + ( + name: 'needs-form', + meta: _clientMeta(), + method: Method.elicitationCreate, + requiredCapabilities: { + 'elicitation': {'form': {}}, + }, + ), + ( + name: 'needs-url', + meta: _clientMeta( + clientCapabilities: const ClientCapabilities( + elicitation: ClientElicitation.formOnly(), + ), + ), + method: Method.elicitationCreate, + requiredCapabilities: { + 'elicitation': {'url': {}}, + }, + ), + ( + name: 'needs-roots', + meta: _clientMeta(), + method: Method.rootsList, + requiredCapabilities: {'roots': {}}, + ), + ( + name: 'needs-sampling-tools', + meta: _clientMeta( + clientCapabilities: const ClientCapabilities( + sampling: ClientCapabilitiesSampling(), + ), + ), + method: Method.samplingCreateMessage, + requiredCapabilities: { + 'sampling': {'tools': {}}, + }, + ), + ]; + + for (final scenario in missingCapabilityCases) { + transport.sentMessages.clear(); + transport.receive( + JsonRpcCallToolRequest( + id: scenario.name, + params: CallToolRequest(name: scenario.name).toJson(), + meta: scenario.meta, + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect( + response.error.code, + ErrorCode.missingRequiredClientCapability.value, + ); + expect(response.error.data['inputRequest'], scenario.name); + expect(response.error.data['method'], scenario.method); + expect( + response.error.data['requiredCapabilities'], + scenario.requiredCapabilities, + ); + } + + transport.sentMessages.clear(); + transport.receive( + JsonRpcCallToolRequest( + id: 'allowed-form', + params: const CallToolRequest(name: 'needs-form').toJson(), + meta: _clientMeta( + clientCapabilities: const ClientCapabilities( + elicitation: ClientElicitation.formOnly(), + ), + ), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + expect(response.result['resultType'], resultTypeInputRequired); + expect( + response.result['inputRequests']['needs-form']['method'], + Method.elicitationCreate, + ); + }); + + test('stateless prompts/get permits input required results', () async { + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + server.registerPrompt( + 'needs_input', + callback: (args, extra) => + const InputRequiredResult(requestState: 'prompt-state'), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcGetPromptRequest( + id: 'prompt-1', + getParams: const GetPromptRequest(name: 'needs_input'), + meta: _clientMeta(), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + expect(response.result['resultType'], resultTypeInputRequired); + expect(response.result['requestState'], 'prompt-state'); + }); + + test('stateless resources/read permits input required results', () async { + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + server.registerResource( + 'needs_input', + 'memory://needs-input', + null, + (uri, extra) => + const InputRequiredResult(requestState: 'resource-state'), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcReadResourceRequest( + id: 'resource-1', + readParams: const ReadResourceRequest(uri: 'memory://needs-input'), + meta: _clientMeta(), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + expect(response.result['resultType'], resultTypeInputRequired); + expect(response.result['requestState'], 'resource-state'); + }); + + test('stateless unsupported methods reject input required results', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: + ServerCapabilities(prompts: ServerCapabilitiesPrompts()), + ), + ); + server.setRequestHandler( + Method.promptsList, + (request, extra) async => + const InputRequiredResult(requestState: 'list-state'), + (id, params, meta) => JsonRpcListPromptsRequest.fromJson({ + 'id': id, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcListPromptsRequest(id: 'prompts', meta: _clientMeta()), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect(response.error.code, ErrorCode.invalidParams.value); + expect(response.error.message, contains('InputRequiredResult')); + expect(response.error.message, contains(Method.promptsGet)); + expect(response.error.message, contains(Method.resourcesRead)); + expect(response.error.message, contains(Method.toolsCall)); + }); + test('stateless required legacy task tool resolves to final result', () async { final server = McpServer( @@ -1624,6 +1905,29 @@ void main() { contains(McpMetaKey.logLevel), ), ); + expect( + validateToolRequest({ + ..._clientMeta(), + 'bad prefix/value': 'value', + }), + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.invalidRequest.value, + ) + .having( + (error) => error.data, + 'data', + contains('bad prefix/value'), + ), + ); + expect( + validateToolRequest( + _clientMeta(meta: const {'com.example.trace/id': 'trace-1'}), + ), + isNull, + ); }); test('server rejects core RPCs removed from stateless MCP', () async { diff --git a/test/server/mcp_server_test.dart b/test/server/mcp_server_test.dart index 9c38661e..a11519f5 100644 --- a/test/server/mcp_server_test.dart +++ b/test/server/mcp_server_test.dart @@ -695,6 +695,63 @@ void main() { final contents = response.result['contents'] as List; expect(contents.first['text'], equals('Hello from resource')); }); + + test('legacy resource miss uses stable resource-not-found error', () async { + server.registerResource( + 'Known Resource', + 'test://known', + null, + (uri, extra) async => ReadResourceResult( + contents: [ + TextResourceContents(uri: uri.toString(), text: 'known'), + ], + ), + ); + + await server.connect(transport); + + transport.receiveMessage( + JsonRpcReadResourceRequest( + id: 'missing-resource', + readParams: const ReadResourceRequestParams(uri: 'test://missing'), + ), + ); + await Future.delayed(const Duration(milliseconds: 100)); + + final response = transport.sentMessages.last as JsonRpcError; + expect(response.error.code, ErrorCode.resourceNotFound.value); + expect(response.error.message, 'Resource not found'); + expect(response.error.data, {'uri': 'test://missing'}); + }); + + test('stateless resource miss uses 2026 invalid params error', () async { + server.registerResource( + 'Known Resource', + 'test://known', + null, + (uri, extra) async => ReadResourceResult( + contents: [ + TextResourceContents(uri: uri.toString(), text: 'known'), + ], + ), + ); + + await server.connect(transport); + + transport.receiveMessage( + JsonRpcReadResourceRequest( + id: 'missing-resource', + readParams: const ReadResourceRequestParams(uri: 'test://missing'), + meta: _statelessMeta(), + ), + ); + await Future.delayed(const Duration(milliseconds: 100)); + + final response = transport.sentMessages.last as JsonRpcError; + expect(response.error.code, ErrorCode.invalidParams.value); + expect(response.error.message, 'Resource not found'); + expect(response.error.data, {'uri': 'test://missing'}); + }); }); group('McpServer Prompt Registration', () { diff --git a/test/server/output_validation_test.dart b/test/server/output_validation_test.dart index 44a3f1ba..36eb82ac 100644 --- a/test/server/output_validation_test.dart +++ b/test/server/output_validation_test.dart @@ -82,7 +82,137 @@ void main() { expect(response, isA()); final successResponse = response as JsonRpcResponse; final result = CallToolResult.fromJson(successResponse.result); - expect(result.structuredContent?['result'], equals('success')); + final structured = result.structuredContent as Map; + expect(structured['result'], equals('success')); + }); + + test('non-object output schema validates for MCP 2026 calls', () async { + mcpServer.registerTool( + 'array_tool', + outputSchema: JsonSchema.array(items: JsonSchema.string()), + callback: (args, extra) async { + return CallToolResult.fromStructuredContent(['alpha', 'beta']); + }, + ); + + await mcpServer.connect(transport); + + final callRequest = JsonRpcCallToolRequest( + id: 2, + params: const CallToolRequest(name: 'array_tool').toJson(), + meta: _statelessMeta(), + ); + transport.receiveMessage(callRequest); + await Future.delayed(const Duration(milliseconds: 10)); + + final response = transport.sentMessages.last; + expect(response, isA()); + final successResponse = response as JsonRpcResponse; + final result = CallToolResult.fromJson(successResponse.result); + expect(result.structuredContent, equals(['alpha', 'beta'])); + }); + + test('non-object output schema validation failures are rejected', () async { + mcpServer.registerTool( + 'invalid_array_tool', + outputSchema: JsonSchema.array(items: JsonSchema.string()), + callback: (args, extra) async { + return CallToolResult.fromStructuredContent(['alpha', 1]); + }, + ); + + await mcpServer.connect(transport); + + final callRequest = JsonRpcCallToolRequest( + id: 2, + params: const CallToolRequest(name: 'invalid_array_tool').toJson(), + meta: _statelessMeta(), + ); + transport.receiveMessage(callRequest); + await Future.delayed(const Duration(milliseconds: 10)); + + final response = transport.sentMessages.last; + expect(response, isA()); + final errorResponse = response as JsonRpcError; + expect(errorResponse.error.code, equals(ErrorCode.invalidParams.value)); + expect(errorResponse.error.message, contains('Output validation error')); + }); + + test('stable tools/list omits non-object output schemas', () async { + mcpServer.registerTool( + 'array_tool', + outputSchema: JsonSchema.array(items: JsonSchema.string()), + callback: (args, extra) async { + return CallToolResult.fromStructuredContent(['alpha', 'beta']); + }, + ); + + await mcpServer.connect(transport); + await _sendInit(transport); + + transport.receiveMessage(const JsonRpcListToolsRequest(id: 2)); + await Future.delayed(const Duration(milliseconds: 10)); + + final response = transport.sentMessages.last; + expect(response, isA()); + final successResponse = response as JsonRpcResponse; + final tools = successResponse.result['tools'] as List; + final tool = tools.single as Map; + expect(tool.containsKey('outputSchema'), isFalse); + }); + + test('MCP 2026 tools/list includes non-object output schemas', () async { + mcpServer.registerTool( + 'array_tool', + outputSchema: JsonSchema.array(items: JsonSchema.string()), + callback: (args, extra) async { + return CallToolResult.fromStructuredContent(['alpha', 'beta']); + }, + ); + + await mcpServer.connect(transport); + + transport.receiveMessage( + JsonRpcListToolsRequest( + id: 2, + meta: _statelessMeta(), + ), + ); + await Future.delayed(const Duration(milliseconds: 10)); + + final response = transport.sentMessages.last; + expect(response, isA()); + final successResponse = response as JsonRpcResponse; + final tools = successResponse.result['tools'] as List; + final tool = tools.single as Map; + expect(tool['outputSchema']['type'], equals('array')); + expect(tool['outputSchema']['items']['type'], equals('string')); + }); + + test('stable tool calls omit non-object structured content', () async { + mcpServer.registerTool( + 'array_tool', + outputSchema: JsonSchema.array(items: JsonSchema.string()), + callback: (args, extra) async { + return CallToolResult.fromStructuredContent(['alpha', 'beta']); + }, + ); + + await mcpServer.connect(transport); + await _sendInit(transport); + + final callRequest = JsonRpcCallToolRequest( + id: 2, + params: const CallToolRequest(name: 'array_tool').toJson(), + ); + transport.receiveMessage(callRequest); + await Future.delayed(const Duration(milliseconds: 10)); + + final response = transport.sentMessages.last; + expect(response, isA()); + final successResponse = response as JsonRpcResponse; + expect(successResponse.result.containsKey('structuredContent'), isFalse); + expect(successResponse.result['content'], isA>()); }); test('invalid output fails validation', () async { @@ -226,6 +356,13 @@ void main() { }); } +Map _statelessMeta() => { + McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, + McpMetaKey.clientInfo: + const Implementation(name: 'TestClient', version: '1.0.0').toJson(), + McpMetaKey.clientCapabilities: const ClientCapabilities().toJson(), + }; + Future _sendInit(MockTransport transport) async { final initRequest = JsonRpcInitializeRequest( id: 1, diff --git a/test/tool_schema_test.dart b/test/tool_schema_test.dart index 42499cc2..f45d9661 100644 --- a/test/tool_schema_test.dart +++ b/test/tool_schema_test.dart @@ -184,6 +184,54 @@ void main() { ); }); + test('Tool preserves non-object output schemas for MCP 2026', () { + final tool = Tool( + name: 'list_results', + inputSchema: const JsonObject(), + outputSchema: JsonSchema.array(items: JsonSchema.string()), + ); + + final json = tool.toJson(); + expect(json['outputSchema']['type'], equals('array')); + expect(json['outputSchema']['items']['type'], equals('string')); + + final deserialized = Tool.fromJson(json); + expect( + deserialized.outputSchema?.toJson(), + equals(json['outputSchema']), + ); + }); + + test('CallToolResult preserves arbitrary JSON structured content', () { + final values = [ + {'status': 'ok'}, + ['alpha', 'beta'], + 'complete', + 42, + true, + ]; + + for (final value in values) { + final result = CallToolResult.fromStructuredContent(value); + final json = result.toJson(); + + expect(json['structuredContent'], equals(value)); + + final parsed = CallToolResult.fromJson(json); + expect(parsed.hasStructuredContent, isTrue); + expect(parsed.structuredContent, equals(value)); + } + + final nullResult = CallToolResult.fromStructuredContent(null); + final nullJson = nullResult.toJson(); + expect(nullJson.containsKey('structuredContent'), isTrue); + expect(nullJson['structuredContent'], isNull); + + final parsedNull = CallToolResult.fromJson(nullJson); + expect(parsedNull.hasStructuredContent, isTrue); + expect(parsedNull.structuredContent, isNull); + }); + test('Tool serializes JsonEnum properties as standard enum schema', () { const tool = Tool( name: 'configure_mode', diff --git a/test/types/sampling_test.dart b/test/types/sampling_test.dart index 0cf8a9dd..3eaacd35 100644 --- a/test/types/sampling_test.dart +++ b/test/types/sampling_test.dart @@ -235,6 +235,30 @@ void main() { expect((json['content'] as List).first['type'], equals('text')); }); + test('toJson preserves arbitrary structured JSON values', () { + const content = SamplingToolResultContent( + toolUseId: 'res1', + content: [ + TextContent(text: 'array result'), + ], + structuredContent: ['alpha', 'beta'], + ); + final json = content.toJson(); + expect(json['structuredContent'], equals(['alpha', 'beta'])); + + const nullContent = SamplingToolResultContent( + toolUseId: 'res2', + content: [ + TextContent(text: 'null result'), + ], + structuredContent: null, + hasStructuredContent: true, + ); + final nullJson = nullContent.toJson(); + expect(nullJson.containsKey('structuredContent'), isTrue); + expect(nullJson['structuredContent'], isNull); + }); + test('fromJson parses correctly', () { final json = { 'type': 'tool_result', @@ -254,6 +278,35 @@ void main() { expect(result.content, hasLength(1)); expect(result.content.first, isA()); }); + + test('fromJson parses arbitrary structured JSON values', () { + final json = { + 'type': 'tool_result', + 'toolUseId': 'tr1', + 'content': [ + {'type': 'text', 'text': 'result data'}, + ], + 'structuredContent': ['alpha', 'beta'], + }; + final content = SamplingContent.fromJson(json); + expect(content, isA()); + final result = content as SamplingToolResultContent; + expect(result.hasStructuredContent, isTrue); + expect(result.structuredContent, equals(['alpha', 'beta'])); + + final nullJson = { + 'type': 'tool_result', + 'toolUseId': 'tr2', + 'content': [ + {'type': 'text', 'text': 'result data'}, + ], + 'structuredContent': null, + }; + final nullContent = + SamplingContent.fromJson(nullJson) as SamplingToolResultContent; + expect(nullContent.hasStructuredContent, isTrue); + expect(nullContent.structuredContent, isNull); + }); }); }); diff --git a/test/types_edge_cases_test.dart b/test/types_edge_cases_test.dart index 0d793fde..d2f5b4fb 100644 --- a/test/types_edge_cases_test.dart +++ b/test/types_edge_cases_test.dart @@ -13,6 +13,7 @@ void main() { test('ErrorCode.fromValue finds all standard codes', () { expect(ErrorCode.fromValue(-32000), equals(ErrorCode.connectionClosed)); expect(ErrorCode.fromValue(-32001), equals(ErrorCode.requestTimeout)); + expect(ErrorCode.fromValue(-32002), equals(ErrorCode.resourceNotFound)); expect(ErrorCode.fromValue(-32700), equals(ErrorCode.parseError)); expect(ErrorCode.fromValue(-32600), equals(ErrorCode.invalidRequest)); expect(ErrorCode.fromValue(-32601), equals(ErrorCode.methodNotFound)); From c994327ea6843afea09294fcb9c40f5a4061431e Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 08:20:52 -0400 Subject: [PATCH 04/68] Consolidate JSON-RPC metadata hardening --- CHANGELOG.md | 52 +- lib/src/client/client.dart | 69 +- lib/src/client/streamable_https.dart | 34 +- lib/src/server/streamable_mcp_server.dart | 69 +- lib/src/shared/protocol.dart | 59 +- lib/src/shared/transport.dart | 5 +- lib/src/types/completion.dart | 5 +- lib/src/types/content.dart | 95 ++- lib/src/types/elicitation.dart | 41 +- lib/src/types/initialization.dart | 172 ++++- lib/src/types/json_rpc.dart | 226 +++++-- lib/src/types/logging.dart | 6 +- lib/src/types/misc.dart | 35 +- lib/src/types/prompts.dart | 15 +- lib/src/types/resources.dart | 38 +- lib/src/types/roots.dart | 10 +- lib/src/types/sampling.dart | 151 +++-- lib/src/types/subscriptions.dart | 12 +- lib/src/types/tasks.dart | 48 +- lib/src/types/tools.dart | 32 +- lib/src/types/validation.dart | 55 +- packages/mcp_dart_cli/README.md | 9 +- .../lib/src/conformance_runner.dart | 351 +++++++++- .../test/src/conformance_command_test.dart | 10 + test/client/client_test.dart | 4 +- test/client/streamable_https_test.dart | 67 ++ test/client/task_client_test.dart | 2 +- test/elicitation_test.dart | 29 +- .../completions_capability_test.dart | 4 +- test/mcp_2025_11_25_test.dart | 178 ++++- test/mcp_2026_07_28_test.dart | 624 +++++++++++++++++- test/server/stdio_test.dart | 43 ++ test/server/streamable_mcp_server_test.dart | 67 ++ test/shared/progress_test.dart | 86 +++ .../protocol_advanced_scenarios_test.dart | 13 +- test/shared/protocol_test.dart | 134 ++++ .../transport_api_compatibility_test.dart | 14 +- test/tool_schema_test.dart | 54 ++ test/types/sampling_test.dart | 135 ++++ test/types/subscriptions_test.dart | 10 + test/types/tasks_extension_test.dart | 40 ++ test/types_edge_cases_test.dart | 322 ++++++++- test/types_test.dart | 306 ++++++++- 43 files changed, 3382 insertions(+), 349 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9e88d61..ee6c6e94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,8 @@ request methods. - Rejected MCP 2026 MRTR `inputRequests` whose embedded client request type is not declared in the caller's per-request client capabilities. +- Rejected non-object `experimental` and `extensions` capability entries to + match the stable and MCP 2026 capability schemas. - Returned version-appropriate resource-not-found errors from high-level `resources/read` handlers: stable 2025 uses legacy `-32002`, while MCP 2026 stateless requests use `-32602` with the missing `uri` in error data. @@ -86,12 +88,58 @@ clamping malformed wire values to zero. - Validated MRTR `inputResponses` as `CreateMessageResult`, `ListRootsResult`, or `ElicitResult` instead of accepting arbitrary result objects. -- Rejected non-integer numeric `ElicitResult.content` values to match the - stable and MCP 2026 schemas. +- Allowed finite numeric `ElicitResult.content` values to match the stable and + MCP 2026 `string | number | boolean | string[]` schema. - Rejected form elicitation schemas that provide legacy `enumNames` without the required string `enum`. - Rejected `ElicitResult.content` when the result action is `decline` or `cancel`. +- Rejected URL elicitation values that are not absolute URIs to match the stable + and MCP 2026 `format: uri` schemas. +- Rejected non-finite numeric values for progress, annotation priority, model + priority, and sampling temperature fields so SDK-built payloads remain valid + JSON numbers. +- Rejected non-JSON values in sampling JSON object fields, including + `tool_use.input`, sampling metadata, annotations, and `_meta` maps. +- Rejected non-JSON values in common content/resource metadata fields and + `resource_link.annotations`. +- Reused shared JSON-object validation for MRTR, task extension, subscription, + and tool object fields. +- Rejected non-JSON values in JSON-RPC envelope and remaining typed result + metadata fields. +- Rejected non-JSON JSON-RPC error `data` values at parse and serialize + boundaries. +- Rejected JSON-RPC response envelopes that include both `result` and `error` + instead of silently treating them as successful responses. +- Rejected JSON-RPC request and notification envelopes whose `method` member is + not a string, and validated generic request `params` as JSON objects. +- Rejected malformed JSON-RPC `error` objects with missing or invalid `code` or + `message` fields instead of surfacing Dart cast errors. +- Rejected JSON-RPC error responses that include an explicit `id: null` member + while continuing to allow omitted IDs for malformed-request error cases. +- Rejected JSON-RPC request and notification envelopes that include an explicit + `params: null` member, since `params` must be an object when present. +- Prevented stateless MCP 2026 clients from sending core request and + notification methods removed from that protocol revision. +- Rejected server-initiated JSON-RPC requests received by stateless MCP 2026 + clients on generic transports. +- Rejected stateless MCP 2026 responses that omit `resultType` or required + cacheable-result fields. +- Stripped caller-supplied `Mcp-Session-Id` headers case-insensitively from + MCP 2026 stateless Streamable HTTP requests. +- Derived MCP 2026 stateless Streamable HTTP headers from nested + `params._meta` metadata for direct JSON-RPC transport sends. +- Allowed Streamable MCP server CORS preflights for 2026 stateless routing and + tool parameter headers, including requested `Mcp-Param-*` headers. +- Serialized MRTR `ElicitResult` and `ListRootsResult` input responses with the + MCP 2026 embedded client-result shapes that omit common Result `_meta`. +- Accepted finite numeric JSON-RPC request IDs and progress tokens, matching + the stable and MCP 2026 `string | number` schema while continuing to reject + non-finite numbers. +- Allowed protocol progress handlers and `RequestHandlerExtra.sendProgress` to + dispatch finite numeric progress tokens end-to-end. +- Widened protocol `relatedRequestId` API parameters to preserve string and + finite numeric JSON-RPC request IDs through request and notification routing. ## 2.2.0 diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 1932338c..0c0fdc9a 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -4,6 +4,7 @@ import 'package:mcp_dart/src/shared/json_schema/json_schema_validator.dart'; import 'package:mcp_dart/src/shared/logging.dart'; import 'package:mcp_dart/src/shared/mcp_header_validation.dart'; import 'package:mcp_dart/src/shared/protocol.dart'; +import 'package:mcp_dart/src/shared/task_interfaces.dart'; import 'package:mcp_dart/src/shared/transport.dart'; import 'package:mcp_dart/src/types.dart'; @@ -108,6 +109,21 @@ dynamic _deepCopy(dynamic value) { } } +const Set _statelessRemovedRequestMethods = { + Method.initialize, + Method.ping, + Method.loggingSetLevel, + Method.resourcesSubscribe, + Method.resourcesUnsubscribe, + Method.tasksList, + Method.tasksResult, +}; + +const Set _statelessRemovedNotificationMethods = { + Method.notificationsInitialized, + Method.notificationsRootsListChanged, +}; + /// An MCP client implementation built on top of a pluggable [Transport]. /// /// Handles the initialization handshake with the server upon connection @@ -463,6 +479,9 @@ class McpClient extends Protocol { _logger.debug( "server/discover not available; falling back to initialize.", ); + if (transport is ProtocolVersionAwareTransport) { + (transport as ProtocolVersionAwareTransport).protocolVersion = null; + } } } @@ -479,8 +498,10 @@ class McpClient extends Protocol { JsonRpcRequest requestData, T Function(Map resultJson) resultFactory, [ RequestOptions? options, - int? relatedRequestId, + RequestId? relatedRequestId, ]) async { + _assertStatelessRequestAllowed(requestData.method); + final outboundRequest = _usesStatelessProtocol && requestData.method != Method.serverDiscover ? JsonRpcRequest( @@ -537,6 +558,44 @@ class McpClient extends Protocol { } } + @override + Future notification( + JsonRpcNotification notificationData, { + RelatedTaskMetadata? relatedTask, + RequestId? relatedRequestId, + }) async { + _assertStatelessNotificationAllowed(notificationData.method); + await super.notification( + notificationData, + relatedTask: relatedTask, + relatedRequestId: relatedRequestId, + ); + } + + void _assertStatelessRequestAllowed(String method) { + if (!_usesStatelessProtocol || + !_statelessRemovedRequestMethods.contains(method)) { + return; + } + + throw McpError( + ErrorCode.methodNotFound.value, + 'MCP $_negotiatedProtocolVersion does not define $method.', + ); + } + + void _assertStatelessNotificationAllowed(String method) { + if (!_usesStatelessProtocol || + !_statelessRemovedNotificationMethods.contains(method)) { + return; + } + + throw McpError( + ErrorCode.methodNotFound.value, + 'MCP $_negotiatedProtocolVersion does not define $method.', + ); + } + /// Gets the server's reported capabilities after successful initialization. ServerCapabilities? getServerCapabilities() => _serverCapabilities; @@ -561,6 +620,14 @@ class McpClient extends Protocol { @override McpError? validateIncomingRequest(JsonRpcRequest request) { + if (_usesStatelessProtocol) { + return McpError( + ErrorCode.invalidRequest.value, + 'Server-initiated JSON-RPC requests are not supported in stateless ' + 'MCP; return input_required with inputRequests instead.', + ); + } + if (_sentInitialized || request.method == Method.ping) { return null; } diff --git a/lib/src/client/streamable_https.dart b/lib/src/client/streamable_https.dart index 2db94a0b..fdfe65ff 100644 --- a/lib/src/client/streamable_https.dart +++ b/lib/src/client/streamable_https.dart @@ -537,6 +537,19 @@ class StreamableHttpClientTransport return headers; } + void _removeHeaderCaseInsensitive( + Map headers, + String headerName, + ) { + final normalizedHeaderName = headerName.toLowerCase(); + final matchingKeys = headers.keys + .where((key) => key.toLowerCase() == normalizedHeaderName) + .toList(); + for (final key in matchingKeys) { + headers.remove(key); + } + } + Map _headersForMessage(JsonRpcMessage message) { final headers = {}; final protocolVersion = _protocolVersion ?? _protocolVersionFrom(message); @@ -702,11 +715,24 @@ class StreamableHttpClientTransport } Map? _metaFrom(JsonRpcMessage message) { + final Map? directMeta; if (message is JsonRpcRequest) { - return message.meta; + directMeta = message.meta; + } else if (message is JsonRpcNotification) { + directMeta = message.meta; + } else { + return null; } - if (message is JsonRpcNotification) { - return message.meta; + if (directMeta != null) { + return directMeta; + } + + final paramsMeta = _paramsFrom(message)?['_meta']; + if (paramsMeta is Map) { + return paramsMeta; + } + if (paramsMeta is Map) { + return paramsMeta.cast(); } return null; } @@ -1218,7 +1244,7 @@ class StreamableHttpClientTransport final isStatelessRequest = protocolVersion != null && isStatelessProtocolVersion(protocolVersion); if (isStatelessRequest) { - headers.remove('mcp-session-id'); + _removeHeaderCaseInsensitive(headers, 'mcp-session-id'); } final requestSessionId = headers['mcp-session-id']; headers['content-type'] = 'application/json'; diff --git a/lib/src/server/streamable_mcp_server.dart b/lib/src/server/streamable_mcp_server.dart index 0200c682..9cdb615e 100644 --- a/lib/src/server/streamable_mcp_server.dart +++ b/lib/src/server/streamable_mcp_server.dart @@ -7,9 +7,23 @@ import 'package:mcp_dart/src/server/dns_rebinding_protection.dart'; import 'package:mcp_dart/src/server/mcp_server.dart'; import 'package:mcp_dart/src/server/streamable_https.dart'; import 'package:mcp_dart/src/shared/logging.dart'; +import 'package:mcp_dart/src/shared/mcp_header_validation.dart'; import 'package:mcp_dart/src/shared/uuid.dart'; import 'package:mcp_dart/src/types.dart'; +const List _defaultCorsAllowedHeaders = [ + 'Origin', + 'X-Requested-With', + 'Content-Type', + 'Accept', + 'mcp-session-id', + 'Last-Event-ID', + 'Authorization', + 'MCP-Protocol-Version', + 'Mcp-Method', + 'Mcp-Name', +]; + String _quoteHeaderValue(String value) { const backslash = '\\'; const escapedBackslash = '\\\\'; @@ -344,7 +358,7 @@ class StreamableMcpServer { } Future _handleRequest(HttpRequest request) async { - _setCorsHeaders(request.response); + _setCorsHeaders(request, request.response); if (enableDnsRebindingProtection && !isRequestAllowedByDnsRebindingProtection( @@ -693,14 +707,6 @@ class StreamableMcpServer { } 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']; @@ -712,6 +718,14 @@ class StreamableMcpServer { } } + final topLevelMeta = body['_meta']; + if (topLevelMeta is Map) { + final version = topLevelMeta[McpMetaKey.protocolVersion]; + if (version is String) { + return version; + } + } + return null; } @@ -899,13 +913,46 @@ class StreamableMcpServer { await response.close(); } - void _setCorsHeaders(HttpResponse response) { + String _corsAllowedHeaders(HttpRequest request) { + final allowedHeaders = []; + final seenHeaders = {}; + + void addAllowedHeader(String headerName) { + final normalized = headerName.toLowerCase(); + if (seenHeaders.add(normalized)) { + allowedHeaders.add(headerName); + } + } + + for (final headerName in _defaultCorsAllowedHeaders) { + addAllowedHeader(headerName); + } + + final requestedHeaders = + request.headers.value('access-control-request-headers'); + if (requestedHeaders == null) { + return allowedHeaders.join(', '); + } + + for (final rawHeaderName in requestedHeaders.split(',')) { + final headerName = rawHeaderName.trim(); + if (headerName.isEmpty || + !headerName.codeUnits.every(isHttpFieldNameTokenChar)) { + continue; + } + addAllowedHeader(headerName); + } + + return allowedHeaders.join(', '); + } + + void _setCorsHeaders(HttpRequest request, HttpResponse response) { response.headers.set('Access-Control-Allow-Origin', '*'); response.headers .set('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); response.headers.set( 'Access-Control-Allow-Headers', - 'Origin, X-Requested-With, Content-Type, Accept, mcp-session-id, Last-Event-ID, Authorization, MCP-Protocol-Version', + _corsAllowedHeaders(request), ); response.headers.set('Access-Control-Allow-Credentials', 'true'); response.headers.set('Access-Control-Max-Age', defaultCorsMaxAgeSeconds); diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 7948820e..ae05f7f5 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -9,7 +9,18 @@ import 'transport.dart'; final _logger = Logger("mcp_dart.shared.protocol"); -bool _isProgressToken(Object? token) => token is int || token is String; +bool _isProgressToken(Object? token) => + token is String || + token is int || + (token is double && token.isFinite && token == token.truncateToDouble()); + +const Set _statelessCacheableResultMethods = { + Method.toolsList, + Method.promptsList, + Method.resourcesList, + Method.resourcesTemplatesList, + Method.resourcesRead, +}; final _lastProgressByExtra = Expando(); final _subscriptionStateByExtra = Expando<_SubscriptionStreamState>(); @@ -192,10 +203,10 @@ class RequestHandlerExtra { return; } - // progressToken can be int or string - if (progressToken is! int && progressToken is! String) { + if (!_isProgressToken(progressToken)) { _logger.warn( - "Invalid progressToken type: ${progressToken.runtimeType}. Expected int or String.", + "Invalid progressToken type: ${progressToken.runtimeType}. " + "Expected string or integer.", ); return; } @@ -508,7 +519,9 @@ abstract class Protocol { final resultType = resultJson['resultType']; if (resultType == null) { - return; + throw const FormatException( + 'MCP stateless responses must include resultType', + ); } if (resultType is! String) { throw const FormatException('MCP resultType must be a string'); @@ -516,6 +529,32 @@ abstract class Protocol { if (!isRecognizedResultType(resultType)) { throw FormatException('Unrecognized MCP resultType "$resultType"'); } + + if (resultType == resultTypeComplete && + _statelessCacheableResultMethods.contains(request.method)) { + _validateStatelessCacheableResult(request, resultJson); + } + } + + void _validateStatelessCacheableResult( + JsonRpcRequest request, + Map resultJson, + ) { + final ttlMs = resultJson['ttlMs']; + if (ttlMs is! int || ttlMs < 0) { + throw FormatException( + 'MCP stateless ${request.method} responses must include ' + 'a non-negative integer ttlMs', + ); + } + + final cacheScope = resultJson['cacheScope']; + if (cacheScope != CacheScope.private && cacheScope != CacheScope.public) { + throw FormatException( + 'MCP stateless ${request.method} responses must include ' + 'cacheScope "private" or "public"', + ); + } } void _registerTaskHandlers() { @@ -1390,7 +1429,8 @@ abstract class Protocol { if (!_isProgressToken(progressToken)) { _onerror( ArgumentError( - "Received invalid progressToken: $progressToken. Expected int or String.", + "Received invalid progressToken: $progressToken. " + "Expected string or integer.", ), ); return; @@ -1536,7 +1576,7 @@ abstract class Protocol { JsonRpcRequest requestData, T Function(Map resultJson) resultFactory, [ RequestOptions? options, - int? relatedRequestId, + RequestId? relatedRequestId, ]) { return _requestWithRequestId( requestData, @@ -1612,7 +1652,8 @@ abstract class Protocol { if (!_isProgressToken(requestedProgressToken)) { return Future.error( ArgumentError( - 'progressToken must be an int or String when onprogress is set.', + 'progressToken must be a string or integer when ' + 'onprogress is set.', ), ); } @@ -1922,7 +1963,7 @@ abstract class Protocol { Future notification( JsonRpcNotification notificationData, { RelatedTaskMetadata? relatedTask, - int? relatedRequestId, + RequestId? relatedRequestId, }) { return _notificationWithRequestId( notificationData, diff --git a/lib/src/shared/transport.dart b/lib/src/shared/transport.dart index cf6df255..2011775e 100644 --- a/lib/src/shared/transport.dart +++ b/lib/src/shared/transport.dart @@ -45,7 +45,8 @@ abstract class Transport { } /// Optional capability for transports that can preserve JSON-RPC request IDs -/// with their full MCP shape (string or integer) for request/stream correlation. +/// with their full MCP shape (string or integer) for request/stream +/// correlation. /// /// Existing custom transports can keep implementing [Transport.send] with /// `int? relatedRequestId`. Transports that need to route messages by string @@ -59,7 +60,7 @@ abstract class RequestIdAwareTransport { } extension RequestIdAwareTransportSend on Transport { - /// Sends [message] while preserving string request IDs when the transport + /// Sends [message] while preserving non-integer request IDs when the transport /// supports [RequestIdAwareTransport]. /// /// Legacy transports receive only integer IDs, matching the existing public diff --git a/lib/src/types/completion.dart b/lib/src/types/completion.dart index 1841ff94..50513521 100644 --- a/lib/src/types/completion.dart +++ b/lib/src/types/completion.dart @@ -1,4 +1,5 @@ import 'json_rpc.dart'; +import 'validation.dart'; /// Sealed class representing a reference for autocompletion targets. sealed class Reference { @@ -236,7 +237,7 @@ class CompleteResult implements BaseResultData { const CompleteResult({required this.completion, this.meta}); factory CompleteResult.fromJson(Map json) { - final meta = json['_meta'] as Map?; + final meta = readOptionalJsonObject(json['_meta'], 'CompleteResult._meta'); return CompleteResult( completion: CompletionResultData.fromJson( json['completion'] as Map, @@ -248,7 +249,7 @@ class CompleteResult implements BaseResultData { @override Map toJson() => { 'completion': completion.toJson(), - if (meta != null) '_meta': meta, + if (meta != null) '_meta': readJsonObject(meta, 'CompleteResult._meta'), }; } diff --git a/lib/src/types/content.dart b/lib/src/types/content.dart index 87845180..42c612b5 100644 --- a/lib/src/types/content.dart +++ b/lib/src/types/content.dart @@ -1,25 +1,22 @@ import 'validation.dart'; -Map? _asJsonObjectOrNull(dynamic value) { +Map? _asJsonObjectOrNull( + dynamic value, [ + String field = 'object', +]) { if (value == null) { return null; } - - if (value is Map) { - return value; - } - - if (value is Map) { - return value.cast(); - } - - throw FormatException('Expected object, got ${value.runtimeType}'); + return readJsonObject(value, field); } -Map _asJsonObject(dynamic value) { - final map = _asJsonObjectOrNull(value); +Map _asJsonObject( + dynamic value, [ + String field = 'object', +]) { + final map = _asJsonObjectOrNull(value, field); if (map == null) { - throw const FormatException('Expected object, got null'); + throw FormatException('$field must be a JSON object'); } return map; } @@ -93,7 +90,10 @@ sealed class ResourceContents { factory ResourceContents.fromJson(Map json) { final uri = json['uri'] as String; final mimeType = json['mimeType'] as String?; - final meta = _asJsonObjectOrNull(json['_meta']); + final meta = _asJsonObjectOrNull( + json['_meta'], + 'ResourceContents._meta', + ); final extra = Map.from(json) ..removeWhere( (key, value) => @@ -104,7 +104,8 @@ sealed class ResourceContents { key == '_meta', ); - final passthrough = extra.isEmpty ? null : extra; + final passthrough = + extra.isEmpty ? null : readJsonObject(extra, 'ResourceContents.extra'); if (json.containsKey('text')) { return TextResourceContents( @@ -141,8 +142,9 @@ sealed class ResourceContents { final BlobResourceContents c => {'blob': c.blob}, UnknownResourceContents _ => {}, }, - if (meta != null) '_meta': meta, - ...?extra, + if (meta != null) + '_meta': readJsonObject(meta, 'ResourceContents._meta'), + if (extra != null) ...readJsonObject(extra, 'ResourceContents.extra'), }; } @@ -259,20 +261,23 @@ sealed class Content { final TextContent c => { 'text': c.text, if (c.annotations != null) 'annotations': c.annotations!.toJson(), - if (c.meta != null) '_meta': c.meta, + if (c.meta != null) + '_meta': readJsonObject(c.meta, 'TextContent._meta'), }, final ImageContent c => { 'data': c.data, 'mimeType': c.mimeType, if (c.theme != null) 'theme': c.theme, if (c.annotations != null) 'annotations': c.annotations!.toJson(), - if (c.meta != null) '_meta': c.meta, + if (c.meta != null) + '_meta': readJsonObject(c.meta, 'ImageContent._meta'), }, final AudioContent c => { 'data': c.data, 'mimeType': c.mimeType, if (c.annotations != null) 'annotations': c.annotations!.toJson(), - if (c.meta != null) '_meta': c.meta, + if (c.meta != null) + '_meta': readJsonObject(c.meta, 'AudioContent._meta'), }, final ResourceLink c => { 'uri': c.uri, @@ -283,13 +288,19 @@ sealed class Content { if (c.size != null) 'size': c.size, if (c.icons != null) 'icons': c.icons!.map((icon) => icon.toJson()).toList(), - if (c.annotations != null) 'annotations': c.annotations, - if (c.meta != null) '_meta': c.meta, + if (c.annotations != null) + 'annotations': readJsonObject( + c.annotations, + 'ResourceLink.annotations', + ), + if (c.meta != null) + '_meta': readJsonObject(c.meta, 'ResourceLink._meta'), }, final EmbeddedResource c => { 'resource': c.resource.toJson(), if (c.annotations != null) 'annotations': c.annotations!.toJson(), - if (c.meta != null) '_meta': c.meta, + if (c.meta != null) + '_meta': readJsonObject(c.meta, 'EmbeddedResource._meta'), }, UnknownContent _ => {}, }, @@ -318,8 +329,10 @@ class TextContent extends Content { text: json['text'] as String, annotations: json['annotations'] == null ? null - : Annotations.fromJson(_asJsonObject(json['annotations'])), - meta: _asJsonObjectOrNull(json['_meta']), + : Annotations.fromJson( + _asJsonObject(json['annotations'], 'TextContent.annotations'), + ), + meta: _asJsonObjectOrNull(json['_meta'], 'TextContent._meta'), ); } } @@ -356,8 +369,10 @@ class ImageContent extends Content { theme: json['theme'] as String?, annotations: json['annotations'] == null ? null - : Annotations.fromJson(_asJsonObject(json['annotations'])), - meta: _asJsonObjectOrNull(json['_meta']), + : Annotations.fromJson( + _asJsonObject(json['annotations'], 'ImageContent.annotations'), + ), + meta: _asJsonObjectOrNull(json['_meta'], 'ImageContent._meta'), ); } } @@ -388,8 +403,10 @@ class AudioContent extends Content { mimeType: json['mimeType'] as String, annotations: json['annotations'] == null ? null - : Annotations.fromJson(_asJsonObject(json['annotations'])), - meta: _asJsonObjectOrNull(json['_meta']), + : Annotations.fromJson( + _asJsonObject(json['annotations'], 'AudioContent.annotations'), + ), + meta: _asJsonObjectOrNull(json['_meta'], 'AudioContent._meta'), ); } } @@ -414,12 +431,17 @@ class EmbeddedResource extends Content { factory EmbeddedResource.fromJson(Map json) { return EmbeddedResource( resource: ResourceContents.fromJson( - _asJsonObject(json['resource']), + _asJsonObject(json['resource'], 'EmbeddedResource.resource'), ), annotations: json['annotations'] == null ? null - : Annotations.fromJson(_asJsonObject(json['annotations'])), - meta: _asJsonObjectOrNull(json['_meta']), + : Annotations.fromJson( + _asJsonObject( + json['annotations'], + 'EmbeddedResource.annotations', + ), + ), + meta: _asJsonObjectOrNull(json['_meta'], 'EmbeddedResource._meta'), ); } } @@ -480,8 +502,11 @@ class ResourceLink extends Content { icons: (json['icons'] as List?) ?.map((icon) => McpIcon.fromJson(_asJsonObject(icon))) .toList(), - annotations: _asJsonObjectOrNull(json['annotations']), - meta: _asJsonObjectOrNull(json['_meta']), + annotations: _asJsonObjectOrNull( + json['annotations'], + 'ResourceLink.annotations', + ), + meta: _asJsonObjectOrNull(json['_meta'], 'ResourceLink._meta'), ); } } diff --git a/lib/src/types/elicitation.dart b/lib/src/types/elicitation.dart index 7543311c..8b009980 100644 --- a/lib/src/types/elicitation.dart +++ b/lib/src/types/elicitation.dart @@ -1,5 +1,6 @@ import '../shared/json_schema/json_schema.dart'; import 'json_rpc.dart'; +import 'validation.dart'; /// Legacy alias for [JsonSchema] used in elicitation requests. typedef ElicitationInputSchema = JsonSchema; @@ -114,6 +115,7 @@ class ElicitRequest { if (url is! String) { throw const FormatException('URL elicitation requires url.'); } + _validateUrlElicitationUri(url, formatException: true); if (elicitationId is! String) { throw const FormatException('URL elicitation requires elicitationId.'); } @@ -159,6 +161,7 @@ class ElicitRequest { if (url == null) { throw ArgumentError('URL elicitation requires url.'); } + _validateUrlElicitationUri(url!); if (elicitationId == null) { throw ArgumentError('URL elicitation requires elicitationId.'); } @@ -279,7 +282,7 @@ class ElicitResult implements BaseResultData { content: content, url: json['url'] as String?, elicitationId: json['elicitationId'] as String?, - meta: (json['_meta'] as Map?)?.cast(), + meta: readOptionalJsonObject(json['_meta'], 'ElicitResult._meta'), ); } @@ -291,7 +294,7 @@ class ElicitResult implements BaseResultData { return { 'action': resultAction, if (content != null) 'content': content, - if (meta != null) '_meta': meta, + if (meta != null) '_meta': readJsonObject(meta, 'ElicitResult._meta'), }; } @@ -366,7 +369,10 @@ class JsonRpcElicitationCompleteNotification extends JsonRpcNotification { "Missing params for elicitation complete notification", ); } - final meta = paramsMap['_meta'] as Map?; + final meta = readOptionalJsonObject( + paramsMap['_meta'], + 'JsonRpcElicitationCompleteNotification._meta', + ); return JsonRpcElicitationCompleteNotification( completeParams: ElicitationCompleteNotification.fromJson(paramsMap), meta: meta, @@ -628,7 +634,10 @@ void _validateElicitResultContent( } for (final entry in content.entries) { final value = entry.value; - if (value is String || value is int || value is bool) { + if (value is String || value is bool) { + continue; + } + if (value is num && value.isFinite) { continue; } if (value is List && value.every((item) => item is String)) { @@ -636,13 +645,13 @@ void _validateElicitResultContent( } if (formatException) { throw FormatException( - 'ElicitResult.content.${entry.key} must be string, integer, boolean, or string[]', + 'ElicitResult.content.${entry.key} must be string, finite number, boolean, or string[]', ); } throw ArgumentError.value( value, 'content.${entry.key}', - 'ElicitResult content values must be string, integer, boolean, or string[]', + 'ElicitResult content values must be string, finite number, boolean, or string[]', ); } } @@ -686,3 +695,23 @@ void _validateUrlElicitations( } } } + +void _validateUrlElicitationUri( + String url, { + bool formatException = false, +}) { + final uri = Uri.tryParse(url); + if (uri != null && uri.hasScheme) { + return; + } + if (formatException) { + throw const FormatException( + 'URL elicitation url must be an absolute URI.', + ); + } + throw ArgumentError.value( + url, + 'url', + 'URL elicitation url must be an absolute URI.', + ); +} diff --git a/lib/src/types/initialization.dart b/lib/src/types/initialization.dart index e3208639..03d1007a 100644 --- a/lib/src/types/initialization.dart +++ b/lib/src/types/initialization.dart @@ -1,5 +1,6 @@ import 'content.dart'; import 'json_rpc.dart'; +import 'validation.dart'; Map? _asJsonObject(dynamic value) { if (value == null) { @@ -17,6 +18,74 @@ Map? _asJsonObject(dynamic value) { throw FormatException('Expected object capability, got ${value.runtimeType}'); } +Map? _asStrictJsonObject(Object? value, String field) { + if (value == null) { + return null; + } + if (value is Map) { + return value; + } + if (value is Map) { + if (value.keys.any((key) => key is! String)) { + throw FormatException('$field must be an object with string keys'); + } + return value.cast(); + } + throw FormatException('$field must be an object'); +} + +Map? _asJsonObjectMap(Object? value, String field) { + final map = _asStrictJsonObject(value, field); + if (map == null) { + return null; + } + + return map.map((key, item) { + final object = _asStrictJsonObject(item, '$field.$key'); + if (object == null) { + throw FormatException('$field.$key must be an object'); + } + return MapEntry(key, object); + }); +} + +Map? _serializeJsonObjectMap( + Map? value, + String field, +) { + if (value == null) { + return null; + } + + return value.map((key, item) { + final object = _asStrictJsonObject(item, '$field.$key'); + if (object == null) { + throw ArgumentError.value(item, '$field.$key', 'must be an object'); + } + return MapEntry(key, object); + }); +} + +Map>? _asExtensionMap( + Object? value, + String field, +) { + final map = _asJsonObjectMap(value, field); + return map?.map( + (key, value) => MapEntry(key, value.cast()), + ); +} + +Map>? _serializeExtensionMap( + Map>? value, + String field, +) { + final map = _serializeJsonObjectMap(value, field); + return map?.map( + (key, value) => MapEntry(key, value.cast()), + ); +} + bool? _capabilityDeclared(dynamic value) { if (value == null) { return null; @@ -380,6 +449,9 @@ class ClientCapabilitiesTasks { /// Capabilities a client may support. class ClientCapabilities { /// Experimental, non-standard capabilities. + /// + /// Each capability value must be a JSON object. Use an empty object to + /// advertise support without settings. final Map? experimental; /// Present if the client supports sampling (`sampling/createMessage`). @@ -414,10 +486,16 @@ class ClientCapabilities { final elicitationMap = _asJsonObject(json['elicitation']); final tasksMap = _asJsonObject(json['tasks']); final samplingMap = _asJsonObject(json['sampling']); - final extensionsMap = _asJsonObject(json['extensions']); + final extensionsMap = _asExtensionMap( + json['extensions'], + 'ClientCapabilities.extensions', + ); return ClientCapabilities( - experimental: json['experimental'] as Map?, + experimental: _asJsonObjectMap( + json['experimental'], + 'ClientCapabilities.experimental', + ), sampling: samplingMap == null ? null : ClientCapabilitiesSampling.fromJson(samplingMap), @@ -428,19 +506,25 @@ class ClientCapabilities { : ClientElicitation.fromJson(elicitationMap), tasks: tasksMap == null ? null : ClientCapabilitiesTasks.fromJson(tasksMap), - extensions: extensionsMap?.map( - (key, value) => MapEntry(key, Map.from(value as Map)), - ), + extensions: extensionsMap, ); } Map toJson() => { - if (experimental != null) 'experimental': experimental, + if (experimental != null) + 'experimental': _serializeJsonObjectMap( + experimental, + 'ClientCapabilities.experimental', + ), if (sampling != null) 'sampling': sampling!.toJson(), if (roots != null) 'roots': roots!.toJson(), if (elicitation != null) 'elicitation': elicitation!.toJson(), if (tasks != null) 'tasks': tasks!.toJson(), - if (extensions != null) 'extensions': extensions, + if (extensions != null) + 'extensions': _serializeExtensionMap( + extensions, + 'ClientCapabilities.extensions', + ), }; /// Whether the MCP Tasks extension is declared. @@ -516,11 +600,44 @@ class JsonRpcServerDiscoverRequest extends JsonRpcRequest { }) : super(method: Method.serverDiscover); factory JsonRpcServerDiscoverRequest.fromJson(Map json) { + final params = readJsonObject( + json['params'], + 'JsonRpcServerDiscoverRequest.params', + ); + final meta = validateRequestMeta( + readJsonObject( + params['_meta'], + 'JsonRpcServerDiscoverRequest.params._meta', + ), + validateKeys: true, + )!; + return JsonRpcServerDiscoverRequest( id: parseRequestId(json['id']), - meta: extractRequestMeta(json), + meta: meta, ); } + + @override + Map toJson() { + final meta = this.meta; + if (meta == null) { + throw const FormatException( + 'JsonRpcServerDiscoverRequest.params._meta is required', + ); + } + return { + 'jsonrpc': jsonrpc, + 'id': parseRequestId(id, fieldName: 'JsonRpcServerDiscoverRequest.id'), + 'method': method, + 'params': { + '_meta': readJsonObject( + validateRequestMeta(meta, validateKeys: true), + 'JsonRpcServerDiscoverRequest.params._meta', + ), + }, + }; + } } /// Describes capabilities related to elicitation > form mode for the server. @@ -778,6 +895,9 @@ class ServerCapabilitiesTasks { /// Capabilities a server may support. class ServerCapabilities { /// Experimental, non-standard capabilities. + /// + /// Each capability value must be a JSON object. Use an empty object to + /// advertise support without settings. final Map? experimental; /// Present if the server supports sending log messages (`notifications/message`). @@ -829,10 +949,16 @@ class ServerCapabilities { final tMap = _asJsonObject(json['tools']); final tasksMap = _asJsonObject(json['tasks']); final elicitationMap = _asJsonObject(json['elicitation']); - final extensionsMap = _asJsonObject(json['extensions']); + final extensionsMap = _asExtensionMap( + json['extensions'], + 'ServerCapabilities.extensions', + ); return ServerCapabilities( - experimental: json['experimental'] as Map?, + experimental: _asJsonObjectMap( + json['experimental'], + 'ServerCapabilities.experimental', + ), logging: json['logging'] as Map?, prompts: pMap == null ? null : ServerCapabilitiesPrompts.fromJson(pMap), resources: @@ -845,21 +971,27 @@ class ServerCapabilities { elicitation: elicitationMap == null ? null : ServerCapabilitiesElicitation.fromJson(elicitationMap), - extensions: extensionsMap?.map( - (key, value) => MapEntry(key, Map.from(value as Map)), - ), + extensions: extensionsMap, ); } Map toJson() => { - if (experimental != null) 'experimental': experimental, + if (experimental != null) + 'experimental': _serializeJsonObjectMap( + experimental, + 'ServerCapabilities.experimental', + ), if (logging != null) 'logging': logging, if (prompts != null) 'prompts': prompts!.toJson(), if (resources != null) 'resources': resources!.toJson(), if (tools != null) 'tools': tools!.toJson(), if (completions != null) 'completions': completions!.toJson(), if (tasks != null) 'tasks': tasks!.toJson(), - if (extensions != null) 'extensions': extensions, + if (extensions != null) + 'extensions': _serializeExtensionMap( + extensions, + 'ServerCapabilities.extensions', + ), }; /// Whether the MCP Tasks extension is declared. @@ -894,7 +1026,8 @@ class InitializeResult implements BaseResultData { }); factory InitializeResult.fromJson(Map json) { - final meta = json['_meta'] as Map?; + final meta = + readOptionalJsonObject(json['_meta'], 'InitializeResult._meta'); return InitializeResult( protocolVersion: json['protocolVersion'] as String, capabilities: ServerCapabilities.fromJson( @@ -914,7 +1047,8 @@ class InitializeResult implements BaseResultData { 'capabilities': capabilities.toJson(), 'serverInfo': serverInfo.toJson(), if (instructions != null) 'instructions': instructions, - if (meta != null) '_meta': meta, + if (meta != null) + '_meta': readJsonObject(meta, 'InitializeResult._meta'), }; } @@ -966,7 +1100,7 @@ class DiscoverResult implements BaseResultData { json['serverInfo'] as Map, ), instructions: json['instructions'] as String?, - meta: json['_meta'] as Map?, + meta: readOptionalJsonObject(json['_meta'], 'DiscoverResult._meta'), ); } @@ -977,7 +1111,7 @@ class DiscoverResult implements BaseResultData { 'capabilities': capabilities.toJson(), 'serverInfo': serverInfo.toJson(), if (instructions != null) 'instructions': instructions, - if (meta != null) '_meta': meta, + if (meta != null) '_meta': readJsonObject(meta, 'DiscoverResult._meta'), }; } diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index b2098250..6cb55fd9 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -165,11 +165,16 @@ ProgressToken parseProgressToken( Object? value, { String fieldName = 'progressToken', }) { - if (value is String || value is int) { + if (value is String) { return value; } + final integer = readOptionalInteger(value, fieldName); + if (integer != null) { + return integer; + } throw FormatException( - 'Invalid $fieldName: expected string or integer, got ${value.runtimeType}', + 'Invalid $fieldName: expected string or integer, ' + 'got ${value.runtimeType}', ); } @@ -185,25 +190,69 @@ typedef RequestId = dynamic; /// boundaries. Notifications omit the `id` member entirely, and responses may /// omit the `id` member for JSON-RPC error cases. RequestId parseRequestId(Object? value, {String fieldName = 'id'}) { - if (value is String || value is int) { + if (value is String) { + return value; + } + final integer = readOptionalInteger(value, fieldName); + if (integer != null) { + return integer; + } + throw FormatException( + 'Invalid $fieldName: expected string or integer, ' + 'got ${value.runtimeType}', + ); +} + +String _parseMethod(Object? value) { + if (value is String) { return value; } throw FormatException( - 'Invalid $fieldName: expected string or integer, got ${value.runtimeType}', + 'Invalid method: expected string, got ${value.runtimeType}', ); } +int _parseErrorCode(Object? value) { + final code = readOptionalInteger(value, 'JsonRpcErrorData.code'); + if (code == null) { + throw const FormatException('JsonRpcErrorData.code is required'); + } + return code; +} + +String _parseErrorMessage(Object? value) { + final message = readOptionalString(value, 'JsonRpcErrorData.message'); + if (message == null) { + throw const FormatException('JsonRpcErrorData.message is required'); + } + return message; +} + RequestId _parseResultResponseId(Object? value) { return parseRequestId(value); } RequestId? _parseErrorResponseId(Map json) { - if (!json.containsKey('id') || json['id'] == null) { + if (!json.containsKey('id')) { return null; } return parseRequestId(json['id']); } +Map? _parseOptionalParamsObject( + Map json, + String fieldName, +) { + if (!json.containsKey('params')) { + return null; + } + return readJsonObject(json['params'], fieldName); +} + +Object _requestIdToJson(RequestId id, String fieldName) { + return parseRequestId(id, fieldName: fieldName); +} + final _metaPrefixLabelPattern = RegExp( r'^[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?$', ); @@ -247,8 +296,8 @@ void validateMetaKeyName(String key, {String fieldName = '_meta'}) { /// Validates request metadata that can affect protocol behavior. /// /// `_meta.progressToken` is an MCP wire token and must be a string or integer -/// when present. [validateKeys] opts in to the MCP 2026 `_meta` key-name -/// grammar without changing stable/legacy request parsing. +/// when present. [validateKeys] opts in to the MCP 2026 `_meta` +/// key-name grammar without changing stable/legacy request parsing. Map? validateRequestMeta( Map? meta, { bool validateKeys = false, @@ -276,23 +325,15 @@ Map? _parseRequestMeta(Object? value) { if (value == null) { return null; } - if (value is! Map) { - throw FormatException( - 'Invalid _meta: expected object, got ${value.runtimeType}', - ); - } - if (value.keys.any((key) => key is! String)) { - throw const FormatException('Invalid _meta: expected string keys'); - } - return validateRequestMeta(Map.from(value)); + return validateRequestMeta(readJsonObject(value, '_meta')); } -/// Extracts request metadata from either top-level or params-nested `_meta`. +/// Extracts request metadata, preferring spec-defined params-nested `_meta`. Map? extractRequestMeta(Map json) { final topLevelMeta = _parseRequestMeta(json['_meta']); final params = json['params']; final paramsMeta = params is Map ? _parseRequestMeta(params['_meta']) : null; - return topLevelMeta ?? paramsMeta; + return paramsMeta ?? topLevelMeta; } /// Base class for all JSON-RPC messages (requests, notifications, responses, errors). @@ -309,9 +350,16 @@ sealed class JsonRpcMessage { throw FormatException('Invalid JSON-RPC version: ${json['jsonrpc']}'); } + final hasResult = json.containsKey('result'); + final hasError = json.containsKey('error'); + if (json.containsKey('method')) { - final method = json['method'] as String; + final method = _parseMethod(json['method']); final hasId = json.containsKey('id'); + final params = _parseOptionalParamsObject( + json, + hasId ? 'JsonRpcRequest.params' : 'JsonRpcNotification.params', + ); if (hasId) { return switch (method) { @@ -346,7 +394,7 @@ sealed class JsonRpcMessage { _ => JsonRpcRequest( id: parseRequestId(json['id']), method: method, - params: json['params'] as Map?, + params: params, meta: extractRequestMeta(json), ), }; @@ -386,21 +434,27 @@ sealed class JsonRpcMessage { JsonRpcElicitationCompleteNotification.fromJson(json), _ => JsonRpcNotification( method: method, - params: json['params'] as Map?, - meta: json['_meta'] as Map? ?? - (json['params'] as Map?)?['_meta'] - as Map?, + params: params, + meta: extractRequestMeta(json), ), }; } - } else if (json.containsKey('result')) { + } else if (hasResult && hasError) { + throw const FormatException( + 'Invalid JSON-RPC response: result and error are mutually exclusive', + ); + } else if (hasResult) { final id = _parseResultResponseId(json['id']); - final resultData = json['result'] as Map; - final meta = resultData['_meta'] as Map?; + final resultData = + readJsonObject(json['result'], 'JsonRpcResponse.result'); + final meta = readOptionalJsonObject( + resultData['_meta'], + 'JsonRpcResponse._meta', + ); final actualResult = Map.from(resultData) ..remove('_meta'); return JsonRpcResponse(id: id, result: actualResult, meta: meta); - } else if (json.containsKey('error')) { + } else if (hasError) { return JsonRpcError.fromJson(json); } else { throw FormatException('Invalid JSON-RPC message format: $json'); @@ -442,12 +496,17 @@ class JsonRpcRequest extends JsonRpcMessage { @override Map toJson() => { 'jsonrpc': jsonrpc, - 'id': id, + 'id': _requestIdToJson(id, 'JsonRpcRequest.id'), 'method': method, if (params != null || meta != null) 'params': { - ...?params, - if (meta != null) '_meta': meta, + if (params != null) + ...readJsonObject(params, 'JsonRpcRequest.params'), + if (meta != null) + '_meta': readJsonObject( + validateRequestMeta(meta), + 'JsonRpcRequest._meta', + ), }, }; } @@ -472,8 +531,10 @@ class JsonRpcNotification extends JsonRpcMessage { 'method': method, if (params != null || meta != null) 'params': { - ...?params, - if (meta != null) '_meta': meta, + if (params != null) + ...readJsonObject(params, 'JsonRpcNotification.params'), + if (meta != null) + '_meta': readJsonObject(meta, 'JsonRpcNotification._meta'), }, }; } @@ -495,8 +556,12 @@ class JsonRpcResponse extends JsonRpcMessage { @override Map toJson() => { 'jsonrpc': jsonrpc, - 'id': id, - 'result': {...result, if (meta != null) '_meta': meta}, + 'id': _requestIdToJson(id, 'JsonRpcResponse.id'), + 'result': { + ...readJsonObject(result, 'JsonRpcResponse.result'), + if (meta != null) + '_meta': readJsonObject(meta, 'JsonRpcResponse._meta'), + }, }; } // --- JSON-RPC Error --- @@ -554,15 +619,17 @@ class JsonRpcErrorData { factory JsonRpcErrorData.fromJson(Map json) => JsonRpcErrorData( - code: json['code'] as int, - message: json['message'] as String, - data: json['data'], + code: _parseErrorCode(json['code']), + message: _parseErrorMessage(json['message']), + data: json.containsKey('data') + ? readJsonValue(json['data'], 'JsonRpcErrorData.data') + : null, ); Map toJson() => { 'code': code, 'message': message, - if (data != null) 'data': data, + if (data != null) 'data': readJsonValue(data, 'JsonRpcErrorData.data'), }; } @@ -575,13 +642,15 @@ class JsonRpcError extends JsonRpcMessage { factory JsonRpcError.fromJson(Map json) => JsonRpcError( id: _parseErrorResponseId(json), - error: JsonRpcErrorData.fromJson(json['error'] as Map), + error: JsonRpcErrorData.fromJson( + readJsonObject(json['error'], 'JsonRpcError.error'), + ), ); @override Map toJson() => { 'jsonrpc': jsonrpc, - if (id != null) 'id': id, + if (id != null) 'id': _requestIdToJson(id, 'JsonRpcError.id'), 'error': error.toJson(), }; } @@ -741,7 +810,8 @@ class InputRequest { Map toJson() => { 'method': method, - if (params != null) 'params': params, + if (params != null) + 'params': readJsonObject(params, 'InputRequest.params'), }; } @@ -754,17 +824,13 @@ class InputResponse { /// Creates an input response from a typed MCP result. factory InputResponse.fromResult(BaseResultData result) { - return InputResponse.raw(result.toJson()); + return InputResponse.raw(_inputResponseJsonForResult(result)); } factory InputResponse.fromJson(Map json) { - if (!_isValidInputResponse(json)) { - throw const FormatException( - 'InputResponse must be a CreateMessageResult, ListRootsResult, ' - 'or ElicitResult', - ); - } - return InputResponse.raw(Map.from(json)); + final value = Map.from(json); + _validateInputResponse(value); + return InputResponse.raw(value); } /// Parses an input response map. @@ -788,13 +854,49 @@ class InputResponse { ); } - Map toJson() => Map.from(value); + Map toJson() { + final json = readJsonObject(value, 'InputResponse'); + _validateInputResponse(json); + return json; + } +} + +Map _inputResponseJsonForResult(BaseResultData result) { + final json = Map.from(result.toJson()); + if (result is ElicitResult || result is ListRootsResult) { + json.remove('_meta'); + } + _validateInputResponse(json); + return json; +} + +void _validateInputResponse(Map json) { + if (_canParseInputResponse(CreateMessageResult.fromJson, json)) { + return; + } + + if (_canParseInputResponse(ListRootsResult.fromJson, json)) { + _rejectInputResponseMeta(json, 'ListRootsResult'); + return; + } + + if (_canParseInputResponse(ElicitResult.fromJson, json)) { + _rejectInputResponseMeta(json, 'ElicitResult'); + return; + } + + throw const FormatException( + 'InputResponse must be a CreateMessageResult, ListRootsResult, ' + 'or ElicitResult', + ); } -bool _isValidInputResponse(Map json) { - return _canParseInputResponse(CreateMessageResult.fromJson, json) || - _canParseInputResponse(ListRootsResult.fromJson, json) || - _canParseInputResponse(ElicitResult.fromJson, json); +void _rejectInputResponseMeta(Map json, String resultName) { + if (json.containsKey('_meta')) { + throw FormatException( + 'InputResponse $resultName must not include _meta in MCP 2026', + ); + } } bool _canParseInputResponse( @@ -875,22 +977,14 @@ class InputRequiredResult implements BaseResultData { if (inputRequests != null) 'inputRequests': InputRequest.mapToJson(inputRequests!), if (requestState != null) 'requestState': requestState, - if (meta != null) '_meta': meta, + if (meta != null) + '_meta': readJsonObject(meta, 'InputRequiredResult._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'); + return readJsonObject(value, field); } Map? _readOptionalJsonObject(Object? value, String field) { diff --git a/lib/src/types/logging.dart b/lib/src/types/logging.dart index fbe51404..b57aa24c 100644 --- a/lib/src/types/logging.dart +++ b/lib/src/types/logging.dart @@ -1,4 +1,5 @@ import 'json_rpc.dart'; +import 'validation.dart'; /// Severity levels for log messages (syslog levels). enum LoggingLevel { @@ -102,7 +103,10 @@ class JsonRpcLoggingMessageNotification extends JsonRpcNotification { "Missing params for logging message notification", ); } - final meta = paramsMap['_meta'] as Map?; + final meta = readOptionalJsonObject( + paramsMap['_meta'], + 'JsonRpcLoggingMessageNotification._meta', + ); return JsonRpcLoggingMessageNotification( logParams: LoggingMessageNotification.fromJson(paramsMap), meta: meta, diff --git a/lib/src/types/misc.dart b/lib/src/types/misc.dart index dc9e4ed2..e4670eb1 100644 --- a/lib/src/types/misc.dart +++ b/lib/src/types/misc.dart @@ -1,4 +1,5 @@ import 'json_rpc.dart'; +import 'validation.dart'; /// A response that indicates success but carries no specific data. class EmptyResult implements BaseResultData { @@ -9,7 +10,7 @@ class EmptyResult implements BaseResultData { @override Map toJson() => { - if (meta != null) '_meta': meta, + if (meta != null) '_meta': readJsonObject(meta, 'EmptyResult._meta'), }; } @@ -30,7 +31,7 @@ class CancelledNotification { ); Map toJson() => { - 'requestId': requestId, + 'requestId': parseRequestId(requestId, fieldName: 'requestId'), if (reason != null) 'reason': reason, }; } @@ -51,7 +52,10 @@ class JsonRpcCancelledNotification extends JsonRpcNotification { if (paramsMap == null) { throw const FormatException("Missing params for cancelled notification"); } - final meta = paramsMap['_meta'] as Map?; + final meta = readOptionalJsonObject( + paramsMap['_meta'], + 'JsonRpcCancelledNotification._meta', + ); return JsonRpcCancelledNotification( cancelParams: CancelledNotification.fromJson(paramsMap), meta: meta, @@ -91,17 +95,21 @@ class Progress { factory Progress.fromJson(Map json) { return Progress( - progress: json['progress'] as num, - total: json['total'] as num?, + progress: readFiniteNumber(json['progress'], 'Progress.progress'), + total: readOptionalFiniteNumber(json['total'], 'Progress.total'), message: json['message'] as String?, ); } - Map toJson() => { - 'progress': progress, - if (total != null) 'total': total, - if (message != null) 'message': message, - }; + Map toJson() { + validateFiniteNumber(progress, 'Progress.progress'); + validateOptionalFiniteNumber(total, 'Progress.total'); + return { + 'progress': progress, + if (total != null) 'total': total, + if (message != null) 'message': message, + }; + } } /// Parameters for the `notifications/progress` notification. @@ -140,7 +148,7 @@ class ProgressNotification implements Progress { @override Map toJson() => { - 'progressToken': progressToken, + 'progressToken': parseProgressToken(progressToken), ...Progress( progress: progress, total: total, @@ -167,7 +175,10 @@ class JsonRpcProgressNotification extends JsonRpcNotification { if (paramsMap == null) { throw const FormatException("Missing params for progress notification"); } - final meta = paramsMap['_meta'] as Map?; + final meta = readOptionalJsonObject( + paramsMap['_meta'], + 'JsonRpcProgressNotification._meta', + ); return JsonRpcProgressNotification( progressParams: ProgressNotification.fromJson(paramsMap), meta: meta, diff --git a/lib/src/types/prompts.dart b/lib/src/types/prompts.dart index 0e4ed466..71a50939 100644 --- a/lib/src/types/prompts.dart +++ b/lib/src/types/prompts.dart @@ -90,7 +90,7 @@ class Prompt { icons: (json['icons'] as List?) ?.map((e) => McpIcon.fromJson(e as Map)) .toList(), - meta: (json['_meta'] as Map?)?.cast(), + meta: readOptionalJsonObject(json['_meta'], 'Prompt._meta'), ); } @@ -102,7 +102,7 @@ class Prompt { 'arguments': arguments!.map((a) => a.toJson()).toList(), if (icons != null) 'icons': icons!.map((icon) => icon.toJson()).toList(), - if (meta != null) '_meta': meta, + if (meta != null) '_meta': readJsonObject(meta, 'Prompt._meta'), }; } @@ -171,7 +171,8 @@ class ListPromptsResult implements CacheableResultData { }); factory ListPromptsResult.fromJson(Map json) { - final meta = json['_meta'] as Map?; + final meta = + readOptionalJsonObject(json['_meta'], 'ListPromptsResult._meta'); final prompts = json['prompts']; if (prompts is! List) { throw const FormatException('ListPromptsResult.prompts is required'); @@ -199,7 +200,8 @@ class ListPromptsResult implements CacheableResultData { if (nextCursor != null) 'nextCursor': nextCursor, if (ttlMs != null) 'ttlMs': ttlMs, if (cacheScope != null) 'cacheScope': cacheScope, - if (meta != null) '_meta': meta, + if (meta != null) + '_meta': readJsonObject(meta, 'ListPromptsResult._meta'), }; } } @@ -319,7 +321,7 @@ class GetPromptResult implements BaseResultData { const GetPromptResult({this.description, required this.messages, this.meta}); factory GetPromptResult.fromJson(Map json) { - final meta = json['_meta'] as Map?; + final meta = readOptionalJsonObject(json['_meta'], 'GetPromptResult._meta'); final messages = json['messages']; if (messages is! List) { throw const FormatException('GetPromptResult.messages is required'); @@ -337,7 +339,8 @@ class GetPromptResult implements BaseResultData { Map toJson() => { if (description != null) 'description': description, 'messages': messages.map((m) => m.toJson()).toList(), - if (meta != null) '_meta': meta, + if (meta != null) + '_meta': readJsonObject(meta, 'GetPromptResult._meta'), }; } diff --git a/lib/src/types/resources.dart b/lib/src/types/resources.dart index a5c3baee..52dc0a77 100644 --- a/lib/src/types/resources.dart +++ b/lib/src/types/resources.dart @@ -117,7 +117,7 @@ class Resource { json['annotations'] as Map, ) : null, - meta: (json['_meta'] as Map?)?.cast(), + meta: readOptionalJsonObject(json['_meta'], 'Resource._meta'), ); } @@ -132,7 +132,7 @@ class Resource { 'icons': icons!.map((icon) => icon.toJson()).toList(), if (size != null) 'size': size, if (annotations != null) 'annotations': annotations!.toJson(), - if (meta != null) '_meta': meta, + if (meta != null) '_meta': readJsonObject(meta, 'Resource._meta'), }; } @@ -200,7 +200,7 @@ class ResourceTemplate { json['annotations'] as Map, ) : null, - meta: (json['_meta'] as Map?)?.cast(), + meta: readOptionalJsonObject(json['_meta'], 'ResourceTemplate._meta'), ); } @@ -214,7 +214,8 @@ class ResourceTemplate { if (icons != null) 'icons': icons!.map((icon) => icon.toJson()).toList(), if (annotations != null) 'annotations': annotations!.toJson(), - if (meta != null) '_meta': meta, + if (meta != null) + '_meta': readJsonObject(meta, 'ResourceTemplate._meta'), }; } @@ -291,7 +292,10 @@ class ListResourcesResult implements CacheableResultData { /// Creates from JSON. factory ListResourcesResult.fromJson(Map json) { - final meta = json['_meta'] as Map?; + final meta = readOptionalJsonObject( + json['_meta'], + 'ListResourcesResult._meta', + ); final resources = json['resources']; if (resources is! List) { throw const FormatException('ListResourcesResult.resources is required'); @@ -320,7 +324,8 @@ class ListResourcesResult implements CacheableResultData { if (nextCursor != null) 'nextCursor': nextCursor, if (ttlMs != null) 'ttlMs': ttlMs, if (cacheScope != null) 'cacheScope': cacheScope, - if (meta != null) '_meta': meta, + if (meta != null) + '_meta': readJsonObject(meta, 'ListResourcesResult._meta'), }; } } @@ -395,7 +400,10 @@ class ListResourceTemplatesResult implements CacheableResultData { }); factory ListResourceTemplatesResult.fromJson(Map json) { - final meta = json['_meta'] as Map?; + final meta = readOptionalJsonObject( + json['_meta'], + 'ListResourceTemplatesResult._meta', + ); final resourceTemplates = json['resourceTemplates']; if (resourceTemplates is! List) { throw const FormatException( @@ -428,7 +436,8 @@ class ListResourceTemplatesResult implements CacheableResultData { if (nextCursor != null) 'nextCursor': nextCursor, if (ttlMs != null) 'ttlMs': ttlMs, if (cacheScope != null) 'cacheScope': cacheScope, - if (meta != null) '_meta': meta, + if (meta != null) + '_meta': readJsonObject(meta, 'ListResourceTemplatesResult._meta'), }; } } @@ -520,7 +529,10 @@ class ReadResourceResult implements CacheableResultData { }); factory ReadResourceResult.fromJson(Map json) { - final meta = json['_meta'] as Map?; + final meta = readOptionalJsonObject( + json['_meta'], + 'ReadResourceResult._meta', + ); final contents = json['contents']; if (contents is! List) { throw const FormatException('ReadResourceResult.contents is required'); @@ -546,7 +558,8 @@ class ReadResourceResult implements CacheableResultData { 'contents': contents.map((c) => c.toJson()).toList(), if (ttlMs != null) 'ttlMs': ttlMs, if (cacheScope != null) 'cacheScope': cacheScope, - if (meta != null) '_meta': meta, + if (meta != null) + '_meta': readJsonObject(meta, 'ReadResourceResult._meta'), }; } } @@ -673,7 +686,10 @@ class JsonRpcResourceUpdatedNotification extends JsonRpcNotification { "Missing params for resource updated notification", ); } - final meta = paramsMap['_meta'] as Map?; + final meta = readOptionalJsonObject( + paramsMap['_meta'], + 'JsonRpcResourceUpdatedNotification._meta', + ); return JsonRpcResourceUpdatedNotification( updatedParams: ResourceUpdatedNotification.fromJson(paramsMap), meta: meta, diff --git a/lib/src/types/roots.dart b/lib/src/types/roots.dart index e329142d..6eb2c323 100644 --- a/lib/src/types/roots.dart +++ b/lib/src/types/roots.dart @@ -1,4 +1,5 @@ import 'json_rpc.dart'; +import 'validation.dart'; /// Represents a root directory or file the server can operate on. class Root { @@ -25,14 +26,14 @@ class Root { return Root( uri: json['uri'] as String, name: json['name'] as String?, - meta: (json['_meta'] as Map?)?.cast(), + meta: readOptionalJsonObject(json['_meta'], 'Root._meta'), ); } Map toJson() => { 'uri': uri, if (name != null) 'name': name, - if (meta != null) '_meta': meta, + if (meta != null) '_meta': readJsonObject(meta, 'Root._meta'), }; } @@ -61,7 +62,7 @@ class ListRootsResult implements BaseResultData { const ListRootsResult({required this.roots, this.meta}); factory ListRootsResult.fromJson(Map json) { - final meta = json['_meta'] as Map?; + final meta = readOptionalJsonObject(json['_meta'], 'ListRootsResult._meta'); final roots = json['roots']; if (roots is! List) { throw const FormatException('ListRootsResult.roots is required'); @@ -76,7 +77,8 @@ class ListRootsResult implements BaseResultData { @override Map toJson() => { 'roots': roots.map((r) => r.toJson()).toList(), - if (meta != null) '_meta': meta, + if (meta != null) + '_meta': readJsonObject(meta, 'ListRootsResult._meta'), }; } diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index 9903bdda..01580869 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -4,23 +4,23 @@ import 'tasks.dart'; import 'tools.dart'; import 'validation.dart'; -Map? _asJsonObjectOrNull(dynamic value) { +Map? _asJsonObjectOrNull( + dynamic value, [ + String field = 'object', +]) { if (value == null) { return null; } - if (value is Map) { - return value; - } - if (value is Map) { - return value.cast(); - } - throw FormatException('Expected object, got ${value.runtimeType}'); + return readJsonObject(value, field); } -Map _asJsonObject(dynamic value) { - final map = _asJsonObjectOrNull(value); +Map _asJsonObject( + dynamic value, [ + String field = 'object', +]) { + final map = _asJsonObjectOrNull(value, field); if (map == null) { - throw const FormatException('Expected object, got null'); + throw FormatException('$field must be a JSON object'); } return map; } @@ -286,26 +286,45 @@ sealed class SamplingContent { ...switch (this) { final SamplingTextContent c => { 'text': c.text, - if (c.annotations != null) 'annotations': c.annotations, - if (c.meta != null) '_meta': c.meta, + if (c.annotations != null) + 'annotations': readJsonObject( + c.annotations, + 'SamplingTextContent.annotations', + ), + if (c.meta != null) + '_meta': readJsonObject(c.meta, 'SamplingTextContent._meta'), }, final SamplingImageContent c => { 'data': c.data, 'mimeType': c.mimeType, - if (c.annotations != null) 'annotations': c.annotations, - if (c.meta != null) '_meta': c.meta, + if (c.annotations != null) + 'annotations': readJsonObject( + c.annotations, + 'SamplingImageContent.annotations', + ), + if (c.meta != null) + '_meta': readJsonObject(c.meta, 'SamplingImageContent._meta'), }, final SamplingAudioContent c => { 'data': c.data, 'mimeType': c.mimeType, - if (c.annotations != null) 'annotations': c.annotations, - if (c.meta != null) '_meta': c.meta, + if (c.annotations != null) + 'annotations': readJsonObject( + c.annotations, + 'SamplingAudioContent.annotations', + ), + if (c.meta != null) + '_meta': readJsonObject(c.meta, 'SamplingAudioContent._meta'), }, final SamplingToolUseContent c => { 'id': c.id, 'name': c.name, - 'input': c.input, - if (c.meta != null) '_meta': c.meta, + 'input': readJsonObject( + c.input, + 'SamplingToolUseContent.input', + ), + if (c.meta != null) + '_meta': readJsonObject(c.meta, 'SamplingToolUseContent._meta'), }, final SamplingToolResultContent c => { 'toolUseId': c.toolUseId, @@ -316,7 +335,11 @@ sealed class SamplingContent { 'SamplingToolResultContent.structuredContent', ), if (c.isError != null) 'isError': c.isError, - if (c.meta != null) '_meta': c.meta, + if (c.meta != null) + '_meta': readJsonObject( + c.meta, + 'SamplingToolResultContent._meta', + ), }, }, }; @@ -342,8 +365,11 @@ class SamplingTextContent extends SamplingContent { factory SamplingTextContent.fromJson(Map json) => SamplingTextContent( text: json['text'] as String, - annotations: _asJsonObjectOrNull(json['annotations']), - meta: _asJsonObjectOrNull(json['_meta']), + annotations: _asJsonObjectOrNull( + json['annotations'], + 'SamplingTextContent.annotations', + ), + meta: _asJsonObjectOrNull(json['_meta'], 'SamplingTextContent._meta'), ); } @@ -372,8 +398,11 @@ class SamplingImageContent extends SamplingContent { SamplingImageContent( data: json['data'] as String, mimeType: json['mimeType'] as String, - annotations: _asJsonObjectOrNull(json['annotations']), - meta: _asJsonObjectOrNull(json['_meta']), + annotations: _asJsonObjectOrNull( + json['annotations'], + 'SamplingImageContent.annotations', + ), + meta: _asJsonObjectOrNull(json['_meta'], 'SamplingImageContent._meta'), ); } @@ -402,8 +431,11 @@ class SamplingAudioContent extends SamplingContent { SamplingAudioContent( data: json['data'] as String, mimeType: json['mimeType'] as String, - annotations: _asJsonObjectOrNull(json['annotations']), - meta: _asJsonObjectOrNull(json['_meta']), + annotations: _asJsonObjectOrNull( + json['annotations'], + 'SamplingAudioContent.annotations', + ), + meta: _asJsonObjectOrNull(json['_meta'], 'SamplingAudioContent._meta'), ); } @@ -425,8 +457,9 @@ class SamplingToolUseContent extends SamplingContent { SamplingToolUseContent( id: json['id'] as String, name: json['name'] as String, - input: _asJsonObject(json['input']), - meta: _asJsonObjectOrNull(json['_meta']), + input: _asJsonObject(json['input'], 'SamplingToolUseContent.input'), + meta: + _asJsonObjectOrNull(json['_meta'], 'SamplingToolUseContent._meta'), ); } @@ -469,7 +502,8 @@ class SamplingToolResultContent extends SamplingContent { : null, hasStructuredContent: json.containsKey('structuredContent'), isError: json['isError'] as bool?, - meta: _asJsonObjectOrNull(json['_meta']), + meta: + _asJsonObjectOrNull(json['_meta'], 'SamplingToolResultContent._meta'), ); } } @@ -508,7 +542,7 @@ class SamplingMessage { return SamplingMessage( role: SamplingMessageRole.values.byName(json['role'] as String), content: _parseSamplingMessageContent(json['content']), - meta: _asJsonObjectOrNull(json['_meta']), + meta: _asJsonObjectOrNull(json['_meta'], 'SamplingMessage._meta'), ); } @@ -516,7 +550,8 @@ class SamplingMessage { Map toJson() => { 'role': role.name, 'content': _samplingMessageContentToJson(content), - if (meta != null) '_meta': meta, + if (meta != null) + '_meta': readJsonObject(meta, 'SamplingMessage._meta'), }; } @@ -625,10 +660,16 @@ class CreateMessageRequest { systemPrompt: json['systemPrompt'] as String?, includeContext: ctxStr == null ? null : IncludeContext.values.byName(ctxStr), - temperature: (json['temperature'] as num?)?.toDouble(), + temperature: readOptionalFiniteDouble( + json['temperature'], + 'CreateMessageRequest.temperature', + ), maxTokens: json['maxTokens'] as int, stopSequences: (json['stopSequences'] as List?)?.cast(), - metadata: _asJsonObjectOrNull(json['metadata']), + metadata: _asJsonObjectOrNull( + json['metadata'], + 'CreateMessageRequest.metadata', + ), modelPreferences: json['modelPreferences'] == null ? null : ModelPreferences.fromJson( @@ -642,20 +683,30 @@ class CreateMessageRequest { } /// Converts to JSON. - Map toJson() => { - 'messages': messages.map((m) => m.toJson()).toList(), - if (task != null) 'task': task!.toJson(), - if (systemPrompt != null) 'systemPrompt': systemPrompt, - if (includeContext != null) 'includeContext': includeContext!.name, - if (temperature != null) 'temperature': temperature, - 'maxTokens': maxTokens, - if (stopSequences != null) 'stopSequences': stopSequences, - if (metadata != null) 'metadata': metadata, - if (modelPreferences != null) - 'modelPreferences': modelPreferences!.toJson(), - if (tools != null) 'tools': tools!.map((t) => t.toJson()).toList(), - if (toolChoiceConfig != null) 'toolChoice': toolChoiceConfig!.toJson(), - }; + Map toJson() { + validateOptionalFiniteNumber( + temperature, + 'CreateMessageRequest.temperature', + ); + return { + 'messages': messages.map((m) => m.toJson()).toList(), + if (task != null) 'task': task!.toJson(), + if (systemPrompt != null) 'systemPrompt': systemPrompt, + if (includeContext != null) 'includeContext': includeContext!.name, + if (temperature != null) 'temperature': temperature, + 'maxTokens': maxTokens, + if (stopSequences != null) 'stopSequences': stopSequences, + if (metadata != null) + 'metadata': readJsonObject( + metadata, + 'CreateMessageRequest.metadata', + ), + if (modelPreferences != null) + 'modelPreferences': modelPreferences!.toJson(), + if (tools != null) 'tools': tools!.map((t) => t.toJson()).toList(), + if (toolChoiceConfig != null) 'toolChoice': toolChoiceConfig!.toJson(), + }; + } } /// Request sent from server to client to sample an LLM. @@ -729,7 +780,8 @@ class CreateMessageResult implements BaseResultData { } factory CreateMessageResult.fromJson(Map json) { - final meta = _asJsonObjectOrNull(json['_meta']); + final meta = + _asJsonObjectOrNull(json['_meta'], 'CreateMessageResult._meta'); dynamic reason = json['stopReason']; if (reason is String) { try { @@ -765,7 +817,8 @@ class CreateMessageResult implements BaseResultData { 'stopReason': reason is StopReason ? reason.name : reason, 'role': role.name, 'content': _samplingMessageContentToJson(content), - if (meta != null) '_meta': meta, + if (meta != null) + '_meta': readJsonObject(meta, 'CreateMessageResult._meta'), }; } } diff --git a/lib/src/types/subscriptions.dart b/lib/src/types/subscriptions.dart index bcdecd66..bb539824 100644 --- a/lib/src/types/subscriptions.dart +++ b/lib/src/types/subscriptions.dart @@ -1,5 +1,6 @@ import 'initialization.dart'; import 'json_rpc.dart'; +import 'validation.dart'; /// Notification filter requested by `subscriptions/listen`. class SubscriptionFilter { @@ -292,14 +293,5 @@ 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'); + return readJsonObject(value, field); } diff --git a/lib/src/types/tasks.dart b/lib/src/types/tasks.dart index a9a6fca7..511e712c 100644 --- a/lib/src/types/tasks.dart +++ b/lib/src/types/tasks.dart @@ -1,5 +1,6 @@ import '../types.dart'; import 'json_rpc.dart'; +import 'validation.dart'; /// The current state of a task execution. enum TaskStatus { @@ -98,7 +99,7 @@ class Task implements BaseResultData { final createdAt = _readRequiredTaskString(json, 'createdAt'); final lastUpdatedAt = _readRequiredTaskString(json, 'lastUpdatedAt'); - final meta = json['_meta'] as Map?; + final meta = readOptionalJsonObject(json['_meta'], 'Task._meta'); return Task( taskId: _readRequiredTaskString(json, 'taskId'), status: TaskStatusName.fromString( @@ -122,7 +123,8 @@ class Task implements BaseResultData { if (pollInterval != null) 'pollInterval': pollInterval, 'createdAt': createdAt, 'lastUpdatedAt': lastUpdatedAt, - if (includeMeta && meta != null) '_meta': meta, + if (includeMeta && meta != null) + '_meta': readJsonObject(meta, 'Task._meta'), }; /// Serializes this task where MCP expects the bare `Task` schema. @@ -231,7 +233,7 @@ class ListTasksResult implements BaseResultData { const ListTasksResult({required this.tasks, this.nextCursor, this.meta}); factory ListTasksResult.fromJson(Map json) { - final meta = json['_meta'] as Map?; + final meta = readOptionalJsonObject(json['_meta'], 'ListTasksResult._meta'); final tasks = json['tasks']; if (tasks is! List) { throw const FormatException('ListTasksResult.tasks is required'); @@ -248,7 +250,8 @@ class ListTasksResult implements BaseResultData { Map toJson() => { 'tasks': tasks.map((t) => t.toBareJson()).toList(), if (nextCursor != null) 'nextCursor': nextCursor, - if (meta != null) '_meta': meta, + if (meta != null) + '_meta': readJsonObject(meta, 'ListTasksResult._meta'), }; } @@ -458,7 +461,8 @@ class CreateTaskResult implements BaseResultData { const CreateTaskResult({required this.task, this.meta}); factory CreateTaskResult.fromJson(Map json) { - final meta = json['_meta'] as Map?; + final meta = + readOptionalJsonObject(json['_meta'], 'CreateTaskResult._meta'); return CreateTaskResult( task: Task.fromJson(json['task'] as Map), meta: meta, @@ -468,7 +472,8 @@ class CreateTaskResult implements BaseResultData { @override Map toJson() => { 'task': task.toBareJson(), - if (meta != null) '_meta': meta, + if (meta != null) + '_meta': readJsonObject(meta, 'CreateTaskResult._meta'), }; } @@ -609,7 +614,8 @@ class TaskExtensionTask { if (pollIntervalMs != null) 'pollIntervalMs': pollIntervalMs, if (inputRequests != null) 'inputRequests': InputRequest.mapToJson(inputRequests!), - if (result != null) 'result': result, + if (result != null) + 'result': readJsonObject(result, 'TaskExtensionTask.result'), if (error != null) 'error': error!.toJson(), }; } @@ -643,7 +649,8 @@ class CreateTaskExtensionResult implements BaseResultData { @override Map toJson() => { ...task.toJson(resultType: resultTypeTask), - if (meta != null) '_meta': meta, + if (meta != null) + '_meta': readJsonObject(meta, 'CreateTaskExtensionResult._meta'), }; } @@ -676,7 +683,8 @@ class GetTaskExtensionResult implements BaseResultData { @override Map toJson() => { ...task.toJson(resultType: resultTypeComplete), - if (meta != null) '_meta': meta, + if (meta != null) + '_meta': readJsonObject(meta, 'GetTaskExtensionResult._meta'), }; } @@ -707,7 +715,11 @@ class TaskExtensionAcknowledgementResult implements BaseResultData { @override Map toJson() => { 'resultType': resultTypeComplete, - if (meta != null) '_meta': meta, + if (meta != null) + '_meta': readJsonObject( + meta, + 'TaskExtensionAcknowledgementResult._meta', + ), }; } @@ -838,7 +850,10 @@ class JsonRpcTaskStatusNotification extends JsonRpcNotification { "Missing params for task status notification", ); } - final meta = paramsMap['_meta'] as Map?; + final meta = _readOptionalJsonObject( + paramsMap['_meta'], + 'JsonRpcTaskStatusNotification._meta', + ); return JsonRpcTaskStatusNotification( statusParams: TaskStatusNotification.fromJson(paramsMap), meta: meta, @@ -870,16 +885,7 @@ class JsonRpcTaskNotification extends JsonRpcNotification { } 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'); + return readJsonObject(value, field); } Map? _readOptionalJsonObject(Object? value, String field) { diff --git a/lib/src/types/tools.dart b/lib/src/types/tools.dart index 519f5b6b..94ca94b2 100644 --- a/lib/src/types/tools.dart +++ b/lib/src/types/tools.dart @@ -212,7 +212,7 @@ class Tool { json['annotations'] as Map, ) : null, - meta: json['_meta'] as Map?, + meta: readOptionalJsonObject(json['_meta'], 'Tool._meta'), execution: json['execution'] != null ? ToolExecution.fromJson(json['execution'] as Map) : null, @@ -235,7 +235,7 @@ class Tool { 'inputSchema': inputSchema.toJson(), if (outputSchema != null) 'outputSchema': outputSchema!.toJson(), if (annotations != null) 'annotations': annotations!.toJson(), - if (meta != null) '_meta': meta, + if (meta != null) '_meta': readJsonObject(meta, 'Tool._meta'), if (execution != null) 'execution': execution!.toJson(), if (icons != null) 'icons': icons!.map((icon) => icon.toJson()).toList(), }; @@ -305,7 +305,7 @@ class ListToolsResult implements CacheableResultData { json['cacheScope'], 'ListToolsResult.cacheScope', ), - meta: json['_meta'] as Map?, + meta: readOptionalJsonObject(json['_meta'], 'ListToolsResult._meta'), ); } @@ -318,7 +318,7 @@ class ListToolsResult implements CacheableResultData { if (nextCursor != null) 'nextCursor': nextCursor, if (ttlMs != null) 'ttlMs': ttlMs, if (cacheScope != null) 'cacheScope': cacheScope, - if (meta != null) '_meta': meta, + if (meta != null) '_meta': readJsonObject(meta, 'ListToolsResult._meta'), }; } } @@ -353,7 +353,7 @@ class CallToolRequest { name: json['name'] as String, arguments: arguments == null ? const {} - : (arguments as Map).cast(), + : _readJsonObject(arguments, 'CallToolRequest.arguments'), inputResponses: InputResponse.mapFromJson( json['inputResponses'], 'CallToolRequest.inputResponses', @@ -367,7 +367,7 @@ class CallToolRequest { Map toJson() => { 'name': name, - 'arguments': arguments, + 'arguments': readJsonObject(arguments, 'CallToolRequest.arguments'), if (inputResponses != null) 'inputResponses': InputResponse.mapToJson(inputResponses!), if (requestState != null) 'requestState': requestState, @@ -448,8 +448,9 @@ class CallToolResult implements BaseResultData { ) : null, hasStructuredContent: json.containsKey('structuredContent'), - meta: json['_meta'] as Map?, - extra: extra.isEmpty ? null : extra, + meta: readOptionalJsonObject(json['_meta'], 'CallToolResult._meta'), + extra: + extra.isEmpty ? null : readJsonObject(extra, 'CallToolResult.extra'), ); } @@ -462,8 +463,8 @@ class CallToolResult implements BaseResultData { structuredContent, 'CallToolResult.structuredContent', ), - if (meta != null) '_meta': meta, - ...?extra, + if (meta != null) '_meta': readJsonObject(meta, 'CallToolResult._meta'), + if (extra != null) ...readJsonObject(extra, 'CallToolResult.extra'), }; } @@ -511,14 +512,5 @@ Map? _readOptionalJsonObject(Object? value, String field) { } Map _readJsonObject(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'); + return readJsonObject(value, field); } diff --git a/lib/src/types/validation.dart b/lib/src/types/validation.dart index ee101058..21220b97 100644 --- a/lib/src/types/validation.dart +++ b/lib/src/types/validation.dart @@ -1,11 +1,9 @@ double? readUnitDouble(Object? value, String field) { - if (value == null) { + final number = readOptionalFiniteNumber(value, field); + final result = number?.toDouble(); + if (result == null) { return null; } - if (value is! num) { - throw FormatException('$field must be a number between 0 and 1'); - } - final result = value.toDouble(); if (result < 0 || result > 1) { throw FormatException('$field must be between 0 and 1'); } @@ -16,11 +14,42 @@ void validateUnitDouble(double? value, String field) { if (value == null) { return; } - if (value < 0 || value > 1) { + if (!value.isFinite || value < 0 || value > 1) { throw ArgumentError.value(value, field, 'must be between 0 and 1'); } } +num readFiniteNumber(Object? value, String field) { + if (value is num && value.isFinite) { + return value; + } + throw FormatException('$field must be a finite JSON number'); +} + +num? readOptionalFiniteNumber(Object? value, String field) { + if (value == null) { + return null; + } + return readFiniteNumber(value, field); +} + +double? readOptionalFiniteDouble(Object? value, String field) { + return readOptionalFiniteNumber(value, field)?.toDouble(); +} + +void validateFiniteNumber(num value, String field) { + if (!value.isFinite) { + throw ArgumentError.value(value, field, 'must be a finite JSON number'); + } +} + +void validateOptionalFiniteNumber(num? value, String field) { + if (value == null) { + return; + } + validateFiniteNumber(value, field); +} + int? readOptionalInteger(Object? value, String field) { if (value == null) { return null; @@ -119,3 +148,17 @@ Object? readJsonValue(Object? value, String field) { } throw FormatException('$field must be a JSON value'); } + +Map readJsonObject(Object? value, String field) { + if (value is! Map) { + throw FormatException('$field must be a JSON object'); + } + return (readJsonValue(value, field) as Map).cast(); +} + +Map? readOptionalJsonObject(Object? value, String field) { + if (value == null) { + return null; + } + return readJsonObject(value, field); +} diff --git a/packages/mcp_dart_cli/README.md b/packages/mcp_dart_cli/README.md index 33a11664..1d246f79 100644 --- a/packages/mcp_dart_cli/README.md +++ b/packages/mcp_dart_cli/README.md @@ -353,10 +353,11 @@ mcp_dart call-tool search --url http://localhost:3000/mcp --json-args '{"q":"mcp Run built-in fixture checks, MCP 2025-11-25 spec-critical checks, and deterministic fuzz checks for protocol edge cases in this Dart SDK/CLI package. -The fixture suite covers JSON-RPC malformed-message handling, string request -IDs, string progress tokens, and advertised protocol-version support. The spec -suite covers raw-wire lifecycle, capability, elicitation, task-metadata, and -progress-token negative cases. +The fixture suite covers JSON-RPC malformed-message handling, string and +integer request IDs, string and integer progress tokens, fractional ID/token +rejection, and advertised protocol-version support. The spec suite covers +raw-wire lifecycle, capability, elicitation, task-metadata, progress-token +dispatch, and negative cases. This command is useful as a regression gate for the Dart SDK and CLI, but it is not a live compliance scanner for arbitrary MCP servers or clients. For external diff --git a/packages/mcp_dart_cli/lib/src/conformance_runner.dart b/packages/mcp_dart_cli/lib/src/conformance_runner.dart index d1987ace..78bf05f6 100644 --- a/packages/mcp_dart_cli/lib/src/conformance_runner.dart +++ b/packages/mcp_dart_cli/lib/src/conformance_runner.dart @@ -6,6 +6,13 @@ import 'package:mcp_dart/mcp_dart.dart'; const String _fixtureSuite = 'fixture'; const String _specSuite = 'spec'; const String _allSuites = 'all'; +const String _serverDiscoverMethod = 'server/discover'; +const String _draftProtocolVersion2026_07_28 = '2026-07-28'; +const String _protocolVersionMetaKey = + 'io.modelcontextprotocol/protocolVersion'; +const String _clientInfoMetaKey = 'io.modelcontextprotocol/clientInfo'; +const String _clientCapabilitiesMetaKey = + 'io.modelcontextprotocol/clientCapabilities'; const List conformanceSuiteNames = [ _fixtureSuite, @@ -96,6 +103,41 @@ class ConformanceRunner { 'Rejects JSON-RPC envelopes without a method, result, or error member.', check: _rejectsMalformedJsonRpcMessage, ), + _ConformanceCase( + suite: _fixtureSuite, + name: 'jsonrpc.rejects-non-string-method', + description: + 'Rejects JSON-RPC requests whose method member is not a string.', + check: _rejectsNonStringJsonRpcMethod, + ), + _ConformanceCase( + suite: _fixtureSuite, + name: 'jsonrpc.rejects-result-error-response', + description: + 'Rejects JSON-RPC responses that include both result and error members.', + check: _rejectsResultErrorJsonRpcResponse, + ), + _ConformanceCase( + suite: _fixtureSuite, + name: 'jsonrpc.rejects-malformed-error-object', + description: + 'Rejects JSON-RPC error responses whose error member is malformed.', + check: _rejectsMalformedJsonRpcErrorObject, + ), + _ConformanceCase( + suite: _fixtureSuite, + name: 'jsonrpc.rejects-null-error-response-id', + description: + 'Rejects JSON-RPC error responses whose id member is explicitly null.', + check: _rejectsNullJsonRpcErrorResponseId, + ), + _ConformanceCase( + suite: _fixtureSuite, + name: 'jsonrpc.rejects-null-params-member', + description: + 'Rejects JSON-RPC request and notification envelopes whose params member is null.', + check: _rejectsNullJsonRpcParamsMember, + ), _ConformanceCase( suite: _fixtureSuite, name: 'jsonrpc.preserves-string-response-id', @@ -103,6 +145,13 @@ class ConformanceRunner { 'Parses and serializes successful responses with string JSON-RPC IDs.', check: _preservesStringResponseId, ), + _ConformanceCase( + suite: _fixtureSuite, + name: 'jsonrpc.preserves-integer-response-id', + description: + 'Parses and serializes successful responses with integer JSON-RPC IDs.', + check: _preservesIntegerResponseId, + ), _ConformanceCase( suite: _fixtureSuite, name: 'jsonrpc.preserves-string-progress-token', @@ -110,6 +159,20 @@ class ConformanceRunner { 'Parses and serializes progress notifications with string progress tokens.', check: _preservesStringProgressToken, ), + _ConformanceCase( + suite: _fixtureSuite, + name: 'jsonrpc.preserves-integer-progress-token', + description: + 'Parses and serializes progress notifications with integer progress tokens.', + check: _preservesIntegerProgressToken, + ), + _ConformanceCase( + suite: _fixtureSuite, + name: 'jsonrpc.rejects-fractional-ids-and-progress-tokens', + description: + 'Rejects fractional JSON-RPC request IDs, response IDs, and progress tokens.', + check: _rejectsFractionalIdsAndProgressTokens, + ), _ConformanceCase( suite: _fixtureSuite, name: 'protocol-version.advertises-latest-2025-11-25', @@ -126,6 +189,13 @@ class ConformanceRunner { 'Rejects operation requests before the initialize handshake.', check: _rejectsPreInitializeRequest, ), + _ConformanceCase( + suite: _specSuite, + name: 'server-discover.requires-request-meta', + description: + 'Rejects server/discover requests that omit params._meta request metadata.', + check: _serverDiscoverRequiresRequestMeta, + ), _ConformanceCase( suite: _specSuite, name: 'capabilities.rejects-unnegotiated-sampling-tools', @@ -154,6 +224,13 @@ class ConformanceRunner { 'Rejects progress notifications whose progressToken is not a string or integer.', check: _rejectsMalformedProgressToken, ), + _ConformanceCase( + suite: _specSuite, + name: 'progress.dispatches-integer-progress-token', + description: + 'Dispatches progress notifications for integer progress tokens.', + check: _dispatchesIntegerProgressToken, + ), ]; /// Runs one named conformance suite. @@ -282,9 +359,9 @@ class _GeneratedJsonRpcFixture { } _GeneratedJsonRpcFixture _generatedJsonRpcFixture(Random random, int index) { - final numericId = random.nextInt(1000000); + final integerId = random.nextInt(1000000); final stringId = 'req-${random.nextInt(1000000)}'; - final progressToken = random.nextBool() ? numericId : 'progress-$numericId'; + final progressToken = random.nextBool() ? integerId : 'progress-$integerId'; return switch (random.nextInt(6)) { 0 => _GeneratedJsonRpcFixture( @@ -293,7 +370,7 @@ _GeneratedJsonRpcFixture _generatedJsonRpcFixture(Random random, int index) { 'Generated request with an invalid JSON-RPC version is rejected.', message: { 'jsonrpc': '2.${random.nextInt(9) + 1}', - 'id': random.nextBool() ? numericId : stringId, + 'id': random.nextBool() ? integerId : stringId, 'method': Method.ping, }, expectation: _expectFormatExceptionForPayload, @@ -304,7 +381,7 @@ _GeneratedJsonRpcFixture _generatedJsonRpcFixture(Random random, int index) { 'Generated JSON-RPC envelope without request/response members is rejected.', message: { 'jsonrpc': jsonRpcVersion, - 'id': random.nextBool() ? numericId : stringId, + 'id': random.nextBool() ? integerId : stringId, 'params': {'noise': random.nextInt(100)}, }, expectation: _expectFormatExceptionForPayload, @@ -314,7 +391,7 @@ _GeneratedJsonRpcFixture _generatedJsonRpcFixture(Random random, int index) { description: 'Generated requests preserve string-or-integer IDs.', message: { 'jsonrpc': jsonRpcVersion, - 'id': random.nextBool() ? numericId : stringId, + 'id': random.nextBool() ? integerId : stringId, 'method': Method.ping, }, expectation: _expectRequestIdRoundTrip, @@ -324,7 +401,7 @@ _GeneratedJsonRpcFixture _generatedJsonRpcFixture(Random random, int index) { description: 'Generated responses preserve string-or-integer IDs.', message: { 'jsonrpc': jsonRpcVersion, - 'id': random.nextBool() ? numericId : stringId, + 'id': random.nextBool() ? integerId : stringId, 'result': {}, }, expectation: _expectResponseIdRoundTrip, @@ -350,7 +427,7 @@ _GeneratedJsonRpcFixture _generatedJsonRpcFixture(Random random, int index) { 'Generated error responses preserve string-or-integer IDs.', message: { 'jsonrpc': jsonRpcVersion, - 'id': random.nextBool() ? numericId : stringId, + 'id': random.nextBool() ? integerId : stringId, 'error': { 'code': ErrorCode.invalidRequest.value, 'message': 'generated invalid request', @@ -490,6 +567,57 @@ Future _rejectsPreInitializeRequest() async { await server.close(); } +Future _serverDiscoverRequiresRequestMeta() async { + for (final message in [ + { + 'jsonrpc': jsonRpcVersion, + 'id': 'discover-1', + 'method': _serverDiscoverMethod, + }, + { + 'jsonrpc': jsonRpcVersion, + 'id': 'discover-1', + 'method': _serverDiscoverMethod, + '_meta': { + _protocolVersionMetaKey: _draftProtocolVersion2026_07_28, + }, + }, + { + 'jsonrpc': jsonRpcVersion, + 'id': 'discover-1', + 'method': _serverDiscoverMethod, + 'params': {}, + }, + ]) { + _expectThrowsFormatException(() => JsonRpcMessage.fromJson(message)); + } + + final parsed = JsonRpcMessage.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'discover-1', + 'method': _serverDiscoverMethod, + 'params': { + '_meta': { + _protocolVersionMetaKey: _draftProtocolVersion2026_07_28, + _clientInfoMetaKey: { + 'name': 'client', + 'version': '1.0.0', + }, + _clientCapabilitiesMetaKey: {}, + }, + }, + }); + if (parsed is! JsonRpcRequest) { + throw StateError( + 'Expected JsonRpcRequest, got ${parsed.runtimeType}.', + ); + } + if (parsed.meta?[_protocolVersionMetaKey] != + _draftProtocolVersion2026_07_28) { + throw StateError('Expected server/discover metadata to be preserved.'); + } +} + Future _rejectsUnnegotiatedSamplingTools() async { final transport = _ConformanceTransport(); final client = McpClient( @@ -625,6 +753,64 @@ Future _rejectsMalformedProgressToken() async { ); } +Future _dispatchesIntegerProgressToken() async { + final transport = _ConformanceTransport(); + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), + ), + ); + server.registerTool( + 'progress_probe', + callback: (args, extra) async { + await extra.sendProgress(1, total: 2, message: 'halfway'); + return const CallToolResult( + content: [TextContent(text: 'ok')], + ); + }, + ); + + await _initializeMcpServer(server, transport); + transport.emit( + const JsonRpcCallToolRequest( + id: 104, + params: { + 'name': 'progress_probe', + 'arguments': {}, + '_meta': { + 'progressToken': 15, + }, + }, + ), + ); + await _settle(); + + final progressMessages = transport.sentMessages + .whereType() + .where((message) => message.method == Method.notificationsProgress) + .toList(); + if (progressMessages.length != 1) { + throw StateError( + 'Expected one progress notification, got ${progressMessages.length}.', + ); + } + final progress = ProgressNotification.fromJson( + progressMessages.single.params ?? const {}, + ); + if (progress.progressToken != 15) { + throw StateError('Expected integer progress token to be preserved.'); + } + if (progress.progress != 1 || progress.total != 2) { + throw StateError('Expected progress values to be preserved.'); + } + + final responses = + transport.sentMessages.whereType().toList(); + _expectSingleErrorFreeResponse(responses, id: 104); + await server.close(); +} + Future _rejectsInvalidJsonRpcVersion() async { _expectThrowsFormatException( () => JsonRpcMessage.fromJson(const { @@ -644,6 +830,115 @@ Future _rejectsMalformedJsonRpcMessage() async { ); } +Future _rejectsNonStringJsonRpcMethod() async { + _expectThrowsFormatException( + () => JsonRpcMessage.fromJson(const { + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': 1, + }), + ); +} + +Future _rejectsResultErrorJsonRpcResponse() async { + _expectThrowsFormatException( + () => JsonRpcMessage.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'result': {}, + 'error': { + 'code': ErrorCode.internalError.value, + 'message': 'Internal error', + }, + }), + ); +} + +Future _rejectsMalformedJsonRpcErrorObject() async { + _expectThrowsFormatException( + () => JsonRpcMessage.fromJson(const { + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'error': { + 'code': 'not-a-number', + 'message': 'Invalid request', + }, + }), + ); +} + +Future _rejectsNullJsonRpcErrorResponseId() async { + _expectThrowsFormatException( + () => JsonRpcMessage.fromJson(const { + 'jsonrpc': jsonRpcVersion, + 'id': null, + 'error': { + 'code': -32600, + 'message': 'Invalid request', + }, + }), + ); +} + +Future _rejectsNullJsonRpcParamsMember() async { + for (final message in const [ + { + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.ping, + 'params': null, + }, + { + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + 'params': null, + }, + ]) { + _expectThrowsFormatException(() => JsonRpcMessage.fromJson(message)); + } +} + +Future _rejectsFractionalIdsAndProgressTokens() async { + for (final message in const [ + { + 'jsonrpc': jsonRpcVersion, + 'id': 1.5, + 'method': Method.ping, + }, + { + 'jsonrpc': jsonRpcVersion, + 'id': 1.5, + 'result': {}, + }, + { + 'jsonrpc': jsonRpcVersion, + 'id': 1.5, + 'error': { + 'code': -32600, + 'message': 'Invalid request', + }, + }, + { + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.ping, + 'params': { + '_meta': {'progressToken': 1.5}, + }, + }, + { + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsProgress, + 'params': { + 'progressToken': 1.5, + 'progress': 1, + }, + }, + ]) { + _expectThrowsFormatException(() => JsonRpcMessage.fromJson(message)); + } +} + Future _preservesStringResponseId() async { final message = JsonRpcMessage.fromJson(const { 'jsonrpc': jsonRpcVersion, @@ -662,6 +957,24 @@ Future _preservesStringResponseId() async { } } +Future _preservesIntegerResponseId() async { + final message = JsonRpcMessage.fromJson(const { + 'jsonrpc': jsonRpcVersion, + 'id': 15, + 'result': {}, + }); + + if (message is! JsonRpcResponse) { + throw StateError('Expected JsonRpcResponse, got ${message.runtimeType}.'); + } + if (message.id != 15) { + throw StateError('Expected integer response ID to be preserved.'); + } + if (message.toJson()['id'] != 15) { + throw StateError('Expected serialized response ID to stay an integer.'); + } +} + JsonRpcResponse _expectSingleErrorFreeResponse( List messages, { required RequestId id, @@ -731,6 +1044,30 @@ Future _preservesStringProgressToken() async { } } +Future _preservesIntegerProgressToken() async { + final message = JsonRpcMessage.fromJson(const { + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsProgress, + 'params': { + 'progressToken': 15, + 'progress': 1, + 'total': 2, + }, + }); + + if (message is! JsonRpcProgressNotification) { + throw StateError( + 'Expected JsonRpcProgressNotification, got ${message.runtimeType}.', + ); + } + if (message.progressParams.progressToken != 15) { + throw StateError('Expected integer progress token to be preserved.'); + } + if (message.toJson()['params']['progressToken'] != 15) { + throw StateError('Expected serialized progress token to stay an integer.'); + } +} + Future _advertisesLatestProtocolVersion() async { if (latestProtocolVersion != '2025-11-25') { throw StateError( diff --git a/packages/mcp_dart_cli/test/src/conformance_command_test.dart b/packages/mcp_dart_cli/test/src/conformance_command_test.dart index 1f303dd1..71eb2708 100644 --- a/packages/mcp_dart_cli/test/src/conformance_command_test.dart +++ b/packages/mcp_dart_cli/test/src/conformance_command_test.dart @@ -20,8 +20,16 @@ void main() { containsAll([ 'jsonrpc.rejects-invalid-version', 'jsonrpc.rejects-malformed-message', + 'jsonrpc.rejects-non-string-method', + 'jsonrpc.rejects-result-error-response', + 'jsonrpc.rejects-malformed-error-object', + 'jsonrpc.rejects-null-error-response-id', + 'jsonrpc.rejects-null-params-member', 'jsonrpc.preserves-string-response-id', + 'jsonrpc.preserves-integer-response-id', 'jsonrpc.preserves-string-progress-token', + 'jsonrpc.preserves-integer-progress-token', + 'jsonrpc.rejects-fractional-ids-and-progress-tokens', 'protocol-version.advertises-latest-2025-11-25', ]), ); @@ -36,10 +44,12 @@ void main() { result.caseNames, containsAll([ 'lifecycle.rejects-pre-initialize-request', + 'server-discover.requires-request-meta', 'capabilities.rejects-unnegotiated-sampling-tools', 'elicitation.rejects-invalid-form-url-union', 'tasks.strips-unnegotiated-related-task-metadata', 'progress.rejects-malformed-progress-token', + 'progress.dispatches-integer-progress-token', ]), ); expect( diff --git a/test/client/client_test.dart b/test/client/client_test.dart index 3638cc89..b790113f 100644 --- a/test/client/client_test.dart +++ b/test/client/client_test.dart @@ -56,14 +56,14 @@ void main() { test('registerCapabilities merges capabilities', () { final initialCapabilities = - const ClientCapabilities(experimental: {'feature1': true}); + const ClientCapabilities(experimental: {'feature1': {}}); client = Client( clientInfo, options: McpClientOptions(capabilities: initialCapabilities), ); final additionalCapabilities = const ClientCapabilities( - experimental: {'feature2': true}, + experimental: {'feature2': {}}, roots: ClientCapabilitiesRoots(listChanged: true), ); diff --git a/test/client/streamable_https_test.dart b/test/client/streamable_https_test.dart index cfe885ab..8196269a 100644 --- a/test/client/streamable_https_test.dart +++ b/test/client/streamable_https_test.dart @@ -1089,6 +1089,11 @@ void main() { transport = StreamableHttpClientTransport( Uri.parse('http://localhost:${server.port}/mcp'), opts: const StreamableHttpClientTransportOptions( + requestInit: { + 'headers': { + 'Mcp-Session-Id': 'custom-session', + }, + }, sessionId: 'legacy-session', ), )..protocolVersion = draftProtocolVersion2026_07_28; @@ -1119,6 +1124,68 @@ void main() { expect(transport.sessionId, 'legacy-session'); }); + test('send derives 2026 stateless HTTP headers from nested metadata', + () 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'); + capturedHeaders['session'] = request.headers.value('mcp-session-id'); + final body = jsonDecode(await utf8.decodeStream(request)) + as Map; + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write( + jsonEncode( + JsonRpcResponse( + id: body['id'], + result: const {'content': []}, + ).toJson(), + ), + ); + await request.response.close(); + }); + + transport = StreamableHttpClientTransport( + Uri.parse('http://localhost:${server.port}/mcp'), + opts: const StreamableHttpClientTransportOptions( + sessionId: 'legacy-session', + ), + ); + await transport.start(); + + final completer = Completer(); + transport.onmessage = completer.complete; + + await transport.send( + const JsonRpcRequest( + id: 1, + method: Method.toolsCall, + params: { + 'name': 'echo', + 'arguments': {'message': 'hello'}, + '_meta': { + McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, + }, + }, + ), + ); + await completer.future.timeout(const Duration(seconds: 5)); + + expect( + capturedHeaders['protocolVersion'], + draftProtocolVersion2026_07_28, + ); + expect(capturedHeaders['method'], Method.toolsCall); + expect(capturedHeaders['name'], 'echo'); + expect(capturedHeaders['session'], isNull); + }); + test('send maps 2026 stateless headers for standard request types', () async { final capturedHeaders = >[]; diff --git a/test/client/task_client_test.dart b/test/client/task_client_test.dart index d66b5ec3..c68021cc 100644 --- a/test/client/task_client_test.dart +++ b/test/client/task_client_test.dart @@ -29,7 +29,7 @@ class MockClient implements McpClient { JsonRpcRequest request, T Function(Map json) parser, [ RequestOptions? options, - int? relatedRequestId, + RequestId? relatedRequestId, ]) async { requests.add(request); diff --git a/test/elicitation_test.dart b/test/elicitation_test.dart index 08580a46..f13f2fff 100644 --- a/test/elicitation_test.dart +++ b/test/elicitation_test.dart @@ -742,6 +742,23 @@ void main() { }), throwsA(isA()), ); + expect( + () => ElicitRequestParams.fromJson({ + 'mode': 'url', + 'message': 'Please authenticate', + 'url': 'relative/callback', + 'elicitationId': 'oauth-123', + }), + throwsA(isA()), + ); + expect( + () => const ElicitRequestParams.url( + message: 'Please authenticate', + url: 'relative/callback', + elicitationId: 'oauth-123', + ).toJson(), + throwsA(isA()), + ); expect( () => ElicitRequestParams.fromJson({ 'mode': 'url', @@ -1043,13 +1060,13 @@ void main() { throwsA(isA()), ); expect( - () => ElicitResult.fromJson({ + ElicitResult.fromJson({ 'action': 'accept', 'content': { 'ratio': 0.5, }, - }), - throwsA(isA()), + }).content?['ratio'], + 0.5, ); expect( () => ElicitResult.fromJson({ @@ -1070,13 +1087,13 @@ void main() { throwsA(isA()), ); expect( - () => const ElicitResult( + const ElicitResult( action: 'accept', content: { 'ratio': 0.5, }, - ).toJson(), - throwsA(isA()), + ).toJson()['content'], + containsPair('ratio', 0.5), ); expect( () => const ElicitResult( diff --git a/test/integration/completions_capability_test.dart b/test/integration/completions_capability_test.dart index 548f7d77..3c145676 100644 --- a/test/integration/completions_capability_test.dart +++ b/test/integration/completions_capability_test.dart @@ -92,7 +92,7 @@ void main() { test('Completions capability survives serialization round-trip', () { final originalCaps = const ServerCapabilities( completions: ServerCapabilitiesCompletions(listChanged: true), - experimental: {'test': true}, + experimental: {'test': {}}, ); final json = originalCaps.toJson(); @@ -100,7 +100,7 @@ void main() { expect(deserializedCaps.completions, isNotNull); expect(json['completions'], isEmpty); - expect(deserializedCaps.experimental?['test'], equals(true)); + expect(deserializedCaps.experimental?['test'], isEmpty); }); test('legacy completions listChanged payload still parses', () { diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 4cdfb874..20250b3b 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -253,6 +253,26 @@ void main() { expect(deserialized.elicitationId, 'ui-123'); }); + test('Elicitation URL must be absolute URI', () { + expect( + () => ElicitRequestParams.fromJson({ + 'mode': 'url', + 'message': 'test', + 'url': '/relative/ui', + 'elicitationId': 'ui-123', + }), + throwsA(isA()), + ); + expect( + () => const ElicitRequestParams.url( + message: 'test', + url: '/relative/ui', + elicitationId: 'ui-123', + ).toJson(), + throwsA(isA()), + ); + }); + test('JsonEnum SEP-1330', () { final schema = const JsonEnum( [ @@ -306,14 +326,17 @@ void main() { action: 'accept', content: { 'text': 'answer', + 'confidence': 0.75, 'selection': ['a', 'b'], // List }, ); + expect(result.content?['confidence'], 0.75); expect(result.content?['selection'], isA()); expect((result.content?['selection'] as List).first, 'a'); final json = result.toJson(); final deserialized = ElicitResult.fromJson(json); + expect(deserialized.content?['confidence'], 0.75); expect((deserialized.content?['selection'] as List).last, 'b'); }); @@ -498,6 +521,129 @@ void main() { expect((json['content'] as List).single['type'], 'tool_use'); }); + test('Sampling JSON object fields reject non-JSON Dart maps', () { + expect( + () => SamplingToolUseContent.fromJson({ + 'type': 'tool_use', + 'id': 'call-1', + 'name': 'calculator', + 'input': {'expr': Object()}, + }), + throwsA(isA()), + ); + expect( + () => SamplingMessage.fromJson({ + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + '_meta': {'provider': Object()}, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageResult.fromJson({ + 'role': 'assistant', + 'content': {'type': 'text', 'text': 'Hello'}, + 'model': 'model-x', + '_meta': {'provider': Object()}, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 100, + 'metadata': {'provider': Object()}, + }), + throwsA(isA()), + ); + }); + + test('Content JSON object fields reject non-JSON Dart maps', () { + expect( + () => TextContent.fromJson({ + 'type': 'text', + 'text': 'Hello', + '_meta': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => ResourceContents.fromJson({ + 'uri': 'file:///docs/readme.md', + 'text': 'README body', + '_meta': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + 'annotations': {'bad': Object()}, + }), + throwsA(isA()), + ); + }); + + test('Tool JSON object fields reject non-JSON Dart maps', () { + expect( + () => Tool.fromJson({ + 'name': 'search', + 'inputSchema': {'type': 'object'}, + '_meta': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => CallToolRequest.fromJson({ + 'name': 'search', + 'arguments': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => CallToolResult.fromJson({ + 'content': >[], + '_meta': {'bad': Object()}, + }), + throwsA(isA()), + ); + }); + + test('Result metadata fields reject non-JSON Dart maps', () { + expect( + () => Root.fromJson({ + 'uri': 'file:///repo', + '_meta': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => ListResourcesResult.fromJson({ + 'resources': [], + '_meta': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcMessage.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'result': { + 'ok': true, + '_meta': {'bad': Object()}, + }, + }), + throwsA(isA()), + ); + }); + group('Tasks API Types', () { test('GetTaskRequestParams serialization', () { final params = const GetTaskRequestParams(taskId: 'task-123'); @@ -1080,6 +1226,26 @@ void main() { ); }); + test('request parsing prefers params metadata over top-level metadata', + () { + final parsed = JsonRpcMessage.fromJson( + const { + 'jsonrpc': jsonRpcVersion, + 'id': 'tools', + 'method': Method.toolsList, + '_meta': {'progressToken': 'top-level'}, + 'params': { + '_meta': {'progressToken': 'params-nested'}, + }, + }, + ); + + expect(parsed, isA()); + final request = parsed as JsonRpcListToolsRequest; + expect(request.meta, {'progressToken': 'params-nested'}); + expect(request.progressToken, 'params-nested'); + }); + test('server capabilities omit non-stable fields while parsing legacy', () { final capabilities = const ServerCapabilities( @@ -1165,7 +1331,7 @@ void main() { isA().having( (error) => error.message, 'message', - contains('Tool.inputSchema must be an object'), + contains('Tool.inputSchema must be a JSON object'), ), ), ); @@ -1179,7 +1345,7 @@ void main() { isA().having( (error) => error.message, 'message', - contains('Tool.outputSchema must be an object'), + contains('Tool.outputSchema must be a JSON object'), ), ), ); @@ -1243,6 +1409,14 @@ void main() { () => Annotations.fromJson({'priority': -0.1}), throwsA(isA()), ); + expect( + () => Annotations(priority: double.nan).toJson(), + throwsA(anyOf(isA(), isA())), + ); + expect( + () => Annotations.fromJson({'priority': double.infinity}), + throwsA(isA()), + ); expect( () => CompletionResultData( values: List.generate(101, (index) => '$index'), diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 498f65a7..ac69cfe4 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -47,7 +47,12 @@ class DiscoveringClientTransport extends Transport this.capabilities = const ServerCapabilities( tools: ServerCapabilitiesTools(), ), - this.toolsListResult = const {'tools': []}, + this.toolsListResult = const { + 'resultType': resultTypeComplete, + 'tools': [], + 'ttlMs': 0, + 'cacheScope': CacheScope.private, + }, }); final List discoverVersions; @@ -317,6 +322,218 @@ void main() { } }); + test('request parsing does not let top-level metadata override params', () { + final parsed = JsonRpcMessage.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'tools', + 'method': Method.toolsList, + '_meta': { + McpMetaKey.protocolVersion: latestProtocolVersion, + }, + 'params': { + '_meta': _clientMeta(), + }, + }); + + expect(parsed, isA()); + final request = parsed as JsonRpcListToolsRequest; + expect( + request.meta?[McpMetaKey.protocolVersion], + draftProtocolVersion2026_07_28, + ); + expect(request.meta?[McpMetaKey.clientInfo], { + 'name': 'client', + 'version': '1.0.0', + }); + }); + + test('preserves integer request ids and progress tokens', () { + final message = JsonRpcMessage.fromJson( + const { + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsList, + 'params': { + '_meta': {'progressToken': 2}, + }, + }, + ); + + expect(message, isA()); + final request = message as JsonRpcListToolsRequest; + expect(request.id, 1); + expect(request.progressToken, 2); + expect(request.toJson()['id'], 1); + expect(request.toJson()['params']['_meta']['progressToken'], 2); + }); + + test('rejects URL elicitation relative URI values', () { + expect( + () => ElicitRequestParams.fromJson({ + 'mode': 'url', + 'message': 'Open browser', + 'url': 'authorize/callback', + 'elicitationId': 'auth-1', + }), + throwsA(isA()), + ); + expect( + () => const ElicitRequestParams.url( + message: 'Open browser', + url: 'authorize/callback', + elicitationId: 'auth-1', + ).toJson(), + throwsA(isA()), + ); + }); + + test('rejects non-finite JSON numbers', () { + expect( + () => ProgressNotification.fromJson({ + 'progressToken': 'progress-1', + 'progress': double.nan, + }), + throwsA(isA()), + ); + expect( + () => const ProgressNotification( + progressToken: 'progress-1', + progress: double.infinity, + ).toJson(), + throwsA(isA()), + ); + expect( + () => CreateMessageRequest.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 16, + 'temperature': double.nan, + }), + throwsA(isA()), + ); + expect( + () => ElicitResult.fromJson({ + 'action': 'accept', + 'content': {'score': double.infinity}, + }), + throwsA(isA()), + ); + expect( + () => const ElicitResult( + action: 'accept', + content: {'score': double.nan}, + ).toJson(), + throwsA(isA()), + ); + }); + + test('rejects non-JSON sampling object values', () { + expect( + () => SamplingToolUseContent.fromJson({ + 'type': 'tool_use', + 'id': 'call-1', + 'name': 'lookup', + 'input': {'query': Object()}, + }), + throwsA(isA()), + ); + expect( + () => SamplingMessage.fromJson({ + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + '_meta': {'provider': Object()}, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageResult.fromJson({ + 'role': 'assistant', + 'content': {'type': 'text', 'text': 'Hello'}, + 'model': 'model-x', + '_meta': {'provider': Object()}, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequest.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 16, + 'metadata': {'provider': Object()}, + }), + throwsA(isA()), + ); + }); + + test('rejects non-JSON content object values', () { + expect( + () => TextContent.fromJson({ + 'type': 'text', + 'text': 'Hello', + '_meta': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => ResourceContents.fromJson({ + 'uri': 'file:///docs/readme.md', + 'text': 'README body', + '_meta': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + 'annotations': {'bad': Object()}, + }), + throwsA(isA()), + ); + }); + + test('rejects non-JSON result metadata values', () { + expect( + () => DiscoverResult.fromJson({ + 'resultType': 'complete', + 'supportedVersions': [draftProtocolVersion2026_07_28], + 'capabilities': {}, + 'serverInfo': {'name': 'server', 'version': '1.0.0'}, + '_meta': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => const DiscoverResult( + supportedVersions: [draftProtocolVersion2026_07_28], + capabilities: ServerCapabilities(), + serverInfo: Implementation(name: 'server', version: '1.0.0'), + meta: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + expect( + () => JsonRpcMessage.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'result': { + 'resultType': 'complete', + '_meta': {'bad': Object()}, + }, + }), + throwsA(isA()), + ); + }); + test('serializes server/discover request and result', () { final request = JsonRpcServerDiscoverRequest( id: 'discover-1', @@ -350,6 +567,63 @@ void main() { ); }); + test('requires server/discover request metadata in params', () { + expect( + () => JsonRpcServerDiscoverRequest(id: 'discover-1').toJson(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('params._meta'), + ), + ), + ); + + for (final message in [ + { + 'jsonrpc': jsonRpcVersion, + 'id': 'discover-1', + 'method': Method.serverDiscover, + }, + { + 'jsonrpc': jsonRpcVersion, + 'id': 'discover-1', + 'method': Method.serverDiscover, + '_meta': _clientMeta(), + }, + { + 'jsonrpc': jsonRpcVersion, + 'id': 'discover-1', + 'method': Method.serverDiscover, + 'params': {}, + }, + ]) { + expect( + () => JsonRpcMessage.fromJson(message), + throwsA( + isA().having( + (error) => error.message, + 'message', + anyOf(contains('params'), contains('params._meta')), + ), + ), + ); + } + + final parsed = JsonRpcMessage.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'discover-1', + 'method': Method.serverDiscover, + 'params': {'_meta': _clientMeta()}, + }); + expect(parsed, isA()); + expect( + (parsed as JsonRpcServerDiscoverRequest) + .meta?[McpMetaKey.protocolVersion], + draftProtocolVersion2026_07_28, + ); + }); + test('serializes cacheable result hints without changing legacy defaults', () { final toolsJson = const ListToolsResult( @@ -499,10 +773,22 @@ void main() { const ElicitResult( action: 'accept', content: {'name': 'octocat'}, + meta: {'stable': true}, ), ), 'roots': InputResponse.fromResult( - ListRootsResult(roots: [Root(uri: 'file:///repo')]), + ListRootsResult( + roots: [Root(uri: 'file:///repo')], + meta: const {'stable': true}, + ), + ), + 'capital_of_france': InputResponse.fromResult( + const CreateMessageResult( + model: 'model', + role: SamplingMessageRole.assistant, + content: SamplingTextContent(text: 'Paris'), + meta: {'preserved': true}, + ), ), }; @@ -514,6 +800,14 @@ void main() { ); final toolJson = toolRequest.toJson(); expect(toolJson['inputResponses']['github_login']['action'], 'accept'); + expect( + toolJson['inputResponses']['github_login'], + isNot(contains('_meta')), + ); + expect(toolJson['inputResponses']['roots'], isNot(contains('_meta'))); + expect(toolJson['inputResponses']['capital_of_france']['_meta'], { + 'preserved': true, + }); expect(toolJson['requestState'], 'opaque-state'); final parsedToolRequest = CallToolRequest.fromJson(toolJson); @@ -577,6 +871,21 @@ void main() { ), throwsFormatException, ); + expect( + () => InputRequiredResult.fromJson({ + 'resultType': resultTypeInputRequired, + 'requestState': 'state', + '_meta': {'bad': Object()}, + }), + throwsFormatException, + ); + expect( + () => const InputRequiredResult( + requestState: 'state', + meta: {'bad': Object()}, + ).toJson(), + throwsFormatException, + ); expect( () => InputRequiredResult.fromJson( const { @@ -594,6 +903,20 @@ void main() { ), throwsFormatException, ); + expect( + () => CallToolRequest.fromJson({ + 'name': 'deploy', + 'arguments': {'bad': Object()}, + }), + throwsFormatException, + ); + expect( + () => const CallToolRequest( + name: 'deploy', + arguments: {'bad': Object()}, + ).toJson(), + throwsFormatException, + ); expect( () => ReadResourceRequest.fromJson( const { @@ -614,6 +937,27 @@ void main() { ), throwsFormatException, ); + expect( + () => ReadResourceRequest.fromJson( + const { + 'uri': 'file:///repo/README.md', + 'inputResponses': { + 'roots': { + 'roots': [], + '_meta': {'trace': 'not-in-draft-client-result'}, + }, + }, + }, + ), + throwsFormatException, + ); + expect( + () => const InputResponse.raw({ + 'action': 'accept', + '_meta': {'trace': 'not-in-draft-client-result'}, + }).toJson(), + throwsFormatException, + ); }); test('server acknowledges subscriptions/listen with subscription id', @@ -1897,6 +2241,23 @@ void main() { contains('Invalid stateless request metadata.'), ), ); + expect( + validateToolRequest({ + McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, + McpMetaKey.clientInfo: { + 'name': 'client', + 'version': '1.0.0', + }, + McpMetaKey.clientCapabilities: { + 'experimental': {'feature': true}, + }, + }), + isA().having( + (error) => error.message, + 'message', + contains('Invalid stateless request metadata.'), + ), + ); expect( validateToolRequest(_clientMeta(logLevel: 'verbose')), isA().having( @@ -2156,6 +2517,159 @@ void main() { expect(listRequest.meta?[McpMetaKey.clientCapabilities], {}); }); + test('stateless client rejects removed request methods before send', + () async { + final transport = DiscoveringClientTransport(); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + await client.connect(transport); + transport.sentMessages.clear(); + + final removedRequests = <({String method, Future Function() call})>[ + ( + method: Method.ping, + call: () async { + await client.ping(); + }, + ), + ( + method: Method.loggingSetLevel, + call: () async { + await client.setLoggingLevel(LoggingLevel.debug); + }, + ), + ( + method: Method.resourcesSubscribe, + call: () async { + await client.subscribeResource( + const SubscribeRequest(uri: 'file:///tmp/example.txt'), + ); + }, + ), + ( + method: Method.resourcesUnsubscribe, + call: () async { + await client.unsubscribeResource( + const UnsubscribeRequest(uri: 'file:///tmp/example.txt'), + ); + }, + ), + ( + method: Method.tasksList, + call: () async { + await client.request( + JsonRpcListTasksRequest(id: -1), + ListTasksResult.fromJson, + ); + }, + ), + ( + method: Method.tasksResult, + call: () async { + await client.request( + JsonRpcTaskResultRequest( + id: -1, + resultParams: const TaskResultRequest(taskId: 'task-1'), + ), + CallToolResult.fromJson, + ); + }, + ), + ]; + + for (final scenario in removedRequests) { + await expectLater( + scenario.call(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.methodNotFound.value, + ) + .having( + (error) => error.message, + 'message', + contains(scenario.method), + ), + ), + ); + } + + expect(transport.sentMessages, isEmpty); + }); + + test('stateless client rejects removed notifications before send', + () async { + final transport = DiscoveringClientTransport(); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + await client.connect(transport); + transport.sentMessages.clear(); + + final removedNotifications = + <({String method, Future Function() call})>[ + ( + method: Method.notificationsInitialized, + call: () => client.notification( + const JsonRpcInitializedNotification(), + ), + ), + ( + method: Method.notificationsRootsListChanged, + call: client.sendRootsListChanged, + ), + ]; + + for (final scenario in removedNotifications) { + await expectLater( + scenario.call(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.methodNotFound.value, + ) + .having( + (error) => error.message, + 'message', + contains(scenario.method), + ), + ), + ); + } + + expect(transport.sentMessages, isEmpty); + }); + + test('stateless client rejects server-initiated requests on transport', + () async { + final transport = DiscoveringClientTransport(); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + capabilities: ClientCapabilities(roots: ClientCapabilitiesRoots()), + useServerDiscover: true, + ), + ); + await client.connect(transport); + transport.sentMessages.clear(); + + transport.onmessage?.call(const JsonRpcListRootsRequest(id: 'roots-1')); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect(response.id, 'roots-1'); + expect(response.error.code, ErrorCode.invalidRequest.value); + expect(response.error.message, contains('input_required')); + expect(response.error.message, contains('inputRequests')); + }); + test('client listenSubscriptions requires a connected transport', () { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), @@ -2576,7 +3090,7 @@ void main() { transport.onmessage?.call( JsonRpcResponse( id: subscription.id, - result: const EmptyResult().toJson(), + result: const {'resultType': resultTypeComplete}, ), ); @@ -2584,6 +3098,39 @@ void main() { await doneExpectation; }); + test('client rejects missing stateless resultType values', () async { + final transport = DiscoveringClientTransport( + toolsListResult: const { + 'tools': [], + 'ttlMs': 0, + 'cacheScope': CacheScope.private, + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + + await client.connect(transport); + + await expectLater( + client.listTools(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.internalError.value, + ) + .having( + (error) => error.data.toString(), + 'data', + contains('must include resultType'), + ), + ), + ); + }); + test('client rejects unrecognized stateless resultType values', () async { final transport = DiscoveringClientTransport( toolsListResult: const { @@ -2653,6 +3200,77 @@ void main() { ); }); + for (final scenario in [ + ( + name: 'missing ttlMs', + result: const { + 'resultType': resultTypeComplete, + 'tools': [], + 'cacheScope': CacheScope.private, + }, + message: 'ttlMs', + ), + ( + name: 'missing cacheScope', + result: const { + 'resultType': resultTypeComplete, + 'tools': [], + 'ttlMs': 0, + }, + message: 'cacheScope', + ), + ( + name: 'negative ttlMs', + result: const { + 'resultType': resultTypeComplete, + 'tools': [], + 'ttlMs': -1, + 'cacheScope': CacheScope.private, + }, + message: 'ttlMs', + ), + ( + name: 'invalid cacheScope', + result: const { + 'resultType': resultTypeComplete, + 'tools': [], + 'ttlMs': 0, + 'cacheScope': 'shared', + }, + message: 'cacheScope', + ), + ]) { + test('client rejects stateless cacheable result ${scenario.name}', + () async { + final transport = DiscoveringClientTransport( + toolsListResult: scenario.result, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + + await client.connect(transport); + + await expectLater( + client.listTools(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.internalError.value, + ) + .having( + (error) => error.data.toString(), + 'data', + contains(scenario.message), + ), + ), + ); + }); + } + test('client accepts advertised task extension resultType values', () async { final transport = DiscoveringClientTransport( diff --git a/test/server/stdio_test.dart b/test/server/stdio_test.dart index 4e9b0c1e..07eadc36 100644 --- a/test/server/stdio_test.dart +++ b/test/server/stdio_test.dart @@ -343,6 +343,14 @@ void main() { 'method': 'ping', }, ), + ( + field: 'id', + message: { + 'jsonrpc': '2.0', + 'id': 1.5, + 'method': 'ping', + }, + ), ( field: 'id', message: { @@ -351,6 +359,19 @@ void main() { 'method': 'ping', }, ), + ( + field: 'progressToken', + message: { + 'jsonrpc': '2.0', + 'id': 'with-bad-meta', + 'method': 'ping', + 'params': { + '_meta': { + 'progressToken': 1.5, + }, + }, + }, + ), ( field: 'progressToken', message: { @@ -375,6 +396,17 @@ void main() { }, }, ), + ( + field: 'progressToken', + message: { + 'jsonrpc': '2.0', + 'method': 'notifications/progress', + 'params': { + 'progressToken': 1.5, + 'progress': 0, + }, + }, + ), ( field: 'progressToken', message: { @@ -404,6 +436,17 @@ void main() { 'result': {}, }, ), + ( + field: 'id', + message: { + 'jsonrpc': '2.0', + 'id': null, + 'error': { + 'code': -32600, + 'message': 'Invalid request', + }, + }, + ), ( field: 'id', message: { diff --git a/test/server/streamable_mcp_server_test.dart b/test/server/streamable_mcp_server_test.dart index 7d4e60ad..3998bfcd 100644 --- a/test/server/streamable_mcp_server_test.dart +++ b/test/server/streamable_mcp_server_test.dart @@ -227,6 +227,28 @@ void main() { } }); + test('CORS preflight allows stateless routing and parameter headers', + () async { + final client = http.Client(); + try { + final req = http.Request('OPTIONS', Uri.parse(baseUrl)) + ..headers['Access-Control-Request-Method'] = 'POST' + ..headers['Access-Control-Request-Headers'] = + 'Mcp-Method, Mcp-Name, Mcp-Param-Region'; + final streamedRes = await client.send(req); + final res = await http.Response.fromStream(streamedRes); + final allowedHeaders = + res.headers['access-control-allow-headers']!.toLowerCase(); + + expect(res.statusCode, HttpStatus.ok); + expect(allowedHeaders, contains('mcp-method')); + expect(allowedHeaders, contains('mcp-name')); + expect(allowedHeaders, contains('mcp-param-region')); + } finally { + client.close(); + } + }); + test('initialize session flow', () async { final initRequest = JsonRpcRequest( id: 1, @@ -340,6 +362,51 @@ void main() { expect(messages.single['result']['tools'][0]['name'], 'echo'); }); + test('detects stateless requests from nested metadata before top-level', + () 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 request = JsonRpcListToolsRequest( + id: 11, + meta: statelessMeta(), + ).toJson() + ..['_meta'] = const { + McpMetaKey.protocolVersion: latestProtocolVersion, + }; + + final response = await http.post( + Uri.parse(baseUrl), + body: jsonEncode(request), + 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), diff --git a/test/shared/progress_test.dart b/test/shared/progress_test.dart index e0dc8de0..07ffef74 100644 --- a/test/shared/progress_test.dart +++ b/test/shared/progress_test.dart @@ -264,6 +264,92 @@ void main() { expect(msg3, isA()); }); + test('Server sends progress with integer progress token', () async { + protocol.setRequestHandler( + 'test/integer-token-task', + (request, extra) async { + await extra.sendProgress(10, total: 100, message: 'Starting'); + return TestResult(value: 'success'); + }, + (id, params, meta) => JsonRpcRequest( + id: id, + method: 'test/integer-token-task', + params: params, + meta: meta, + ), + ); + + const progressToken = 12345; + transport.receiveMessage( + const JsonRpcRequest( + id: 99, + method: 'test/integer-token-task', + meta: {'progressToken': progressToken}, + ), + ); + + await Future.delayed(const Duration(milliseconds: 50)); + + expect(transport.sentMessages.length, 2); + + final msg1 = transport.sentMessages[0]; + expect(msg1, isA()); + expect((msg1 as JsonRpcNotification).method, 'notifications/progress'); + expect(msg1.params, isNotNull); + expect(msg1.params!['progressToken'], progressToken); + expect(msg1.params!['progress'], 10); + expect(msg1.params!['message'], 'Starting'); + + final msg2 = transport.sentMessages[1]; + expect(msg2, isA()); + }); + + test('RequestHandlerExtra ignores malformed progress token', () async { + final sentNotifications = []; + final extra = RequestHandlerExtra( + signal: BasicAbortController().signal, + requestId: 101, + meta: const {'progressToken': double.nan}, + sendNotification: (notification, {relatedTask}) async { + sentNotifications.add(notification); + }, + sendRequest: ( + JsonRpcRequest request, + T Function(Map) resultFactory, + RequestOptions options, + ) async { + return resultFactory({}); + }, + ); + + await extra.sendProgress(10); + + expect(sentNotifications, isEmpty); + }); + + test('RequestHandlerExtra ignores fractional progress token', () async { + final sentNotifications = []; + final extra = RequestHandlerExtra( + signal: BasicAbortController().signal, + requestId: 101, + meta: const {'progressToken': 123.5}, + sendNotification: (notification, {relatedTask}) async { + sentNotifications.add(notification); + }, + sendRequest: ( + JsonRpcRequest request, + T Function(Map) resultFactory, + RequestOptions options, + ) async { + return resultFactory({}); + }, + ); + + await extra.sendProgress(10); + + expect(sentNotifications, isEmpty); + }); + test('RequestHandlerExtra rejects non-increasing progress', () async { final sentNotifications = []; final extra = RequestHandlerExtra( diff --git a/test/shared/protocol_advanced_scenarios_test.dart b/test/shared/protocol_advanced_scenarios_test.dart index 0d65f9a1..29bb6985 100644 --- a/test/shared/protocol_advanced_scenarios_test.dart +++ b/test/shared/protocol_advanced_scenarios_test.dart @@ -103,12 +103,13 @@ void main() { // Send progress notification with an invalid progressToken type. transport.receiveMessage( - JsonRpcProgressNotification( - progressParams: const ProgressNotificationParams( - progressToken: false, - progress: 50, - total: 100, - ), + const JsonRpcNotification( + method: Method.notificationsProgress, + params: { + 'progressToken': false, + 'progress': 50, + 'total': 100, + }, ), ); diff --git a/test/shared/protocol_test.dart b/test/shared/protocol_test.dart index 34dcf20e..d65d86b5 100644 --- a/test/shared/protocol_test.dart +++ b/test/shared/protocol_test.dart @@ -477,6 +477,46 @@ void main() { expect(response.result['value'], 'nested-ok'); }); + test('public request preserves string relatedRequestId', () async { + await protocol.connect(transport); + + final requestFuture = protocol + .request( + const JsonRpcRequest(id: 0, method: 'test/method'), + (json) => TestResult(value: json['value'] as String), + const RequestOptions(timeout: Duration(seconds: 1)), + 'parent-req-1', + ) + .timeout(const Duration(seconds: 5)); + + await waitForSentMessages(transport, 1); + + expect(transport.sentMessages[0], isA()); + expect(transport.relatedRequestIds[0], 'parent-req-1'); + + final request = transport.sentMessages[0] as JsonRpcRequest; + transport.receiveMessage( + JsonRpcResponse( + id: request.id, + result: {'value': 'ok'}, + ), + ); + + expect((await requestFuture).value, 'ok'); + }); + + test('public notification preserves integer relatedRequestId', () async { + await protocol.connect(transport); + + await protocol.notification( + const JsonRpcNotification(method: 'test/notification'), + relatedRequestId: 15, + ); + + expect(transport.sentMessages.single, isA()); + expect(transport.relatedRequestIds.single, 15); + }); + test('routes nested cancellation notifications for string request IDs', () async { await protocol.connect(transport); @@ -617,6 +657,58 @@ void main() { expect(result.value, 'response-data'); }); + test('dispatches integer progress tokens from request metadata', () async { + await protocol.connect(transport); + + final progressUpdates = []; + final requestFuture = protocol + .request( + const JsonRpcRequest( + id: 0, + method: 'test/method', + meta: {'progressToken': 15}, + ), + (json) => TestResult(value: json['value'] as String), + RequestOptions( + onprogress: progressUpdates.add, + timeout: const Duration(seconds: 1), + ), + ) + .timeout(const Duration(seconds: 5)); + + expect(transport.sentMessages, hasLength(1)); + final sentRequest = transport.sentMessages.single as JsonRpcRequest; + expect(sentRequest.meta?['progressToken'], 15); + + transport.receiveMessage( + JsonRpcProgressNotification( + progressParams: const ProgressNotification( + progressToken: 15, + progress: 50, + total: 100, + message: 'halfway', + ), + ), + ); + + await Future.delayed(const Duration(milliseconds: 20)); + + expect(progressUpdates, hasLength(1)); + expect(progressUpdates.single.progress, 50); + expect(progressUpdates.single.total, 100); + expect(progressUpdates.single.message, 'halfway'); + + transport.receiveMessage( + JsonRpcResponse( + id: sentRequest.id, + result: {'value': 'response-data'}, + ), + ); + + final result = await requestFuture; + expect(result.value, 'response-data'); + }); + test('task options serialize as task-augmented request params', () async { await protocol.connect(transport); @@ -1146,6 +1238,48 @@ void main() { expect(transport.sentMessages, isEmpty); }); + test( + 'rejects non-finite request progress tokens when progress handler is set', + () async { + await protocol.connect(transport); + + await expectLater( + protocol.request( + const JsonRpcRequest( + id: 0, + method: 'test/method', + meta: {'progressToken': double.nan}, + ), + (json) => TestResult(value: json['value'] as String), + RequestOptions(onprogress: (_) {}), + ), + throwsA(isA()), + ); + + expect(transport.sentMessages, isEmpty); + }); + + test( + 'rejects fractional request progress tokens when progress handler is set', + () async { + await protocol.connect(transport); + + await expectLater( + protocol.request( + const JsonRpcRequest( + id: 0, + method: 'test/method', + meta: {'progressToken': 1.5}, + ), + (json) => TestResult(value: json['value'] as String), + RequestOptions(onprogress: (_) {}), + ), + throwsA(isA()), + ); + + expect(transport.sentMessages, isEmpty); + }); + test('keeps task-augmented progress tokens until terminal task status', () async { await protocol.connect(transport); diff --git a/test/shared/transport_api_compatibility_test.dart b/test/shared/transport_api_compatibility_test.dart index d458b7c2..00473749 100644 --- a/test/shared/transport_api_compatibility_test.dart +++ b/test/shared/transport_api_compatibility_test.dart @@ -30,7 +30,7 @@ class LegacyTransport implements Transport { Future start() async {} } -class StringAwareTransport extends LegacyTransport +class FullRequestIdAwareTransport extends LegacyTransport implements RequestIdAwareTransport { RequestId? lastRequestIdAwareRelatedRequestId; @@ -59,9 +59,10 @@ void main() { expect(transport.lastRelatedRequestId, isNull); }); - test('request-id-aware transports preserve string relatedRequestId', + test( + 'request-id-aware transports preserve string and integer relatedRequestId', () async { - final transport = StringAwareTransport(); + final transport = FullRequestIdAwareTransport(); await transport.sendPreservingRequestId( const JsonRpcNotification(method: 'test/notification'), @@ -71,6 +72,13 @@ void main() { expect(transport.lastMessage, isA()); expect(transport.lastRelatedRequestId, isNull); expect(transport.lastRequestIdAwareRelatedRequestId, 'client-req-1'); + + await transport.sendPreservingRequestId( + const JsonRpcNotification(method: 'test/notification'), + relatedRequestId: 15, + ); + + expect(transport.lastRequestIdAwareRelatedRequestId, 15); }); }); } diff --git a/test/tool_schema_test.dart b/test/tool_schema_test.dart index f45d9661..b3e42154 100644 --- a/test/tool_schema_test.dart +++ b/test/tool_schema_test.dart @@ -232,6 +232,60 @@ void main() { expect(parsedNull.structuredContent, isNull); }); + test('Tool JSON object fields reject non-JSON Dart map values', () { + expect( + () => Tool.fromJson({ + 'name': 'search', + 'inputSchema': {'type': 'object'}, + '_meta': {'bad': Object()}, + }), + throwsFormatException, + ); + expect( + () => const Tool( + name: 'search', + inputSchema: JsonObject(), + meta: {'bad': Object()}, + ).toJson(), + throwsFormatException, + ); + expect( + () => CallToolRequest.fromJson({ + 'name': 'search', + 'arguments': {'bad': Object()}, + }), + throwsFormatException, + ); + expect( + () => const CallToolRequest( + name: 'search', + arguments: {'bad': Object()}, + ).toJson(), + throwsFormatException, + ); + expect( + () => CallToolResult.fromJson({ + 'content': >[], + '_meta': {'bad': Object()}, + }), + throwsFormatException, + ); + expect( + () => const CallToolResult( + content: [], + meta: {'bad': Object()}, + ).toJson(), + throwsFormatException, + ); + expect( + () => CallToolResult.fromJson({ + 'content': >[], + 'x-extra': Object(), + }), + throwsFormatException, + ); + }); + test('Tool serializes JsonEnum properties as standard enum schema', () { const tool = Tool( name: 'configure_mode', diff --git a/test/types/sampling_test.dart b/test/types/sampling_test.dart index 3eaacd35..a0e808c5 100644 --- a/test/types/sampling_test.dart +++ b/test/types/sampling_test.dart @@ -76,6 +76,19 @@ void main() { expect(prefs.hints, isNull); expect(prefs.costPriority, isNull); }); + + test('rejects non-finite priorities', () { + for (final value in [double.nan, double.infinity]) { + expect( + () => ModelPreferences.fromJson({'costPriority': value}), + throwsA(isA()), + ); + expect( + () => ModelPreferences(costPriority: value).toJson(), + throwsA(anyOf(isA(), isA())), + ); + } + }); }); group('SamplingContent', () { @@ -204,6 +217,26 @@ void main() { expect(tool.name, equals('fetch')); expect(tool.id, equals('tu1')); }); + + test('rejects non-JSON input objects', () { + expect( + () => SamplingContent.fromJson({ + 'type': 'tool_use', + 'id': 'tu1', + 'name': 'fetch', + 'input': {1: 'bad'}, + }), + throwsA(isA()), + ); + expect( + () => const SamplingToolUseContent( + id: 'tu1', + name: 'fetch', + input: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + }); }); group('SamplingToolResultContent', () { @@ -354,6 +387,25 @@ void main() { expect(msg.contentBlocks, hasLength(2)); expect(msg.toJson()['content'], isA()); }); + + test('rejects non-JSON metadata objects', () { + expect( + () => SamplingMessage.fromJson({ + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + '_meta': {1: 'bad'}, + }), + throwsA(isA()), + ); + expect( + () => const SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Hello'), + meta: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + }); }); group('CreateMessageRequestParams', () { @@ -441,6 +493,68 @@ void main() { expect(params.maxTokens, equals(200)); expect(params.includeContext, equals(IncludeContext.allServers)); }); + + test('rejects non-finite temperature values', () { + final messages = [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ]; + for (final value in [double.nan, double.infinity]) { + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'temperature': value, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams( + messages: const [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Hello'), + ), + ], + maxTokens: 100, + temperature: value, + ).toJson(), + throwsA(isA()), + ); + } + }); + + test('rejects non-JSON metadata objects', () { + final messages = [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ]; + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'metadata': {1: 'bad'}, + }), + throwsA(isA()), + ); + expect( + () => const CreateMessageRequestParams( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Hello'), + ), + ], + maxTokens: 100, + metadata: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + }); }); group('CreateMessageResult', () { @@ -505,6 +619,27 @@ void main() { final result = CreateMessageResult.fromJson(json); expect(result.stopReason, equals('customReason')); }); + + test('rejects non-JSON metadata objects', () { + expect( + () => CreateMessageResult.fromJson({ + 'role': 'assistant', + 'content': {'type': 'text', 'text': 'Message'}, + 'model': 'model-x', + '_meta': {1: 'bad'}, + }), + throwsA(isA()), + ); + expect( + () => const CreateMessageResult( + role: SamplingMessageRole.assistant, + content: SamplingTextContent(text: 'Message'), + model: 'model-x', + meta: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + }); }); group('JsonRpcCreateMessageRequest', () { diff --git a/test/types/subscriptions_test.dart b/test/types/subscriptions_test.dart index 0eec7c22..a6560a64 100644 --- a/test/types/subscriptions_test.dart +++ b/test/types/subscriptions_test.dart @@ -284,6 +284,16 @@ void main() { ), throwsFormatException, ); + expect( + () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson({ + 'method': Method.notificationsSubscriptionsAcknowledged, + 'params': { + 'notifications': {'toolsListChanged': true}, + '_meta': {'bad': Object()}, + }, + }), + throwsFormatException, + ); }); }); diff --git a/test/types/tasks_extension_test.dart b/test/types/tasks_extension_test.dart index bc1c466f..45b51505 100644 --- a/test/types/tasks_extension_test.dart +++ b/test/types/tasks_extension_test.dart @@ -127,6 +127,46 @@ void main() { expect(parsed.meta, {'trace': 'abc'}); }); + test('rejects non-JSON task extension object values', () { + final taskJson = { + 'taskId': 'task-1', + 'status': 'completed', + 'createdAt': '2026-07-28T00:00:00Z', + 'lastUpdatedAt': '2026-07-28T00:02:00Z', + 'ttlMs': 60000, + 'result': {'bad': Object()}, + }; + + expect( + () => TaskExtensionTask.fromJson(taskJson), + throwsFormatException, + ); + expect( + () => const TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.completed, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:02:00Z', + ttlMs: 60000, + result: {'bad': Object()}, + ).toJson(), + throwsFormatException, + ); + expect( + () => TaskExtensionAcknowledgementResult.fromJson({ + 'resultType': resultTypeComplete, + '_meta': {'bad': Object()}, + }), + throwsFormatException, + ); + expect( + () => const TaskExtensionAcknowledgementResult( + meta: {'bad': Object()}, + ).toJson(), + throwsFormatException, + ); + }); + test('serializes notifications/tasks with detailed task state', () { final notification = JsonRpcTaskNotification( task: const TaskExtensionTask( diff --git a/test/types_edge_cases_test.dart b/test/types_edge_cases_test.dart index d2f5b4fb..aff5633d 100644 --- a/test/types_edge_cases_test.dart +++ b/test/types_edge_cases_test.dart @@ -53,6 +53,93 @@ void main() { final restored = JsonRpcErrorData.fromJson(json); expect(restored.data['nested']['level'], equals(2)); }); + + test('JsonRpcErrorData validates required code and message fields', () { + for (final code in [ + null, + false, + 'not-code', + 1.5, + {}, + [], + ]) { + expect( + () => JsonRpcErrorData.fromJson({ + 'code': code, + 'message': 'Bad code', + }), + throwsA( + isA() + .having((e) => e.message, 'message', contains('code')), + ), + ); + } + + for (final message in [null, false, 1, {}, []]) { + expect( + () => JsonRpcErrorData.fromJson({ + 'code': ErrorCode.invalidRequest.value, + 'message': message, + }), + throwsA( + isA() + .having((e) => e.message, 'message', contains('message')), + ), + ); + } + }); + + test('JsonRpcErrorData accepts whole-number numeric code values', () { + final errorData = JsonRpcErrorData.fromJson({ + 'code': -32600.0, + 'message': 'Whole-number JSON code', + }); + + expect(errorData.code, ErrorCode.invalidRequest.value); + }); + + test('JsonRpcErrorData rejects non-JSON data values', () { + expect( + () => const JsonRpcErrorData( + code: -32600, + message: 'Bad data', + data: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + expect( + () => const JsonRpcErrorData( + code: -32600, + message: 'Bad number', + data: {'score': double.infinity}, + ).toJson(), + throwsA(isA()), + ); + expect( + () => JsonRpcErrorData.fromJson({ + 'code': -32600, + 'message': 'Bad data', + 'data': {'bad': Object()}, + }), + throwsA(isA()), + ); + }); + + test('JsonRpcError rejects malformed error object wire values', () { + for (final error in [null, false, 1, 'not-error', []]) { + expect( + () => JsonRpcMessage.fromJson({ + 'jsonrpc': '2.0', + 'id': 1, + 'error': error, + }), + throwsA( + isA() + .having((e) => e.message, 'message', contains('error')), + ), + ); + } + }); }); group('JsonRpcCancelledNotification Edge Cases', () { @@ -91,7 +178,15 @@ void main() { }); test('rejects malformed requestId wire values', () { - for (final requestId in [null, true, {}, []]) { + for (final requestId in [ + null, + true, + 123.5, + double.nan, + double.infinity, + {}, + [], + ]) { expect( () => JsonRpcCancelledNotification.fromJson({ 'jsonrpc': '2.0', @@ -106,6 +201,21 @@ void main() { ), ); } + + expect( + () => const CancelledNotificationParams(requestId: 123.5).toJson(), + throwsA( + isA() + .having((e) => e.message, 'message', contains('requestId')), + ), + ); + expect( + () => const CancelledNotificationParams(requestId: double.nan).toJson(), + throwsA( + isA() + .having((e) => e.message, 'message', contains('requestId')), + ), + ); }); test('preserves string and integer requestId wire values', () { @@ -212,10 +322,34 @@ void main() { expect(json.containsKey('total'), isFalse); }); + test('rejects non-finite progress numbers', () { + for (final value in [double.nan, double.infinity]) { + expect( + () => Progress.fromJson({'progress': value}), + throwsA(isA()), + ); + expect( + () => Progress(progress: value).toJson(), + throwsA(isA()), + ); + expect( + () => Progress.fromJson({'progress': 1, 'total': value}), + throwsA(isA()), + ); + expect( + () => Progress(progress: 1, total: value).toJson(), + throwsA(isA()), + ); + } + }); + test('rejects malformed progressToken wire values', () { for (final progressToken in [ null, false, + 123.5, + double.nan, + double.infinity, {}, [], ]) { @@ -237,6 +371,23 @@ void main() { ), ); } + + expect( + () => const ProgressNotification(progressToken: 123.5, progress: 1) + .toJson(), + throwsA( + isA() + .having((e) => e.message, 'message', contains('progressToken')), + ), + ); + expect( + () => const ProgressNotification(progressToken: double.nan, progress: 1) + .toJson(), + throwsA( + isA() + .having((e) => e.message, 'message', contains('progressToken')), + ), + ); }); test('preserves string and integer progressToken wire values', () { @@ -289,8 +440,81 @@ void main() { ); }); + test('rejects malformed method wire values', () { + for (final method in [ + null, + false, + 1, + {}, + [], + ]) { + for (final hasId in [true, false]) { + expect( + () => JsonRpcMessage.fromJson({ + 'jsonrpc': '2.0', + if (hasId) 'id': 'request-1', + 'method': method, + }), + throwsA( + isA() + .having((e) => e.message, 'message', contains('method')), + ), + ); + } + } + }); + + test('rejects malformed generic request params wire values', () { + for (final params in [null, false, 1, 'not-params', []]) { + expect( + () => JsonRpcMessage.fromJson({ + 'jsonrpc': '2.0', + 'id': 'request-1', + 'method': 'unknown/request', + 'params': params, + }), + throwsA( + isA() + .having((e) => e.message, 'message', contains('params')), + ), + ); + } + }); + + test('rejects explicit null params on typed request and notification', () { + for (final json in [ + { + 'jsonrpc': '2.0', + 'id': 'request-1', + 'method': Method.ping, + 'params': null, + }, + { + 'jsonrpc': '2.0', + 'method': Method.notificationsInitialized, + 'params': null, + }, + ]) { + expect( + () => JsonRpcMessage.fromJson(json), + throwsA( + isA() + .having((e) => e.message, 'message', contains('params')), + ), + ); + } + }); + test('rejects malformed request id wire values', () { - for (final id in [null, false, 1.5, {}, []]) { + for (final id in [ + null, + false, + 123.5, + double.nan, + double.infinity, + {}, + [], + ]) { expect( () => JsonRpcMessage.fromJson({ 'jsonrpc': '2.0', @@ -303,6 +527,33 @@ void main() { ), ); } + + expect( + () => const JsonRpcRequest( + id: 123.5, + method: 'unknown/request', + ).toJson(), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('JsonRpcRequest.id'), + ), + ), + ); + expect( + () => const JsonRpcRequest( + id: double.nan, + method: 'unknown/request', + ).toJson(), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('JsonRpcRequest.id'), + ), + ), + ); }); test('preserves string and integer request ids', () { @@ -320,7 +571,15 @@ void main() { }); test('rejects malformed request progressToken wire values', () { - for (final token in [null, false, 1.5, {}, []]) { + for (final token in [ + null, + false, + 123.5, + double.nan, + double.infinity, + {}, + [], + ]) { expect( () => JsonRpcMessage.fromJson({ 'jsonrpc': '2.0', @@ -339,6 +598,21 @@ void main() { ), ); } + + expect( + () => const JsonRpcRequest( + id: 'request-1', + method: 'unknown/request', + meta: {'progressToken': 123.5}, + ).toJson(), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('_meta.progressToken'), + ), + ), + ); }); test('rejects malformed request _meta wire values', () { @@ -431,7 +705,14 @@ void main() { }); test('rejects malformed response id wire values', () { - for (final id in [false, 1.5, {}, []]) { + for (final id in [ + false, + 123.5, + double.nan, + double.infinity, + {}, + [], + ]) { expect( () => JsonRpcMessage.fromJson({ 'jsonrpc': '2.0', @@ -446,10 +727,25 @@ void main() { } }); - test('handles error with null id', () { + test('rejects response envelopes with both result and error', () { + expect( + () => JsonRpcMessage.fromJson({ + 'jsonrpc': '2.0', + 'id': 1, + 'result': {'data': 'test'}, + 'error': {'code': -32603, 'message': 'Internal error'}, + }), + throwsA( + isA() + .having((e) => e.message, 'message', contains('result')) + .having((e) => e.message, 'message', contains('error')), + ), + ); + }); + + test('handles error with omitted id', () { final json = { 'jsonrpc': '2.0', - 'id': null, 'error': {'code': -32600, 'message': 'Error'}, }; @@ -459,7 +755,15 @@ void main() { }); test('rejects malformed error id wire values', () { - for (final id in [false, 1.5, {}, []]) { + for (final id in [ + null, + false, + 123.5, + double.nan, + double.infinity, + {}, + [], + ]) { expect( () => JsonRpcMessage.fromJson({ 'jsonrpc': '2.0', @@ -510,7 +814,7 @@ void main() { test('handles null roots and elicitation maps', () { final json = { - 'experimental': {'feature': true}, + 'experimental': {'feature': {}}, 'sampling': {'enabled': true}, 'roots': null, 'elicitation': null, @@ -542,7 +846,7 @@ void main() { test('handles null capability maps', () { final json = { - 'experimental': {'feature': true}, + 'experimental': {'feature': {}}, 'logging': {'level': 'info'}, 'prompts': null, 'resources': null, diff --git a/test/types_test.dart b/test/types_test.dart index c180839d..250258ee 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -9,7 +9,9 @@ void main() { initParams: const InitializeRequestParams( protocolVersion: latestProtocolVersion, capabilities: ClientCapabilities( - experimental: {'featureX': true}, + experimental: { + 'featureX': {}, + }, sampling: ClientCapabilitiesSampling(), ), clientInfo: Implementation(name: 'test-client', version: '1.0.0'), @@ -43,6 +45,41 @@ void main() { expect(json['result']['_meta']['metaKey'], equals('metaValue')); }); + test('JSON-RPC envelope metadata rejects non-JSON Dart maps', () { + final invalidMeta = {'bad': Object()}; + + expect( + () => JsonRpcRequest( + id: 1, + method: 'custom/request', + meta: invalidMeta, + ).toJson(), + throwsA(isA()), + ); + expect( + () => JsonRpcNotification( + method: 'custom/notification', + meta: invalidMeta, + ).toJson(), + throwsA(isA()), + ); + expect( + () => JsonRpcMessage.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': 'custom/notification', + 'params': {'_meta': invalidMeta}, + }), + throwsA(isA()), + ); + expect( + () => const JsonRpcResponse( + id: 1, + result: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + }); + test('JsonRpcError serialization and deserialization', () { final error = JsonRpcError( id: 1, @@ -131,6 +168,135 @@ void main() { expectMeta(result); } }); + + test('typed metadata rejects non-JSON Dart maps', () { + final invalidMeta = {'bad': Object()}; + final task = Task( + taskId: 'task-1', + status: TaskStatus.completed, + ttl: null, + createdAt: '2026-05-25T00:00:00.000Z', + lastUpdatedAt: '2026-05-25T00:00:01.000Z', + meta: invalidMeta, + ); + + for (final serialize in Function()>[ + () => Root(uri: 'file:///repo', meta: invalidMeta).toJson(), + () => Resource( + uri: 'file:///repo/readme.md', + name: 'readme', + meta: invalidMeta, + ).toJson(), + () => ResourceTemplate( + uriTemplate: 'file:///repo/{name}', + name: 'repo-file', + meta: invalidMeta, + ).toJson(), + () => Prompt(name: 'summary', meta: invalidMeta).toJson(), + () => EmptyResult(meta: invalidMeta).toJson(), + () => InitializeResult( + protocolVersion: latestProtocolVersion, + capabilities: const ServerCapabilities(), + serverInfo: const Implementation(name: 'server', version: '1.0'), + meta: invalidMeta, + ).toJson(), + () => DiscoverResult( + supportedVersions: const [draftProtocolVersion2026_07_28], + capabilities: const ServerCapabilities(), + serverInfo: const Implementation(name: 'server', version: '1.0'), + meta: invalidMeta, + ).toJson(), + () => ListRootsResult(roots: const [], meta: invalidMeta).toJson(), + () => ListResourcesResult(resources: const [], meta: invalidMeta) + .toJson(), + () => ListResourceTemplatesResult( + resourceTemplates: const [], + meta: invalidMeta, + ).toJson(), + () => + ReadResourceResult(contents: const [], meta: invalidMeta).toJson(), + () => ListPromptsResult(prompts: const [], meta: invalidMeta).toJson(), + () => GetPromptResult(messages: const [], meta: invalidMeta).toJson(), + () => CompleteResult( + completion: CompletionResultData(values: const []), + meta: invalidMeta, + ).toJson(), + () => ElicitResult(action: 'accept', meta: invalidMeta).toJson(), + () => ListToolsResult(tools: const [], meta: invalidMeta).toJson(), + () => task.toJson(), + () => ListTasksResult(tasks: const [], meta: invalidMeta).toJson(), + () => CreateTaskResult(task: task, meta: invalidMeta).toJson(), + () => JsonRpcResponse( + id: 1, + result: const {'ok': true}, + meta: invalidMeta, + ).toJson(), + ]) { + expect(serialize, throwsA(isA())); + } + + for (final parse in [ + () => Root.fromJson({'uri': 'file:///repo', '_meta': invalidMeta}), + () => Resource.fromJson({ + 'uri': 'file:///repo/readme.md', + 'name': 'readme', + '_meta': invalidMeta, + }), + () => ResourceTemplate.fromJson({ + 'uriTemplate': 'file:///repo/{name}', + 'name': 'repo-file', + '_meta': invalidMeta, + }), + () => Prompt.fromJson({'name': 'summary', '_meta': invalidMeta}), + () => ListRootsResult.fromJson({'roots': [], '_meta': invalidMeta}), + () => ListResourcesResult.fromJson({ + 'resources': [], + '_meta': invalidMeta, + }), + () => ListResourceTemplatesResult.fromJson({ + 'resourceTemplates': [], + '_meta': invalidMeta, + }), + () => ReadResourceResult.fromJson({ + 'contents': [], + '_meta': invalidMeta, + }), + () => ListPromptsResult.fromJson({'prompts': [], '_meta': invalidMeta}), + () => GetPromptResult.fromJson({'messages': [], '_meta': invalidMeta}), + () => CompleteResult.fromJson({ + 'completion': {'values': []}, + '_meta': invalidMeta, + }), + () => ElicitResult.fromJson({'action': 'accept', '_meta': invalidMeta}), + () => ListToolsResult.fromJson({'tools': [], '_meta': invalidMeta}), + () => Task.fromJson({ + 'taskId': 'task-1', + 'status': 'completed', + 'ttl': null, + 'createdAt': '2026-05-25T00:00:00.000Z', + 'lastUpdatedAt': '2026-05-25T00:00:01.000Z', + '_meta': invalidMeta, + }), + () => ListTasksResult.fromJson({'tasks': [], '_meta': invalidMeta}), + () => CreateTaskResult.fromJson({ + 'task': const { + 'taskId': 'task-1', + 'status': 'completed', + 'ttl': null, + 'createdAt': '2026-05-25T00:00:00.000Z', + 'lastUpdatedAt': '2026-05-25T00:00:01.000Z', + }, + '_meta': invalidMeta, + }), + () => JsonRpcMessage.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'result': {'ok': true, '_meta': invalidMeta}, + }), + ]) { + expect(parse, throwsA(isA())); + } + }); }); group('ToolExecution Tests', () { @@ -165,7 +331,9 @@ void main() { test('ServerCapabilities includes completions', () { final capabilities = const ServerCapabilities( - experimental: {'featureY': true}, + experimental: { + 'featureY': {}, + }, logging: {'enabled': true}, prompts: ServerCapabilitiesPrompts(listChanged: true), resources: @@ -175,7 +343,7 @@ void main() { ); final json = capabilities.toJson(); - expect(json['experimental']['featureY'], equals(true)); + expect(json['experimental']['featureY'], isEmpty); expect(json['logging']['enabled'], equals(true)); expect(json['prompts']['listChanged'], equals(true)); expect(json['resources']['subscribe'], equals(true)); @@ -190,7 +358,9 @@ void main() { test('ServerCapabilities serialization and deserialization', () { final capabilities = const ServerCapabilities( - experimental: {'featureY': true}, + experimental: { + 'featureY': {}, + }, logging: {'enabled': true}, prompts: ServerCapabilitiesPrompts(listChanged: true), resources: @@ -199,7 +369,7 @@ void main() { ); final json = capabilities.toJson(); - expect(json['experimental']['featureY'], equals(true)); + expect(json['experimental']['featureY'], isEmpty); expect(json['logging']['enabled'], equals(true)); expect(json['prompts']['listChanged'], equals(true)); expect(json['resources']['subscribe'], equals(true)); @@ -212,19 +382,71 @@ void main() { test('ClientCapabilities serialization and deserialization', () { final capabilities = const ClientCapabilities( - experimental: {'featureZ': true}, + experimental: { + 'featureZ': {}, + }, sampling: ClientCapabilitiesSampling(), roots: ClientCapabilitiesRoots(listChanged: true), ); final json = capabilities.toJson(); - expect(json['experimental']['featureZ'], equals(true)); + expect(json['experimental']['featureZ'], isEmpty); expect(json['sampling'], isNotNull); expect(json['roots']['listChanged'], equals(true)); final deserialized = ClientCapabilities.fromJson(json); expect(deserialized.roots?.listChanged, equals(true)); }); + + test('experimental capability values must be objects', () { + expect( + () => ClientCapabilities.fromJson( + const { + 'experimental': {'feature': true}, + }, + ), + throwsA(isA()), + ); + expect( + () => ServerCapabilities.fromJson( + const { + 'experimental': {'feature': true}, + }, + ), + throwsA(isA()), + ); + expect( + () => const ClientCapabilities( + experimental: {'feature': true}, + ).toJson(), + throwsA(anyOf(isA(), isA())), + ); + expect( + () => const ServerCapabilities( + experimental: {'feature': true}, + ).toJson(), + throwsA(anyOf(isA(), isA())), + ); + }); + + test('extension capability values must be objects', () { + expect( + () => ClientCapabilities.fromJson( + const { + 'extensions': {'io.example/feature': true}, + }, + ), + throwsA(isA()), + ); + expect( + () => ServerCapabilities.fromJson( + const { + 'extensions': {'io.example/feature': true}, + }, + ), + throwsA(isA()), + ); + }); }); group('Content Tests', () { @@ -416,6 +638,41 @@ void main() { ); expect(deserialized.meta?['display'], equals('inline')); }); + + test('content JSON object fields reject non-JSON Dart maps', () { + expect( + () => TextContent.fromJson({ + 'type': 'text', + 'text': 'Hello', + '_meta': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => const TextContent( + text: 'Hello', + meta: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + 'annotations': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => const ResourceLink( + uri: 'file:///docs/readme.md', + name: 'readme', + annotations: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + }); }); group('Resource Tests', () { @@ -473,6 +730,41 @@ void main() { expect(deserialized.uri, equals('file://example.bin')); expect(deserialized.blob, equals('base64data')); }); + + test('ResourceContents rejects non-JSON metadata and passthrough maps', () { + expect( + () => ResourceContents.fromJson({ + 'uri': 'file://example.txt', + 'text': 'Sample text content', + '_meta': {'bad': Object()}, + }), + throwsA(isA()), + ); + expect( + () => ResourceContents.fromJson({ + 'uri': 'file://example.txt', + 'text': 'Sample text content', + 'x-extra': Object(), + }), + throwsA(isA()), + ); + expect( + () => const TextResourceContents( + uri: 'file://example.txt', + text: 'Sample text content', + meta: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + expect( + () => const TextResourceContents( + uri: 'file://example.txt', + text: 'Sample text content', + extra: {'bad': Object()}, + ).toJson(), + throwsA(isA()), + ); + }); }); group('Prompt Tests', () { From f9f9df5c1ff483e03e06f315eefa7fe1f5847b31 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 08:20:57 -0400 Subject: [PATCH 05/68] Consolidate type schema validation --- CHANGELOG.md | 24 +- example/client_stdio.dart | 7 +- example/server_stdio.dart | 6 +- lib/src/client/client.dart | 64 +++- lib/src/server/server.dart | 1 + lib/src/server/streamable_https.dart | 22 +- lib/src/shared/json_schema/json_schema.dart | 71 ++-- .../json_schema/json_schema_validator.dart | 2 +- lib/src/shared/protocol.dart | 7 +- lib/src/shared/uri_template.dart | 31 +- lib/src/types/completion.dart | 30 +- lib/src/types/content.dart | 168 +++++++-- lib/src/types/elicitation.dart | 211 +++++++++-- lib/src/types/initialization.dart | 149 ++++++-- lib/src/types/json_rpc.dart | 30 +- lib/src/types/logging.dart | 12 +- lib/src/types/prompts.dart | 4 +- lib/src/types/resources.dart | 135 ++++--- lib/src/types/roots.dart | 37 +- lib/src/types/sampling.dart | 89 +++-- lib/src/types/tasks.dart | 5 +- lib/src/types/validation.dart | 178 +++++++++ .../lib/src/conformance_runner.dart | 19 + .../client_elicitation_defaults_test.dart | 13 +- test/client/client_test.dart | 22 +- test/client/client_tool_validation_test.dart | 13 +- test/client/streamable_https_test.dart | 7 +- test/elicitation_test.dart | 236 +++++++++++- test/lifecycle_test.dart | 4 + test/mcp_2025_11_25_test.dart | 176 ++++++++- test/mcp_2026_07_28_test.dart | 249 ++++++++++++- test/server/server_test.dart | 4 +- test/server/streamable_https_test.dart | 12 + test/server/streamable_mcp_server_test.dart | 24 ++ test/shared/json_schema_from_json_test.dart | 75 +++- test/shared/json_schema_validator_test.dart | 14 +- test/shared/protocol_test.dart | 37 ++ test/shared/uri_template_test.dart | 43 +++ test/types/logging_types_test.dart | 28 ++ test/types/resources_test.dart | 133 ++++++- test/types/sampling_test.dart | 217 ++++++++++- test/types_test.dart | 346 +++++++++++++++++- 42 files changed, 2636 insertions(+), 319 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee6c6e94..607ddcc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,9 @@ - Retried `server/discover` with an advertised compatible stateless protocol version after `UnsupportedProtocolVersionError` instead of falling back to legacy initialization. +- Accepted whole-number JSON numeric values for integer wire fields such as + resource link sizes, completion totals, sampling `maxTokens`, task TTLs, and + JSON Schema length/item bounds while continuing to reject fractional values. - Added client-side `subscriptions/listen` handles that correlate stream notifications by `io.modelcontextprotocol/subscriptionId`, validate the acknowledgment, and cancel long-lived streams with `notifications/cancelled`. @@ -88,14 +91,28 @@ clamping malformed wire values to zero. - Validated MRTR `inputResponses` as `CreateMessageResult`, `ListRootsResult`, or `ElicitResult` instead of accepting arbitrary result objects. -- Allowed finite numeric `ElicitResult.content` values to match the stable and - MCP 2026 `string | number | boolean | string[]` schema. +- Restricted numeric `ElicitResult.content` values to integers, matching the + stable and MCP 2026 `string | integer | boolean | string[]` schemas while + still accepting whole-number JSON numeric values. +- Made form elicitation number-schema keyword validation protocol-aware: + stable 2025 keeps integer-only `minimum`, `maximum`, and `default` values, + while MCP 2026 accepts fractional number keywords. - Rejected form elicitation schemas that provide legacy `enumNames` without the required string `enum`. - Rejected `ElicitResult.content` when the result action is `decline` or `cancel`. - Rejected URL elicitation values that are not absolute URIs to match the stable and MCP 2026 `format: uri` schemas. +- Rejected non-absolute resource URIs and malformed resource URI templates to + match stable and MCP 2026 `format: uri` and `format: uri-template` schemas. +- Rejected malformed base64 payloads for image, audio, and blob resource + content to match stable and MCP 2026 `format: byte` schemas. +- Rejected malformed shared annotation fields, including non-role audiences, + out-of-range priorities, and non-string `lastModified` values. +- Rejected malformed `Role` values in prompt and sampling messages instead of + allowing raw enum lookup failures. +- Rejected malformed logging level, sampling `includeContext`, and sampling + `toolChoice.mode` enum values with protocol parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. @@ -140,6 +157,9 @@ dispatch finite numeric progress tokens end-to-end. - Widened protocol `relatedRequestId` API parameters to preserve string and finite numeric JSON-RPC request IDs through request and notification routing. +- Accepted numeric `minimum`, `maximum`, `exclusiveMinimum`, + `exclusiveMaximum`, `multipleOf`, and `default` values on JSON Schema + `integer` schemas, matching the stable and MCP 2026 schema definitions. ## 2.2.0 diff --git a/example/client_stdio.dart b/example/client_stdio.dart index e3cfa9d7..aad46b9e 100644 --- a/example/client_stdio.dart +++ b/example/client_stdio.dart @@ -8,7 +8,7 @@ import 'package:mcp_dart/mcp_dart.dart'; /// It demonstrates how to use the MCP client library with standard I/O. /// It runs the server example from `example/server_stdio.dart` /// and communicates with it using the StdioClientTransport. -/// The client sends various requests to the server, including ping, tool calls, +/// The client sends various requests to the server, including tool calls, /// resource reads, and prompt calls. Future main() async { // Define the server executable and arguments @@ -48,11 +48,6 @@ Future main() async { await client.connect(transport); print('Connected to server.'); - // Example: Send a ping request - print('Sending ping...'); - final pingResult = await client.ping(); - print('Ping successful: ${pingResult.toJson()}'); - print('Listing tools...'); final tools = await client.listTools(); print('Resources: ${tools.toJson()}'); diff --git a/example/server_stdio.dart b/example/server_stdio.dart index d2c38f07..1434cd3c 100644 --- a/example/server_stdio.dart +++ b/example/server_stdio.dart @@ -63,7 +63,11 @@ void main() async { final text = 'Sample log content'; return ReadResourceResult( contents: [ - TextResourceContents(uri: uri.path, mimeType: 'text/plain', text: text), + TextResourceContents( + uri: uri.toString(), + mimeType: 'text/plain', + text: text, + ), ], ); }); diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 0c0fdc9a..38249a4c 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -15,24 +15,21 @@ class McpClientOptions extends ProtocolOptions { /// Capabilities to advertise as being supported by this client. final ClientCapabilities? capabilities; - /// Preferred protocol version for opt-in `server/discover` negotiation. - /// - /// The current default keeps existing clients on the stable initialization - /// flow unless [useServerDiscover] is enabled. + /// Preferred protocol version for `server/discover` negotiation. final String protocolVersion; /// Whether [McpClient.connect] should probe with `server/discover` first. final bool useServerDiscover; - /// Whether a `server/discover` method-not-found response should fall back to - /// the legacy `initialize` handshake. + /// Whether a failed `server/discover` probe should fall back to the legacy + /// `initialize` handshake when the peer looks like a pre-discovery server. final bool allowLegacyInitializationFallback; const McpClientOptions({ super.enforceStrictCapabilities, this.capabilities, this.protocolVersion = latestDraftProtocolVersion, - this.useServerDiscover = false, + this.useServerDiscover = true, this.allowLegacyInitializationFallback = true, }); } @@ -122,6 +119,7 @@ const Set _statelessRemovedRequestMethods = { const Set _statelessRemovedNotificationMethods = { Method.notificationsInitialized, Method.notificationsRootsListChanged, + Method.notificationsTasksStatus, }; /// An MCP client implementation built on top of a pluggable [Transport]. @@ -172,7 +170,7 @@ class McpClient extends Protocol { : _capabilities = options?.capabilities ?? const ClientCapabilities(), _preferredProtocolVersion = options?.protocolVersion ?? latestDraftProtocolVersion, - _useServerDiscover = options?.useServerDiscover ?? false, + _useServerDiscover = options?.useServerDiscover ?? true, _allowLegacyInitializationFallback = options?.allowLegacyInitializationFallback ?? true, super(options) { @@ -216,11 +214,18 @@ class McpClient extends Protocol { } return result; }, - (id, params, meta) => JsonRpcElicitRequest( - id: id, - elicitParams: ElicitRequest.fromJson(params ?? {}), - meta: meta, - ), + (id, params, meta) { + final protocolVersion = _protocolVersionForIncomingRequest(meta); + return JsonRpcElicitRequest( + id: id, + elicitParams: ElicitRequest.fromJson( + params ?? {}, + protocolVersion: protocolVersion, + ), + meta: meta, + protocolVersion: protocolVersion, + ); + }, ); } @@ -470,10 +475,7 @@ class McpClient extends Protocol { await discoverServer(); return; } catch (error) { - final canFallback = _allowLegacyInitializationFallback && - error is McpError && - error.code == ErrorCode.methodNotFound.value; - if (!canFallback) { + if (!_isLegacyDiscoveryFallbackError(error)) { rethrow; } _logger.debug( @@ -493,6 +495,26 @@ class McpClient extends Protocol { } } + bool _isLegacyDiscoveryFallbackError(Object error) { + if (!_allowLegacyInitializationFallback || error is! McpError) { + return false; + } + if (error.code == ErrorCode.methodNotFound.value) { + return true; + } + + final message = error.message; + if (error.code == 0 && + message.contains('Error POSTing to endpoint (HTTP 400)')) { + return true; + } + + return (error.code == 0 || + error.code == ErrorCode.connectionClosed.value || + error.code == ErrorCode.invalidRequest.value) && + message.contains('Server not initialized'); + } + @override Future request( JsonRpcRequest requestData, @@ -608,6 +630,14 @@ class McpClient extends Protocol { /// Gets the negotiated protocol version after connection. String? getProtocolVersion() => _negotiatedProtocolVersion; + String? _protocolVersionForIncomingRequest(Map? meta) { + final protocolVersion = meta?[McpMetaKey.protocolVersion]; + if (protocolVersion is String) { + return protocolVersion; + } + return _negotiatedProtocolVersion ?? _preferredProtocolVersion; + } + @override bool isRecognizedResultType(String resultType) { if (super.isRecognizedResultType(resultType)) { diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index e5da1fdb..d5a1f52d 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -76,6 +76,7 @@ class Server extends Protocol { static const Set _statelessRemovedNotificationMethods = { Method.notificationsInitialized, Method.notificationsRootsListChanged, + Method.notificationsTasksStatus, }; static const Set _inputRequiredResultMethods = { diff --git a/lib/src/server/streamable_https.dart b/lib/src/server/streamable_https.dart index 5f540921..b0994a79 100644 --- a/lib/src/server/streamable_https.dart +++ b/lib/src/server/streamable_https.dart @@ -439,6 +439,18 @@ class StreamableHTTPServerTransport return null; } + String? _nestedMetadataProtocolVersion(Map messageJson) { + final params = messageJson['params']; + if (params is Map) { + final meta = params['_meta']; + if (meta is Map) { + final version = meta[McpMetaKey.protocolVersion]; + return version is String ? version : null; + } + } + return null; + } + bool _usesStatelessHttpValidation( HttpRequest req, List messages, @@ -756,6 +768,7 @@ class StreamableHTTPServerTransport Future _validateStatelessHttpHeaders( HttpRequest req, List messages, + List> messageJsons, ) async { if (!_usesStatelessHttpValidation(req, messages)) { return true; @@ -773,6 +786,7 @@ class StreamableHTTPServerTransport } final message = messages.single; + final messageJson = messageJsons.single; final protocolHeader = req.headers.value('mcp-protocol-version')?.trim(); if (protocolHeader == null || protocolHeader.isEmpty) { await _writeHeaderMismatchResponse( @@ -791,12 +805,12 @@ class StreamableHTTPServerTransport return false; } - final metadataVersion = _metadataProtocolVersion(message); + final metadataVersion = _nestedMetadataProtocolVersion(messageJson); if (metadataVersion == null) { await _writeHeaderMismatchResponse( req.response, message, - 'MCP-Protocol-Version header has no matching request _meta protocol version', + 'MCP-Protocol-Version header has no matching request _meta protocol version in params._meta', ); return false; } @@ -1336,6 +1350,7 @@ class StreamableHTTPServerTransport } final List messages = []; + final List> messageJsons = []; if (rawMessages.isEmpty) { await _writeJsonRpcErrorResponse( req.response, @@ -1362,6 +1377,7 @@ class StreamableHTTPServerTransport final messageJson = rawItem is Map ? rawItem : rawItem.cast(); + messageJsons.add(messageJson); messages.add(JsonRpcMessage.fromJson(messageJson)); } catch (e) { await _writeJsonRpcErrorResponse( @@ -1376,7 +1392,7 @@ class StreamableHTTPServerTransport } } - if (!await _validateStatelessHttpHeaders(req, messages)) { + if (!await _validateStatelessHttpHeaders(req, messages, messageJsons)) { return; } diff --git a/lib/src/shared/json_schema/json_schema.dart b/lib/src/shared/json_schema/json_schema.dart index da48f924..c9e0b927 100644 --- a/lib/src/shared/json_schema/json_schema.dart +++ b/lib/src/shared/json_schema/json_schema.dart @@ -1,5 +1,18 @@ const _jsonSchemaAnnotationKeys = {'title', 'description', 'default'}; +int? _readOptionalInteger(Object? value, String field) { + if (value == null) { + return null; + } + if (value is int) { + return value; + } + if (value is double && value.isFinite && value == value.truncateToDouble()) { + return value.toInt(); + } + throw FormatException('$field must be an integer'); +} + /// A builder for creating JSON Schemas in a type-safe way. sealed class JsonSchema { final String? title; @@ -8,7 +21,7 @@ sealed class JsonSchema { /// The default value for this schema. /// /// The type of this value depends on the schema type (e.g., [String] for [JsonString], - /// [int] for [JsonInteger], etc.). + /// [num] for [JsonNumber] and [JsonInteger], etc.). dynamic get defaultValue; const JsonSchema({this.title, this.description}); @@ -255,14 +268,14 @@ sealed class JsonSchema { /// Creates an integer schema. static JsonInteger integer({ - int? minimum, - int? maximum, - int? exclusiveMinimum, - int? exclusiveMaximum, - int? multipleOf, + num? minimum, + num? maximum, + num? exclusiveMinimum, + num? exclusiveMaximum, + num? multipleOf, String? title, String? description, - int? defaultValue, + num? defaultValue, String? mcpHeader, }) { return JsonInteger( @@ -496,8 +509,14 @@ class JsonString extends JsonSchema { factory JsonString.fromJson(Map json) { final rawMcpHeader = json['x-mcp-header']; return JsonString._( - minLength: json['minLength'] as int?, - maxLength: json['maxLength'] as int?, + minLength: _readOptionalInteger( + json['minLength'], + 'JsonString.minLength', + ), + maxLength: _readOptionalInteger( + json['maxLength'], + 'JsonString.maxLength', + ), pattern: json['pattern'] as String?, format: json['format'] as String?, enumValues: (json['enum'] as List?)?.cast() ?? @@ -619,11 +638,11 @@ class JsonInteger extends JsonSchema { final bool _hasDefault; final bool _hasMcpHeader; final Object? _rawMcpHeader; - final int? minimum; - final int? maximum; - final int? exclusiveMinimum; - final int? exclusiveMaximum; - final int? multipleOf; + final num? minimum; + final num? maximum; + final num? exclusiveMinimum; + final num? exclusiveMaximum; + final num? multipleOf; /// MCP `x-mcp-header` extension for mirroring this parameter into HTTP. final String? mcpHeader; @@ -660,19 +679,19 @@ class JsonInteger extends JsonSchema { _rawMcpHeader = rawMcpHeader; @override - final int? defaultValue; + final num? defaultValue; factory JsonInteger.fromJson(Map json) { final rawMcpHeader = json['x-mcp-header']; return JsonInteger._( - minimum: json['minimum'] as int?, - maximum: json['maximum'] as int?, - exclusiveMinimum: json['exclusiveMinimum'] as int?, - exclusiveMaximum: json['exclusiveMaximum'] as int?, - multipleOf: json['multipleOf'] as int?, + minimum: json['minimum'] as num?, + maximum: json['maximum'] as num?, + exclusiveMinimum: json['exclusiveMinimum'] as num?, + exclusiveMaximum: json['exclusiveMaximum'] as num?, + multipleOf: json['multipleOf'] as num?, title: json['title'] as String?, description: json['description'] as String?, - defaultValue: json['default'] as int?, + defaultValue: json['default'] as num?, mcpHeader: rawMcpHeader is String ? rawMcpHeader : null, rawMcpHeader: rawMcpHeader, hasDefault: json.containsKey('default'), @@ -832,8 +851,14 @@ class JsonArray extends JsonSchema { items: json['items'] != null ? JsonSchema.fromJson(json['items'] as Map) : null, - minItems: json['minItems'] as int?, - maxItems: json['maxItems'] as int?, + minItems: _readOptionalInteger( + json['minItems'], + 'JsonArray.minItems', + ), + maxItems: _readOptionalInteger( + json['maxItems'], + 'JsonArray.maxItems', + ), uniqueItems: json['uniqueItems'] as bool?, title: json['title'] as String?, description: json['description'] as String?, diff --git a/lib/src/shared/json_schema/json_schema_validator.dart b/lib/src/shared/json_schema/json_schema_validator.dart index bb92abbe..8264cc04 100644 --- a/lib/src/shared/json_schema/json_schema_validator.dart +++ b/lib/src/shared/json_schema/json_schema_validator.dart @@ -188,7 +188,7 @@ extension JsonSchemaValidation on JsonSchema { } if (schema.multipleOf != null) { - if ((data % schema.multipleOf!).abs() > 0) { + if ((data % schema.multipleOf!).abs() > 1e-10) { throw JsonSchemaValidationException( 'Value must be multiple of ${schema.multipleOf}', path, diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index ae05f7f5..58c08297 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:mcp_dart/src/shared/logging.dart'; import 'package:mcp_dart/src/shared/task_interfaces.dart'; import 'package:mcp_dart/src/types.dart'; +import 'package:mcp_dart/src/types/validation.dart'; import 'package:meta/meta.dart'; import 'transport.dart'; @@ -1275,8 +1276,10 @@ abstract class Protocol { this, ) : null, - taskRequestedTtl: - (request.params?['task'] as Map?)?['ttl'] as int?, + taskRequestedTtl: readOptionalInteger( + (request.params?['task'] as Map?)?['ttl'], + 'RequestOptions.task.ttl', + ), sendNotification: (notification, {relatedTask}) { var outgoingNotification = notification; if (subscriptionState != null) { diff --git a/lib/src/shared/uri_template.dart b/lib/src/shared/uri_template.dart index 77bdc112..368a59f9 100644 --- a/lib/src/shared/uri_template.dart +++ b/lib/src/shared/uri_template.dart @@ -129,6 +129,10 @@ class UriTemplateExpander { parts.add(_ExpressionPart(operator, varSpecs)); i = end + 1; + } else if (template[i] == '}') { + throw ArgumentError( + "Unmatched closing template expression at index $i", + ); } else { currentText.write(template[i]); i++; @@ -153,8 +157,9 @@ class UriTemplateExpander { } static List<_VarSpec> _parseVarSpecs(String specsString) { - return specsString.split(',').map((spec) { - spec = spec.trim(); + return specsString.split(',').map((rawSpec) { + var spec = rawSpec.trim(); + final originalSpec = spec; bool explode = false; int? prefix; @@ -164,16 +169,20 @@ class UriTemplateExpander { } else { final prefixMatch = RegExp(r':(\d+)$').firstMatch(spec); if (prefixMatch != null) { - prefix = int.tryParse(prefixMatch.group(1)!); + final prefixText = prefixMatch.group(1)!; + if (!RegExp(r'^[1-9][0-9]{0,4}$').hasMatch(prefixText)) { + throw ArgumentError( + "Invalid prefix modifier '$prefixText' in variable spec " + "'$originalSpec'", + ); + } + prefix = int.parse(prefixText); spec = spec.substring(0, prefixMatch.start); } } - if (spec.isEmpty || - !RegExp(r'^[a-zA-Z0-9_]|(%[0-9A-Fa-f]{2})').hasMatch(spec[0])) { - if (spec.isNotEmpty) { - // Allow empty string from splitting trailing comma - } + if (!_isValidVariableName(spec)) { + throw ArgumentError("Invalid variable name '$spec'"); } _validateLength(spec, maxVariableLength, "Variable name '$spec'"); @@ -182,6 +191,12 @@ class UriTemplateExpander { }).toList(); } + static bool _isValidVariableName(String spec) { + return RegExp( + r'^(?:[a-zA-Z0-9_]|%[0-9A-Fa-f]{2})(?:\.?(?:[a-zA-Z0-9_]|%[0-9A-Fa-f]{2}))*$', + ).hasMatch(spec); + } + String _encodeValue(String value, String operator) { _validateLength(value, maxVariableLength, "Variable value"); diff --git a/lib/src/types/completion.dart b/lib/src/types/completion.dart index 50513521..57ceb848 100644 --- a/lib/src/types/completion.dart +++ b/lib/src/types/completion.dart @@ -19,16 +19,24 @@ sealed class Reference { }; } - Map toJson() => { - 'type': type, - ...switch (this) { - final ResourceReference r => {'uri': r.uri}, - final PromptReference p => { - 'name': p.name, - if (p.title != null) 'title': p.title, - }, + Map toJson() { + return switch (this) { + final ResourceReference r => _resourceReferenceToJson(r), + final PromptReference p => { + 'type': p.type, + 'name': p.name, + if (p.title != null) 'title': p.title, }, - }; + }; + } +} + +Map _resourceReferenceToJson(ResourceReference reference) { + validateUriTemplateString(reference.uri, 'ResourceReference.uri'); + return { + 'type': reference.type, + 'uri': reference.uri, + }; } /// Reference to a resource or resource template URI. @@ -39,7 +47,7 @@ class ResourceReference extends Reference { factory ResourceReference.fromJson(Map json) { return ResourceReference( - uri: json['uri'] as String, + uri: readRequiredUriTemplateString(json['uri'], 'ResourceReference.uri'), ); } } @@ -204,7 +212,7 @@ class CompletionResultData { } return CompletionResultData( values: values.cast(), - total: json['total'] as int?, + total: readOptionalInteger(json['total'], 'CompletionResultData.total'), hasMore: json['hasMore'] as bool?, ); } diff --git a/lib/src/types/content.dart b/lib/src/types/content.dart index 42c612b5..fd364653 100644 --- a/lib/src/types/content.dart +++ b/lib/src/types/content.dart @@ -21,6 +21,83 @@ Map _asJsonObject( return map; } +String _readRequiredString(Object? value, String field) { + if (value is String) { + return value; + } + throw FormatException('$field must be a string'); +} + +bool _isAbsoluteUri(String value) { + return Uri.tryParse(value)?.hasScheme ?? false; +} + +String _readRequiredAbsoluteUriString(Object? value, String field) { + final result = _readRequiredString(value, field); + if (!_isAbsoluteUri(result)) { + throw FormatException('$field must be an absolute URI'); + } + return result; +} + +void _validateAbsoluteUriString(String value, String field) { + if (!_isAbsoluteUri(value)) { + throw ArgumentError.value(value, field, 'must be an absolute URI'); + } +} + +String _absoluteUriForJson(String value, String field) { + validateAbsoluteUriString(value, field); + return value; +} + +String _base64ForJson(String value, String field) { + validateBase64String(value, field); + return value; +} + +Map _annotationsForJson( + Map value, + String field, +) { + final result = readJsonObject(value, field); + validateAnnotationsObject(result, field); + return result; +} + +String? _readOptionalPresentString( + Map json, + String key, + String field, +) { + if (!json.containsKey(key)) { + return null; + } + return _readRequiredString(json[key], field); +} + +List? _readOptionalPresentStringList( + Map json, + String key, + String field, +) { + if (!json.containsKey(key)) { + return null; + } + final value = json[key]; + if (value is! List) { + throw FormatException('$field must be a list of strings'); + } + + return [ + for (final item in value) + if (item is String) + item + else + throw FormatException('$field items must be strings'), + ]; +} + /// Allowed audience values for content/resource annotations. enum AnnotationAudience { user, assistant } @@ -45,12 +122,19 @@ class Annotations { ); factory Annotations.fromJson(Map json) { + final audience = readOptionalAnnotationAudience( + json['audience'], + 'Annotations.audience', + ); return Annotations( - audience: (json['audience'] as List?) - ?.map((value) => AnnotationAudience.values.byName(value as String)) + audience: audience + ?.map((value) => AnnotationAudience.values.byName(value)) .toList(), priority: readUnitDouble(json['priority'], 'Annotations.priority'), - lastModified: json['lastModified'] as String?, + lastModified: readOptionalString( + json['lastModified'], + 'Annotations.lastModified', + ), ); } @@ -88,7 +172,10 @@ sealed class ResourceContents { /// Creates a specific [ResourceContents] subclass from JSON. factory ResourceContents.fromJson(Map json) { - final uri = json['uri'] as String; + final uri = readRequiredAbsoluteUriString( + json['uri'], + 'ResourceContents.uri', + ); final mimeType = json['mimeType'] as String?; final meta = _asJsonObjectOrNull( json['_meta'], @@ -120,7 +207,10 @@ sealed class ResourceContents { return BlobResourceContents( uri: uri, mimeType: mimeType, - blob: json['blob'] as String, + blob: readRequiredBase64String( + json['blob'], + 'BlobResourceContents.blob', + ), meta: meta, extra: passthrough, ); @@ -135,11 +225,13 @@ sealed class ResourceContents { /// Converts resource contents to JSON. Map toJson() => { - 'uri': uri, + 'uri': _absoluteUriForJson(uri, 'ResourceContents.uri'), if (mimeType != null) 'mimeType': mimeType, ...switch (this) { final TextResourceContents c => {'text': c.text}, - final BlobResourceContents c => {'blob': c.blob}, + final BlobResourceContents c => { + 'blob': _base64ForJson(c.blob, 'BlobResourceContents.blob'), + }, UnknownResourceContents _ => {}, }, if (meta != null) @@ -211,27 +303,42 @@ class McpIcon { }); factory McpIcon.fromJson(Map json) { - final themeString = json['theme'] as String?; + final themeString = _readOptionalPresentString( + json, + 'theme', + 'McpIcon.theme', + ); final iconTheme = switch (themeString) { 'light' => IconTheme.light, 'dark' => IconTheme.dark, - _ => null, + null => null, + _ => throw const FormatException( + 'McpIcon.theme must be either "light" or "dark"', + ), }; return McpIcon( - src: json['src'] as String, - mimeType: json['mimeType'] as String?, - sizes: (json['sizes'] as List?)?.cast(), + src: _readRequiredAbsoluteUriString(json['src'], 'McpIcon.src'), + mimeType: _readOptionalPresentString( + json, + 'mimeType', + 'McpIcon.mimeType', + ), + sizes: _readOptionalPresentStringList(json, 'sizes', 'McpIcon.sizes'), theme: iconTheme, ); } - Map toJson() => { - 'src': src, - if (mimeType != null) 'mimeType': mimeType, - if (sizes != null) 'sizes': sizes, - if (theme != null) 'theme': theme!.name, - }; + Map toJson() { + _validateAbsoluteUriString(src, 'McpIcon.src'); + + return { + 'src': src, + if (mimeType != null) 'mimeType': mimeType, + if (sizes != null) 'sizes': sizes, + if (theme != null) 'theme': theme!.name, + }; + } } /// Base class for content parts within prompts or tool results. @@ -265,22 +372,21 @@ sealed class Content { '_meta': readJsonObject(c.meta, 'TextContent._meta'), }, final ImageContent c => { - 'data': c.data, + 'data': _base64ForJson(c.data, 'ImageContent.data'), 'mimeType': c.mimeType, - if (c.theme != null) 'theme': c.theme, if (c.annotations != null) 'annotations': c.annotations!.toJson(), if (c.meta != null) '_meta': readJsonObject(c.meta, 'ImageContent._meta'), }, final AudioContent c => { - 'data': c.data, + 'data': _base64ForJson(c.data, 'AudioContent.data'), 'mimeType': c.mimeType, if (c.annotations != null) 'annotations': c.annotations!.toJson(), if (c.meta != null) '_meta': readJsonObject(c.meta, 'AudioContent._meta'), }, final ResourceLink c => { - 'uri': c.uri, + 'uri': _absoluteUriForJson(c.uri, 'ResourceLink.uri'), 'name': c.name, if (c.title != null) 'title': c.title, if (c.description != null) 'description': c.description, @@ -289,8 +395,8 @@ sealed class Content { if (c.icons != null) 'icons': c.icons!.map((icon) => icon.toJson()).toList(), if (c.annotations != null) - 'annotations': readJsonObject( - c.annotations, + 'annotations': _annotationsForJson( + c.annotations!, 'ResourceLink.annotations', ), if (c.meta != null) @@ -346,6 +452,10 @@ class ImageContent extends Content { final String mimeType; /// Optional theme hint for legacy icon usage (`light` | `dark`). + /// + /// This field is parsed for backwards compatibility with older icon-shaped + /// payloads. MCP ImageContent content blocks do not serialize `theme`; use + /// [McpIcon.theme] for advertised icons. final String? theme; /// Optional annotations for the content block. @@ -364,7 +474,7 @@ class ImageContent extends Content { factory ImageContent.fromJson(Map json) { return ImageContent( - data: json['data'] as String, + data: readRequiredBase64String(json['data'], 'ImageContent.data'), mimeType: json['mimeType'] as String, theme: json['theme'] as String?, annotations: json['annotations'] == null @@ -399,7 +509,7 @@ class AudioContent extends Content { factory AudioContent.fromJson(Map json) { return AudioContent( - data: json['data'] as String, + data: readRequiredBase64String(json['data'], 'AudioContent.data'), mimeType: json['mimeType'] as String, annotations: json['annotations'] == null ? null @@ -493,16 +603,16 @@ class ResourceLink extends Content { factory ResourceLink.fromJson(Map json) { return ResourceLink( - uri: json['uri'] as String, + uri: readRequiredAbsoluteUriString(json['uri'], 'ResourceLink.uri'), name: json['name'] as String, title: json['title'] as String?, description: json['description'] as String?, mimeType: json['mimeType'] as String?, - size: json['size'] as int?, + size: readOptionalInteger(json['size'], 'ResourceLink.size'), icons: (json['icons'] as List?) ?.map((icon) => McpIcon.fromJson(_asJsonObject(icon))) .toList(), - annotations: _asJsonObjectOrNull( + annotations: readOptionalAnnotationsObject( json['annotations'], 'ResourceLink.annotations', ), diff --git a/lib/src/types/elicitation.dart b/lib/src/types/elicitation.dart index 8b009980..a8cb4932 100644 --- a/lib/src/types/elicitation.dart +++ b/lib/src/types/elicitation.dart @@ -1,5 +1,6 @@ import '../shared/json_schema/json_schema.dart'; import 'json_rpc.dart'; +import 'tasks.dart'; import 'validation.dart'; /// Legacy alias for [JsonSchema] used in elicitation requests. @@ -40,12 +41,16 @@ class ElicitRequest { /// Required for URL mode to correlate with completion notifications. final String? elicitationId; + /// Task metadata for task-augmented execution. + final TaskCreation? task; + const ElicitRequest({ this.mode, required this.message, this.requestedSchema, this.url, this.elicitationId, + this.task, }) : assert( mode != ElicitationMode.url || requestedSchema == null, 'URL elicitation must not include requestedSchema.', @@ -75,6 +80,7 @@ class ElicitRequest { const ElicitRequest.form({ required this.message, required ElicitationInputSchema this.requestedSchema, + this.task, }) : mode = ElicitationMode.form, url = null, elicitationId = null; @@ -84,10 +90,14 @@ class ElicitRequest { required this.message, required String this.url, required String this.elicitationId, + this.task, }) : mode = ElicitationMode.url, requestedSchema = null; - factory ElicitRequest.fromJson(Map json) { + factory ElicitRequest.fromJson( + Map json, { + String? protocolVersion, + }) { final modeValue = json['mode']; if (modeValue != null && modeValue is! String) { throw const FormatException('Elicitation mode must be a string.'); @@ -110,6 +120,7 @@ class ElicitRequest { final requestedSchemaJson = json['requestedSchema']; final url = json['url']; final elicitationId = json['elicitationId']; + final task = readOptionalJsonObject(json['task'], 'ElicitRequest.task'); if (mode == ElicitationMode.url) { if (url is! String) { @@ -128,13 +139,17 @@ class ElicitRequest { message: message, url: url, elicitationId: elicitationId, + task: task == null ? null : TaskCreation.fromJson(task), ); } if (requestedSchemaJson is! Map) { throw const FormatException('Form elicitation requires requestedSchema.'); } - _validateFormRequestedSchemaJson(requestedSchemaJson); + _validateFormRequestedSchemaJson( + requestedSchemaJson, + protocolVersion: protocolVersion, + ); if (url != null) { throw const FormatException('Form elicitation must not include url.'); } @@ -148,10 +163,11 @@ class ElicitRequest { mode: mode, message: message, requestedSchema: JsonSchema.fromJson(requestedSchemaJson), + task: task == null ? null : TaskCreation.fromJson(task), ); } - void _validateShape() { + void _validateShape({String? protocolVersion}) { if (isUrlMode) { if (requestedSchema != null) { throw ArgumentError( @@ -171,7 +187,10 @@ class ElicitRequest { if (requestedSchema == null) { throw ArgumentError('Form elicitation requires requestedSchema.'); } - _validateFormRequestedSchema(requestedSchema!); + _validateFormRequestedSchema( + requestedSchema!, + protocolVersion: protocolVersion, + ); if (url != null) { throw ArgumentError('Form elicitation must not include url.'); } @@ -180,14 +199,15 @@ class ElicitRequest { } } - Map toJson() { - _validateShape(); + Map toJson({String? protocolVersion}) { + _validateShape(protocolVersion: protocolVersion); return { if (mode != null) 'mode': mode!.name, 'message': message, if (requestedSchema != null) 'requestedSchema': requestedSchema!.toJson(), if (url != null) 'url': url, if (elicitationId != null) 'elicitationId': elicitationId, + if (task != null) 'task': task!.toJson(), }; } @@ -207,7 +227,13 @@ class JsonRpcElicitRequest extends JsonRpcRequest { required super.id, required this.elicitParams, super.meta, - }) : super(method: Method.elicitationCreate, params: elicitParams.toJson()); + String? protocolVersion, + }) : super( + method: Method.elicitationCreate, + params: elicitParams.toJson( + protocolVersion: protocolVersion ?? _protocolVersionFromMeta(meta), + ), + ); factory JsonRpcElicitRequest.fromJson(Map json) { final paramsMap = json['params'] as Map?; @@ -215,10 +241,15 @@ class JsonRpcElicitRequest extends JsonRpcRequest { throw const FormatException("Missing params for elicit request"); } final meta = extractRequestMeta(json); + final protocolVersion = _protocolVersionFromMeta(meta); return JsonRpcElicitRequest( id: parseRequestId(json['id']), - elicitParams: ElicitRequest.fromJson(paramsMap), + elicitParams: ElicitRequest.fromJson( + paramsMap, + protocolVersion: protocolVersion, + ), meta: meta, + protocolVersion: protocolVersion, ); } } @@ -290,10 +321,10 @@ class ElicitResult implements BaseResultData { Map toJson() { final resultAction = action; _validateElicitResultContentForAction(resultAction, content); - _validateElicitResultContent(content); + final normalizedContent = _normalizeElicitResultContent(content); return { 'action': resultAction, - if (content != null) 'content': content, + if (normalizedContent != null) 'content': normalizedContent, if (meta != null) '_meta': readJsonObject(meta, 'ElicitResult._meta'), }; } @@ -421,16 +452,30 @@ typedef ElicitRequestParams = ElicitRequest; @Deprecated('Use ElicitationCompleteNotification instead') typedef ElicitationCompleteParams = ElicitationCompleteNotification; -void _validateFormRequestedSchema(ElicitationInputSchema schema) { - _validateFormRequestedSchemaJson(schema.toJson()); +void _validateFormRequestedSchema( + ElicitationInputSchema schema, { + String? protocolVersion, +}) { + _validateFormRequestedSchemaJson( + schema.toJson(), + protocolVersion: protocolVersion, + ); } -void _validateFormRequestedSchemaJson(Map json) { +void _validateFormRequestedSchemaJson( + Map json, { + String? protocolVersion, +}) { _ensureAllowedKeys( json, const {r'$schema', 'type', 'properties', 'required'}, 'ElicitRequest.requestedSchema', ); + _validateOptionalStringKeyword( + json, + r'$schema', + 'ElicitRequest.requestedSchema', + ); if (json['type'] != 'object') { throw const FormatException( 'Form elicitation requestedSchema must have type object.', @@ -451,6 +496,7 @@ void _validateFormRequestedSchemaJson(Map json) { _validatePrimitiveSchema( (entry.value as Map).cast(), 'ElicitRequest.requestedSchema.properties.${entry.key}', + protocolVersion: protocolVersion, ); } final required = json['required']; @@ -462,7 +508,11 @@ void _validateFormRequestedSchemaJson(Map json) { } } -void _validatePrimitiveSchema(Map json, String context) { +void _validatePrimitiveSchema( + Map json, + String context, { + String? protocolVersion, +}) { final type = json['type']; switch (type) { case 'string': @@ -482,6 +532,12 @@ void _validatePrimitiveSchema(Map json, String context) { }, context, ); + _validatePrimitiveBaseKeywords(json, context); + _validateNumberSchemaKeywords( + json, + context, + protocolVersion: protocolVersion, + ); return; case 'boolean': _ensureAllowedKeys( @@ -489,6 +545,10 @@ void _validatePrimitiveSchema(Map json, String context) { const {'type', 'title', 'description', 'default'}, context, ); + _validatePrimitiveBaseKeywords(json, context); + if (json['default'] != null && json['default'] is! bool) { + throw FormatException('$context.default must be a boolean.'); + } return; case 'array': _validateMultiSelectEnumSchema(json, context); @@ -500,6 +560,33 @@ void _validatePrimitiveSchema(Map json, String context) { } } +void _validatePrimitiveBaseKeywords( + Map json, + String context, +) { + _validateOptionalStringKeyword(json, 'title', context); + _validateOptionalStringKeyword(json, 'description', context); +} + +void _validateNumberSchemaKeywords( + Map json, + String context, { + String? protocolVersion, +}) { + if (!_usesDraftNumberSchemaKeywords(protocolVersion)) { + for (final key in const ['default', 'minimum', 'maximum']) { + _validateOptionalIntegerKeyword(json, key, context); + } + return; + } + + for (final key in const ['default', 'minimum', 'maximum']) { + if (json[key] != null) { + readFiniteNumber(json[key], '$context.$key'); + } + } +} + void _validateStringOrSingleEnumSchema( Map json, String context, @@ -510,6 +597,8 @@ void _validateStringOrSingleEnumSchema( const {'type', 'title', 'description', 'oneOf', 'default'}, context, ); + _validatePrimitiveBaseKeywords(json, context); + _validateOptionalStringKeyword(json, 'default', context); final oneOf = json['oneOf']; if (oneOf is! List || oneOf.any( @@ -538,6 +627,10 @@ void _validateStringOrSingleEnumSchema( }, context, ); + _validatePrimitiveBaseKeywords(json, context); + _validateOptionalStringKeyword(json, 'default', context); + _validateOptionalIntegerKeyword(json, 'minLength', context); + _validateOptionalIntegerKeyword(json, 'maxLength', context); final enumValues = json['enum']; if (enumValues != null && (enumValues is! List || enumValues.any((value) => value is! String))) { @@ -575,16 +668,21 @@ void _validateMultiSelectEnumSchema( }, context, ); + _validatePrimitiveBaseKeywords(json, context); + _validateOptionalIntegerKeyword(json, 'minItems', context); + _validateOptionalIntegerKeyword(json, 'maxItems', context); + _validateOptionalStringListKeyword(json, 'default', context); final items = json['items']; if (items is! Map) { throw FormatException('$context.items is required for array schemas.'); } final itemMap = items.cast(); - if (itemMap['type'] == 'string' && itemMap['enum'] is List) { - final enumValues = itemMap['enum'] as List; - if (enumValues.any((value) => value is! String)) { - throw FormatException('$context.items.enum must be a string array.'); + if (itemMap.containsKey('enum')) { + _ensureAllowedKeys(itemMap, const {'type', 'enum'}, '$context.items'); + if (itemMap['type'] != 'string') { + throw FormatException('$context.items.type must be string.'); } + _validateRequiredStringListKeyword(itemMap, 'enum', '$context.items'); return; } final anyOf = itemMap['anyOf']; @@ -600,6 +698,50 @@ void _validateMultiSelectEnumSchema( throw FormatException('$context.items must define a string enum.'); } +void _validateOptionalStringKeyword( + Map json, + String key, + String context, +) { + final value = json[key]; + if (value != null && value is! String) { + throw FormatException('$context.$key must be a string.'); + } +} + +void _validateOptionalIntegerKeyword( + Map json, + String key, + String context, +) { + if (json[key] == null) { + return; + } + readOptionalInteger(json[key], '$context.$key'); +} + +void _validateOptionalStringListKeyword( + Map json, + String key, + String context, +) { + if (json[key] == null) { + return; + } + _validateRequiredStringListKeyword(json, key, context); +} + +void _validateRequiredStringListKeyword( + Map json, + String key, + String context, +) { + final value = json[key]; + if (value is! List || value.any((item) => item is! String)) { + throw FormatException('$context.$key must be a string array.'); + } +} + void _ensureAllowedKeys( Map json, Set allowed, @@ -613,6 +755,15 @@ void _ensureAllowedKeys( } } +String? _protocolVersionFromMeta(Map? meta) { + final protocolVersion = meta?[McpMetaKey.protocolVersion]; + return protocolVersion is String ? protocolVersion : null; +} + +bool _usesDraftNumberSchemaKeywords(String? protocolVersion) { + return protocolVersion != null && isStatelessProtocolVersion(protocolVersion); +} + Map? _parseElicitResultContent(Object? content) { if (content == null) { return null; @@ -621,39 +772,49 @@ Map? _parseElicitResultContent(Object? content) { throw const FormatException('ElicitResult.content must be an object.'); } final result = content.cast(); - _validateElicitResultContent(result, formatException: true); - return result; + return _normalizeElicitResultContent(result, formatException: true); } -void _validateElicitResultContent( +Map? _normalizeElicitResultContent( Map? content, { bool formatException = false, }) { if (content == null) { - return; + return null; } + final normalized = {}; for (final entry in content.entries) { final value = entry.value; if (value is String || value is bool) { + normalized[entry.key] = value; + continue; + } + if (value is int) { + normalized[entry.key] = value; continue; } - if (value is num && value.isFinite) { + if (value is double && + value.isFinite && + value == value.truncateToDouble()) { + normalized[entry.key] = value.toInt(); continue; } if (value is List && value.every((item) => item is String)) { + normalized[entry.key] = List.from(value); continue; } if (formatException) { throw FormatException( - 'ElicitResult.content.${entry.key} must be string, finite number, boolean, or string[]', + 'ElicitResult.content.${entry.key} must be string, integer, boolean, or string[]', ); } throw ArgumentError.value( value, 'content.${entry.key}', - 'ElicitResult content values must be string, finite number, boolean, or string[]', + 'ElicitResult content values must be string, integer, boolean, or string[]', ); } + return normalized; } void _validateElicitResultContentForAction( diff --git a/lib/src/types/initialization.dart b/lib/src/types/initialization.dart index 03d1007a..e743be17 100644 --- a/lib/src/types/initialization.dart +++ b/lib/src/types/initialization.dart @@ -18,6 +18,69 @@ Map? _asJsonObject(dynamic value) { throw FormatException('Expected object capability, got ${value.runtimeType}'); } +String _readRequiredString(Object? value, String field) { + if (value is String) { + return value; + } + throw FormatException('$field must be a string'); +} + +String? _readOptionalPresentString( + Map json, + String key, + String field, +) { + if (!json.containsKey(key)) { + return null; + } + return _readRequiredString(json[key], field); +} + +bool _isAbsoluteUri(String value) { + return Uri.tryParse(value)?.hasScheme ?? false; +} + +String? _readOptionalPresentUriString( + Map json, + String key, + String field, +) { + final value = _readOptionalPresentString(json, key, field); + if (value == null) { + return null; + } + if (!_isAbsoluteUri(value)) { + throw FormatException('$field must be an absolute URI'); + } + return value; +} + +void _validateAbsoluteUriString(String value, String field) { + if (!_isAbsoluteUri(value)) { + throw ArgumentError.value(value, field, 'must be an absolute URI'); + } +} + +List? _readOptionalIconList( + Map json, + String key, + String field, +) { + if (!json.containsKey(key)) { + return null; + } + + final value = json[key]; + if (value is! List) { + throw FormatException('$field must be a list of objects'); + } + + return [ + for (var i = 0; i < value.length; i++) + McpIcon.fromJson(readJsonObject(value[i], '$field[$i]')), + ]; +} + Map? _asStrictJsonObject(Object? value, String field) { if (value == null) { return null; @@ -150,25 +213,42 @@ class Implementation { factory Implementation.fromJson(Map json) { return Implementation( - name: json['name'] as String, - title: json['title'] as String?, - version: json['version'] as String, - description: json['description'] as String?, - icons: (json['icons'] as List?) - ?.map((e) => McpIcon.fromJson(e as Map)) - .toList(), - websiteUrl: json['websiteUrl'] as String?, + name: _readRequiredString(json['name'], 'Implementation.name'), + title: _readOptionalPresentString( + json, + 'title', + 'Implementation.title', + ), + version: _readRequiredString(json['version'], 'Implementation.version'), + description: _readOptionalPresentString( + json, + 'description', + 'Implementation.description', + ), + icons: _readOptionalIconList(json, 'icons', 'Implementation.icons'), + websiteUrl: _readOptionalPresentUriString( + json, + 'websiteUrl', + 'Implementation.websiteUrl', + ), ); } - Map toJson() => { - 'name': name, - if (title != null) 'title': title, - 'version': version, - if (description != null) 'description': description, - if (icons != null) 'icons': icons?.map((e) => e.toJson()).toList(), - if (websiteUrl != null) 'websiteUrl': websiteUrl, - }; + Map toJson() { + final websiteUrl = this.websiteUrl; + if (websiteUrl != null) { + _validateAbsoluteUriString(websiteUrl, 'Implementation.websiteUrl'); + } + + return { + 'name': name, + if (title != null) 'title': title, + 'version': version, + if (description != null) 'description': description, + if (icons != null) 'icons': icons?.map((e) => e.toJson()).toList(), + if (websiteUrl != null) 'websiteUrl': websiteUrl, + }; + } } /// Describes capabilities related to root resources (e.g., workspace folders). @@ -1083,6 +1163,16 @@ class DiscoverResult implements BaseResultData { }); factory DiscoverResult.fromJson(Map json) { + final resultType = readOptionalString( + json['resultType'], + 'DiscoverResult.resultType', + ); + if (resultType != resultTypeComplete) { + throw const FormatException( + 'DiscoverResult.resultType must be complete', + ); + } + final supportedVersions = json['supportedVersions']; if (supportedVersions is! List) { throw const FormatException( @@ -1091,7 +1181,6 @@ class DiscoverResult implements BaseResultData { } return DiscoverResult( - resultType: json['resultType'] as String? ?? 'complete', supportedVersions: supportedVersions.cast(), capabilities: ServerCapabilities.fromJson( json['capabilities'] as Map, @@ -1105,14 +1194,24 @@ class DiscoverResult implements BaseResultData { } @override - Map toJson() => { - 'resultType': resultType, - 'supportedVersions': supportedVersions, - 'capabilities': capabilities.toJson(), - 'serverInfo': serverInfo.toJson(), - if (instructions != null) 'instructions': instructions, - if (meta != null) '_meta': readJsonObject(meta, 'DiscoverResult._meta'), - }; + Map toJson() { + if (resultType != resultTypeComplete) { + throw ArgumentError.value( + resultType, + 'DiscoverResult.resultType', + 'must be complete', + ); + } + + return { + 'resultType': resultType, + 'supportedVersions': supportedVersions, + 'capabilities': capabilities.toJson(), + 'serverInfo': serverInfo.toJson(), + if (instructions != null) 'instructions': instructions, + if (meta != null) '_meta': readJsonObject(meta, 'DiscoverResult._meta'), + }; + } } /// Notification sent from the client to the server after initialization is finished. diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index 6cb55fd9..ddc82f9a 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -711,17 +711,21 @@ class InputRequest { /// Creates an embedded `elicitation/create` input request. factory InputRequest.elicit(ElicitRequest params) { + final inputParams = params.toJson( + protocolVersion: latestDraftProtocolVersion, + )..remove('task'); return InputRequest._( method: Method.elicitationCreate, - params: params.toJson(), + params: inputParams, ); } /// Creates an embedded `sampling/createMessage` input request. factory InputRequest.createMessage(CreateMessageRequest params) { + final inputParams = params.toJson()..remove('task'); return InputRequest._( method: Method.samplingCreateMessage, - params: params.toJson(), + params: inputParams, ); } @@ -745,13 +749,28 @@ class InputRequest { json['params'], 'InputRequest.params', ); - ElicitRequest.fromJson(params); + if (params.containsKey('task')) { + throw const FormatException( + 'InputRequest elicitation/create params must not include ' + 'legacy task metadata', + ); + } + ElicitRequest.fromJson( + params, + protocolVersion: latestDraftProtocolVersion, + ); return InputRequest._(method: method, params: params); case Method.samplingCreateMessage: final params = _readRequiredJsonObject( json['params'], 'InputRequest.params', ); + if (params.containsKey('task')) { + throw const FormatException( + 'InputRequest sampling/createMessage params must not include ' + 'legacy task metadata', + ); + } CreateMessageRequest.fromJson(params); return InputRequest._(method: method, params: params); case Method.rootsList: @@ -797,7 +816,10 @@ class InputRequest { if (method != Method.elicitationCreate || params == null) { throw StateError('InputRequest is not an elicitation/create request'); } - return ElicitRequest.fromJson(params!); + return ElicitRequest.fromJson( + params!, + protocolVersion: latestDraftProtocolVersion, + ); } /// The typed params for an embedded `sampling/createMessage` request. diff --git a/lib/src/types/logging.dart b/lib/src/types/logging.dart index b57aa24c..fe4090ae 100644 --- a/lib/src/types/logging.dart +++ b/lib/src/types/logging.dart @@ -22,7 +22,11 @@ class SetLevelRequest { factory SetLevelRequest.fromJson(Map json) => SetLevelRequest( - level: LoggingLevel.values.byName(json['level'] as String), + level: readRequiredEnumValue( + json['level'], + LoggingLevel.values, + 'SetLevelRequest.level', + ), ); Map toJson() => {'level': level.name}; @@ -74,7 +78,11 @@ class LoggingMessageNotification { Map json, ) => LoggingMessageNotification( - level: LoggingLevel.values.byName(json['level'] as String), + level: readRequiredEnumValue( + json['level'], + LoggingLevel.values, + 'LoggingMessageNotification.level', + ), logger: json['logger'] as String?, data: json['data'], ); diff --git a/lib/src/types/prompts.dart b/lib/src/types/prompts.dart index 71a50939..a74ab569 100644 --- a/lib/src/types/prompts.dart +++ b/lib/src/types/prompts.dart @@ -295,7 +295,9 @@ class PromptMessage { factory PromptMessage.fromJson(Map json) { return PromptMessage( - role: PromptMessageRole.values.byName(json['role'] as String), + role: PromptMessageRole.values.byName( + readRequiredRoleString(json['role'], 'PromptMessage.role'), + ), content: Content.fromJson(json['content'] as Map), ); } diff --git a/lib/src/types/resources.dart b/lib/src/types/resources.dart index 52dc0a77..aa226545 100644 --- a/lib/src/types/resources.dart +++ b/lib/src/types/resources.dart @@ -32,14 +32,24 @@ class ResourceAnnotations { factory ResourceAnnotations.fromJson(Map json) { return ResourceAnnotations( title: json['title'] as String?, - audience: (json['audience'] as List?)?.cast(), + audience: readOptionalAnnotationAudience( + json['audience'], + 'ResourceAnnotations.audience', + ), priority: readUnitDouble(json['priority'], 'ResourceAnnotations.priority'), - lastModified: json['lastModified'] as String?, + lastModified: readOptionalString( + json['lastModified'], + 'ResourceAnnotations.lastModified', + ), ); } Map toJson() { + validateAnnotationAudience( + audience, + 'ResourceAnnotations.audience', + ); validateUnitDouble(priority, 'ResourceAnnotations.priority'); return { if (audience != null) 'audience': audience, @@ -100,7 +110,7 @@ class Resource { /// Creates from JSON. factory Resource.fromJson(Map json) { return Resource( - uri: json['uri'] as String, + uri: readRequiredAbsoluteUriString(json['uri'], 'Resource.uri'), name: json['name'] as String, title: json['title'] as String?, description: json['description'] as String?, @@ -114,7 +124,7 @@ class Resource { size: readOptionalInteger(json['size'], 'Resource.size'), annotations: json['annotations'] != null ? ResourceAnnotations.fromJson( - json['annotations'] as Map, + readJsonObject(json['annotations'], 'Resource.annotations'), ) : null, meta: readOptionalJsonObject(json['_meta'], 'Resource._meta'), @@ -122,18 +132,20 @@ class Resource { } /// Converts to JSON. - Map toJson() => { - 'uri': uri, - 'name': name, - if (title != null) 'title': title, - if (description != null) 'description': description, - if (mimeType != null) 'mimeType': mimeType, - if (icons != null) - 'icons': icons!.map((icon) => icon.toJson()).toList(), - if (size != null) 'size': size, - if (annotations != null) 'annotations': annotations!.toJson(), - if (meta != null) '_meta': readJsonObject(meta, 'Resource._meta'), - }; + Map toJson() { + validateAbsoluteUriString(uri, 'Resource.uri'); + return { + 'uri': uri, + 'name': name, + if (title != null) 'title': title, + if (description != null) 'description': description, + if (mimeType != null) 'mimeType': mimeType, + if (icons != null) 'icons': icons!.map((icon) => icon.toJson()).toList(), + if (size != null) 'size': size, + if (annotations != null) 'annotations': annotations!.toJson(), + if (meta != null) '_meta': readJsonObject(meta, 'Resource._meta'), + }; + } } /// A template description for resources available on the server. @@ -184,7 +196,10 @@ class ResourceTemplate { /// Creates from JSON. factory ResourceTemplate.fromJson(Map json) { return ResourceTemplate( - uriTemplate: json['uriTemplate'] as String, + uriTemplate: readRequiredUriTemplateString( + json['uriTemplate'], + 'ResourceTemplate.uriTemplate', + ), name: json['name'] as String, title: json['title'] as String?, description: json['description'] as String?, @@ -197,7 +212,10 @@ class ResourceTemplate { .toList(), annotations: json['annotations'] != null ? ResourceAnnotations.fromJson( - json['annotations'] as Map, + readJsonObject( + json['annotations'], + 'ResourceTemplate.annotations', + ), ) : null, meta: readOptionalJsonObject(json['_meta'], 'ResourceTemplate._meta'), @@ -205,18 +223,22 @@ class ResourceTemplate { } /// Converts to JSON. - Map toJson() => { - 'uriTemplate': uriTemplate, - 'name': name, - if (title != null) 'title': title, - if (description != null) 'description': description, - if (mimeType != null) 'mimeType': mimeType, - if (icons != null) - 'icons': icons!.map((icon) => icon.toJson()).toList(), - if (annotations != null) 'annotations': annotations!.toJson(), - if (meta != null) - '_meta': readJsonObject(meta, 'ResourceTemplate._meta'), - }; + Map toJson() { + validateUriTemplateString( + uriTemplate, + 'ResourceTemplate.uriTemplate', + ); + return { + 'uriTemplate': uriTemplate, + 'name': name, + if (title != null) 'title': title, + if (description != null) 'description': description, + if (mimeType != null) 'mimeType': mimeType, + if (icons != null) 'icons': icons!.map((icon) => icon.toJson()).toList(), + if (annotations != null) 'annotations': annotations!.toJson(), + if (meta != null) '_meta': readJsonObject(meta, 'ResourceTemplate._meta'), + }; + } } /// Parameters for the `resources/list` request. Includes pagination. @@ -461,7 +483,10 @@ class ReadResourceRequest { factory ReadResourceRequest.fromJson(Map json) => ReadResourceRequest( - uri: json['uri'] as String, + uri: readRequiredAbsoluteUriString( + json['uri'], + 'ReadResourceRequest.uri', + ), inputResponses: InputResponse.mapFromJson( json['inputResponses'], 'ReadResourceRequest.inputResponses', @@ -472,12 +497,15 @@ class ReadResourceRequest { ), ); - Map toJson() => { - 'uri': uri, - if (inputResponses != null) - 'inputResponses': InputResponse.mapToJson(inputResponses!), - if (requestState != null) 'requestState': requestState, - }; + Map toJson() { + validateAbsoluteUriString(uri, 'ReadResourceRequest.uri'); + return { + 'uri': uri, + if (inputResponses != null) + 'inputResponses': InputResponse.mapToJson(inputResponses!), + if (requestState != null) 'requestState': requestState, + }; + } } /// Request sent from client to read a specific resource. @@ -583,9 +611,14 @@ class SubscribeRequest { const SubscribeRequest({required this.uri}); factory SubscribeRequest.fromJson(Map json) => - SubscribeRequest(uri: json['uri'] as String); + SubscribeRequest( + uri: readRequiredAbsoluteUriString(json['uri'], 'SubscribeRequest.uri'), + ); - Map toJson() => {'uri': uri}; + Map toJson() { + validateAbsoluteUriString(uri, 'SubscribeRequest.uri'); + return {'uri': uri}; + } } /// Request sent from client to subscribe to updates for a resource. @@ -621,9 +654,17 @@ class UnsubscribeRequest { const UnsubscribeRequest({required this.uri}); factory UnsubscribeRequest.fromJson(Map json) => - UnsubscribeRequest(uri: json['uri'] as String); + UnsubscribeRequest( + uri: readRequiredAbsoluteUriString( + json['uri'], + 'UnsubscribeRequest.uri', + ), + ); - Map toJson() => {'uri': uri}; + Map toJson() { + validateAbsoluteUriString(uri, 'UnsubscribeRequest.uri'); + return {'uri': uri}; + } } /// Request sent from client to cancel a resource subscription. @@ -661,9 +702,17 @@ class ResourceUpdatedNotification { factory ResourceUpdatedNotification.fromJson( Map json, ) => - ResourceUpdatedNotification(uri: json['uri'] as String); + ResourceUpdatedNotification( + uri: readRequiredAbsoluteUriString( + json['uri'], + 'ResourceUpdatedNotification.uri', + ), + ); - Map toJson() => {'uri': uri}; + Map toJson() { + validateAbsoluteUriString(uri, 'ResourceUpdatedNotification.uri'); + return {'uri': uri}; + } } /// Notification from server indicating a subscribed resource has changed. diff --git a/lib/src/types/roots.dart b/lib/src/types/roots.dart index 6eb2c323..648b7fd2 100644 --- a/lib/src/types/roots.dart +++ b/lib/src/types/roots.dart @@ -1,6 +1,24 @@ import 'json_rpc.dart'; import 'validation.dart'; +String _readRootUri(Object? value) { + final uri = readRequiredString(value, 'Root.uri'); + if (!isAbsoluteUriString(uri)) { + throw const FormatException('Root.uri must be an absolute URI'); + } + if (!uri.startsWith('file://')) { + throw const FormatException('Root.uri must start with file://'); + } + return uri; +} + +void _validateRootUri(String uri) { + validateAbsoluteUriString(uri, 'Root.uri'); + if (!uri.startsWith('file://')) { + throw ArgumentError.value(uri, 'uri', 'Root.uri must start with file://'); + } +} + /// Represents a root directory or file the server can operate on. class Root { /// URI identifying the root (must start with `file://`). @@ -17,24 +35,25 @@ class Root { this.name, this.meta, }) { - if (!uri.startsWith('file://')) { - throw ArgumentError.value(uri, 'uri', 'Root.uri must start with file://'); - } + _validateRootUri(uri); } factory Root.fromJson(Map json) { return Root( - uri: json['uri'] as String, + uri: _readRootUri(json['uri']), name: json['name'] as String?, meta: readOptionalJsonObject(json['_meta'], 'Root._meta'), ); } - Map toJson() => { - 'uri': uri, - if (name != null) 'name': name, - if (meta != null) '_meta': readJsonObject(meta, 'Root._meta'), - }; + Map toJson() { + _validateRootUri(uri); + return { + 'uri': uri, + if (name != null) 'name': name, + if (meta != null) '_meta': readJsonObject(meta, 'Root._meta'), + }; + } } /// Request sent from server to client to get the list of root URIs. diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index 01580869..cd17fbcd 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -25,6 +25,20 @@ Map _asJsonObject( return map; } +String _base64ForJson(String value, String field) { + validateBase64String(value, field); + return value; +} + +Map _annotationsForJson( + Map value, + String field, +) { + final result = readJsonObject(value, field); + validateAnnotationsObject(result, field); + return result; +} + Object _parseSamplingMessageContent(dynamic value) { if (value is List) { return value @@ -287,30 +301,30 @@ sealed class SamplingContent { final SamplingTextContent c => { 'text': c.text, if (c.annotations != null) - 'annotations': readJsonObject( - c.annotations, + 'annotations': _annotationsForJson( + c.annotations!, 'SamplingTextContent.annotations', ), if (c.meta != null) '_meta': readJsonObject(c.meta, 'SamplingTextContent._meta'), }, final SamplingImageContent c => { - 'data': c.data, + 'data': _base64ForJson(c.data, 'SamplingImageContent.data'), 'mimeType': c.mimeType, if (c.annotations != null) - 'annotations': readJsonObject( - c.annotations, + 'annotations': _annotationsForJson( + c.annotations!, 'SamplingImageContent.annotations', ), if (c.meta != null) '_meta': readJsonObject(c.meta, 'SamplingImageContent._meta'), }, final SamplingAudioContent c => { - 'data': c.data, + 'data': _base64ForJson(c.data, 'SamplingAudioContent.data'), 'mimeType': c.mimeType, if (c.annotations != null) - 'annotations': readJsonObject( - c.annotations, + 'annotations': _annotationsForJson( + c.annotations!, 'SamplingAudioContent.annotations', ), if (c.meta != null) @@ -365,7 +379,7 @@ class SamplingTextContent extends SamplingContent { factory SamplingTextContent.fromJson(Map json) => SamplingTextContent( text: json['text'] as String, - annotations: _asJsonObjectOrNull( + annotations: readOptionalAnnotationsObject( json['annotations'], 'SamplingTextContent.annotations', ), @@ -396,9 +410,12 @@ class SamplingImageContent extends SamplingContent { factory SamplingImageContent.fromJson(Map json) => SamplingImageContent( - data: json['data'] as String, + data: readRequiredBase64String( + json['data'], + 'SamplingImageContent.data', + ), mimeType: json['mimeType'] as String, - annotations: _asJsonObjectOrNull( + annotations: readOptionalAnnotationsObject( json['annotations'], 'SamplingImageContent.annotations', ), @@ -429,9 +446,12 @@ class SamplingAudioContent extends SamplingContent { factory SamplingAudioContent.fromJson(Map json) => SamplingAudioContent( - data: json['data'] as String, + data: readRequiredBase64String( + json['data'], + 'SamplingAudioContent.data', + ), mimeType: json['mimeType'] as String, - annotations: _asJsonObjectOrNull( + annotations: readOptionalAnnotationsObject( json['annotations'], 'SamplingAudioContent.annotations', ), @@ -540,7 +560,9 @@ class SamplingMessage { factory SamplingMessage.fromJson(Map json) { return SamplingMessage( - role: SamplingMessageRole.values.byName(json['role'] as String), + role: SamplingMessageRole.values.byName( + readRequiredRoleString(json['role'], 'SamplingMessage.role'), + ), content: _parseSamplingMessageContent(json['content']), meta: _asJsonObjectOrNull(json['_meta'], 'SamplingMessage._meta'), ); @@ -574,11 +596,13 @@ class ToolChoice { return const ToolChoice(); } - if (rawMode is! String) { - throw FormatException('Expected toolChoice mode string, got $rawMode'); - } - - return ToolChoice(mode: ToolChoiceMode.values.byName(rawMode)); + return ToolChoice( + mode: readRequiredEnumValue( + rawMode, + ToolChoiceMode.values, + 'ToolChoice.mode', + ), + ); } Map toJson() => { @@ -645,9 +669,14 @@ class CreateMessageRequest { }); factory CreateMessageRequest.fromJson(Map json) { - final ctxStr = json['includeContext'] as String?; - final task = _asJsonObjectOrNull(json['task']); - final toolChoice = _asJsonObjectOrNull(json['toolChoice']); + final task = _asJsonObjectOrNull(json['task'], 'CreateMessageRequest.task'); + final toolChoice = _asJsonObjectOrNull( + json['toolChoice'], + 'CreateMessageRequest.toolChoice', + ); + if (toolChoice != null) { + ToolChoice.fromJson(toolChoice); + } final messages = json['messages']; if (messages is! List) { throw const FormatException('CreateMessageRequest.messages is required'); @@ -658,13 +687,19 @@ class CreateMessageRequest { .toList(), task: task == null ? null : TaskCreation.fromJson(task), systemPrompt: json['systemPrompt'] as String?, - includeContext: - ctxStr == null ? null : IncludeContext.values.byName(ctxStr), + includeContext: readOptionalEnumValue( + json['includeContext'], + IncludeContext.values, + 'CreateMessageRequest.includeContext', + ), temperature: readOptionalFiniteDouble( json['temperature'], 'CreateMessageRequest.temperature', ), - maxTokens: json['maxTokens'] as int, + maxTokens: readInteger( + json['maxTokens'], + 'CreateMessageRequest.maxTokens', + ), stopSequences: (json['stopSequences'] as List?)?.cast(), metadata: _asJsonObjectOrNull( json['metadata'], @@ -795,7 +830,9 @@ class CreateMessageResult implements BaseResultData { return CreateMessageResult( model: json['model'] as String, stopReason: reason, - role: SamplingMessageRole.values.byName(json['role'] as String), + role: SamplingMessageRole.values.byName( + readRequiredRoleString(json['role'], 'CreateMessageResult.role'), + ), content: _parseSamplingMessageContent(json['content']), meta: meta, ); diff --git a/lib/src/types/tasks.dart b/lib/src/types/tasks.dart index 511e712c..633c6417 100644 --- a/lib/src/types/tasks.dart +++ b/lib/src/types/tasks.dart @@ -178,6 +178,9 @@ int? _readTaskInt( if (value == null || value is int) { return value as int?; } + if (value is double && value.isFinite && value == value.truncateToDouble()) { + return value.toInt(); + } throw FormatException('$owner.$field must be an integer or null'); } @@ -442,7 +445,7 @@ class TaskCreation { const TaskCreation({this.ttl}); factory TaskCreation.fromJson(Map json) => - TaskCreation(ttl: json['ttl'] as int?); + TaskCreation(ttl: readOptionalInteger(json['ttl'], 'TaskCreation.ttl')); Map toJson() => { if (ttl != null) 'ttl': ttl, diff --git a/lib/src/types/validation.dart b/lib/src/types/validation.dart index 21220b97..021862ed 100644 --- a/lib/src/types/validation.dart +++ b/lib/src/types/validation.dart @@ -1,3 +1,173 @@ +import 'dart:convert'; + +import '../shared/uri_template.dart'; + +final _base64Pattern = RegExp( + r'^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$', +); + +String readRequiredString(Object? value, String field) { + if (value is String) { + return value; + } + throw FormatException('$field must be a string'); +} + +bool isAbsoluteUriString(String value) { + return Uri.tryParse(value)?.hasScheme ?? false; +} + +String readRequiredAbsoluteUriString(Object? value, String field) { + final result = readRequiredString(value, field); + if (!isAbsoluteUriString(result)) { + throw FormatException('$field must be an absolute URI'); + } + return result; +} + +void validateAbsoluteUriString(String value, String field) { + if (!isAbsoluteUriString(value)) { + throw ArgumentError.value(value, field, 'must be an absolute URI'); + } +} + +String readRequiredUriTemplateString(Object? value, String field) { + final result = readRequiredString(value, field); + try { + UriTemplateExpander(result); + } on ArgumentError catch (error) { + throw FormatException( + '$field must be a URI template: ${error.message}', + ); + } + return result; +} + +void validateUriTemplateString(String value, String field) { + try { + UriTemplateExpander(value); + } on ArgumentError catch (error) { + throw ArgumentError.value( + value, + field, + 'must be a URI template: ' + '${error.message}'); + } +} + +bool isBase64String(String value) { + if (!_base64Pattern.hasMatch(value)) { + return false; + } + try { + base64.decode(value); + return true; + } on FormatException { + return false; + } +} + +String readRequiredBase64String(Object? value, String field) { + final result = readRequiredString(value, field); + if (!isBase64String(result)) { + throw FormatException('$field must be a base64-encoded string'); + } + return result; +} + +void validateBase64String(String value, String field) { + if (!isBase64String(value)) { + throw ArgumentError.value( + value, + field, + 'must be a base64-encoded string', + ); + } +} + +T readRequiredEnumValue( + Object? value, + Iterable values, + String field, +) { + final name = readRequiredString(value, field); + for (final enumValue in values) { + if (enumValue.name == name) { + return enumValue; + } + } + final allowed = values.map((value) => '"${value.name}"').join(', '); + throw FormatException('$field must be one of: $allowed'); +} + +T? readOptionalEnumValue( + Object? value, + Iterable values, + String field, +) { + if (value == null) { + return null; + } + return readRequiredEnumValue(value, values, field); +} + +bool isRoleString(String value) { + return value == 'user' || value == 'assistant'; +} + +String readRequiredRoleString(Object? value, String field) { + final result = readRequiredString(value, field); + if (!isRoleString(result)) { + throw FormatException('$field must be "user" or "assistant"'); + } + return result; +} + +List? readOptionalAnnotationAudience(Object? value, String field) { + if (value == null) { + return null; + } + if (value is! List) { + throw FormatException('$field must be a list of roles'); + } + return [ + for (final item in value) readRequiredRoleString(item, '$field items'), + ]; +} + +void validateAnnotationAudience(List? value, String field) { + if (value == null) { + return; + } + for (final item in value) { + if (!isRoleString(item)) { + throw ArgumentError.value( + item, + field, + 'items must be "user" or "assistant"', + ); + } + } +} + +void validateAnnotationsObject(Map? value, String field) { + if (value == null) { + return; + } + readOptionalAnnotationAudience(value['audience'], '$field.audience'); + readUnitDouble(value['priority'], '$field.priority'); + readOptionalString(value['lastModified'], '$field.lastModified'); +} + +Map? readOptionalAnnotationsObject( + Object? value, + String field, +) { + final result = readOptionalJsonObject(value, field); + validateAnnotationsObject(result, field); + return result; +} + double? readUnitDouble(Object? value, String field) { final number = readOptionalFiniteNumber(value, field); final result = number?.toDouble(); @@ -63,6 +233,14 @@ int? readOptionalInteger(Object? value, String field) { throw FormatException('$field must be an integer'); } +int readInteger(Object? value, String field) { + final integer = readOptionalInteger(value, field); + if (integer == null) { + throw FormatException('$field is required'); + } + return integer; +} + String? readOptionalString(Object? value, String field) { if (value == null) { return null; diff --git a/packages/mcp_dart_cli/lib/src/conformance_runner.dart b/packages/mcp_dart_cli/lib/src/conformance_runner.dart index 78bf05f6..3ec05a10 100644 --- a/packages/mcp_dart_cli/lib/src/conformance_runner.dart +++ b/packages/mcp_dart_cli/lib/src/conformance_runner.dart @@ -525,6 +525,25 @@ Future _initializeClient( final connectFuture = client.connect(transport); await _settle(); + final discoverRequests = transport.sentMessages + .whereType() + .where((request) => request.method == _serverDiscoverMethod) + .toList(); + for (final discoverRequest in discoverRequests) { + transport.emit( + JsonRpcError( + id: discoverRequest.id, + error: JsonRpcErrorData( + code: ErrorCode.methodNotFound.value, + message: 'Method not found', + ), + ), + ); + } + if (discoverRequests.isNotEmpty) { + await _settle(); + } + final initializeRequests = transport.sentMessages .whereType() .where((request) => request.method == Method.initialize) diff --git a/test/client/client_elicitation_defaults_test.dart b/test/client/client_elicitation_defaults_test.dart index 85d05814..29b0cf0e 100644 --- a/test/client/client_elicitation_defaults_test.dart +++ b/test/client/client_elicitation_defaults_test.dart @@ -17,7 +17,18 @@ class MockTransport extends Transport { @override Future send(JsonRpcMessage message, {int? relatedRequestId}) async { sentMessages.add(message); - if (message is JsonRpcRequest && message.method == Method.initialize) { + if (message is JsonRpcRequest && message.method == Method.serverDiscover) { + _respond( + JsonRpcError( + id: message.id, + error: const JsonRpcErrorData( + code: -32601, + message: 'Method not found', + ), + ), + ); + } else if (message is JsonRpcRequest && + message.method == Method.initialize) { _respond( JsonRpcResponse( id: message.id, diff --git a/test/client/client_test.dart b/test/client/client_test.dart index b790113f..ef07d9d0 100644 --- a/test/client/client_test.dart +++ b/test/client/client_test.dart @@ -109,8 +109,10 @@ void main() { expect(transport.sentMessages.length, greaterThan(0)); expect(transport.sentMessages.first is JsonRpcRequest, isTrue); expect( - (transport.sentMessages.first as JsonRpcRequest).method, - equals('initialize'), + transport.sentMessages + .whereType() + .map((message) => message.method), + containsAllInOrder([Method.serverDiscover, Method.initialize]), ); // Verify that an initialized notification was sent @@ -585,8 +587,20 @@ class MockTransport extends Transport { Future send(JsonRpcMessage message, {int? relatedRequestId}) async { sentMessages.add(message); - // If it's an initialize request, respond with the mock response - if (message is JsonRpcRequest && + // Simulate a legacy peer by rejecting discovery, then respond to initialize. + if (message is JsonRpcRequest && message.method == Method.serverDiscover) { + if (onmessage != null) { + onmessage!( + JsonRpcError( + id: message.id, + error: const JsonRpcErrorData( + code: -32601, + message: 'Method not found', + ), + ), + ); + } + } else if (message is JsonRpcRequest && message.method == 'initialize' && mockInitializeResponse != null) { if (onmessage != null) { diff --git a/test/client/client_tool_validation_test.dart b/test/client/client_tool_validation_test.dart index 252263ed..fc77c58b 100644 --- a/test/client/client_tool_validation_test.dart +++ b/test/client/client_tool_validation_test.dart @@ -28,7 +28,18 @@ class MockTransport extends Transport @override Future send(JsonRpcMessage message, {int? relatedRequestId}) async { sentMessages.add(message); - if (message is JsonRpcRequest && message.method == Method.initialize) { + if (message is JsonRpcRequest && message.method == Method.serverDiscover) { + _respond( + JsonRpcError( + id: message.id, + error: const JsonRpcErrorData( + code: -32601, + message: 'Method not found', + ), + ), + ); + } else if (message is JsonRpcRequest && + message.method == Method.initialize) { _respond( JsonRpcResponse( id: message.id, diff --git a/test/client/streamable_https_test.dart b/test/client/streamable_https_test.dart index 8196269a..835e42a2 100644 --- a/test/client/streamable_https_test.dart +++ b/test/client/streamable_https_test.dart @@ -331,8 +331,11 @@ void main() { expect(initializeCount, 1); expect(initializedNotificationCount, 1); - expect(capturedSessionHeaders, isNotEmpty); - expect(capturedSessionHeaders, everyElement(preconfiguredSessionId)); + expect(capturedSessionHeaders, [ + null, + preconfiguredSessionId, + preconfiguredSessionId, + ]); expect(client.getServerCapabilities()?.logging, isNotNull); expect(client.getServerVersion()?.name, 'PreconfiguredSessionServer'); expect( diff --git a/test/elicitation_test.dart b/test/elicitation_test.dart index f13f2fff..75254bd8 100644 --- a/test/elicitation_test.dart +++ b/test/elicitation_test.dart @@ -19,6 +19,21 @@ class MockTransport extends Transport { Future send(JsonRpcMessage message, {int? relatedRequestId}) async { sentMessages.add(message); + // Handle discovery probe from default 2026 clients against this legacy + // mock transport. + if (message is JsonRpcRequest && message.method == Method.serverDiscover) { + onmessage?.call( + JsonRpcError( + id: message.id, + error: JsonRpcErrorData( + code: ErrorCode.methodNotFound.value, + message: 'Method not found', + ), + ), + ); + return; + } + // Handle initialize request if (message is JsonRpcRequest && message.method == 'initialize' && @@ -808,6 +823,7 @@ void main() { 'mode': 'form', 'message': 'Configure deployment', 'requestedSchema': { + r'$schema': 'https://json-schema.org/draft/2020-12/schema', 'type': 'object', 'properties': { 'email': { @@ -816,6 +832,8 @@ void main() { 'title': 'Email', 'description': 'Contact address', 'default': 'ops@example.com', + 'minLength': 3, + 'maxLength': 320, }, 'size': { 'type': 'string', @@ -846,6 +864,9 @@ void main() { }, 'features': { 'type': 'array', + 'minItems': 1, + 'maxItems': 2, + 'default': ['logs'], 'items': { 'type': 'string', 'enum': ['logs', 'metrics'], @@ -869,6 +890,138 @@ void main() { expect(request.toJson()['requestedSchema'], isA>()); }); + test('Form elicitation defaults to stable number schema keywords', () { + Map requestWithProperty( + String name, + Map property, + ) => + { + 'message': 'Configure deployment', + 'requestedSchema': { + 'type': 'object', + 'properties': {name: property}, + }, + }; + + for (final property in >{ + 'fractionalNumberDefault': { + 'type': 'number', + 'default': 0.5, + }, + 'fractionalNumberMinimum': { + 'type': 'number', + 'minimum': 0.1, + }, + 'fractionalNumberMaximum': { + 'type': 'number', + 'maximum': 0.9, + }, + 'fractionalIntegerDefault': { + 'type': 'integer', + 'default': 1.5, + }, + 'fractionalIntegerMinimum': { + 'type': 'integer', + 'minimum': 1.5, + }, + 'fractionalIntegerMaximum': { + 'type': 'integer', + 'maximum': 10.5, + }, + }.entries) { + expect( + () => ElicitRequestParams.fromJson( + requestWithProperty(property.key, property.value), + ), + throwsA(isA()), + ); + } + + expect( + () => ElicitRequestParams.form( + message: 'Configure deployment', + requestedSchema: JsonSchema.object( + properties: { + 'ratio': JsonSchema.number( + minimum: 0.1, + maximum: 0.9, + defaultValue: 0.5, + ), + }, + ), + ).toJson(), + throwsA(isA()), + ); + }); + + test('Draft form elicitation accepts fractional number schema keywords', + () { + final params = { + 'mode': 'form', + 'message': 'Configure deployment', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'ratio': { + 'type': 'number', + 'minimum': 0.1, + 'maximum': 0.9, + 'default': 0.5, + }, + 'count': { + 'type': 'integer', + 'minimum': 0.5, + 'maximum': 10.5, + 'default': 1.5, + }, + }, + }, + }; + + final request = ElicitRequestParams.fromJson( + params, + protocolVersion: draftProtocolVersion2026_07_28, + ); + final draftJson = request.toJson( + protocolVersion: draftProtocolVersion2026_07_28, + ); + final schema = draftJson['requestedSchema'] as Map; + final properties = schema['properties'] as Map; + + expect( + (properties['ratio'] as Map)['default'], + 0.5, + ); + expect( + (properties['count'] as Map)['default'], + 1.5, + ); + expect( + (properties['count'] as Map)['maximum'], + 10.5, + ); + expect( + () => request.toJson(), + throwsA(isA()), + ); + + final rpc = JsonRpcElicitRequest.fromJson({ + 'id': 1, + 'params': { + ...params, + '_meta': { + McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, + }, + }, + }); + final rpcSchema = rpc.params!['requestedSchema'] as Map; + final rpcProperties = rpcSchema['properties'] as Map; + expect( + (rpcProperties['ratio'] as Map)['minimum'], + 0.1, + ); + }); + test('Form elicitation rejects non-spec schema shapes', () { Map requestWithProperty( String name, @@ -917,6 +1070,68 @@ void main() { }), throwsA(isA()), ); + expect( + () => ElicitRequestParams.fromJson({ + 'message': 'Bad schema URI', + 'requestedSchema': { + r'$schema': 2020, + 'type': 'object', + 'properties': { + 'value': {'type': 'string'}, + }, + }, + }), + throwsA(isA()), + ); + for (final property in >{ + 'badStringTitle': { + 'type': 'string', + 'title': 1, + }, + 'badStringDefault': { + 'type': 'string', + 'default': false, + }, + 'badStringMinLength': { + 'type': 'string', + 'minLength': 1.5, + }, + 'badNumberDefault': { + 'type': 'number', + 'default': '0', + }, + 'badIntegerDefault': { + 'type': 'integer', + 'default': 1.5, + }, + 'badBooleanDefault': { + 'type': 'boolean', + 'default': 'false', + }, + 'badArrayDefault': { + 'type': 'array', + 'default': ['ok', 1], + 'items': { + 'type': 'string', + 'enum': ['ok'], + }, + }, + 'badArrayMinItems': { + 'type': 'array', + 'minItems': '1', + 'items': { + 'type': 'string', + 'enum': ['ok'], + }, + }, + }.entries) { + expect( + () => ElicitRequestParams.fromJson( + requestWithProperty(property.key, property.value), + ), + throwsA(isA()), + ); + } expect( () => ElicitRequestParams.fromJson( requestWithProperty('value', { @@ -1033,7 +1248,7 @@ void main() { 'action': 'accept', 'content': { 'text': 'value', - 'count': 3, + 'count': 3.0, 'confirmed': true, 'selections': ['a', 'b'], }, @@ -1060,13 +1275,13 @@ void main() { throwsA(isA()), ); expect( - ElicitResult.fromJson({ + () => ElicitResult.fromJson({ 'action': 'accept', 'content': { 'ratio': 0.5, }, - }).content?['ratio'], - 0.5, + }), + throwsA(isA()), ); expect( () => ElicitResult.fromJson({ @@ -1087,13 +1302,22 @@ void main() { throwsA(isA()), ); expect( - const ElicitResult( + () => const ElicitResult( action: 'accept', content: { 'ratio': 0.5, }, + ).toJson(), + throwsA(isA()), + ); + expect( + const ElicitResult( + action: 'accept', + content: { + 'count': 3.0, + }, ).toJson()['content'], - containsPair('ratio', 0.5), + containsPair('count', 3), ); expect( () => const ElicitResult( diff --git a/test/lifecycle_test.dart b/test/lifecycle_test.dart index 78ff1684..57c66c06 100644 --- a/test/lifecycle_test.dart +++ b/test/lifecycle_test.dart @@ -376,6 +376,7 @@ void main() { final client = Client( const Implementation(name: 'client', version: '1.0.0'), options: const ClientOptions( + useServerDiscover: false, capabilities: ClientCapabilities( sampling: ClientCapabilitiesSampling(), ), @@ -414,6 +415,7 @@ void main() { final transport = LifecycleTransport(); final client = Client( const Implementation(name: 'client', version: '1.0.0'), + options: const ClientOptions(useServerDiscover: false), ); final errors = []; client.onerror = errors.add; @@ -436,6 +438,7 @@ void main() { final client = Client( const Implementation(name: 'client', version: '1.0.0'), options: const ClientOptions( + useServerDiscover: false, capabilities: ClientCapabilities( sampling: ClientCapabilitiesSampling(), ), @@ -484,6 +487,7 @@ void main() { final transport = FailingInitializedSendTransport(); final client = Client( const Implementation(name: 'client', version: '1.0.0'), + options: const ClientOptions(useServerDiscover: false), ); final connectFuture = client.connect(transport); diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 20250b3b..05932c33 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -56,7 +56,8 @@ void main() { }); test('Icon Field Support', () { - final icon = const ImageContent(data: 'base64', mimeType: 'image/png'); + const iconData = 'YmFzZTY0'; + final icon = const ImageContent(data: iconData, mimeType: 'image/png'); final icons = [ const McpIcon( src: 'https://example.com/icon.png', @@ -71,12 +72,12 @@ void main() { icon: icon, icons: icons, ); - expect(tool.icon?.data, 'base64'); + expect(tool.icon?.data, iconData); expect(tool.toJson().containsKey('icon'), isFalse); expect((tool.toJson()['icons'] as List).first['theme'], 'dark'); expect( Tool.fromJson({...tool.toJson(), 'icon': icon.toJson()}).icon?.data, - 'base64', + iconData, ); final resource = Resource( @@ -85,14 +86,14 @@ void main() { icon: icon, icons: icons, ); - expect(resource.icon?.data, 'base64'); + expect(resource.icon?.data, iconData); expect(resource.toJson().containsKey('icon'), isFalse); expect((resource.toJson()['icons'] as List).first['theme'], 'dark'); expect( Resource.fromJson( {...resource.toJson(), 'icon': icon.toJson()}, ).icon?.data, - 'base64', + iconData, ); final prompt = Prompt( @@ -100,12 +101,12 @@ void main() { icon: icon, icons: icons, ); - expect(prompt.icon?.data, 'base64'); + expect(prompt.icon?.data, iconData); expect(prompt.toJson().containsKey('icon'), isFalse); expect((prompt.toJson()['icons'] as List).first['theme'], 'dark'); expect( Prompt.fromJson({...prompt.toJson(), 'icon': icon.toJson()}).icon?.data, - 'base64', + iconData, ); final template = ResourceTemplate( @@ -114,14 +115,14 @@ void main() { icon: icon, icons: icons, ); - expect(template.icon?.data, 'base64'); + expect(template.icon?.data, iconData); expect(template.toJson().containsKey('icon'), isFalse); expect((template.toJson()['icons'] as List).first['theme'], 'dark'); expect( ResourceTemplate.fromJson( {...template.toJson(), 'icon': icon.toJson()}, ).icon?.data, - 'base64', + iconData, ); }); @@ -238,19 +239,23 @@ void main() { message: 'test', url: 'https://example.com/ui', elicitationId: 'ui-123', + task: TaskCreationParams(ttl: 7200), ); expect(params.url, 'https://example.com/ui'); expect(params.elicitationId, 'ui-123'); + expect(params.task?.ttl, 7200); final json = params.toJson(); expect(json['mode'], 'url'); expect(json['url'], 'https://example.com/ui'); expect(json['elicitationId'], 'ui-123'); + expect(json['task'], {'ttl': 7200}); final deserialized = ElicitRequestParams.fromJson(json); expect(deserialized.url, 'https://example.com/ui'); expect(deserialized.elicitationId, 'ui-123'); + expect(deserialized.task?.ttl, 7200); }); test('Elicitation URL must be absolute URI', () { @@ -326,24 +331,24 @@ void main() { action: 'accept', content: { 'text': 'answer', - 'confidence': 0.75, + 'confidence': 75, 'selection': ['a', 'b'], // List }, ); - expect(result.content?['confidence'], 0.75); + expect(result.content?['confidence'], 75); expect(result.content?['selection'], isA()); expect((result.content?['selection'] as List).first, 'a'); final json = result.toJson(); final deserialized = ElicitResult.fromJson(json); - expect(deserialized.content?['confidence'], 0.75); + expect(deserialized.content?['confidence'], 75); expect((deserialized.content?['selection'] as List).last, 'b'); }); test('McpServer Metadata Logic', () { final server = McpServer(const Implementation(name: 'test', version: '1.0')); - final icon = const ImageContent(data: 'data', mimeType: 'image/png'); + final icon = const ImageContent(data: 'ZGF0YQ==', mimeType: 'image/png'); // We can rely on the fact that we updated the code to pass it through. // Let's rely on the previous unit tests for `Tool` serialization, and here just ensure `McpServer` methods don't crash. @@ -739,6 +744,17 @@ void main() { expect(deserialized.ttl, 3600); }); + test('TaskCreationParams accepts whole-number JSON ttl values', () { + final deserialized = TaskCreationParams.fromJson({'ttl': 3600.0}); + expect(deserialized.ttl, 3600); + expect(deserialized.toJson()['ttl'], 3600); + + expect( + () => TaskCreationParams.fromJson({'ttl': 3600.5}), + throwsA(isA()), + ); + }); + test('TaskCreationParams without ttl', () { final params = const TaskCreationParams(); expect(params.ttl, isNull); @@ -1039,6 +1055,22 @@ void main() { expect(json, isNot(contains('pollInterval'))); }); + test('Task accepts whole-number JSON ttl and poll interval values', () { + final task = Task.fromJson({ + 'taskId': 'numeric-task', + 'status': 'working', + 'ttl': 3600.0, + 'pollInterval': 500.0, + 'createdAt': '2025-01-15T10:00:00Z', + 'lastUpdatedAt': '2025-01-15T10:01:00Z', + }); + + expect(task.ttl, 3600); + expect(task.pollInterval, 500); + expect(task.toJson(), containsPair('ttl', 3600)); + expect(task.toJson(), containsPair('pollInterval', 500)); + }); + test('Task rejects missing MCP-required fields', () { expect( () => Task.fromJson({ @@ -1363,6 +1395,21 @@ void main() { ); expect(request.toJson()['requestedSchema']['type'], 'object'); + expect( + () => ElicitRequest.form( + message: 'Fractional bounds', + requestedSchema: JsonSchema.object( + properties: { + 'ratio': JsonSchema.number( + minimum: 0.1, + maximum: 0.9, + defaultValue: 0.5, + ), + }, + ), + ).toJson(), + throwsA(isA()), + ); expect( () => const ElicitRequest.form( message: 'Nested', @@ -1390,6 +1437,24 @@ void main() { ).toJson(), throwsA(isA()), ); + expect( + () => ElicitResult.fromJson({ + 'action': 'accept', + 'content': { + 'fractional': 1.5, + }, + }), + throwsA(isA()), + ); + expect( + () => const ElicitResult( + action: 'accept', + content: { + 'fractional': 1.5, + }, + ).toJson(), + throwsA(isA()), + ); expect( () => URLElicitationRequiredErrorData.fromJson({ 'elicitations': [ @@ -1417,6 +1482,16 @@ void main() { () => Annotations.fromJson({'priority': double.infinity}), throwsA(isA()), ); + expect( + () => Annotations.fromJson({ + 'audience': ['model'], + }), + throwsA(isA()), + ); + expect( + () => Annotations.fromJson({'lastModified': 1}), + throwsA(isA()), + ); expect( () => CompletionResultData( values: List.generate(101, (index) => '$index'), @@ -1429,10 +1504,27 @@ void main() { }), throwsA(isA()), ); + final completion = CompletionResultData.fromJson({ + 'values': ['a'], + 'total': 10.0, + }); + expect(completion.total, 10); + expect(completion.toJson()['total'], 10); + expect( + () => CompletionResultData.fromJson({ + 'values': ['a'], + 'total': 10.5, + }), + throwsA(isA()), + ); expect( () => Root(uri: 'https://example.com'), throwsA(isA()), ); + expect( + () => Root.fromJson({'uri': 'relative/path'}), + throwsA(isA()), + ); expect( () => ModelPreferences(costPriority: 2).toJson(), throwsA(anyOf(isA(), isA())), @@ -1441,6 +1533,64 @@ void main() { () => ModelPreferences.fromJson({'costPriority': -1}), throwsA(isA()), ); + expect( + () => SamplingMessage.fromJson({ + 'role': 'system', + 'content': {'type': 'text', 'text': 'Hello'}, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageResult.fromJson({ + 'role': 'system', + 'content': {'type': 'text', 'text': 'Hello'}, + 'model': 'model', + }), + throwsA(isA()), + ); + expect( + () => PromptMessage.fromJson({ + 'role': 'system', + 'content': {'type': 'text', 'text': 'Hello'}, + }), + throwsA(isA()), + ); + expect( + () => SetLevelRequestParams.fromJson({'level': 'verbose'}), + throwsA(isA()), + ); + expect( + () => LoggingMessageNotificationParams.fromJson({ + 'level': 'verbose', + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 100, + 'includeContext': 'nearbyServers', + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 100, + 'toolChoice': {'mode': 'sometimes'}, + }), + throwsA(isA()), + ); }); test('bare task containers strip task metadata', () { diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index ac69cfe4..ac056d59 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -130,9 +130,11 @@ class DiscoveringClientTransport extends Transport class LegacyFallbackTransport extends Transport implements ProtocolVersionAwareTransport { LegacyFallbackTransport({ + this.discoveryError, this.toolsListResult = const {'tools': []}, }); + final McpError? discoveryError; final Map toolsListResult; final List sentMessages = []; @@ -152,6 +154,10 @@ class LegacyFallbackTransport extends Transport sentMessages.add(message); if (message is JsonRpcRequest && message.method == Method.serverDiscover) { + final error = discoveryError; + if (error != null) { + throw error; + } onmessage?.call( JsonRpcError( id: message.id, @@ -387,6 +393,44 @@ void main() { ); }); + test('allows fractional elicitation number schema keywords', () { + final request = ElicitRequestParams.form( + message: 'Configure ratio', + requestedSchema: JsonSchema.object( + properties: { + 'ratio': JsonSchema.number( + minimum: 0.1, + maximum: 0.9, + defaultValue: 0.5, + ), + 'count': JsonSchema.integer( + minimum: 0.5, + maximum: 10.5, + defaultValue: 1.5, + ), + }, + ), + ); + + final json = request.toJson( + protocolVersion: draftProtocolVersion2026_07_28, + ); + final schema = json['requestedSchema'] as Map; + final properties = schema['properties'] as Map; + expect((properties['ratio'] as Map)['minimum'], 0.1); + expect((properties['count'] as Map)['default'], 1.5); + expect((properties['count'] as Map)['maximum'], 10.5); + + final inputRequest = InputRequest.elicit(request); + final inputSchema = + inputRequest.params!['requestedSchema'] as Map; + final inputProperties = inputSchema['properties'] as Map; + expect( + (inputProperties['ratio'] as Map)['default'], + 0.5, + ); + }); + test('rejects non-finite JSON numbers', () { expect( () => ProgressNotification.fromJson({ @@ -422,6 +466,13 @@ void main() { }), throwsA(isA()), ); + expect( + () => ElicitResult.fromJson({ + 'action': 'accept', + 'content': {'score': 1.5}, + }), + throwsA(isA()), + ); expect( () => const ElicitResult( action: 'accept', @@ -429,6 +480,13 @@ void main() { ).toJson(), throwsA(isA()), ); + expect( + () => const ElicitResult( + action: 'accept', + content: {'score': 1.5}, + ).toJson(), + throwsA(isA()), + ); }); test('rejects non-JSON sampling object values', () { @@ -567,6 +625,43 @@ void main() { ); }); + test('requires complete resultType on server/discover results', () { + final validResult = const DiscoverResult( + supportedVersions: [draftProtocolVersion2026_07_28], + capabilities: ServerCapabilities(), + serverInfo: Implementation(name: 'server', version: '1.0.0'), + ).toJson(); + + for (final json in [ + { + ...validResult, + }..remove('resultType'), + { + ...validResult, + 'resultType': resultTypeInputRequired, + }, + { + ...validResult, + 'resultType': 1, + }, + ]) { + expect( + () => DiscoverResult.fromJson(json), + throwsFormatException, + ); + } + + expect( + () => const DiscoverResult( + resultType: resultTypeInputRequired, + supportedVersions: [draftProtocolVersion2026_07_28], + capabilities: ServerCapabilities(), + serverInfo: Implementation(name: 'server', version: '1.0.0'), + ).toJson(), + throwsArgumentError, + ); + }); + test('requires server/discover request metadata in params', () { expect( () => JsonRpcServerDiscoverRequest(id: 'discover-1').toJson(), @@ -719,6 +814,7 @@ void main() { properties: {'name': JsonSchema.string()}, required: ['name'], ), + task: const TaskCreation(ttl: 1000), ), ), 'capital_of_france': InputRequest.createMessage( @@ -731,6 +827,7 @@ void main() { ), ), ], + task: TaskCreation(ttl: 1000), maxTokens: 100, ), ), @@ -748,10 +845,18 @@ void main() { json['inputRequests']['github_login']['method'], Method.elicitationCreate, ); + expect( + json['inputRequests']['github_login']['params'], + isNot(contains('task')), + ); expect( json['inputRequests']['capital_of_france']['method'], Method.samplingCreateMessage, ); + expect( + json['inputRequests']['capital_of_france']['params'], + isNot(contains('task')), + ); expect(json['inputRequests']['roots'], {'method': Method.rootsList}); final parsed = InputRequiredResult.fromJson(json); @@ -760,11 +865,19 @@ void main() { parsed.inputRequests!['github_login']!.elicitParams.message, 'Please provide your GitHub username', ); + expect( + parsed.inputRequests!['github_login']!.elicitParams.task, + isNull, + ); expect( parsed .inputRequests!['capital_of_france']!.createMessageParams.maxTokens, 100, ); + expect( + parsed.inputRequests!['capital_of_france']!.createMessageParams.task, + isNull, + ); }); test('serializes MRTR retry fields on supported client requests', () { @@ -897,6 +1010,56 @@ void main() { ), throwsFormatException, ); + expect( + () => InputRequiredResult.fromJson( + const { + 'resultType': resultTypeInputRequired, + 'inputRequests': { + 'legacy_task_elicit': { + 'method': Method.elicitationCreate, + 'params': { + 'mode': 'form', + 'message': 'Need username', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + }, + }, + 'task': {'ttl': 1000}, + }, + }, + }, + }, + ), + throwsFormatException, + ); + expect( + () => InputRequiredResult.fromJson( + const { + 'resultType': resultTypeInputRequired, + 'inputRequests': { + 'legacy_task_sampling': { + 'method': Method.samplingCreateMessage, + 'params': { + 'messages': [ + { + 'role': 'user', + 'content': { + 'type': 'text', + 'text': 'Continue?', + }, + }, + ], + 'maxTokens': 1, + 'task': {'ttl': 1000}, + }, + }, + }, + }, + ), + throwsFormatException, + ); expect( () => CallToolRequest.fromJson( const {'name': 'deploy', 'requestState': 1}, @@ -1541,6 +1704,18 @@ void main() { resultParams: const TaskResultRequest(taskId: 'task-1'), meta: taskExtensionMeta, ), + ) + ..receive( + JsonRpcTaskStatusNotification( + statusParams: const TaskStatusNotification( + taskId: 'task-1', + status: TaskStatus.working, + ttl: null, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:00Z', + ), + meta: taskExtensionMeta, + ), ); await _pump(); @@ -2485,12 +2660,11 @@ void main() { expect(transport.sentMessages.single, isA()); }); - test('client can opt in to server/discover and sends stateless metadata', + test('client defaults to server/discover and sends stateless metadata', () async { final transport = DiscoveringClientTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), ); await client.connect(transport); @@ -2517,6 +2691,62 @@ void main() { expect(listRequest.meta?[McpMetaKey.clientCapabilities], {}); }); + test('client can opt out of discovery for legacy initialization', () async { + final transport = LegacyFallbackTransport(); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: false), + ); + + await client.connect(transport); + + expect(client.getProtocolVersion(), stableProtocolVersion2025_11_25); + expect(transport.protocolVersion, stableProtocolVersion2025_11_25); + expect( + transport.sentMessages + .whereType() + .map((message) => message.method), + isNot(contains(Method.serverDiscover)), + ); + expect( + transport.sentMessages + .whereType() + .map((message) => message.method), + contains(Method.initialize), + ); + }); + + test('client falls back when legacy HTTP rejects discovery before init', + () async { + final errors = [ + McpError( + 0, + 'Error POSTing to endpoint (HTTP 400): ' + '{"jsonrpc":"2.0","error":{"code":-32000,' + '"message":"Bad Request: Server not initialized"},"id":null}', + ), + McpError(0, 'Error POSTing to endpoint (HTTP 400): '), + ]; + + for (final error in errors) { + final transport = LegacyFallbackTransport(discoveryError: error); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + + await client.connect(transport); + + expect(client.getProtocolVersion(), stableProtocolVersion2025_11_25); + expect(transport.protocolVersion, stableProtocolVersion2025_11_25); + expect( + transport.sentMessages + .whereType() + .map((message) => message.method), + [Method.serverDiscover, Method.initialize], + ); + } + }); + test('stateless client rejects removed request methods before send', () async { final transport = DiscoveringClientTransport(); @@ -2623,6 +2853,20 @@ void main() { method: Method.notificationsRootsListChanged, call: client.sendRootsListChanged, ), + ( + method: Method.notificationsTasksStatus, + call: () => client.notification( + JsonRpcTaskStatusNotification( + statusParams: const TaskStatusNotification( + taskId: 'task-1', + status: TaskStatus.working, + ttl: null, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:00Z', + ), + ), + ), + ), ]; for (final scenario in removedNotifications) { @@ -3444,7 +3688,6 @@ void main() { final transport = LegacyFallbackTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), ); await client.connect(transport); diff --git a/test/server/server_test.dart b/test/server/server_test.dart index 7b5e6dda..7c1585c4 100644 --- a/test/server/server_test.dart +++ b/test/server/server_test.dart @@ -507,7 +507,7 @@ void main() { // Send resource updated notification final resourceParams = const ResourceUpdatedNotification( - uri: 'test-resource', + uri: 'file:///test-resource', ); await resourceServer.sendResourceUpdated(resourceParams); @@ -542,7 +542,7 @@ void main() { ); final resourceParams = const ResourceUpdatedNotification( - uri: 'test-resource', + uri: 'file:///test-resource', ); expect( () => plainServer.sendResourceUpdated(resourceParams), diff --git a/test/server/streamable_https_test.dart b/test/server/streamable_https_test.dart index 8c288071..6038152b 100644 --- a/test/server/streamable_https_test.dart +++ b/test/server/streamable_https_test.dart @@ -2730,6 +2730,18 @@ void main() { contains('no matching request _meta protocol version'), ); + final topLevelMetaOnly = const JsonRpcListToolsRequest(id: 20).toJson() + ..['_meta'] = _statelessMeta(); + body = await postJson( + topLevelMetaOnly, + headers: { + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.toolsList, + }, + ); + expect(body['id'], 20); + expect(body['error']['message'], contains('params._meta')); + body = await postJson( JsonRpcListToolsRequest(id: 5, meta: _statelessMeta()).toJson(), headers: { diff --git a/test/server/streamable_mcp_server_test.dart b/test/server/streamable_mcp_server_test.dart index 3998bfcd..3bba902e 100644 --- a/test/server/streamable_mcp_server_test.dart +++ b/test/server/streamable_mcp_server_test.dart @@ -428,6 +428,30 @@ void main() { ); }); + test('keeps top-level metadata as stateless detection fallback', () async { + final response = await http.post( + Uri.parse(baseUrl), + body: jsonEncode( + const JsonRpcListToolsRequest(id: 12).toJson() + ..['_meta'] = const { + McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, + }, + ), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'Mcp-Method': Method.toolsList, + }, + ); + + expect(response.statusCode, HttpStatus.badRequest); + final body = jsonDecode(response.body) as Map; + expect( + body['error']['message'], + contains('MCP-Protocol-Version header is required'), + ); + }); + test('routes 2026 stateless non-POST methods without a session ID', () async { final client = HttpClient(); diff --git a/test/shared/json_schema_from_json_test.dart b/test/shared/json_schema_from_json_test.dart index efccbf09..c19165e3 100644 --- a/test/shared/json_schema_from_json_test.dart +++ b/test/shared/json_schema_from_json_test.dart @@ -22,6 +22,32 @@ void main() { expect(s.enumValues, ['a', 'b']); }); + test('accepts whole-number numeric string schema bounds', () { + final schema = JsonSchema.fromJson({ + 'type': 'string', + 'minLength': 5.0, + 'maxLength': 10.0, + }); + + expect(schema, isA()); + final stringSchema = schema as JsonString; + expect(stringSchema.minLength, 5); + expect(stringSchema.maxLength, 10); + expect(stringSchema.toJson(), { + 'minLength': 5, + 'maxLength': 10, + 'type': 'string', + }); + + expect( + () => JsonSchema.fromJson({ + 'type': 'string', + 'minLength': 1.5, + }), + throwsA(isA()), + ); + }); + test('preserves mixed typed enum schemas conjunctively', () { final json = { 'type': 'string', @@ -146,20 +172,23 @@ void main() { test('parses integer schema', () { final json = { 'type': 'integer', - 'minimum': 1, - 'maximum': 10, - 'exclusiveMinimum': 0, - 'exclusiveMaximum': 11, - 'multipleOf': 2, + 'minimum': 1.5, + 'maximum': 10.5, + 'exclusiveMinimum': 0.5, + 'exclusiveMaximum': 11.5, + 'multipleOf': 0.5, + 'default': 2.0, }; final schema = JsonSchema.fromJson(json); expect(schema, isA()); final s = schema as JsonInteger; - expect(s.minimum, 1); - expect(s.maximum, 10); - expect(s.exclusiveMinimum, 0); - expect(s.exclusiveMaximum, 11); - expect(s.multipleOf, 2); + expect(s.minimum, 1.5); + expect(s.maximum, 10.5); + expect(s.exclusiveMinimum, 0.5); + expect(s.exclusiveMaximum, 11.5); + expect(s.multipleOf, 0.5); + expect(s.defaultValue, 2.0); + expect(s.toJson(), json); }); test('parses boolean schema', () { @@ -191,6 +220,32 @@ void main() { expect(s.uniqueItems, true); }); + test('accepts whole-number numeric array schema bounds', () { + final schema = JsonSchema.fromJson({ + 'type': 'array', + 'minItems': 1.0, + 'maxItems': 5.0, + }); + + expect(schema, isA()); + final arraySchema = schema as JsonArray; + expect(arraySchema.minItems, 1); + expect(arraySchema.maxItems, 5); + expect(arraySchema.toJson(), { + 'minItems': 1, + 'maxItems': 5, + 'type': 'array', + }); + + expect( + () => JsonSchema.fromJson({ + 'type': 'array', + 'minItems': 1.5, + }), + throwsA(isA()), + ); + }); + test('parses object schema', () { final json = { 'type': 'object', diff --git a/test/shared/json_schema_validator_test.dart b/test/shared/json_schema_validator_test.dart index 23dcbbf8..22cc927f 100644 --- a/test/shared/json_schema_validator_test.dart +++ b/test/shared/json_schema_validator_test.dart @@ -160,7 +160,7 @@ void main() { }); test('validates exclusiveMinimum', () { - final schema = JsonSchema.integer(exclusiveMinimum: 5); + final schema = JsonSchema.integer(exclusiveMinimum: 5.5); schema.validate(6); expect( () => schema.validate(5), @@ -169,19 +169,21 @@ void main() { }); test('validates exclusiveMaximum', () { - final schema = JsonSchema.integer(exclusiveMaximum: 10); + final schema = JsonSchema.integer(exclusiveMaximum: 10.5); schema.validate(9); + schema.validate(10); expect( - () => schema.validate(10), + () => schema.validate(11), throwsA(isA()), ); }); test('validates multipleOf', () { - final schema = JsonSchema.integer(multipleOf: 3); - schema.validate(9); + final schema = JsonSchema.integer(multipleOf: 1.5); + schema.validate(3); + schema.validate(6); expect( - () => schema.validate(10), + () => schema.validate(4), throwsA(isA()), ); }); diff --git a/test/shared/protocol_test.dart b/test/shared/protocol_test.dart index d65d86b5..3cd2cf9d 100644 --- a/test/shared/protocol_test.dart +++ b/test/shared/protocol_test.dart @@ -751,6 +751,43 @@ void main() { expect((await requestFuture).task.taskId, 'shape-task'); }); + test('handler extra accepts whole-number JSON task ttl values', () async { + await protocol.connect(transport); + + final observedTtl = Completer(); + protocol.setRequestHandler( + 'test/task-ttl', + (request, extra) async { + observedTtl.complete(extra.taskRequestedTtl); + return TestResult(value: 'ok'); + }, + (id, params, meta) => JsonRpcRequest( + id: id, + method: 'test/task-ttl', + params: params, + meta: meta, + ), + ); + + transport.receiveMessage( + const JsonRpcRequest( + id: 99, + method: 'test/task-ttl', + params: { + 'task': {'ttl': 1234.0}, + }, + ), + ); + + await waitForSentMessages(transport, 1); + expect( + await observedTtl.future.timeout(const Duration(seconds: 5)), + 1234, + ); + final response = transport.sentMessages.single as JsonRpcResponse; + expect(response.result, {'value': 'ok'}); + }); + test('progress notifications reset timeout for custom tokens', () async { await protocol.connect(transport); diff --git a/test/shared/uri_template_test.dart b/test/shared/uri_template_test.dart index 2589f47d..c2c0bffa 100644 --- a/test/shared/uri_template_test.dart +++ b/test/shared/uri_template_test.dart @@ -295,6 +295,49 @@ void main() { ); }); + test('throws on unmatched closing expression', () { + expect( + () => UriTemplateExpander('/path/}'), + throwsA( + isA() + .having((e) => e.message, 'message', contains('Unmatched')), + ), + ); + }); + + test('throws on invalid variable specs', () { + expect( + () => UriTemplateExpander('/path/{valid,}'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid variable name'), + ), + ), + ); + expect( + () => UriTemplateExpander('/path/{invalid-name}'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid variable name'), + ), + ), + ); + expect( + () => UriTemplateExpander('/path/{var:0}'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid prefix modifier'), + ), + ), + ); + }); + test('throws on template too long', () { final longTemplate = 'a' * (maxTemplateLength + 1); expect( diff --git a/test/types/logging_types_test.dart b/test/types/logging_types_test.dart index d1a02f01..d4200bae 100644 --- a/test/types/logging_types_test.dart +++ b/test/types/logging_types_test.dart @@ -48,6 +48,17 @@ void main() { expect(params.level, equals(level)); } }); + + test('rejects malformed logging levels', () { + expect( + () => SetLevelRequestParams.fromJson({'level': 'verbose'}), + throwsA(isA()), + ); + expect( + () => SetLevelRequestParams.fromJson({'level': 1}), + throwsA(isA()), + ); + }); }); group('JsonRpcSetLevelRequest', () { @@ -187,6 +198,23 @@ void main() { expect(restored.logger, equals(original.logger)); expect(restored.data, equals(original.data)); }); + + test('rejects malformed logging levels', () { + expect( + () => LoggingMessageNotificationParams.fromJson({ + 'level': 'verbose', + 'data': 'message', + }), + throwsA(isA()), + ); + expect( + () => LoggingMessageNotificationParams.fromJson({ + 'level': 1, + 'data': 'message', + }), + throwsA(isA()), + ); + }); }); group('JsonRpcLoggingMessageNotification', () { diff --git a/test/types/resources_test.dart b/test/types/resources_test.dart index 64a7df5d..3c137c0e 100644 --- a/test/types/resources_test.dart +++ b/test/types/resources_test.dart @@ -50,6 +50,31 @@ void main() { expect(json.containsKey('priority'), isFalse); expect(json.containsKey('lastModified'), isFalse); }); + + test('validates shared annotation fields', () { + expect( + () => ResourceAnnotations.fromJson({ + 'audience': ['model'], + }), + throwsA(isA()), + ); + expect( + () => ResourceAnnotations.fromJson({ + 'audience': 'user', + }), + throwsA(isA()), + ); + expect( + () => ResourceAnnotations.fromJson({ + 'lastModified': 1, + }), + throwsA(isA()), + ); + expect( + () => const ResourceAnnotations(audience: ['model']).toJson(), + throwsA(isA()), + ); + }); }); group('Resource', () { @@ -76,7 +101,7 @@ void main() { 'mimeType': 'text/plain', 'icon': { 'type': 'image', - 'data': 'base64data', + 'data': 'YmFzZTY0ZGF0YQ==', 'mimeType': 'image/png', }, 'annotations': { @@ -98,7 +123,7 @@ void main() { expect(resource.description, equals('A test file resource')); expect(resource.mimeType, equals('text/plain')); expect(resource.icon, isNotNull); - expect(resource.icon!.data, equals('base64data')); + expect(resource.icon!.data, equals('YmFzZTY0ZGF0YQ==')); expect(resource.icons, isNotNull); expect( resource.icons!.single.src, @@ -225,7 +250,7 @@ void main() { 'mimeType': 'application/json', 'icon': { 'type': 'image', - 'data': 'icondata', + 'data': 'aWNvbmRhdGE=', 'mimeType': 'image/svg+xml', }, 'annotations': { @@ -829,4 +854,106 @@ void main() { expect(notification.meta!['key'], equals('value')); }); }); + + group('Resource URI format validation', () { + test('rejects non-absolute resource URIs from wire JSON', () { + expect( + () => Resource.fromJson({'uri': 'relative/path', 'name': 'Relative'}), + throwsA(isA()), + ); + expect( + () => ResourceContents.fromJson({'uri': 'relative/path'}), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'relative/path', + 'name': 'Relative', + }), + throwsA(isA()), + ); + expect( + () => ReadResourceRequest.fromJson({'uri': 'relative/path'}), + throwsA(isA()), + ); + expect( + () => SubscribeRequest.fromJson({'uri': 'relative/path'}), + throwsA(isA()), + ); + expect( + () => UnsubscribeRequest.fromJson({'uri': 'relative/path'}), + throwsA(isA()), + ); + expect( + () => ResourceUpdatedNotification.fromJson({'uri': 'relative/path'}), + throwsA(isA()), + ); + }); + + test('rejects non-absolute resource URIs during serialization', () { + expect( + () => const Resource(uri: 'relative/path', name: 'Relative').toJson(), + throwsA(isA()), + ); + expect( + () => const TextResourceContents( + uri: 'relative/path', + text: 'Relative', + ).toJson(), + throwsA(isA()), + ); + expect( + () => const ResourceLink( + uri: 'relative/path', + name: 'Relative', + ).toJson(), + throwsA(isA()), + ); + expect( + () => const ReadResourceRequest(uri: 'relative/path').toJson(), + throwsA(isA()), + ); + expect( + () => const SubscribeRequest(uri: 'relative/path').toJson(), + throwsA(isA()), + ); + expect( + () => const UnsubscribeRequest(uri: 'relative/path').toJson(), + throwsA(isA()), + ); + expect( + () => const ResourceUpdatedNotification(uri: 'relative/path').toJson(), + throwsA(isA()), + ); + }); + + test('rejects malformed resource URI templates', () { + expect( + () => ResourceTemplate.fromJson({ + 'uriTemplate': 'file:///{path', + 'name': 'Bad Template', + }), + throwsA(isA()), + ); + expect( + () => const ResourceTemplate( + uriTemplate: 'file:///{path', + name: 'Bad Template', + ).toJson(), + throwsA(isA()), + ); + expect( + () => ResourceReference.fromJson({ + 'type': 'ref/resource', + 'uri': 'file:///{path', + }), + throwsA(isA()), + ); + expect( + () => const ResourceReference(uri: 'file:///{path').toJson(), + throwsA(isA()), + ); + }); + }); } diff --git a/test/types/sampling_test.dart b/test/types/sampling_test.dart index a0e808c5..f0adf2b5 100644 --- a/test/types/sampling_test.dart +++ b/test/types/sampling_test.dart @@ -107,76 +107,157 @@ void main() { }); test('fromJson parses correctly', () { - final json = {'type': 'text', 'text': 'Parsed text'}; + final json = { + 'type': 'text', + 'text': 'Parsed text', + 'annotations': { + 'audience': ['user'], + 'vendor': {'hint': true}, + }, + }; final content = SamplingContent.fromJson(json); expect(content, isA()); - expect((content as SamplingTextContent).text, equals('Parsed text')); + final text = content as SamplingTextContent; + expect(text.text, equals('Parsed text')); + expect(text.annotations?['vendor'], equals({'hint': true})); + }); + + test('validates shared annotation fields', () { + expect( + () => SamplingContent.fromJson({ + 'type': 'text', + 'text': 'Parsed text', + 'annotations': { + 'audience': ['model'], + }, + }), + throwsA(isA()), + ); + expect( + () => const SamplingTextContent( + text: 'Parsed text', + annotations: { + 'priority': 2, + }, + ).toJson(), + throwsA(isA()), + ); }); }); group('SamplingImageContent', () { test('constructs correctly', () { + const imageData = 'YmFzZTY0ZGF0YQ=='; const content = - SamplingImageContent(data: 'base64data', mimeType: 'image/png'); - expect(content.data, equals('base64data')); + SamplingImageContent(data: imageData, mimeType: 'image/png'); + expect(content.data, equals(imageData)); expect(content.mimeType, equals('image/png')); }); test('toJson serializes correctly', () { + const imageData = 'aW1nZGF0YQ=='; const content = - SamplingImageContent(data: 'imgdata', mimeType: 'image/jpeg'); + SamplingImageContent(data: imageData, mimeType: 'image/jpeg'); final json = content.toJson(); expect(json['type'], equals('image')); - expect(json['data'], equals('imgdata')); + expect(json['data'], equals(imageData)); expect(json['mimeType'], equals('image/jpeg')); }); test('fromJson parses correctly', () { + const imageData = 'ZW5jb2RlZA=='; final json = { 'type': 'image', - 'data': 'encoded', + 'data': imageData, 'mimeType': 'image/gif', + 'annotations': { + 'audience': ['assistant'], + }, }; final content = SamplingContent.fromJson(json); expect(content, isA()); final img = content as SamplingImageContent; - expect(img.data, equals('encoded')); + expect(img.data, equals(imageData)); expect(img.mimeType, equals('image/gif')); + expect(img.annotations?['audience'], equals(['assistant'])); + }); + + test('validates base64 byte data', () { + expect( + () => SamplingContent.fromJson({ + 'type': 'image', + 'data': 'not base64!', + 'mimeType': 'image/png', + }), + throwsA(isA()), + ); + expect( + () => const SamplingImageContent( + data: 'not base64!', + mimeType: 'image/png', + ).toJson(), + throwsA(isA()), + ); }); }); group('SamplingAudioContent', () { test('constructs correctly', () { + const audioData = 'YmFzZTY0YXVkaW8='; const content = SamplingAudioContent( - data: 'base64audio', + data: audioData, mimeType: 'audio/wav', ); - expect(content.data, equals('base64audio')); + expect(content.data, equals(audioData)); expect(content.mimeType, equals('audio/wav')); }); test('toJson serializes correctly', () { + const audioData = 'YXVkaW8tZGF0YQ=='; const content = SamplingAudioContent( - data: 'audio-data', + data: audioData, mimeType: 'audio/mpeg', ); final json = content.toJson(); expect(json['type'], equals('audio')); - expect(json['data'], equals('audio-data')); + expect(json['data'], equals(audioData)); expect(json['mimeType'], equals('audio/mpeg')); }); test('fromJson parses correctly', () { + const audioData = 'ZW5jb2RlZC1hdWRpbw=='; final json = { 'type': 'audio', - 'data': 'encoded-audio', + 'data': audioData, 'mimeType': 'audio/ogg', + 'annotations': { + 'priority': 0.2, + }, }; final content = SamplingContent.fromJson(json); expect(content, isA()); final audio = content as SamplingAudioContent; - expect(audio.data, equals('encoded-audio')); + expect(audio.data, equals(audioData)); expect(audio.mimeType, equals('audio/ogg')); + expect(audio.annotations?['priority'], equals(0.2)); + }); + + test('validates base64 byte data', () { + expect( + () => SamplingContent.fromJson({ + 'type': 'audio', + 'data': 'not base64!', + 'mimeType': 'audio/wav', + }), + throwsA(isA()), + ); + expect( + () => const SamplingAudioContent( + data: 'not base64!', + mimeType: 'audio/wav', + ).toJson(), + throwsA(isA()), + ); }); }); @@ -374,6 +455,23 @@ void main() { expect(msg.content, isA()); }); + test('validates role wire values', () { + expect( + () => SamplingMessage.fromJson({ + 'role': 'system', + 'content': {'type': 'text', 'text': 'Question'}, + }), + throwsA(isA()), + ); + expect( + () => SamplingMessage.fromJson({ + 'role': 1, + 'content': {'type': 'text', 'text': 'Question'}, + }), + throwsA(isA()), + ); + }); + test('supports array content with normalized contentBlocks', () { final msg = const SamplingMessage( role: SamplingMessageRole.assistant, @@ -494,6 +592,78 @@ void main() { expect(params.includeContext, equals(IncludeContext.allServers)); }); + test('validates enum wire fields', () { + final messages = [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ]; + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'includeContext': 'nearbyServers', + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'includeContext': 1, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'toolChoice': {'mode': 'sometimes'}, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'toolChoice': {'mode': 1}, + }), + throwsA(isA()), + ); + }); + + test('accepts whole-number JSON maxTokens values', () { + final messages = [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ]; + + final params = CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100.0, + }); + + expect(params.maxTokens, 100); + expect(params.toJson()['maxTokens'], 100); + + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100.5, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + }), + throwsA(isA()), + ); + }); + test('rejects non-finite temperature values', () { final messages = [ { @@ -620,6 +790,25 @@ void main() { expect(result.stopReason, equals('customReason')); }); + test('validates role wire values', () { + expect( + () => CreateMessageResult.fromJson({ + 'role': 'system', + 'content': {'type': 'text', 'text': 'Msg'}, + 'model': 'model-x', + }), + throwsA(isA()), + ); + expect( + () => CreateMessageResult.fromJson({ + 'role': 1, + 'content': {'type': 'text', 'text': 'Msg'}, + 'model': 'model-x', + }), + throwsA(isA()), + ); + }); + test('rejects non-JSON metadata objects', () { expect( () => CreateMessageResult.fromJson({ diff --git a/test/types_test.dart b/test/types_test.dart index 250258ee..c2053ffa 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -482,35 +482,236 @@ void main() { }); test('ImageContent serialization and deserialization', () { + const imageData = 'YmFzZTY0ZGF0YQ=='; final content = - const ImageContent(data: 'base64data', mimeType: 'image/png'); + const ImageContent(data: imageData, mimeType: 'image/png'); final json = content.toJson(); expect(json['type'], equals('image')); - expect(json['data'], equals('base64data')); + expect(json['data'], equals(imageData)); expect(json['mimeType'], equals('image/png')); final deserialized = ImageContent.fromJson(json); - expect(deserialized.data, equals('base64data')); + expect(deserialized.data, equals(imageData)); expect(deserialized.mimeType, equals('image/png')); }); - test('ImageContent supports optional theme', () { + test('ImageContent parses legacy theme without serializing it', () { final content = const ImageContent( - data: 'base64data', + data: 'YmFzZTY0ZGF0YQ==', mimeType: 'image/png', theme: 'dark', ); final json = content.toJson(); - expect(json['theme'], equals('dark')); + expect(json, isNot(contains('theme'))); - final deserialized = ImageContent.fromJson(json); + final deserialized = ImageContent.fromJson({ + ...json, + 'theme': 'dark', + }); expect(deserialized.theme, equals('dark')); }); + test('ImageContent validates base64 byte data', () { + expect( + () => ImageContent.fromJson({ + 'type': 'image', + 'data': 'not base64!', + 'mimeType': 'image/png', + }), + throwsA(isA()), + ); + expect( + () => ImageContent.fromJson({ + 'type': 'image', + 'data': 'a-b_', + 'mimeType': 'image/png', + }), + throwsA(isA()), + ); + expect( + () => const ImageContent( + data: 'not base64!', + mimeType: 'image/png', + ).toJson(), + throwsA(isA()), + ); + }); + + test('McpIcon parses stable wire fields', () { + final icon = McpIcon.fromJson({ + 'src': 'https://example.com/icon.png', + 'mimeType': 'image/png', + 'sizes': ['48x48', 'any'], + 'theme': 'dark', + }); + + expect(icon.src, equals('https://example.com/icon.png')); + expect(icon.mimeType, equals('image/png')); + expect(icon.sizes, equals(['48x48', 'any'])); + expect(icon.theme, equals(IconTheme.dark)); + expect(icon.toJson(), { + 'src': 'https://example.com/icon.png', + 'mimeType': 'image/png', + 'sizes': ['48x48', 'any'], + 'theme': 'dark', + }); + + final dataIcon = McpIcon.fromJson({ + 'src': 'data:image/png;base64,aWNvbg==', + }); + expect(dataIcon.src, equals('data:image/png;base64,aWNvbg==')); + }); + + test('McpIcon rejects malformed stable wire fields', () { + void expectInvalid( + Map json, + ) { + expect(() => McpIcon.fromJson(json), throwsA(isA())); + } + + expectInvalid({}); + expectInvalid({'src': 1}); + expectInvalid({'src': 'icon.png'}); + expectInvalid({'src': '://not-a-uri'}); + expectInvalid({'src': 'https://example.com/icon.png', 'mimeType': null}); + expectInvalid({'src': 'https://example.com/icon.png', 'mimeType': 1}); + expectInvalid({'src': 'https://example.com/icon.png', 'sizes': null}); + expectInvalid({'src': 'https://example.com/icon.png', 'sizes': '48x48'}); + expectInvalid({ + 'src': 'https://example.com/icon.png', + 'sizes': ['48x48', 1], + }); + expectInvalid({'src': 'https://example.com/icon.png', 'theme': null}); + expectInvalid({'src': 'https://example.com/icon.png', 'theme': 1}); + expectInvalid({'src': 'https://example.com/icon.png', 'theme': 'sepia'}); + }); + + test('McpIcon validates src URI during serialization', () { + expect( + () => const McpIcon(src: 'icon.png').toJson(), + throwsA(isA()), + ); + expect( + const McpIcon(src: 'data:image/png;base64,aWNvbg==').toJson()['src'], + equals('data:image/png;base64,aWNvbg=='), + ); + }); + + test('Implementation icon parsing rejects invalid themes', () { + expect( + () => Implementation.fromJson({ + 'name': 'test-client', + 'version': '1.0.0', + 'icons': [ + { + 'src': 'https://example.com/icon.png', + 'theme': 'sepia', + }, + ], + }), + throwsA(isA()), + ); + }); + + test('Implementation parses stable wire fields', () { + final implementation = Implementation.fromJson({ + 'name': 'test-client', + 'title': 'Test Client', + 'version': '1.0.0', + 'description': 'A test MCP client', + 'icons': [ + { + 'src': 'https://example.com/icon.png', + 'theme': 'light', + }, + ], + 'websiteUrl': 'https://example.com', + }); + + expect(implementation.name, equals('test-client')); + expect(implementation.title, equals('Test Client')); + expect(implementation.version, equals('1.0.0')); + expect(implementation.description, equals('A test MCP client')); + expect(implementation.icons!.single.theme, equals(IconTheme.light)); + expect(implementation.websiteUrl, equals('https://example.com')); + expect(implementation.toJson(), { + 'name': 'test-client', + 'title': 'Test Client', + 'version': '1.0.0', + 'description': 'A test MCP client', + 'icons': [ + { + 'src': 'https://example.com/icon.png', + 'theme': 'light', + }, + ], + 'websiteUrl': 'https://example.com', + }); + }); + + test('Implementation rejects malformed stable wire fields', () { + void expectInvalid(Map json) { + expect( + () => Implementation.fromJson(json), + throwsA(isA()), + ); + } + + expectInvalid({}); + expectInvalid({'name': 'test-client'}); + expectInvalid({'name': 1, 'version': '1.0.0'}); + expectInvalid({'name': 'test-client', 'version': 1}); + expectInvalid({'name': 'test-client', 'version': '1.0.0', 'title': null}); + expectInvalid({'name': 'test-client', 'version': '1.0.0', 'title': 1}); + expectInvalid({ + 'name': 'test-client', + 'version': '1.0.0', + 'description': null, + }); + expectInvalid({ + 'name': 'test-client', + 'version': '1.0.0', + 'description': 1, + }); + expectInvalid({'name': 'test-client', 'version': '1.0.0', 'icons': null}); + expectInvalid({'name': 'test-client', 'version': '1.0.0', 'icons': {}}); + expectInvalid({ + 'name': 'test-client', + 'version': '1.0.0', + 'icons': [null], + }); + expectInvalid({ + 'name': 'test-client', + 'version': '1.0.0', + 'websiteUrl': null, + }); + expectInvalid({ + 'name': 'test-client', + 'version': '1.0.0', + 'websiteUrl': 1, + }); + expectInvalid({ + 'name': 'test-client', + 'version': '1.0.0', + 'websiteUrl': 'example.com', + }); + }); + + test('Implementation validates website URL during serialization', () { + expect( + () => const Implementation( + name: 'test-client', + version: '1.0.0', + websiteUrl: 'example.com', + ).toJson(), + throwsA(isA()), + ); + }); + test('ImageContent supports annotations and meta', () { final content = const ImageContent( - data: 'base64data', + data: 'YmFzZTY0ZGF0YQ==', mimeType: 'image/png', annotations: Annotations( audience: [AnnotationAudience.user], @@ -530,21 +731,22 @@ void main() { }); test('AudioContent serialization and deserialization', () { + const audioData = 'YmFzZTY0ZGF0YQ=='; final content = - const AudioContent(data: 'base64data', mimeType: 'audio/wav'); + const AudioContent(data: audioData, mimeType: 'audio/wav'); final json = content.toJson(); expect(json['type'], equals('audio')); - expect(json['data'], equals('base64data')); + expect(json['data'], equals(audioData)); expect(json['mimeType'], equals('audio/wav')); final deserialized = AudioContent.fromJson(json); - expect(deserialized.data, equals('base64data')); + expect(deserialized.data, equals(audioData)); expect(deserialized.mimeType, equals('audio/wav')); }); test('AudioContent supports annotations and meta', () { final content = const AudioContent( - data: 'base64data', + data: 'YmFzZTY0ZGF0YQ==', mimeType: 'audio/wav', annotations: Annotations(priority: 0.3), meta: { @@ -561,6 +763,24 @@ void main() { expect(deserialized.meta?['traceId'], equals('audio-1')); }); + test('AudioContent validates base64 byte data', () { + expect( + () => AudioContent.fromJson({ + 'type': 'audio', + 'data': 'not base64!', + 'mimeType': 'audio/wav', + }), + throwsA(isA()), + ); + expect( + () => const AudioContent( + data: 'not base64!', + mimeType: 'audio/wav', + ).toJson(), + throwsA(isA()), + ); + }); + test('UnknownContent serialization and deserialization', () { final content = const UnknownContent(type: 'unknown'); final json = content.toJson(); @@ -579,6 +799,7 @@ void main() { annotations: { 'audience': ['assistant'], 'priority': 0.5, + 'vendor': {'hint': true}, }, ); @@ -592,12 +813,47 @@ void main() { expect(deserialized.name, equals('readme')); expect(deserialized.mimeType, equals('text/markdown')); expect(deserialized.annotations?['priority'], equals(0.5)); + expect(deserialized.annotations?['vendor'], equals({'hint': true})); expect( deserialized.parsedAnnotations?.audience, equals([AnnotationAudience.assistant]), ); }); + test('ResourceLink validates shared annotation fields', () { + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + 'annotations': { + 'audience': ['model'], + }, + }), + throwsA(isA()), + ); + expect( + () => const ResourceLink( + uri: 'file:///docs/readme.md', + name: 'readme', + annotations: { + 'priority': 2, + }, + ).toJson(), + throwsA(isA()), + ); + expect( + () => const ResourceLink( + uri: 'file:///docs/readme.md', + name: 'readme', + annotations: { + 'lastModified': 1, + }, + ).toJson(), + throwsA(isA()), + ); + }); + test('Content.fromJson handles resource_link content type', () { final json = { 'type': 'resource_link', @@ -610,6 +866,28 @@ void main() { expect((content as ResourceLink).uri, equals('file:///docs/spec.md')); }); + test('ResourceLink accepts whole-number JSON size values', () { + final link = ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/spec.md', + 'name': 'spec', + 'size': 123.0, + }); + + expect(link.size, 123); + expect(link.toJson()['size'], 123); + + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/spec.md', + 'name': 'spec', + 'size': 123.5, + }), + throwsA(isA()), + ); + }); + test('EmbeddedResource supports annotations and meta', () { final content = const EmbeddedResource( resource: TextResourceContents( @@ -714,21 +992,39 @@ void main() { }); test('BlobResourceContents serialization and deserialization', () { + const blobData = 'YmFzZTY0ZGF0YQ=='; final contents = const BlobResourceContents( uri: 'file://example.bin', - blob: 'base64data', + blob: blobData, mimeType: 'application/octet-stream', ); final json = contents.toJson(); expect(json['uri'], equals('file://example.bin')); - expect(json['blob'], equals('base64data')); + expect(json['blob'], equals(blobData)); expect(json['mimeType'], equals('application/octet-stream')); final deserialized = ResourceContents.fromJson(json) as BlobResourceContents; expect(deserialized.uri, equals('file://example.bin')); - expect(deserialized.blob, equals('base64data')); + expect(deserialized.blob, equals(blobData)); + }); + + test('BlobResourceContents validates base64 byte data', () { + expect( + () => ResourceContents.fromJson({ + 'uri': 'file://example.bin', + 'blob': 'not base64!', + }), + throwsA(isA()), + ); + expect( + () => const BlobResourceContents( + uri: 'file://example.bin', + blob: 'not base64!', + ).toJson(), + throwsA(isA()), + ); }); test('ResourceContents rejects non-JSON metadata and passthrough maps', () { @@ -808,6 +1104,23 @@ void main() { expect(deserialized.description, equals('Argument 1')); expect(deserialized.required, equals(true)); }); + + test('PromptMessage validates role wire values', () { + expect( + () => PromptMessage.fromJson({ + 'role': 'system', + 'content': {'type': 'text', 'text': 'Hello'}, + }), + throwsA(isA()), + ); + expect( + () => PromptMessage.fromJson({ + 'role': 1, + 'content': {'type': 'text', 'text': 'Hello'}, + }), + throwsA(isA()), + ); + }); }); group('CreateMessageResult Tests', () { test('CreateMessageResult serialization and deserialization', () { @@ -1066,16 +1379,19 @@ void main() { properties: {'name': JsonSchema.string(minLength: 1)}, required: const ['name'], ), + task: const TaskCreationParams(ttl: 3600), ); final json = params.toJson(); expect(json['message'], equals("Enter your name")); expect(json['requestedSchema']['type'], equals('object')); expect(json['requestedSchema']['properties']['name']['type'], 'string'); + expect(json['task'], {'ttl': 3600}); final restored = ElicitRequestParams.fromJson(json); expect(restored.message, equals("Enter your name")); expect(restored.requestedSchema!.toJson()['type'], equals('object')); + expect(restored.task?.ttl, 3600); }); test('JsonRpcElicitRequest serialization and deserialization', () { From 9ee1f994f692e663a6845f1f2c62a485e54078b0 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 08:39:56 -0400 Subject: [PATCH 06/68] Validate sampling wire fields --- CHANGELOG.md | 2 + lib/src/types/sampling.dart | 63 +++++++++---- lib/src/types/validation.dart | 26 ++++++ test/mcp_2025_11_25_test.dart | 43 +++++++++ test/mcp_2026_07_28_test.dart | 43 +++++++++ test/types/sampling_test.dart | 162 ++++++++++++++++++++++++++++++++++ 6 files changed, 322 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 607ddcc4..9fe7f867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,8 @@ allowing raw enum lookup failures. - Rejected malformed logging level, sampling `includeContext`, and sampling `toolChoice.mode` enum values with protocol parse errors. +- Rejected malformed sampling string, boolean, and string-list wire fields with + protocol parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index cd17fbcd..976980da 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -67,7 +67,9 @@ List _asSamplingContentBlocks( return item; } if (item is Map) { - return SamplingContent.fromJson(item.cast()); + return SamplingContent.fromJson( + readJsonObject(item, '$context items'), + ); } throw FormatException( 'Expected $context items to be SamplingContent or object, got ${item.runtimeType}', @@ -76,7 +78,7 @@ List _asSamplingContentBlocks( } if (value is Map) { - return [SamplingContent.fromJson(value.cast())]; + return [SamplingContent.fromJson(readJsonObject(value, context))]; } throw FormatException( @@ -199,7 +201,9 @@ class ModelHint { const ModelHint({this.name}); factory ModelHint.fromJson(Map json) { - return ModelHint(name: json['name'] as String?); + return ModelHint( + name: readOptionalString(json['name'], 'ModelHint.name'), + ); } Map toJson() => { @@ -238,9 +242,13 @@ class ModelPreferences { ); factory ModelPreferences.fromJson(Map json) { + final hints = json['hints']; + if (hints != null && hints is! List) { + throw const FormatException('ModelPreferences.hints must be a list'); + } return ModelPreferences( - hints: (json['hints'] as List?) - ?.map((h) => ModelHint.fromJson(_asJsonObject(h))) + hints: hints + ?.map((h) => ModelHint.fromJson(_asJsonObject(h))) .toList(), costPriority: readUnitDouble( json['costPriority'], @@ -283,7 +291,7 @@ sealed class SamplingContent { /// Creates specific subclass from JSON. factory SamplingContent.fromJson(Map json) { - final type = json['type'] as String?; + final type = readRequiredString(json['type'], 'SamplingContent.type'); return switch (type) { 'text' => SamplingTextContent.fromJson(json), 'image' => SamplingImageContent.fromJson(json), @@ -378,7 +386,7 @@ class SamplingTextContent extends SamplingContent { factory SamplingTextContent.fromJson(Map json) => SamplingTextContent( - text: json['text'] as String, + text: readRequiredString(json['text'], 'SamplingTextContent.text'), annotations: readOptionalAnnotationsObject( json['annotations'], 'SamplingTextContent.annotations', @@ -414,7 +422,10 @@ class SamplingImageContent extends SamplingContent { json['data'], 'SamplingImageContent.data', ), - mimeType: json['mimeType'] as String, + mimeType: readRequiredString( + json['mimeType'], + 'SamplingImageContent.mimeType', + ), annotations: readOptionalAnnotationsObject( json['annotations'], 'SamplingImageContent.annotations', @@ -450,7 +461,10 @@ class SamplingAudioContent extends SamplingContent { json['data'], 'SamplingAudioContent.data', ), - mimeType: json['mimeType'] as String, + mimeType: readRequiredString( + json['mimeType'], + 'SamplingAudioContent.mimeType', + ), annotations: readOptionalAnnotationsObject( json['annotations'], 'SamplingAudioContent.annotations', @@ -475,8 +489,8 @@ class SamplingToolUseContent extends SamplingContent { factory SamplingToolUseContent.fromJson(Map json) => SamplingToolUseContent( - id: json['id'] as String, - name: json['name'] as String, + id: readRequiredString(json['id'], 'SamplingToolUseContent.id'), + name: readRequiredString(json['name'], 'SamplingToolUseContent.name'), input: _asJsonObject(json['input'], 'SamplingToolUseContent.input'), meta: _asJsonObjectOrNull(json['_meta'], 'SamplingToolUseContent._meta'), @@ -512,7 +526,10 @@ class SamplingToolResultContent extends SamplingContent { factory SamplingToolResultContent.fromJson(Map json) { return SamplingToolResultContent( - toolUseId: json['toolUseId'] as String, + toolUseId: readRequiredString( + json['toolUseId'], + 'SamplingToolResultContent.toolUseId', + ), content: _parseToolResultWireContent(json['content']), structuredContent: json.containsKey('structuredContent') ? readJsonValue( @@ -521,7 +538,10 @@ class SamplingToolResultContent extends SamplingContent { ) : null, hasStructuredContent: json.containsKey('structuredContent'), - isError: json['isError'] as bool?, + isError: readOptionalBool( + json['isError'], + 'SamplingToolResultContent.isError', + ), meta: _asJsonObjectOrNull(json['_meta'], 'SamplingToolResultContent._meta'), ); @@ -686,7 +706,10 @@ class CreateMessageRequest { .map((m) => SamplingMessage.fromJson(_asJsonObject(m))) .toList(), task: task == null ? null : TaskCreation.fromJson(task), - systemPrompt: json['systemPrompt'] as String?, + systemPrompt: readOptionalString( + json['systemPrompt'], + 'CreateMessageRequest.systemPrompt', + ), includeContext: readOptionalEnumValue( json['includeContext'], IncludeContext.values, @@ -700,7 +723,10 @@ class CreateMessageRequest { json['maxTokens'], 'CreateMessageRequest.maxTokens', ), - stopSequences: (json['stopSequences'] as List?)?.cast(), + stopSequences: readOptionalStringList( + json['stopSequences'], + 'CreateMessageRequest.stopSequences', + ), metadata: _asJsonObjectOrNull( json['metadata'], 'CreateMessageRequest.metadata', @@ -759,7 +785,10 @@ class JsonRpcCreateMessageRequest extends JsonRpcRequest { ); factory JsonRpcCreateMessageRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcCreateMessageRequest.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for create message request"); } @@ -828,7 +857,7 @@ class CreateMessageResult implements BaseResultData { ); } return CreateMessageResult( - model: json['model'] as String, + model: readRequiredString(json['model'], 'CreateMessageResult.model'), stopReason: reason, role: SamplingMessageRole.values.byName( readRequiredRoleString(json['role'], 'CreateMessageResult.role'), diff --git a/lib/src/types/validation.dart b/lib/src/types/validation.dart index 021862ed..b45cf656 100644 --- a/lib/src/types/validation.dart +++ b/lib/src/types/validation.dart @@ -251,6 +251,32 @@ String? readOptionalString(Object? value, String field) { throw FormatException('$field must be a string'); } +bool readRequiredBool(Object? value, String field) { + if (value is bool) { + return value; + } + throw FormatException('$field must be a boolean'); +} + +bool? readOptionalBool(Object? value, String field) { + if (value == null) { + return null; + } + return readRequiredBool(value, field); +} + +List? readOptionalStringList(Object? value, String field) { + if (value == null) { + return null; + } + if (value is! List) { + throw FormatException('$field must be a list of strings'); + } + return [ + for (final item in value) readRequiredString(item, '$field items'), + ]; +} + int? readOptionalTtlMs(Object? value, String field) { final ttlMs = readOptionalInteger(value, field); if (ttlMs == null) { diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 05932c33..8e08f412 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1591,6 +1591,49 @@ void main() { }), throwsA(isA()), ); + expect( + () => ModelHint.fromJson({'name': 1}), + throwsA(isA()), + ); + expect( + () => SamplingContent.fromJson({ + 'type': 'text', + 'text': 1, + }), + throwsA(isA()), + ); + expect( + () => SamplingToolResultContent.fromJson({ + 'type': 'tool_result', + 'toolUseId': 'call-1', + 'content': [ + {'type': 'text', 'text': 'Hello'}, + ], + 'isError': 'false', + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 100, + 'stopSequences': ['STOP', 1], + }), + throwsA(isA()), + ); + expect( + () => CreateMessageResult.fromJson({ + 'role': 'assistant', + 'content': {'type': 'text', 'text': 'Hello'}, + 'model': 1, + }), + throwsA(isA()), + ); }); test('bare task containers strip task metadata', () { diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index ac056d59..f5e5bfbf 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -529,6 +529,49 @@ void main() { }), throwsA(isA()), ); + expect( + () => ModelHint.fromJson({'name': 1}), + throwsA(isA()), + ); + expect( + () => SamplingContent.fromJson({ + 'type': 'text', + 'text': 1, + }), + throwsA(isA()), + ); + expect( + () => SamplingToolResultContent.fromJson({ + 'type': 'tool_result', + 'toolUseId': 'call-1', + 'content': [ + {'type': 'text', 'text': 'Hello'}, + ], + 'isError': 'false', + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequest.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 16, + 'stopSequences': ['STOP', 1], + }), + throwsA(isA()), + ); + expect( + () => CreateMessageResult.fromJson({ + 'role': 'assistant', + 'content': {'type': 'text', 'text': 'Hello'}, + 'model': 1, + }), + throwsA(isA()), + ); }); test('rejects non-JSON content object values', () { diff --git a/test/types/sampling_test.dart b/test/types/sampling_test.dart index f0adf2b5..5d74e8a1 100644 --- a/test/types/sampling_test.dart +++ b/test/types/sampling_test.dart @@ -20,6 +20,13 @@ void main() { final hint = ModelHint.fromJson(json); expect(hint.name, equals('gemini-pro')); }); + + test('rejects malformed wire fields', () { + expect( + () => ModelHint.fromJson({'name': 1}), + throwsA(isA()), + ); + }); }); group('ModelPreferences', () { @@ -89,6 +96,21 @@ void main() { ); } }); + + test('rejects malformed hint lists', () { + expect( + () => ModelPreferences.fromJson({'hints': 'model-a'}), + throwsA(isA()), + ); + expect( + () => ModelPreferences.fromJson({ + 'hints': [ + {'name': 1}, + ], + }), + throwsA(isA()), + ); + }); }); group('SamplingContent', () { @@ -143,6 +165,23 @@ void main() { throwsA(isA()), ); }); + + test('rejects malformed text wire fields', () { + expect( + () => SamplingContent.fromJson({ + 'type': 1, + 'text': 'Parsed text', + }), + throwsA(isA()), + ); + expect( + () => SamplingContent.fromJson({ + 'type': 'text', + 'text': 1, + }), + throwsA(isA()), + ); + }); }); group('SamplingImageContent', () { @@ -199,6 +238,17 @@ void main() { throwsA(isA()), ); }); + + test('rejects malformed image wire fields', () { + expect( + () => SamplingContent.fromJson({ + 'type': 'image', + 'data': 'aW1nZGF0YQ==', + 'mimeType': 1, + }), + throwsA(isA()), + ); + }); }); group('SamplingAudioContent', () { @@ -259,6 +309,17 @@ void main() { throwsA(isA()), ); }); + + test('rejects malformed audio wire fields', () { + expect( + () => SamplingContent.fromJson({ + 'type': 'audio', + 'data': 'YXVkaW8tZGF0YQ==', + 'mimeType': 1, + }), + throwsA(isA()), + ); + }); }); group('SamplingToolUseContent', () { @@ -318,6 +379,27 @@ void main() { throwsA(isA()), ); }); + + test('rejects malformed tool use wire fields', () { + expect( + () => SamplingContent.fromJson({ + 'type': 'tool_use', + 'id': 1, + 'name': 'fetch', + 'input': {'url': 'http://test.com'}, + }), + throwsA(isA()), + ); + expect( + () => SamplingContent.fromJson({ + 'type': 'tool_use', + 'id': 'tu1', + 'name': 1, + 'input': {'url': 'http://test.com'}, + }), + throwsA(isA()), + ); + }); }); group('SamplingToolResultContent', () { @@ -421,6 +503,29 @@ void main() { expect(nullContent.hasStructuredContent, isTrue); expect(nullContent.structuredContent, isNull); }); + + test('rejects malformed tool result wire fields', () { + final content = [ + {'type': 'text', 'text': 'result data'}, + ]; + expect( + () => SamplingContent.fromJson({ + 'type': 'tool_result', + 'toolUseId': 1, + 'content': content, + }), + throwsA(isA()), + ); + expect( + () => SamplingContent.fromJson({ + 'type': 'tool_result', + 'toolUseId': 'tr1', + 'content': content, + 'isError': 'false', + }), + throwsA(isA()), + ); + }); }); }); @@ -633,6 +738,39 @@ void main() { ); }); + test('validates string wire fields', () { + final messages = [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ]; + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'systemPrompt': 1, + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'stopSequences': 'STOP', + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'stopSequences': ['STOP', 1], + }), + throwsA(isA()), + ); + }); + test('accepts whole-number JSON maxTokens values', () { final messages = [ { @@ -809,6 +947,17 @@ void main() { ); }); + test('validates model wire field', () { + expect( + () => CreateMessageResult.fromJson({ + 'role': 'assistant', + 'content': {'type': 'text', 'text': 'Msg'}, + 'model': 1, + }), + throwsA(isA()), + ); + }); + test('rejects non-JSON metadata objects', () { expect( () => CreateMessageResult.fromJson({ @@ -880,6 +1029,19 @@ void main() { throwsA(isA()), ); }); + + test('fromJson rejects non-object params', () { + final json = { + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'sampling/createMessage', + 'params': 'bad', + }; + expect( + () => JsonRpcCreateMessageRequest.fromJson(json), + throwsA(isA()), + ); + }); }); group('IncludeContext', () { From 58071145af86a2468d732ea7a2086ae9f10d9ecb Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 08:50:39 -0400 Subject: [PATCH 07/68] Validate content resource wire fields --- CHANGELOG.md | 2 + lib/src/types/content.dart | 60 ++++++++---- lib/src/types/resources.dart | 138 ++++++++++++++++++++------ test/mcp_2025_11_25_test.dart | 43 +++++++++ test/mcp_2026_07_28_test.dart | 43 +++++++++ test/types/resources_test.dart | 171 +++++++++++++++++++++++++++++++++ test/types_test.dart | 83 ++++++++++++++++ 7 files changed, 492 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe7f867..ca937fbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,8 @@ `toolChoice.mode` enum values with protocol parse errors. - Rejected malformed sampling string, boolean, and string-list wire fields with protocol parse errors. +- Rejected malformed content and resource string/list wire fields with protocol + parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/content.dart b/lib/src/types/content.dart index fd364653..ebe9ebca 100644 --- a/lib/src/types/content.dart +++ b/lib/src/types/content.dart @@ -22,10 +22,7 @@ Map _asJsonObject( } String _readRequiredString(Object? value, String field) { - if (value is String) { - return value; - } - throw FormatException('$field must be a string'); + return readRequiredString(value, field); } bool _isAbsoluteUri(String value) { @@ -98,6 +95,26 @@ List? _readOptionalPresentStringList( ]; } +List? _readOptionalIconList( + Map json, + String key, + String field, +) { + if (!json.containsKey(key)) { + return null; + } + + final value = json[key]; + if (value is! List) { + throw FormatException('$field must be a list of objects'); + } + + return [ + for (var i = 0; i < value.length; i++) + McpIcon.fromJson(readJsonObject(value[i], '$field[$i]')), + ]; +} + /// Allowed audience values for content/resource annotations. enum AnnotationAudience { user, assistant } @@ -176,7 +193,10 @@ sealed class ResourceContents { json['uri'], 'ResourceContents.uri', ); - final mimeType = json['mimeType'] as String?; + final mimeType = readOptionalString( + json['mimeType'], + 'ResourceContents.mimeType', + ); final meta = _asJsonObjectOrNull( json['_meta'], 'ResourceContents._meta', @@ -198,7 +218,10 @@ sealed class ResourceContents { return TextResourceContents( uri: uri, mimeType: mimeType, - text: json['text'] as String, + text: readRequiredString( + json['text'], + 'TextResourceContents.text', + ), meta: meta, extra: passthrough, ); @@ -351,7 +374,7 @@ sealed class Content { }); factory Content.fromJson(Map json) { - final type = json['type'] as String?; + final type = readOptionalString(json['type'], 'Content.type'); return switch (type) { 'text' => TextContent.fromJson(json), 'image' => ImageContent.fromJson(json), @@ -432,7 +455,7 @@ class TextContent extends Content { factory TextContent.fromJson(Map json) { return TextContent( - text: json['text'] as String, + text: readRequiredString(json['text'], 'TextContent.text'), annotations: json['annotations'] == null ? null : Annotations.fromJson( @@ -475,8 +498,8 @@ class ImageContent extends Content { factory ImageContent.fromJson(Map json) { return ImageContent( data: readRequiredBase64String(json['data'], 'ImageContent.data'), - mimeType: json['mimeType'] as String, - theme: json['theme'] as String?, + mimeType: readRequiredString(json['mimeType'], 'ImageContent.mimeType'), + theme: readOptionalString(json['theme'], 'ImageContent.theme'), annotations: json['annotations'] == null ? null : Annotations.fromJson( @@ -510,7 +533,7 @@ class AudioContent extends Content { factory AudioContent.fromJson(Map json) { return AudioContent( data: readRequiredBase64String(json['data'], 'AudioContent.data'), - mimeType: json['mimeType'] as String, + mimeType: readRequiredString(json['mimeType'], 'AudioContent.mimeType'), annotations: json['annotations'] == null ? null : Annotations.fromJson( @@ -604,14 +627,15 @@ class ResourceLink extends Content { factory ResourceLink.fromJson(Map json) { return ResourceLink( uri: readRequiredAbsoluteUriString(json['uri'], 'ResourceLink.uri'), - name: json['name'] as String, - title: json['title'] as String?, - description: json['description'] as String?, - mimeType: json['mimeType'] as String?, + name: readRequiredString(json['name'], 'ResourceLink.name'), + title: readOptionalString(json['title'], 'ResourceLink.title'), + description: readOptionalString( + json['description'], + 'ResourceLink.description', + ), + mimeType: readOptionalString(json['mimeType'], 'ResourceLink.mimeType'), size: readOptionalInteger(json['size'], 'ResourceLink.size'), - icons: (json['icons'] as List?) - ?.map((icon) => McpIcon.fromJson(_asJsonObject(icon))) - .toList(), + icons: _readOptionalIconList(json, 'icons', 'ResourceLink.icons'), annotations: readOptionalAnnotationsObject( json['annotations'], 'ResourceLink.annotations', diff --git a/lib/src/types/resources.dart b/lib/src/types/resources.dart index aa226545..53860fe6 100644 --- a/lib/src/types/resources.dart +++ b/lib/src/types/resources.dart @@ -2,6 +2,26 @@ import '../types.dart'; import 'json_rpc.dart'; import 'validation.dart'; +List? _readOptionalIconList( + Map json, + String key, + String field, +) { + if (!json.containsKey(key)) { + return null; + } + + final value = json[key]; + if (value is! List) { + throw FormatException('$field must be a list of objects'); + } + + return [ + for (var i = 0; i < value.length; i++) + McpIcon.fromJson(readJsonObject(value[i], '$field[$i]')), + ]; +} + /// Additional properties describing a Resource to clients. class ResourceAnnotations { /// A human-readable title for the resource. @@ -31,7 +51,7 @@ class ResourceAnnotations { factory ResourceAnnotations.fromJson(Map json) { return ResourceAnnotations( - title: json['title'] as String?, + title: readOptionalString(json['title'], 'ResourceAnnotations.title'), audience: readOptionalAnnotationAudience( json['audience'], 'ResourceAnnotations.audience', @@ -111,16 +131,17 @@ class Resource { factory Resource.fromJson(Map json) { return Resource( uri: readRequiredAbsoluteUriString(json['uri'], 'Resource.uri'), - name: json['name'] as String, - title: json['title'] as String?, - description: json['description'] as String?, - mimeType: json['mimeType'] as String?, + name: readRequiredString(json['name'], 'Resource.name'), + title: readOptionalString(json['title'], 'Resource.title'), + description: readOptionalString( + json['description'], + 'Resource.description', + ), + mimeType: readOptionalString(json['mimeType'], 'Resource.mimeType'), icon: json['icon'] != null - ? ImageContent.fromJson(json['icon'] as Map) + ? ImageContent.fromJson(readJsonObject(json['icon'], 'Resource.icon')) : null, - icons: (json['icons'] as List?) - ?.map((e) => McpIcon.fromJson(e as Map)) - .toList(), + icons: _readOptionalIconList(json, 'icons', 'Resource.icons'), size: readOptionalInteger(json['size'], 'Resource.size'), annotations: json['annotations'] != null ? ResourceAnnotations.fromJson( @@ -200,16 +221,26 @@ class ResourceTemplate { json['uriTemplate'], 'ResourceTemplate.uriTemplate', ), - name: json['name'] as String, - title: json['title'] as String?, - description: json['description'] as String?, - mimeType: json['mimeType'] as String?, + name: readRequiredString(json['name'], 'ResourceTemplate.name'), + title: readOptionalString(json['title'], 'ResourceTemplate.title'), + description: readOptionalString( + json['description'], + 'ResourceTemplate.description', + ), + mimeType: readOptionalString( + json['mimeType'], + 'ResourceTemplate.mimeType', + ), icon: json['icon'] != null - ? ImageContent.fromJson(json['icon'] as Map) + ? ImageContent.fromJson( + readJsonObject(json['icon'], 'ResourceTemplate.icon'), + ) : null, - icons: (json['icons'] as List?) - ?.map((e) => McpIcon.fromJson(e as Map)) - .toList(), + icons: _readOptionalIconList( + json, + 'icons', + 'ResourceTemplate.icons', + ), annotations: json['annotations'] != null ? ResourceAnnotations.fromJson( readJsonObject( @@ -251,7 +282,10 @@ class ListResourcesRequest { /// Creates from JSON. factory ListResourcesRequest.fromJson(Map json) => - ListResourcesRequest(cursor: json['cursor'] as String?); + ListResourcesRequest( + cursor: + readOptionalString(json['cursor'], 'ListResourcesRequest.cursor'), + ); /// Converts to JSON. Map toJson() => {if (cursor != null) 'cursor': cursor}; @@ -272,7 +306,10 @@ class JsonRpcListResourcesRequest extends JsonRpcRequest { /// Creates from JSON. factory JsonRpcListResourcesRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcListResourcesRequest.params', + ); final meta = extractRequestMeta(json); return JsonRpcListResourcesRequest( id: parseRequestId(json['id']), @@ -324,9 +361,16 @@ class ListResourcesResult implements CacheableResultData { } return ListResourcesResult( resources: resources - .map((e) => Resource.fromJson(e as Map)) + .map( + (e) => Resource.fromJson( + readJsonObject(e, 'ListResourcesResult.resources items'), + ), + ) .toList(), - nextCursor: json['nextCursor'] as String?, + nextCursor: readOptionalString( + json['nextCursor'], + 'ListResourcesResult.nextCursor', + ), ttlMs: readOptionalTtlMs(json['ttlMs'], 'ListResourcesResult.ttlMs'), cacheScope: readOptionalCacheScope( json['cacheScope'], @@ -362,7 +406,12 @@ class ListResourceTemplatesRequest { factory ListResourceTemplatesRequest.fromJson( Map json, ) => - ListResourceTemplatesRequest(cursor: json['cursor'] as String?); + ListResourceTemplatesRequest( + cursor: readOptionalString( + json['cursor'], + 'ListResourceTemplatesRequest.cursor', + ), + ); Map toJson() => {if (cursor != null) 'cursor': cursor}; } @@ -382,7 +431,10 @@ class JsonRpcListResourceTemplatesRequest extends JsonRpcRequest { factory JsonRpcListResourceTemplatesRequest.fromJson( Map json, ) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcListResourceTemplatesRequest.params', + ); final meta = extractRequestMeta(json); return JsonRpcListResourceTemplatesRequest( id: parseRequestId(json['id']), @@ -434,9 +486,19 @@ class ListResourceTemplatesResult implements CacheableResultData { } return ListResourceTemplatesResult( resourceTemplates: resourceTemplates - .map((e) => ResourceTemplate.fromJson(e as Map)) + .map( + (e) => ResourceTemplate.fromJson( + readJsonObject( + e, + 'ListResourceTemplatesResult.resourceTemplates items', + ), + ), + ) .toList(), - nextCursor: json['nextCursor'] as String?, + nextCursor: readOptionalString( + json['nextCursor'], + 'ListResourceTemplatesResult.nextCursor', + ), ttlMs: readOptionalTtlMs( json['ttlMs'], 'ListResourceTemplatesResult.ttlMs', @@ -520,7 +582,10 @@ class JsonRpcReadResourceRequest extends JsonRpcRequest { }) : super(method: Method.resourcesRead, params: readParams.toJson()); factory JsonRpcReadResourceRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcReadResourceRequest.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for read resource request"); } @@ -567,7 +632,11 @@ class ReadResourceResult implements CacheableResultData { } return ReadResourceResult( contents: contents - .map((e) => ResourceContents.fromJson(e as Map)) + .map( + (e) => ResourceContents.fromJson( + readJsonObject(e, 'ReadResourceResult.contents items'), + ), + ) .toList(), ttlMs: readOptionalTtlMs(json['ttlMs'], 'ReadResourceResult.ttlMs'), cacheScope: readOptionalCacheScope( @@ -633,7 +702,10 @@ class JsonRpcSubscribeRequest extends JsonRpcRequest { }) : super(method: Method.resourcesSubscribe, params: subParams.toJson()); factory JsonRpcSubscribeRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcSubscribeRequest.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for subscribe request"); } @@ -679,7 +751,10 @@ class JsonRpcUnsubscribeRequest extends JsonRpcRequest { }) : super(method: Method.resourcesUnsubscribe, params: unsubParams.toJson()); factory JsonRpcUnsubscribeRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcUnsubscribeRequest.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for unsubscribe request"); } @@ -729,7 +804,10 @@ class JsonRpcResourceUpdatedNotification extends JsonRpcNotification { factory JsonRpcResourceUpdatedNotification.fromJson( Map json, ) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcResourceUpdatedNotification.params', + ); if (paramsMap == null) { throw const FormatException( "Missing params for resource updated notification", diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 8e08f412..e5b8bf9b 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1634,6 +1634,49 @@ void main() { }), throwsA(isA()), ); + expect( + () => Content.fromJson({ + 'type': 1, + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => TextContent.fromJson({ + 'type': 'text', + 'text': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceContents.fromJson({ + 'uri': 'file:///docs/readme.md', + 'text': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 1, + }), + throwsA(isA()), + ); + expect( + () => Resource.fromJson({ + 'uri': 'file:///docs/readme.md', + 'name': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceTemplate.fromJson({ + 'uriTemplate': 'file:///{path}', + 'name': 1, + }), + throwsA(isA()), + ); }); test('bare task containers strip task metadata', () { diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index f5e5bfbf..cf685523 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -572,6 +572,49 @@ void main() { }), throwsA(isA()), ); + expect( + () => Content.fromJson({ + 'type': 1, + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => TextContent.fromJson({ + 'type': 'text', + 'text': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceContents.fromJson({ + 'uri': 'file:///docs/readme.md', + 'text': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 1, + }), + throwsA(isA()), + ); + expect( + () => Resource.fromJson({ + 'uri': 'file:///docs/readme.md', + 'name': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceTemplate.fromJson({ + 'uriTemplate': 'file:///{path}', + 'name': 1, + }), + throwsA(isA()), + ); }); test('rejects non-JSON content object values', () { diff --git a/test/types/resources_test.dart b/test/types/resources_test.dart index 3c137c0e..aaf13335 100644 --- a/test/types/resources_test.dart +++ b/test/types/resources_test.dart @@ -75,6 +75,13 @@ void main() { throwsA(isA()), ); }); + + test('rejects malformed string wire fields', () { + expect( + () => ResourceAnnotations.fromJson({'title': 1}), + throwsA(isA()), + ); + }); }); group('Resource', () { @@ -177,6 +184,48 @@ void main() { ); }); + test('fromJson rejects malformed string and icon wire fields', () { + expect( + () => Resource.fromJson({ + 'uri': 'file:///test.txt', + 'name': 1, + }), + throwsA(isA()), + ); + expect( + () => Resource.fromJson({ + 'uri': 'file:///test.txt', + 'name': 'Test File', + 'title': 1, + }), + throwsA(isA()), + ); + expect( + () => Resource.fromJson({ + 'uri': 'file:///test.txt', + 'name': 'Test File', + 'icon': 'bad', + }), + throwsA(isA()), + ); + expect( + () => Resource.fromJson({ + 'uri': 'file:///test.txt', + 'name': 'Test File', + 'icons': 'bad', + }), + throwsA(isA()), + ); + expect( + () => Resource.fromJson({ + 'uri': 'file:///test.txt', + 'name': 'Test File', + 'icons': ['bad'], + }), + throwsA(isA()), + ); + }); + test('toJson serializes correctly with all fields', () { const resource = Resource( uri: 'file:///example.txt', @@ -340,6 +389,40 @@ void main() { expect(json['_meta'], isNotNull); expect(json['_meta']['ui']['prefersBorder'], isFalse); }); + + test('fromJson rejects malformed string and icon wire fields', () { + expect( + () => ResourceTemplate.fromJson({ + 'uriTemplate': 'file:///{path}', + 'name': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceTemplate.fromJson({ + 'uriTemplate': 'file:///{path}', + 'name': 'File Template', + 'description': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceTemplate.fromJson({ + 'uriTemplate': 'file:///{path}', + 'name': 'File Template', + 'icon': 'bad', + }), + throwsA(isA()), + ); + expect( + () => ResourceTemplate.fromJson({ + 'uriTemplate': 'file:///{path}', + 'name': 'File Template', + 'icons': ['bad'], + }), + throwsA(isA()), + ); + }); }); group('ListResourcesRequest', () { @@ -366,6 +449,13 @@ void main() { final json = request.toJson(); expect(json.containsKey('cursor'), isFalse); }); + + test('fromJson rejects malformed cursor', () { + expect( + () => ListResourcesRequest.fromJson({'cursor': 1}), + throwsA(isA()), + ); + }); }); group('JsonRpcListResourcesRequest', () { @@ -406,6 +496,17 @@ void main() { expect(request.id, equals(4)); expect(request.listParams.cursor, isNull); }); + + test('fromJson rejects non-object params', () { + expect( + () => JsonRpcListResourcesRequest.fromJson({ + 'id': 5, + 'method': 'resources/list', + 'params': 'bad', + }), + throwsA(isA()), + ); + }); }); group('ListResourcesResult', () { @@ -443,6 +544,22 @@ void main() { expect(result.meta!['customKey'], equals('customValue')); }); + test('fromJson rejects malformed resource items and cursor', () { + expect( + () => ListResourcesResult.fromJson({ + 'resources': ['bad'], + }), + throwsA(isA()), + ); + expect( + () => ListResourcesResult.fromJson({ + 'resources': [], + 'nextCursor': 1, + }), + throwsA(isA()), + ); + }); + test('toJson serializes correctly', () { const result = ListResourcesResult( resources: [ @@ -470,6 +587,13 @@ void main() { final json = request.toJson(); expect(json['cursor'], equals('next_tmpl')); }); + + test('fromJson rejects malformed cursor', () { + expect( + () => ListResourceTemplatesRequest.fromJson({'cursor': 1}), + throwsA(isA()), + ); + }); }); group('JsonRpcListResourceTemplatesRequest', () { @@ -489,6 +613,17 @@ void main() { expect(request.id, equals(11)); expect(request.listParams.cursor, equals('tmpl_page')); }); + + test('fromJson rejects non-object params', () { + expect( + () => JsonRpcListResourceTemplatesRequest.fromJson({ + 'id': 12, + 'method': 'resources/templates/list', + 'params': 'bad', + }), + throwsA(isA()), + ); + }); }); group('ListResourceTemplatesResult', () { @@ -512,6 +647,22 @@ void main() { expect(result.resourceTemplates, isEmpty); }); + test('fromJson rejects malformed template items and cursor', () { + expect( + () => ListResourceTemplatesResult.fromJson({ + 'resourceTemplates': ['bad'], + }), + throwsA(isA()), + ); + expect( + () => ListResourceTemplatesResult.fromJson({ + 'resourceTemplates': [], + 'nextCursor': 1, + }), + throwsA(isA()), + ); + }); + test('toJson serializes correctly', () { const result = ListResourceTemplatesResult( resourceTemplates: [ @@ -667,6 +818,26 @@ void main() { expect(roundTripped['payload']['kind'], equals('custom')); expect(roundTripped['_meta']['ui']['prefersBorder'], isTrue); }); + + test('fromJson rejects malformed content items', () { + expect( + () => ReadResourceResult.fromJson({ + 'contents': ['bad'], + }), + throwsA(isA()), + ); + expect( + () => ReadResourceResult.fromJson({ + 'contents': [ + { + 'uri': 'file:///content.txt', + 'text': 1, + }, + ], + }), + throwsA(isA()), + ); + }); }); group('JsonRpcResourceListChangedNotification', () { diff --git a/test/types_test.dart b/test/types_test.dart index c2053ffa..6d4903c0 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -538,6 +538,89 @@ void main() { ); }); + test('content blocks reject malformed wire fields', () { + expect( + () => Content.fromJson({ + 'type': 1, + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => TextContent.fromJson({ + 'type': 'text', + 'text': 1, + }), + throwsA(isA()), + ); + expect( + () => ImageContent.fromJson({ + 'type': 'image', + 'data': 'YmFzZTY0ZGF0YQ==', + 'mimeType': 1, + }), + throwsA(isA()), + ); + expect( + () => ImageContent.fromJson({ + 'type': 'image', + 'data': 'YmFzZTY0ZGF0YQ==', + 'mimeType': 'image/png', + 'theme': 1, + }), + throwsA(isA()), + ); + expect( + () => AudioContent.fromJson({ + 'type': 'audio', + 'data': 'YmFzZTY0ZGF0YQ==', + 'mimeType': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceContents.fromJson({ + 'uri': 'file:///docs/readme.md', + 'mimeType': 1, + 'text': 'README body', + }), + throwsA(isA()), + ); + expect( + () => ResourceContents.fromJson({ + 'uri': 'file:///docs/readme.md', + 'text': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 1, + }), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + 'icons': 'bad', + }), + throwsA(isA()), + ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource_link', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + 'icons': ['bad'], + }), + throwsA(isA()), + ); + }); + test('McpIcon parses stable wire fields', () { final icon = McpIcon.fromJson({ 'src': 'https://example.com/icon.png', From 883532aa54f4cf592ce856fd5aa6b594dd126ed9 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 09:02:36 -0400 Subject: [PATCH 08/68] Validate initialization wire fields --- CHANGELOG.md | 2 + lib/src/types/initialization.dart | 259 ++++++++++++++++++++---------- test/mcp_2025_11_25_test.dart | 40 +++++ test/mcp_2026_07_28_test.dart | 37 +++++ test/types_test.dart | 119 ++++++++++++++ 5 files changed, 374 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca937fbc..252ff2a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,8 @@ protocol parse errors. - Rejected malformed content and resource string/list wire fields with protocol parse errors. +- Rejected malformed initialization and capability wire fields with protocol + parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/initialization.dart b/lib/src/types/initialization.dart index e743be17..4cde02af 100644 --- a/lib/src/types/initialization.dart +++ b/lib/src/types/initialization.dart @@ -2,27 +2,14 @@ import 'content.dart'; import 'json_rpc.dart'; import 'validation.dart'; -Map? _asJsonObject(dynamic value) { +Map? _asJsonObject(Object? value, String field) { if (value == null) { return null; } - if (value is Map) { - return value; - } - if (value is Map) { - return value.cast(); - } if (value is bool) { return value ? {} : null; } - throw FormatException('Expected object capability, got ${value.runtimeType}'); -} - -String _readRequiredString(Object? value, String field) { - if (value is String) { - return value; - } - throw FormatException('$field must be a string'); + return readJsonObject(value, field); } String? _readOptionalPresentString( @@ -33,7 +20,7 @@ String? _readOptionalPresentString( if (!json.containsKey(key)) { return null; } - return _readRequiredString(json[key], field); + return readRequiredString(json[key], field); } bool _isAbsoluteUri(String value) { @@ -85,16 +72,7 @@ Map? _asStrictJsonObject(Object? value, String field) { if (value == null) { return null; } - if (value is Map) { - return value; - } - if (value is Map) { - if (value.keys.any((key) => key is! String)) { - throw FormatException('$field must be an object with string keys'); - } - return value.cast(); - } - throw FormatException('$field must be an object'); + return readJsonObject(value, field); } Map? _asJsonObjectMap(Object? value, String field) { @@ -149,17 +127,15 @@ Map>? _serializeExtensionMap( ); } -bool? _capabilityDeclared(dynamic value) { +bool? _capabilityDeclared(Object? value, String field) { if (value == null) { return null; } if (value is bool) { return value; } - if (value is Map) { - return true; - } - throw FormatException('Expected capability marker, got ${value.runtimeType}'); + readJsonObject(value, field); + return true; } Map? _serializeCapabilityObject(bool? declared) { @@ -213,13 +189,13 @@ class Implementation { factory Implementation.fromJson(Map json) { return Implementation( - name: _readRequiredString(json['name'], 'Implementation.name'), + name: readRequiredString(json['name'], 'Implementation.name'), title: _readOptionalPresentString( json, 'title', 'Implementation.title', ), - version: _readRequiredString(json['version'], 'Implementation.version'), + version: readRequiredString(json['version'], 'Implementation.version'), description: _readOptionalPresentString( json, 'description', @@ -262,7 +238,10 @@ class ClientCapabilitiesRoots { factory ClientCapabilitiesRoots.fromJson(Map json) { return ClientCapabilitiesRoots( - listChanged: json['listChanged'] as bool?, + listChanged: readOptionalBool( + json['listChanged'], + 'ClientCapabilitiesRoots.listChanged', + ), ); } @@ -281,7 +260,10 @@ class ClientElicitationForm { factory ClientElicitationForm.fromJson(Map json) { return ClientElicitationForm( - applyDefaults: json['applyDefaults'] as bool?, + applyDefaults: readOptionalBool( + json['applyDefaults'], + 'ClientElicitationForm.applyDefaults', + ), ); } @@ -343,8 +325,8 @@ class ClientElicitation { return const ClientElicitation.formOnly(); } - final formMap = (json['form'] as Map?)?.cast(); - final urlMap = (json['url'] as Map?)?.cast(); + final formMap = _asJsonObject(json['form'], 'ClientElicitation.form'); + final urlMap = _asJsonObject(json['url'], 'ClientElicitation.url'); return ClientElicitation( form: formMap == null ? null : ClientElicitationForm.fromJson(formMap), @@ -373,8 +355,16 @@ class ClientCapabilitiesSampling { factory ClientCapabilitiesSampling.fromJson(Map json) { return ClientCapabilitiesSampling( - context: _capabilityDeclared(json['context']) ?? false, - tools: _capabilityDeclared(json['tools']) ?? false, + context: _capabilityDeclared( + json['context'], + 'ClientCapabilitiesSampling.context', + ) ?? + false, + tools: _capabilityDeclared( + json['tools'], + 'ClientCapabilitiesSampling.tools', + ) ?? + false, ); } @@ -405,7 +395,10 @@ class ClientCapabilitiesTasksElicitation { factory ClientCapabilitiesTasksElicitation.fromJson( Map json, ) { - final createMap = _asJsonObject(json['create']); + final createMap = _asJsonObject( + json['create'], + 'ClientCapabilitiesTasksElicitation.create', + ); return ClientCapabilitiesTasksElicitation( create: createMap != null ? ClientCapabilitiesTasksElicitationCreate.fromJson(createMap) @@ -437,7 +430,10 @@ class ClientCapabilitiesTasksSampling { const ClientCapabilitiesTasksSampling({this.createMessage}); factory ClientCapabilitiesTasksSampling.fromJson(Map json) { - final createMessageMap = _asJsonObject(json['createMessage']); + final createMessageMap = _asJsonObject( + json['createMessage'], + 'ClientCapabilitiesTasksSampling.createMessage', + ); return ClientCapabilitiesTasksSampling( createMessage: createMessageMap != null ? ClientCapabilitiesTasksSamplingCreateMessage.fromJson( @@ -467,8 +463,14 @@ class ClientCapabilitiesTasksRequests { }); factory ClientCapabilitiesTasksRequests.fromJson(Map json) { - final elicitationMap = _asJsonObject(json['elicitation']); - final samplingMap = _asJsonObject(json['sampling']); + final elicitationMap = _asJsonObject( + json['elicitation'], + 'ClientCapabilitiesTasksRequests.elicitation', + ); + final samplingMap = _asJsonObject( + json['sampling'], + 'ClientCapabilitiesTasksRequests.sampling', + ); return ClientCapabilitiesTasksRequests( elicitation: elicitationMap != null @@ -504,10 +506,19 @@ class ClientCapabilitiesTasks { }); factory ClientCapabilitiesTasks.fromJson(Map json) { - final requestsMap = _asJsonObject(json['requests']); + final requestsMap = _asJsonObject( + json['requests'], + 'ClientCapabilitiesTasks.requests', + ); return ClientCapabilitiesTasks( - cancel: _capabilityDeclared(json['cancel']), - list: _capabilityDeclared(json['list']), + cancel: _capabilityDeclared( + json['cancel'], + 'ClientCapabilitiesTasks.cancel', + ), + list: _capabilityDeclared( + json['list'], + 'ClientCapabilitiesTasks.list', + ), requests: requestsMap == null ? null : ClientCapabilitiesTasksRequests.fromJson(requestsMap), @@ -562,10 +573,16 @@ class ClientCapabilities { }); factory ClientCapabilities.fromJson(Map json) { - final rootsMap = _asJsonObject(json['roots']); - final elicitationMap = _asJsonObject(json['elicitation']); - final tasksMap = _asJsonObject(json['tasks']); - final samplingMap = _asJsonObject(json['sampling']); + final rootsMap = _asJsonObject(json['roots'], 'ClientCapabilities.roots'); + final elicitationMap = _asJsonObject( + json['elicitation'], + 'ClientCapabilities.elicitation', + ); + final tasksMap = _asJsonObject(json['tasks'], 'ClientCapabilities.tasks'); + final samplingMap = _asJsonObject( + json['sampling'], + 'ClientCapabilities.sampling', + ); final extensionsMap = _asExtensionMap( json['extensions'], 'ClientCapabilities.extensions', @@ -631,12 +648,18 @@ class InitializeRequest { factory InitializeRequest.fromJson(Map json) => InitializeRequest( - protocolVersion: json['protocolVersion'] as String, + protocolVersion: readRequiredString( + json['protocolVersion'], + 'InitializeRequest.protocolVersion', + ), capabilities: ClientCapabilities.fromJson( - json['capabilities'] as Map, + readJsonObject( + json['capabilities'], + 'InitializeRequest.capabilities', + ), ), clientInfo: Implementation.fromJson( - json['clientInfo'] as Map, + readJsonObject(json['clientInfo'], 'InitializeRequest.clientInfo'), ), ); @@ -659,7 +682,10 @@ class JsonRpcInitializeRequest extends JsonRpcRequest { }) : super(method: Method.initialize, params: initParams.toJson()); factory JsonRpcInitializeRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcInitializeRequest.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for initialize request"); } @@ -771,8 +797,14 @@ class ServerCapabilitiesElicitation { url = const ServerElicitationUrl(); factory ServerCapabilitiesElicitation.fromJson(Map json) { - final formMap = _asJsonObject(json['form']); - final urlMap = _asJsonObject(json['url']); + final formMap = _asJsonObject( + json['form'], + 'ServerCapabilitiesElicitation.form', + ); + final urlMap = _asJsonObject( + json['url'], + 'ServerCapabilitiesElicitation.url', + ); return ServerCapabilitiesElicitation( form: formMap == null ? null : ServerElicitationForm.fromJson(formMap), @@ -797,7 +829,10 @@ class ServerCapabilitiesPrompts { factory ServerCapabilitiesPrompts.fromJson(Map json) { return ServerCapabilitiesPrompts( - listChanged: json['listChanged'] as bool?, + listChanged: readOptionalBool( + json['listChanged'], + 'ServerCapabilitiesPrompts.listChanged', + ), ); } @@ -821,8 +856,14 @@ class ServerCapabilitiesResources { factory ServerCapabilitiesResources.fromJson(Map json) { return ServerCapabilitiesResources( - subscribe: json['subscribe'] as bool?, - listChanged: json['listChanged'] as bool?, + subscribe: readOptionalBool( + json['subscribe'], + 'ServerCapabilitiesResources.subscribe', + ), + listChanged: readOptionalBool( + json['listChanged'], + 'ServerCapabilitiesResources.listChanged', + ), ); } @@ -843,7 +884,10 @@ class ServerCapabilitiesTools { factory ServerCapabilitiesTools.fromJson(Map json) { return ServerCapabilitiesTools( - listChanged: json['listChanged'] as bool?, + listChanged: readOptionalBool( + json['listChanged'], + 'ServerCapabilitiesTools.listChanged', + ), ); } @@ -869,7 +913,10 @@ class ServerCapabilitiesCompletions { factory ServerCapabilitiesCompletions.fromJson(Map json) { return ServerCapabilitiesCompletions( - listChanged: json['listChanged'] as bool?, + listChanged: readOptionalBool( + json['listChanged'], + 'ServerCapabilitiesCompletions.listChanged', + ), ); } @@ -893,7 +940,10 @@ class ServerCapabilitiesTasksTools { const ServerCapabilitiesTasksTools({this.call}); factory ServerCapabilitiesTasksTools.fromJson(Map json) { - final callMap = _asJsonObject(json['call']); + final callMap = _asJsonObject( + json['call'], + 'ServerCapabilitiesTasksTools.call', + ); return ServerCapabilitiesTasksTools( call: callMap == null ? null @@ -912,7 +962,10 @@ class ServerCapabilitiesTasksRequests { const ServerCapabilitiesTasksRequests({this.tools}); factory ServerCapabilitiesTasksRequests.fromJson(Map json) { - final toolsMap = _asJsonObject(json['tools']); + final toolsMap = _asJsonObject( + json['tools'], + 'ServerCapabilitiesTasksRequests.tools', + ); return ServerCapabilitiesTasksRequests( tools: toolsMap == null ? null @@ -949,14 +1002,26 @@ class ServerCapabilitiesTasks { }); factory ServerCapabilitiesTasks.fromJson(Map json) { - final requestsMap = _asJsonObject(json['requests']); + final requestsMap = _asJsonObject( + json['requests'], + 'ServerCapabilitiesTasks.requests', + ); return ServerCapabilitiesTasks( - list: _capabilityDeclared(json['list']), - cancel: _capabilityDeclared(json['cancel']), + list: _capabilityDeclared( + json['list'], + 'ServerCapabilitiesTasks.list', + ), + cancel: _capabilityDeclared( + json['cancel'], + 'ServerCapabilitiesTasks.cancel', + ), requests: requestsMap == null ? null : ServerCapabilitiesTasksRequests.fromJson(requestsMap), - listChanged: json['listChanged'] as bool?, + listChanged: readOptionalBool( + json['listChanged'], + 'ServerCapabilitiesTasks.listChanged', + ), ); } @@ -1023,12 +1088,21 @@ class ServerCapabilities { }); factory ServerCapabilities.fromJson(Map json) { - final pMap = _asJsonObject(json['prompts']); - final rMap = _asJsonObject(json['resources']); - final cMap = _asJsonObject(json['completions']); - final tMap = _asJsonObject(json['tools']); - final tasksMap = _asJsonObject(json['tasks']); - final elicitationMap = _asJsonObject(json['elicitation']); + final pMap = _asJsonObject(json['prompts'], 'ServerCapabilities.prompts'); + final rMap = _asJsonObject( + json['resources'], + 'ServerCapabilities.resources', + ); + final cMap = _asJsonObject( + json['completions'], + 'ServerCapabilities.completions', + ); + final tMap = _asJsonObject(json['tools'], 'ServerCapabilities.tools'); + final tasksMap = _asJsonObject(json['tasks'], 'ServerCapabilities.tasks'); + final elicitationMap = _asJsonObject( + json['elicitation'], + 'ServerCapabilities.elicitation', + ); final extensionsMap = _asExtensionMap( json['extensions'], 'ServerCapabilities.extensions', @@ -1039,7 +1113,10 @@ class ServerCapabilities { json['experimental'], 'ServerCapabilities.experimental', ), - logging: json['logging'] as Map?, + logging: readOptionalJsonObject( + json['logging'], + 'ServerCapabilities.logging', + ), prompts: pMap == null ? null : ServerCapabilitiesPrompts.fromJson(pMap), resources: rMap == null ? null : ServerCapabilitiesResources.fromJson(rMap), @@ -1061,7 +1138,8 @@ class ServerCapabilities { experimental, 'ServerCapabilities.experimental', ), - if (logging != null) 'logging': logging, + if (logging != null) + 'logging': readJsonObject(logging, 'ServerCapabilities.logging'), if (prompts != null) 'prompts': prompts!.toJson(), if (resources != null) 'resources': resources!.toJson(), if (tools != null) 'tools': tools!.toJson(), @@ -1109,14 +1187,23 @@ class InitializeResult implements BaseResultData { final meta = readOptionalJsonObject(json['_meta'], 'InitializeResult._meta'); return InitializeResult( - protocolVersion: json['protocolVersion'] as String, + protocolVersion: readRequiredString( + json['protocolVersion'], + 'InitializeResult.protocolVersion', + ), capabilities: ServerCapabilities.fromJson( - json['capabilities'] as Map, + readJsonObject( + json['capabilities'], + 'InitializeResult.capabilities', + ), ), serverInfo: Implementation.fromJson( - json['serverInfo'] as Map, + readJsonObject(json['serverInfo'], 'InitializeResult.serverInfo'), + ), + instructions: readOptionalString( + json['instructions'], + 'InitializeResult.instructions', ), - instructions: json['instructions'] as String?, meta: meta, ); } @@ -1181,14 +1268,20 @@ class DiscoverResult implements BaseResultData { } return DiscoverResult( - supportedVersions: supportedVersions.cast(), + supportedVersions: [ + for (final version in supportedVersions) + readRequiredString(version, 'DiscoverResult.supportedVersions items'), + ], capabilities: ServerCapabilities.fromJson( - json['capabilities'] as Map, + readJsonObject(json['capabilities'], 'DiscoverResult.capabilities'), ), serverInfo: Implementation.fromJson( - json['serverInfo'] as Map, + readJsonObject(json['serverInfo'], 'DiscoverResult.serverInfo'), + ), + instructions: readOptionalString( + json['instructions'], + 'DiscoverResult.instructions', ), - instructions: json['instructions'] as String?, meta: readOptionalJsonObject(json['_meta'], 'DiscoverResult._meta'), ); } diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index e5b8bf9b..8e83af2c 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1465,6 +1465,46 @@ void main() { ); }); + test('initialization and capability wire fields reject bad shapes', () { + final initializeRequest = { + 'protocolVersion': latestProtocolVersion, + 'capabilities': {}, + 'clientInfo': {'name': 'client', 'version': '1.0.0'}, + }; + final initializeResult = { + 'protocolVersion': latestProtocolVersion, + 'capabilities': {}, + 'serverInfo': {'name': 'server', 'version': '1.0.0'}, + }; + + for (final parse in [ + () => InitializeRequest.fromJson({ + ...initializeRequest, + 'protocolVersion': 1, + }), + () => InitializeRequest.fromJson({ + ...initializeRequest, + 'capabilities': 'bad', + }), + () => InitializeRequest.fromJson({ + ...initializeRequest, + 'clientInfo': 'bad', + }), + () => InitializeResult.fromJson({ + ...initializeResult, + 'capabilities': 'bad', + }), + () => InitializeResult.fromJson({ + ...initializeResult, + 'instructions': 1, + }), + () => ClientCapabilitiesRoots.fromJson({'listChanged': 'true'}), + () => ServerCapabilitiesResources.fromJson({'subscribe': 'true'}), + ]) { + expect(parse, throwsA(isA())); + } + }); + test('runtime value constraints are enforced without asserts', () { expect( () => Annotations(priority: 2).toJson(), diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index cf685523..3e000fb7 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -711,6 +711,43 @@ void main() { ); }); + test('server/discover and capability fields reject malformed wire shapes', + () { + final result = { + 'resultType': resultTypeComplete, + 'supportedVersions': [draftProtocolVersion2026_07_28], + 'capabilities': {}, + 'serverInfo': {'name': 'server', 'version': '1.0.0'}, + }; + + for (final parse in [ + () => DiscoverResult.fromJson({ + ...result, + 'supportedVersions': [draftProtocolVersion2026_07_28, 1], + }), + () => DiscoverResult.fromJson({ + ...result, + 'capabilities': 'bad', + }), + () => DiscoverResult.fromJson({ + ...result, + 'serverInfo': 'bad', + }), + () => DiscoverResult.fromJson({ + ...result, + 'instructions': 1, + }), + () => ClientCapabilitiesSampling.fromJson({ + 'tools': {'bad': Object()}, + }), + () => ServerCapabilities.fromJson({ + 'logging': {'bad': Object()}, + }), + ]) { + expect(parse, throwsFormatException); + } + }); + test('requires complete resultType on server/discover results', () { final validResult = const DiscoverResult( supportedVersions: [draftProtocolVersion2026_07_28], diff --git a/test/types_test.dart b/test/types_test.dart index 6d4903c0..e8eefc56 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -31,6 +31,37 @@ void main() { ); }); + test('initialize request rejects malformed wire fields', () { + final params = { + 'protocolVersion': latestProtocolVersion, + 'capabilities': {}, + 'clientInfo': {'name': 'test-client', 'version': '1.0.0'}, + }; + + for (final parse in [ + () => InitializeRequest.fromJson({ + ...params, + 'protocolVersion': 1, + }), + () => InitializeRequest.fromJson({ + ...params, + 'capabilities': 'bad', + }), + () => InitializeRequest.fromJson({ + ...params, + 'clientInfo': 'bad', + }), + () => JsonRpcInitializeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.initialize, + 'params': 'bad', + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + test('JsonRpcResponse serialization', () { final response = const JsonRpcResponse( id: 1, @@ -297,6 +328,57 @@ void main() { expect(parse, throwsA(isA())); } }); + + test('initialize and discover results reject malformed wire fields', () { + final initializeResult = { + 'protocolVersion': latestProtocolVersion, + 'capabilities': {}, + 'serverInfo': {'name': 'server', 'version': '1.0.0'}, + }; + final discoverResult = { + 'resultType': resultTypeComplete, + 'supportedVersions': [draftProtocolVersion2026_07_28], + 'capabilities': {}, + 'serverInfo': {'name': 'server', 'version': '1.0.0'}, + }; + + for (final parse in [ + () => InitializeResult.fromJson({ + ...initializeResult, + 'protocolVersion': 1, + }), + () => InitializeResult.fromJson({ + ...initializeResult, + 'capabilities': 'bad', + }), + () => InitializeResult.fromJson({ + ...initializeResult, + 'serverInfo': 'bad', + }), + () => InitializeResult.fromJson({ + ...initializeResult, + 'instructions': 1, + }), + () => DiscoverResult.fromJson({ + ...discoverResult, + 'supportedVersions': [draftProtocolVersion2026_07_28, 1], + }), + () => DiscoverResult.fromJson({ + ...discoverResult, + 'capabilities': 'bad', + }), + () => DiscoverResult.fromJson({ + ...discoverResult, + 'serverInfo': 'bad', + }), + () => DiscoverResult.fromJson({ + ...discoverResult, + 'instructions': 1, + }), + ]) { + expect(parse, throwsA(isA())); + } + }); }); group('ToolExecution Tests', () { @@ -447,6 +529,43 @@ void main() { throwsA(isA()), ); }); + + test('capability parsers reject malformed wire fields', () { + for (final parse in [ + () => ClientCapabilitiesRoots.fromJson({'listChanged': 'true'}), + () => ClientElicitationForm.fromJson({'applyDefaults': 'true'}), + () => ClientElicitation.fromJson({'form': 'bad'}), + () => ClientCapabilitiesSampling.fromJson({ + 'tools': {'bad': Object()}, + }), + () => ClientCapabilities.fromJson({ + 'experimental': { + 'feature': {'bad': Object()}, + }, + }), + () => ClientCapabilities.fromJson({ + 'extensions': { + 'io.example/feature': {'bad': Object()}, + }, + }), + () => ServerCapabilities.fromJson({ + 'logging': {'bad': Object()}, + }), + () => const ServerCapabilities( + logging: {'bad': Object()}, + ).toJson(), + () => ServerCapabilitiesPrompts.fromJson({'listChanged': 'true'}), + () => ServerCapabilitiesResources.fromJson({'subscribe': 'true'}), + () => ServerCapabilitiesTools.fromJson({'listChanged': 'true'}), + () => ServerCapabilitiesCompletions.fromJson({'listChanged': 'true'}), + () => ServerCapabilitiesTasks.fromJson({'listChanged': 'true'}), + () => ServerCapabilitiesTasks.fromJson({ + 'list': {'bad': Object()}, + }), + ]) { + expect(parse, throwsA(isA())); + } + }); }); group('Content Tests', () { From 49e86b1ed854b3ac28a7a39ffc008949a70ce7c3 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 09:14:27 -0400 Subject: [PATCH 09/68] Validate prompt completion wire fields --- CHANGELOG.md | 2 + lib/src/types/completion.dart | 40 +++++++---- lib/src/types/logging.dart | 19 +++-- lib/src/types/misc.dart | 28 +++++--- lib/src/types/prompts.dart | 100 +++++++++++++++++++------- lib/src/types/validation.dart | 11 +++ test/mcp_2025_11_25_test.dart | 47 ++++++++++++ test/mcp_2026_07_28_test.dart | 34 +++++++++ test/types/logging_types_test.dart | 49 +++++++++++++ test/types_edge_cases_test.dart | 14 ++++ test/types_test.dart | 110 +++++++++++++++++++++++++++++ 11 files changed, 402 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 252ff2a1..ede7f576 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,8 @@ parse errors. - Rejected malformed initialization and capability wire fields with protocol parse errors. +- Rejected malformed prompt, completion, logging, and common notification wire + fields with protocol parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/completion.dart b/lib/src/types/completion.dart index 57ceb848..48d4f7d4 100644 --- a/lib/src/types/completion.dart +++ b/lib/src/types/completion.dart @@ -11,7 +11,7 @@ sealed class Reference { }); factory Reference.fromJson(Map json) { - final type = json['type'] as String?; + final type = readRequiredString(json['type'], 'Reference.type'); return switch (type) { 'ref/resource' => ResourceReference.fromJson(json), 'ref/prompt' => PromptReference.fromJson(json), @@ -66,8 +66,8 @@ class PromptReference extends Reference { factory PromptReference.fromJson(Map json) { return PromptReference( - name: json['name'] as String, - title: json['title'] as String?, + name: readRequiredString(json['name'], 'PromptReference.name'), + title: readOptionalString(json['title'], 'PromptReference.title'), ); } } @@ -87,8 +87,8 @@ class ArgumentCompletionInfo { factory ArgumentCompletionInfo.fromJson(Map json) { return ArgumentCompletionInfo( - name: json['name'] as String, - value: json['value'] as String, + name: readRequiredString(json['name'], 'ArgumentCompletionInfo.name'), + value: readRequiredString(json['value'], 'ArgumentCompletionInfo.value'), ); } @@ -107,8 +107,9 @@ class CompletionContext { factory CompletionContext.fromJson(Map json) { return CompletionContext( - arguments: (json['arguments'] as Map?)?.map( - (key, value) => MapEntry(key, value as String), + arguments: readOptionalStringMap( + json['arguments'], + 'CompletionContext.arguments', ), ); } @@ -137,14 +138,16 @@ class CompleteRequest { factory CompleteRequest.fromJson(Map json) => CompleteRequest( - ref: Reference.fromJson(json['ref'] as Map), + ref: Reference.fromJson( + readJsonObject(json['ref'], 'CompleteRequest.ref'), + ), argument: ArgumentCompletionInfo.fromJson( - json['argument'] as Map, + readJsonObject(json['argument'], 'CompleteRequest.argument'), ), context: json['context'] == null ? null : CompletionContext.fromJson( - json['context'] as Map, + readJsonObject(json['context'], 'CompleteRequest.context'), ), ); @@ -170,7 +173,10 @@ class JsonRpcCompleteRequest extends JsonRpcRequest { ); factory JsonRpcCompleteRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcCompleteRequest.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for complete request"); } @@ -211,9 +217,15 @@ class CompletionResultData { ); } return CompletionResultData( - values: values.cast(), + values: [ + for (final value in values) + readRequiredString(value, 'CompletionResultData.values items'), + ], total: readOptionalInteger(json['total'], 'CompletionResultData.total'), - hasMore: json['hasMore'] as bool?, + hasMore: readOptionalBool( + json['hasMore'], + 'CompletionResultData.hasMore', + ), ); } @@ -248,7 +260,7 @@ class CompleteResult implements BaseResultData { final meta = readOptionalJsonObject(json['_meta'], 'CompleteResult._meta'); return CompleteResult( completion: CompletionResultData.fromJson( - json['completion'] as Map, + readJsonObject(json['completion'], 'CompleteResult.completion'), ), meta: meta, ); diff --git a/lib/src/types/logging.dart b/lib/src/types/logging.dart index fe4090ae..6553f1c9 100644 --- a/lib/src/types/logging.dart +++ b/lib/src/types/logging.dart @@ -44,7 +44,10 @@ class JsonRpcSetLevelRequest extends JsonRpcRequest { }) : super(method: Method.loggingSetLevel, params: setParams.toJson()); factory JsonRpcSetLevelRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcSetLevelRequest.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for set level request"); } @@ -83,14 +86,17 @@ class LoggingMessageNotification { LoggingLevel.values, 'LoggingMessageNotification.level', ), - logger: json['logger'] as String?, - data: json['data'], + logger: readOptionalString( + json['logger'], + 'LoggingMessageNotification.logger', + ), + data: readJsonValue(json['data'], 'LoggingMessageNotification.data'), ); Map toJson() => { 'level': level.name, if (logger != null) 'logger': logger, - 'data': data, + 'data': readJsonValue(data, 'LoggingMessageNotification.data'), }; } @@ -105,7 +111,10 @@ class JsonRpcLoggingMessageNotification extends JsonRpcNotification { factory JsonRpcLoggingMessageNotification.fromJson( Map json, ) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcLoggingMessageNotification.params', + ); if (paramsMap == null) { throw const FormatException( "Missing params for logging message notification", diff --git a/lib/src/types/misc.dart b/lib/src/types/misc.dart index e4670eb1..f386e6f3 100644 --- a/lib/src/types/misc.dart +++ b/lib/src/types/misc.dart @@ -17,21 +17,27 @@ class EmptyResult implements BaseResultData { /// Parameters for the `notifications/cancelled` notification. class CancelledNotification { /// The ID of the request to cancel. - final RequestId requestId; + final RequestId? requestId; /// An optional string describing the reason for the cancellation. final String? reason; - const CancelledNotification({required this.requestId, this.reason}); + const CancelledNotification({this.requestId, this.reason}); factory CancelledNotification.fromJson(Map json) => CancelledNotification( - requestId: parseRequestId(json['requestId'], fieldName: 'requestId'), - reason: json['reason'] as String?, + requestId: json.containsKey('requestId') + ? parseRequestId(json['requestId'], fieldName: 'requestId') + : null, + reason: readOptionalString( + json['reason'], + 'CancelledNotification.reason', + ), ); Map toJson() => { - 'requestId': parseRequestId(requestId, fieldName: 'requestId'), + if (requestId != null) + 'requestId': parseRequestId(requestId, fieldName: 'requestId'), if (reason != null) 'reason': reason, }; } @@ -48,7 +54,10 @@ class JsonRpcCancelledNotification extends JsonRpcNotification { ); factory JsonRpcCancelledNotification.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcCancelledNotification.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for cancelled notification"); } @@ -97,7 +106,7 @@ class Progress { return Progress( progress: readFiniteNumber(json['progress'], 'Progress.progress'), total: readOptionalFiniteNumber(json['total'], 'Progress.total'), - message: json['message'] as String?, + message: readOptionalString(json['message'], 'Progress.message'), ); } @@ -171,7 +180,10 @@ class JsonRpcProgressNotification extends JsonRpcNotification { /// Creates from JSON. factory JsonRpcProgressNotification.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcProgressNotification.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for progress notification"); } diff --git a/lib/src/types/prompts.dart b/lib/src/types/prompts.dart index a74ab569..bfadf3ed 100644 --- a/lib/src/types/prompts.dart +++ b/lib/src/types/prompts.dart @@ -2,6 +2,23 @@ import '../types.dart'; import 'json_rpc.dart'; import 'validation.dart'; +List? _readOptionalObjectList( + Object? value, + String field, + T Function(Map json) fromJson, +) { + if (value == null) { + return null; + } + if (value is! List) { + throw FormatException('$field must be a list of objects'); + } + return [ + for (var i = 0; i < value.length; i++) + fromJson(readJsonObject(value[i], '$field[$i]')), + ]; +} + /// Describes an argument accepted by a prompt template. class PromptArgument { /// The name of the argument. @@ -25,10 +42,13 @@ class PromptArgument { factory PromptArgument.fromJson(Map json) { return PromptArgument( - name: json['name'] as String, - title: json['title'] as String?, - description: json['description'] as String?, - required: json['required'] as bool?, + name: readRequiredString(json['name'], 'PromptArgument.name'), + title: readOptionalString(json['title'], 'PromptArgument.title'), + description: readOptionalString( + json['description'], + 'PromptArgument.description', + ), + required: readOptionalBool(json['required'], 'PromptArgument.required'), ); } @@ -78,18 +98,23 @@ class Prompt { factory Prompt.fromJson(Map json) { return Prompt( - name: json['name'] as String, - title: json['title'] as String?, - description: json['description'] as String?, - arguments: (json['arguments'] as List?) - ?.map((a) => PromptArgument.fromJson(a as Map)) - .toList(), + name: readRequiredString(json['name'], 'Prompt.name'), + title: readOptionalString(json['title'], 'Prompt.title'), + description: + readOptionalString(json['description'], 'Prompt.description'), + arguments: _readOptionalObjectList( + json['arguments'], + 'Prompt.arguments', + PromptArgument.fromJson, + ), icon: json['icon'] != null - ? ImageContent.fromJson(json['icon'] as Map) + ? ImageContent.fromJson(readJsonObject(json['icon'], 'Prompt.icon')) : null, - icons: (json['icons'] as List?) - ?.map((e) => McpIcon.fromJson(e as Map)) - .toList(), + icons: _readOptionalObjectList( + json['icons'], + 'Prompt.icons', + McpIcon.fromJson, + ), meta: readOptionalJsonObject(json['_meta'], 'Prompt._meta'), ); } @@ -114,7 +139,9 @@ class ListPromptsRequest { const ListPromptsRequest({this.cursor}); factory ListPromptsRequest.fromJson(Map json) => - ListPromptsRequest(cursor: json['cursor'] as String?); + ListPromptsRequest( + cursor: readOptionalString(json['cursor'], 'ListPromptsRequest.cursor'), + ); Map toJson() => {if (cursor != null) 'cursor': cursor}; } @@ -132,7 +159,10 @@ class JsonRpcListPromptsRequest extends JsonRpcRequest { super(method: Method.promptsList, params: params?.toJson()); factory JsonRpcListPromptsRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcListPromptsRequest.params', + ); final meta = extractRequestMeta(json); return JsonRpcListPromptsRequest( id: parseRequestId(json['id']), @@ -179,9 +209,16 @@ class ListPromptsResult implements CacheableResultData { } return ListPromptsResult( prompts: prompts - .map((p) => Prompt.fromJson(p as Map)) + .map( + (p) => Prompt.fromJson( + readJsonObject(p, 'ListPromptsResult.prompts items'), + ), + ) .toList(), - nextCursor: json['nextCursor'] as String?, + nextCursor: readOptionalString( + json['nextCursor'], + 'ListPromptsResult.nextCursor', + ), ttlMs: readOptionalTtlMs(json['ttlMs'], 'ListPromptsResult.ttlMs'), cacheScope: readOptionalCacheScope( json['cacheScope'], @@ -229,9 +266,10 @@ class GetPromptRequest { factory GetPromptRequest.fromJson(Map json) => GetPromptRequest( - name: json['name'] as String, - arguments: (json['arguments'] as Map?)?.map( - (k, v) => MapEntry(k, v as String), + name: readRequiredString(json['name'], 'GetPromptRequest.name'), + arguments: readOptionalStringMap( + json['arguments'], + 'GetPromptRequest.arguments', ), inputResponses: InputResponse.mapFromJson( json['inputResponses'], @@ -264,7 +302,10 @@ class JsonRpcGetPromptRequest extends JsonRpcRequest { }) : super(method: Method.promptsGet, params: getParams.toJson()); factory JsonRpcGetPromptRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcGetPromptRequest.params', + ); if (paramsMap == null) { throw const FormatException("Missing params for get prompt request"); } @@ -298,7 +339,9 @@ class PromptMessage { role: PromptMessageRole.values.byName( readRequiredRoleString(json['role'], 'PromptMessage.role'), ), - content: Content.fromJson(json['content'] as Map), + content: Content.fromJson( + readJsonObject(json['content'], 'PromptMessage.content'), + ), ); } @@ -329,9 +372,16 @@ class GetPromptResult implements BaseResultData { throw const FormatException('GetPromptResult.messages is required'); } return GetPromptResult( - description: json['description'] as String?, + description: readOptionalString( + json['description'], + 'GetPromptResult.description', + ), messages: messages - .map((m) => PromptMessage.fromJson(m as Map)) + .map( + (m) => PromptMessage.fromJson( + readJsonObject(m, 'GetPromptResult.messages items'), + ), + ) .toList(), meta: meta, ); diff --git a/lib/src/types/validation.dart b/lib/src/types/validation.dart index b45cf656..a27a582b 100644 --- a/lib/src/types/validation.dart +++ b/lib/src/types/validation.dart @@ -277,6 +277,17 @@ List? readOptionalStringList(Object? value, String field) { ]; } +Map? readOptionalStringMap(Object? value, String field) { + if (value == null) { + return null; + } + final map = readJsonObject(value, field); + return { + for (final entry in map.entries) + entry.key: readRequiredString(entry.value, '$field.${entry.key}'), + }; +} + int? readOptionalTtlMs(Object? value, String field) { final ttlMs = readOptionalInteger(value, field); if (ttlMs == null) { diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 8e83af2c..4c82d73b 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1605,6 +1605,53 @@ void main() { }), throwsA(isA()), ); + expect( + () => LoggingMessageNotificationParams.fromJson({ + 'level': 'info', + 'data': Object(), + }), + throwsA(isA()), + ); + expect( + () => PromptArgument.fromJson({'name': 1}), + throwsA(isA()), + ); + expect( + () => Prompt.fromJson({ + 'name': 'prompt', + 'arguments': [1], + }), + throwsA(isA()), + ); + expect( + () => GetPromptRequest.fromJson({ + 'name': 'prompt', + 'arguments': {'arg': 1}, + }), + throwsA(isA()), + ); + expect( + () => CompleteRequest.fromJson({ + 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, + 'argument': {'name': 'arg', 'value': 1}, + }), + throwsA(isA()), + ); + expect( + () => CompletionResultData.fromJson({ + 'values': ['a'], + 'hasMore': 'true', + }), + throwsA(isA()), + ); + expect( + () => ProgressNotification.fromJson({ + 'progressToken': 'progress-1', + 'progress': 1, + 'message': 1, + }), + throwsA(isA()), + ); expect( () => CreateMessageRequestParams.fromJson({ 'messages': [ diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 3e000fb7..a14b8cfe 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -927,6 +927,40 @@ void main() { ); }); + test( + 'prompt completion and notification fields reject malformed wire shapes', + () { + for (final parse in [ + () => Prompt.fromJson({ + 'name': 'prompt', + 'arguments': [1], + }), + () => GetPromptRequest.fromJson({ + 'name': 'prompt', + 'arguments': {'arg': 1}, + }), + () => CompleteRequest.fromJson({ + 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, + 'argument': {'name': 'arg', 'value': 1}, + }), + () => CompletionResultData.fromJson({ + 'values': ['a'], + 'hasMore': 'true', + }), + () => LoggingMessageNotification.fromJson({ + 'level': 'info', + 'data': Object(), + }), + () => ProgressNotification.fromJson({ + 'progressToken': 'progress-1', + 'progress': 1, + 'message': 1, + }), + ]) { + expect(parse, throwsFormatException); + } + }); + test('serializes MRTR input required results', () { final result = InputRequiredResult( inputRequests: { diff --git a/test/types/logging_types_test.dart b/test/types/logging_types_test.dart index d4200bae..a4cfabe0 100644 --- a/test/types/logging_types_test.dart +++ b/test/types/logging_types_test.dart @@ -111,6 +111,19 @@ void main() { ); }); + test('fromJson rejects non-object params', () { + final json = { + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'logging/setLevel', + 'params': 'bad', + }; + expect( + () => JsonRpcSetLevelRequest.fromJson(json), + throwsA(isA()), + ); + }); + test('toJson serializes correctly', () { final request = JsonRpcSetLevelRequest( id: 5, @@ -215,6 +228,30 @@ void main() { throwsA(isA()), ); }); + + test('rejects malformed logger and non-JSON data values', () { + expect( + () => LoggingMessageNotificationParams.fromJson({ + 'level': 'info', + 'logger': 1, + }), + throwsA(isA()), + ); + expect( + () => LoggingMessageNotificationParams.fromJson({ + 'level': 'info', + 'data': Object(), + }), + throwsA(isA()), + ); + expect( + () => const LoggingMessageNotificationParams( + level: LoggingLevel.info, + data: Object(), + ).toJson(), + throwsA(isA()), + ); + }); }); group('JsonRpcLoggingMessageNotification', () { @@ -270,6 +307,18 @@ void main() { ); }); + test('fromJson rejects non-object params', () { + final json = { + 'jsonrpc': '2.0', + 'method': 'notifications/message', + 'params': 'bad', + }; + expect( + () => JsonRpcLoggingMessageNotification.fromJson(json), + throwsA(isA()), + ); + }); + test('toJson serializes correctly', () { final notification = JsonRpcLoggingMessageNotification( logParams: const LoggingMessageNotificationParams( diff --git a/test/types_edge_cases_test.dart b/test/types_edge_cases_test.dart index aff5633d..5333a5ba 100644 --- a/test/types_edge_cases_test.dart +++ b/test/types_edge_cases_test.dart @@ -177,6 +177,20 @@ void main() { expect(json.containsKey('reason'), isFalse); }); + test('allows omitted requestId per notification wire schema', () { + final parsed = JsonRpcCancelledNotification.fromJson({ + 'jsonrpc': '2.0', + 'method': 'notifications/cancelled', + 'params': {'reason': 'Task cancellation uses tasks/cancel'}, + }); + + expect(parsed.cancelParams.requestId, isNull); + expect(parsed.cancelParams.reason, 'Task cancellation uses tasks/cancel'); + expect(parsed.toJson()['params'], { + 'reason': 'Task cancellation uses tasks/cancel', + }); + }); + test('rejects malformed requestId wire values', () { for (final requestId in [ null, diff --git a/test/types_test.dart b/test/types_test.dart index e8eefc56..5f9c9c86 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -1307,6 +1307,69 @@ void main() { expect(deserialized.required, equals(true)); }); + test('Prompt parsers reject malformed wire fields', () { + for (final parse in [ + () => PromptArgument.fromJson({'name': 1}), + () => PromptArgument.fromJson({ + 'name': 'arg', + 'required': 'true', + }), + () => Prompt.fromJson({'name': 1}), + () => Prompt.fromJson({ + 'name': 'prompt', + 'arguments': 'bad', + }), + () => Prompt.fromJson({ + 'name': 'prompt', + 'arguments': [1], + }), + () => Prompt.fromJson({ + 'name': 'prompt', + 'icon': 'bad', + }), + () => Prompt.fromJson({ + 'name': 'prompt', + 'icons': [1], + }), + () => ListPromptsRequest.fromJson({'cursor': 1}), + () => ListPromptsResult.fromJson({ + 'prompts': [1], + }), + () => ListPromptsResult.fromJson({ + 'prompts': [], + 'nextCursor': 1, + }), + () => GetPromptRequest.fromJson({'name': 1}), + () => GetPromptRequest.fromJson({ + 'name': 'prompt', + 'arguments': 'bad', + }), + () => GetPromptRequest.fromJson({ + 'name': 'prompt', + 'arguments': {'arg': 1}, + }), + () => JsonRpcGetPromptRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.promptsGet, + 'params': 'bad', + }), + () => PromptMessage.fromJson({ + 'role': 'user', + 'content': 'bad', + }), + () => GetPromptResult.fromJson({ + 'description': 1, + 'messages': [], + }), + () => GetPromptResult.fromJson({ + 'messages': [1], + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + test('PromptMessage validates role wire values', () { expect( () => PromptMessage.fromJson({ @@ -1324,6 +1387,53 @@ void main() { ); }); }); + + group('Completion Tests', () { + test('Completion parsers reject malformed wire fields', () { + final validRef = {'type': 'ref/prompt', 'name': 'prompt'}; + final validArgument = {'name': 'arg', 'value': 'prefix'}; + + for (final parse in [ + () => Reference.fromJson({'type': 1}), + () => PromptReference.fromJson({'name': 1}), + () => ArgumentCompletionInfo.fromJson({'name': 1, 'value': 'v'}), + () => ArgumentCompletionInfo.fromJson({'name': 'arg', 'value': 1}), + () => CompletionContext.fromJson({ + 'arguments': {'arg': 1}, + }), + () => CompleteRequest.fromJson({ + 'ref': 'bad', + 'argument': validArgument, + }), + () => CompleteRequest.fromJson({ + 'ref': validRef, + 'argument': 'bad', + }), + () => CompleteRequest.fromJson({ + 'ref': validRef, + 'argument': validArgument, + 'context': 'bad', + }), + () => JsonRpcCompleteRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.completionComplete, + 'params': 'bad', + }), + () => CompletionResultData.fromJson({ + 'values': ['one', 1], + }), + () => CompletionResultData.fromJson({ + 'values': ['one'], + 'hasMore': 'true', + }), + () => CompleteResult.fromJson({'completion': 'bad'}), + ]) { + expect(parse, throwsA(isA())); + } + }); + }); + group('CreateMessageResult Tests', () { test('CreateMessageResult serialization and deserialization', () { final result = const CreateMessageResult( From ddd8cb5c4a7220adc9489832c3e265290a47e568 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 09:26:35 -0400 Subject: [PATCH 10/68] Validate tool wire fields --- CHANGELOG.md | 2 + lib/src/types/json_rpc.dart | 11 +++- lib/src/types/tools.dart | 101 ++++++++++++++++++++++++--------- test/mcp_2025_11_25_test.dart | 46 +++++++++++++++ test/mcp_2026_07_28_test.dart | 40 +++++++++++++ test/types_test.dart | 102 ++++++++++++++++++++++++++++++++++ 6 files changed, 275 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ede7f576..d3f863d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,8 @@ parse errors. - Rejected malformed prompt, completion, logging, and common notification wire fields with protocol parse errors. +- Rejected malformed tool definition, tool-list, and tool-call wire fields with + protocol parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index ddc82f9a..88bfc2f0 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -1060,7 +1060,10 @@ class JsonRpcListToolsRequest extends JsonRpcRequest { factory JsonRpcListToolsRequest.fromJson(Map json) { return JsonRpcListToolsRequest( id: parseRequestId(json['id']), - params: json['params'] as Map?, + params: readOptionalJsonObject( + json['params'], + 'JsonRpcListToolsRequest.params', + ), meta: extractRequestMeta(json), ); } @@ -1085,7 +1088,11 @@ class JsonRpcCallToolRequest extends JsonRpcRequest { factory JsonRpcCallToolRequest.fromJson(Map json) { return JsonRpcCallToolRequest( id: parseRequestId(json['id']), - params: json['params'] as Map? ?? {}, + params: readOptionalJsonObject( + json['params'], + 'JsonRpcCallToolRequest.params', + ) ?? + {}, meta: extractRequestMeta(json), ); } diff --git a/lib/src/types/tools.dart b/lib/src/types/tools.dart index 94ca94b2..dd9b1fc1 100644 --- a/lib/src/types/tools.dart +++ b/lib/src/types/tools.dart @@ -77,13 +77,32 @@ class ToolAnnotations { factory ToolAnnotations.fromJson(Map json) { return ToolAnnotations( - title: json['title'] as String?, - readOnlyHint: json['readOnlyHint'] as bool? ?? false, - destructiveHint: json['destructiveHint'] as bool? ?? true, - idempotentHint: json['idempotentHint'] as bool? ?? false, - openWorldHint: json['openWorldHint'] as bool? ?? true, + title: readOptionalString(json['title'], 'ToolAnnotations.title'), + readOnlyHint: readOptionalBool( + json['readOnlyHint'], + 'ToolAnnotations.readOnlyHint', + ) ?? + false, + destructiveHint: readOptionalBool( + json['destructiveHint'], + 'ToolAnnotations.destructiveHint', + ) ?? + true, + idempotentHint: readOptionalBool( + json['idempotentHint'], + 'ToolAnnotations.idempotentHint', + ) ?? + false, + openWorldHint: readOptionalBool( + json['openWorldHint'], + 'ToolAnnotations.openWorldHint', + ) ?? + true, priority: readUnitDouble(json['priority'], 'ToolAnnotations.priority'), - audience: (json['audience'] as List?)?.cast(), + audience: readOptionalAnnotationAudience( + json['audience'], + 'ToolAnnotations.audience', + ), ); } @@ -117,7 +136,9 @@ class ToolExecution { const ToolExecution({this.taskSupport = 'forbidden'}); factory ToolExecution.fromJson(Map json) { - final taskSupport = json['taskSupport'] as String? ?? 'forbidden'; + final taskSupport = + readOptionalString(json['taskSupport'], 'ToolExecution.taskSupport') ?? + 'forbidden'; if (!allowedTaskSupportValues.contains(taskSupport)) { throw FormatException( "Invalid tool execution taskSupport '$taskSupport'. Expected one of: ${allowedTaskSupportValues.join(', ')}", @@ -202,26 +223,30 @@ class Tool { outputSchemaJson == null ? null : JsonSchema.fromJson(outputSchemaJson); return Tool( - name: json['name'] as String, - title: json['title'] as String?, - description: json['description'] as String?, + name: readRequiredString(json['name'], 'Tool.name'), + title: readOptionalString(json['title'], 'Tool.title'), + description: readOptionalString(json['description'], 'Tool.description'), inputSchema: inputSchema, outputSchema: outputSchema, annotations: json['annotations'] != null ? ToolAnnotations.fromJson( - json['annotations'] as Map, + readJsonObject(json['annotations'], 'Tool.annotations'), ) : null, meta: readOptionalJsonObject(json['_meta'], 'Tool._meta'), execution: json['execution'] != null - ? ToolExecution.fromJson(json['execution'] as Map) + ? ToolExecution.fromJson( + readJsonObject(json['execution'], 'Tool.execution'), + ) : null, icon: json['icon'] != null - ? ImageContent.fromJson(json['icon'] as Map) + ? ImageContent.fromJson(readJsonObject(json['icon'], 'Tool.icon')) : null, - icons: (json['icons'] as List?) - ?.map((e) => McpIcon.fromJson(e as Map)) - .toList(), + icons: _readOptionalObjectList( + json['icons'], + 'Tool.icons', + McpIcon.fromJson, + ), ); } @@ -251,7 +276,7 @@ class ListToolsRequest { factory ListToolsRequest.fromJson(Map json) { return ListToolsRequest( - cursor: json['cursor'] as String?, + cursor: readOptionalString(json['cursor'], 'ListToolsRequest.cursor'), ); } @@ -297,9 +322,14 @@ class ListToolsResult implements CacheableResultData { throw const FormatException('ListToolsResult.tools is required'); } return ListToolsResult( - tools: - tools.map((e) => Tool.fromJson(e as Map)).toList(), - nextCursor: json['nextCursor'] as String?, + tools: [ + for (var i = 0; i < tools.length; i++) + Tool.fromJson( + readJsonObject(tools[i], 'ListToolsResult.tools[$i]'), + ), + ], + nextCursor: + readOptionalString(json['nextCursor'], 'ListToolsResult.nextCursor'), ttlMs: readOptionalTtlMs(json['ttlMs'], 'ListToolsResult.ttlMs'), cacheScope: readOptionalCacheScope( json['cacheScope'], @@ -350,7 +380,7 @@ class CallToolRequest { factory CallToolRequest.fromJson(Map json) { final arguments = json['arguments']; return CallToolRequest( - name: json['name'] as String, + name: readRequiredString(json['name'], 'CallToolRequest.name'), arguments: arguments == null ? const {} : _readJsonObject(arguments, 'CallToolRequest.arguments'), @@ -437,10 +467,14 @@ class CallToolResult implements BaseResultData { } return CallToolResult( - content: content - .map((e) => Content.fromJson(e as Map)) - .toList(), - isError: json['isError'] as bool? ?? false, + content: [ + for (var i = 0; i < content.length; i++) + Content.fromJson( + readJsonObject(content[i], 'CallToolResult.content[$i]'), + ), + ], + isError: + readOptionalBool(json['isError'], 'CallToolResult.isError') ?? false, structuredContent: json.containsKey('structuredContent') ? readJsonValue( json['structuredContent'], @@ -511,6 +545,23 @@ Map? _readOptionalJsonObject(Object? value, String field) { return _readJsonObject(value, field); } +List? _readOptionalObjectList( + Object? value, + String field, + T Function(Map json) fromJson, +) { + if (value == null) { + return null; + } + if (value is! List) { + throw FormatException('$field must be a list of JSON objects'); + } + return [ + for (var i = 0; i < value.length; i++) + fromJson(_readJsonObject(value[i], '$field[$i]')), + ]; +} + Map _readJsonObject(Object? value, String field) { return readJsonObject(value, field); } diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 4c82d73b..875646b8 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -621,6 +621,52 @@ void main() { ); }); + test('Tool wire fields reject malformed values', () { + for (final parse in [ + () => ToolAnnotations.fromJson({'title': 1}), + () => ToolAnnotations.fromJson({'readOnlyHint': 'true'}), + () => ToolExecution.fromJson({'taskSupport': 1}), + () => Tool.fromJson({ + 'name': 1, + 'inputSchema': {'type': 'object'}, + }), + () => Tool.fromJson({ + 'name': 'search', + 'inputSchema': {'type': 'object'}, + 'annotations': 'bad', + }), + () => Tool.fromJson({ + 'name': 'search', + 'inputSchema': {'type': 'object'}, + 'icons': [1], + }), + () => ListToolsRequest.fromJson({'cursor': 1}), + () => ListToolsResult.fromJson({ + 'tools': >[], + 'nextCursor': 1, + }), + () => CallToolRequest.fromJson({'name': 1}), + () => CallToolResult.fromJson({ + 'content': >[], + 'isError': 'true', + }), + () => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsList, + 'params': 'bad', + }), + () => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsCall, + 'params': 'bad', + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + test('Result metadata fields reject non-JSON Dart maps', () { expect( () => Root.fromJson({ diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index a14b8cfe..362360e5 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1280,6 +1280,46 @@ void main() { ); }); + test('rejects malformed tool wire shapes', () { + for (final parse in [ + () => ToolAnnotations.fromJson({'openWorldHint': 'false'}), + () => ToolExecution.fromJson({'taskSupport': 1}), + () => Tool.fromJson({ + 'name': 'search', + 'inputSchema': {'type': 'object'}, + 'execution': 'bad', + }), + () => Tool.fromJson({ + 'name': 'search', + 'inputSchema': {'type': 'object'}, + 'icons': [1], + }), + () => ListToolsRequest.fromJson({'cursor': 1}), + () => ListToolsResult.fromJson({ + 'tools': [1], + }), + () => CallToolRequest.fromJson({'name': 1}), + () => CallToolResult.fromJson({ + 'content': >[], + 'isError': 'true', + }), + () => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsList, + 'params': 'bad', + }), + () => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsCall, + 'params': 'bad', + }), + ]) { + expect(parse, throwsFormatException); + } + }); + test('server acknowledges subscriptions/listen with subscription id', () async { final server = Server( diff --git a/test/types_test.dart b/test/types_test.dart index 5f9c9c86..deab3c09 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -381,6 +381,23 @@ void main() { }); }); + group('ToolAnnotations Tests', () { + test('rejects malformed wire fields', () { + for (final parse in [ + () => ToolAnnotations.fromJson({'title': 1}), + () => ToolAnnotations.fromJson({'readOnlyHint': 'true'}), + () => ToolAnnotations.fromJson({'destructiveHint': 'true'}), + () => ToolAnnotations.fromJson({'idempotentHint': 'false'}), + () => ToolAnnotations.fromJson({'openWorldHint': 'false'}), + () => ToolAnnotations.fromJson({ + 'audience': ['user', 1], + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + }); + group('ToolExecution Tests', () { test('rejects invalid taskSupport while parsing wire JSON', () { expect( @@ -389,6 +406,13 @@ void main() { ); }); + test('rejects non-string taskSupport while parsing wire JSON', () { + expect( + () => ToolExecution.fromJson({'taskSupport': 1}), + throwsA(isA()), + ); + }); + test('rejects invalid taskSupport while serializing wire JSON', () { expect( () => const ToolExecution(taskSupport: 'sometimes').toJson(), @@ -397,6 +421,84 @@ void main() { }); }); + group('Tool wire parsing Tests', () { + test('rejects malformed tool definition fields', () { + for (final parse in [ + () => Tool.fromJson({ + 'name': 1, + 'inputSchema': {'type': 'object'}, + }), + () => Tool.fromJson({ + 'name': 'search', + 'title': 1, + 'inputSchema': {'type': 'object'}, + }), + () => Tool.fromJson({ + 'name': 'search', + 'description': 1, + 'inputSchema': {'type': 'object'}, + }), + () => Tool.fromJson({ + 'name': 'search', + 'inputSchema': {'type': 'object'}, + 'annotations': 'bad', + }), + () => Tool.fromJson({ + 'name': 'search', + 'inputSchema': {'type': 'object'}, + 'execution': 'bad', + }), + () => Tool.fromJson({ + 'name': 'search', + 'inputSchema': {'type': 'object'}, + 'icon': 'bad', + }), + () => Tool.fromJson({ + 'name': 'search', + 'inputSchema': {'type': 'object'}, + 'icons': [1], + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + + test('rejects malformed list and call fields', () { + for (final parse in [ + () => ListToolsRequest.fromJson({'cursor': 1}), + () => ListToolsResult.fromJson({ + 'tools': [1], + }), + () => ListToolsResult.fromJson({ + 'tools': >[], + 'nextCursor': 1, + }), + () => CallToolRequest.fromJson({'name': 1}), + () => CallToolResult.fromJson({ + 'content': [1], + }), + () => CallToolResult.fromJson({ + 'content': >[], + 'isError': 'true', + }), + () => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsList, + 'params': 'bad', + }), + () => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsCall, + 'params': 'bad', + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + }); + group('Capabilities Tests', () { test('ServerCapabilitiesCompletions serialization and deserialization', () { final completions = From f2f3b0cc1d70dbc610dd9ad8959e50956788e6ae Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 09:36:30 -0400 Subject: [PATCH 11/68] Validate root wire fields --- CHANGELOG.md | 1 + lib/src/types/roots.dart | 27 ++++++++++++++++++++++----- test/mcp_2025_11_25_test.dart | 33 +++++++++++++++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 33 +++++++++++++++++++++++++++++++++ test/types_test.dart | 35 +++++++++++++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3f863d2..e8e5066d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,7 @@ fields with protocol parse errors. - Rejected malformed tool definition, tool-list, and tool-call wire fields with protocol parse errors. +- Rejected malformed root-list wire fields with protocol parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/roots.dart b/lib/src/types/roots.dart index 648b7fd2..1895ed28 100644 --- a/lib/src/types/roots.dart +++ b/lib/src/types/roots.dart @@ -41,7 +41,7 @@ class Root { factory Root.fromJson(Map json) { return Root( uri: _readRootUri(json['uri']), - name: json['name'] as String?, + name: readOptionalString(json['name'], 'Root.name'), meta: readOptionalJsonObject(json['_meta'], 'Root._meta'), ); } @@ -62,6 +62,7 @@ class JsonRpcListRootsRequest extends JsonRpcRequest { : super(method: Method.rootsList); factory JsonRpcListRootsRequest.fromJson(Map json) { + _readOptionalParamsObject(json, 'JsonRpcListRootsRequest.params'); return JsonRpcListRootsRequest( id: parseRequestId(json['id']), meta: extractRequestMeta(json), @@ -87,8 +88,12 @@ class ListRootsResult implements BaseResultData { throw const FormatException('ListRootsResult.roots is required'); } return ListRootsResult( - roots: - roots.map((r) => Root.fromJson(r as Map)).toList(), + roots: [ + for (var i = 0; i < roots.length; i++) + Root.fromJson( + readJsonObject(roots[i], 'ListRootsResult.roots[$i]'), + ), + ], meta: meta, ); } @@ -108,6 +113,18 @@ class JsonRpcRootsListChangedNotification extends JsonRpcNotification { factory JsonRpcRootsListChangedNotification.fromJson( Map json, - ) => - JsonRpcRootsListChangedNotification(meta: extractRequestMeta(json)); + ) { + _readOptionalParamsObject( + json, + 'JsonRpcRootsListChangedNotification.params', + ); + return JsonRpcRootsListChangedNotification(meta: extractRequestMeta(json)); + } +} + +void _readOptionalParamsObject(Map json, String field) { + if (!json.containsKey('params')) { + return; + } + readJsonObject(json['params'], field); } diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 875646b8..8bb24b64 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -667,6 +667,39 @@ void main() { } }); + test('Root wire fields reject malformed values', () { + for (final parse in [ + () => Root.fromJson({'uri': 'file:///repo', 'name': 1}), + () => ListRootsResult.fromJson({ + 'roots': [1], + }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.rootsList, + 'params': 'bad', + }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.rootsList, + 'params': null, + }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsRootsListChanged, + 'params': 'bad', + }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsRootsListChanged, + 'params': null, + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + test('Result metadata fields reject non-JSON Dart maps', () { expect( () => Root.fromJson({ diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 362360e5..73fc19ad 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1320,6 +1320,39 @@ void main() { } }); + test('rejects malformed root wire shapes', () { + for (final parse in [ + () => Root.fromJson({'uri': 'file:///repo', 'name': 1}), + () => ListRootsResult.fromJson({ + 'roots': [1], + }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.rootsList, + 'params': 'bad', + }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.rootsList, + 'params': null, + }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsRootsListChanged, + 'params': 'bad', + }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsRootsListChanged, + 'params': null, + }), + ]) { + expect(parse, throwsFormatException); + } + }); + test('server acknowledges subscriptions/listen with subscription id', () async { final server = Server( diff --git a/test/types_test.dart b/test/types_test.dart index deab3c09..22664d2a 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -381,6 +381,41 @@ void main() { }); }); + group('Root wire parsing Tests', () { + test('rejects malformed root list fields', () { + for (final parse in [ + () => Root.fromJson({'uri': 'file:///repo', 'name': 1}), + () => ListRootsResult.fromJson({ + 'roots': [1], + }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.rootsList, + 'params': 'bad', + }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.rootsList, + 'params': null, + }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsRootsListChanged, + 'params': 'bad', + }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsRootsListChanged, + 'params': null, + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + }); + group('ToolAnnotations Tests', () { test('rejects malformed wire fields', () { for (final parse in [ From 14cf221f55213150213d1a907dd69ad116b1179a Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 09:52:28 -0400 Subject: [PATCH 12/68] Validate task wire fields --- CHANGELOG.md | 2 + lib/src/server/mcp_server.dart | 2 +- lib/src/types/tasks.dart | 95 +++++++++++++++++----------- test/mcp_2025_11_25_test.dart | 59 +++++++++++++++++ test/mcp_2026_07_28_test.dart | 55 ++++++++++++++++ test/types/tasks_extension_test.dart | 42 ++++++++++++ test/types_test.dart | 61 ++++++++++++++++++ 7 files changed, 279 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e5066d..a4b754ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,6 +124,8 @@ - Rejected malformed tool definition, tool-list, and tool-call wire fields with protocol parse errors. - Rejected malformed root-list wire fields with protocol parse errors. +- Rejected malformed stable task and task-extension wire fields with protocol + parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index a24b4cbc..58773c1d 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -1386,7 +1386,7 @@ class McpServer { }, (id, params, meta) => JsonRpcListTasksRequest.fromJson({ 'id': id, - 'params': params, + if (params != null) 'params': params, if (meta != null) '_meta': meta, }), ); diff --git a/lib/src/types/tasks.dart b/lib/src/types/tasks.dart index 633c6417..8cd1471d 100644 --- a/lib/src/types/tasks.dart +++ b/lib/src/types/tasks.dart @@ -193,7 +193,9 @@ class ListTasksRequest { const ListTasksRequest({this.cursor}); factory ListTasksRequest.fromJson(Map json) => - ListTasksRequest(cursor: json['cursor'] as String?); + ListTasksRequest( + cursor: readOptionalString(json['cursor'], 'ListTasksRequest.cursor'), + ); Map toJson() => {if (cursor != null) 'cursor': cursor}; } @@ -211,7 +213,8 @@ class JsonRpcListTasksRequest extends JsonRpcRequest { super(method: Method.tasksList, params: params?.toJson()); factory JsonRpcListTasksRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; + final paramsMap = + _readOptionalParamsObject(json, 'JsonRpcListTasksRequest.params'); final meta = extractRequestMeta(json); return JsonRpcListTasksRequest( id: parseRequestId(json['id']), @@ -242,9 +245,14 @@ class ListTasksResult implements BaseResultData { throw const FormatException('ListTasksResult.tasks is required'); } return ListTasksResult( - tasks: - tasks.map((e) => Task.fromJson(e as Map)).toList(), - nextCursor: json['nextCursor'] as String?, + tasks: [ + for (var i = 0; i < tasks.length; i++) + Task.fromJson( + readJsonObject(tasks[i], 'ListTasksResult.tasks[$i]'), + ), + ], + nextCursor: + readOptionalString(json['nextCursor'], 'ListTasksResult.nextCursor'), meta: meta, ); } @@ -266,7 +274,9 @@ class CancelTaskRequest { const CancelTaskRequest({required this.taskId}); factory CancelTaskRequest.fromJson(Map json) => - CancelTaskRequest(taskId: json['taskId'] as String); + CancelTaskRequest( + taskId: readRequiredString(json['taskId'], 'CancelTaskRequest.taskId'), + ); Map toJson() => {'taskId': taskId}; } @@ -283,10 +293,8 @@ class JsonRpcCancelTaskRequest extends JsonRpcRequest { }) : super(method: Method.tasksCancel, params: cancelParams.toJson()); factory JsonRpcCancelTaskRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException("Missing params for cancel task request"); - } + final paramsMap = + _readRequiredParamsObject(json, 'JsonRpcCancelTaskRequest.params'); final meta = extractRequestMeta(json); return JsonRpcCancelTaskRequest( id: parseRequestId(json['id']), @@ -303,8 +311,9 @@ class GetTaskRequest { const GetTaskRequest({required this.taskId}); - factory GetTaskRequest.fromJson(Map json) => - GetTaskRequest(taskId: json['taskId'] as String); + factory GetTaskRequest.fromJson(Map json) => GetTaskRequest( + taskId: readRequiredString(json['taskId'], 'GetTaskRequest.taskId'), + ); Map toJson() => {'taskId': taskId}; } @@ -321,10 +330,8 @@ class JsonRpcGetTaskRequest extends JsonRpcRequest { }) : super(method: Method.tasksGet, params: getParams.toJson()); factory JsonRpcGetTaskRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException("Missing params for get task request"); - } + final paramsMap = + _readRequiredParamsObject(json, 'JsonRpcGetTaskRequest.params'); final meta = extractRequestMeta(json); return JsonRpcGetTaskRequest( id: parseRequestId(json['id']), @@ -342,7 +349,9 @@ class TaskResultRequest { const TaskResultRequest({required this.taskId}); factory TaskResultRequest.fromJson(Map json) => - TaskResultRequest(taskId: json['taskId'] as String); + TaskResultRequest( + taskId: readRequiredString(json['taskId'], 'TaskResultRequest.taskId'), + ); Map toJson() => {'taskId': taskId}; } @@ -359,10 +368,8 @@ class JsonRpcTaskResultRequest extends JsonRpcRequest { }) : super(method: Method.tasksResult, params: resultParams.toJson()); factory JsonRpcTaskResultRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException("Missing params for task result request"); - } + final paramsMap = + _readRequiredParamsObject(json, 'JsonRpcTaskResultRequest.params'); final meta = extractRequestMeta(json); return JsonRpcTaskResultRequest( id: parseRequestId(json['id']), @@ -424,10 +431,8 @@ class JsonRpcUpdateTaskRequest extends JsonRpcRequest { }) : super(method: Method.tasksUpdate, params: updateParams.toJson()); factory JsonRpcUpdateTaskRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException("Missing params for update task request"); - } + final paramsMap = + _readRequiredParamsObject(json, 'JsonRpcUpdateTaskRequest.params'); final meta = extractRequestMeta(json); return JsonRpcUpdateTaskRequest( id: parseRequestId(json['id']), @@ -467,7 +472,9 @@ class CreateTaskResult implements BaseResultData { final meta = readOptionalJsonObject(json['_meta'], 'CreateTaskResult._meta'); return CreateTaskResult( - task: Task.fromJson(json['task'] as Map), + task: Task.fromJson( + _readRequiredJsonObject(json['task'], 'CreateTaskResult.task'), + ), meta: meta, ); } @@ -847,12 +854,10 @@ class JsonRpcTaskStatusNotification extends JsonRpcNotification { ); factory JsonRpcTaskStatusNotification.fromJson(Map json) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException( - "Missing params for task status notification", - ); - } + final paramsMap = _readRequiredParamsObject( + json, + 'JsonRpcTaskStatusNotification.params', + ); final meta = _readOptionalJsonObject( paramsMap['_meta'], 'JsonRpcTaskStatusNotification._meta', @@ -873,10 +878,8 @@ class JsonRpcTaskNotification extends JsonRpcNotification { : super(method: Method.notificationsTasks, params: task.toJson()); factory JsonRpcTaskNotification.fromJson(Map json) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException("Missing params for task notification"); - } + final paramsMap = + _readRequiredParamsObject(json, 'JsonRpcTaskNotification.params'); return JsonRpcTaskNotification( task: TaskExtensionTask.fromJson(paramsMap), meta: _readOptionalJsonObject( @@ -891,6 +894,26 @@ Map _readRequiredJsonObject(Object? value, String field) { return readJsonObject(value, field); } +Map? _readOptionalParamsObject( + Map json, + String field, +) { + if (!json.containsKey('params')) { + return null; + } + return _readRequiredJsonObject(json['params'], field); +} + +Map _readRequiredParamsObject( + Map json, + String field, +) { + if (!json.containsKey('params')) { + throw FormatException('$field is required'); + } + return _readRequiredJsonObject(json['params'], field); +} + Map? _readOptionalJsonObject(Object? value, String field) { if (value == null) { return null; diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 8bb24b64..d1c3e0e8 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -871,6 +871,65 @@ void main() { expect(deserialized.task.ttl, 7200); }); + test('task request and result wire fields reject malformed values', () { + for (final parse in [ + () => ListTasksRequest.fromJson({'cursor': 1}), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksList, + 'params': 'bad', + }), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksList, + 'params': null, + }), + () => ListTasksResult.fromJson({ + 'tasks': [1], + }), + () => ListTasksResult.fromJson({ + 'tasks': >[], + 'nextCursor': 1, + }), + () => CancelTaskRequest.fromJson({'taskId': 1}), + () => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksCancel, + 'params': 'bad', + }), + () => GetTaskRequest.fromJson({'taskId': 1}), + () => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': 'bad', + }), + () => TaskResultRequest.fromJson({'taskId': 1}), + () => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksResult, + 'params': null, + }), + () => CreateTaskResult.fromJson({'task': 'bad'}), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasksStatus, + 'params': 'bad', + }), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasksStatus, + 'params': null, + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + test('TaskStatusNotificationParams serialization', () { final params = const TaskStatusNotificationParams( taskId: 'task-notify-123', diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 73fc19ad..d14f1476 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1353,6 +1353,61 @@ void main() { } }); + test('rejects malformed task wire shapes', () { + for (final parse in [ + () => ListTasksRequest.fromJson({'cursor': 1}), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksList, + 'params': null, + }), + () => ListTasksResult.fromJson({ + 'tasks': [1], + }), + () => CancelTaskRequest.fromJson({'taskId': 1}), + () => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksCancel, + 'params': 'bad', + }), + () => GetTaskRequest.fromJson({'taskId': 1}), + () => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': 'bad', + }), + () => TaskResultRequest.fromJson({'taskId': 1}), + () => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksResult, + 'params': null, + }), + () => CreateTaskResult.fromJson({'task': 'bad'}), + () => JsonRpcUpdateTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksUpdate, + 'params': 'bad', + }), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasksStatus, + 'params': 'bad', + }), + () => JsonRpcTaskNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasks, + 'params': null, + }), + ]) { + expect(parse, throwsFormatException); + } + }); + test('server acknowledges subscriptions/listen with subscription id', () async { final server = Server( diff --git a/test/types/tasks_extension_test.dart b/test/types/tasks_extension_test.dart index 45b51505..801e76e3 100644 --- a/test/types/tasks_extension_test.dart +++ b/test/types/tasks_extension_test.dart @@ -217,6 +217,48 @@ void main() { ), throwsFormatException, ); + expect( + () => JsonRpcUpdateTaskRequest.fromJson( + const { + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksUpdate, + 'params': 'bad', + }, + ), + throwsFormatException, + ); + expect( + () => JsonRpcUpdateTaskRequest.fromJson( + const { + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksUpdate, + 'params': null, + }, + ), + throwsFormatException, + ); + expect( + () => JsonRpcTaskNotification.fromJson( + const { + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasks, + 'params': 'bad', + }, + ), + throwsFormatException, + ); + expect( + () => JsonRpcTaskNotification.fromJson( + const { + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasks, + 'params': null, + }, + ), + throwsFormatException, + ); }); }); } diff --git a/test/types_test.dart b/test/types_test.dart index 22664d2a..00a75934 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -416,6 +416,67 @@ void main() { }); }); + group('Task wire parsing Tests', () { + test('rejects malformed task request and result fields', () { + for (final parse in [ + () => ListTasksRequest.fromJson({'cursor': 1}), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksList, + 'params': 'bad', + }), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksList, + 'params': null, + }), + () => ListTasksResult.fromJson({ + 'tasks': [1], + }), + () => ListTasksResult.fromJson({ + 'tasks': >[], + 'nextCursor': 1, + }), + () => CancelTaskRequest.fromJson({'taskId': 1}), + () => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksCancel, + 'params': 'bad', + }), + () => GetTaskRequest.fromJson({'taskId': 1}), + () => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': 'bad', + }), + () => TaskResultRequest.fromJson({'taskId': 1}), + () => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksResult, + 'params': null, + }), + () => CreateTaskResult.fromJson({'task': 'bad'}), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasksStatus, + 'params': 'bad', + }), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasksStatus, + 'params': null, + }), + ]) { + expect(parse, throwsA(isA())); + } + }); + }); + group('ToolAnnotations Tests', () { test('rejects malformed wire fields', () { for (final parse in [ From 48192a378c631de871e47750a805f5718a5f1baa Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 10:03:41 -0400 Subject: [PATCH 13/68] Validate elicitation wire fields --- CHANGELOG.md | 2 + lib/src/types/elicitation.dart | 77 ++++++++++++++++++++++------------ test/mcp_2025_11_25_test.dart | 29 +++++++++++++ test/mcp_2026_07_28_test.dart | 47 +++++++++++++++++++++ test/types_test.dart | 61 +++++++++++++++++++++++++++ 5 files changed, 190 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4b754ef..eb937cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,6 +126,8 @@ - Rejected malformed root-list wire fields with protocol parse errors. - Rejected malformed stable task and task-extension wire fields with protocol parse errors. +- Rejected malformed elicitation request, result, completion, and URL-required + error wire fields with protocol parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/elicitation.dart b/lib/src/types/elicitation.dart index a8cb4932..e58cbf83 100644 --- a/lib/src/types/elicitation.dart +++ b/lib/src/types/elicitation.dart @@ -117,7 +117,10 @@ class ElicitRequest { throw const FormatException('Elicitation message is required.'); } - final requestedSchemaJson = json['requestedSchema']; + final requestedSchemaJson = readOptionalJsonObject( + json['requestedSchema'], + 'ElicitRequest.requestedSchema', + ); final url = json['url']; final elicitationId = json['elicitationId']; final task = readOptionalJsonObject(json['task'], 'ElicitRequest.task'); @@ -143,7 +146,7 @@ class ElicitRequest { ); } - if (requestedSchemaJson is! Map) { + if (requestedSchemaJson == null) { throw const FormatException('Form elicitation requires requestedSchema.'); } _validateFormRequestedSchemaJson( @@ -236,10 +239,8 @@ class JsonRpcElicitRequest extends JsonRpcRequest { ); factory JsonRpcElicitRequest.fromJson(Map json) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException("Missing params for elicit request"); - } + final paramsMap = + _readRequiredParamsObject(json, 'JsonRpcElicitRequest.params'); final meta = extractRequestMeta(json); final protocolVersion = _protocolVersionFromMeta(meta); return JsonRpcElicitRequest( @@ -311,8 +312,11 @@ class ElicitResult implements BaseResultData { return ElicitResult( action: action, content: content, - url: json['url'] as String?, - elicitationId: json['elicitationId'] as String?, + url: readOptionalString(json['url'], 'ElicitResult.url'), + elicitationId: readOptionalString( + json['elicitationId'], + 'ElicitResult.elicitationId', + ), meta: readOptionalJsonObject(json['_meta'], 'ElicitResult._meta'), ); } @@ -368,7 +372,10 @@ class ElicitationCompleteNotification { factory ElicitationCompleteNotification.fromJson(Map json) { return ElicitationCompleteNotification( - elicitationId: json['elicitationId'] as String, + elicitationId: readRequiredString( + json['elicitationId'], + 'ElicitationCompleteNotification.elicitationId', + ), ); } @@ -394,12 +401,10 @@ class JsonRpcElicitationCompleteNotification extends JsonRpcNotification { factory JsonRpcElicitationCompleteNotification.fromJson( Map json, ) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException( - "Missing params for elicitation complete notification", - ); - } + final paramsMap = _readRequiredParamsObject( + json, + 'JsonRpcElicitationCompleteNotification.params', + ); final meta = readOptionalJsonObject( paramsMap['_meta'], 'JsonRpcElicitationCompleteNotification._meta', @@ -429,8 +434,15 @@ class URLElicitationRequiredErrorData { 'URLElicitationRequiredErrorData.elicitations is required', ); } - final elicitations = elicitationsList - .map((e) => ElicitRequest.fromJson(e as Map)) + final elicitations = elicitationsList.indexed + .map( + (entry) => ElicitRequest.fromJson( + readJsonObject( + entry.$2, + 'URLElicitationRequiredErrorData.elicitations[${entry.$1}]', + ), + ), + ) .toList(); _validateUrlElicitations(elicitations, formatException: true); return URLElicitationRequiredErrorData(elicitations: elicitations); @@ -481,20 +493,26 @@ void _validateFormRequestedSchemaJson( 'Form elicitation requestedSchema must have type object.', ); } - final properties = json['properties']; - if (properties is! Map) { + final properties = readOptionalJsonObject( + json['properties'], + 'ElicitRequest.requestedSchema.properties', + ); + if (properties == null) { throw const FormatException( 'Form elicitation requestedSchema.properties is required.', ); } for (final entry in properties.entries) { - if (entry.key is! String || entry.value is! Map) { + if (entry.value is! Map) { throw const FormatException( 'Form elicitation requestedSchema properties must be schema objects.', ); } _validatePrimitiveSchema( - (entry.value as Map).cast(), + readJsonObject( + entry.value, + 'ElicitRequest.requestedSchema.properties.${entry.key}', + ), 'ElicitRequest.requestedSchema.properties.${entry.key}', protocolVersion: protocolVersion, ); @@ -676,7 +694,7 @@ void _validateMultiSelectEnumSchema( if (items is! Map) { throw FormatException('$context.items is required for array schemas.'); } - final itemMap = items.cast(); + final itemMap = readJsonObject(items, '$context.items'); if (itemMap.containsKey('enum')) { _ensureAllowedKeys(itemMap, const {'type', 'enum'}, '$context.items'); if (itemMap['type'] != 'string') { @@ -768,13 +786,20 @@ Map? _parseElicitResultContent(Object? content) { if (content == null) { return null; } - if (content is! Map) { - throw const FormatException('ElicitResult.content must be an object.'); - } - final result = content.cast(); + final result = readJsonObject(content, 'ElicitResult.content'); return _normalizeElicitResultContent(result, formatException: true); } +Map _readRequiredParamsObject( + Map json, + String field, +) { + if (!json.containsKey('params')) { + throw FormatException('$field is required'); + } + return readJsonObject(json['params'], field); +} + Map? _normalizeElicitResultContent( Map? content, { bool formatException = false, diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index d1c3e0e8..a78d5010 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1601,6 +1601,35 @@ void main() { }), throwsA(isA()), ); + for (final parse in [ + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': 'bad', + }), + () => ElicitRequest.fromJson({ + 'message': 'Bad schema', + 'requestedSchema': 'bad', + }), + () => ElicitResult.fromJson({ + 'action': 'accept', + 'elicitationId': 1, + }), + () => ElicitationCompleteNotification.fromJson({ + 'elicitationId': 1, + }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsElicitationComplete, + 'params': null, + }), + () => URLElicitationRequiredErrorData.fromJson({ + 'elicitations': [1], + }), + ]) { + expect(parse, throwsA(isA())); + } }); test('initialization and capability wire fields reject bad shapes', () { diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index d14f1476..53fd46fa 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -431,6 +431,53 @@ void main() { ); }); + test('rejects malformed elicitation wire shapes', () { + for (final parse in [ + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': 'bad', + }), + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': null, + }), + () => ElicitRequest.fromJson({ + 'message': 'Bad properties', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 1: {'type': 'string'}, + }, + }, + }), + () => ElicitResult.fromJson({ + 'action': 'accept', + 'url': 1, + }), + () => ElicitResult.fromJson({ + 'action': 'accept', + 'content': {1: 'bad'}, + }), + () => ElicitationCompleteNotification.fromJson({ + 'elicitationId': 1, + }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsElicitationComplete, + 'params': 'bad', + }), + () => URLElicitationRequiredErrorData.fromJson({ + 'elicitations': [1], + }), + ]) { + expect(parse, throwsFormatException); + } + }); + test('rejects non-finite JSON numbers', () { expect( () => ProgressNotification.fromJson({ diff --git a/test/types_test.dart b/test/types_test.dart index 00a75934..416974a7 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -1987,6 +1987,67 @@ void main() { expect(result.elicitationId, equals('elicitation-1')); expect(result.toJson(), equals({'action': 'accept'})); }); + + test('elicitation parsers reject malformed wire fields', () { + for (final parse in [ + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': 'bad', + }), + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': null, + }), + () => ElicitRequest.fromJson({ + 'message': 'Bad schema', + 'requestedSchema': 'bad', + }), + () => ElicitRequest.fromJson({ + 'message': 'Bad properties', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 1: {'type': 'string'}, + }, + }, + }), + () => ElicitResult.fromJson({ + 'action': 'accept', + 'url': 1, + }), + () => ElicitResult.fromJson({ + 'action': 'accept', + 'content': {1: 'bad'}, + }), + () => ElicitationCompleteNotification.fromJson({ + 'elicitationId': 1, + }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsElicitationComplete, + 'params': 'bad', + }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsElicitationComplete, + 'params': null, + }), + () => URLElicitationRequiredErrorData.fromJson({ + 'elicitations': [1], + }), + () => URLElicitationRequiredErrorData.fromJson({ + 'elicitations': [ + {1: 'bad'}, + ], + }), + ]) { + expect(parse, throwsA(isA())); + } + }); }); group('ClientElicitation Tests', () { From f3412b789b450ced7a606496f5ca4dead3218ba7 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 10:13:20 -0400 Subject: [PATCH 14/68] Validate subscription wire fields --- CHANGELOG.md | 2 + lib/src/types/subscriptions.dart | 62 +++++++++++++++--------------- test/mcp_2026_07_28_test.dart | 42 ++++++++++++++++++++ test/types/subscriptions_test.dart | 53 +++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb937cd9..f289dc60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,8 @@ parse errors. - Rejected malformed elicitation request, result, completion, and URL-required error wire fields with protocol parse errors. +- Rejected malformed subscription listen and acknowledgment wire fields with + protocol parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/subscriptions.dart b/lib/src/types/subscriptions.dart index bb539824..16393a04 100644 --- a/lib/src/types/subscriptions.dart +++ b/lib/src/types/subscriptions.dart @@ -141,17 +141,13 @@ class SubscriptionsListenRequest { const SubscriptionsListenRequest({required this.notifications}); factory SubscriptionsListenRequest.fromJson(Map json) { - final notifications = json['notifications']; - if (notifications is! Map) { - throw const FormatException( - 'SubscriptionsListenRequest.notifications is required', - ); - } + final notifications = _readRequiredJsonObject( + json['notifications'], + 'SubscriptionsListenRequest.notifications', + ); return SubscriptionsListenRequest( - notifications: SubscriptionFilter.fromJson( - notifications.cast(), - ), + notifications: SubscriptionFilter.fromJson(notifications), ); } @@ -177,12 +173,10 @@ class JsonRpcSubscriptionsListenRequest extends JsonRpcRequest { factory JsonRpcSubscriptionsListenRequest.fromJson( Map json, ) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException( - 'Missing params for subscriptions/listen request', - ); - } + final paramsMap = _readRequiredParamsObject( + json, + 'JsonRpcSubscriptionsListenRequest.params', + ); return JsonRpcSubscriptionsListenRequest( id: parseRequestId(json['id']), @@ -202,17 +196,13 @@ class SubscriptionsAcknowledgedNotification { factory SubscriptionsAcknowledgedNotification.fromJson( Map json, ) { - final notifications = json['notifications']; - if (notifications is! Map) { - throw const FormatException( - 'SubscriptionsAcknowledgedNotification.notifications is required', - ); - } + final notifications = _readRequiredJsonObject( + json['notifications'], + 'SubscriptionsAcknowledgedNotification.notifications', + ); return SubscriptionsAcknowledgedNotification( - notifications: SubscriptionFilter.fromJson( - notifications.cast(), - ), + notifications: SubscriptionFilter.fromJson(notifications), ); } @@ -237,12 +227,10 @@ class JsonRpcSubscriptionsAcknowledgedNotification extends JsonRpcNotification { factory JsonRpcSubscriptionsAcknowledgedNotification.fromJson( Map json, ) { - final paramsMap = json['params'] as Map?; - if (paramsMap == null) { - throw const FormatException( - 'Missing params for subscriptions acknowledged notification', - ); - } + final paramsMap = _readRequiredParamsObject( + json, + 'JsonRpcSubscriptionsAcknowledgedNotification.params', + ); return JsonRpcSubscriptionsAcknowledgedNotification( acknowledgedParams: @@ -255,6 +243,20 @@ class JsonRpcSubscriptionsAcknowledgedNotification extends JsonRpcNotification { } } +Map _readRequiredJsonObject(Object? value, String field) { + return readJsonObject(value, field); +} + +Map _readRequiredParamsObject( + Map json, + String field, +) { + if (!json.containsKey('params')) { + throw FormatException('$field is required'); + } + return _readRequiredJsonObject(json['params'], field); +} + bool? _readOptionalBool(Object? value, String field) { if (value == null) { return null; diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 53fd46fa..bcd005c7 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1455,6 +1455,48 @@ void main() { } }); + test('rejects malformed subscription wire shapes', () { + for (final parse in [ + () => JsonRpcSubscriptionsListenRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.subscriptionsListen, + 'params': 'bad', + }), + () => JsonRpcSubscriptionsListenRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.subscriptionsListen, + 'params': null, + }), + () => SubscriptionsListenRequest.fromJson({ + 'notifications': 'bad', + }), + () => SubscriptionsListenRequest.fromJson({ + 'notifications': { + 1: true, + }, + }), + () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsSubscriptionsAcknowledged, + 'params': 'bad', + }), + () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsSubscriptionsAcknowledged, + 'params': null, + }), + () => SubscriptionsAcknowledgedNotification.fromJson({ + 'notifications': { + 1: true, + }, + }), + ]) { + expect(parse, throwsFormatException); + } + }); + test('server acknowledges subscriptions/listen with subscription id', () async { final server = Server( diff --git a/test/types/subscriptions_test.dart b/test/types/subscriptions_test.dart index a6560a64..1004bbca 100644 --- a/test/types/subscriptions_test.dart +++ b/test/types/subscriptions_test.dart @@ -215,6 +215,33 @@ void main() { throwsFormatException, ); }); + + test('rejects malformed listen wire fields', () { + for (final parse in [ + () => JsonRpcSubscriptionsListenRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.subscriptionsListen, + 'params': 'bad', + }), + () => JsonRpcSubscriptionsListenRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.subscriptionsListen, + 'params': null, + }), + () => SubscriptionsListenRequest.fromJson({ + 'notifications': 'bad', + }), + () => SubscriptionsListenRequest.fromJson({ + 'notifications': { + 1: true, + }, + }), + ]) { + expect(parse, throwsFormatException); + } + }); }); group('JsonRpcSubscriptionsAcknowledgedNotification', () { @@ -294,6 +321,32 @@ void main() { }), throwsFormatException, ); + expect( + () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson( + const { + 'method': Method.notificationsSubscriptionsAcknowledged, + 'params': 'bad', + }, + ), + throwsFormatException, + ); + expect( + () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson( + const { + 'method': Method.notificationsSubscriptionsAcknowledged, + 'params': null, + }, + ), + throwsFormatException, + ); + expect( + () => SubscriptionsAcknowledgedNotification.fromJson({ + 'notifications': { + 1: true, + }, + }), + throwsFormatException, + ); }); }); From c6b3f03d85e052ad0657d573763b67b8bf449fbd Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 10:24:17 -0400 Subject: [PATCH 15/68] Validate sampling tool wire fields --- CHANGELOG.md | 2 ++ lib/src/types/sampling.dart | 30 +++++++++++++++++------ test/mcp_2025_11_25_test.dart | 26 ++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 20 ++++++++++++++++ test/types/sampling_test.dart | 45 +++++++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f289dc60..6cc2fbad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,6 +130,8 @@ error wire fields with protocol parse errors. - Rejected malformed subscription listen and acknowledgment wire fields with protocol parse errors. +- Rejected malformed sampling tool-list, tool-choice, and tool-result content + wire fields with protocol parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index 976980da..c4336d6f 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -114,7 +114,7 @@ ToolChoice? _parseToolChoice(dynamic value) { } if (value is Map) { - return ToolChoice.fromJson(value.cast()); + return ToolChoice.fromJson(readJsonObject(value, 'toolChoice')); } throw FormatException( @@ -132,7 +132,7 @@ Map? _toolChoiceToLegacyMap(dynamic value) { } if (value is Map) { - return value.cast(); + return readJsonObject(value, 'toolChoice'); } if (value is ToolChoice) { @@ -164,7 +164,9 @@ List _parseToolResultContent(dynamic rawContent) { } if (item is Map) { - return Content.fromJson(item.cast()); + return Content.fromJson( + readJsonObject(item, 'SamplingToolResultContent.content[]'), + ); } return TextContent(text: item.toString()); @@ -172,7 +174,7 @@ List _parseToolResultContent(dynamic rawContent) { } if (rawContent is Map) { - final map = rawContent.cast(); + final map = readJsonObject(rawContent, 'SamplingToolResultContent.content'); if (map.containsKey('type')) { return [Content.fromJson(map)]; } @@ -193,6 +195,22 @@ List _parseToolResultWireContent(dynamic rawContent) { .toList(); } +List? _parseSamplingTools(Object? value) { + if (value == null) { + return null; + } + if (value is! List) { + throw const FormatException('CreateMessageRequest.tools must be a list'); + } + return value.indexed + .map( + (entry) => Tool.fromJson( + _asJsonObject(entry.$2, 'CreateMessageRequest.tools[${entry.$1}]'), + ), + ) + .toList(); +} + /// Hints for model selection during sampling. class ModelHint { /// Hint for a model name. @@ -736,9 +754,7 @@ class CreateMessageRequest { : ModelPreferences.fromJson( _asJsonObject(json['modelPreferences']), ), - tools: (json['tools'] as List?) - ?.map((t) => Tool.fromJson(_asJsonObject(t))) - .toList(), + tools: _parseSamplingTools(json['tools']), toolChoice: toolChoice, ); } diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index a78d5010..0e12e3c3 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1845,6 +1845,32 @@ void main() { }), throwsA(isA()), ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 100, + 'tools': 'bad', + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 100, + 'tools': [1], + }), + throwsA(isA()), + ); expect( () => ModelHint.fromJson({'name': 1}), throwsA(isA()), diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index bcd005c7..02904d84 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -994,6 +994,26 @@ void main() { 'values': ['a'], 'hasMore': 'true', }), + () => CreateMessageRequestParams.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 100, + 'tools': 'bad', + }), + () => CreateMessageRequestParams.fromJson({ + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 100, + 'tools': [1], + }), () => LoggingMessageNotification.fromJson({ 'level': 'info', 'data': Object(), diff --git a/test/types/sampling_test.dart b/test/types/sampling_test.dart index 5d74e8a1..a215665c 100644 --- a/test/types/sampling_test.dart +++ b/test/types/sampling_test.dart @@ -525,6 +525,13 @@ void main() { }), throwsA(isA()), ); + expect( + () => const SamplingToolResultContent( + toolUseId: 'tr1', + content: {1: 'bad'}, + ).toJson(), + throwsA(isA()), + ); }); }); }); @@ -736,6 +743,19 @@ void main() { }), throwsA(isA()), ); + expect( + () => const CreateMessageRequestParams( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Hello'), + ), + ], + maxTokens: 100, + toolChoice: {1: 'required'}, + ).toJson(), + throwsA(isA()), + ); }); test('validates string wire fields', () { @@ -771,6 +791,31 @@ void main() { ); }); + test('validates tool wire fields', () { + final messages = [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ]; + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'tools': 'bad', + }), + throwsA(isA()), + ); + expect( + () => CreateMessageRequestParams.fromJson({ + 'messages': messages, + 'maxTokens': 100, + 'tools': [1], + }), + throwsA(isA()), + ); + }); + test('accepts whole-number JSON maxTokens values', () { final messages = [ { From 74d3ff7a2a21991c3e13c83abc7b562bc0c1aaa2 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 10:35:36 -0400 Subject: [PATCH 16/68] Validate content block discriminators --- CHANGELOG.md | 2 ++ lib/src/types/content.dart | 22 ++++++++++++++++++++-- test/mcp_2025_11_25_test.dart | 28 ++++++++++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 28 ++++++++++++++++++++++++++++ test/types_test.dart | 28 ++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cc2fbad..51099109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,8 @@ protocol parse errors. - Rejected malformed sampling tool-list, tool-choice, and tool-result content wire fields with protocol parse errors. +- Rejected missing, unknown, and mismatched content block type discriminators + with protocol parse errors. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/content.dart b/lib/src/types/content.dart index ebe9ebca..b7ae3df2 100644 --- a/lib/src/types/content.dart +++ b/lib/src/types/content.dart @@ -25,6 +25,17 @@ String _readRequiredString(Object? value, String field) { return readRequiredString(value, field); } +void _expectType( + Map json, + String expected, + String field, +) { + final value = _readRequiredString(json['type'], field); + if (value != expected) { + throw FormatException('$field must be "$expected"'); + } +} + bool _isAbsoluteUri(String value) { return Uri.tryParse(value)?.hasScheme ?? false; } @@ -374,14 +385,16 @@ sealed class Content { }); factory Content.fromJson(Map json) { - final type = readOptionalString(json['type'], 'Content.type'); + final type = readRequiredString(json['type'], 'Content.type'); return switch (type) { 'text' => TextContent.fromJson(json), 'image' => ImageContent.fromJson(json), 'audio' => AudioContent.fromJson(json), 'resource_link' => ResourceLink.fromJson(json), 'resource' => EmbeddedResource.fromJson(json), - _ => UnknownContent(type: type ?? 'unknown'), + _ => throw const FormatException( + 'Content.type must be a known content type', + ), }; } @@ -454,6 +467,7 @@ class TextContent extends Content { }) : super(type: 'text'); factory TextContent.fromJson(Map json) { + _expectType(json, 'text', 'TextContent.type'); return TextContent( text: readRequiredString(json['text'], 'TextContent.text'), annotations: json['annotations'] == null @@ -496,6 +510,7 @@ class ImageContent extends Content { }) : super(type: 'image'); factory ImageContent.fromJson(Map json) { + _expectType(json, 'image', 'ImageContent.type'); return ImageContent( data: readRequiredBase64String(json['data'], 'ImageContent.data'), mimeType: readRequiredString(json['mimeType'], 'ImageContent.mimeType'), @@ -531,6 +546,7 @@ class AudioContent extends Content { }) : super(type: 'audio'); factory AudioContent.fromJson(Map json) { + _expectType(json, 'audio', 'AudioContent.type'); return AudioContent( data: readRequiredBase64String(json['data'], 'AudioContent.data'), mimeType: readRequiredString(json['mimeType'], 'AudioContent.mimeType'), @@ -562,6 +578,7 @@ class EmbeddedResource extends Content { }) : super(type: 'resource'); factory EmbeddedResource.fromJson(Map json) { + _expectType(json, 'resource', 'EmbeddedResource.type'); return EmbeddedResource( resource: ResourceContents.fromJson( _asJsonObject(json['resource'], 'EmbeddedResource.resource'), @@ -625,6 +642,7 @@ class ResourceLink extends Content { }) : super(type: 'resource_link'); factory ResourceLink.fromJson(Map json) { + _expectType(json, 'resource_link', 'ResourceLink.type'); return ResourceLink( uri: readRequiredAbsoluteUriString(json['uri'], 'ResourceLink.uri'), name: readRequiredString(json['name'], 'ResourceLink.name'), diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 0e12e3c3..baa3412f 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1921,6 +1921,26 @@ void main() { }), throwsA(isA()), ); + expect( + () => Content.fromJson({ + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => Content.fromJson({ + 'type': 'unknown', + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => TextContent.fromJson({ + 'type': 'image', + 'text': 'Hello', + }), + throwsA(isA()), + ); expect( () => TextContent.fromJson({ 'type': 'text', @@ -1943,6 +1963,14 @@ void main() { }), throwsA(isA()), ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + }), + throwsA(isA()), + ); expect( () => Resource.fromJson({ 'uri': 'file:///docs/readme.md', diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 02904d84..ced04d2a 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -626,6 +626,26 @@ void main() { }), throwsA(isA()), ); + expect( + () => Content.fromJson({ + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => Content.fromJson({ + 'type': 'unknown', + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => TextContent.fromJson({ + 'type': 'image', + 'text': 'Hello', + }), + throwsA(isA()), + ); expect( () => TextContent.fromJson({ 'type': 'text', @@ -648,6 +668,14 @@ void main() { }), throwsA(isA()), ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + }), + throwsA(isA()), + ); expect( () => Resource.fromJson({ 'uri': 'file:///docs/readme.md', diff --git a/test/types_test.dart b/test/types_test.dart index 416974a7..28b22064 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -863,6 +863,26 @@ void main() { }), throwsA(isA()), ); + expect( + () => Content.fromJson({ + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => Content.fromJson({ + 'type': 'unknown', + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => TextContent.fromJson({ + 'type': 'image', + 'text': 'Hello', + }), + throwsA(isA()), + ); expect( () => TextContent.fromJson({ 'type': 'text', @@ -918,6 +938,14 @@ void main() { }), throwsA(isA()), ); + expect( + () => ResourceLink.fromJson({ + 'type': 'resource', + 'uri': 'file:///docs/readme.md', + 'name': 'readme', + }), + throwsA(isA()), + ); expect( () => ResourceLink.fromJson({ 'type': 'resource_link', From 5a37ecf60cc833612ec494b3769909e877712dc4 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 10:44:03 -0400 Subject: [PATCH 17/68] Validate resource content union --- CHANGELOG.md | 2 ++ lib/src/types/content.dart | 18 ++++++++---- test/mcp_2025_11_25_test.dart | 6 ++++ test/mcp_2026_07_28_test.dart | 6 ++++ test/types/resources_test.dart | 50 ++++++++++++++++++---------------- test/types_test.dart | 12 ++++++++ 6 files changed, 64 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51099109..82fda9b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -134,6 +134,8 @@ wire fields with protocol parse errors. - Rejected missing, unknown, and mismatched content block type discriminators with protocol parse errors. +- Rejected resource content items that omit both `text` and `blob`, matching + the spec-defined `TextResourceContents | BlobResourceContents` union. - Rejected non-finite numeric values for progress, annotation priority, model priority, and sampling temperature fields so SDK-built payloads remain valid JSON numbers. diff --git a/lib/src/types/content.dart b/lib/src/types/content.dart index b7ae3df2..ba828516 100644 --- a/lib/src/types/content.dart +++ b/lib/src/types/content.dart @@ -249,11 +249,8 @@ sealed class ResourceContents { extra: passthrough, ); } - return UnknownResourceContents( - uri: uri, - mimeType: mimeType, - meta: meta, - extra: passthrough, + throw const FormatException( + 'ResourceContents must include text or blob', ); } @@ -266,7 +263,11 @@ sealed class ResourceContents { final BlobResourceContents c => { 'blob': _base64ForJson(c.blob, 'BlobResourceContents.blob'), }, - UnknownResourceContents _ => {}, + UnknownResourceContents _ => throw ArgumentError.value( + this, + 'ResourceContents', + 'must include text or blob', + ), }, if (meta != null) '_meta': readJsonObject(meta, 'ResourceContents._meta'), @@ -303,6 +304,11 @@ class BlobResourceContents extends ResourceContents { } /// Represents unknown or passthrough resource content types. +/// +/// Stable MCP and MCP 2026 wire results require either text or blob content. +/// This class is retained for source compatibility, but serialization rejects +/// it because no current protocol result shape references bare +/// `ResourceContents`. class UnknownResourceContents extends ResourceContents { const UnknownResourceContents({ required super.uri, diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index baa3412f..0e64588b 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1955,6 +1955,12 @@ void main() { }), throwsA(isA()), ); + expect( + () => ResourceContents.fromJson({ + 'uri': 'file:///docs/readme.md', + }), + throwsA(isA()), + ); expect( () => ResourceLink.fromJson({ 'type': 'resource_link', diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index ced04d2a..a5f0d4dc 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -660,6 +660,12 @@ void main() { }), throwsA(isA()), ); + expect( + () => ResourceContents.fromJson({ + 'uri': 'file:///docs/readme.md', + }), + throwsA(isA()), + ); expect( () => ResourceLink.fromJson({ 'type': 'resource_link', diff --git a/test/types/resources_test.dart b/test/types/resources_test.dart index aaf13335..8936f9b8 100644 --- a/test/types/resources_test.dart +++ b/test/types/resources_test.dart @@ -790,33 +790,35 @@ void main() { expect(roundTripped['customField']['enabled'], isTrue); }); - test('unknown resource content preserves passthrough fields', () { - final result = ReadResourceResult.fromJson({ - 'contents': [ - { - 'uri': 'ui://weather/raw', - 'mimeType': 'application/vnd.custom+json', - '_meta': { - 'ui': { - 'prefersBorder': true, + test('resource contents require text or blob', () { + expect( + () => ReadResourceResult.fromJson({ + 'contents': [ + { + 'uri': 'ui://weather/raw', + 'mimeType': 'application/vnd.custom+json', + '_meta': { + 'ui': { + 'prefersBorder': true, + }, + }, + 'payload': { + 'kind': 'custom', }, }, - 'payload': { - 'kind': 'custom', - }, + ], + }), + throwsA(isA()), + ); + expect( + () => const UnknownResourceContents( + uri: 'ui://weather/raw', + extra: { + 'payload': {'kind': 'custom'}, }, - ], - }); - - final content = result.contents.single; - expect(content, isA()); - expect(content.meta!['ui']['prefersBorder'], isTrue); - expect(content.extra!['payload']['kind'], equals('custom')); - - final json = result.toJson(); - final roundTripped = (json['contents'] as List).single; - expect(roundTripped['payload']['kind'], equals('custom')); - expect(roundTripped['_meta']['ui']['prefersBorder'], isTrue); + ).toJson(), + throwsA(isA()), + ); }); test('fromJson rejects malformed content items', () { diff --git a/test/types_test.dart b/test/types_test.dart index 28b22064..3172a7aa 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -1456,6 +1456,12 @@ void main() { }); test('ResourceContents rejects non-JSON metadata and passthrough maps', () { + expect( + () => ResourceContents.fromJson({ + 'uri': 'file://example.txt', + }), + throwsA(isA()), + ); expect( () => ResourceContents.fromJson({ 'uri': 'file://example.txt', @@ -1488,6 +1494,12 @@ void main() { ).toJson(), throwsA(isA()), ); + expect( + () => const UnknownResourceContents( + uri: 'file://example.txt', + ).toJson(), + throwsA(isA()), + ); }); }); From 85974faa48d497d7dd879d3202f32ded881775d2 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 10:53:57 -0400 Subject: [PATCH 18/68] Validate sampling content discriminators --- CHANGELOG.md | 2 + lib/src/types/sampling.dart | 117 ++++++++++++++++++++-------------- test/mcp_2025_11_25_test.dart | 48 ++++++++++++++ test/mcp_2026_07_28_test.dart | 48 ++++++++++++++ test/types/sampling_test.dart | 75 ++++++++++++++++++++++ 5 files changed, 241 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82fda9b1..59b16cb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -134,6 +134,8 @@ wire fields with protocol parse errors. - Rejected missing, unknown, and mismatched content block type discriminators with protocol parse errors. +- Rejected missing and mismatched sampling content type discriminators with + protocol parse errors. - Rejected resource content items that omit both `text` and `blob`, matching the spec-defined `TextResourceContents | BlobResourceContents` union. - Rejected non-finite numeric values for progress, annotation priority, model diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index c4336d6f..d3477cfa 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -39,6 +39,17 @@ Map _annotationsForJson( return result; } +void _expectType( + Map json, + String expected, + String field, +) { + final value = readRequiredString(json['type'], field); + if (value != expected) { + throw FormatException('$field must be "$expected"'); + } +} + Object _parseSamplingMessageContent(dynamic value) { if (value is List) { return value @@ -402,15 +413,17 @@ class SamplingTextContent extends SamplingContent { this.meta, }) : super(type: 'text'); - factory SamplingTextContent.fromJson(Map json) => - SamplingTextContent( - text: readRequiredString(json['text'], 'SamplingTextContent.text'), - annotations: readOptionalAnnotationsObject( - json['annotations'], - 'SamplingTextContent.annotations', - ), - meta: _asJsonObjectOrNull(json['_meta'], 'SamplingTextContent._meta'), - ); + factory SamplingTextContent.fromJson(Map json) { + _expectType(json, 'text', 'SamplingTextContent.type'); + return SamplingTextContent( + text: readRequiredString(json['text'], 'SamplingTextContent.text'), + annotations: readOptionalAnnotationsObject( + json['annotations'], + 'SamplingTextContent.annotations', + ), + meta: _asJsonObjectOrNull(json['_meta'], 'SamplingTextContent._meta'), + ); + } } /// Image content for sampling messages. @@ -434,22 +447,24 @@ class SamplingImageContent extends SamplingContent { this.meta, }) : super(type: 'image'); - factory SamplingImageContent.fromJson(Map json) => - SamplingImageContent( - data: readRequiredBase64String( - json['data'], - 'SamplingImageContent.data', - ), - mimeType: readRequiredString( - json['mimeType'], - 'SamplingImageContent.mimeType', - ), - annotations: readOptionalAnnotationsObject( - json['annotations'], - 'SamplingImageContent.annotations', - ), - meta: _asJsonObjectOrNull(json['_meta'], 'SamplingImageContent._meta'), - ); + factory SamplingImageContent.fromJson(Map json) { + _expectType(json, 'image', 'SamplingImageContent.type'); + return SamplingImageContent( + data: readRequiredBase64String( + json['data'], + 'SamplingImageContent.data', + ), + mimeType: readRequiredString( + json['mimeType'], + 'SamplingImageContent.mimeType', + ), + annotations: readOptionalAnnotationsObject( + json['annotations'], + 'SamplingImageContent.annotations', + ), + meta: _asJsonObjectOrNull(json['_meta'], 'SamplingImageContent._meta'), + ); + } } /// Audio content for sampling messages. @@ -473,22 +488,24 @@ class SamplingAudioContent extends SamplingContent { this.meta, }) : super(type: 'audio'); - factory SamplingAudioContent.fromJson(Map json) => - SamplingAudioContent( - data: readRequiredBase64String( - json['data'], - 'SamplingAudioContent.data', - ), - mimeType: readRequiredString( - json['mimeType'], - 'SamplingAudioContent.mimeType', - ), - annotations: readOptionalAnnotationsObject( - json['annotations'], - 'SamplingAudioContent.annotations', - ), - meta: _asJsonObjectOrNull(json['_meta'], 'SamplingAudioContent._meta'), - ); + factory SamplingAudioContent.fromJson(Map json) { + _expectType(json, 'audio', 'SamplingAudioContent.type'); + return SamplingAudioContent( + data: readRequiredBase64String( + json['data'], + 'SamplingAudioContent.data', + ), + mimeType: readRequiredString( + json['mimeType'], + 'SamplingAudioContent.mimeType', + ), + annotations: readOptionalAnnotationsObject( + json['annotations'], + 'SamplingAudioContent.annotations', + ), + meta: _asJsonObjectOrNull(json['_meta'], 'SamplingAudioContent._meta'), + ); + } } /// Tool use content for sampling messages. @@ -505,14 +522,15 @@ class SamplingToolUseContent extends SamplingContent { this.meta, }) : super(type: 'tool_use'); - factory SamplingToolUseContent.fromJson(Map json) => - SamplingToolUseContent( - id: readRequiredString(json['id'], 'SamplingToolUseContent.id'), - name: readRequiredString(json['name'], 'SamplingToolUseContent.name'), - input: _asJsonObject(json['input'], 'SamplingToolUseContent.input'), - meta: - _asJsonObjectOrNull(json['_meta'], 'SamplingToolUseContent._meta'), - ); + factory SamplingToolUseContent.fromJson(Map json) { + _expectType(json, 'tool_use', 'SamplingToolUseContent.type'); + return SamplingToolUseContent( + id: readRequiredString(json['id'], 'SamplingToolUseContent.id'), + name: readRequiredString(json['name'], 'SamplingToolUseContent.name'), + input: _asJsonObject(json['input'], 'SamplingToolUseContent.input'), + meta: _asJsonObjectOrNull(json['_meta'], 'SamplingToolUseContent._meta'), + ); + } } /// Tool result content for sampling messages. @@ -543,6 +561,7 @@ class SamplingToolResultContent extends SamplingContent { dynamic get legacyContent => content; factory SamplingToolResultContent.fromJson(Map json) { + _expectType(json, 'tool_result', 'SamplingToolResultContent.type'); return SamplingToolResultContent( toolUseId: readRequiredString( json['toolUseId'], diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 0e64588b..7f406a13 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1882,6 +1882,54 @@ void main() { }), throwsA(isA()), ); + expect( + () => SamplingTextContent.fromJson({ + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => SamplingTextContent.fromJson({ + 'type': 'image', + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => SamplingImageContent.fromJson({ + 'type': 'text', + 'data': 'aW1nZGF0YQ==', + 'mimeType': 'image/png', + }), + throwsA(isA()), + ); + expect( + () => SamplingAudioContent.fromJson({ + 'type': 'image', + 'data': 'YXVkaW8tZGF0YQ==', + 'mimeType': 'audio/wav', + }), + throwsA(isA()), + ); + expect( + () => SamplingToolUseContent.fromJson({ + 'type': 'tool_result', + 'id': 'call-1', + 'name': 'search', + 'input': {}, + }), + throwsA(isA()), + ); + expect( + () => SamplingToolResultContent.fromJson({ + 'type': 'tool_use', + 'toolUseId': 'call-1', + 'content': [ + {'type': 'text', 'text': 'Hello'}, + ], + }), + throwsA(isA()), + ); expect( () => SamplingToolResultContent.fromJson({ 'type': 'tool_result', diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index a5f0d4dc..e15d4691 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -587,6 +587,54 @@ void main() { }), throwsA(isA()), ); + expect( + () => SamplingTextContent.fromJson({ + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => SamplingTextContent.fromJson({ + 'type': 'image', + 'text': 'Hello', + }), + throwsA(isA()), + ); + expect( + () => SamplingImageContent.fromJson({ + 'type': 'text', + 'data': 'aW1nZGF0YQ==', + 'mimeType': 'image/png', + }), + throwsA(isA()), + ); + expect( + () => SamplingAudioContent.fromJson({ + 'type': 'image', + 'data': 'YXVkaW8tZGF0YQ==', + 'mimeType': 'audio/wav', + }), + throwsA(isA()), + ); + expect( + () => SamplingToolUseContent.fromJson({ + 'type': 'tool_result', + 'id': 'call-1', + 'name': 'search', + 'input': {}, + }), + throwsA(isA()), + ); + expect( + () => SamplingToolResultContent.fromJson({ + 'type': 'tool_use', + 'toolUseId': 'call-1', + 'content': [ + {'type': 'text', 'text': 'Hello'}, + ], + }), + throwsA(isA()), + ); expect( () => SamplingToolResultContent.fromJson({ 'type': 'tool_result', diff --git a/test/types/sampling_test.dart b/test/types/sampling_test.dart index a215665c..c84042d4 100644 --- a/test/types/sampling_test.dart +++ b/test/types/sampling_test.dart @@ -181,6 +181,19 @@ void main() { }), throwsA(isA()), ); + expect( + () => SamplingTextContent.fromJson({ + 'text': 'Parsed text', + }), + throwsA(isA()), + ); + expect( + () => SamplingTextContent.fromJson({ + 'type': 'image', + 'text': 'Parsed text', + }), + throwsA(isA()), + ); }); }); @@ -248,6 +261,21 @@ void main() { }), throwsA(isA()), ); + expect( + () => SamplingImageContent.fromJson({ + 'data': 'aW1nZGF0YQ==', + 'mimeType': 'image/png', + }), + throwsA(isA()), + ); + expect( + () => SamplingImageContent.fromJson({ + 'type': 'text', + 'data': 'aW1nZGF0YQ==', + 'mimeType': 'image/png', + }), + throwsA(isA()), + ); }); }); @@ -319,6 +347,21 @@ void main() { }), throwsA(isA()), ); + expect( + () => SamplingAudioContent.fromJson({ + 'data': 'YXVkaW8tZGF0YQ==', + 'mimeType': 'audio/wav', + }), + throwsA(isA()), + ); + expect( + () => SamplingAudioContent.fromJson({ + 'type': 'image', + 'data': 'YXVkaW8tZGF0YQ==', + 'mimeType': 'audio/wav', + }), + throwsA(isA()), + ); }); }); @@ -399,6 +442,23 @@ void main() { }), throwsA(isA()), ); + expect( + () => SamplingToolUseContent.fromJson({ + 'id': 'tu1', + 'name': 'fetch', + 'input': {'url': 'http://test.com'}, + }), + throwsA(isA()), + ); + expect( + () => SamplingToolUseContent.fromJson({ + 'type': 'tool_result', + 'id': 'tu1', + 'name': 'fetch', + 'input': {'url': 'http://test.com'}, + }), + throwsA(isA()), + ); }); }); @@ -525,6 +585,21 @@ void main() { }), throwsA(isA()), ); + expect( + () => SamplingToolResultContent.fromJson({ + 'toolUseId': 'tr1', + 'content': content, + }), + throwsA(isA()), + ); + expect( + () => SamplingToolResultContent.fromJson({ + 'type': 'tool_use', + 'toolUseId': 'tr1', + 'content': content, + }), + throwsA(isA()), + ); expect( () => const SamplingToolResultContent( toolUseId: 'tr1', From 980822e7176b63772e6babf74281ba4daef83f78 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 11:03:50 -0400 Subject: [PATCH 19/68] Validate completion reference discriminators --- CHANGELOG.md | 2 ++ lib/src/types/completion.dart | 13 +++++++++++++ test/mcp_2025_11_25_test.dart | 26 ++++++++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 14 ++++++++++++++ test/types_test.dart | 10 ++++++++++ 5 files changed, 65 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59b16cb4..9a4be810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,8 @@ parse errors. - Rejected malformed prompt, completion, logging, and common notification wire fields with protocol parse errors. +- Rejected missing and mismatched completion reference type discriminators with + protocol parse errors. - Rejected malformed tool definition, tool-list, and tool-call wire fields with protocol parse errors. - Rejected malformed root-list wire fields with protocol parse errors. diff --git a/lib/src/types/completion.dart b/lib/src/types/completion.dart index 48d4f7d4..46796338 100644 --- a/lib/src/types/completion.dart +++ b/lib/src/types/completion.dart @@ -1,6 +1,17 @@ import 'json_rpc.dart'; import 'validation.dart'; +void _expectType( + Map json, + String expected, + String field, +) { + final value = readRequiredString(json['type'], field); + if (value != expected) { + throw FormatException('$field must be "$expected"'); + } +} + /// Sealed class representing a reference for autocompletion targets. sealed class Reference { /// The type of reference ("ref/resource" or "ref/prompt"). @@ -46,6 +57,7 @@ class ResourceReference extends Reference { const ResourceReference({required this.uri}) : super(type: 'ref/resource'); factory ResourceReference.fromJson(Map json) { + _expectType(json, 'ref/resource', 'ResourceReference.type'); return ResourceReference( uri: readRequiredUriTemplateString(json['uri'], 'ResourceReference.uri'), ); @@ -65,6 +77,7 @@ class PromptReference extends Reference { }) : super(type: 'ref/prompt'); factory PromptReference.fromJson(Map json) { + _expectType(json, 'ref/prompt', 'PromptReference.type'); return PromptReference( name: readRequiredString(json['name'], 'PromptReference.name'), title: readOptionalString(json['title'], 'PromptReference.title'), diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 7f406a13..56400a5a 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1804,6 +1804,32 @@ void main() { }), throwsA(isA()), ); + expect( + () => ResourceReference.fromJson({ + 'uri': 'file:///{path}', + }), + throwsA(isA()), + ); + expect( + () => ResourceReference.fromJson({ + 'type': 'ref/prompt', + 'uri': 'file:///{path}', + }), + throwsA(isA()), + ); + expect( + () => PromptReference.fromJson({ + 'name': 'prompt', + }), + throwsA(isA()), + ); + expect( + () => PromptReference.fromJson({ + 'type': 'ref/resource', + 'name': 'prompt', + }), + throwsA(isA()), + ); expect( () => CompletionResultData.fromJson({ 'values': ['a'], diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index e15d4691..52afa61a 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1072,6 +1072,20 @@ void main() { 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, 'argument': {'name': 'arg', 'value': 1}, }), + () => ResourceReference.fromJson({ + 'uri': 'file:///{path}', + }), + () => ResourceReference.fromJson({ + 'type': 'ref/prompt', + 'uri': 'file:///{path}', + }), + () => PromptReference.fromJson({ + 'name': 'prompt', + }), + () => PromptReference.fromJson({ + 'type': 'ref/resource', + 'name': 'prompt', + }), () => CompletionResultData.fromJson({ 'values': ['a'], 'hasMore': 'true', diff --git a/test/types_test.dart b/test/types_test.dart index 3172a7aa..d240ab90 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -1633,6 +1633,16 @@ void main() { for (final parse in [ () => Reference.fromJson({'type': 1}), + () => ResourceReference.fromJson({'uri': 'file:///{path}'}), + () => ResourceReference.fromJson({ + 'type': 'ref/prompt', + 'uri': 'file:///{path}', + }), + () => PromptReference.fromJson({'name': 'prompt'}), + () => PromptReference.fromJson({ + 'type': 'ref/resource', + 'name': 'prompt', + }), () => PromptReference.fromJson({'name': 1}), () => ArgumentCompletionInfo.fromJson({'name': 1, 'value': 'v'}), () => ArgumentCompletionInfo.fromJson({'name': 'arg', 'value': 1}), From 61ea48637bf7fb649155a472235be4de9b73ab64 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 11:16:33 -0400 Subject: [PATCH 20/68] Validate completion request wrapper constants --- CHANGELOG.md | 2 ++ lib/src/server/mcp_server.dart | 2 ++ lib/src/types/completion.dart | 20 ++++++++++++++++++++ test/mcp_2025_11_25_test.dart | 24 ++++++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 18 ++++++++++++++++++ test/types_test.dart | 18 ++++++++++++++++++ 6 files changed, 84 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a4be810..d5a577e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,8 @@ fields with protocol parse errors. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. +- Rejected malformed completion JSON-RPC wrapper constants with protocol parse + errors. - Rejected malformed tool definition, tool-list, and tool-call wire fields with protocol parse errors. - Rejected malformed root-list wire fields with protocol parse errors. diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index 58773c1d..9b8dc3fe 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -1666,7 +1666,9 @@ class McpServer { ), }, (id, params, meta) => JsonRpcCompleteRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.completionComplete, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/lib/src/types/completion.dart b/lib/src/types/completion.dart index 46796338..8b5d971b 100644 --- a/lib/src/types/completion.dart +++ b/lib/src/types/completion.dart @@ -1,6 +1,21 @@ import 'json_rpc.dart'; import 'validation.dart'; +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + void _expectType( Map json, String expected, @@ -186,6 +201,11 @@ class JsonRpcCompleteRequest extends JsonRpcRequest { ); factory JsonRpcCompleteRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.completionComplete, + 'JsonRpcCompleteRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcCompleteRequest.params', diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 56400a5a..9203bb00 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1804,6 +1804,30 @@ void main() { }), throwsA(isA()), ); + expect( + () => JsonRpcCompleteRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 'complete', + 'method': Method.completionComplete, + 'params': { + 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, + 'argument': {'name': 'arg', 'value': 'prefix'}, + }, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcCompleteRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'complete', + 'method': Method.promptsGet, + 'params': { + 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, + 'argument': {'name': 'arg', 'value': 'prefix'}, + }, + }), + throwsA(isA()), + ); expect( () => ResourceReference.fromJson({ 'uri': 'file:///{path}', diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 52afa61a..c785b5aa 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1072,6 +1072,24 @@ void main() { 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, 'argument': {'name': 'arg', 'value': 1}, }), + () => JsonRpcCompleteRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 'complete', + 'method': Method.completionComplete, + 'params': { + 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, + 'argument': {'name': 'arg', 'value': 'prefix'}, + }, + }), + () => JsonRpcCompleteRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'complete', + 'method': Method.promptsGet, + 'params': { + 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, + 'argument': {'name': 'arg', 'value': 'prefix'}, + }, + }), () => ResourceReference.fromJson({ 'uri': 'file:///{path}', }), diff --git a/test/types_test.dart b/test/types_test.dart index d240ab90..197cf8bd 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -1662,6 +1662,24 @@ void main() { 'argument': validArgument, 'context': 'bad', }), + () => JsonRpcCompleteRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.completionComplete, + 'params': { + 'ref': validRef, + 'argument': validArgument, + }, + }), + () => JsonRpcCompleteRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.promptsGet, + 'params': { + 'ref': validRef, + 'argument': validArgument, + }, + }), () => JsonRpcCompleteRequest.fromJson({ 'jsonrpc': jsonRpcVersion, 'id': 1, From 3c6f9762d3a00e9a139218a5d587a156f2afdc6b Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 11:28:24 -0400 Subject: [PATCH 21/68] Validate prompt request wrapper constants --- CHANGELOG.md | 2 ++ lib/src/server/mcp_server.dart | 4 ++++ lib/src/types/prompts.dart | 35 ++++++++++++++++++++++++++++++++-- test/mcp_2025_11_25_test.dart | 24 +++++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 19 ++++++++++++++++++ test/types_test.dart | 30 +++++++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5a577e1..b775e386 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,8 @@ parse errors. - Rejected malformed prompt, completion, logging, and common notification wire fields with protocol parse errors. +- Rejected malformed prompt JSON-RPC wrapper constants with protocol parse + errors. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index 9b8dc3fe..ba7530ad 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -1858,7 +1858,9 @@ class McpServer { .toList(), ), (id, params, meta) => JsonRpcListPromptsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.promptsList, 'params': params, if (meta != null) '_meta': meta, }), @@ -1906,7 +1908,9 @@ class McpServer { } }, (id, params, meta) => JsonRpcGetPromptRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.promptsGet, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/lib/src/types/prompts.dart b/lib/src/types/prompts.dart index bfadf3ed..040c376d 100644 --- a/lib/src/types/prompts.dart +++ b/lib/src/types/prompts.dart @@ -2,6 +2,21 @@ import '../types.dart'; import 'json_rpc.dart'; import 'validation.dart'; +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + List? _readOptionalObjectList( Object? value, String field, @@ -159,6 +174,11 @@ class JsonRpcListPromptsRequest extends JsonRpcRequest { super(method: Method.promptsList, params: params?.toJson()); factory JsonRpcListPromptsRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.promptsList, + 'JsonRpcListPromptsRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcListPromptsRequest.params', @@ -302,6 +322,11 @@ class JsonRpcGetPromptRequest extends JsonRpcRequest { }) : super(method: Method.promptsGet, params: getParams.toJson()); factory JsonRpcGetPromptRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.promptsGet, + 'JsonRpcGetPromptRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcGetPromptRequest.params', @@ -403,8 +428,14 @@ class JsonRpcPromptListChangedNotification extends JsonRpcNotification { factory JsonRpcPromptListChangedNotification.fromJson( Map json, - ) => - JsonRpcPromptListChangedNotification(meta: extractRequestMeta(json)); + ) { + _expectJsonRpcMethod( + json, + Method.notificationsPromptsListChanged, + 'JsonRpcPromptListChangedNotification', + ); + return JsonRpcPromptListChangedNotification(meta: extractRequestMeta(json)); + } } /// Deprecated alias for [ListPromptsRequest]. diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 9203bb00..c13be0e7 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1797,6 +1797,30 @@ void main() { }), throwsA(isA()), ); + expect( + () => JsonRpcListPromptsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'prompts', + 'method': Method.resourcesList, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcGetPromptRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 'prompt', + 'method': Method.promptsGet, + 'params': {'name': 'prompt'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcPromptListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsResourcesListChanged, + }), + throwsA(isA()), + ); expect( () => CompleteRequest.fromJson({ 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index c785b5aa..f9fd8d07 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1068,6 +1068,21 @@ void main() { 'name': 'prompt', 'arguments': {'arg': 1}, }), + () => JsonRpcListPromptsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'prompts', + 'method': Method.resourcesList, + }), + () => JsonRpcGetPromptRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 'prompt', + 'method': Method.promptsGet, + 'params': {'name': 'prompt'}, + }), + () => JsonRpcPromptListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsResourcesListChanged, + }), () => CompleteRequest.fromJson({ 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, 'argument': {'name': 'arg', 'value': 1}, @@ -1837,7 +1852,9 @@ void main() { Method.promptsList, (request, extra) async => const ListPromptsResult(prompts: []), (id, params, meta) => JsonRpcListPromptsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.promptsList, 'params': params, if (meta != null) '_meta': meta, }), @@ -2643,7 +2660,9 @@ void main() { (request, extra) async => const InputRequiredResult(requestState: 'list-state'), (id, params, meta) => JsonRpcListPromptsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.promptsList, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/test/types_test.dart b/test/types_test.dart index 197cf8bd..8182efb9 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -1570,6 +1570,16 @@ void main() { 'icons': [1], }), () => ListPromptsRequest.fromJson({'cursor': 1}), + () => JsonRpcListPromptsRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.promptsList, + }), + () => JsonRpcListPromptsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.resourcesList, + }), () => ListPromptsResult.fromJson({ 'prompts': [1], }), @@ -1586,12 +1596,32 @@ void main() { 'name': 'prompt', 'arguments': {'arg': 1}, }), + () => JsonRpcGetPromptRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.promptsGet, + 'params': {'name': 'prompt'}, + }), + () => JsonRpcGetPromptRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.resourcesRead, + 'params': {'name': 'prompt'}, + }), () => JsonRpcGetPromptRequest.fromJson({ 'jsonrpc': jsonRpcVersion, 'id': 1, 'method': Method.promptsGet, 'params': 'bad', }), + () => JsonRpcPromptListChangedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsPromptsListChanged, + }), + () => JsonRpcPromptListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsResourcesListChanged, + }), () => PromptMessage.fromJson({ 'role': 'user', 'content': 'bad', From 4a3a59a60e9f64b0a00e5d852d3830b267432817 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 11:41:52 -0400 Subject: [PATCH 22/68] Validate resource request wrapper constants --- CHANGELOG.md | 2 + .../lib/services/streamable_mcp_service.dart | 2 + .../client_streamable_https.dart | 2 + lib/src/server/mcp_server.dart | 6 + lib/src/types/resources.dart | 58 ++++++- test/mcp_2025_11_25_test.dart | 58 +++++++ test/mcp_2026_07_28_test.dart | 31 ++++ test/types/resources_test.dart | 152 ++++++++++++++++++ 8 files changed, 309 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b775e386..f1ae0e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,8 @@ fields with protocol parse errors. - Rejected malformed prompt JSON-RPC wrapper constants with protocol parse errors. +- Rejected malformed resource JSON-RPC wrapper constants with protocol parse + errors. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/example/flutter_http_client/lib/services/streamable_mcp_service.dart b/example/flutter_http_client/lib/services/streamable_mcp_service.dart index 1923f79e..6b2c3519 100644 --- a/example/flutter_http_client/lib/services/streamable_mcp_service.dart +++ b/example/flutter_http_client/lib/services/streamable_mcp_service.dart @@ -173,6 +173,8 @@ class StreamableMcpService extends ChangeNotifier { return Future.value(); }, (params, meta) => JsonRpcResourceListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsResourcesListChanged, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/example/streamable_https/client_streamable_https.dart b/example/streamable_https/client_streamable_https.dart index dd28208c..07327655 100644 --- a/example/streamable_https/client_streamable_https.dart +++ b/example/streamable_https/client_streamable_https.dart @@ -250,6 +250,8 @@ Future connect([String? url]) async { return Future.value(); }, (params, meta) => JsonRpcResourceListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsResourcesListChanged, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index ba7530ad..9bff2216 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -1773,7 +1773,9 @@ class McpServer { return ListResourcesResult(resources: [...fixed, ...templates]); }, (id, params, meta) => JsonRpcListResourcesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.resourcesList, 'params': params, if (meta != null) '_meta': meta, }), @@ -1788,7 +1790,9 @@ class McpServer { .toList(), ), (id, params, meta) => JsonRpcListResourceTemplatesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.resourcesTemplatesList, 'params': params, if (meta != null) '_meta': meta, }), @@ -1831,7 +1835,9 @@ class McpServer { ); }, (id, params, meta) => JsonRpcReadResourceRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.resourcesRead, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/lib/src/types/resources.dart b/lib/src/types/resources.dart index 53860fe6..b26cb171 100644 --- a/lib/src/types/resources.dart +++ b/lib/src/types/resources.dart @@ -22,6 +22,22 @@ List? _readOptionalIconList( ]; } +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + /// Additional properties describing a Resource to clients. class ResourceAnnotations { /// A human-readable title for the resource. @@ -306,6 +322,11 @@ class JsonRpcListResourcesRequest extends JsonRpcRequest { /// Creates from JSON. factory JsonRpcListResourcesRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.resourcesList, + 'JsonRpcListResourcesRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcListResourcesRequest.params', @@ -431,6 +452,11 @@ class JsonRpcListResourceTemplatesRequest extends JsonRpcRequest { factory JsonRpcListResourceTemplatesRequest.fromJson( Map json, ) { + _expectJsonRpcMethod( + json, + Method.resourcesTemplatesList, + 'JsonRpcListResourceTemplatesRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcListResourceTemplatesRequest.params', @@ -582,6 +608,11 @@ class JsonRpcReadResourceRequest extends JsonRpcRequest { }) : super(method: Method.resourcesRead, params: readParams.toJson()); factory JsonRpcReadResourceRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.resourcesRead, + 'JsonRpcReadResourceRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcReadResourceRequest.params', @@ -668,8 +699,16 @@ class JsonRpcResourceListChangedNotification extends JsonRpcNotification { factory JsonRpcResourceListChangedNotification.fromJson( Map json, - ) => - JsonRpcResourceListChangedNotification(meta: extractRequestMeta(json)); + ) { + _expectJsonRpcMethod( + json, + Method.notificationsResourcesListChanged, + 'JsonRpcResourceListChangedNotification', + ); + return JsonRpcResourceListChangedNotification( + meta: extractRequestMeta(json), + ); + } } /// Parameters for the `resources/subscribe` request. @@ -702,6 +741,11 @@ class JsonRpcSubscribeRequest extends JsonRpcRequest { }) : super(method: Method.resourcesSubscribe, params: subParams.toJson()); factory JsonRpcSubscribeRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.resourcesSubscribe, + 'JsonRpcSubscribeRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcSubscribeRequest.params', @@ -751,6 +795,11 @@ class JsonRpcUnsubscribeRequest extends JsonRpcRequest { }) : super(method: Method.resourcesUnsubscribe, params: unsubParams.toJson()); factory JsonRpcUnsubscribeRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.resourcesUnsubscribe, + 'JsonRpcUnsubscribeRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcUnsubscribeRequest.params', @@ -804,6 +853,11 @@ class JsonRpcResourceUpdatedNotification extends JsonRpcNotification { factory JsonRpcResourceUpdatedNotification.fromJson( Map json, ) { + _expectJsonRpcMethod( + json, + Method.notificationsResourcesUpdated, + 'JsonRpcResourceUpdatedNotification', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcResourceUpdatedNotification.params', diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index c13be0e7..2e6507fd 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1821,6 +1821,64 @@ void main() { }), throwsA(isA()), ); + expect( + () => JsonRpcListResourcesRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 'resources', + 'method': Method.resourcesList, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcListResourceTemplatesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'templates', + 'method': Method.resourcesList, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcReadResourceRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'read', + 'method': Method.resourcesList, + 'params': {'uri': 'file:///a.txt'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcSubscribeRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 'subscribe', + 'method': Method.resourcesSubscribe, + 'params': {'uri': 'file:///a.txt'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcUnsubscribeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'unsubscribe', + 'method': Method.resourcesSubscribe, + 'params': {'uri': 'file:///a.txt'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcResourceListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsResourcesUpdated, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcResourceUpdatedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsResourcesUpdated, + 'params': {'uri': 'file:///a.txt'}, + }), + throwsA(isA()), + ); expect( () => CompleteRequest.fromJson({ 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index f9fd8d07..92e4bb82 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1083,6 +1083,31 @@ void main() { 'jsonrpc': jsonRpcVersion, 'method': Method.notificationsResourcesListChanged, }), + () => JsonRpcListResourcesRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 'resources', + 'method': Method.resourcesList, + }), + () => JsonRpcListResourceTemplatesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'templates', + 'method': Method.resourcesList, + }), + () => JsonRpcReadResourceRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'read', + 'method': Method.resourcesList, + 'params': {'uri': 'file:///a.txt'}, + }), + () => JsonRpcResourceListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsResourcesUpdated, + }), + () => JsonRpcResourceUpdatedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsResourcesUpdated, + 'params': {'uri': 'file:///a.txt'}, + }), () => CompleteRequest.fromJson({ 'ref': {'type': 'ref/prompt', 'name': 'prompt'}, 'argument': {'name': 'arg', 'value': 1}, @@ -1863,7 +1888,9 @@ void main() { Method.resourcesList, (request, extra) async => const ListResourcesResult(resources: []), (id, params, meta) => JsonRpcListResourcesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.resourcesList, 'params': params, if (meta != null) '_meta': meta, }), @@ -1873,7 +1900,9 @@ void main() { (request, extra) async => const ListResourceTemplatesResult(resourceTemplates: []), (id, params, meta) => JsonRpcListResourceTemplatesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.resourcesTemplatesList, 'params': params, if (meta != null) '_meta': meta, }), @@ -1884,7 +1913,9 @@ void main() { contents: [TextResourceContents(uri: 'file:///a.txt', text: 'a')], ), (id, params, meta) => JsonRpcReadResourceRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.resourcesRead, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/test/types/resources_test.dart b/test/types/resources_test.dart index 8936f9b8..d6d6eb59 100644 --- a/test/types/resources_test.dart +++ b/test/types/resources_test.dart @@ -476,6 +476,7 @@ void main() { test('fromJson parses correctly', () { final json = { + 'jsonrpc': jsonRpcVersion, 'id': 3, 'method': 'resources/list', 'params': {'cursor': 'xyz'}, @@ -488,6 +489,7 @@ void main() { test('fromJson without params', () { final json = { + 'jsonrpc': jsonRpcVersion, 'id': 4, 'method': 'resources/list', }; @@ -500,6 +502,7 @@ void main() { test('fromJson rejects non-object params', () { expect( () => JsonRpcListResourcesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': 5, 'method': 'resources/list', 'params': 'bad', @@ -507,6 +510,25 @@ void main() { throwsA(isA()), ); }); + + test('fromJson rejects wrong wrapper constants', () { + expect( + () => JsonRpcListResourcesRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 5, + 'method': 'resources/list', + }), + throwsA(isA()), + ); + expect( + () => JsonRpcListResourcesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 5, + 'method': 'resources/read', + }), + throwsA(isA()), + ); + }); }); group('ListResourcesResult', () { @@ -604,6 +626,7 @@ void main() { test('fromJson parses correctly', () { final json = { + 'jsonrpc': jsonRpcVersion, 'id': 11, 'method': 'resources/templates/list', 'params': {'cursor': 'tmpl_page'}, @@ -617,6 +640,7 @@ void main() { test('fromJson rejects non-object params', () { expect( () => JsonRpcListResourceTemplatesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': 12, 'method': 'resources/templates/list', 'params': 'bad', @@ -624,6 +648,25 @@ void main() { throwsA(isA()), ); }); + + test('fromJson rejects wrong wrapper constants', () { + expect( + () => JsonRpcListResourceTemplatesRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 12, + 'method': 'resources/templates/list', + }), + throwsA(isA()), + ); + expect( + () => JsonRpcListResourceTemplatesRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 12, + 'method': 'resources/list', + }), + throwsA(isA()), + ); + }); }); group('ListResourceTemplatesResult', () { @@ -702,6 +745,7 @@ void main() { test('fromJson parses correctly', () { final json = { + 'jsonrpc': jsonRpcVersion, 'id': 21, 'method': 'resources/read', 'params': {'uri': 'file:///parsed.txt'}, @@ -714,6 +758,7 @@ void main() { test('fromJson throws on missing params', () { final json = { + 'jsonrpc': jsonRpcVersion, 'id': 22, 'method': 'resources/read', }; @@ -723,6 +768,27 @@ void main() { throwsA(isA()), ); }); + + test('fromJson rejects wrong wrapper constants', () { + expect( + () => JsonRpcReadResourceRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 22, + 'method': 'resources/read', + 'params': {'uri': 'file:///parsed.txt'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcReadResourceRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 22, + 'method': 'resources/list', + 'params': {'uri': 'file:///parsed.txt'}, + }), + throwsA(isA()), + ); + }); }); group('ReadResourceResult', () { @@ -853,6 +919,7 @@ void main() { test('fromJson creates notification', () { final json = { + 'jsonrpc': jsonRpcVersion, 'method': 'notifications/resources/list_changed', }; @@ -863,6 +930,23 @@ void main() { equals('notifications/resources/list_changed'), ); }); + + test('fromJson rejects wrong wrapper constants', () { + expect( + () => JsonRpcResourceListChangedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': 'notifications/resources/list_changed', + }), + throwsA(isA()), + ); + expect( + () => JsonRpcResourceListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': 'notifications/resources/updated', + }), + throwsA(isA()), + ); + }); }); group('SubscribeRequest', () { @@ -890,6 +974,7 @@ void main() { test('fromJson parses correctly', () { final json = { + 'jsonrpc': jsonRpcVersion, 'id': 31, 'method': 'resources/subscribe', 'params': {'uri': 'file:///subscribed.txt'}, @@ -902,6 +987,7 @@ void main() { test('fromJson throws on missing params', () { final json = { + 'jsonrpc': jsonRpcVersion, 'id': 32, 'method': 'resources/subscribe', }; @@ -911,6 +997,27 @@ void main() { throwsA(isA()), ); }); + + test('fromJson rejects wrong wrapper constants', () { + expect( + () => JsonRpcSubscribeRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 32, + 'method': 'resources/subscribe', + 'params': {'uri': 'file:///subscribed.txt'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcSubscribeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 32, + 'method': 'resources/unsubscribe', + 'params': {'uri': 'file:///subscribed.txt'}, + }), + throwsA(isA()), + ); + }); }); group('UnsubscribeRequest', () { @@ -938,6 +1045,7 @@ void main() { test('fromJson parses correctly', () { final json = { + 'jsonrpc': jsonRpcVersion, 'id': 41, 'method': 'resources/unsubscribe', 'params': {'uri': 'file:///unsubscribed.txt'}, @@ -950,6 +1058,7 @@ void main() { test('fromJson throws on missing params', () { final json = { + 'jsonrpc': jsonRpcVersion, 'id': 42, 'method': 'resources/unsubscribe', }; @@ -959,6 +1068,27 @@ void main() { throwsA(isA()), ); }); + + test('fromJson rejects wrong wrapper constants', () { + expect( + () => JsonRpcUnsubscribeRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 42, + 'method': 'resources/unsubscribe', + 'params': {'uri': 'file:///unsubscribed.txt'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcUnsubscribeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 42, + 'method': 'resources/subscribe', + 'params': {'uri': 'file:///unsubscribed.txt'}, + }), + throwsA(isA()), + ); + }); }); group('ResourceUpdatedNotification', () { @@ -991,6 +1121,7 @@ void main() { test('fromJson parses correctly', () { final json = { + 'jsonrpc': jsonRpcVersion, 'method': 'notifications/resources/updated', 'params': {'uri': 'file:///parsed_notify.txt'}, }; @@ -1004,6 +1135,7 @@ void main() { test('fromJson throws on missing params', () { final json = { + 'jsonrpc': jsonRpcVersion, 'method': 'notifications/resources/updated', }; @@ -1015,6 +1147,7 @@ void main() { test('fromJson with meta', () { final json = { + 'jsonrpc': jsonRpcVersion, 'method': 'notifications/resources/updated', 'params': { 'uri': 'file:///with_meta.txt', @@ -1026,6 +1159,25 @@ void main() { expect(notification.meta, isNotNull); expect(notification.meta!['key'], equals('value')); }); + + test('fromJson rejects wrong wrapper constants', () { + expect( + () => JsonRpcResourceUpdatedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': 'notifications/resources/updated', + 'params': {'uri': 'file:///parsed_notify.txt'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcResourceUpdatedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': 'notifications/resources/list_changed', + 'params': {'uri': 'file:///parsed_notify.txt'}, + }), + throwsA(isA()), + ); + }); }); group('Resource URI format validation', () { From 8cc2d0c24f45708495a5785423740642672865c3 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 11:52:36 -0400 Subject: [PATCH 23/68] Validate tool request wrapper constants --- CHANGELOG.md | 2 ++ lib/src/server/mcp_server.dart | 4 +++ lib/src/types/json_rpc.dart | 18 ++++++++++++ lib/src/types/tools.dart | 26 +++++++++++++++-- test/mcp_2025_11_25_test.dart | 30 ++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 40 +++++++++++++++++++++++++++ test/server/server_advanced_test.dart | 8 +++++- test/types_test.dart | 30 ++++++++++++++++++++ 8 files changed, 155 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ae0e40..cafe4826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,8 @@ errors. - Rejected malformed resource JSON-RPC wrapper constants with protocol parse errors. +- Rejected malformed tool JSON-RPC wrapper constants with protocol parse + errors. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index 9bff2216..c8dccd8d 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -1494,7 +1494,9 @@ class McpServer { ); }, (id, params, meta) => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.toolsList, 'params': params, if (meta != null) '_meta': meta, }), @@ -1635,7 +1637,9 @@ class McpServer { } }, (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.toolsCall, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index 88bfc2f0..adc50e66 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -336,6 +336,22 @@ Map? extractRequestMeta(Map json) { return paramsMeta ?? topLevelMeta; } +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + /// Base class for all JSON-RPC messages (requests, notifications, responses, errors). sealed class JsonRpcMessage { /// The JSON-RPC version string. Always "2.0". @@ -1058,6 +1074,7 @@ class JsonRpcListToolsRequest extends JsonRpcRequest { } factory JsonRpcListToolsRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.toolsList, 'JsonRpcListToolsRequest'); return JsonRpcListToolsRequest( id: parseRequestId(json['id']), params: readOptionalJsonObject( @@ -1086,6 +1103,7 @@ class JsonRpcCallToolRequest extends JsonRpcRequest { }) : super(method: Method.toolsCall, params: params); factory JsonRpcCallToolRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.toolsCall, 'JsonRpcCallToolRequest'); return JsonRpcCallToolRequest( id: parseRequestId(json['id']), params: readOptionalJsonObject( diff --git a/lib/src/types/tools.dart b/lib/src/types/tools.dart index dd9b1fc1..486ae33e 100644 --- a/lib/src/types/tools.dart +++ b/lib/src/types/tools.dart @@ -15,6 +15,22 @@ typedef ToolInputSchema = JsonObject; /// [JsonSchema] directly when the output schema root is not an object. typedef ToolOutputSchema = JsonObject; +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + /// Additional properties describing a Tool to clients. /// /// NOTE: all properties in ToolAnnotations are **hints**. @@ -509,8 +525,14 @@ class JsonRpcToolListChangedNotification extends JsonRpcNotification { factory JsonRpcToolListChangedNotification.fromJson( Map json, - ) => - JsonRpcToolListChangedNotification(meta: extractRequestMeta(json)); + ) { + _expectJsonRpcMethod( + json, + Method.notificationsToolsListChanged, + 'JsonRpcToolListChangedNotification', + ); + return JsonRpcToolListChangedNotification(meta: extractRequestMeta(json)); + } } void _validateObjectRootSchema( diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 2e6507fd..805224bc 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -656,12 +656,42 @@ void main() { 'method': Method.toolsList, 'params': 'bad', }), + () => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.toolsList, + }), + () => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.promptsList, + }), () => JsonRpcCallToolRequest.fromJson({ 'jsonrpc': jsonRpcVersion, 'id': 1, 'method': Method.toolsCall, 'params': 'bad', }), + () => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.toolsCall, + 'params': {'name': 'tool'}, + }), + () => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.promptsGet, + 'params': {'name': 'tool'}, + }), + () => JsonRpcToolListChangedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsToolsListChanged, + }), + () => JsonRpcToolListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsPromptsListChanged, + }), ]) { expect(parse, throwsA(isA())); } diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 92e4bb82..5c6aecb6 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1530,12 +1530,42 @@ void main() { 'method': Method.toolsList, 'params': 'bad', }), + () => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.toolsList, + }), + () => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.promptsList, + }), () => JsonRpcCallToolRequest.fromJson({ 'jsonrpc': jsonRpcVersion, 'id': 1, 'method': Method.toolsCall, 'params': 'bad', }), + () => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.toolsCall, + 'params': {'name': 'tool'}, + }), + () => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.promptsGet, + 'params': {'name': 'tool'}, + }), + () => JsonRpcToolListChangedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsToolsListChanged, + }), + () => JsonRpcToolListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsPromptsListChanged, + }), ]) { expect(parse, throwsFormatException); } @@ -1868,7 +1898,9 @@ void main() { cacheScope: CacheScope.public, ), (id, params, meta) => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.toolsList, 'params': params, if (meta != null) '_meta': meta, }), @@ -2369,7 +2401,9 @@ void main() { ), ), (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.toolsCall, 'params': params, if (meta != null) '_meta': meta, }), @@ -2419,7 +2453,9 @@ void main() { ), ), (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.toolsCall, 'params': params, if (meta != null) '_meta': meta, }), @@ -2455,7 +2491,9 @@ void main() { (request, extra) async => const InputRequiredResult(requestState: 'retry-state'), (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.toolsCall, 'params': params, if (meta != null) '_meta': meta, }), @@ -2528,7 +2566,9 @@ void main() { ); }, (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.toolsCall, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/test/server/server_advanced_test.dart b/test/server/server_advanced_test.dart index c9050fc9..4c4fca16 100644 --- a/test/server/server_advanced_test.dart +++ b/test/server/server_advanced_test.dart @@ -185,7 +185,13 @@ void main() { return const EmptyResult(); }, (id, params, meta) => JsonRpcCallToolRequest.fromJson( - {'id': id, 'params': params, '_meta': meta}, + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + '_meta': meta, + }, ), ); diff --git a/test/types_test.dart b/test/types_test.dart index 8182efb9..7e643dcf 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -583,12 +583,42 @@ void main() { 'method': Method.toolsList, 'params': 'bad', }), + () => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.toolsList, + }), + () => JsonRpcListToolsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.promptsList, + }), () => JsonRpcCallToolRequest.fromJson({ 'jsonrpc': jsonRpcVersion, 'id': 1, 'method': Method.toolsCall, 'params': 'bad', }), + () => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.toolsCall, + 'params': {'name': 'tool'}, + }), + () => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.promptsGet, + 'params': {'name': 'tool'}, + }), + () => JsonRpcToolListChangedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsToolsListChanged, + }), + () => JsonRpcToolListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsPromptsListChanged, + }), ]) { expect(parse, throwsA(isA())); } From 8f28d5227e6ee47f0e4b90075f2b30f6595546c2 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 12:01:00 -0400 Subject: [PATCH 24/68] Validate root request wrapper constants --- CHANGELOG.md | 2 ++ lib/src/types/roots.dart | 22 ++++++++++++++++++++++ test/client/client_test.dart | 2 ++ test/mcp_2025_11_25_test.dart | 18 ++++++++++++++++++ test/mcp_2026_07_28_test.dart | 18 ++++++++++++++++++ test/types_test.dart | 18 ++++++++++++++++++ 6 files changed, 80 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cafe4826..0e2b012b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,8 @@ errors. - Rejected malformed tool JSON-RPC wrapper constants with protocol parse errors. +- Rejected malformed root JSON-RPC wrapper constants with protocol parse + errors. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/lib/src/types/roots.dart b/lib/src/types/roots.dart index 1895ed28..3911ffc6 100644 --- a/lib/src/types/roots.dart +++ b/lib/src/types/roots.dart @@ -19,6 +19,22 @@ void _validateRootUri(String uri) { } } +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + /// Represents a root directory or file the server can operate on. class Root { /// URI identifying the root (must start with `file://`). @@ -62,6 +78,7 @@ class JsonRpcListRootsRequest extends JsonRpcRequest { : super(method: Method.rootsList); factory JsonRpcListRootsRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.rootsList, 'JsonRpcListRootsRequest'); _readOptionalParamsObject(json, 'JsonRpcListRootsRequest.params'); return JsonRpcListRootsRequest( id: parseRequestId(json['id']), @@ -114,6 +131,11 @@ class JsonRpcRootsListChangedNotification extends JsonRpcNotification { factory JsonRpcRootsListChangedNotification.fromJson( Map json, ) { + _expectJsonRpcMethod( + json, + Method.notificationsRootsListChanged, + 'JsonRpcRootsListChangedNotification', + ); _readOptionalParamsObject( json, 'JsonRpcRootsListChangedNotification.params', diff --git a/test/client/client_test.dart b/test/client/client_test.dart index ef07d9d0..88ccb1b9 100644 --- a/test/client/client_test.dart +++ b/test/client/client_test.dart @@ -1093,7 +1093,9 @@ void _addCriticalPathTests() { 'roots/list', (request, extra) async => const ListRootsResult(roots: []), (id, params, meta) => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.rootsList, if (params != null) 'params': params, if (meta != null) '_meta': meta, }), diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 805224bc..71ecc66b 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -715,6 +715,16 @@ void main() { 'method': Method.rootsList, 'params': null, }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.rootsList, + }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsList, + }), () => JsonRpcRootsListChangedNotification.fromJson({ 'jsonrpc': jsonRpcVersion, 'method': Method.notificationsRootsListChanged, @@ -725,6 +735,14 @@ void main() { 'method': Method.notificationsRootsListChanged, 'params': null, }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsRootsListChanged, + }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsToolsListChanged, + }), ]) { expect(parse, throwsA(isA())); } diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 5c6aecb6..315f4cf0 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1589,6 +1589,16 @@ void main() { 'method': Method.rootsList, 'params': null, }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.rootsList, + }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsList, + }), () => JsonRpcRootsListChangedNotification.fromJson({ 'jsonrpc': jsonRpcVersion, 'method': Method.notificationsRootsListChanged, @@ -1599,6 +1609,14 @@ void main() { 'method': Method.notificationsRootsListChanged, 'params': null, }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsRootsListChanged, + }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsToolsListChanged, + }), ]) { expect(parse, throwsFormatException); } diff --git a/test/types_test.dart b/test/types_test.dart index 7e643dcf..e31711c9 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -400,6 +400,16 @@ void main() { 'method': Method.rootsList, 'params': null, }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.rootsList, + }), + () => JsonRpcListRootsRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsList, + }), () => JsonRpcRootsListChangedNotification.fromJson({ 'jsonrpc': jsonRpcVersion, 'method': Method.notificationsRootsListChanged, @@ -410,6 +420,14 @@ void main() { 'method': Method.notificationsRootsListChanged, 'params': null, }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsRootsListChanged, + }), + () => JsonRpcRootsListChangedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsToolsListChanged, + }), ]) { expect(parse, throwsA(isA())); } From dfc0fe35ff34a03ccf8080717ee00130d67652f2 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 12:12:41 -0400 Subject: [PATCH 25/68] Validate common request wrapper constants --- CHANGELOG.md | 2 + lib/src/server/server.dart | 2 + lib/src/shared/protocol.dart | 4 ++ lib/src/types/logging.dart | 26 ++++++++++++ lib/src/types/misc.dart | 35 +++++++++++++++ test/mcp_2025_11_25_test.dart | 49 +++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 20 +++++++++ test/types/logging_types_test.dart | 41 ++++++++++++++++++ test/types_edge_cases_test.dart | 68 ++++++++++++++++++++++++++++++ 9 files changed, 247 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e2b012b..f78fc13f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -129,6 +129,8 @@ errors. - Rejected malformed root JSON-RPC wrapper constants with protocol parse errors. +- Rejected malformed common notification and logging JSON-RPC wrapper + constants with protocol parse errors. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index d5a1f52d..0db353fb 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -130,7 +130,9 @@ class Server extends Protocol { return const EmptyResult(); }, (id, params, meta) => JsonRpcSetLevelRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.loggingSetLevel, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 58c08297..15cb561e 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -461,6 +461,8 @@ abstract class Protocol { controller?.abort(params.reason); }, (params, meta) => JsonRpcCancelledNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsCancelled, 'params': params, if (meta != null) '_meta': meta, }), @@ -470,6 +472,8 @@ abstract class Protocol { "notifications/progress", (notification) async => _onprogress(notification), (params, meta) => JsonRpcProgressNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsProgress, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/lib/src/types/logging.dart b/lib/src/types/logging.dart index 6553f1c9..5657b588 100644 --- a/lib/src/types/logging.dart +++ b/lib/src/types/logging.dart @@ -1,6 +1,22 @@ import 'json_rpc.dart'; import 'validation.dart'; +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + /// Severity levels for log messages (syslog levels). enum LoggingLevel { debug, @@ -44,6 +60,11 @@ class JsonRpcSetLevelRequest extends JsonRpcRequest { }) : super(method: Method.loggingSetLevel, params: setParams.toJson()); factory JsonRpcSetLevelRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.loggingSetLevel, + 'JsonRpcSetLevelRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcSetLevelRequest.params', @@ -111,6 +132,11 @@ class JsonRpcLoggingMessageNotification extends JsonRpcNotification { factory JsonRpcLoggingMessageNotification.fromJson( Map json, ) { + _expectJsonRpcMethod( + json, + Method.notificationsMessage, + 'JsonRpcLoggingMessageNotification', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcLoggingMessageNotification.params', diff --git a/lib/src/types/misc.dart b/lib/src/types/misc.dart index f386e6f3..c17f22a9 100644 --- a/lib/src/types/misc.dart +++ b/lib/src/types/misc.dart @@ -1,6 +1,29 @@ import 'json_rpc.dart'; import 'validation.dart'; +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + +void _readOptionalParamsObject(Map json, String field) { + if (!json.containsKey('params')) { + return; + } + readJsonObject(json['params'], field); +} + /// A response that indicates success but carries no specific data. class EmptyResult implements BaseResultData { @override @@ -54,6 +77,11 @@ class JsonRpcCancelledNotification extends JsonRpcNotification { ); factory JsonRpcCancelledNotification.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.notificationsCancelled, + 'JsonRpcCancelledNotification', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcCancelledNotification.params', @@ -78,6 +106,8 @@ class JsonRpcPingRequest extends JsonRpcRequest { : super(method: Method.ping); factory JsonRpcPingRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.ping, 'JsonRpcPingRequest'); + _readOptionalParamsObject(json, 'JsonRpcPingRequest.params'); return JsonRpcPingRequest( id: parseRequestId(json['id']), meta: extractRequestMeta(json), @@ -180,6 +210,11 @@ class JsonRpcProgressNotification extends JsonRpcNotification { /// Creates from JSON. factory JsonRpcProgressNotification.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.notificationsProgress, + 'JsonRpcProgressNotification', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcProgressNotification.params', diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 71ecc66b..d7ef0f67 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1827,6 +1827,55 @@ void main() { }), throwsA(isA()), ); + expect( + () => JsonRpcPingRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 'ping', + 'method': Method.ping, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcPingRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'ping', + 'method': Method.toolsList, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcSetLevelRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'log-level', + 'method': Method.toolsCall, + 'params': {'level': 'info'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcLoggingMessageNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsMessage, + 'params': {'level': 'info', 'data': 'message'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcCancelledNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsProgress, + 'params': {'requestId': 'request-1'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcProgressNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsProgress, + 'params': {'progressToken': 'progress-1', 'progress': 1}, + }), + throwsA(isA()), + ); expect( () => PromptArgument.fromJson({'name': 1}), throwsA(isA()), diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 315f4cf0..f8aee816 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1172,6 +1172,26 @@ void main() { 'level': 'info', 'data': Object(), }), + () => JsonRpcLoggingMessageNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsMessage, + 'params': {'level': 'info', 'data': 'message'}, + }), + () => JsonRpcLoggingMessageNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsProgress, + 'params': {'level': 'info', 'data': 'message'}, + }), + () => JsonRpcCancelledNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsProgress, + 'params': {'requestId': 'request-1'}, + }), + () => JsonRpcProgressNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsProgress, + 'params': {'progressToken': 'progress-1', 'progress': 1}, + }), () => ProgressNotification.fromJson({ 'progressToken': 'progress-1', 'progress': 1, diff --git a/test/types/logging_types_test.dart b/test/types/logging_types_test.dart index a4cfabe0..10943976 100644 --- a/test/types/logging_types_test.dart +++ b/test/types/logging_types_test.dart @@ -1,3 +1,4 @@ +import 'package:mcp_dart/src/types/json_rpc.dart'; import 'package:mcp_dart/src/types/logging.dart'; import 'package:test/test.dart'; @@ -124,6 +125,27 @@ void main() { ); }); + test('fromJson rejects wrong wrapper constants', () { + expect( + () => JsonRpcSetLevelRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': 'logging/setLevel', + 'params': {'level': 'info'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcSetLevelRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': 'notifications/message', + 'params': {'level': 'info'}, + }), + throwsA(isA()), + ); + }); + test('toJson serializes correctly', () { final request = JsonRpcSetLevelRequest( id: 5, @@ -319,6 +341,25 @@ void main() { ); }); + test('fromJson rejects wrong wrapper constants', () { + expect( + () => JsonRpcLoggingMessageNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': 'notifications/message', + 'params': {'level': 'info', 'data': 'message'}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcLoggingMessageNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': 'logging/setLevel', + 'params': {'level': 'info', 'data': 'message'}, + }), + throwsA(isA()), + ); + }); + test('toJson serializes correctly', () { final notification = JsonRpcLoggingMessageNotification( logParams: const LoggingMessageNotificationParams( diff --git a/test/types_edge_cases_test.dart b/test/types_edge_cases_test.dart index 5333a5ba..7f3608b2 100644 --- a/test/types_edge_cases_test.dart +++ b/test/types_edge_cases_test.dart @@ -162,6 +162,25 @@ void main() { ); }); + test('rejects wrong wrapper constants', () { + expect( + () => JsonRpcCancelledNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': 'notifications/cancelled', + 'params': {'requestId': 1}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcCancelledNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': 'notifications/progress', + 'params': {'requestId': 1}, + }), + throwsA(isA()), + ); + }); + test('handles optional reason field correctly', () { // With reason final withReason = const CancelledNotificationParams( @@ -302,6 +321,36 @@ void main() { }); }); + group('JsonRpcPingRequest Edge Cases', () { + test('rejects wrong wrapper constants and malformed params', () { + expect( + () => JsonRpcPingRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.ping, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcPingRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsList, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcPingRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.ping, + 'params': null, + }), + throwsA(isA()), + ); + }); + }); + group('JsonRpcProgressNotification Edge Cases', () { test('throws FormatException when params is missing', () { final json = { @@ -322,6 +371,25 @@ void main() { ); }); + test('rejects wrong wrapper constants', () { + expect( + () => JsonRpcProgressNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': 'notifications/progress', + 'params': {'progressToken': 'token', 'progress': 1}, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcProgressNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': 'notifications/cancelled', + 'params': {'progressToken': 'token', 'progress': 1}, + }), + throwsA(isA()), + ); + }); + test('handles progress with optional total field', () { // With total final withTotal = const Progress(progress: 50, total: 100); From 4602e75703dc47d7d59a68cc46ad3e8bfe4183d3 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 12:23:58 -0400 Subject: [PATCH 26/68] Validate initialization wrapper constants --- CHANGELOG.md | 2 + lib/src/server/server.dart | 6 ++- lib/src/types/initialization.dart | 45 ++++++++++++++++++++- test/mcp_2025_11_25_test.dart | 25 ++++++++++++ test/mcp_2026_07_28_test.dart | 17 ++++++++ test/types_edge_cases_test.dart | 66 +++++++++++++++++++++++++++++++ test/types_test.dart | 31 +++++++++++++++ 7 files changed, 189 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f78fc13f..128bc6be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,8 @@ errors. - Rejected malformed common notification and logging JSON-RPC wrapper constants with protocol parse errors. +- Rejected malformed initialization and `server/discover` JSON-RPC wrapper + constants with protocol parse errors. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 0db353fb..0554d273 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -104,7 +104,9 @@ class Server extends Protocol { Method.initialize, (request, extra) async => _oninitialize(request.initParams), (id, params, meta) => JsonRpcInitializeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.initialize, 'params': params, if (meta != null) '_meta': meta, }), @@ -117,7 +119,9 @@ class Server extends Protocol { _lifecycleState = _ServerLifecycleState.ready; }, (params, meta) => JsonRpcInitializedNotification.fromJson({ - 'params': params, + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + if (params != null) 'params': params, if (meta != null) '_meta': meta, }), ); diff --git a/lib/src/types/initialization.dart b/lib/src/types/initialization.dart index 4cde02af..f1bb65ba 100644 --- a/lib/src/types/initialization.dart +++ b/lib/src/types/initialization.dart @@ -27,6 +27,29 @@ bool _isAbsoluteUri(String value) { return Uri.tryParse(value)?.hasScheme ?? false; } +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + +void _readOptionalParamsObject(Map json, String field) { + if (!json.containsKey('params')) { + return; + } + readJsonObject(json['params'], field); +} + String? _readOptionalPresentUriString( Map json, String key, @@ -682,6 +705,7 @@ class JsonRpcInitializeRequest extends JsonRpcRequest { }) : super(method: Method.initialize, params: initParams.toJson()); factory JsonRpcInitializeRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.initialize, 'JsonRpcInitializeRequest'); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcInitializeRequest.params', @@ -706,6 +730,11 @@ class JsonRpcServerDiscoverRequest extends JsonRpcRequest { }) : super(method: Method.serverDiscover); factory JsonRpcServerDiscoverRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.serverDiscover, + 'JsonRpcServerDiscoverRequest', + ); final params = readJsonObject( json['params'], 'JsonRpcServerDiscoverRequest.params', @@ -1312,8 +1341,20 @@ class JsonRpcInitializedNotification extends JsonRpcNotification { const JsonRpcInitializedNotification({super.meta}) : super(method: Method.notificationsInitialized); - factory JsonRpcInitializedNotification.fromJson(Map json) => - JsonRpcInitializedNotification(meta: extractRequestMeta(json)); + factory JsonRpcInitializedNotification.fromJson( + Map json, + ) { + _expectJsonRpcMethod( + json, + Method.notificationsInitialized, + 'JsonRpcInitializedNotification', + ); + _readOptionalParamsObject( + json, + 'JsonRpcInitializedNotification.params', + ); + return JsonRpcInitializedNotification(meta: extractRequestMeta(json)); + } } /// Deprecated alias for [InitializeRequest]. diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index d7ef0f67..f0fc6bc6 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1705,6 +1705,31 @@ void main() { ...initializeRequest, 'clientInfo': 'bad', }), + () => JsonRpcInitializeRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.initialize, + 'params': initializeRequest, + }), + () => JsonRpcInitializeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.ping, + 'params': initializeRequest, + }), + () => JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsInitialized, + }), + () => JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsCancelled, + }), + () => JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + 'params': null, + }), () => InitializeResult.fromJson({ ...initializeResult, 'capabilities': 'bad', diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index f8aee816..f81ba66a 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -957,6 +957,23 @@ void main() { ); } + for (final parse in [ + () => JsonRpcServerDiscoverRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 'discover-1', + 'method': Method.serverDiscover, + 'params': {'_meta': _clientMeta()}, + }), + () => JsonRpcServerDiscoverRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'discover-1', + 'method': Method.initialize, + 'params': {'_meta': _clientMeta()}, + }), + ]) { + expect(parse, throwsFormatException); + } + final parsed = JsonRpcMessage.fromJson({ 'jsonrpc': jsonRpcVersion, 'id': 'discover-1', diff --git a/test/types_edge_cases_test.dart b/test/types_edge_cases_test.dart index 7f3608b2..7b5f0a9e 100644 --- a/test/types_edge_cases_test.dart +++ b/test/types_edge_cases_test.dart @@ -303,6 +303,33 @@ void main() { ); }); + test('rejects wrong wrapper constants', () { + final params = { + 'protocolVersion': latestProtocolVersion, + 'capabilities': {}, + 'clientInfo': {'name': 'test', 'version': '1.0'}, + }; + + expect( + () => JsonRpcInitializeRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.initialize, + 'params': params, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcInitializeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.ping, + 'params': params, + }), + throwsA(isA()), + ); + }); + test('handles meta field in initialize request', () { final json = { 'jsonrpc': '2.0', @@ -321,6 +348,45 @@ void main() { }); }); + group('JsonRpcInitializedNotification Edge Cases', () { + test('rejects wrong wrapper constants and malformed params', () { + expect( + () => JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsInitialized, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsCancelled, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + 'params': null, + }), + throwsA(isA()), + ); + }); + + test('handles metadata in params', () { + final notification = JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + 'params': { + '_meta': {'sessionId': 'abc123'}, + }, + }); + + expect(notification.meta, equals({'sessionId': 'abc123'})); + }); + }); + group('JsonRpcPingRequest Edge Cases', () { test('rejects wrong wrapper constants and malformed params', () { expect( diff --git a/test/types_test.dart b/test/types_test.dart index e31711c9..adf718b6 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -57,6 +57,37 @@ void main() { 'method': Method.initialize, 'params': 'bad', }), + () => JsonRpcInitializeRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.initialize, + 'params': params, + }), + () => JsonRpcInitializeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.ping, + 'params': params, + }), + () => JsonRpcInitializeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.initialize, + 'params': null, + }), + () => JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsInitialized, + }), + () => JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsCancelled, + }), + () => JsonRpcInitializedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + 'params': null, + }), ]) { expect(parse, throwsA(isA())); } From b2dca451b4b3d61d19a5da6cf9cd7c45800835f1 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 12:35:03 -0400 Subject: [PATCH 27/68] Validate task wrapper constants --- CHANGELOG.md | 2 + lib/src/server/mcp_server.dart | 8 ++ lib/src/shared/protocol.dart | 6 ++ lib/src/types/tasks.dart | 31 ++++++++ test/mcp_2025_11_25_test.dart | 65 ++++++++++++++++ test/mcp_2026_07_28_test.dart | 108 +++++++++++++++++++++++++++ test/types/tasks_extension_test.dart | 46 ++++++++++++ test/types_test.dart | 65 ++++++++++++++++ 8 files changed, 331 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 128bc6be..fd4fe5ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,8 @@ constants with protocol parse errors. - Rejected malformed initialization and `server/discover` JSON-RPC wrapper constants with protocol parse errors. +- Rejected malformed task and task-extension JSON-RPC wrapper constants with + protocol parse errors. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index c8dccd8d..ceec896f 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -1385,7 +1385,9 @@ class McpServer { return await Future.value(_listTasksCallback!(extra)); }, (id, params, meta) => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksList, if (params != null) 'params': params, if (meta != null) '_meta': meta, }), @@ -1418,7 +1420,9 @@ class McpServer { return task; }, (id, params, meta) => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksCancel, 'params': params, if (meta != null) '_meta': meta, }), @@ -1432,7 +1436,9 @@ class McpServer { return await Future.value(_getTaskCallback!(taskId, extra)); }, (id, params, meta) => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksGet, 'params': params, if (meta != null) '_meta': meta, }), @@ -1450,7 +1456,9 @@ class McpServer { return _withRelatedTaskMeta(result, taskId); }, (id, params, meta) => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksResult, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 15cb561e..312957ed 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -579,7 +579,9 @@ abstract class Protocol { return task; }, (id, params, meta) => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksGet, 'params': params, if (meta != null) '_meta': meta, }), @@ -602,7 +604,9 @@ abstract class Protocol { } }, (id, params, meta) => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksList, if (params != null) 'params': params, if (meta != null) '_meta': meta, }), @@ -656,7 +660,9 @@ abstract class Protocol { } }, (id, params, meta) => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksCancel, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/lib/src/types/tasks.dart b/lib/src/types/tasks.dart index 8cd1471d..32082fd6 100644 --- a/lib/src/types/tasks.dart +++ b/lib/src/types/tasks.dart @@ -2,6 +2,22 @@ import '../types.dart'; import 'json_rpc.dart'; import 'validation.dart'; +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + /// The current state of a task execution. enum TaskStatus { working, @@ -213,6 +229,7 @@ class JsonRpcListTasksRequest extends JsonRpcRequest { super(method: Method.tasksList, params: params?.toJson()); factory JsonRpcListTasksRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.tasksList, 'JsonRpcListTasksRequest'); final paramsMap = _readOptionalParamsObject(json, 'JsonRpcListTasksRequest.params'); final meta = extractRequestMeta(json); @@ -293,6 +310,7 @@ class JsonRpcCancelTaskRequest extends JsonRpcRequest { }) : super(method: Method.tasksCancel, params: cancelParams.toJson()); factory JsonRpcCancelTaskRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.tasksCancel, 'JsonRpcCancelTaskRequest'); final paramsMap = _readRequiredParamsObject(json, 'JsonRpcCancelTaskRequest.params'); final meta = extractRequestMeta(json); @@ -330,6 +348,7 @@ class JsonRpcGetTaskRequest extends JsonRpcRequest { }) : super(method: Method.tasksGet, params: getParams.toJson()); factory JsonRpcGetTaskRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.tasksGet, 'JsonRpcGetTaskRequest'); final paramsMap = _readRequiredParamsObject(json, 'JsonRpcGetTaskRequest.params'); final meta = extractRequestMeta(json); @@ -368,6 +387,7 @@ class JsonRpcTaskResultRequest extends JsonRpcRequest { }) : super(method: Method.tasksResult, params: resultParams.toJson()); factory JsonRpcTaskResultRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.tasksResult, 'JsonRpcTaskResultRequest'); final paramsMap = _readRequiredParamsObject(json, 'JsonRpcTaskResultRequest.params'); final meta = extractRequestMeta(json); @@ -431,6 +451,7 @@ class JsonRpcUpdateTaskRequest extends JsonRpcRequest { }) : super(method: Method.tasksUpdate, params: updateParams.toJson()); factory JsonRpcUpdateTaskRequest.fromJson(Map json) { + _expectJsonRpcMethod(json, Method.tasksUpdate, 'JsonRpcUpdateTaskRequest'); final paramsMap = _readRequiredParamsObject(json, 'JsonRpcUpdateTaskRequest.params'); final meta = extractRequestMeta(json); @@ -854,6 +875,11 @@ class JsonRpcTaskStatusNotification extends JsonRpcNotification { ); factory JsonRpcTaskStatusNotification.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.notificationsTasksStatus, + 'JsonRpcTaskStatusNotification', + ); final paramsMap = _readRequiredParamsObject( json, 'JsonRpcTaskStatusNotification.params', @@ -878,6 +904,11 @@ class JsonRpcTaskNotification extends JsonRpcNotification { : super(method: Method.notificationsTasks, params: task.toJson()); factory JsonRpcTaskNotification.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.notificationsTasks, + 'JsonRpcTaskNotification', + ); final paramsMap = _readRequiredParamsObject(json, 'JsonRpcTaskNotification.params'); return JsonRpcTaskNotification( diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index f0fc6bc6..f716a174 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -920,6 +920,15 @@ void main() { }); test('task request and result wire fields reject malformed values', () { + final taskParams = {'taskId': 'task-1'}; + final taskStatusParams = { + 'taskId': 'task-1', + 'status': 'working', + 'ttl': null, + 'createdAt': '2025-11-25T00:00:00Z', + 'lastUpdatedAt': '2025-11-25T00:00:01Z', + }; + for (final parse in [ () => ListTasksRequest.fromJson({'cursor': 1}), () => JsonRpcListTasksRequest.fromJson({ @@ -934,6 +943,16 @@ void main() { 'method': Method.tasksList, 'params': null, }), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksList, + }), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + }), () => ListTasksResult.fromJson({ 'tasks': [1], }), @@ -948,6 +967,18 @@ void main() { 'method': Method.tasksCancel, 'params': 'bad', }), + () => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksCancel, + 'params': taskParams, + }), + () => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': taskParams, + }), () => GetTaskRequest.fromJson({'taskId': 1}), () => JsonRpcGetTaskRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -955,6 +986,18 @@ void main() { 'method': Method.tasksGet, 'params': 'bad', }), + () => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksGet, + 'params': taskParams, + }), + () => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksCancel, + 'params': taskParams, + }), () => TaskResultRequest.fromJson({'taskId': 1}), () => JsonRpcTaskResultRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -962,6 +1005,18 @@ void main() { 'method': Method.tasksResult, 'params': null, }), + () => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksResult, + 'params': taskParams, + }), + () => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': taskParams, + }), () => CreateTaskResult.fromJson({'task': 'bad'}), () => JsonRpcTaskStatusNotification.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -973,6 +1028,16 @@ void main() { 'method': Method.notificationsTasksStatus, 'params': null, }), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsTasksStatus, + 'params': taskStatusParams, + }), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasks, + 'params': taskStatusParams, + }), ]) { expect(parse, throwsA(isA())); } diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index f81ba66a..bfaf513f 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -1660,6 +1660,26 @@ void main() { }); test('rejects malformed task wire shapes', () { + final taskParams = {'taskId': 'task-1'}; + final updateTaskParams = { + 'taskId': 'task-1', + 'inputResponses': {}, + }; + final taskStatusParams = { + 'taskId': 'task-1', + 'status': 'working', + 'ttl': null, + 'createdAt': '2026-07-28T00:00:00Z', + 'lastUpdatedAt': '2026-07-28T00:00:01Z', + }; + final taskExtensionParams = { + 'taskId': 'task-1', + 'status': 'working', + 'createdAt': '2026-07-28T00:00:00Z', + 'lastUpdatedAt': '2026-07-28T00:00:01Z', + 'ttlMs': null, + }; + for (final parse in [ () => ListTasksRequest.fromJson({'cursor': 1}), () => JsonRpcListTasksRequest.fromJson({ @@ -1668,6 +1688,16 @@ void main() { 'method': Method.tasksList, 'params': null, }), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksList, + }), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + }), () => ListTasksResult.fromJson({ 'tasks': [1], }), @@ -1678,6 +1708,18 @@ void main() { 'method': Method.tasksCancel, 'params': 'bad', }), + () => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksCancel, + 'params': taskParams, + }), + () => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': taskParams, + }), () => GetTaskRequest.fromJson({'taskId': 1}), () => JsonRpcGetTaskRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -1685,6 +1727,18 @@ void main() { 'method': Method.tasksGet, 'params': 'bad', }), + () => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksGet, + 'params': taskParams, + }), + () => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksCancel, + 'params': taskParams, + }), () => TaskResultRequest.fromJson({'taskId': 1}), () => JsonRpcTaskResultRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -1692,6 +1746,18 @@ void main() { 'method': Method.tasksResult, 'params': null, }), + () => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksResult, + 'params': taskParams, + }), + () => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': taskParams, + }), () => CreateTaskResult.fromJson({'task': 'bad'}), () => JsonRpcUpdateTaskRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -1699,16 +1765,48 @@ void main() { 'method': Method.tasksUpdate, 'params': 'bad', }), + () => JsonRpcUpdateTaskRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksUpdate, + 'params': updateTaskParams, + }), + () => JsonRpcUpdateTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': updateTaskParams, + }), () => JsonRpcTaskStatusNotification.fromJson({ 'jsonrpc': jsonRpcVersion, 'method': Method.notificationsTasksStatus, 'params': 'bad', }), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsTasksStatus, + 'params': taskStatusParams, + }), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasks, + 'params': taskStatusParams, + }), () => JsonRpcTaskNotification.fromJson({ 'jsonrpc': jsonRpcVersion, 'method': Method.notificationsTasks, 'params': null, }), + () => JsonRpcTaskNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsTasks, + 'params': taskExtensionParams, + }), + () => JsonRpcTaskNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasksStatus, + 'params': taskExtensionParams, + }), ]) { expect(parse, throwsFormatException); } @@ -2166,7 +2264,9 @@ void main() { ), ), (id, params, meta) => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksGet, 'params': params, if (meta != null) '_meta': meta, }), @@ -2175,7 +2275,9 @@ void main() { Method.tasksCancel, (request, extra) async => const TaskExtensionAcknowledgementResult(), (id, params, meta) => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksCancel, 'params': params, if (meta != null) '_meta': meta, }), @@ -2184,7 +2286,9 @@ void main() { Method.tasksUpdate, (request, extra) async => const EmptyResult(), (id, params, meta) => JsonRpcUpdateTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksUpdate, 'params': params, if (meta != null) '_meta': meta, }), @@ -2292,7 +2396,9 @@ void main() { lastUpdatedAt: '2026-07-28T00:01:00Z', ), (id, params, meta) => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksGet, 'params': params, if (meta != null) '_meta': meta, }), @@ -2895,7 +3001,9 @@ void main() { Method.tasksUpdate, (request, extra) async => const TaskExtensionAcknowledgementResult(), (id, params, meta) => JsonRpcUpdateTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.tasksUpdate, 'params': params, if (meta != null) '_meta': meta, }), diff --git a/test/types/tasks_extension_test.dart b/test/types/tasks_extension_test.dart index 801e76e3..cc5cc949 100644 --- a/test/types/tasks_extension_test.dart +++ b/test/types/tasks_extension_test.dart @@ -196,6 +196,18 @@ void main() { }); test('rejects malformed task extension payloads', () { + final updateParams = { + 'taskId': 'task-1', + 'inputResponses': {}, + }; + final taskParams = { + 'taskId': 'task-1', + 'status': 'working', + 'createdAt': '2026-07-28T00:00:00Z', + 'lastUpdatedAt': '2026-07-28T00:00:01Z', + 'ttlMs': null, + }; + expect( () => CreateTaskExtensionResult.fromJson( const { @@ -239,6 +251,24 @@ void main() { ), throwsFormatException, ); + expect( + () => JsonRpcUpdateTaskRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksUpdate, + 'params': updateParams, + }), + throwsFormatException, + ); + expect( + () => JsonRpcUpdateTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': updateParams, + }), + throwsFormatException, + ); expect( () => JsonRpcTaskNotification.fromJson( const { @@ -259,6 +289,22 @@ void main() { ), throwsFormatException, ); + expect( + () => JsonRpcTaskNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsTasks, + 'params': taskParams, + }), + throwsFormatException, + ); + expect( + () => JsonRpcTaskNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasksStatus, + 'params': taskParams, + }), + throwsFormatException, + ); }); }); } diff --git a/test/types_test.dart b/test/types_test.dart index adf718b6..24599473 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -467,6 +467,15 @@ void main() { group('Task wire parsing Tests', () { test('rejects malformed task request and result fields', () { + final taskParams = {'taskId': 'task-1'}; + final taskStatusParams = { + 'taskId': 'task-1', + 'status': 'working', + 'ttl': null, + 'createdAt': '2025-11-25T00:00:00Z', + 'lastUpdatedAt': '2025-11-25T00:00:01Z', + }; + for (final parse in [ () => ListTasksRequest.fromJson({'cursor': 1}), () => JsonRpcListTasksRequest.fromJson({ @@ -481,6 +490,16 @@ void main() { 'method': Method.tasksList, 'params': null, }), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksList, + }), + () => JsonRpcListTasksRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + }), () => ListTasksResult.fromJson({ 'tasks': [1], }), @@ -495,6 +514,18 @@ void main() { 'method': Method.tasksCancel, 'params': 'bad', }), + () => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksCancel, + 'params': taskParams, + }), + () => JsonRpcCancelTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': taskParams, + }), () => GetTaskRequest.fromJson({'taskId': 1}), () => JsonRpcGetTaskRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -502,6 +533,18 @@ void main() { 'method': Method.tasksGet, 'params': 'bad', }), + () => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksGet, + 'params': taskParams, + }), + () => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksCancel, + 'params': taskParams, + }), () => TaskResultRequest.fromJson({'taskId': 1}), () => JsonRpcTaskResultRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -509,6 +552,18 @@ void main() { 'method': Method.tasksResult, 'params': null, }), + () => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.tasksResult, + 'params': taskParams, + }), + () => JsonRpcTaskResultRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.tasksGet, + 'params': taskParams, + }), () => CreateTaskResult.fromJson({'task': 'bad'}), () => JsonRpcTaskStatusNotification.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -520,6 +575,16 @@ void main() { 'method': Method.notificationsTasksStatus, 'params': null, }), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsTasksStatus, + 'params': taskStatusParams, + }), + () => JsonRpcTaskStatusNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsTasks, + 'params': taskStatusParams, + }), ]) { expect(parse, throwsA(isA())); } From 7a7b5270d07cd3790fcd401be48b4f12ed60a7ab Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 12:49:12 -0400 Subject: [PATCH 28/68] Validate sampling and elicitation wrapper constants --- CHANGELOG.md | 2 + lib/src/types/elicitation.dart | 26 ++++++++++ lib/src/types/sampling.dart | 21 ++++++++ test/client/client_test.dart | 4 ++ test/elicitation_test.dart | 2 + test/mcp_2025_11_25_test.dart | 59 ++++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 90 ++++++++++++++++++++++++++++++++++ test/types/sampling_test.dart | 50 +++++++++++++++++++ test/types_test.dart | 33 +++++++++++++ 9 files changed, 287 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd4fe5ea..6103b7ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -135,6 +135,8 @@ constants with protocol parse errors. - Rejected malformed task and task-extension JSON-RPC wrapper constants with protocol parse errors. +- Rejected malformed sampling and elicitation JSON-RPC wrapper constants while + preserving embedded MRTR input request parsing. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/lib/src/types/elicitation.dart b/lib/src/types/elicitation.dart index e58cbf83..6499b826 100644 --- a/lib/src/types/elicitation.dart +++ b/lib/src/types/elicitation.dart @@ -3,6 +3,22 @@ import 'json_rpc.dart'; import 'tasks.dart'; import 'validation.dart'; +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + /// Legacy alias for [JsonSchema] used in elicitation requests. typedef ElicitationInputSchema = JsonSchema; @@ -239,6 +255,11 @@ class JsonRpcElicitRequest extends JsonRpcRequest { ); factory JsonRpcElicitRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.elicitationCreate, + 'JsonRpcElicitRequest', + ); final paramsMap = _readRequiredParamsObject(json, 'JsonRpcElicitRequest.params'); final meta = extractRequestMeta(json); @@ -401,6 +422,11 @@ class JsonRpcElicitationCompleteNotification extends JsonRpcNotification { factory JsonRpcElicitationCompleteNotification.fromJson( Map json, ) { + _expectJsonRpcMethod( + json, + Method.notificationsElicitationComplete, + 'JsonRpcElicitationCompleteNotification', + ); final paramsMap = _readRequiredParamsObject( json, 'JsonRpcElicitationCompleteNotification.params', diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index d3477cfa..6911c56f 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -25,6 +25,22 @@ Map _asJsonObject( return map; } +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); + if (version != jsonRpcVersion) { + throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); + } + + final method = readRequiredString(json['method'], '$context.method'); + if (method != expected) { + throw FormatException('$context.method must be "$expected"'); + } +} + String _base64ForJson(String value, String field) { validateBase64String(value, field); return value; @@ -820,6 +836,11 @@ class JsonRpcCreateMessageRequest extends JsonRpcRequest { ); factory JsonRpcCreateMessageRequest.fromJson(Map json) { + _expectJsonRpcMethod( + json, + Method.samplingCreateMessage, + 'JsonRpcCreateMessageRequest', + ); final paramsMap = readOptionalJsonObject( json['params'], 'JsonRpcCreateMessageRequest.params', diff --git a/test/client/client_test.dart b/test/client/client_test.dart index 88ccb1b9..3e6408c8 100644 --- a/test/client/client_test.dart +++ b/test/client/client_test.dart @@ -1122,7 +1122,9 @@ void _addCriticalPathTests() { content: SamplingTextContent(text: 'response'), ), (id, params, meta) => JsonRpcCreateMessageRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.samplingCreateMessage, 'params': params ?? {}, if (meta != null) '_meta': meta, }), @@ -1311,7 +1313,9 @@ void _addCriticalPathTests() { content: {}, ), (id, params, meta) => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': id, + 'method': Method.elicitationCreate, 'params': params ?? {}, if (meta != null) '_meta': meta, }), diff --git a/test/elicitation_test.dart b/test/elicitation_test.dart index 75254bd8..fa1c5cb0 100644 --- a/test/elicitation_test.dart +++ b/test/elicitation_test.dart @@ -1006,7 +1006,9 @@ void main() { ); final rpc = JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, 'id': 1, + 'method': Method.elicitationCreate, 'params': { ...params, '_meta': { diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index f716a174..7e35bdaf 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -566,6 +566,33 @@ void main() { }), throwsA(isA()), ); + final createMessageParams = { + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 100, + }; + expect( + () => JsonRpcCreateMessageRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.samplingCreateMessage, + 'params': createMessageParams, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcCreateMessageRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': createMessageParams, + }), + throwsA(isA()), + ); }); test('Content JSON object fields reject non-JSON Dart maps', () { @@ -1714,6 +1741,16 @@ void main() { }), throwsA(isA()), ); + final elicitParams = { + 'message': 'Choose option', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'option': {'type': 'string'}, + }, + }, + }; + final completeParams = {'elicitationId': 'elicitation-1'}; for (final parse in [ () => JsonRpcElicitRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -1721,6 +1758,18 @@ void main() { 'method': Method.elicitationCreate, 'params': 'bad', }), + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.elicitationCreate, + 'params': elicitParams, + }), + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.samplingCreateMessage, + 'params': elicitParams, + }), () => ElicitRequest.fromJson({ 'message': 'Bad schema', 'requestedSchema': 'bad', @@ -1737,6 +1786,16 @@ void main() { 'method': Method.notificationsElicitationComplete, 'params': null, }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsElicitationComplete, + 'params': completeParams, + }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + 'params': completeParams, + }), () => URLElicitationRequiredErrorData.fromJson({ 'elicitations': [1], }), diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index bfaf513f..bc56d30a 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -432,6 +432,17 @@ void main() { }); test('rejects malformed elicitation wire shapes', () { + final elicitParams = { + 'message': 'Choose option', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'option': {'type': 'string'}, + }, + }, + }; + final completeParams = {'elicitationId': 'elicitation-1'}; + for (final parse in [ () => JsonRpcElicitRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -445,6 +456,18 @@ void main() { 'method': Method.elicitationCreate, 'params': null, }), + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.elicitationCreate, + 'params': elicitParams, + }), + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.samplingCreateMessage, + 'params': elicitParams, + }), () => ElicitRequest.fromJson({ 'message': 'Bad properties', 'requestedSchema': { @@ -470,6 +493,16 @@ void main() { 'method': Method.notificationsElicitationComplete, 'params': 'bad', }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsElicitationComplete, + 'params': completeParams, + }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + 'params': completeParams, + }), () => URLElicitationRequiredErrorData.fromJson({ 'elicitations': [1], }), @@ -478,6 +511,36 @@ void main() { } }); + test('embedded MRTR input requests keep method and params shape', () { + final elicitInput = InputRequest.fromJson({ + 'method': Method.elicitationCreate, + 'params': { + 'message': 'Choose option', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'option': {'type': 'string'}, + }, + }, + }, + }); + final samplingInput = InputRequest.fromJson({ + 'method': Method.samplingCreateMessage, + 'params': { + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 16, + }, + }); + + expect(elicitInput.elicitParams.message, 'Choose option'); + expect(samplingInput.createMessageParams.maxTokens, 16); + }); + test('rejects non-finite JSON numbers', () { expect( () => ProgressNotification.fromJson({ @@ -554,6 +617,33 @@ void main() { }), throwsA(isA()), ); + final createMessageParams = { + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Hello'}, + }, + ], + 'maxTokens': 16, + }; + expect( + () => JsonRpcCreateMessageRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.samplingCreateMessage, + 'params': createMessageParams, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcCreateMessageRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': createMessageParams, + }), + throwsA(isA()), + ); expect( () => CreateMessageResult.fromJson({ 'role': 'assistant', diff --git a/test/types/sampling_test.dart b/test/types/sampling_test.dart index c84042d4..477edae9 100644 --- a/test/types/sampling_test.dart +++ b/test/types/sampling_test.dart @@ -1,4 +1,5 @@ import 'package:mcp_dart/src/types/content.dart'; +import 'package:mcp_dart/src/types/json_rpc.dart'; import 'package:mcp_dart/src/types/sampling.dart'; import 'package:test/test.dart'; @@ -1162,6 +1163,55 @@ void main() { throwsA(isA()), ); }); + + test('fromJson rejects wrong wrapper constants', () { + final params = { + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Question'}, + }, + ], + 'maxTokens': 100, + }; + + expect( + () => JsonRpcCreateMessageRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.samplingCreateMessage, + 'params': params, + }), + throwsA(isA()), + ); + expect( + () => JsonRpcCreateMessageRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': params, + }), + throwsA(isA()), + ); + }); + + test('embedded input requests do not require JSON-RPC wrapper fields', () { + final request = InputRequest.fromJson({ + 'method': Method.samplingCreateMessage, + 'params': { + 'messages': [ + { + 'role': 'user', + 'content': {'type': 'text', 'text': 'Question'}, + }, + ], + 'maxTokens': 100, + }, + }); + + expect(request.method, Method.samplingCreateMessage); + expect(request.createMessageParams.maxTokens, 100); + }); }); group('IncludeContext', () { diff --git a/test/types_test.dart b/test/types_test.dart index 24599473..786533c9 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -2231,6 +2231,17 @@ void main() { }); test('elicitation parsers reject malformed wire fields', () { + final elicitParams = { + 'message': 'Choose option', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'option': {'type': 'string'}, + }, + }, + }; + final completeParams = {'elicitationId': 'elicitation-1'}; + for (final parse in [ () => JsonRpcElicitRequest.fromJson({ 'jsonrpc': jsonRpcVersion, @@ -2244,6 +2255,18 @@ void main() { 'method': Method.elicitationCreate, 'params': null, }), + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.elicitationCreate, + 'params': elicitParams, + }), + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.samplingCreateMessage, + 'params': elicitParams, + }), () => ElicitRequest.fromJson({ 'message': 'Bad schema', 'requestedSchema': 'bad', @@ -2278,6 +2301,16 @@ void main() { 'method': Method.notificationsElicitationComplete, 'params': null, }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsElicitationComplete, + 'params': completeParams, + }), + () => JsonRpcElicitationCompleteNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + 'params': completeParams, + }), () => URLElicitationRequiredErrorData.fromJson({ 'elicitations': [1], }), From 59a6a0051b05de33ec2427f8c2a0a967b58790b4 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 13:01:31 -0400 Subject: [PATCH 29/68] Preserve empty result metadata --- CHANGELOG.md | 2 ++ lib/src/client/client.dart | 10 +++++----- lib/src/client/task_client.dart | 2 +- lib/src/server/server.dart | 2 +- lib/src/types/misc.dart | 4 ++++ test/client/client_test.dart | 7 +++++-- test/server/server_test.dart | 8 +++++++- test/types_edge_cases_test.dart | 18 ++++++++++++++++++ 8 files changed, 43 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6103b7ff..da2d08ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,8 @@ protocol parse errors. - Rejected malformed sampling and elicitation JSON-RPC wrapper constants while preserving embedded MRTR input request parsing. +- Preserved `Result._meta` while parsing empty results for high-level ping, + logging, and subscription acknowledgments. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 38249a4c..37509c4e 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -879,7 +879,7 @@ class McpClient extends Protocol { Future ping([RequestOptions? options]) { return request( const JsonRpcPingRequest(id: -1), - (json) => const EmptyResult(), + EmptyResult.fromJson, options, ); } @@ -904,7 +904,7 @@ class McpClient extends Protocol { ]) { final params = SetLevelRequest(level: level); final req = JsonRpcSetLevelRequest(id: -1, setParams: params); - return request(req, (json) => const EmptyResult(), options); + return request(req, EmptyResult.fromJson, options); } /// Sends a `prompts/get` request to retrieve a specific prompt/template. @@ -978,7 +978,7 @@ class McpClient extends Protocol { RequestOptions? options, ]) { final req = JsonRpcSubscribeRequest(id: -1, subParams: params); - return request(req, (json) => const EmptyResult(), options); + return request(req, EmptyResult.fromJson, options); } /// Sends a `resources/unsubscribe` request to cancel a resource subscription. @@ -987,7 +987,7 @@ class McpClient extends Protocol { RequestOptions? options, ]) { final req = JsonRpcUnsubscribeRequest(id: -1, unsubParams: params); - return request(req, (json) => const EmptyResult(), options); + return request(req, EmptyResult.fromJson, options); } /// Opens a `subscriptions/listen` stream and demultiplexes notifications. @@ -1014,7 +1014,7 @@ class McpClient extends Protocol { final requestDone = super.requestWithReservedId( requestId, requestData, - (json) => const EmptyResult(), + EmptyResult.fromJson, RequestOptions( signal: abortController.signal, timeoutEnabled: false, diff --git a/lib/src/client/task_client.dart b/lib/src/client/task_client.dart index 785cffee..dee32b09 100644 --- a/lib/src/client/task_client.dart +++ b/lib/src/client/task_client.dart @@ -232,7 +232,7 @@ class TaskClient { ); await client.request( req, - (json) => const EmptyResult(), + EmptyResult.fromJson, ); } diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 0554d273..726e2c01 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -1243,7 +1243,7 @@ class Server extends Protocol { Future ping([RequestOptions? options]) { return request( const JsonRpcPingRequest(id: -1), - (json) => const EmptyResult(), + EmptyResult.fromJson, options, ); } diff --git a/lib/src/types/misc.dart b/lib/src/types/misc.dart index c17f22a9..53b22cbe 100644 --- a/lib/src/types/misc.dart +++ b/lib/src/types/misc.dart @@ -31,6 +31,10 @@ class EmptyResult implements BaseResultData { const EmptyResult({this.meta}); + factory EmptyResult.fromJson(Map json) => EmptyResult( + meta: readOptionalJsonObject(json['_meta'], 'EmptyResult._meta'), + ); + @override Map toJson() => { if (meta != null) '_meta': readJsonObject(meta, 'EmptyResult._meta'), diff --git a/test/client/client_test.dart b/test/client/client_test.dart index 3e6408c8..abbce2be 100644 --- a/test/client/client_test.dart +++ b/test/client/client_test.dart @@ -268,11 +268,12 @@ void main() { capabilities: mockServerCapabilities, serverInfo: const Implementation(name: 'TestServer', version: '2.0.0'), ); + transport.emptyResponseMeta = {'traceId': 'ping-trace'}; await client.connect(transport); transport.clearSentMessages(); - await client.ping(); + final result = await client.ping(); // Verify a ping request was sent expect(transport.sentMessages.length, equals(1)); @@ -280,6 +281,7 @@ void main() { (transport.sentMessages.first as JsonRpcRequest).method, equals('ping'), ); + expect(result.meta, {'traceId': 'ping-trace'}); }); test('complete sends completion request', () async { @@ -570,6 +572,7 @@ void main() { class MockTransport extends Transport { final List sentMessages = []; InitializeResult? mockInitializeResponse; + Map? emptyResponseMeta; bool shouldThrowOnStart = false; void clearSentMessages() { @@ -741,7 +744,7 @@ class MockTransport extends Transport { onmessage!( JsonRpcResponse( id: message.id, - result: const EmptyResult().toJson(), + result: EmptyResult(meta: emptyResponseMeta).toJson(), ), ); } diff --git a/test/server/server_test.dart b/test/server/server_test.dart index 7c1585c4..4cebec5f 100644 --- a/test/server/server_test.dart +++ b/test/server/server_test.dart @@ -13,6 +13,7 @@ class MockTransport extends Transport { bool isStarted = false; bool isClosed = false; ClientCapabilities? clientCapabilities; + Map? emptyResponseMeta; @override String? get sessionId => null; @@ -35,7 +36,10 @@ class MockTransport extends Transport { if (message is JsonRpcRequest) { final request = message; if (request.method == 'ping') { - final response = JsonRpcResponse(id: request.id, result: {}); + final response = JsonRpcResponse( + id: request.id, + result: EmptyResult(meta: emptyResponseMeta).toJson(), + ); if (onmessage != null) { onmessage!(response); } @@ -386,6 +390,7 @@ void main() { // Initialize client capabilities await _initializeClient(transport, server); + transport.emptyResponseMeta = {'traceId': 'server-ping'}; // Send ping request final result = await server.ping(); @@ -399,6 +404,7 @@ void main() { // Verify response was received expect(result, isA()); + expect(result.meta, {'traceId': 'server-ping'}); }); test('Can send createMessage request when client has sampling capability', diff --git a/test/types_edge_cases_test.dart b/test/types_edge_cases_test.dart index 7b5f0a9e..13e7aaf4 100644 --- a/test/types_edge_cases_test.dart +++ b/test/types_edge_cases_test.dart @@ -1064,6 +1064,24 @@ void main() { }), ); }); + + test('parses meta when present', () { + final result = EmptyResult.fromJson({ + '_meta': {'key': 'value'}, + }); + + expect(result.meta, equals({'key': 'value'})); + expect(result.toJson(), { + '_meta': {'key': 'value'}, + }); + }); + + test('rejects malformed meta', () { + expect( + () => EmptyResult.fromJson({'_meta': 'bad'}), + throwsA(isA()), + ); + }); }); group('ClientCapabilitiesRoots Edge Cases', () { From e236fe4f73e28aeced5ed393901fb53723c4a670 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 13:11:57 -0400 Subject: [PATCH 30/68] Preserve filtered tool cache hints --- CHANGELOG.md | 2 ++ lib/src/client/client.dart | 2 ++ test/mcp_2026_07_28_test.dart | 39 +++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da2d08ec..16e989cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,6 +139,8 @@ preserving embedded MRTR input request parsing. - Preserved `Result._meta` while parsing empty results for high-level ping, logging, and subscription acknowledgments. +- Preserved MCP 2026 `tools/list` cache hints when client-side tool metadata + filtering removes invalid tool definitions. - Rejected missing and mismatched completion reference type discriminators with protocol parse errors. - Rejected malformed completion JSON-RPC wrapper constants with protocol parse diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 37509c4e..68c783b4 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -1086,6 +1086,8 @@ class McpClient extends Protocol { return ListToolsResult( tools: tools, nextCursor: result.nextCursor, + ttlMs: result.ttlMs, + cacheScope: result.cacheScope, meta: result.meta, ); } diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index bc56d30a..e4c291fd 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -4389,6 +4389,45 @@ void main() { expect(result.tools, isEmpty); }); + test('client preserves cache hints when filtering invalid tools', () async { + final transport = DiscoveringClientTransport( + toolsListResult: const { + 'resultType': resultTypeComplete, + 'tools': [ + { + 'name': 'valid', + 'inputSchema': {'type': 'object'}, + }, + { + 'name': 'invalid_header', + 'inputSchema': { + 'type': 'object', + 'properties': { + 'ratio': { + 'type': 'number', + 'x-mcp-header': 'Ratio', + }, + }, + }, + }, + ], + 'ttlMs': 300000, + 'cacheScope': CacheScope.public, + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + + await client.connect(transport); + + final result = await client.listTools(); + expect(result.tools.map((tool) => tool.name), ['valid']); + expect(result.ttlMs, 300000); + expect(result.cacheScope, CacheScope.public); + }); + test('stable client sessions do not validate future resultType values', () async { final transport = LegacyFallbackTransport( From c113ff28bba776651a712f04b35e953d02a14340 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 13:26:14 -0400 Subject: [PATCH 31/68] Preserve unknown capability entries --- lib/src/types/initialization.dart | 93 +++++++++++++++++++++++++++++++ test/mcp_2026_07_28_test.dart | 12 ++++ test/types_test.dart | 68 ++++++++++++++++++++++ 3 files changed, 173 insertions(+) diff --git a/lib/src/types/initialization.dart b/lib/src/types/initialization.dart index f1bb65ba..9fb1e156 100644 --- a/lib/src/types/initialization.dart +++ b/lib/src/types/initialization.dart @@ -150,6 +150,50 @@ Map>? _serializeExtensionMap( ); } +Map? _readAdditionalCapabilities( + Map json, + Set knownKeys, + String field, +) { + final additional = {}; + for (final entry in json.entries) { + if (knownKeys.contains(entry.key)) { + continue; + } + additional[entry.key] = readJsonValue( + entry.value, + '$field.${entry.key}', + ); + } + return additional.isEmpty ? null : additional; +} + +Map? _serializeAdditionalCapabilities( + Map? value, + Set knownKeys, + String field, +) { + if (value == null) { + return null; + } + + final additional = {}; + for (final entry in value.entries) { + if (knownKeys.contains(entry.key)) { + throw ArgumentError.value( + entry.key, + '$field.${entry.key}', + 'must not duplicate a known capability key', + ); + } + additional[entry.key] = readJsonValue( + entry.value, + '$field.${entry.key}', + ); + } + return additional; +} + bool? _capabilityDeclared(Object? value, String field) { if (value == null) { return null; @@ -181,6 +225,27 @@ Map> withMcpTasksExtension([ }; } +const _clientCapabilityKeys = { + 'experimental', + 'sampling', + 'roots', + 'elicitation', + 'tasks', + 'extensions', +}; + +const _serverCapabilityKeys = { + 'experimental', + 'logging', + 'prompts', + 'resources', + 'tools', + 'completions', + 'tasks', + 'elicitation', + 'extensions', +}; + /// Describes an MCP implementation (client or server). class Implementation { /// The name of the implementation. @@ -586,6 +651,9 @@ class ClientCapabilities { /// values are extension-specific settings. final Map>? extensions; + /// Additional client capabilities not yet modeled by this SDK. + final Map? additionalCapabilities; + const ClientCapabilities({ this.experimental, this.sampling, @@ -593,6 +661,7 @@ class ClientCapabilities { this.elicitation, this.tasks, this.extensions, + this.additionalCapabilities, }); factory ClientCapabilities.fromJson(Map json) { @@ -627,6 +696,11 @@ class ClientCapabilities { tasks: tasksMap == null ? null : ClientCapabilitiesTasks.fromJson(tasksMap), extensions: extensionsMap, + additionalCapabilities: _readAdditionalCapabilities( + json, + _clientCapabilityKeys, + 'ClientCapabilities', + ), ); } @@ -645,6 +719,11 @@ class ClientCapabilities { extensions, 'ClientCapabilities.extensions', ), + ...?_serializeAdditionalCapabilities( + additionalCapabilities, + _clientCapabilityKeys, + 'ClientCapabilities.additionalCapabilities', + ), }; /// Whether the MCP Tasks extension is declared. @@ -1104,6 +1183,9 @@ class ServerCapabilities { /// values are extension-specific settings. final Map>? extensions; + /// Additional server capabilities not yet modeled by this SDK. + final Map? additionalCapabilities; + const ServerCapabilities({ this.experimental, this.logging, @@ -1114,6 +1196,7 @@ class ServerCapabilities { this.tasks, this.elicitation, this.extensions, + this.additionalCapabilities, }); factory ServerCapabilities.fromJson(Map json) { @@ -1158,6 +1241,11 @@ class ServerCapabilities { ? null : ServerCapabilitiesElicitation.fromJson(elicitationMap), extensions: extensionsMap, + additionalCapabilities: _readAdditionalCapabilities( + json, + _serverCapabilityKeys, + 'ServerCapabilities', + ), ); } @@ -1179,6 +1267,11 @@ class ServerCapabilities { extensions, 'ServerCapabilities.extensions', ), + ...?_serializeAdditionalCapabilities( + additionalCapabilities, + _serverCapabilityKeys, + 'ServerCapabilities.additionalCapabilities', + ), }; /// Whether the MCP Tasks extension is declared. diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index e4c291fd..caa910a6 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -3315,6 +3315,18 @@ void main() { ), isNull, ); + expect( + validateToolRequest( + _clientMeta( + clientCapabilities: const ClientCapabilities( + additionalCapabilities: { + 'com.example/clientFeature': {'enabled': true}, + }, + ), + ), + ), + isNull, + ); }); test('server rejects core RPCs removed from stateless MCP', () async { diff --git a/test/types_test.dart b/test/types_test.dart index 786533c9..1051f831 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -822,6 +822,74 @@ void main() { expect(deserialized.roots?.listChanged, equals(true)); }); + test('capabilities preserve unknown top-level capability entries', () { + final clientCapabilities = ClientCapabilities.fromJson( + const { + 'roots': {}, + 'com.example/clientFeature': { + 'enabled': true, + 'modes': ['fast', 'safe'], + }, + }, + ); + expect(clientCapabilities.roots, isNotNull); + expect(clientCapabilities.additionalCapabilities, { + 'com.example/clientFeature': { + 'enabled': true, + 'modes': ['fast', 'safe'], + }, + }); + expect(clientCapabilities.toJson()['com.example/clientFeature'], { + 'enabled': true, + 'modes': ['fast', 'safe'], + }); + + final serverCapabilities = ServerCapabilities.fromJson( + const { + 'tools': {}, + 'com.example/serverFeature': { + 'limits': {'requests': 10}, + }, + }, + ); + expect(serverCapabilities.tools, isNotNull); + expect(serverCapabilities.additionalCapabilities, { + 'com.example/serverFeature': { + 'limits': {'requests': 10}, + }, + }); + expect(serverCapabilities.toJson()['com.example/serverFeature'], { + 'limits': {'requests': 10}, + }); + }); + + test('additional capability values must be JSON values', () { + expect( + () => ClientCapabilities.fromJson( + {'com.example/clientFeature': Object()}, + ), + throwsA(isA()), + ); + expect( + () => ServerCapabilities.fromJson( + {'com.example/serverFeature': Object()}, + ), + throwsA(isA()), + ); + expect( + () => const ClientCapabilities( + additionalCapabilities: {'roots': {}}, + ).toJson(), + throwsA(isA()), + ); + expect( + () => const ServerCapabilities( + additionalCapabilities: {'tools': {}}, + ).toJson(), + throwsA(isA()), + ); + }); + test('experimental capability values must be objects', () { expect( () => ClientCapabilities.fromJson( From afabf929918df9435342b59d090ec5a3768ced12 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 13:44:34 -0400 Subject: [PATCH 32/68] Handle client input_required retries --- lib/src/client/client.dart | 128 +++++++++++++++++++++++--- lib/src/shared/protocol.dart | 70 +++++++++++++++ test/mcp_2026_07_28_test.dart | 165 ++++++++++++++++++++++++++++++++++ 3 files changed, 351 insertions(+), 12 deletions(-) diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 68c783b4..77fd9d85 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -122,6 +122,8 @@ const Set _statelessRemovedNotificationMethods = { Method.notificationsTasksStatus, }; +const int _maxInputRequiredRetries = 16; + /// An MCP client implementation built on top of a pluggable [Transport]. /// /// Handles the initialization handshake with the server upon connection @@ -580,6 +582,85 @@ class McpClient extends Protocol { } } + BaseResultData _parseExpectedOrInputRequired( + Map json, + T Function(Map) resultFactory, + ) { + if (json['resultType'] == resultTypeInputRequired) { + return InputRequiredResult.fromJson(json); + } + return resultFactory(json); + } + + Future _resolveInputRequests( + InputRequests? inputRequests, + AbortSignal? signal, + ) async { + if (inputRequests == null) { + return null; + } + + final inputResponses = {}; + for (final entry in inputRequests.entries) { + signal?.throwIfAborted(); + final result = await handleEmbeddedInputRequest( + entry.key, + entry.value, + signal: signal, + ); + inputResponses[entry.key] = InputResponse.fromResult(result); + } + return inputResponses; + } + + Future _requestResolvingInputRequired( + String method, + JsonRpcRequest Function( + InputResponses? inputResponses, + String? requestState, + bool isRetry, + ) buildRequest, + T Function(Map) resultFactory, [ + RequestOptions? options, + ]) async { + InputResponses? inputResponses; + String? requestState; + + for (var attempt = 0; attempt <= _maxInputRequiredRetries; attempt++) { + final result = await request( + buildRequest(inputResponses, requestState, attempt > 0), + (json) => _parseExpectedOrInputRequired(json, resultFactory), + options, + ); + + if (result is T) { + return result; + } + + if (result is! InputRequiredResult) { + throw McpError( + ErrorCode.internalError.value, + 'Unexpected result type ${result.runtimeType} for $method.', + ); + } + + if (attempt == _maxInputRequiredRetries) { + throw McpError( + ErrorCode.invalidRequest.value, + 'Exceeded $_maxInputRequiredRetries input_required retries for $method.', + ); + } + + inputResponses = await _resolveInputRequests( + result.inputRequests, + options?.signal, + ); + requestState = result.requestState; + } + + throw StateError('Unreachable input_required retry state for $method.'); + } + @override Future notification( JsonRpcNotification notificationData, { @@ -912,10 +993,18 @@ class McpClient extends Protocol { GetPromptRequest params, [ RequestOptions? options, ]) { - final req = JsonRpcGetPromptRequest(id: -1, getParams: params); - return request( - req, - (json) => GetPromptResult.fromJson(json), + return _requestResolvingInputRequired( + Method.promptsGet, + (inputResponses, requestState, isRetry) => JsonRpcGetPromptRequest( + id: -1, + getParams: GetPromptRequest( + name: params.name, + arguments: params.arguments, + inputResponses: isRetry ? inputResponses : params.inputResponses, + requestState: isRetry ? requestState : params.requestState, + ), + ), + GetPromptResult.fromJson, options, ); } @@ -964,10 +1053,17 @@ class McpClient extends Protocol { ReadResourceRequest params, [ RequestOptions? options, ]) { - final req = JsonRpcReadResourceRequest(id: -1, readParams: params); - return request( - req, - (json) => ReadResourceResult.fromJson(json), + return _requestResolvingInputRequired( + Method.resourcesRead, + (inputResponses, requestState, isRetry) => JsonRpcReadResourceRequest( + id: -1, + readParams: ReadResourceRequest( + uri: params.uri, + inputResponses: isRetry ? inputResponses : params.inputResponses, + requestState: isRetry ? requestState : params.requestState, + ), + ), + ReadResourceResult.fromJson, options, ); } @@ -1043,10 +1139,18 @@ class McpClient extends Protocol { ); } - final req = JsonRpcCallToolRequest(id: -1, params: params.toJson()); - final result = await request( - req, - (json) => CallToolResult.fromJson(json), + final result = await _requestResolvingInputRequired( + Method.toolsCall, + (inputResponses, requestState, isRetry) => JsonRpcCallToolRequest( + id: -1, + params: CallToolRequest( + name: params.name, + arguments: params.arguments, + inputResponses: isRetry ? inputResponses : params.inputResponses, + requestState: isRetry ? requestState : params.requestState, + ).toJson(), + ), + CallToolResult.fromJson, options, ); diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 312957ed..4dd1aaec 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -1092,6 +1092,76 @@ abstract class Protocol { ) => result.toJson(); + /// Handles an MRTR input request embedded in an `InputRequiredResult`. + /// + /// Embedded input requests reuse the locally registered request handlers, but + /// are not received as transport-level JSON-RPC requests. + @protected + Future handleEmbeddedInputRequest( + String inputRequestKey, + InputRequest inputRequest, { + AbortSignal? signal, + }) async { + final request = JsonRpcRequest( + id: inputRequestKey, + method: inputRequest.method, + params: inputRequest.params, + ); + final registeredHandler = _requestHandlers[inputRequest.method]; + final fallbackHandler = fallbackRequestHandler; + if (registeredHandler == null && fallbackHandler == null) { + throw McpError( + ErrorCode.methodNotFound.value, + 'No handler registered for MRTR input request ${inputRequest.method}', + ); + } + + final abortController = signal == null ? BasicAbortController() : null; + final effectiveSignal = signal ?? abortController!.signal; + effectiveSignal.throwIfAborted(); + + final extra = RequestHandlerExtra( + signal: effectiveSignal, + sessionId: _transport?.sessionId, + requestId: request.id, + meta: request.meta, + sendNotification: (notification, {relatedTask}) { + return _notificationWithRequestId( + notification, + relatedTask: relatedTask, + relatedRequestId: request.id, + ); + }, + sendRequest: ( + JsonRpcRequest req, + T Function(Map) resultFactory, + RequestOptions options, + ) { + return _requestWithRequestId( + req, + resultFactory, + options, + request.id, + ); + }, + ); + + try { + if (registeredHandler != null) { + final result = await registeredHandler(request, extra); + effectiveSignal.throwIfAborted(); + return result; + } + + final result = await fallbackHandler!(request); + effectiveSignal.throwIfAborted(); + return result; + } catch (error) { + onIncomingRequestFailed(request, error); + rethrow; + } + } + /// Subclass hook called after protocol-owned state has been cleared for a /// closed transport. @protected diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index caa910a6..03213abf 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -53,6 +53,7 @@ class DiscoveringClientTransport extends Transport 'ttlMs': 0, 'cacheScope': CacheScope.private, }, + this.onRequest, }); final List discoverVersions; @@ -60,6 +61,7 @@ class DiscoveringClientTransport extends Transport final Object? unsupportedDiscoverData; final ServerCapabilities capabilities; final Map toolsListResult; + final void Function(JsonRpcRequest request)? onRequest; final List sentMessages = []; @override @@ -120,6 +122,11 @@ class DiscoveringClientTransport extends Transport result: toolsListResult, ), ); + return; + } + + if (message is JsonRpcRequest) { + onRequest?.call(message); } } @@ -3777,6 +3784,164 @@ void main() { expect(response.error.message, contains('inputRequests')); }); + test('client retries tools/call after fulfilling input_required requests', + () async { + late DiscoveringClientTransport transport; + final callRequests = []; + transport = DiscoveringClientTransport( + onRequest: (request) { + if (request.method != Method.toolsCall) { + return; + } + + callRequests.add(request); + if (callRequests.length == 1) { + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: InputRequiredResult( + requestState: 'state-1', + inputRequests: { + 'profile': InputRequest.elicit( + ElicitRequest.form( + message: 'Enter profile', + requestedSchema: JsonSchema.object( + properties: {'name': JsonSchema.string()}, + required: ['name'], + ), + ), + ), + 'roots': InputRequest.listRoots(), + }, + ).toJson(), + ), + ); + return; + } + + expect(request.params?['requestState'], 'state-1'); + final inputResponses = + request.params?['inputResponses'] as Map; + expect(inputResponses['profile'], { + 'action': 'accept', + 'content': {'name': 'Ada'}, + }); + expect(inputResponses['roots'], { + 'roots': [ + {'uri': 'file:///repo'}, + ], + }); + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: { + 'resultType': resultTypeComplete, + ...const CallToolResult( + content: [TextContent(text: 'ok')], + ).toJson(), + }, + ), + ); + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + capabilities: ClientCapabilities( + elicitation: ClientElicitation.formOnly(), + roots: ClientCapabilitiesRoots(), + ), + ), + ); + client.onElicitRequest = (params) async { + expect(params.message, 'Enter profile'); + return const ElicitResult( + action: 'accept', + content: {'name': 'Ada'}, + ); + }; + client.setRequestHandler( + Method.rootsList, + (request, extra) async => ListRootsResult( + roots: [Root(uri: 'file:///repo')], + ), + (id, params, meta) => JsonRpcListRootsRequest(id: id, meta: meta), + ); + await client.connect(transport); + transport.sentMessages.clear(); + + final result = await client.callTool( + const CallToolRequest(name: 'lookup'), + ); + + expect((result.content.single as TextContent).text, 'ok'); + expect(callRequests, hasLength(2)); + expect(callRequests[1].id, isNot(callRequests[0].id)); + }); + + test('client retries requestState-only input_required without responses', + () async { + late DiscoveringClientTransport transport; + final readRequests = []; + transport = DiscoveringClientTransport( + capabilities: const ServerCapabilities( + resources: ServerCapabilitiesResources(), + ), + onRequest: (request) { + if (request.method != Method.resourcesRead) { + return; + } + + readRequests.add(request); + if (readRequests.length == 1) { + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: const InputRequiredResult( + requestState: 'read-state', + ).toJson(), + ), + ); + return; + } + + expect(request.params?['requestState'], 'read-state'); + expect(request.params, isNot(contains('inputResponses'))); + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: { + 'resultType': resultTypeComplete, + ...const ReadResourceResult( + contents: [ + TextResourceContents( + uri: 'file:///doc.txt', + text: 'hello', + ), + ], + ttlMs: 0, + cacheScope: CacheScope.private, + ).toJson(), + }, + ), + ); + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + await client.connect(transport); + transport.sentMessages.clear(); + + final result = await client.readResource( + const ReadResourceRequest(uri: 'file:///doc.txt'), + ); + + expect((result.contents.single as TextResourceContents).text, 'hello'); + expect(readRequests, hasLength(2)); + expect(readRequests[1].id, isNot(readRequests[0].id)); + }); + test('client listenSubscriptions requires a connected transport', () { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), From 4af899b16d4bebb818ced33c7ff5ba7f3884df2f Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 13:56:51 -0400 Subject: [PATCH 33/68] Handle custom request fallback dispatch --- lib/src/shared/protocol.dart | 15 +++- test/shared/protocol_edge_cases_test.dart | 97 +++++++++++++++-------- 2 files changed, 78 insertions(+), 34 deletions(-) diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 4dd1aaec..2d2b2456 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -1311,7 +1311,8 @@ abstract class Protocol { return; } - final handler = _requestHandlers[request.method] ?? fallbackRequestHandler; + final registeredHandler = _requestHandlers[request.method]; + final fallbackHandler = fallbackRequestHandler; if (_hasTaskAugmentation(request) && !_canHandleTaskAugmentation(request.method)) { @@ -1325,7 +1326,7 @@ abstract class Protocol { meta?[legacyRelatedTaskMetadataKey]) as Map?; final relatedTaskId = relatedTaskJson?['taskId'] as String?; - if (handler == null) { + if (registeredHandler == null && fallbackHandler == null) { _sendErrorResponse( request.id, ErrorCode.methodNotFound.value, @@ -1435,7 +1436,15 @@ abstract class Protocol { ); } - Future.microtask(() => handler(request, extra)).then( + Future invokeHandler() { + final handler = registeredHandler; + if (handler != null) { + return handler(request, extra); + } + return fallbackHandler!(request); + } + + Future.microtask(invokeHandler).then( (result) async { if (abortController.signal.aborted) { return; diff --git a/test/shared/protocol_edge_cases_test.dart b/test/shared/protocol_edge_cases_test.dart index 691777e5..a6ebb9dc 100644 --- a/test/shared/protocol_edge_cases_test.dart +++ b/test/shared/protocol_edge_cases_test.dart @@ -216,43 +216,70 @@ void main() { // Test passes if no exception is thrown }); - test('fallback notification handler would be called if method parsed', + test('parses custom notification methods and calls fallback handler', () async { - // Note: This test documents that fallback handlers CAN'T be tested with - // custom methods because JsonRpcMessage.fromJson throws UnimplementedError - // for unknown notification methods. The fallback handler mechanism exists - // but only works for methods that successfully parse. - await protocol.connect(transport); - // Set up fallback handler (it exists, just can't be triggered with unknown methods) + final received = Completer(); protocol.fallbackNotificationHandler = (notification) async { - // Would be called if a known notification type had no specific handler + received.complete(notification); }; - // Verify fallback handler is set - expect(protocol.fallbackNotificationHandler, isNotNull); + final parsed = JsonRpcMessage.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': 'extension/notification', + 'params': { + 'value': 'custom', + '_meta': {'vendor/trace': 'notification-1'}, + }, + }); + transport.receiveMessage(parsed); + + final notification = await received.future.timeout( + const Duration(seconds: 1), + ); - // Test passes to document this architectural limitation + expect(notification.method, 'extension/notification'); + expect(notification.params?['value'], 'custom'); + expect(notification.meta, {'vendor/trace': 'notification-1'}); }); - test('fallback request handler would be called if method parsed', () async { - // Note: Similar to notifications, fallback request handlers can't be tested - // with custom methods because JsonRpcMessage.fromJson throws UnimplementedError - // for unknown request methods. The fallback mechanism exists but only works - // for methods that successfully parse. - + test('parses custom request methods and calls fallback handler', () async { await protocol.connect(transport); - // Set up fallback handler + JsonRpcRequest? received; protocol.fallbackRequestHandler = (request) async { - return EdgeCaseResult(data: 'fallback'); + received = request; + return EdgeCaseResult( + data: 'fallback', + meta: {'vendor/trace': 'response-1'}, + ); }; - // Verify fallback handler is set - expect(protocol.fallbackRequestHandler, isNotNull); + final parsed = JsonRpcMessage.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'custom-request-1', + 'method': 'extension/request', + 'params': { + 'value': 'custom', + '_meta': {'vendor/trace': 'request-1'}, + }, + }); + transport.receiveMessage(parsed); + + await Future.delayed(const Duration(milliseconds: 50)); - // Test passes to document this architectural limitation + expect(received, isNotNull); + expect(received!.id, 'custom-request-1'); + expect(received!.method, 'extension/request'); + expect(received!.params?['value'], 'custom'); + expect(received!.meta, {'vendor/trace': 'request-1'}); + + expect(transport.sentMessages, hasLength(1)); + final response = transport.sentMessages.single as JsonRpcResponse; + expect(response.id, 'custom-request-1'); + expect(response.result, {'data': 'fallback'}); + expect(response.meta, {'vendor/trace': 'response-1'}); }); test('handles connection close with pending requests', () async { @@ -329,21 +356,29 @@ void main() { }); test('handles notification handler error gracefully', () async { - // Note: Custom notification methods throw UnimplementedError during parsing, - // so we can't test error handling for notification handlers since unknown - // methods never reach the handler. This test documents that errors in - // known notification handlers would be caught and passed to onerror. - await protocol.connect(transport); final receivedErrors = []; protocol.onerror = (error) => receivedErrors.add(error); - // The built-in handlers exist and would propagate errors through _onerror - // if they threw exceptions. Since we can't create a scenario that triggers - // this without modifying protocol internals, we document the behavior. + protocol.fallbackNotificationHandler = (notification) async { + throw StateError('Notification handler error'); + }; - // Test passes to document error propagation architecture + transport.receiveMessage( + const JsonRpcNotification( + method: 'extension/notification', + params: {}, + ), + ); + + await Future.delayed(const Duration(milliseconds: 50)); + + expect(receivedErrors, hasLength(1)); + expect( + receivedErrors.single.toString(), + contains('Notification handler error'), + ); }); }); From c6132afdb38f91e033bfb5fcef8177ec480473ad Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 14:07:28 -0400 Subject: [PATCH 34/68] Validate stateless result types per request --- lib/src/client/client.dart | 21 +++++++++++++ lib/src/shared/protocol.dart | 13 ++++++++ test/mcp_2026_07_28_test.dart | 58 +++++++++++++++++++++++++++++++++-- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 77fd9d85..8d9bff12 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -122,6 +122,12 @@ const Set _statelessRemovedNotificationMethods = { Method.notificationsTasksStatus, }; +const Set _statelessInputRequiredResultMethods = { + Method.toolsCall, + Method.promptsGet, + Method.resourcesRead, +}; + const int _maxInputRequiredRetries = 16; /// An MCP client implementation built on top of a pluggable [Transport]. @@ -729,6 +735,21 @@ class McpClient extends Protocol { (_serverCapabilities?.supportsTasksExtension ?? false); } + @override + bool isResultTypeAllowedForRequest( + JsonRpcRequest request, + String resultType, + ) { + if (resultType == resultTypeInputRequired) { + return _statelessInputRequiredResultMethods.contains(request.method); + } + if (resultType == resultTypeTask) { + return request.method == Method.toolsCall && + (_serverCapabilities?.supportsTasksExtension ?? false); + } + return super.isResultTypeAllowedForRequest(request, resultType); + } + @override McpError? validateIncomingRequest(JsonRpcRequest request) { if (_usesStatelessProtocol) { diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 2d2b2456..813fd086 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -497,6 +497,14 @@ abstract class Protocol { resultType == resultTypeInputRequired; } + /// Returns whether [resultType] is valid for [request]. + @protected + bool isResultTypeAllowedForRequest( + JsonRpcRequest request, + String resultType, + ) => + isRecognizedResultType(resultType); + bool _usesStatelessResultTypes(JsonRpcRequest request) { final requestProtocolVersion = request.meta?[McpMetaKey.protocolVersion]; if (requestProtocolVersion is String && @@ -534,6 +542,11 @@ abstract class Protocol { if (!isRecognizedResultType(resultType)) { throw FormatException('Unrecognized MCP resultType "$resultType"'); } + if (!isResultTypeAllowedForRequest(request, resultType)) { + throw FormatException( + 'MCP resultType "$resultType" is not valid for ${request.method}', + ); + } if (resultType == resultTypeComplete && _statelessCacheableResultMethods.contains(request.method)) { diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 03213abf..1b386bb4 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -4472,6 +4472,41 @@ void main() { ); }); + test('client rejects input_required on non-MRTR requests', () async { + final transport = DiscoveringClientTransport( + toolsListResult: const { + 'resultType': resultTypeInputRequired, + 'requestState': 'list-state', + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + + await client.connect(transport); + + await expectLater( + client.listTools(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.internalError.value, + ) + .having( + (error) => error.data.toString(), + 'data', + contains( + 'MCP resultType "$resultTypeInputRequired" is not valid for ' + '${Method.toolsList}', + ), + ), + ), + ); + }); + for (final scenario in [ ( name: 'missing ttlMs', @@ -4543,7 +4578,7 @@ void main() { }); } - test('client accepts advertised task extension resultType values', + test('client rejects task resultType on non-task-eligible requests', () async { final transport = DiscoveringClientTransport( capabilities: ServerCapabilities( @@ -4562,8 +4597,25 @@ void main() { await client.connect(transport); - final result = await client.listTools(); - expect(result.tools, isEmpty); + await expectLater( + client.listTools(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.internalError.value, + ) + .having( + (error) => error.data.toString(), + 'data', + contains( + 'MCP resultType "$resultTypeTask" is not valid for ' + '${Method.toolsList}', + ), + ), + ), + ); }); test('client preserves cache hints when filtering invalid tools', () async { From 1ed5ea5c9fdad5bd14dcb739647b8656020203c1 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 14:21:53 -0400 Subject: [PATCH 35/68] Handle task extension tool results --- lib/src/client/client.dart | 249 ++++++++++++++++++++++++++++++-- test/mcp_2026_07_28_test.dart | 259 ++++++++++++++++++++++++++++++++++ 2 files changed, 494 insertions(+), 14 deletions(-) diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 8d9bff12..506b6e8f 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -598,6 +598,24 @@ class McpClient extends Protocol { return resultFactory(json); } + BaseResultData _parseToolCallResult(Map json) { + switch (json['resultType']) { + case resultTypeInputRequired: + return InputRequiredResult.fromJson(json); + case resultTypeTask: + if (!_usesStatelessProtocol || + !_capabilities.supportsTasksExtension || + !(_serverCapabilities?.supportsTasksExtension ?? false)) { + throw const FormatException( + 'MCP resultType "task" is not valid for tools/call', + ); + } + return CreateTaskExtensionResult.fromJson(json); + default: + return CallToolResult.fromJson(json); + } + } + Future _resolveInputRequests( InputRequests? inputRequests, AbortSignal? signal, @@ -619,6 +637,35 @@ class McpClient extends Protocol { return inputResponses; } + Future _resolveNewInputRequests( + InputRequests? inputRequests, + Set answeredKeys, + AbortSignal? signal, + ) async { + if (inputRequests == null) { + return null; + } + + final pendingRequests = {}; + for (final entry in inputRequests.entries) { + if (!answeredKeys.contains(entry.key)) { + pendingRequests[entry.key] = entry.value; + } + } + if (pendingRequests.isEmpty) { + return null; + } + + final inputResponses = await _resolveInputRequests( + pendingRequests, + signal, + ); + if (inputResponses != null) { + answeredKeys.addAll(inputResponses.keys); + } + return inputResponses; + } + Future _requestResolvingInputRequired( String method, JsonRpcRequest Function( @@ -667,6 +714,186 @@ class McpClient extends Protocol { throw StateError('Unreachable input_required retry state for $method.'); } + Future _requestResolvingToolCall( + CallToolRequest params, + RequestOptions? options, + ) async { + InputResponses? inputResponses; + String? requestState; + + for (var attempt = 0; attempt <= _maxInputRequiredRetries; attempt++) { + final result = await request( + JsonRpcCallToolRequest( + id: -1, + params: CallToolRequest( + name: params.name, + arguments: params.arguments, + inputResponses: + attempt > 0 ? inputResponses : params.inputResponses, + requestState: attempt > 0 ? requestState : params.requestState, + ).toJson(), + ), + _parseToolCallResult, + options, + ); + + if (result is CallToolResult || result is CreateTaskExtensionResult) { + return result; + } + + if (result is! InputRequiredResult) { + throw McpError( + ErrorCode.internalError.value, + 'Unexpected result type ${result.runtimeType} for ${Method.toolsCall}.', + ); + } + + if (attempt == _maxInputRequiredRetries) { + throw McpError( + ErrorCode.invalidRequest.value, + 'Exceeded $_maxInputRequiredRetries input_required retries for ${Method.toolsCall}.', + ); + } + + inputResponses = await _resolveInputRequests( + result.inputRequests, + options?.signal, + ); + requestState = result.requestState; + } + + throw StateError( + 'Unreachable input_required retry state for ${Method.toolsCall}.', + ); + } + + Future _resolveTaskExtensionToolResult( + TaskExtensionTask initialTask, + RequestOptions? options, + ) async { + var currentTask = initialTask; + final answeredInputKeys = {}; + + while (true) { + options?.signal?.throwIfAborted(); + + switch (currentTask.status) { + case TaskStatus.completed: + final result = currentTask.result; + if (result == null) { + throw McpError( + ErrorCode.internalError.value, + 'Completed task ${currentTask.taskId} is missing a result.', + ); + } + return CallToolResult.fromJson(result); + + case TaskStatus.failed: + final error = currentTask.error; + if (error != null) { + throw McpError(error.code, error.message, error.data); + } + throw McpError( + ErrorCode.internalError.value, + 'Task ${currentTask.taskId} failed without error details.', + ); + + case TaskStatus.cancelled: + throw McpError( + ErrorCode.invalidRequest.value, + 'Task ${currentTask.taskId} was cancelled.', + ); + + case TaskStatus.inputRequired: + final inputResponses = await _resolveNewInputRequests( + currentTask.inputRequests, + answeredInputKeys, + options?.signal, + ); + if (inputResponses != null && inputResponses.isNotEmpty) { + await request( + JsonRpcUpdateTaskRequest( + id: -1, + updateParams: UpdateTaskRequest( + taskId: currentTask.taskId, + inputResponses: inputResponses, + ), + ), + TaskExtensionAcknowledgementResult.fromJson, + _taskFollowUpOptions(options), + ); + } + break; + + case TaskStatus.working: + break; + } + + await _waitForTaskExtensionPoll(currentTask, options?.signal); + currentTask = await _getTaskExtension(currentTask.taskId, options); + } + } + + Future _getTaskExtension( + String taskId, + RequestOptions? options, + ) async { + final result = await request( + JsonRpcGetTaskRequest( + id: -1, + getParams: GetTaskRequest(taskId: taskId), + ), + GetTaskExtensionResult.fromJson, + _taskFollowUpOptions(options), + ); + return result.task; + } + + RequestOptions? _taskFollowUpOptions(RequestOptions? options) { + if (options == null) { + return null; + } + return RequestOptions( + signal: options.signal, + timeout: options.timeout, + resetTimeoutOnProgress: options.resetTimeoutOnProgress, + maxTotalTimeout: options.maxTotalTimeout, + timeoutEnabled: options.timeoutEnabled, + ); + } + + Future _waitForTaskExtensionPoll( + TaskExtensionTask task, + AbortSignal? signal, + ) async { + signal?.throwIfAborted(); + + final interval = task.pollIntervalMs ?? 1000; + final completer = Completer(); + final timer = Timer(Duration(milliseconds: interval), () { + if (!completer.isCompleted) { + completer.complete(); + } + }); + + StreamSubscription? abortSubscription; + if (signal != null) { + abortSubscription = signal.onAbort.listen((_) { + timer.cancel(); + if (!completer.isCompleted) { + completer.completeError(AbortError(signal.reason)); + } + }); + } + + try { + await completer.future; + } finally { + timer.cancel(); + await abortSubscription?.cancel(); + } + } + @override Future notification( JsonRpcNotification notificationData, { @@ -745,6 +972,7 @@ class McpClient extends Protocol { } if (resultType == resultTypeTask) { return request.method == Method.toolsCall && + _capabilities.supportsTasksExtension && (_serverCapabilities?.supportsTasksExtension ?? false); } return super.isResultTypeAllowedForRequest(request, resultType); @@ -1160,20 +1388,13 @@ class McpClient extends Protocol { ); } - final result = await _requestResolvingInputRequired( - Method.toolsCall, - (inputResponses, requestState, isRetry) => JsonRpcCallToolRequest( - id: -1, - params: CallToolRequest( - name: params.name, - arguments: params.arguments, - inputResponses: isRetry ? inputResponses : params.inputResponses, - requestState: isRetry ? requestState : params.requestState, - ).toJson(), - ), - CallToolResult.fromJson, - options, - ); + final taskOrToolResult = await _requestResolvingToolCall(params, options); + final result = taskOrToolResult is CreateTaskExtensionResult + ? await _resolveTaskExtensionToolResult( + taskOrToolResult.task, + options, + ) + : taskOrToolResult as CallToolResult; final outputSchema = _cachedToolOutputSchemas[params.name]; if (outputSchema != null && !result.isError) { diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 1b386bb4..26ce510a 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -3879,6 +3879,265 @@ void main() { expect(callRequests[1].id, isNot(callRequests[0].id)); }); + test('client resolves task resultType tools/call responses', () async { + late DiscoveringClientTransport transport; + final requests = []; + transport = DiscoveringClientTransport( + capabilities: ServerCapabilities( + tools: const ServerCapabilitiesTools(), + extensions: withMcpTasksExtension(null), + ), + onRequest: (request) { + requests.add(request); + switch (request.method) { + case Method.toolsCall: + expect(request.params?['name'], 'delayed'); + final clientCapabilities = request + .meta?[McpMetaKey.clientCapabilities] as Map; + expect( + clientCapabilities['extensions'][mcpTasksExtensionId], + {}, + ); + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: const CreateTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:01Z', + ttlMs: null, + pollIntervalMs: 1, + ), + ).toJson(), + ), + ); + break; + + case Method.tasksGet: + expect(request.params?['taskId'], 'task-1'); + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: const GetTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.completed, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:02Z', + ttlMs: null, + result: { + 'content': [ + {'type': 'text', 'text': 'task done'}, + ], + 'isError': false, + }, + ), + ).toJson(), + ), + ); + break; + } + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: McpClientOptions( + capabilities: ClientCapabilities( + extensions: withMcpTasksExtension(null), + ), + ), + ); + await client.connect(transport); + transport.sentMessages.clear(); + + final result = await client.callTool( + const CallToolRequest(name: 'delayed'), + ); + + expect((result.content.single as TextContent).text, 'task done'); + expect(requests.map((request) => request.method), [ + Method.toolsCall, + Method.tasksGet, + ]); + }); + + test('client updates task input requests once while polling', () async { + late DiscoveringClientTransport transport; + var getCount = 0; + var updateCount = 0; + transport = DiscoveringClientTransport( + capabilities: ServerCapabilities( + tools: const ServerCapabilitiesTools(), + extensions: withMcpTasksExtension(null), + ), + onRequest: (request) { + switch (request.method) { + case Method.toolsCall: + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: const CreateTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-2', + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:01Z', + ttlMs: null, + pollIntervalMs: 1, + ), + ).toJson(), + ), + ); + break; + + case Method.tasksGet: + getCount += 1; + final task = getCount < 3 + ? TaskExtensionTask( + taskId: 'task-2', + status: TaskStatus.inputRequired, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:02Z', + ttlMs: null, + pollIntervalMs: 1, + inputRequests: { + 'approval': InputRequest.elicit( + ElicitRequest.form( + message: 'Approve?', + requestedSchema: JsonSchema.object( + properties: { + 'approved': JsonSchema.boolean(), + }, + required: ['approved'], + ), + ), + ), + }, + ) + : const TaskExtensionTask( + taskId: 'task-2', + status: TaskStatus.completed, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:03Z', + ttlMs: null, + result: { + 'content': [ + {'type': 'text', 'text': 'approved'}, + ], + }, + ); + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: GetTaskExtensionResult(task: task).toJson(), + ), + ); + break; + + case Method.tasksUpdate: + updateCount += 1; + expect(request.params?['taskId'], 'task-2'); + expect( + request.params?['inputResponses']['approval'], + { + 'action': 'accept', + 'content': {'approved': true}, + }, + ); + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: const TaskExtensionAcknowledgementResult().toJson(), + ), + ); + break; + } + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: McpClientOptions( + capabilities: ClientCapabilities( + elicitation: const ClientElicitation.formOnly(), + extensions: withMcpTasksExtension(null), + ), + ), + ); + client.onElicitRequest = (params) async { + expect(params.message, 'Approve?'); + return const ElicitResult( + action: 'accept', + content: {'approved': true}, + ); + }; + await client.connect(transport); + transport.sentMessages.clear(); + + final result = await client.callTool( + const CallToolRequest(name: 'approval-tool'), + ); + + expect((result.content.single as TextContent).text, 'approved'); + expect(getCount, 3); + expect(updateCount, 1); + }); + + test('client rejects task resultType when request lacks task extension', + () async { + late DiscoveringClientTransport transport; + transport = DiscoveringClientTransport( + capabilities: ServerCapabilities( + tools: const ServerCapabilitiesTools(), + extensions: withMcpTasksExtension(null), + ), + onRequest: (request) { + if (request.method != Method.toolsCall) { + return; + } + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: const CreateTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-3', + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:01Z', + ttlMs: null, + ), + ).toJson(), + ), + ); + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + await client.connect(transport); + + await expectLater( + client.callTool(const CallToolRequest(name: 'delayed')), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.internalError.value, + ) + .having( + (error) => error.data.toString(), + 'data', + contains( + 'MCP resultType "$resultTypeTask" is not valid for ' + '${Method.toolsCall}', + ), + ), + ), + ); + }); + test('client retries requestState-only input_required without responses', () async { late DiscoveringClientTransport transport; From 3242957d2f9e22105918f3f45a7548a484513d9d Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 14:29:42 -0400 Subject: [PATCH 36/68] Validate task extension status payloads --- lib/src/types/tasks.dart | 66 ++++++++++++++----- test/types/tasks_extension_test.dart | 97 ++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 16 deletions(-) diff --git a/lib/src/types/tasks.dart b/lib/src/types/tasks.dart index 32082fd6..45dcab0d 100644 --- a/lib/src/types/tasks.dart +++ b/lib/src/types/tasks.dart @@ -580,7 +580,7 @@ class TaskExtensionTask { }); factory TaskExtensionTask.fromJson(Map json) { - return TaskExtensionTask( + final task = TaskExtensionTask( taskId: _readRequiredTaskString( json, 'taskId', @@ -632,23 +632,57 @@ class TaskExtensionTask { ), ), ); + task._validateStatusPayload(); + return task; } - Map toJson({String? resultType}) => { - if (resultType != null) 'resultType': resultType, - 'taskId': taskId, - 'status': status.name, - if (statusMessage != null) 'statusMessage': statusMessage, - 'createdAt': createdAt, - 'lastUpdatedAt': lastUpdatedAt, - 'ttlMs': ttlMs, - if (pollIntervalMs != null) 'pollIntervalMs': pollIntervalMs, - if (inputRequests != null) - 'inputRequests': InputRequest.mapToJson(inputRequests!), - if (result != null) - 'result': readJsonObject(result, 'TaskExtensionTask.result'), - if (error != null) 'error': error!.toJson(), - }; + void _validateStatusPayload() { + switch (status) { + case TaskStatus.inputRequired: + if (inputRequests == null) { + throw const FormatException( + 'TaskExtensionTask.inputRequests is required when status is input_required', + ); + } + break; + case TaskStatus.completed: + if (result == null) { + throw const FormatException( + 'TaskExtensionTask.result is required when status is completed', + ); + } + break; + case TaskStatus.failed: + if (error == null) { + throw const FormatException( + 'TaskExtensionTask.error is required when status is failed', + ); + } + break; + case TaskStatus.working: + case TaskStatus.cancelled: + break; + } + } + + Map toJson({String? resultType}) { + _validateStatusPayload(); + return { + if (resultType != null) 'resultType': resultType, + 'taskId': taskId, + 'status': status.name, + if (statusMessage != null) 'statusMessage': statusMessage, + 'createdAt': createdAt, + 'lastUpdatedAt': lastUpdatedAt, + 'ttlMs': ttlMs, + if (pollIntervalMs != null) 'pollIntervalMs': pollIntervalMs, + if (inputRequests != null) + 'inputRequests': InputRequest.mapToJson(inputRequests!), + if (result != null) + 'result': readJsonObject(result, 'TaskExtensionTask.result'), + if (error != null) 'error': error!.toJson(), + }; + } } /// `resultType: "task"` response from the MCP Tasks extension. diff --git a/test/types/tasks_extension_test.dart b/test/types/tasks_extension_test.dart index cc5cc949..d4b0f555 100644 --- a/test/types/tasks_extension_test.dart +++ b/test/types/tasks_extension_test.dart @@ -167,6 +167,103 @@ void main() { ); }); + test('rejects missing status-specific task payload fields', () { + final baseTask = { + 'taskId': 'task-1', + 'createdAt': '2026-07-28T00:00:00Z', + 'lastUpdatedAt': '2026-07-28T00:02:00Z', + 'ttlMs': null, + }; + + expect( + () => TaskExtensionTask.fromJson({ + ...baseTask, + 'status': 'input_required', + }), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('inputRequests'), + ), + ), + ); + expect( + () => TaskExtensionTask.fromJson({ + ...baseTask, + 'status': 'completed', + }), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('result'), + ), + ), + ); + expect( + () => TaskExtensionTask.fromJson({ + ...baseTask, + 'status': 'failed', + }), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('error'), + ), + ), + ); + expect( + () => const TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.inputRequired, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:02:00Z', + ttlMs: null, + ).toJson(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('inputRequests'), + ), + ), + ); + expect( + () => const TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.completed, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:02:00Z', + ttlMs: null, + ).toJson(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('result'), + ), + ), + ); + expect( + () => const TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.failed, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:02:00Z', + ttlMs: null, + ).toJson(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('error'), + ), + ), + ); + }); + test('serializes notifications/tasks with detailed task state', () { final notification = JsonRpcTaskNotification( task: const TaskExtensionTask( From 89ebf0c0cc159362e985720cec0280048c9faf2f Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 14:39:54 -0400 Subject: [PATCH 37/68] Route TaskClient through task extension flow --- doc/client-guide.md | 7 +++ doc/tools.md | 4 ++ lib/src/client/task_client.dart | 34 +++++++++++-- test/client/task_client_test.dart | 81 +++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 3 deletions(-) diff --git a/doc/client-guide.md b/doc/client-guide.md index c9efd21c..a9d4fed9 100644 --- a/doc/client-guide.md +++ b/doc/client-guide.md @@ -158,6 +158,13 @@ final result = await client.callTool( ### Task-Augmented Tool Calls +For MCP 2026 stateless servers that advertise the +`io.modelcontextprotocol/tasks` extension, task creation is server-directed. +Call `client.callTool()` normally, or call `TaskClient.callToolStream()` without +the legacy `task` argument; the client follows `resultType: "task"` with +`tasks/get`, using `tasks/update` only when the server requests more input, +until the final tool result is available. + For task-capable tools, use `TaskClient.callToolStream()` and pass task creation parameters through the `task` argument. The server must advertise `tasks.requests.tools.call`, and the target tool must be visible from diff --git a/doc/tools.md b/doc/tools.md index e9834080..88a24a1c 100644 --- a/doc/tools.md +++ b/doc/tools.md @@ -514,6 +514,10 @@ final server = McpServer( ``` Clients that call task-augmented tools can use `TaskClient.callToolStream()`. +With MCP 2026 stateless servers that advertise +`io.modelcontextprotocol/tasks`, omit the legacy `task` argument; task creation +is server-directed and the client follows the extension polling flow +transparently. When the `task` argument is supplied, `TaskClient` first verifies that the server advertised `tasks.requests.tools.call`, then lists tools to confirm the target tool advertises `execution.taskSupport` as `optional` or `required`. diff --git a/lib/src/client/task_client.dart b/lib/src/client/task_client.dart index dee32b09..14a628d1 100644 --- a/lib/src/client/task_client.dart +++ b/lib/src/client/task_client.dart @@ -28,6 +28,13 @@ class TaskClient { TaskClient(this.client); + bool get _usesTasksExtension { + final protocolVersion = client.getProtocolVersion(); + return protocolVersion != null && + isStatelessProtocolVersion(protocolVersion) && + (client.getServerCapabilities()?.supportsTasksExtension ?? false); + } + Future _findTool(String name) async { String? cursor; do { @@ -50,9 +57,14 @@ class TaskClient { /// and long-running tasks (yielding [TaskCreatedMessage], multiple /// [TaskStatusMessage]s, and finally [TaskResultMessage]). /// - /// The [task] parameter is used for task augmentation. Pass task creation - /// parameters (e.g., `{'ttl': 60000, 'pollInterval': 50}`) to request - /// task-based execution from tools that support it. + /// For MCP 2026 stateless sessions with the `io.modelcontextprotocol/tasks` + /// extension, task creation is server-directed and [task] must be omitted. + /// The call is routed through [McpClient.callTool], which transparently + /// follows the extension polling flow and yields the final tool result. + /// + /// For MCP 2025-11-25 legacy tasks, [task] is used for task augmentation. + /// Pass task creation parameters (e.g., `{'ttl': 60000, 'pollInterval': 50}`) + /// to request task-based execution from tools that support it. /// /// When [task] is provided, the connected server must advertise /// `tasks.requests.tools.call`, and the target tool must be discoverable from @@ -65,6 +77,22 @@ class TaskClient { Map? task, }) async* { try { + if (_usesTasksExtension) { + if (task != null) { + throw McpError( + ErrorCode.invalidRequest.value, + 'MCP ${client.getProtocolVersion()} uses the ' + '$mcpTasksExtensionId extension instead of the legacy task ' + 'request parameter.', + ); + } + final result = await client.callTool( + CallToolRequest(name: name, arguments: arguments), + ); + yield TaskResultMessage(result); + return; + } + if (task != null) { client.assertTaskCapability(Method.toolsCall); final tool = await _findTool(name); diff --git a/test/client/task_client_test.dart b/test/client/task_client_test.dart index c68021cc..8403090f 100644 --- a/test/client/task_client_test.dart +++ b/test/client/task_client_test.dart @@ -6,6 +6,9 @@ class MockClient implements McpClient { final Map _responses = {}; final List requests = []; bool supportsTaskAugmentedTools = true; + String? protocolVersion; + ServerCapabilities? serverCapabilities; + CallToolResult? callToolResult; List listedTools = const []; Map listedToolPages = const {}; @@ -73,6 +76,29 @@ class MockClient implements McpClient { } } + @override + String? getProtocolVersion() => protocolVersion; + + @override + ServerCapabilities? getServerCapabilities() => serverCapabilities; + + @override + Future callTool( + CallToolRequest params, { + RequestOptions? options, + }) async { + requests.add(JsonRpcCallToolRequest(id: -1, params: params.toJson())); + final result = callToolResult; + if (result != null) { + return result; + } + final response = _responses[Method.toolsCall]; + if (response == null) { + throw Exception('Mock response not found for ${Method.toolsCall}'); + } + return CallToolResult.fromJson(Map.from(response)); + } + @override Future listTools({ ListToolsRequest? params, @@ -121,6 +147,61 @@ void main() { ); }); + test('callToolStream delegates 2026 task extension tools to callTool', + () async { + mockClient.protocolVersion = draftProtocolVersion2026_07_28; + mockClient.serverCapabilities = ServerCapabilities( + tools: const ServerCapabilitiesTools(), + extensions: withMcpTasksExtension(null), + ); + mockClient.callToolResult = const CallToolResult( + content: [TextContent(text: 'Extension task done')], + ); + + final events = await taskClient.callToolStream( + 'extension-tool', + {'city': 'Toronto'}, + ).toList(); + + expect(events, hasLength(1)); + expect(events.single, isA()); + expect( + (((events.single as TaskResultMessage).result as CallToolResult) + .content + .single as TextContent) + .text, + 'Extension task done', + ); + expect(mockClient.requests.map((r) => r.method), [Method.toolsCall]); + expect(mockClient.requests.single.params, isNot(contains('task'))); + expect(mockClient.requests.single.params?['arguments'], { + 'city': 'Toronto', + }); + }); + + test('callToolStream rejects legacy task parameter for 2026 task extension', + () async { + mockClient.protocolVersion = draftProtocolVersion2026_07_28; + mockClient.serverCapabilities = ServerCapabilities( + tools: const ServerCapabilitiesTools(), + extensions: withMcpTasksExtension(null), + ); + + final events = await taskClient.callToolStream( + 'extension-tool', + {}, + task: {'ttl': 1000}, + ).toList(); + + expect(events, hasLength(1)); + expect(events.single, isA()); + expect( + (events.single as TaskErrorMessage).error.toString(), + contains('legacy task request parameter'), + ); + expect(mockClient.requests, isEmpty); + }); + test('callToolStream handles long-running task workflow', () async { final taskId = 'task-123'; From b2213879f58cc107a87b07c0718f9104246c3562 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 1 Jun 2026 14:54:57 -0400 Subject: [PATCH 38/68] Expand compliance coverage for 2026 RC --- .github/workflows/test_cli.yml | 6 +- CHANGELOG.md | 62 +- doc/interoperability.md | 3 +- doc/spec-coverage-2025-11-25.md | 91 +- lib/src/client/client.dart | 30 +- lib/src/client/streamable_https.dart | 75 +- lib/src/server/server.dart | 156 +- lib/src/server/streamable_https.dart | 65 +- lib/src/server/streamable_mcp_server.dart | 8 +- lib/src/shared/json_schema/json_schema.dart | 39 +- lib/src/shared/protocol.dart | 167 +- lib/src/types.dart | 1 + lib/src/types/completion.dart | 21 +- lib/src/types/elicitation.dart | 36 +- lib/src/types/initialization.dart | 42 +- lib/src/types/json_rpc.dart | 96 +- lib/src/types/logging.dart | 10 +- lib/src/types/misc.dart | 21 +- lib/src/types/prompts.dart | 9 +- lib/src/types/resources.dart | 10 +- lib/src/types/roots.dart | 10 +- lib/src/types/sampling.dart | 17 +- lib/src/types/subscriptions.dart | 97 +- lib/src/types/tasks.dart | 10 +- lib/src/types/tools.dart | 14 +- packages/mcp_dart_cli/CHANGELOG.md | 15 + packages/mcp_dart_cli/README.md | 15 +- .../lib/src/conformance_runner.dart | 4811 ++++++++++++++++- .../test/fixtures/raw_stdio_server.dart | 22 +- .../test/src/conformance_command_test.dart | 53 +- test/client/client_test.dart | 21 +- test/client/client_tool_validation_test.dart | 36 +- test/client/streamable_https_test.dart | 4 +- test/docs/markdown_docs_test.dart | 2 +- test/elicitation_test.dart | 123 +- .../ts_client_with_dart_server_test.dart | 15 +- test/mcp_2025_11_25_test.dart | 55 +- test/mcp_2026_07_28_test.dart | 1354 ++++- test/server/server_test.dart | 195 +- test/server/streamable_https_test.dart | 343 +- test/server/streamable_mcp_server_test.dart | 181 + test/shared/json_schema_from_json_test.dart | 36 + test/shared/json_schema_validator_test.dart | 8 +- test/shared/protocol_test.dart | 51 + test/tool/spec_example_audit_test.dart | 182 + test/tool_schema_test.dart | 2 +- test/types/subscriptions_test.dart | 78 +- test/types_edge_cases_test.dart | 123 +- test/types_test.dart | 38 +- tool/spec_example_audit.dart | 287 + 50 files changed, 8471 insertions(+), 675 deletions(-) create mode 100644 test/tool/spec_example_audit_test.dart create mode 100644 tool/spec_example_audit.dart diff --git a/.github/workflows/test_cli.yml b/.github/workflows/test_cli.yml index 93fe580f..4300cf84 100644 --- a/.github/workflows/test_cli.yml +++ b/.github/workflows/test_cli.yml @@ -100,8 +100,10 @@ jobs: # Given the previous task aimed for 160/160, let's keep it strict but maybe allow slight dev. # For this initial workflow file, I will fail if not max points to maintain quality. if [ "$PANA_EXIT" -ne 0 ] || [ "$SCORE" != "$MAX_POINTS" ]; then - if grep -q 'depends on mcp_dart .*which doesn.*t match any versions' pana_output.json; then - echo "::warning::Skipping strict CLI pana score because this PR prepares the CLI against an unpublished mcp_dart release. Publish mcp_dart first, then rerun pana before publishing mcp_dart_cli." + if grep -q 'depends on mcp_dart .*which doesn.*t match any versions' pana_output.json || + { grep -q '^dependency_overrides:' pubspec.yaml && + grep -Eq 'UNDEFINED_(NAMED_PARAMETER|FUNCTION|GETTER|METHOD|CLASS)' pana_output.json; }; then + echo "::warning::Skipping strict CLI pana score because this PR prepares the CLI against unpublished local mcp_dart APIs. Publish mcp_dart first, then rerun pana before publishing mcp_dart_cli." exit 0 fi echo "pana exited with code $PANA_EXIT." diff --git a/CHANGELOG.md b/CHANGELOG.md index 16e989cc..b89b447e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,11 +22,11 @@ - Synced registered tool `x-mcp-header` metadata into Streamable HTTP server transports so 2026 stateless `tools/call` requests reject missing or mismatched `Mcp-Param-*` argument headers. -- Rejected `x-mcp-header` usage on JSON Schema `number` parameters, keeping - mirrored tool headers limited to string, JavaScript-safe integer, and boolean - parameters. - Removed `Mcp-Session-Id` from 2026 stateless Streamable HTTP requests by stripping it from client sends and ignoring it on stateless server POSTs. +- Prevented 2026 stateless request handlers and task-store operations from + inheriting transport session IDs, including direct Streamable HTTP transport + responses after a prior stateful initialization. - Enforced 2026 stateless Streamable HTTP POST-only behavior by skipping legacy client GET/DELETE session paths and returning `Allow: POST` for stateless non-POST server requests. @@ -37,16 +37,39 @@ values beginning with `=?base64?` and ending with `?=` round-trip correctly. - Synced nested 2026 `x-mcp-header` mappings into Streamable HTTP transports using JSON Pointer selectors for nested tool arguments. +- Limited 2026 Streamable HTTP `x-mcp-header` mirroring to string, boolean, + and JavaScript-safe integer argument values; fractional numbers and unsafe + integers are omitted, and `number` schemas are rejected from advertised + header mappings. - Returned HTTP 404 with JSON-RPC `Method not found` for unsupported or removed 2026 stateless Streamable HTTP request methods before opening response streams. +- Sent and required `Mcp-Name` for MCP 2026 task lifecycle requests over + Streamable HTTP, using the request body `taskId` as the routing value. +- Accepted MCP 2026 stateless Streamable HTTP JSON-RPC response POSTs without + requiring request-only metadata in the response body. - Treated client closure of a 2026 stateless Streamable HTTP SSE response stream as cancellation of that pending request. - Sorted 2026 stateless high-level `tools/list` responses by tool name for deterministic list results while preserving legacy registration-order output. +- Omitted stable-only `Tool.execution` metadata from 2026 stateless + `tools/list` responses and embedded MRTR sampling tool definitions while + preserving stable/default serialization. +- Rejected legacy `RequestOptions.task` augmentation before sending 2026 + stateless requests while preserving stable task augmentation. +- Added `tool/spec_example_audit.dart` so upstream machine-readable spec + examples can be parsed through the checked-in typed SDK surfaces during + RC/final release audits. - Gated 2026 stateless task extension methods on advertised server extension support and rejected legacy task result shapes on extension `tasks/get`, `tasks/update`, and `tasks/cancel` handlers. +- Ignored legacy `tools/call` `task` parameters on 2026 stateless requests so + handlers do not receive legacy task TTL hints through `RequestHandlerExtra`. +- Required 2026 stateless task creation results to be immediately resolvable + through `tasks/get` before returning `resultType: "task"`. +- Exposed task-store options on `McpServerOptions` and serialized built-in + task-store `tasks/get`/`tasks/cancel` handlers in the 2026 task-extension + wire shape for stateless requests. - Added request-scoped stateless logging gating via `io.modelcontextprotocol/logLevel` metadata so 2026 log notifications are emitted only when the current request opts in. @@ -63,6 +86,28 @@ `input_required` results instead. - Enforced `subscriptions/listen` stream ordering and filters for 2026 subscription notifications. +- Required nested request `_meta` on `subscriptions/listen` requests, matching + the 2026 schema's per-request metadata requirement. +- Rejected mismatched JSON-RPC `jsonrpc` and `method` wrapper constants when + parsing typed `notifications/subscriptions/acknowledged` notifications. +- Rejected mismatched JSON-RPC wrapper constants when directly parsing the + experimental completion list-changed notification. +- Rejected incoming `tools/call` JSON-RPC requests that omit the MCP-required + `params` object. +- Rejected JSON-RPC envelopes that mix request/notification `method` fields + with response `result` or `error` fields, including direct typed + request/notification/error parsing. +- Returned `MissingRequiredClientCapability` (`-32003`) with required task + extension capability data when 2026 task notification subscriptions omit the + per-request `io.modelcontextprotocol/tasks` client capability. +- Rejected deprecated sampling `includeContext` values unless the client + advertises `sampling.context`, while still allowing omitted context and + `includeContext: "none"`. +- Stopped sending `notifications/cancelled` when an outgoing `initialize` + request is aborted or times out, matching the stable lifecycle rule that + clients must not cancel initialization. +- Required `notifications/cancelled` payloads to carry a valid string-or-integer + `requestId` instead of accepting ID-less cancellation notifications. - Retried `server/discover` with an advertised compatible stateless protocol version after `UnsupportedProtocolVersionError` instead of falling back to legacy initialization. @@ -87,6 +132,8 @@ stateless requests use `-32602` with the missing `uri` in error data. - Enforced MCP 2026 `_meta` key-name grammar on stateless request metadata and the 2026 request metadata builder while preserving legacy metadata parsing. +- Exposed typed request-envelope accessors on `RequestHandlerExtra` for + per-request protocol version, client info, and client capabilities metadata. - Rejected negative cacheable-result `ttlMs` values during parsing instead of clamping malformed wire values to zero. - Validated MRTR `inputResponses` as `CreateMessageResult`, `ListRootsResult`, @@ -94,9 +141,10 @@ - Restricted numeric `ElicitResult.content` values to integers, matching the stable and MCP 2026 `string | integer | boolean | string[]` schemas while still accepting whole-number JSON numeric values. -- Made form elicitation number-schema keyword validation protocol-aware: - stable 2025 keeps integer-only `minimum`, `maximum`, and `default` values, - while MCP 2026 accepts fractional number keywords. +- Required integer `minimum`, `maximum`, and `default` values in form + elicitation number schemas for both stable 2025 and MCP 2026. +- Rejected MCP 2026 `CallToolResult.extra` attempts to spoof non-complete + `resultType` values, and added CLI conformance coverage for that guard. - Rejected form elicitation schemas that provide legacy `enumNames` without the required string `enum`. - Rejected `ElicitResult.content` when the result action is `decline` or @@ -189,6 +237,8 @@ notification methods removed from that protocol revision. - Rejected server-initiated JSON-RPC requests received by stateless MCP 2026 clients on generic transports. +- Omitted the removed `roots.listChanged` client capability from MCP 2026 + stateless request metadata while preserving it for stable 2025 metadata. - Rejected stateless MCP 2026 responses that omit `resultType` or required cacheable-result fields. - Stripped caller-supplied `Mcp-Session-Id` headers case-insensitively from diff --git a/doc/interoperability.md b/doc/interoperability.md index 9eaf3597..cbbc701c 100644 --- a/doc/interoperability.md +++ b/doc/interoperability.md @@ -43,7 +43,8 @@ dart test --tags interop If the compiled fixtures are missing, local test runs skip the interop groups; CI should fail when required fixtures are unavailable. The CLI spec conformance gate covers raw-wire negative cases that do not need a -cross-SDK fixture: +cross-SDK fixture, including stable MCP 2025-11-25 checks and MCP 2026-07-28 RC +stateless/discovery/task-extension checks: ```bash cd packages/mcp_dart_cli diff --git a/doc/spec-coverage-2025-11-25.md b/doc/spec-coverage-2025-11-25.md index 4db17cc9..fa9a21f4 100644 --- a/doc/spec-coverage-2025-11-25.md +++ b/doc/spec-coverage-2025-11-25.md @@ -25,6 +25,14 @@ cd ../../.. dart test -t interop ``` +For MCP 2026 RC/final release audits, also run the upstream +machine-readable example corpus through the checked-in typed parsers after +extracting the upstream `modelcontextprotocol` archive: + +```bash +dart run tool/spec_example_audit.dart /path/to/modelcontextprotocol/schema/draft/examples +``` + CI runs both gates: the core workflow runs the TypeScript interop suite and the full CLI conformance gate, while the CLI workflow runs the conformance gate with the CLI test suite. @@ -33,16 +41,21 @@ the CLI test suite. | Spec area | Spec source | Requirement tracked here | Local coverage | Interop coverage | Conformance case or gap | Status | | --- | --- | --- | --- | --- | --- | --- | -| Lifecycle initialization ordering | [Lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) | `initialize` is first, peers do not run normal operations before lifecycle readiness, and `notifications/initialized` transitions the session into normal operation. | [`test/lifecycle_test.dart`](../test/lifecycle_test.dart) | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/ts/src/lifecycle_client.ts`](../test/interop/ts/src/lifecycle_client.ts) | `lifecycle.rejects-pre-initialize-request` | Verified | -| Protocol version negotiation and HTTP header behavior | [Lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle), [Transports](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) | Peers negotiate a supported protocol version, and Streamable HTTP requests carry valid `MCP-Protocol-Version` after initialization. | [`test/client/client_test.dart`](../test/client/client_test.dart), [`test/server/server_test.dart`](../test/server/server_test.dart), [`test/mcp_2025_11_25_test.dart`](../test/mcp_2025_11_25_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | [`test/interop/dart_client_with_ts_server_test.dart`](../test/interop/dart_client_with_ts_server_test.dart), [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart) | `protocol-version.advertises-latest-2025-11-25` | Verified | +| Lifecycle initialization ordering | [Lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) | `initialize` is first, peers do not run normal operations before lifecycle readiness, clients do not attempt to cancel `initialize`, and `notifications/initialized` transitions the session into normal operation. | [`test/lifecycle_test.dart`](../test/lifecycle_test.dart), [`test/shared/protocol_test.dart`](../test/shared/protocol_test.dart) | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/ts/src/lifecycle_client.ts`](../test/interop/ts/src/lifecycle_client.ts) | `lifecycle.rejects-pre-initialize-request`, `lifecycle.gates-until-initialized-notification`, `lifecycle.does-not-cancel-initialize` | Verified | +| Cancellation notifications | [Cancellation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation) | `notifications/cancelled` preserves a string-or-integer JSON-RPC request ID and rejects payloads that omit or malform the ID; task cancellation uses `tasks/cancel` rather than cancellation notifications. | [`test/types_edge_cases_test.dart`](../test/types_edge_cases_test.dart), [`test/shared/protocol_test.dart`](../test/shared/protocol_test.dart) | Covered by TypeScript interop cancellation and task flows where applicable. | `cancellation.requires-request-id`; task cancellation coverage lives in `tasks-extension.task-store-uses-extension-result-shapes`. | Verified | +| Protocol version negotiation and HTTP header behavior | [Lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle), [Transports](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports), [Draft lifecycle](https://modelcontextprotocol.io/specification/draft/basic/lifecycle) | Peers negotiate a supported protocol version, Streamable HTTP requests carry valid `MCP-Protocol-Version` after initialization, draft stateless requests include protocol, client identity, client capabilities, method, name, and parameter routing headers while omitting stable-only capability fields removed from the draft, and draft HTTP clients inspect modern JSON-RPC `400` error bodies before deciding whether to fall back to legacy `initialize`. | [`test/client/client_test.dart`](../test/client/client_test.dart), [`test/server/server_test.dart`](../test/server/server_test.dart), [`test/mcp_2025_11_25_test.dart`](../test/mcp_2025_11_25_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | [`test/interop/dart_client_with_ts_server_test.dart`](../test/interop/dart_client_with_ts_server_test.dart), [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart) | `protocol-version.advertises-latest-2025-11-25`, `stateless.requires-complete-request-meta`, `protocol-version.http-modern-400-retries-discovery`, `capabilities.http-modern-400-does-not-fallback`, `stateless-http.requires-routing-headers`, `stateless-http.validates-parameter-headers`, `stateless-http.encodes-parameter-header-values` | Verified | | Stable schema metadata and capabilities | [Schema reference](https://modelcontextprotocol.io/specification/2025-11-25/schema) | Stable model serializers preserve schema fields such as `Resource.size` and `Root._meta`, emit stable `icons` and annotation fields, and avoid non-stable server capability fields. | [`test/mcp_2025_11_25_test.dart`](../test/mcp_2025_11_25_test.dart), [`test/types/resources_test.dart`](../test/types/resources_test.dart) | Covered by TypeScript interop initialization and list/read flows. | Legacy singular `icon`, `ResourceAnnotations.title`, `ToolAnnotations.priority`, `ToolAnnotations.audience`, top-level server `elicitation`, and `tasks.listChanged` parse for compatibility but do not serialize on stable wire objects. | Verified | -| JSON-RPC responses and strict required fields | [Schema reference JSON-RPC](https://modelcontextprotocol.io/specification/2025-11-25/schema#jsonrpcmessage) | JSON-RPC response IDs preserve string-or-integer identity, successful responses require an `id`, error responses may omit it, and required result arrays are not silently synthesized when absent. | [`test/types_edge_cases_test.dart`](../test/types_edge_cases_test.dart), [`test/types/resources_test.dart`](../test/types/resources_test.dart), [`test/mcp_2025_11_25_test.dart`](../test/mcp_2025_11_25_test.dart) | Covered by TypeScript interop request/response flows. | `jsonrpc.preserves-string-response-id`; additional strict-array regression coverage lives in `test/mcp_2025_11_25_test.dart`. | Verified | -| Negotiated capability enforcement | [Lifecycle capability negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#capability-negotiation), [Sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) | Requests that require an unadvertised feature are rejected before handler code observes them. | [`test/client/client_tool_validation_test.dart`](../test/client/client_tool_validation_test.dart), [`test/client/client_test.dart`](../test/client/client_test.dart), [`test/server/server_test.dart`](../test/server/server_test.dart) | [`test/interop/dart_client_with_ts_server_features_test.dart`](../test/interop/dart_client_with_ts_server_features_test.dart) | `capabilities.rejects-unnegotiated-sampling-tools` | Verified | +| JSON-RPC responses and strict required fields | [Schema reference JSON-RPC](https://modelcontextprotocol.io/specification/2025-11-25/schema#jsonrpcmessage) | JSON-RPC response IDs preserve string-or-integer identity, successful responses require an `id`, error responses may omit it, request/notification envelopes do not mix `method` with response fields, required request params such as `tools/call.params` are not synthesized, and required result arrays are not silently synthesized when absent. | [`test/types_edge_cases_test.dart`](../test/types_edge_cases_test.dart), [`test/types/resources_test.dart`](../test/types/resources_test.dart), [`test/mcp_2025_11_25_test.dart`](../test/mcp_2025_11_25_test.dart) | Covered by TypeScript interop request/response flows. | `jsonrpc.preserves-string-response-id`, `jsonrpc.accepts-omitted-error-response-id`, `jsonrpc.rejects-method-response-envelope`, `tools-call.requires-params`; additional strict-array regression coverage lives in `test/mcp_2025_11_25_test.dart`. | Verified | +| Negotiated capability enforcement | [Lifecycle capability negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#capability-negotiation), [Sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling), [Draft MethodNotFoundError](https://modelcontextprotocol.io/specification/draft/schema#methodnotfounderror) | Requests that require an unadvertised feature are rejected before handler code observes them, and unadvertised peer method capabilities surface `MethodNotFound` (`-32601`). | [`test/client/client_tool_validation_test.dart`](../test/client/client_tool_validation_test.dart), [`test/client/client_test.dart`](../test/client/client_test.dart), [`test/server/server_test.dart`](../test/server/server_test.dart) | [`test/interop/dart_client_with_ts_server_features_test.dart`](../test/interop/dart_client_with_ts_server_features_test.dart) | `capabilities.rejects-unnegotiated-sampling-tools`, `capabilities.rejects-unnegotiated-sampling-context`; `capabilities.unadvertised-peer-methods-use-method-not-found` | Verified | | Tool schema root-object validation | [Tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools), [Schema reference tools](https://modelcontextprotocol.io/specification/2025-11-25/schema#tool) | `Tool.inputSchema` and `Tool.outputSchema` serialize as object-root JSON Schema values and reject primitive root schemas at the wire boundary. | [`test/tool_schema_test.dart`](../test/tool_schema_test.dart), [`test/mcp_2025_11_25_test.dart`](../test/mcp_2025_11_25_test.dart) | Covered by tool-list and tool-call interop tests. | Root object validation is enforced in `Tool.fromJson()` and `Tool.toJson()` while preserving `JsonSchema`-typed source compatibility. | Verified | | Elicitation form/URL variant validation | [Elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) | `elicitation/create` is treated as a discriminated form/URL shape, form schemas use object-root primitive property schemas, URL-required errors contain URL-mode elicitation requests, and invalid mixed payloads are rejected. | [`test/elicitation_test.dart`](../test/elicitation_test.dart), [`test/client/client_elicitation_defaults_test.dart`](../test/client/client_elicitation_defaults_test.dart), [`test/server/server_validation_test.dart`](../test/server/server_validation_test.dart), [`test/mcp_2025_11_25_test.dart`](../test/mcp_2025_11_25_test.dart) | [`test/interop/dart_client_with_ts_server_features_test.dart`](../test/interop/dart_client_with_ts_server_features_test.dart) | `elicitation.rejects-invalid-form-url-union` | Verified | -| Task metadata and related-task propagation | [Schema reference tasks](https://modelcontextprotocol.io/specification/2025-11-25/schema#tasks) | Task-augmented requests require negotiated task support, and related-task metadata is preserved only where task association is valid. | [`test/server/tasks_test.dart`](../test/server/tasks_test.dart), [`test/client/task_client_test.dart`](../test/client/task_client_test.dart), [`test/shared/protocol_task_handlers_test.dart`](../test/shared/protocol_task_handlers_test.dart), [`test/server/tasks_components_test.dart`](../test/server/tasks_components_test.dart), [`test/server/mcp_test.dart`](../test/server/mcp_test.dart) | [`test/interop/dart_client_with_ts_server_task_test.dart`](../test/interop/dart_client_with_ts_server_task_test.dart), [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/ts/src/client.ts`](../test/interop/ts/src/client.ts) | `tasks.strips-unnegotiated-related-task-metadata`; SDK-generated related responses and `tasks/result` overwrite reserved related-task metadata from the source task id while preserving unrelated handler metadata. | Verified | +| Task metadata and related-task propagation | [Schema reference tasks](https://modelcontextprotocol.io/specification/2025-11-25/schema#tasks) | Task-augmented requests require negotiated task support, related-task metadata is preserved only where task association is valid, and clients do not emit legacy task augmentation on 2026 stateless requests where the schema removed it. | [`test/server/tasks_test.dart`](../test/server/tasks_test.dart), [`test/client/task_client_test.dart`](../test/client/task_client_test.dart), [`test/shared/protocol_task_handlers_test.dart`](../test/shared/protocol_task_handlers_test.dart), [`test/server/tasks_components_test.dart`](../test/server/tasks_components_test.dart), [`test/server/mcp_test.dart`](../test/server/mcp_test.dart), [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart) | [`test/interop/dart_client_with_ts_server_task_test.dart`](../test/interop/dart_client_with_ts_server_task_test.dart), [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/ts/src/client.ts`](../test/interop/ts/src/client.ts) | `tasks.strips-unnegotiated-related-task-metadata`, `stateless-client.rejects-legacy-task-options`; SDK-generated related responses and `tasks/result` overwrite reserved related-task metadata from the source task id while preserving unrelated handler metadata. | Verified | +| Draft cacheable result and list stability | [Draft caching](https://modelcontextprotocol.io/specification/draft/server/utilities/caching), [Draft tools](https://modelcontextprotocol.io/specification/draft/server/tools) | Stateless `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, and `resources/read` cacheable results include `resultType`, `ttlMs`, and `cacheScope`; `tools/list` results are deterministic for client-side caching and omit stable-only `Tool.execution` metadata removed from the draft schema. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart) | Covered by stateless conformance until a released 2026 interop fixture is available. | `stateless.adds-result-type-and-cache-defaults`, `tools-list.stateless-returns-deterministic-order`, `tools-list.stateless-omits-legacy-execution` | Verified | +| Resource read error semantics | [Resources](https://modelcontextprotocol.io/specification/2025-11-25/server/resources), [Draft resources](https://modelcontextprotocol.io/specification/draft/server/resources) | Missing resources return the current stable resource-not-found code for legacy requests and draft `InvalidParams` (`-32602`) for 2026 stateless requests, without returning an ambiguous empty `contents` array. | [`test/server/mcp_server_test.dart`](../test/server/mcp_server_test.dart), [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart) | Covered by TypeScript interop resource read flows for successful reads. | `resources.missing-resource-error-code-by-version` | Verified | +| Draft request-scoped logging | [Draft logging](https://modelcontextprotocol.io/specification/draft/server/utilities/logging), [Draft schema request metadata](https://modelcontextprotocol.io/specification/draft/schema#requestmetaobject) | Stateless requests use `io.modelcontextprotocol/logLevel` as the per-request logging opt-in, removed `logging/setLevel` is rejected, and servers do not emit `notifications/message` unless the request opts in. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/server_advanced_test.dart`](../test/server/server_advanced_test.dart) | Covered by stateless conformance until a released 2026 interop fixture is available. | `stateless.rejects-removed-core-rpcs`, `logging.stateless-requires-request-log-level` | Verified | +| Draft notification subscriptions | [Draft subscriptions](https://modelcontextprotocol.io/specification/draft/server/utilities/subscriptions), [Draft schema subscriptions](https://modelcontextprotocol.io/specification/draft/schema#subscriptionslistenrequest) | `subscriptions/listen` requests require per-request `_meta`, acknowledged subscription filters include only supported notification types, and `notifications/subscriptions/acknowledged` typed parsing rejects mismatched JSON-RPC wrapper constants. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart) | Covered by stateless conformance until a released 2026 interop fixture is available. | `subscriptions-listen.requires-request-meta`, `subscriptions-listen.resource-subscriptions-require-capability`, `subscriptions-acknowledged.rejects-wrapper-mismatch` | Verified | | Progress token preservation and progress stream validation | [Progress](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress) | Progress tokens preserve string-or-integer wire shape, malformed token shapes fail at decode boundaries, and progress values should advance monotonically for a request. | [`test/types_edge_cases_test.dart`](../test/types_edge_cases_test.dart), [`test/shared/progress_test.dart`](../test/shared/progress_test.dart), [`test/shared/protocol_test.dart`](../test/shared/protocol_test.dart) | [`test/interop/dart_client_with_ts_server_features_test.dart`](../test/interop/dart_client_with_ts_server_features_test.dart), [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/test_dart_server.dart`](../test/interop/test_dart_server.dart) | `jsonrpc.preserves-string-progress-token`, `progress.rejects-malformed-progress-token`; `RequestHandlerExtra.sendProgress` rejects repeated/decreasing progress before sending invalid notifications. | Verified | -| Streamable HTTP sessions, stale recovery, SSE replay, and batch rejection | [Transports](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) | Session IDs, stale-session retry, initial SSE event IDs for resumability, `Last-Event-ID` replay, protocol-version headers, and JSON-RPC batch rejection follow Streamable HTTP semantics. | [`test/client/streamable_https_test.dart`](../test/client/streamable_https_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | [`test/interop/dart_client_with_ts_server_test.dart`](../test/interop/dart_client_with_ts_server_test.dart), [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/ts/src/replay_client.ts`](../test/interop/ts/src/replay_client.ts) | Covered by `dart test -t interop` and unit tests; no separate CLI raw-wire case yet because this requires HTTP server fixtures. | Verified | +| Streamable HTTP sessions, stateless connection-independence, stale recovery, SSE replay, and batch rejection | [Transports](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports), [Draft lifecycle](https://modelcontextprotocol.io/specification/draft/basic/lifecycle) | Session IDs, stale-session retry, initial SSE event IDs for resumability, `Last-Event-ID` replay, protocol-version headers, JSON-RPC batch rejection, and draft stateless connection-independence are covered by Streamable HTTP and stateless lifecycle checks. Draft HTTP GET/DELETE removal is enforced with `405 Method Not Allowed`, and draft stateless POST bodies are rejected unless they contain exactly one JSON-RPC message. | [`test/client/streamable_https_test.dart`](../test/client/streamable_https_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart), [`test/server/streamable_mcp_server_test.dart`](../test/server/streamable_mcp_server_test.dart) | [`test/interop/dart_client_with_ts_server_test.dart`](../test/interop/dart_client_with_ts_server_test.dart), [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/ts/src/replay_client.ts`](../test/interop/ts/src/replay_client.ts) | `stateless-http.rejects-non-post-methods`, `stateless-http.rejects-batch-payloads`; `stateless.related-task-uses-explicit-id-across-transports` covers draft related operations across separate transports. | Verified | | Auth/security deployment behavior | [Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization), [Transports security notes](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning) | OAuth, DNS rebinding, Origin/Host restrictions, and production deployment toggles are covered by executable harnesses where practical. OAuth authorization-code clients require authorization servers to advertise PKCE `S256`. | [`test/client/streamable_https_test.dart`](../test/client/streamable_https_test.dart), [`test/server/streamable_security_harness_test.dart`](../test/server/streamable_security_harness_test.dart), [`test/server/streamable_mcp_server_test.dart`](../test/server/streamable_mcp_server_test.dart), [`test/example/oauth_client_example_test.dart`](../test/example/oauth_client_example_test.dart), [`example/authentication/`](../example/authentication/), [`doc/transports.md`](transports.md) | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/ts/src/oauth_client.ts`](../test/interop/ts/src/oauth_client.ts) | Safe local-development and production Host/Origin scenarios, bearer-token gating, compatibility-toggle trade-offs, first-class OAuth protected-resource metadata/challenges, OAuth insufficient-scope 403 challenges, official TypeScript SDK upscoping, OAuth protected-resource discovery, PKCE S256 authorization redirect, resource-bound token exchange, missing-PKCE-metadata refusal, and bearer reconnect are covered by tests. | Verified | ## Stable Conformance Case Names @@ -52,14 +65,80 @@ without relying on output text: - `jsonrpc.rejects-invalid-version` - `jsonrpc.rejects-malformed-message` +- `jsonrpc.rejects-non-string-method` +- `jsonrpc.rejects-result-error-response` +- `jsonrpc.rejects-method-response-envelope` +- `jsonrpc.rejects-malformed-error-object` +- `jsonrpc.rejects-null-error-response-id` +- `jsonrpc.accepts-omitted-error-response-id` +- `jsonrpc.rejects-null-params-member` +- `tools-call.requires-params` - `jsonrpc.preserves-string-response-id` +- `jsonrpc.preserves-integer-response-id` - `jsonrpc.preserves-string-progress-token` +- `jsonrpc.preserves-integer-progress-token` +- `jsonrpc.rejects-fractional-ids-and-progress-tokens` - `protocol-version.advertises-latest-2025-11-25` - `lifecycle.rejects-pre-initialize-request` +- `lifecycle.gates-until-initialized-notification` +- `lifecycle.does-not-cancel-initialize` +- `cancellation.requires-request-id` - `capabilities.rejects-unnegotiated-sampling-tools` +- `capabilities.rejects-unnegotiated-sampling-context` +- `capabilities.unadvertised-peer-methods-use-method-not-found` +- `capabilities.task-scoped-peer-methods-use-method-not-found` - `elicitation.rejects-invalid-form-url-union` - `tasks.strips-unnegotiated-related-task-metadata` - `progress.rejects-malformed-progress-token` +- `progress.dispatches-integer-progress-token` + +The same CLI gate also includes draft MCP 2026-07-28 RC cases while that spec +is being prepared: + +- `protocol-version.advertises-draft-2026-07-28` +- `server-discover.requires-request-meta` +- `server-discover.returns-draft-capabilities` +- `protocol-version.rejects-unsupported-stateless-version` +- `stateless.requires-complete-request-meta` +- `protocol-version.http-modern-400-retries-discovery` +- `capabilities.http-modern-400-does-not-fallback` +- `protocol-version.initialize-negotiates-stateful-version` +- `capabilities.stateless-does-not-infer-initialize-extensions` +- `stateless-http.rejects-mismatched-routing-headers` +- `stateless-http.requires-routing-headers` +- `stateless-http.rejects-non-post-methods` +- `stateless-http.rejects-batch-payloads` +- `stateless-http.task-requests-require-name-header` +- `stateless-http.validates-parameter-headers` +- `stateless-http.omits-invalid-numeric-parameter-headers` +- `stateless-http.encodes-parameter-header-values` +- `stateless-http.accepts-response-posts` +- `stateless-http.task-subscription-requires-client-capability` +- `stateless-http.omits-session-header-after-initialize` +- `stateless.related-task-uses-explicit-id-across-transports` +- `stateless.ignores-legacy-task-parameter` +- `stateless-client.rejects-legacy-task-options` +- `stateless.adds-result-type-and-cache-defaults` +- `tools-list.stateless-returns-deterministic-order` +- `tools-list.stateless-omits-legacy-execution` +- `resources.missing-resource-error-code-by-version` +- `stateless.rejects-unrecognized-result-type` +- `mrtr.input-required-supported-requests` +- `mrtr.rejects-unsupported-input-required-results` +- `mrtr.input-requests-require-client-capabilities` +- `stateless.rejects-removed-core-rpcs` +- `stateless.rejects-removed-core-notifications` +- `logging.stateless-requires-request-log-level` +- `tasks-extension.lifecycle-methods-do-not-require-repeated-capability` +- `tasks-extension.task-store-uses-extension-result-shapes` +- `tasks-extension.call-tool-result-cannot-spoof-task-result` +- `tasks-extension.task-result-requires-client-extension` +- `subscriptions-listen.task-ids-require-client-capability` +- `subscriptions-listen.requires-request-meta` +- `subscriptions-listen.resource-subscriptions-require-capability` +- `subscriptions-acknowledged.rejects-wrapper-mismatch` +- `capabilities.stateless-omits-legacy-task-capabilities` +- `elicitation.accepts-numeric-number-schema-keywords` Use exact-case filtering when diagnosing one row: diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 506b6e8f..c97329a6 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -266,7 +266,7 @@ class McpClient extends Protocol { request.createParams.toolChoice != null) && _capabilities.sampling?.tools != true) { throw McpError( - ErrorCode.invalidRequest.value, + ErrorCode.methodNotFound.value, "Client does not support 'sampling.tools' capability required by sampling/createMessage request.", ); } @@ -510,6 +510,10 @@ class McpClient extends Protocol { if (error.code == ErrorCode.methodNotFound.value) { return true; } + if (error.code == ErrorCode.invalidParams.value && + error.message.contains('Invalid request parameters')) { + return true; + } final message = error.message; if (error.code == 0 && @@ -981,6 +985,15 @@ class McpClient extends Protocol { @override McpError? validateIncomingRequest(JsonRpcRequest request) { if (_usesStatelessProtocol) { + final missingPeerCapability = + _missingPeerCapabilityForIncomingRequest(request.method); + if (missingPeerCapability != null) { + return McpError( + ErrorCode.methodNotFound.value, + "Client does not support capability '$missingPeerCapability' " + "required for method '${request.method}'", + ); + } return McpError( ErrorCode.invalidRequest.value, 'Server-initiated JSON-RPC requests are not supported in stateless ' @@ -998,6 +1011,17 @@ class McpClient extends Protocol { ); } + String? _missingPeerCapabilityForIncomingRequest(String method) { + return switch (method) { + Method.rootsList => _capabilities.roots == null ? 'roots' : null, + Method.samplingCreateMessage => + _capabilities.sampling == null ? 'sampling' : null, + Method.elicitationCreate => + _capabilities.elicitation == null ? 'elicitation' : null, + _ => null, + }; + } + @override McpError? validateIncomingNotification(JsonRpcNotification notification) { if (_sentInitialized) { @@ -1111,7 +1135,7 @@ class McpClient extends Protocol { if (!supported) { throw McpError( - ErrorCode.invalidRequest.value, + ErrorCode.methodNotFound.value, "Server does not support capability '$requiredCapability' required for method '$method'", ); } @@ -1178,7 +1202,7 @@ class McpClient extends Protocol { if (missingCapability != null) { throw McpError( - ErrorCode.invalidRequest.value, + ErrorCode.methodNotFound.value, "Server does not support capability '$missingCapability' required for task-based '$method'", ); } diff --git a/lib/src/client/streamable_https.dart b/lib/src/client/streamable_https.dart index fdfe65ff..044f3c97 100644 --- a/lib/src/client/streamable_https.dart +++ b/lib/src/client/streamable_https.dart @@ -643,9 +643,11 @@ class StreamableHttpClientTransport } String? _toolParameterHeaderString(Object? value) { - final integer = _safeHeaderInteger(value); - if (integer != null) { - return integer.toString(); + if (value is int) { + if (value < _minSafeHeaderInteger || value > _maxSafeHeaderInteger) { + return null; + } + return value.toString(); } return switch (value) { @@ -655,25 +657,6 @@ class StreamableHttpClientTransport }; } - int? _safeHeaderInteger(Object? value) { - if (value is int) { - if (value < _minSafeHeaderInteger || value > _maxSafeHeaderInteger) { - return null; - } - return value; - } - - if (value is double && - value.isFinite && - value.truncateToDouble() == value && - value >= _minSafeHeaderInteger && - value <= _maxSafeHeaderInteger) { - return value.toInt(); - } - - return null; - } - String _encodeToolParameterHeaderValue(String value) { if (_isPlainToolParameterHeaderValue(value)) { return value; @@ -754,9 +737,9 @@ class StreamableHttpClientTransport Method.toolsCall => params['name'], Method.resourcesRead => params['uri'], Method.promptsGet => params['name'], - Method.tasksCancel || Method.tasksGet || - Method.tasksUpdate => + Method.tasksUpdate || + Method.tasksCancel => params['taskId'], _ => null, }; @@ -1295,6 +1278,13 @@ class StreamableHttpClientTransport } return; } + if (_dispatchHttpJsonRpcErrorBody( + text, + message, + rejectServerRequests: isStatelessRequest, + )) { + return; + } throw McpError( 0, "Error POSTing to endpoint (HTTP ${response.statusCode}): $text", @@ -1397,6 +1387,43 @@ class StreamableHttpClientTransport } } + bool _dispatchHttpJsonRpcErrorBody( + String body, + JsonRpcMessage requestMessage, { + required bool rejectServerRequests, + }) { + if (requestMessage is! JsonRpcRequest || body.trim().isEmpty) { + return false; + } + + try { + final decoded = jsonDecode(body); + final responseCandidates = decoded is List ? decoded : [decoded]; + var dispatched = false; + + for (final candidate in responseCandidates) { + if (candidate is! Map) { + continue; + } + final parsed = JsonRpcMessage.fromJson( + candidate.cast(), + ); + if (parsed is! JsonRpcError || parsed.id != requestMessage.id) { + continue; + } + _dispatchReceivedMessage( + parsed, + rejectServerRequests: rejectServerRequests, + ); + dispatched = true; + } + + return dispatched; + } catch (_) { + return false; + } + } + @override String? get sessionId => _sessionId; diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 726e2c01..543222fc 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -25,6 +25,10 @@ class McpServerOptions extends ProtocolOptions { const McpServerOptions({ super.enforceStrictCapabilities, + super.taskStore, + super.taskMessageQueue, + super.defaultTaskPollInterval, + super.maxTaskQueueSize, this.capabilities, this.instructions, }); @@ -256,9 +260,11 @@ class Server extends Protocol { return McpError( ErrorCode.missingRequiredClientCapability.value, 'Missing required client capability', - { + const { 'requiredCapabilities': { - 'extensions': {mcpTasksExtensionId: {}}, + 'extensions': { + mcpTasksExtensionId: {}, + }, }, }, ); @@ -402,12 +408,8 @@ class Server extends Protocol { McpError? _validateTasksExtensionCapabilities(JsonRpcRequest request) { final requiresTasksExtension = - (request is JsonRpcSubscriptionsListenRequest && - request.listenParams.notifications.taskIds != null) || - (_isStatelessRequest(request) && - (request.method == Method.tasksGet || - request.method == Method.tasksCancel || - request.method == Method.tasksUpdate)); + request is JsonRpcSubscriptionsListenRequest && + request.listenParams.notifications.taskIds != null; if (!requiresTasksExtension) { return null; @@ -529,21 +531,112 @@ class Server extends Protocol { return null; } - bool _allowsToolCallResult(BaseResultData result, JsonRpcRequest request) { + Future _allowsToolCallResult( + BaseResultData result, + JsonRpcRequest request, + RequestHandlerExtra extra, + ) async { if (result is CallToolResult) { + _validateCallToolResult(result, request); return true; } if (_allowsInputRequiredResult(result, request)) { return true; } if (result is CreateTaskExtensionResult && _isStatelessRequest(request)) { - _assertTasksExtensionClientCapability(request); + await _validateTaskCreationResult(result, request, extra); return true; } return false; } + void _validateCallToolResult( + CallToolResult result, + JsonRpcRequest request, + ) { + if (!_isStatelessRequest(request)) { + return; + } + + final resultType = result.extra?['resultType']; + if (resultType == null || resultType == resultTypeComplete) { + return; + } + + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid ${request.method} result: CallToolResult cannot set MCP ' + 'resultType "$resultType"; use InputRequiredResult or ' + 'CreateTaskExtensionResult.', + ); + } + + Future _validateTaskCreationResult( + CreateTaskExtensionResult result, + JsonRpcRequest request, + RequestHandlerExtra extra, + ) async { + if (!_capabilities.supportsTasksExtension) { + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid ${request.method} result: CreateTaskExtensionResult requires ' + 'server support for $mcpTasksExtensionId.', + ); + } + + _assertTasksExtensionClientCapability(request); + + if (!canHandleRequestMethod(Method.tasksGet)) { + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid ${request.method} result: CreateTaskExtensionResult requires ' + 'a tasks/get handler so ${result.task.taskId} can be resolved.', + ); + } + + final resolvedResult = await _resolveCreatedTask(result, request, extra); + if (resolvedResult is! GetTaskExtensionResult) { + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid ${request.method} result: tasks/get for ' + '${result.task.taskId} must return GetTaskExtensionResult.', + ); + } + if (resolvedResult.task.taskId != result.task.taskId) { + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid ${request.method} result: tasks/get resolved ' + '${resolvedResult.task.taskId} instead of ${result.task.taskId}.', + ); + } + } + + Future _resolveCreatedTask( + CreateTaskExtensionResult result, + JsonRpcRequest request, + RequestHandlerExtra extra, + ) async { + try { + return await invokeRequestHandlerForValidation( + JsonRpcGetTaskRequest( + id: request.id, + getParams: GetTaskRequest(taskId: result.task.taskId), + meta: request.meta, + ), + extra, + ); + } catch (error) { + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid ${request.method} result: CreateTaskExtensionResult taskId ' + '${result.task.taskId} must be resolvable by tasks/get before ' + 'returning.', + error.toString(), + ); + } + } + bool _allowsPromptGetResult(BaseResultData result, JsonRpcRequest request) { return result is GetPromptResult || _allowsInputRequiredResult(result, request); @@ -773,6 +866,20 @@ class Server extends Protocol { }; } + void _omitStatelessLegacyToolExecution( + Map resultJson, + ) { + final tools = resultJson['tools']; + if (tools is! List) { + return; + } + for (final tool in tools) { + if (tool is Map) { + tool.remove('execution'); + } + } + } + @override Map serializeIncomingResult( JsonRpcRequest request, @@ -783,6 +890,10 @@ class Server extends Protocol { return json; } + if (request.method == Method.toolsList) { + _omitStatelessLegacyToolExecution(json); + } + json.putIfAbsent('resultType', () => resultTypeComplete); if (_requiresCacheableResult(request.method)) { json.putIfAbsent( @@ -860,7 +971,7 @@ class Server extends Protocol { ); } } else { - if (!_allowsToolCallResult(result, request)) { + if (!await _allowsToolCallResult(result, request, extra)) { throw McpError( ErrorCode.invalidParams.value, "Invalid tools/call result: Expected CallToolResult", @@ -985,7 +1096,7 @@ class Server extends Protocol { case Method.samplingCreateMessage: if (!(_clientCapabilities?.sampling != null)) { throw McpError( - ErrorCode.invalidRequest.value, + ErrorCode.methodNotFound.value, "Client does not support sampling (required for server to send $method)", ); } @@ -994,7 +1105,7 @@ class Server extends Protocol { case Method.rootsList: if (!(_clientCapabilities?.roots != null)) { throw McpError( - ErrorCode.invalidRequest.value, + ErrorCode.methodNotFound.value, "Client does not support listing roots (required for server to send $method)", ); } @@ -1003,7 +1114,7 @@ class Server extends Protocol { case Method.elicitationCreate: if (!(_clientCapabilities?.elicitation != null)) { throw McpError( - ErrorCode.invalidRequest.value, + ErrorCode.methodNotFound.value, "Client does not support elicitation (required for server to send $method)", ); } @@ -1168,9 +1279,10 @@ class Server extends Protocol { case Method.tasksList: case Method.tasksResult: - if (!(_capabilities.tasks != null)) { + if (!(_capabilities.tasks != null || + _capabilities.supportsTasksExtension)) { throw StateError( - "Server setup error: Cannot handle '$method' without 'tasks' capability", + "Server setup error: Cannot handle '$method' without 'tasks' capability or '$mcpTasksExtensionId' extension", ); } break; @@ -1217,7 +1329,7 @@ class Server extends Protocol { if (missingCapability != null) { throw McpError( - ErrorCode.invalidRequest.value, + ErrorCode.methodNotFound.value, "Client does not support capability '$missingCapability' required for task-based '$method'", ); } @@ -1257,11 +1369,19 @@ class Server extends Protocol { if (params.tools != null || params.toolChoice != null) { if (!(_clientCapabilities?.sampling?.tools ?? false)) { throw McpError( - ErrorCode.invalidRequest.value, + ErrorCode.methodNotFound.value, "Client does not support sampling tools capability.", ); } } + if (params.includeContext != null && + params.includeContext != IncludeContext.none && + !(_clientCapabilities?.sampling?.context ?? false)) { + throw McpError( + ErrorCode.methodNotFound.value, + "Client does not support sampling context capability.", + ); + } // Message structure validation - always validate tool_use/tool_result pairs. if (params.messages.isNotEmpty) { diff --git a/lib/src/server/streamable_https.dart b/lib/src/server/streamable_https.dart index b0994a79..101f497a 100644 --- a/lib/src/server/streamable_https.dart +++ b/lib/src/server/streamable_https.dart @@ -10,8 +10,6 @@ import '../shared/transport.dart'; import '../types.dart'; import 'dns_rebinding_protection.dart'; -const int _maxSafeHeaderInteger = 9007199254740991; -const int _minSafeHeaderInteger = -9007199254740991; const String _xAccelBufferingHeader = 'X-Accel-Buffering'; /// ID for SSE streams @@ -497,9 +495,9 @@ class StreamableHTTPServerTransport Method.toolsCall => params['name'], Method.resourcesRead => params['uri'], Method.promptsGet => params['name'], - Method.tasksCancel || Method.tasksGet || - Method.tasksUpdate => + Method.tasksUpdate || + Method.tasksCancel => params['taskId'], _ => null, }; @@ -520,9 +518,14 @@ class StreamableHTTPServerTransport } String? _primitiveHeaderString(Object? value) { - final integer = _safeHeaderInteger(value); - if (integer != null) { - return integer.toString(); + if (value is num) { + if (!value.isFinite) { + return null; + } + if (value is double && value.truncateToDouble() == value) { + return value.toInt().toString(); + } + return value.toString(); } return switch (value) { @@ -533,30 +536,15 @@ class StreamableHTTPServerTransport }; } - int? _safeHeaderInteger(Object? value) { - if (value is int) { - if (value < _minSafeHeaderInteger || value > _maxSafeHeaderInteger) { - return null; - } - return value; - } - - if (value is double && - value.isFinite && - value.truncateToDouble() == value && - value >= _minSafeHeaderInteger && - value <= _maxSafeHeaderInteger) { - return value.toInt(); - } - - return null; - } - bool _headerValueMatchesPrimitive(Object? bodyValue, String headerValue) { - final integer = _safeHeaderInteger(bodyValue); - if (integer != null) { - final headerInteger = _safeHeaderInteger(num.tryParse(headerValue)); - return headerInteger != null && headerInteger == integer; + if (bodyValue is num) { + if (!bodyValue.isFinite) { + return false; + } + final headerNumber = num.tryParse(headerValue); + return headerNumber != null && + headerNumber.isFinite && + headerNumber == bodyValue; } final value = _primitiveHeaderString(bodyValue); @@ -805,6 +793,10 @@ class StreamableHTTPServerTransport return false; } + if (message is JsonRpcResponse || message is JsonRpcError) { + return true; + } + final metadataVersion = _nestedMetadataProtocolVersion(messageJson); if (metadataVersion == null) { await _writeHeaderMismatchResponse( @@ -1392,6 +1384,8 @@ class StreamableHTTPServerTransport } } + final usesStatelessHttpValidation = + _usesStatelessHttpValidation(req, messages); if (!await _validateStatelessHttpHeaders(req, messages, messageJsons)) { return; } @@ -1400,6 +1394,8 @@ class StreamableHTTPServerTransport // https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle/ final isInitializationRequest = messages.any(_isInitializeRequest); final isStatelessRequest = messages.any(_isStatelessJsonRpcRequest); + final isStatelessMessage = + usesStatelessHttpValidation || isStatelessRequest; if (isInitializationRequest) { final requestSessionId = req.headers.value('mcp-session-id'); @@ -1475,7 +1471,7 @@ class StreamableHTTPServerTransport // clients using the Streamable HTTP transport MUST include it // in the Mcp-Session-Id header on all of their subsequent HTTP requests. if (!isInitializationRequest && - !isStatelessRequest && + !isStatelessMessage && !await _validateSession(req, req.response)) { return; } @@ -1506,7 +1502,7 @@ class StreamableHTTPServerTransport final headers = _sseResponseHeaders(); // After initialization, always include the session ID if we have one - if (sessionId != null) { + if (sessionId != null && !isStatelessRequest) { headers["mcp-session-id"] = sessionId!; } @@ -1860,7 +1856,10 @@ class StreamableHTTPServerTransport HttpHeaders.contentTypeHeader: 'application/json; charset=utf-8', }; - if (sessionId != null) { + final isStatelessResponse = relatedIds.any( + (id) => _statelessRequestIds.contains(id), + ); + if (sessionId != null && !isStatelessResponse) { headers['mcp-session-id'] = sessionId!; } diff --git a/lib/src/server/streamable_mcp_server.dart b/lib/src/server/streamable_mcp_server.dart index 9cdb615e..d0d711b1 100644 --- a/lib/src/server/streamable_mcp_server.dart +++ b/lib/src/server/streamable_mcp_server.dart @@ -258,6 +258,12 @@ class StreamableMcpServer { /// Port to bind the HTTP server to. final int port; + /// Port currently bound by the HTTP server. + /// + /// This differs from [port] when the server was configured with `port: 0` + /// and the operating system selected an available port during [start]. + int get boundPort => _httpServer?.port ?? port; + /// Path to listen for MCP requests on. final String path; @@ -334,7 +340,7 @@ class StreamableMcpServer { _httpServer = await HttpServer.bind(host, port); _logger.info( - 'MCP Streamable HTTP Server listening on http://$host:$port$path', + 'MCP Streamable HTTP Server listening on http://$host:$boundPort$path', ); final httpServer = _httpServer; diff --git a/lib/src/shared/json_schema/json_schema.dart b/lib/src/shared/json_schema/json_schema.dart index c9e0b927..731b7825 100644 --- a/lib/src/shared/json_schema/json_schema.dart +++ b/lib/src/shared/json_schema/json_schema.dart @@ -32,14 +32,35 @@ sealed class JsonSchema { } static JsonSchema _fromJson(Map json) { - if (_hasMcpHeaderOnNonPrimitiveSchema(json)) { - return JsonAny.fromJson(json); - } - if (JsonEnum._canParse(json)) { return JsonEnum.fromJson(json); } + final type = json['type']; + if (json.containsKey('type')) { + if (type is List) { + if (!_isValidJsonTypeArray(type)) { + throw const FormatException( + 'JsonSchema.type must be a non-empty array of unique JSON Schema type strings', + ); + } + } else if (type is String) { + if (!_knownJsonTypes.contains(type)) { + throw FormatException( + "JsonSchema.type '$type' is not a supported JSON Schema type", + ); + } + } else { + throw const FormatException( + 'JsonSchema.type must be a string or array of strings', + ); + } + } + + if (_hasMcpHeaderOnNonPrimitiveSchema(json)) { + return JsonAny.fromJson(json); + } + final conjunctiveSchema = _splitConjunctiveSchema(json); if (conjunctiveSchema != null) { return conjunctiveSchema; @@ -61,11 +82,7 @@ sealed class JsonSchema { return JsonNot.fromJson(json); } - final type = json['type']; if (type is List) { - if (!_isValidJsonTypeArray(type)) { - return JsonAny.fromJson(json); - } return JsonUnion.fromJson(json); } if (type is String) { @@ -561,7 +578,11 @@ class JsonNumber extends JsonSchema { final num? exclusiveMaximum; final num? multipleOf; - /// MCP `x-mcp-header` extension for mirroring this parameter into HTTP. + /// MCP `x-mcp-header` extension metadata. + /// + /// This is preserved for schema round-tripping. MCP 2026 stateless + /// Streamable HTTP header mirroring only accepts string, integer, and boolean + /// schemas, so number schemas carrying this metadata are not mirrored. final String? mcpHeader; const JsonNumber({ diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 813fd086..d4717c86 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -128,6 +128,34 @@ class RequestHandlerExtra { /// Metadata from the original request. final Map? meta; + /// MCP protocol version from the request metadata, when present. + String? get protocolVersion { + final value = meta?[McpMetaKey.protocolVersion]; + return value is String ? value : null; + } + + /// Client implementation from the request metadata, when present. + Implementation? get clientInfo { + final value = meta?[McpMetaKey.clientInfo]; + if (value == null) { + return null; + } + return Implementation.fromJson( + readJsonObject(value, 'RequestHandlerExtra.clientInfo'), + ); + } + + /// Client capabilities from the request metadata, when present. + ClientCapabilities? get clientCapabilities { + final value = meta?[McpMetaKey.clientCapabilities]; + if (value == null) { + return null; + } + return ClientCapabilities.fromJson( + readJsonObject(value, 'RequestHandlerExtra.clientCapabilities'), + ); + } + /// Information about a validated access token. final AuthInfo? authInfo; @@ -589,6 +617,14 @@ abstract class Protocol { 'Failed to retrieve task: Task not found', ); } + if (_usesStatelessResultTypes(request)) { + return GetTaskExtensionResult( + task: await _taskExtensionTaskFromStore( + task, + extra.sessionId, + ), + ); + } return task; }, (id, params, meta) => JsonRpcGetTaskRequest.fromJson({ @@ -662,6 +698,9 @@ abstract class Protocol { 'Task not found after cancellation: $taskId', ); } + if (_usesStatelessResultTypes(request)) { + return const TaskExtensionAcknowledgementResult(); + } return cancelledTask; } catch (error) { if (error is McpError) rethrow; @@ -682,6 +721,50 @@ abstract class Protocol { ); } + Future _taskExtensionTaskFromStore( + Task task, + String? sessionId, + ) async { + Map? result; + JsonRpcErrorData? error; + InputRequests? inputRequests; + + switch (task.status) { + case TaskStatus.completed: + result = (await _taskStore!.getTaskResult( + task.taskId, + sessionId, + )) + .toJson(); + break; + case TaskStatus.failed: + error = JsonRpcErrorData( + code: ErrorCode.internalError.value, + message: task.statusMessage ?? 'Task failed', + ); + break; + case TaskStatus.inputRequired: + inputRequests = const {}; + break; + case TaskStatus.working: + case TaskStatus.cancelled: + break; + } + + return TaskExtensionTask( + taskId: task.taskId, + status: task.status, + statusMessage: task.statusMessage, + createdAt: task.createdAt, + lastUpdatedAt: task.lastUpdatedAt, + ttlMs: task.ttl, + pollIntervalMs: task.pollInterval, + inputRequests: inputRequests, + result: result, + error: error, + ); + } + /// Attaches to the given transport, starts it, and starts listening for messages. Future connect(Transport transport) async { if (_transport != null) { @@ -695,7 +778,7 @@ abstract class Protocol { validateIncomingRequest, ); validationAwareTransport - .setRequestMethodSupported(_supportsRequestMethod); + .setRequestMethodSupported(canHandleRequestMethod); } _transport!.onclose = _onclose; _transport!.onerror = _onerror; @@ -733,9 +816,31 @@ abstract class Protocol { } } - bool _supportsRequestMethod(String method) => + @protected + bool canHandleRequestMethod(String method) => _requestHandlers.containsKey(method) || fallbackRequestHandler != null; + @protected + Future invokeRequestHandlerForValidation( + JsonRpcRequest request, + RequestHandlerExtra extra, + ) { + final registeredHandler = _requestHandlers[request.method]; + if (registeredHandler != null) { + return registeredHandler(request, extra); + } + + final fallbackHandler = fallbackRequestHandler; + if (fallbackHandler != null) { + return fallbackHandler(request); + } + + throw McpError( + ErrorCode.methodNotFound.value, + 'Method not found: ${request.method}', + ); + } + /// Gets the currently attached transport, or null if not connected. Transport? get transport => _transport; @@ -951,6 +1056,23 @@ abstract class Protocol { return token; } + bool _usesStatelessRequestShape(JsonRpcRequest request) { + final requestProtocolVersion = request.meta?[McpMetaKey.protocolVersion]; + if (requestProtocolVersion is String) { + return isStatelessProtocolVersion(requestProtocolVersion); + } + + final activeTransport = _transport; + if (activeTransport is! ProtocolVersionAwareTransport) { + return false; + } + final versionAwareTransport = + activeTransport as ProtocolVersionAwareTransport; + final transportProtocolVersion = versionAwareTransport.protocolVersion; + return transportProtocolVersion != null && + isStatelessProtocolVersion(transportProtocolVersion); + } + Map? _mergeRelatedTaskMeta( Map? meta, Map? relatedTaskJson, @@ -1355,10 +1477,13 @@ abstract class Protocol { final subscriptionState = request is JsonRpcSubscriptionsListenRequest ? _SubscriptionStreamState() : null; + final usesStatelessResultTypes = _usesStatelessResultTypes(request); + final requestSessionId = + usesStatelessResultTypes ? null : _transport?.sessionId; final extra = RequestHandlerExtra( signal: abortController.signal, - sessionId: _transport?.sessionId, + sessionId: requestSessionId, requestId: request.id, meta: request.meta, taskId: relatedTaskId, @@ -1366,14 +1491,16 @@ abstract class Protocol { ? _RequestTaskStoreImpl( _taskStore!, request, - _transport?.sessionId, + requestSessionId, this, ) : null, - taskRequestedTtl: readOptionalInteger( - (request.params?['task'] as Map?)?['ttl'], - 'RequestOptions.task.ttl', - ), + taskRequestedTtl: usesStatelessResultTypes + ? null + : readOptionalInteger( + (request.params?['task'] as Map?)?['ttl'], + 'RequestOptions.task.ttl', + ), sendNotification: (notification, {relatedTask}) { var outgoingNotification = notification; if (subscriptionState != null) { @@ -1421,8 +1548,9 @@ abstract class Protocol { } // If task creation is requested, check capability - if (extra.taskRequestedTtl != null || - request.params?.containsKey('task') == true) { + if (!usesStatelessResultTypes && + (extra.taskRequestedTtl != null || + request.params?.containsKey('task') == true)) { try { assertTaskHandlerCapability(request.method); } catch (e) { @@ -1445,7 +1573,7 @@ abstract class Protocol { relatedTaskId, TaskStatus.inputRequired, null, - _transport?.sessionId, + requestSessionId, ); } @@ -1749,6 +1877,17 @@ abstract class Protocol { Map? finalMeta = requestData.meta; Map? finalParams = requestData.params; + final usesStatelessRequestShape = _usesStatelessRequestShape(requestData); + + if (usesStatelessRequestShape && options?.task != null) { + return Future.error( + McpError( + ErrorCode.invalidRequest.value, + 'RequestOptions.task is not supported for stateless MCP requests; ' + 'use the $mcpTasksExtensionId extension flow instead.', + ), + ); + } if (options?.onprogress != null) { final currentMeta = Map.from(finalMeta ?? {}); @@ -1854,6 +1993,12 @@ abstract class Protocol { _cleanupProgressHandler(messageId); _cleanupTimeout(messageId); + // MCP 2025-11-25 forbids clients from cancelling `initialize`. + if (jsonrpcRequest.method == Method.initialize) { + completer.completeError(errorReason); + return; + } + final cancelReason = reason?.toString() ?? 'Request cancelled'; final notification = JsonRpcCancelledNotification( cancelParams: CancelledNotification( diff --git a/lib/src/types.dart b/lib/src/types.dart index 7f392fc4..a8147418 100644 --- a/lib/src/types.dart +++ b/lib/src/types.dart @@ -6,6 +6,7 @@ export 'types/tools.dart'; export 'types/tasks.dart'; export 'types/json_rpc.dart' hide + expectJsonRpcMethod, extractRequestMeta, parseProgressToken, parseRequestId, diff --git a/lib/src/types/completion.dart b/lib/src/types/completion.dart index 8b5d971b..a67e302a 100644 --- a/lib/src/types/completion.dart +++ b/lib/src/types/completion.dart @@ -6,14 +6,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } void _expectType( @@ -319,8 +312,16 @@ class JsonRpcCompletionListChangedNotification extends JsonRpcNotification { factory JsonRpcCompletionListChangedNotification.fromJson( Map json, - ) => - JsonRpcCompletionListChangedNotification(meta: extractRequestMeta(json)); + ) { + _expectJsonRpcMethod( + json, + Method.notificationsExperimentalCompletionsListChanged, + 'JsonRpcCompletionListChangedNotification', + ); + return JsonRpcCompletionListChangedNotification( + meta: extractRequestMeta(json), + ); + } } /// Deprecated alias for [CompleteRequest]. diff --git a/lib/src/types/elicitation.dart b/lib/src/types/elicitation.dart index 6499b826..d59e14ee 100644 --- a/lib/src/types/elicitation.dart +++ b/lib/src/types/elicitation.dart @@ -8,15 +8,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } /// Legacy alias for [JsonSchema] used in elicitation requests. @@ -577,11 +569,7 @@ void _validatePrimitiveSchema( context, ); _validatePrimitiveBaseKeywords(json, context); - _validateNumberSchemaKeywords( - json, - context, - protocolVersion: protocolVersion, - ); + _validateNumberSchemaKeywords(json, context); return; case 'boolean': _ensureAllowedKeys( @@ -614,20 +602,10 @@ void _validatePrimitiveBaseKeywords( void _validateNumberSchemaKeywords( Map json, - String context, { - String? protocolVersion, -}) { - if (!_usesDraftNumberSchemaKeywords(protocolVersion)) { - for (final key in const ['default', 'minimum', 'maximum']) { - _validateOptionalIntegerKeyword(json, key, context); - } - return; - } - + String context, +) { for (final key in const ['default', 'minimum', 'maximum']) { - if (json[key] != null) { - readFiniteNumber(json[key], '$context.$key'); - } + readOptionalFiniteNumber(json[key], '$context.$key'); } } @@ -804,10 +782,6 @@ String? _protocolVersionFromMeta(Map? meta) { return protocolVersion is String ? protocolVersion : null; } -bool _usesDraftNumberSchemaKeywords(String? protocolVersion) { - return protocolVersion != null && isStatelessProtocolVersion(protocolVersion); -} - Map? _parseElicitResultContent(Object? content) { if (content == null) { return null; diff --git a/lib/src/types/initialization.dart b/lib/src/types/initialization.dart index 9fb1e156..352c49b0 100644 --- a/lib/src/types/initialization.dart +++ b/lib/src/types/initialization.dart @@ -32,15 +32,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } void _readOptionalParamsObject(Map json, String field) { @@ -333,8 +325,8 @@ class ClientCapabilitiesRoots { ); } - Map toJson() => { - if (listChanged != null) 'listChanged': listChanged, + Map toJson({bool omitListChanged = false}) => { + if (!omitListChanged && listChanged != null) 'listChanged': listChanged, }; } @@ -645,7 +637,7 @@ class ClientCapabilities { /// Present if the client supports tasks (`tasks/list`, `tasks/requests`, etc). final ClientCapabilitiesTasks? tasks; - /// Optional MCP extension capabilities (SEP-1724). + /// Optional MCP extension capabilities. /// /// Keys are extension identifiers (e.g. `"io.modelcontextprotocol/ui"`), /// values are extension-specific settings. @@ -704,16 +696,23 @@ class ClientCapabilities { ); } - Map toJson() => { + Map toJson({ + bool omitLegacyTasks = false, + bool omitLegacyRootsListChanged = false, + }) => + { if (experimental != null) 'experimental': _serializeJsonObjectMap( experimental, 'ClientCapabilities.experimental', ), if (sampling != null) 'sampling': sampling!.toJson(), - if (roots != null) 'roots': roots!.toJson(), + if (roots != null) + 'roots': roots!.toJson( + omitListChanged: omitLegacyRootsListChanged, + ), if (elicitation != null) 'elicitation': elicitation!.toJson(), - if (tasks != null) 'tasks': tasks!.toJson(), + if (!omitLegacyTasks && tasks != null) 'tasks': tasks!.toJson(), if (extensions != null) 'extensions': _serializeExtensionMap( extensions, @@ -951,7 +950,10 @@ class ServerCapabilitiesPrompts { /// Describes capabilities related to resources. class ServerCapabilitiesResources { - /// Whether the server supports `resources/subscribe` and `resources/unsubscribe`. + /// Whether the server supports resource update subscriptions. + /// + /// MCP 2025 uses `resources/subscribe` and `resources/unsubscribe`; MCP 2026 + /// uses `subscriptions/listen` with `resourceSubscriptions`. final bool? subscribe; /// Whether the server supports `notifications/resources/list_changed`. @@ -1177,7 +1179,7 @@ class ServerCapabilities { ) final ServerCapabilitiesElicitation? elicitation; - /// Optional MCP extension capabilities (SEP-1724). + /// Optional MCP extension capabilities. /// /// Keys are extension identifiers (e.g. `"io.modelcontextprotocol/ui"`), /// values are extension-specific settings. @@ -1249,7 +1251,7 @@ class ServerCapabilities { ); } - Map toJson() => { + Map toJson({bool omitLegacyTasks = false}) => { if (experimental != null) 'experimental': _serializeJsonObjectMap( experimental, @@ -1261,7 +1263,7 @@ class ServerCapabilities { if (resources != null) 'resources': resources!.toJson(), if (tools != null) 'tools': tools!.toJson(), if (completions != null) 'completions': completions!.toJson(), - if (tasks != null) 'tasks': tasks!.toJson(), + if (!omitLegacyTasks && tasks != null) 'tasks': tasks!.toJson(), if (extensions != null) 'extensions': _serializeExtensionMap( extensions, @@ -1421,7 +1423,7 @@ class DiscoverResult implements BaseResultData { return { 'resultType': resultType, 'supportedVersions': supportedVersions, - 'capabilities': capabilities.toJson(), + 'capabilities': capabilities.toJson(omitLegacyTasks: true), 'serverInfo': serverInfo.toJson(), if (instructions != null) 'instructions': instructions, if (meta != null) '_meta': readJsonObject(meta, 'DiscoverResult._meta'), diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index adc50e66..fe4c3b11 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -87,7 +87,10 @@ Map buildProtocolRequestMeta({ ...?meta, McpMetaKey.protocolVersion: protocolVersion, McpMetaKey.clientInfo: clientInfo.toJson(), - McpMetaKey.clientCapabilities: clientCapabilities.toJson(), + McpMetaKey.clientCapabilities: clientCapabilities.toJson( + omitLegacyTasks: isStatelessProtocolVersion(protocolVersion), + omitLegacyRootsListChanged: isStatelessProtocolVersion(protocolVersion), + ), if (logLevel != null) McpMetaKey.logLevel: logLevel, }; } @@ -336,20 +339,42 @@ Map? extractRequestMeta(Map json) { return paramsMeta ?? topLevelMeta; } -void _expectJsonRpcMethod( - Map json, - String expected, - String context, -) { +void _expectJsonRpcVersion(Map json, String context) { final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); if (version != jsonRpcVersion) { throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); } +} + +/// Validates the JSON-RPC wrapper fields for a typed request or notification. +/// +/// This is hidden from the public `mcp_dart` export surface but shared by the +/// typed protocol modules so direct parser calls enforce the same envelope +/// constraints as [JsonRpcMessage.fromJson]. +void expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + _expectJsonRpcVersion(json, context); final method = readRequiredString(json['method'], '$context.method'); if (method != expected) { throw FormatException('$context.method must be "$expected"'); } + if (json.containsKey('result') || json.containsKey('error')) { + throw const FormatException( + 'Invalid JSON-RPC message: method cannot be combined with result or error', + ); + } +} + +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + expectJsonRpcMethod(json, expected, context); } /// Base class for all JSON-RPC messages (requests, notifications, responses, errors). @@ -366,10 +391,22 @@ sealed class JsonRpcMessage { throw FormatException('Invalid JSON-RPC version: ${json['jsonrpc']}'); } + final hasMethod = json.containsKey('method'); final hasResult = json.containsKey('result'); final hasError = json.containsKey('error'); - if (json.containsKey('method')) { + if (hasResult && hasError) { + throw const FormatException( + 'Invalid JSON-RPC response: result and error are mutually exclusive', + ); + } + if (hasMethod && (hasResult || hasError)) { + throw const FormatException( + 'Invalid JSON-RPC message: method cannot be combined with result or error', + ); + } + + if (hasMethod) { final method = _parseMethod(json['method']); final hasId = json.containsKey('id'); final params = _parseOptionalParamsObject( @@ -455,10 +492,6 @@ sealed class JsonRpcMessage { ), }; } - } else if (hasResult && hasError) { - throw const FormatException( - 'Invalid JSON-RPC response: result and error are mutually exclusive', - ); } else if (hasResult) { final id = _parseResultResponseId(json['id']); final resultData = @@ -656,12 +689,26 @@ class JsonRpcError extends JsonRpcMessage { const JsonRpcError({required this.id, required this.error}); - factory JsonRpcError.fromJson(Map json) => JsonRpcError( - id: _parseErrorResponseId(json), - error: JsonRpcErrorData.fromJson( - readJsonObject(json['error'], 'JsonRpcError.error'), - ), + factory JsonRpcError.fromJson(Map json) { + _expectJsonRpcVersion(json, 'JsonRpcError'); + if (json.containsKey('method')) { + throw const FormatException( + 'Invalid JSON-RPC error response: method cannot be combined with error', + ); + } + if (json.containsKey('result')) { + throw const FormatException( + 'Invalid JSON-RPC error response: result and error are mutually exclusive', ); + } + + return JsonRpcError( + id: _parseErrorResponseId(json), + error: JsonRpcErrorData.fromJson( + readJsonObject(json['error'], 'JsonRpcError.error'), + ), + ); + } @override Map toJson() => { @@ -738,7 +785,7 @@ class InputRequest { /// Creates an embedded `sampling/createMessage` input request. factory InputRequest.createMessage(CreateMessageRequest params) { - final inputParams = params.toJson()..remove('task'); + final inputParams = params.toJson(omitToolExecution: true)..remove('task'); return InputRequest._( method: Method.samplingCreateMessage, params: inputParams, @@ -1104,13 +1151,18 @@ class JsonRpcCallToolRequest extends JsonRpcRequest { factory JsonRpcCallToolRequest.fromJson(Map json) { _expectJsonRpcMethod(json, Method.toolsCall, 'JsonRpcCallToolRequest'); + final paramsMap = readOptionalJsonObject( + json['params'], + 'JsonRpcCallToolRequest.params', + ); + if (paramsMap == null) { + throw const FormatException( + 'JsonRpcCallToolRequest.params is required', + ); + } return JsonRpcCallToolRequest( id: parseRequestId(json['id']), - params: readOptionalJsonObject( - json['params'], - 'JsonRpcCallToolRequest.params', - ) ?? - {}, + params: paramsMap, meta: extractRequestMeta(json), ); } diff --git a/lib/src/types/logging.dart b/lib/src/types/logging.dart index 5657b588..87789783 100644 --- a/lib/src/types/logging.dart +++ b/lib/src/types/logging.dart @@ -6,15 +6,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } /// Severity levels for log messages (syslog levels). diff --git a/lib/src/types/misc.dart b/lib/src/types/misc.dart index 53b22cbe..66ab94d5 100644 --- a/lib/src/types/misc.dart +++ b/lib/src/types/misc.dart @@ -6,15 +6,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } void _readOptionalParamsObject(Map json, String field) { @@ -44,18 +36,16 @@ class EmptyResult implements BaseResultData { /// Parameters for the `notifications/cancelled` notification. class CancelledNotification { /// The ID of the request to cancel. - final RequestId? requestId; + final RequestId requestId; /// An optional string describing the reason for the cancellation. final String? reason; - const CancelledNotification({this.requestId, this.reason}); + const CancelledNotification({required this.requestId, this.reason}); factory CancelledNotification.fromJson(Map json) => CancelledNotification( - requestId: json.containsKey('requestId') - ? parseRequestId(json['requestId'], fieldName: 'requestId') - : null, + requestId: parseRequestId(json['requestId'], fieldName: 'requestId'), reason: readOptionalString( json['reason'], 'CancelledNotification.reason', @@ -63,8 +53,7 @@ class CancelledNotification { ); Map toJson() => { - if (requestId != null) - 'requestId': parseRequestId(requestId, fieldName: 'requestId'), + 'requestId': parseRequestId(requestId, fieldName: 'requestId'), if (reason != null) 'reason': reason, }; } diff --git a/lib/src/types/prompts.dart b/lib/src/types/prompts.dart index 040c376d..cee66287 100644 --- a/lib/src/types/prompts.dart +++ b/lib/src/types/prompts.dart @@ -7,14 +7,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } List? _readOptionalObjectList( diff --git a/lib/src/types/resources.dart b/lib/src/types/resources.dart index b26cb171..e727aa0b 100644 --- a/lib/src/types/resources.dart +++ b/lib/src/types/resources.dart @@ -27,15 +27,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } /// Additional properties describing a Resource to clients. diff --git a/lib/src/types/roots.dart b/lib/src/types/roots.dart index 3911ffc6..c4e4078d 100644 --- a/lib/src/types/roots.dart +++ b/lib/src/types/roots.dart @@ -24,15 +24,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } /// Represents a root directory or file the server can operate on. diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index 6911c56f..609942c7 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -30,15 +30,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } String _base64ForJson(String value, String field) { @@ -795,7 +787,7 @@ class CreateMessageRequest { } /// Converts to JSON. - Map toJson() { + Map toJson({bool omitToolExecution = false}) { validateOptionalFiniteNumber( temperature, 'CreateMessageRequest.temperature', @@ -815,7 +807,10 @@ class CreateMessageRequest { ), if (modelPreferences != null) 'modelPreferences': modelPreferences!.toJson(), - if (tools != null) 'tools': tools!.map((t) => t.toJson()).toList(), + if (tools != null) + 'tools': tools! + .map((t) => t.toJson(omitExecution: omitToolExecution)) + .toList(), if (toolChoiceConfig != null) 'toolChoice': toolChoiceConfig!.toJson(), }; } diff --git a/lib/src/types/subscriptions.dart b/lib/src/types/subscriptions.dart index 16393a04..4c5d3ad9 100644 --- a/lib/src/types/subscriptions.dart +++ b/lib/src/types/subscriptions.dart @@ -67,10 +67,10 @@ class SubscriptionFilter { (capabilities.resources?.listChanged ?? false) ? true : null, - resourceSubscriptions: - resourceSubscriptions != null && capabilities.resources != null - ? List.unmodifiable(resourceSubscriptions!) - : null, + resourceSubscriptions: resourceSubscriptions != null && + (capabilities.resources?.subscribe ?? false) + ? List.unmodifiable(resourceSubscriptions!) + : null, taskIds: taskIds != null && capabilities.supportsTasksExtension ? List.unmodifiable(taskIds!) : null, @@ -112,7 +112,7 @@ class SubscriptionFilter { return resourcesListChanged == true; case Method.notificationsResourcesUpdated: final uri = notification.params?['uri']; - return uri is String && (resourceSubscriptions?.contains(uri) ?? false); + return uri is String && _allowsResourceUri(uri, resourceSubscriptions); case Method.notificationsTasks: final taskId = notification.params?['taskId']; return taskId is String && (taskIds?.contains(taskId) ?? false); @@ -133,6 +133,41 @@ class SubscriptionFilter { }; } +bool _allowsResourceUri(String uri, List? subscribedUris) { + if (subscribedUris == null) { + return false; + } + return subscribedUris.any((subscribedUri) { + if (uri == subscribedUri) { + return true; + } + return _isSubResourceUri(uri, subscribedUri); + }); +} + +bool _isSubResourceUri(String uri, String subscribedUri) { + final updated = Uri.tryParse(uri); + final subscribed = Uri.tryParse(subscribedUri); + if (updated == null || + subscribed == null || + !updated.hasScheme || + !subscribed.hasScheme) { + return false; + } + if (updated.scheme != subscribed.scheme || + updated.authority != subscribed.authority) { + return false; + } + if (subscribed.query.isNotEmpty || subscribed.fragment.isNotEmpty) { + return false; + } + + final subscribedPath = subscribed.path.isEmpty ? '/' : subscribed.path; + final childPathPrefix = + subscribedPath.endsWith('/') ? subscribedPath : '$subscribedPath/'; + return updated.path.startsWith(childPathPrefix); +} + /// Parameters for a `subscriptions/listen` request. class SubscriptionsListenRequest { /// Notifications the client opts into on this stream. @@ -173,17 +208,54 @@ class JsonRpcSubscriptionsListenRequest extends JsonRpcRequest { factory JsonRpcSubscriptionsListenRequest.fromJson( Map json, ) { + _expectJsonRpcMethod( + json, + Method.subscriptionsListen, + 'JsonRpcSubscriptionsListenRequest', + ); final paramsMap = _readRequiredParamsObject( json, 'JsonRpcSubscriptionsListenRequest.params', ); + final meta = validateRequestMeta( + readJsonObject( + paramsMap['_meta'], + 'JsonRpcSubscriptionsListenRequest.params._meta', + ), + validateKeys: true, + )!; return JsonRpcSubscriptionsListenRequest( id: parseRequestId(json['id']), listenParams: SubscriptionsListenRequest.fromJson(paramsMap), - meta: extractRequestMeta(json), + meta: meta, ); } + + @override + Map toJson() { + final meta = this.meta; + if (meta == null) { + throw const FormatException( + 'JsonRpcSubscriptionsListenRequest.params._meta is required', + ); + } + return { + 'jsonrpc': jsonrpc, + 'id': parseRequestId( + id, + fieldName: 'JsonRpcSubscriptionsListenRequest.id', + ), + 'method': method, + 'params': { + ...listenParams.toJson(), + '_meta': readJsonObject( + validateRequestMeta(meta, validateKeys: true), + 'JsonRpcSubscriptionsListenRequest.params._meta', + ), + }, + }; + } } /// Parameters for `notifications/subscriptions/acknowledged`. @@ -227,6 +299,11 @@ class JsonRpcSubscriptionsAcknowledgedNotification extends JsonRpcNotification { factory JsonRpcSubscriptionsAcknowledgedNotification.fromJson( Map json, ) { + _expectJsonRpcMethod( + json, + Method.notificationsSubscriptionsAcknowledged, + 'JsonRpcSubscriptionsAcknowledgedNotification', + ); final paramsMap = _readRequiredParamsObject( json, 'JsonRpcSubscriptionsAcknowledgedNotification.params', @@ -297,3 +374,11 @@ Map? _readOptionalJsonObject(Object? value, String field) { } return readJsonObject(value, field); } + +void _expectJsonRpcMethod( + Map json, + String expected, + String context, +) { + expectJsonRpcMethod(json, expected, context); +} diff --git a/lib/src/types/tasks.dart b/lib/src/types/tasks.dart index 45dcab0d..61824b8e 100644 --- a/lib/src/types/tasks.dart +++ b/lib/src/types/tasks.dart @@ -7,15 +7,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } /// The current state of a task execution. diff --git a/lib/src/types/tools.dart b/lib/src/types/tools.dart index 486ae33e..9a48acea 100644 --- a/lib/src/types/tools.dart +++ b/lib/src/types/tools.dart @@ -20,15 +20,7 @@ void _expectJsonRpcMethod( String expected, String context, ) { - final version = readRequiredString(json['jsonrpc'], '$context.jsonrpc'); - if (version != jsonRpcVersion) { - throw FormatException('$context.jsonrpc must be "$jsonRpcVersion"'); - } - - final method = readRequiredString(json['method'], '$context.method'); - if (method != expected) { - throw FormatException('$context.method must be "$expected"'); - } + expectJsonRpcMethod(json, expected, context); } /// Additional properties describing a Tool to clients. @@ -266,7 +258,7 @@ class Tool { ); } - Map toJson() { + Map toJson({bool omitExecution = false}) { _validateObjectRootSchema(inputSchema, 'Tool.inputSchema'); return { @@ -277,7 +269,7 @@ class Tool { if (outputSchema != null) 'outputSchema': outputSchema!.toJson(), if (annotations != null) 'annotations': annotations!.toJson(), if (meta != null) '_meta': readJsonObject(meta, 'Tool._meta'), - if (execution != null) 'execution': execution!.toJson(), + if (!omitExecution && execution != null) 'execution': execution!.toJson(), if (icons != null) 'icons': icons!.map((icon) => icon.toJson()).toList(), }; } diff --git a/packages/mcp_dart_cli/CHANGELOG.md b/packages/mcp_dart_cli/CHANGELOG.md index b300d7e8..f1334eb3 100644 --- a/packages/mcp_dart_cli/CHANGELOG.md +++ b/packages/mcp_dart_cli/CHANGELOG.md @@ -35,6 +35,21 @@ - Add `mcp_dart conformance` with built-in JSON-RPC and protocol-version fixture checks, deterministic JSON-RPC fuzz cases, exact-case filtering, and JSON output for CI/scripts. - Add `mcp_dart conformance --suite spec` for MCP 2025-11-25 lifecycle, capability, elicitation, task-metadata, and progress-token raw-wire checks. +- Extend `mcp_dart conformance --suite spec` with MCP 2026-07-28 RC checks for + draft protocol advertisement, `server/discover`, stateless result/cache + defaults, removed core RPCs, stateless HTTP parameter header encoding, and + task subscription missing-capability errors. +- Add conformance coverage for `sampling.context` negotiation before deprecated + sampling `includeContext` values are sent. +- Add conformance coverage that aborted `initialize` requests do not emit + `notifications/cancelled`. +- Add conformance coverage that `notifications/cancelled` payloads require a + valid `requestId`. +- Add conformance coverage that `notifications/subscriptions/acknowledged` + typed parsers reject mismatched JSON-RPC wrapper constants. +- Add JSON-RPC fixture conformance coverage for rejecting envelopes that mix + request/notification `method` fields with response `result` or `error` + fields, including direct typed parser coverage. - Document `mcp_dart conformance --suite all` as the stable non-fuzz coverage gate used by CI. diff --git a/packages/mcp_dart_cli/README.md b/packages/mcp_dart_cli/README.md index 1d246f79..e0ec836e 100644 --- a/packages/mcp_dart_cli/README.md +++ b/packages/mcp_dart_cli/README.md @@ -351,12 +351,13 @@ mcp_dart call-tool search --url http://localhost:3000/mcp --json-args '{"q":"mcp ### Conformance -Run built-in fixture checks, MCP 2025-11-25 spec-critical checks, and -deterministic fuzz checks for protocol edge cases in this Dart SDK/CLI package. -The fixture suite covers JSON-RPC malformed-message handling, string and -integer request IDs, string and integer progress tokens, fractional ID/token -rejection, and advertised protocol-version support. The spec suite covers -raw-wire lifecycle, capability, elicitation, task-metadata, progress-token +Run built-in fixture checks, MCP 2025-11-25 spec-critical checks, MCP +2026-07-28 RC stateless checks, and deterministic fuzz checks for protocol edge +cases in this Dart SDK/CLI package. The fixture suite covers JSON-RPC +malformed-message handling, string and integer request IDs, string and integer +progress tokens, fractional ID/token rejection, and advertised protocol-version +support. The spec suite covers raw-wire lifecycle, discovery, stateless +result/cache behavior, capability, elicitation, task-metadata, progress-token dispatch, and negative cases. This command is useful as a regression gate for the Dart SDK and CLI, but it is @@ -372,7 +373,7 @@ mcp_dart conformance # Run all stable non-fuzz suites mcp_dart conformance --suite all -# Run only MCP 2025-11-25 raw-wire spec cases +# Run only raw-wire spec cases mcp_dart conformance --suite spec # Run one case by exact name diff --git a/packages/mcp_dart_cli/lib/src/conformance_runner.dart b/packages/mcp_dart_cli/lib/src/conformance_runner.dart index 3ec05a10..fff05f62 100644 --- a/packages/mcp_dart_cli/lib/src/conformance_runner.dart +++ b/packages/mcp_dart_cli/lib/src/conformance_runner.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; import 'dart:math'; import 'package:mcp_dart/mcp_dart.dart'; @@ -13,6 +15,17 @@ const String _protocolVersionMetaKey = const String _clientInfoMetaKey = 'io.modelcontextprotocol/clientInfo'; const String _clientCapabilitiesMetaKey = 'io.modelcontextprotocol/clientCapabilities'; +const String _resultTypeComplete = 'complete'; +const String _resultTypeInputRequired = 'input_required'; +const String _resultTypeFutureExtension = 'future_extension'; +const String _cacheScopePrivate = 'private'; +const String _tasksExtensionId = 'io.modelcontextprotocol/tasks'; +const String _methodTasksGet = 'tasks/get'; +const String _methodTasksUpdate = 'tasks/update'; +const String _methodSubscriptionsListen = 'subscriptions/listen'; +const String _methodNotificationsTasksStatus = 'notifications/tasks/status'; +const int _headerMismatchCode = -32001; +const int _unsupportedProtocolVersionCode = -32004; const List conformanceSuiteNames = [ _fixtureSuite, @@ -82,6 +95,20 @@ class _ConformanceCase { }); } +class _MissingCapabilityScenario { + final String name; + final ClientCapabilities capabilities; + final String method; + final Map requiredCapabilities; + + const _MissingCapabilityScenario({ + required this.name, + required this.capabilities, + required this.method, + required this.requiredCapabilities, + }); +} + /// Runs the built-in MCP conformance fixture checks. class ConformanceRunner { final List<_ConformanceCase> _fixtureCases; @@ -117,6 +144,13 @@ class ConformanceRunner { 'Rejects JSON-RPC responses that include both result and error members.', check: _rejectsResultErrorJsonRpcResponse, ), + _ConformanceCase( + suite: _fixtureSuite, + name: 'jsonrpc.rejects-method-response-envelope', + description: + 'Rejects JSON-RPC envelopes that combine request/notification method fields with response result or error fields.', + check: _rejectsMethodResponseJsonRpcEnvelope, + ), _ConformanceCase( suite: _fixtureSuite, name: 'jsonrpc.rejects-malformed-error-object', @@ -131,6 +165,13 @@ class ConformanceRunner { 'Rejects JSON-RPC error responses whose id member is explicitly null.', check: _rejectsNullJsonRpcErrorResponseId, ), + _ConformanceCase( + suite: _fixtureSuite, + name: 'jsonrpc.accepts-omitted-error-response-id', + description: + 'Parses and serializes JSON-RPC error responses that omit the optional id member.', + check: _acceptsOmittedJsonRpcErrorResponseId, + ), _ConformanceCase( suite: _fixtureSuite, name: 'jsonrpc.rejects-null-params-member', @@ -138,6 +179,13 @@ class ConformanceRunner { 'Rejects JSON-RPC request and notification envelopes whose params member is null.', check: _rejectsNullJsonRpcParamsMember, ), + _ConformanceCase( + suite: _fixtureSuite, + name: 'tools-call.requires-params', + description: + 'Rejects tools/call requests that omit the required params object.', + check: _requiresCallToolRequestParams, + ), _ConformanceCase( suite: _fixtureSuite, name: 'jsonrpc.preserves-string-response-id', @@ -180,6 +228,13 @@ class ConformanceRunner { 'Advertises MCP 2025-11-25 as the latest supported protocol version.', check: _advertisesLatestProtocolVersion, ), + _ConformanceCase( + suite: _fixtureSuite, + name: 'protocol-version.advertises-draft-2026-07-28', + description: + 'Advertises MCP 2026-07-28 as the latest draft stateless protocol version.', + check: _advertisesDraftProtocolVersion, + ), ], _specCases = <_ConformanceCase>[ _ConformanceCase( @@ -189,6 +244,27 @@ class ConformanceRunner { 'Rejects operation requests before the initialize handshake.', check: _rejectsPreInitializeRequest, ), + _ConformanceCase( + suite: _specSuite, + name: 'lifecycle.gates-until-initialized-notification', + description: + 'Keeps normal operation requests gated until notifications/initialized is received.', + check: _gatesUntilInitializedNotification, + ), + _ConformanceCase( + suite: _specSuite, + name: 'lifecycle.does-not-cancel-initialize', + description: + 'Does not send notifications/cancelled for initialize request cancellation.', + check: _doesNotCancelInitializeRequest, + ), + _ConformanceCase( + suite: _specSuite, + name: 'cancellation.requires-request-id', + description: + 'Rejects notifications/cancelled payloads without a requestId.', + check: _requiresCancellationRequestId, + ), _ConformanceCase( suite: _specSuite, name: 'server-discover.requires-request-meta', @@ -196,6 +272,288 @@ class ConformanceRunner { 'Rejects server/discover requests that omit params._meta request metadata.', check: _serverDiscoverRequiresRequestMeta, ), + _ConformanceCase( + suite: _specSuite, + name: 'server-discover.returns-draft-capabilities', + description: + 'Returns complete server/discover results with supported draft protocol versions.', + check: _serverDiscoverReturnsDraftCapabilities, + ), + _ConformanceCase( + suite: _specSuite, + name: 'protocol-version.rejects-unsupported-stateless-version', + description: + 'Rejects unsupported stateless protocol versions with supported/requested error data.', + check: _rejectsUnsupportedStatelessProtocolVersion, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless.requires-complete-request-meta', + description: + 'Rejects 2026 stateless requests whose _meta omits required client identity or capability fields.', + check: _statelessRequestsRequireCompleteRequestMeta, + ), + _ConformanceCase( + suite: _specSuite, + name: 'protocol-version.http-modern-400-retries-discovery', + description: + 'Retries server/discover with an advertised version after HTTP 400 UnsupportedProtocolVersion without falling back to initialize.', + check: _httpModernProtocolErrorsRetryDiscovery, + ), + _ConformanceCase( + suite: _specSuite, + name: 'capabilities.http-modern-400-does-not-fallback', + description: + 'Surfaces HTTP 400 MissingRequiredClientCapability errors without falling back to initialize.', + check: _httpModernMissingCapabilityErrorsDoNotFallback, + ), + _ConformanceCase( + suite: _specSuite, + name: 'protocol-version.initialize-negotiates-stateful-version', + description: + 'Keeps initialize negotiation on stateful MCP versions even when the draft stateless version is preferred.', + check: _initializeNegotiatesStatefulProtocolVersion, + ), + _ConformanceCase( + suite: _specSuite, + name: 'capabilities.stateless-does-not-infer-initialize-extensions', + description: + 'Requires 2026 stateless requests to declare extension capabilities per request instead of inheriting initialize capabilities.', + check: _statelessDoesNotInferInitializeExtensions, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.rejects-mismatched-routing-headers', + description: + 'Rejects 2026 Streamable HTTP requests whose routing headers disagree with the JSON-RPC body.', + check: _rejectsMismatchedStatelessHttpRoutingHeaders, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.requires-routing-headers', + description: + 'Requires 2026 Streamable HTTP requests to include protocol and method routing headers.', + check: _requiresStatelessHttpRoutingHeaders, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.rejects-non-post-methods', + description: + 'Returns HTTP 405 for 2026 stateless Streamable HTTP methods other than POST.', + check: _rejectsStatelessHttpNonPostMethods, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.rejects-batch-payloads', + description: + 'Rejects 2026 stateless Streamable HTTP POST bodies that contain more than one JSON-RPC message.', + check: _rejectsStatelessHttpBatchPayloads, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.task-requests-require-name-header', + description: + 'Requires 2026 task lifecycle requests to route with Mcp-Name task IDs.', + check: _taskRequestsRequireStatelessHttpNameHeader, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.validates-parameter-headers', + description: + 'Requires and matches 2026 Mcp-Param routing headers for configured tool arguments.', + check: _validatesStatelessHttpParameterHeaders, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.omits-invalid-numeric-parameter-headers', + description: + 'Omits fractional and unsafe integer x-mcp-header values while preserving safe integers.', + check: _omitsInvalidNumericParameterHeaders, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.encodes-parameter-header-values', + description: + 'Encodes non-plain 2026 Mcp-Param string header values while preserving plain strings.', + check: _encodesStatelessHttpParameterHeaderValues, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.accepts-response-posts', + description: + 'Accepts 2026 JSON-RPC response POSTs without request-body metadata.', + check: _acceptsStatelessHttpResponsePosts, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.omits-session-header-after-initialize', + description: + 'Omits Mcp-Session-Id on 2026 stateless responses even after stateful initialization.', + check: _statelessHttpOmitsSessionHeaderAfterInitialize, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-http.task-subscription-requires-client-capability', + description: + 'Returns MissingRequiredClientCapability for stateless task subscriptions when the client did not advertise the task extension.', + check: _taskSubscriptionRequiresClientCapability, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless.related-task-uses-explicit-id-across-transports', + description: + 'Processes related task operations across separate transports using explicit task IDs.', + check: _relatedTaskUsesExplicitIdAcrossTransports, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless.ignores-legacy-task-parameter', + description: + 'Ignores legacy tools/call task parameters on 2026 stateless requests.', + check: _statelessIgnoresLegacyTaskParameter, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless-client.rejects-legacy-task-options', + description: + 'Rejects legacy RequestOptions.task before sending 2026 stateless requests.', + check: _statelessClientRejectsLegacyTaskOptions, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless.adds-result-type-and-cache-defaults', + description: + 'Adds 2026 complete resultType and cache defaults for all cacheable stateless results.', + check: _statelessAddsResultTypeAndCacheDefaults, + ), + _ConformanceCase( + suite: _specSuite, + name: 'tools-list.stateless-returns-deterministic-order', + description: + 'Returns 2026 stateless tools/list results in deterministic name order.', + check: _statelessToolsListReturnsDeterministicOrder, + ), + _ConformanceCase( + suite: _specSuite, + name: 'tools-list.stateless-omits-legacy-execution', + description: + 'Omits stable-only Tool.execution metadata from 2026 stateless tools/list results.', + check: _statelessToolsListOmitsLegacyExecution, + ), + _ConformanceCase( + suite: _specSuite, + name: 'resources.missing-resource-error-code-by-version', + description: + 'Uses legacy ResourceNotFound for stable resource misses and InvalidParams for 2026 stateless resource misses.', + check: _missingResourceErrorCodeByVersion, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless.rejects-unrecognized-result-type', + description: + 'Rejects 2026 stateless responses with unrecognized resultType values.', + check: _statelessRejectsUnrecognizedResultType, + ), + _ConformanceCase( + suite: _specSuite, + name: 'mrtr.input-required-supported-requests', + description: + 'Allows input_required results on tools/call, prompts/get, and resources/read.', + check: _mrtrInputRequiredSupportedRequests, + ), + _ConformanceCase( + suite: _specSuite, + name: 'mrtr.rejects-unsupported-input-required-results', + description: + 'Rejects input_required results on methods outside the MRTR allowlist.', + check: _mrtrRejectsUnsupportedInputRequiredResults, + ), + _ConformanceCase( + suite: _specSuite, + name: 'mrtr.input-requests-require-client-capabilities', + description: + 'Rejects MRTR inputRequests whose client capabilities were not declared.', + check: _mrtrInputRequestsRequireClientCapabilities, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless.rejects-removed-core-rpcs', + description: + 'Rejects initialize, ping, logging/setLevel, and resource subscription RPCs in stateless MCP.', + check: _rejectsRemovedStatelessCoreRpcs, + ), + _ConformanceCase( + suite: _specSuite, + name: 'stateless.rejects-removed-core-notifications', + description: + 'Rejects initialized, roots/list_changed, and legacy task status notifications in stateless MCP.', + check: _rejectsRemovedStatelessCoreNotifications, + ), + _ConformanceCase( + suite: _specSuite, + name: 'logging.stateless-requires-request-log-level', + description: + 'Sends stateless logging notifications only when the request opts in with io.modelcontextprotocol/logLevel.', + check: _statelessLoggingRequiresRequestLogLevel, + ), + _ConformanceCase( + suite: _specSuite, + name: + 'tasks-extension.lifecycle-methods-do-not-require-repeated-capability', + description: + 'Does not reject task lifecycle requests solely because the request omits repeated task extension capability metadata.', + check: _taskLifecycleMethodsAllowResumedClientCapability, + ), + _ConformanceCase( + suite: _specSuite, + name: 'tasks-extension.task-store-uses-extension-result-shapes', + description: + 'Serializes built-in task-store tasks/get and tasks/cancel responses in the MCP Tasks extension wire shape.', + check: _taskStoreUsesTaskExtensionResultShapes, + ), + _ConformanceCase( + suite: _specSuite, + name: 'tasks-extension.call-tool-result-cannot-spoof-task-result', + description: + 'Rejects CallToolResult.extra attempts to spoof resultType task.', + check: _callToolResultCannotSpoofTaskResult, + ), + _ConformanceCase( + suite: _specSuite, + name: 'tasks-extension.task-result-requires-client-extension', + description: + 'Rejects resultType task unless the tools/call request negotiated the tasks extension.', + check: _taskResultRequiresClientExtension, + ), + _ConformanceCase( + suite: _specSuite, + name: 'subscriptions-listen.task-ids-require-client-capability', + description: + 'Rejects task-status subscriptions when the client did not advertise the task extension.', + check: _subscriptionTaskIdsRequireClientCapability, + ), + _ConformanceCase( + suite: _specSuite, + name: 'subscriptions-listen.requires-request-meta', + description: + 'Rejects subscriptions/listen requests that omit params._meta request metadata.', + check: _subscriptionsListenRequiresRequestMeta, + ), + _ConformanceCase( + suite: _specSuite, + name: + 'subscriptions-listen.resource-subscriptions-require-capability', + description: + 'Acknowledges resource subscriptions only when resources.subscribe is advertised.', + check: _subscriptionsListenRequiresResourceSubscribeCapability, + ), + _ConformanceCase( + suite: _specSuite, + name: 'subscriptions-acknowledged.rejects-wrapper-mismatch', + description: + 'Rejects notifications/subscriptions/acknowledged wrappers with mismatched JSON-RPC constants.', + check: _subscriptionsAcknowledgedRejectsWrapperMismatch, + ), _ConformanceCase( suite: _specSuite, name: 'capabilities.rejects-unnegotiated-sampling-tools', @@ -203,6 +561,34 @@ class ConformanceRunner { 'Rejects sampling/createMessage tool-use when sampling.tools was not negotiated.', check: _rejectsUnnegotiatedSamplingTools, ), + _ConformanceCase( + suite: _specSuite, + name: 'capabilities.rejects-unnegotiated-sampling-context', + description: + 'Rejects deprecated sampling includeContext values when sampling.context was not negotiated.', + check: _rejectsUnnegotiatedSamplingContext, + ), + _ConformanceCase( + suite: _specSuite, + name: 'capabilities.unadvertised-peer-methods-use-method-not-found', + description: + 'Uses MethodNotFound for MCP methods whose peer capability was not advertised.', + check: _unadvertisedPeerMethodsUseMethodNotFound, + ), + _ConformanceCase( + suite: _specSuite, + name: 'capabilities.task-scoped-peer-methods-use-method-not-found', + description: + 'Uses MethodNotFound for task-scoped MCP requests whose peer task capability was not advertised.', + check: _taskScopedPeerMethodsUseMethodNotFound, + ), + _ConformanceCase( + suite: _specSuite, + name: 'capabilities.stateless-omits-legacy-task-capabilities', + description: + 'Omits legacy task and removed roots.listChanged capability fields from 2026 stateless metadata.', + check: _statelessOmitsLegacyTaskCapabilities, + ), _ConformanceCase( suite: _specSuite, name: 'elicitation.rejects-invalid-form-url-union', @@ -210,6 +596,13 @@ class ConformanceRunner { 'Rejects elicitation/create payloads that mix form and URL variants.', check: _rejectsInvalidElicitationVariantPayload, ), + _ConformanceCase( + suite: _specSuite, + name: 'elicitation.accepts-numeric-number-schema-keywords', + description: + 'Accepts finite numeric default/minimum/maximum keywords in elicitation number schemas.', + check: _acceptsNumericElicitationNumberSchemaKeywords, + ), _ConformanceCase( suite: _specSuite, name: 'tasks.strips-unnegotiated-related-task-metadata', @@ -258,7 +651,7 @@ class ConformanceRunner { return _runCases(_fixtureCases, filter: filter); } - /// Runs MCP 2025-11-25 spec-critical raw-wire behavior checks. + /// Runs spec-critical raw-wire behavior checks. Future runSpecSuite({String? filter}) { return _runCases(_specCases, filter: filter); } @@ -467,44 +860,164 @@ class _ConformanceTransport extends Transport { } } -Future _settle() => Future.delayed(Duration.zero); +class _ConformanceProtocol extends Protocol { + _ConformanceProtocol() : super(null); -JsonRpcInitializeRequest _initializeRequest({ - RequestId id = 1, - ClientCapabilities capabilities = const ClientCapabilities(), -}) { - return JsonRpcInitializeRequest( - id: id, - initParams: InitializeRequest( - protocolVersion: latestProtocolVersion, - capabilities: capabilities, - clientInfo: const Implementation( - name: 'conformance-client', - version: '1.0.0', - ), - ), - ); -} + @override + void assertCapabilityForMethod(String method) {} -JsonRpcResponse _initializeResponse({ - required RequestId id, - ServerCapabilities capabilities = const ServerCapabilities(), -}) { - return JsonRpcResponse( - id: id, - result: InitializeResult( - protocolVersion: latestProtocolVersion, - capabilities: capabilities, - serverInfo: const Implementation( - name: 'conformance-server', - version: '1.0.0', - ), - ).toJson(), - ); + @override + void assertNotificationCapability(String method) {} + + @override + void assertRequestHandlerCapability(String method) {} + + @override + void assertTaskCapability(String method) {} + + @override + void assertTaskHandlerCapability(String method) {} } -Future _initializeMcpServer( - McpServer server, +class _DiscoveringConformanceTransport extends Transport + implements ProtocolVersionAwareTransport { + _DiscoveringConformanceTransport({ + required this.toolsListResult, + Map? capabilities, + this.toolsCallResult, + }) : capabilities = capabilities ?? + const { + 'tools': {}, + }; + + final Map toolsListResult; + final Map capabilities; + final Map? toolsCallResult; + final List sentMessages = []; + + @override + String? protocolVersion; + + @override + String? get sessionId => null; + + @override + Future start() async {} + + @override + Future send(JsonRpcMessage message, {int? relatedRequestId}) async { + sentMessages.add(message); + + if (message is JsonRpcRequest && message.method == _serverDiscoverMethod) { + onmessage?.call( + JsonRpcResponse( + id: message.id, + result: { + 'resultType': _resultTypeComplete, + 'supportedVersions': const [ + _draftProtocolVersion2026_07_28, + ], + 'capabilities': capabilities, + 'serverInfo': const { + 'name': 'conformance-server', + 'version': '1.0.0', + }, + }, + ), + ); + return; + } + + final toolsCallResult = this.toolsCallResult; + if (message is JsonRpcRequest && + message.method == Method.toolsCall && + toolsCallResult != null) { + onmessage?.call( + JsonRpcResponse(id: message.id, result: toolsCallResult), + ); + return; + } + + if (message is JsonRpcRequest && message.method == Method.toolsList) { + onmessage?.call( + JsonRpcResponse(id: message.id, result: toolsListResult), + ); + } + } + + @override + Future close() async { + onclose?.call(); + } +} + +Future _settle() => Future.delayed(Duration.zero); + +bool _stringListEquals(List actual, List expected) { + if (actual.length != expected.length) { + return false; + } + for (var index = 0; index < actual.length; index += 1) { + if (actual[index] != expected[index]) { + return false; + } + } + return true; +} + +JsonRpcInitializeRequest _initializeRequest({ + RequestId id = 1, + ClientCapabilities capabilities = const ClientCapabilities(), +}) { + return JsonRpcInitializeRequest( + id: id, + initParams: InitializeRequest( + protocolVersion: latestProtocolVersion, + capabilities: capabilities, + clientInfo: const Implementation( + name: 'conformance-client', + version: '1.0.0', + ), + ), + ); +} + +JsonRpcResponse _initializeResponse({ + required RequestId id, + ServerCapabilities capabilities = const ServerCapabilities(), +}) { + return JsonRpcResponse( + id: id, + result: InitializeResult( + protocolVersion: latestProtocolVersion, + capabilities: capabilities, + serverInfo: const Implementation( + name: 'conformance-server', + version: '1.0.0', + ), + ).toJson(), + ); +} + +Map _statelessRequestMeta({ + String protocolVersion = _draftProtocolVersion2026_07_28, + ClientCapabilities capabilities = const ClientCapabilities(), +}) { + return { + _protocolVersionMetaKey: protocolVersion, + _clientInfoMetaKey: const Implementation( + name: 'conformance-client', + version: '1.0.0', + ).toJson(), + _clientCapabilitiesMetaKey: capabilities.toJson( + omitLegacyTasks: isStatelessProtocolVersion(protocolVersion), + omitLegacyRootsListChanged: isStatelessProtocolVersion(protocolVersion), + ), + }; +} + +Future _initializeMcpServer( + McpServer server, _ConformanceTransport transport, { ClientCapabilities clientCapabilities = const ClientCapabilities(), }) async { @@ -520,8 +1033,9 @@ Future _initializeMcpServer( Future _initializeClient( McpClient client, - _ConformanceTransport transport, -) async { + _ConformanceTransport transport, { + ServerCapabilities serverCapabilities = const ServerCapabilities(), +}) async { final connectFuture = client.connect(transport); await _settle(); @@ -553,7 +1067,12 @@ Future _initializeClient( } final initializeRequest = initializeRequests.single; - transport.emit(_initializeResponse(id: initializeRequest.id)); + transport.emit( + _initializeResponse( + id: initializeRequest.id, + capabilities: serverCapabilities, + ), + ); await connectFuture.timeout(const Duration(seconds: 1)); transport.sentMessages.clear(); } @@ -586,6 +1105,148 @@ Future _rejectsPreInitializeRequest() async { await server.close(); } +Future _gatesUntilInitializedNotification() async { + final transport = _ConformanceTransport(); + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), + ), + ); + var handlerCallCount = 0; + server.registerTool( + 'probe', + callback: (args, extra) async { + handlerCallCount += 1; + return const CallToolResult( + content: [TextContent(text: 'ok')], + ); + }, + ); + + await server.connect(transport); + transport.emit(_initializeRequest()); + await _settle(); + _expectSingleErrorFreeResponse(transport.sentMessages, id: 1); + + transport.sentMessages.clear(); + transport.emit( + const JsonRpcCallToolRequest( + id: 101, + params: { + 'name': 'probe', + 'arguments': {}, + }, + ), + ); + await _settle(); + + if (handlerCallCount != 0) { + throw StateError('Tool handler ran before notifications/initialized.'); + } + _expectSingleError( + transport.sentMessages, + id: 101, + code: ErrorCode.invalidRequest.value, + messageContains: 'notifications/initialized', + ); + + transport.sentMessages.clear(); + transport.emit(const JsonRpcInitializedNotification()); + transport.emit( + const JsonRpcCallToolRequest( + id: 102, + params: { + 'name': 'probe', + 'arguments': {}, + }, + ), + ); + await _settle(); + + if (handlerCallCount != 1) { + throw StateError( + 'Tool handler did not run after initialized notification.'); + } + _expectSingleErrorFreeResponse(transport.sentMessages, id: 102); + await server.close(); +} + +Future _doesNotCancelInitializeRequest() async { + final transport = _ConformanceTransport(); + final protocol = _ConformanceProtocol(); + await protocol.connect(transport); + + final controller = BasicAbortController(); + final requestFuture = protocol.request( + _initializeRequest(), + InitializeResult.fromJson, + RequestOptions( + signal: controller.signal, + timeoutEnabled: false, + ), + ); + await _settle(); + + final initializeRequests = transport.sentMessages + .whereType() + .where((request) => request.method == Method.initialize) + .toList(); + if (initializeRequests.length != 1) { + throw StateError( + 'Expected one initialize request, got ${initializeRequests.length}.', + ); + } + + controller.abort('cancel initialize'); + try { + await requestFuture.timeout(const Duration(seconds: 1)); + throw StateError( + 'Expected initialize request cancellation to fail locally.'); + } catch (error) { + if (!error.toString().contains('cancel initialize')) { + throw StateError( + 'Expected initialize cancellation reason, got $error.', + ); + } + } + await _settle(); + + final cancellations = + transport.sentMessages.whereType().toList(); + if (cancellations.isNotEmpty) { + throw StateError( + 'Expected no cancellation notification for initialize, got $cancellations.', + ); + } + + await protocol.close(); +} + +Future _requiresCancellationRequestId() async { + _expectThrowsFormatException( + () => JsonRpcMessage.fromJson(const { + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsCancelled, + 'params': { + 'reason': 'missing request id', + }, + }), + ); + + try { + const CancelledNotification( + requestId: null, + reason: 'missing request id', + ).toJson(); + } on FormatException { + return; + } + + throw StateError( + 'Expected CancelledNotification.toJson to require requestId.'); +} + Future _serverDiscoverRequiresRequestMeta() async { for (final message in [ { @@ -611,82 +1272,3743 @@ Future _serverDiscoverRequiresRequestMeta() async { _expectThrowsFormatException(() => JsonRpcMessage.fromJson(message)); } - final parsed = JsonRpcMessage.fromJson({ - 'jsonrpc': jsonRpcVersion, - 'id': 'discover-1', - 'method': _serverDiscoverMethod, - 'params': { - '_meta': { - _protocolVersionMetaKey: _draftProtocolVersion2026_07_28, - _clientInfoMetaKey: { - 'name': 'client', - 'version': '1.0.0', - }, - _clientCapabilitiesMetaKey: {}, - }, - }, - }); - if (parsed is! JsonRpcRequest) { + final parsed = JsonRpcMessage.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 'discover-1', + 'method': _serverDiscoverMethod, + 'params': { + '_meta': { + _protocolVersionMetaKey: _draftProtocolVersion2026_07_28, + _clientInfoMetaKey: { + 'name': 'client', + 'version': '1.0.0', + }, + _clientCapabilitiesMetaKey: {}, + }, + }, + }); + if (parsed is! JsonRpcRequest) { + throw StateError( + 'Expected JsonRpcRequest, got ${parsed.runtimeType}.', + ); + } + if (parsed.meta?[_protocolVersionMetaKey] != + _draftProtocolVersion2026_07_28) { + throw StateError('Expected server/discover metadata to be preserved.'); + } +} + +Future _serverDiscoverReturnsDraftCapabilities() async { + final transport = _ConformanceTransport(); + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + instructions: 'Conformance server.', + ), + ); + + await server.connect(transport); + transport.emit( + JsonRpcRequest( + id: 'discover-1', + method: _serverDiscoverMethod, + meta: _statelessRequestMeta(), + ), + ); + await _settle(); + + final response = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: 'discover-1', + ); + final result = response.result; + if (result['resultType'] != _resultTypeComplete) { + throw StateError('Expected complete server/discover result.'); + } + final supportedVersions = result['supportedVersions']; + if (supportedVersions is! List || + !supportedVersions.contains(_draftProtocolVersion2026_07_28)) { + throw StateError( + 'Expected server/discover to include $_draftProtocolVersion2026_07_28.', + ); + } + final serverInfo = result['serverInfo']; + if (serverInfo is! Map || serverInfo['name'] != 'server') { + throw StateError('Expected server/discover to include server identity.'); + } + if (result['instructions'] != 'Conformance server.') { + throw StateError('Expected server/discover to include instructions.'); + } + final capabilities = result['capabilities']; + if (capabilities is! Map || capabilities['tools'] is! Map) { + throw StateError('Expected server/discover to include tool capabilities.'); + } + + await server.close(); +} + +Future _rejectsUnsupportedStatelessProtocolVersion() async { + final transport = _ConformanceTransport(); + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + + await server.connect(transport); + transport.emit( + JsonRpcRequest( + id: 'unsupported-version', + method: _serverDiscoverMethod, + meta: _statelessRequestMeta(protocolVersion: '1900-01-01'), + ), + ); + await _settle(); + + final error = _expectSingleError( + transport.sentMessages, + id: 'unsupported-version', + code: _unsupportedProtocolVersionCode, + messageContains: 'Unsupported protocol version', + ); + _expectUnsupportedProtocolVersionData(error, requested: '1900-01-01'); + + await server.close(); +} + +Future _statelessRequestsRequireCompleteRequestMeta() async { + final scenarios = <({String id, Map meta, String missing})>[ + ( + id: 'missing-client-info', + meta: { + _protocolVersionMetaKey: _draftProtocolVersion2026_07_28, + _clientCapabilitiesMetaKey: {}, + }, + missing: _clientInfoMetaKey, + ), + ( + id: 'missing-client-capabilities', + meta: { + _protocolVersionMetaKey: _draftProtocolVersion2026_07_28, + _clientInfoMetaKey: { + 'name': 'client', + 'version': '1.0.0', + }, + }, + missing: _clientCapabilitiesMetaKey, + ), + ]; + + final transport = _ConformanceTransport(); + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + + await server.connect(transport); + for (final scenario in scenarios) { + transport.emit( + JsonRpcListToolsRequest( + id: scenario.id, + meta: scenario.meta, + ), + ); + await _settle(); + + _expectSingleError( + transport.sentMessages, + id: scenario.id, + code: ErrorCode.invalidRequest.value, + messageContains: scenario.missing, + ); + transport.sentMessages.clear(); + } + await server.close(); +} + +Future _httpModernProtocolErrorsRetryDiscovery() async { + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final receivedMethods = []; + + late final StreamSubscription serverSubscription; + serverSubscription = httpServer.listen((request) { + unawaited(() async { + try { + final bodyText = await utf8.decodeStream(request); + final body = jsonDecode(bodyText) as Map; + final id = body['id']; + final method = body['method']; + if (method is String) { + receivedMethods.add(method); + } + + request.response.headers.contentType = ContentType.json; + + if (method == _serverDiscoverMethod) { + final params = body['params']; + final meta = params is Map ? params['_meta'] : null; + final requestedVersion = + meta is Map ? meta[_protocolVersionMetaKey] : null; + + if (requestedVersion == '1900-01-01') { + request.response.statusCode = HttpStatus.badRequest; + request.response.write( + jsonEncode( + JsonRpcError( + id: id, + error: JsonRpcErrorData( + code: ErrorCode.unsupportedProtocolVersion.value, + message: 'Unsupported protocol version', + data: const { + 'supported': [_draftProtocolVersion2026_07_28], + 'requested': '1900-01-01', + }, + ), + ).toJson(), + ), + ); + } else { + request.response.statusCode = HttpStatus.ok; + request.response.write( + jsonEncode( + JsonRpcResponse( + id: id, + result: const DiscoverResult( + supportedVersions: [ + _draftProtocolVersion2026_07_28, + ], + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + serverInfo: Implementation( + name: 'modern-http-server', + version: '1.0.0', + ), + ).toJson(), + ).toJson(), + ), + ); + } + } else if (method == Method.initialize) { + request.response.statusCode = HttpStatus.ok; + request.response.write( + jsonEncode(_initializeResponse(id: id).toJson()), + ); + } else { + request.response.statusCode = HttpStatus.accepted; + } + } finally { + await request.response.close(); + } + }()); + }); + + final transport = StreamableHttpClientTransport( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocolVersion: '1900-01-01', + useServerDiscover: true, + ), + ); + + try { + await client.connect(transport); + if (receivedMethods.contains(Method.initialize)) { + throw StateError( + 'Modern HTTP 400 JSON-RPC errors must not trigger initialize fallback.', + ); + } + if (client.getProtocolVersion() != _draftProtocolVersion2026_07_28) { + throw StateError( + 'Expected retry to negotiate $_draftProtocolVersion2026_07_28, ' + 'got ${client.getProtocolVersion()}.', + ); + } + if (receivedMethods + .where((method) => method == _serverDiscoverMethod) + .length != + 2) { + throw StateError( + 'Expected two server/discover attempts, got $receivedMethods.', + ); + } + } finally { + await client.close(); + await serverSubscription.cancel(); + await httpServer.close(force: true); + } +} + +Future _httpModernMissingCapabilityErrorsDoNotFallback() async { + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final receivedMethods = []; + + late final StreamSubscription serverSubscription; + serverSubscription = httpServer.listen((request) { + unawaited(() async { + try { + final bodyText = await utf8.decodeStream(request); + final body = jsonDecode(bodyText) as Map; + final id = body['id']; + final method = body['method']; + if (method is String) { + receivedMethods.add(method); + } + + request.response.headers.contentType = ContentType.json; + + if (method == _serverDiscoverMethod) { + request.response.statusCode = HttpStatus.badRequest; + request.response.write( + jsonEncode( + JsonRpcError( + id: id, + error: JsonRpcErrorData( + code: ErrorCode.missingRequiredClientCapability.value, + message: + 'Server requires the elicitation capability for this request', + data: const { + 'requiredCapabilities': { + 'elicitation': {}, + }, + }, + ), + ).toJson(), + ), + ); + } else if (method == Method.initialize) { + request.response.statusCode = HttpStatus.ok; + request.response.write( + jsonEncode(_initializeResponse(id: id).toJson()), + ); + } else { + request.response.statusCode = HttpStatus.accepted; + } + } finally { + await request.response.close(); + } + }()); + }); + + final transport = StreamableHttpClientTransport( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocolVersion: _draftProtocolVersion2026_07_28, + useServerDiscover: true, + ), + ); + + try { + await client.connect(transport); + throw StateError('Expected missing capability error.'); + } on McpError catch (error) { + if (error.code != ErrorCode.missingRequiredClientCapability.value) { + throw StateError( + 'Expected missing client capability error code, got ${error.code}.', + ); + } + if (!error.message.contains('elicitation capability')) { + throw StateError( + 'Expected missing elicitation capability message, got ' + "'${error.message}'.", + ); + } + final data = error.data; + if (data is! Map || + data['requiredCapabilities'] is! Map || + (data['requiredCapabilities'] as Map)['elicitation'] is! Map) { + throw StateError( + 'Expected requiredCapabilities.elicitation error data, got $data.', + ); + } + if (receivedMethods.contains(Method.initialize)) { + throw StateError( + 'Modern HTTP 400 JSON-RPC errors must not trigger initialize fallback.', + ); + } + if (receivedMethods + .where((method) => method == _serverDiscoverMethod) + .length != + 1) { + throw StateError( + 'Expected one server/discover attempt, got $receivedMethods.', + ); + } + } finally { + await client.close(); + await serverSubscription.cancel(); + await httpServer.close(force: true); + } +} + +Future _initializeNegotiatesStatefulProtocolVersion() async { + final serverTransport = _ConformanceTransport(); + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + await server.connect(serverTransport); + serverTransport.emit( + JsonRpcInitializeRequest( + id: 'draft-initialize', + initParams: const InitializeRequest( + protocolVersion: _draftProtocolVersion2026_07_28, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'client', version: '1.0.0'), + ), + ), + ); + await _settle(); + + final serverResponse = _expectSingleErrorFreeResponse( + serverTransport.sentMessages, + id: 'draft-initialize', + ); + if (serverResponse.result['protocolVersion'] != latestProtocolVersion) { + throw StateError( + 'Expected initialize response protocolVersion $latestProtocolVersion, ' + 'got ${serverResponse.result['protocolVersion']}.', + ); + } + if (serverResponse.result['protocolVersion'] == + _draftProtocolVersion2026_07_28) { + throw StateError('initialize must not negotiate the stateless draft.'); + } + await server.close(); + + final clientTransport = _ConformanceTransport(); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + final connectFuture = client.connect(clientTransport); + await _settle(); + + final discoverRequest = clientTransport.sentMessages + .whereType() + .singleWhere((request) => request.method == _serverDiscoverMethod); + clientTransport.emit( + JsonRpcError( + id: discoverRequest.id, + error: JsonRpcErrorData( + code: ErrorCode.methodNotFound.value, + message: 'Method not found', + ), + ), + ); + await _settle(); + + final initializeRequest = clientTransport.sentMessages + .whereType() + .singleWhere((request) => request.method == Method.initialize); + if (initializeRequest.params?['protocolVersion'] != latestProtocolVersion) { + throw StateError( + 'Expected fallback initialize request protocolVersion ' + '$latestProtocolVersion, got ' + '${initializeRequest.params?['protocolVersion']}.', + ); + } + if (initializeRequest.params?['protocolVersion'] == + _draftProtocolVersion2026_07_28) { + throw StateError('client fallback initialize must not send the draft.'); + } + + clientTransport.emit(_initializeResponse(id: initializeRequest.id)); + await connectFuture.timeout(const Duration(seconds: 1)); + await client.close(); +} + +Future _statelessDoesNotInferInitializeExtensions() async { + final transport = _ConformanceTransport(); + // Raw map parsing keeps this conformance case analyzable against the hosted + // CLI package lower bound while still exercising the 2026 wire behavior. + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ); + + await server.connect(transport); + transport.emit( + _initializeRequest( + id: 'init', + capabilities: const ClientCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ); + await _settle(); + _expectSingleErrorFreeResponse(transport.sentMessages, id: 'init'); + transport.sentMessages.clear(); + + final request = JsonRpcMessage.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': 'stateless-subscribe', + 'method': _methodSubscriptionsListen, + 'params': { + '_meta': _statelessRequestMeta(), + 'notifications': { + 'taskIds': ['task-1'], + }, + }, + }, + ); + if (request is! JsonRpcRequest) { + throw StateError( + 'Expected subscriptions/listen to parse as a request, got ' + '${request.runtimeType}.', + ); + } + + transport.emit(request); + await _settle(); + + final error = _expectSingleError( + transport.sentMessages, + id: 'stateless-subscribe', + code: ErrorCode.missingRequiredClientCapability.value, + messageContains: 'Missing required client capability', + ); + _expectMissingTasksExtensionCapabilityData(error.error.data); + + await server.close(); +} + +Future _rejectsMismatchedStatelessHttpRoutingHeaders() async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + enableDnsRebindingProtection: false, + ), + ); + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final httpClient = HttpClient(); + + await transport.start(); + final serverSubscription = httpServer.listen((request) { + unawaited(transport.handleRequest(request)); + }); + + try { + final request = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + request.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', _draftProtocolVersion2026_07_28) + ..set('Mcp-Method', Method.toolsCall) + ..set('Mcp-Name', 'wrong-tool'); + request.write( + jsonEncode( + { + 'jsonrpc': jsonRpcVersion, + 'id': 'http-header-mismatch', + 'method': Method.toolsCall, + 'params': { + 'name': 'actual-tool', + 'arguments': {}, + '_meta': _statelessRequestMeta(), + }, + }, + ), + ); + + final response = await request.close(); + final responseBody = + jsonDecode(await utf8.decodeStream(response)) as Map; + + if (response.statusCode != HttpStatus.badRequest) { + throw StateError( + 'Expected HTTP 400 for mismatched stateless routing headers, got ' + '${response.statusCode}.', + ); + } + if (responseBody['id'] != 'http-header-mismatch') { + throw StateError( + 'Expected JSON-RPC error id http-header-mismatch, got ' + "${responseBody['id']}.", + ); + } + final error = responseBody['error']; + if (error is! Map || error['code'] != _headerMismatchCode) { + throw StateError('Expected HeaderMismatch error, got $error.'); + } + final message = error['message']; + if (message is! String || !message.contains('Mcp-Name header value')) { + throw StateError('Expected Mcp-Name mismatch diagnostic, got $message.'); + } + } finally { + httpClient.close(force: true); + await serverSubscription.cancel(); + await httpServer.close(force: true); + await transport.close(); + } +} + +Future _requiresStatelessHttpRoutingHeaders() async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + enableDnsRebindingProtection: false, + ), + ); + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final httpClient = HttpClient(); + + await transport.start(); + final serverSubscription = httpServer.listen((request) { + unawaited(transport.handleRequest(request)); + }); + + Future expectHeaderMismatch( + String id, { + required void Function(HttpHeaders headers) addRoutingHeaders, + required String messageFragment, + }) async { + final request = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + request.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream'); + addRoutingHeaders(request.headers); + request.write( + jsonEncode( + JsonRpcListToolsRequest(id: id, meta: _statelessRequestMeta()).toJson(), + ), + ); + + final response = await request.close(); + final responseBody = + jsonDecode(await utf8.decodeStream(response)) as Map; + + if (response.statusCode != HttpStatus.badRequest) { + throw StateError( + 'Expected HTTP 400 for missing stateless routing header, got ' + '${response.statusCode}: $responseBody.', + ); + } + if (responseBody['id'] != id) { + throw StateError( + 'Expected JSON-RPC error id $id, got ${responseBody['id']}.', + ); + } + final error = responseBody['error']; + if (error is! Map || error['code'] != _headerMismatchCode) { + throw StateError('Expected HeaderMismatch error, got $error.'); + } + final message = error['message']; + if (message is! String || !message.contains(messageFragment)) { + throw StateError( + 'Expected diagnostic containing $messageFragment, got $message.', + ); + } + if (response.headers.value('mcp-session-id') != null) { + throw StateError( + 'Expected stateless header mismatch response to omit Mcp-Session-Id, ' + 'got ${response.headers.value('mcp-session-id')}.', + ); + } + } + + try { + await expectHeaderMismatch( + 'http-missing-protocol-header', + addRoutingHeaders: (headers) { + headers.set('Mcp-Method', Method.toolsList); + }, + messageFragment: 'MCP-Protocol-Version header is required', + ); + await expectHeaderMismatch( + 'http-missing-method-header', + addRoutingHeaders: (headers) { + headers.set('MCP-Protocol-Version', _draftProtocolVersion2026_07_28); + }, + messageFragment: 'Mcp-Method header is required', + ); + } finally { + httpClient.close(force: true); + await serverSubscription.cancel(); + await httpServer.close(force: true); + await transport.close(); + } +} + +Future _rejectsStatelessHttpNonPostMethods() async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + enableDnsRebindingProtection: false, + ), + ); + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final httpClient = HttpClient(); + + await transport.start(); + final serverSubscription = httpServer.listen((request) { + unawaited(transport.handleRequest(request)); + }); + + Future expectMethodNotAllowed(String method) async { + final request = await httpClient.openUrl( + method, + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + request.headers.set( + 'MCP-Protocol-Version', + _draftProtocolVersion2026_07_28, + ); + + final response = await request.close(); + final responseBody = + jsonDecode(await utf8.decodeStream(response)) as Map; + + if (response.statusCode != HttpStatus.methodNotAllowed) { + throw StateError( + 'Expected HTTP 405 for stateless $method, got ' + '${response.statusCode}: $responseBody.', + ); + } + if (response.headers.value(HttpHeaders.allowHeader) != 'POST') { + throw StateError( + 'Expected Allow: POST for stateless $method, got ' + '${response.headers.value(HttpHeaders.allowHeader)}.', + ); + } + final error = responseBody['error']; + if (error is! Map || error['code'] != ErrorCode.connectionClosed.value) { + throw StateError( + 'Expected stateless $method to return connection closed error, got ' + '$responseBody.', + ); + } + if (response.headers.value('mcp-session-id') != null) { + throw StateError( + 'Expected stateless $method response to omit Mcp-Session-Id, got ' + '${response.headers.value('mcp-session-id')}.', + ); + } + } + + try { + await expectMethodNotAllowed('GET'); + await expectMethodNotAllowed('DELETE'); + } finally { + httpClient.close(force: true); + await serverSubscription.cancel(); + await httpServer.close(force: true); + await transport.close(); + } +} + +Future _rejectsStatelessHttpBatchPayloads() async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + enableDnsRebindingProtection: false, + rejectBatchJsonRpcPayloads: false, + ), + ); + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final httpClient = HttpClient(); + + await transport.start(); + final serverSubscription = httpServer.listen((request) { + unawaited(transport.handleRequest(request)); + }); + + try { + final request = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + request.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', _draftProtocolVersion2026_07_28); + request.write( + jsonEncode( + >[ + JsonRpcListToolsRequest( + id: 'http-batch-tools-1', + meta: _statelessRequestMeta(), + ).toJson(), + JsonRpcListToolsRequest( + id: 'http-batch-tools-2', + meta: _statelessRequestMeta(), + ).toJson(), + ], + ), + ); + + final response = await request.close(); + final responseBody = + jsonDecode(await utf8.decodeStream(response)) as Map; + + if (response.statusCode != HttpStatus.badRequest) { + throw StateError( + 'Expected HTTP 400 for stateless batch POST body, got ' + '${response.statusCode}: $responseBody.', + ); + } + if (responseBody.containsKey('id')) { + throw StateError( + 'Expected batch-level JSON-RPC error to omit id, got $responseBody.', + ); + } + final error = responseBody['error']; + if (error is! Map || error['code'] != ErrorCode.invalidRequest.value) { + throw StateError('Expected InvalidRequest error, got $error.'); + } + final message = error['message']; + if (message is! String || !message.contains('must contain one')) { + throw StateError( + 'Expected one-message diagnostic for stateless batch body, got ' + '$message.', + ); + } + } finally { + httpClient.close(force: true); + await serverSubscription.cancel(); + await httpServer.close(force: true); + await transport.close(); + } +} + +Future _taskRequestsRequireStatelessHttpNameHeader() async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + enableDnsRebindingProtection: false, + enableJsonResponse: true, + ), + ); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ); + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final httpClient = HttpClient(); + + server.setRequestHandler( + _methodTasksUpdate, + (request, extra) async => const EmptyResult(), + (id, params, meta) => JsonRpcRequest( + id: id, + method: _methodTasksUpdate, + params: params, + meta: meta, + ), + ); + + await server.connect(transport); + final serverSubscription = httpServer.listen((request) { + unawaited(transport.handleRequest(request)); + }); + + try { + final missingNameRequest = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + missingNameRequest.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', _draftProtocolVersion2026_07_28) + ..set('Mcp-Method', _methodTasksUpdate); + missingNameRequest.write( + jsonEncode( + { + 'jsonrpc': jsonRpcVersion, + 'id': 'http-task-update-no-name', + 'method': _methodTasksUpdate, + 'params': { + '_meta': _statelessRequestMeta( + capabilities: const ClientCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + 'taskId': 'task-1', + 'inputResponses': {}, + }, + }, + ), + ); + + final missingNameResponse = await missingNameRequest.close(); + final missingNameBody = jsonDecode( + await utf8.decodeStream(missingNameResponse), + ) as Map; + + if (missingNameResponse.statusCode != HttpStatus.badRequest) { + throw StateError( + 'Expected HTTP 400 for task request without Mcp-Name, got ' + '${missingNameResponse.statusCode}: $missingNameBody.', + ); + } + final missingNameError = missingNameBody['error']; + if (missingNameError is! Map || + missingNameError['code'] != _headerMismatchCode) { + throw StateError( + 'Expected HeaderMismatch for missing task Mcp-Name, got ' + '$missingNameBody.', + ); + } + final missingNameMessage = missingNameError['message']; + if (missingNameMessage is! String || + !missingNameMessage.contains('Mcp-Name header')) { + throw StateError( + 'Expected missing Mcp-Name diagnostic, got $missingNameMessage.', + ); + } + + final request = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + request.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', _draftProtocolVersion2026_07_28) + ..set('Mcp-Method', _methodTasksUpdate) + ..set('Mcp-Name', 'task-1'); + request.write( + jsonEncode( + { + 'jsonrpc': jsonRpcVersion, + 'id': 'http-task-update-name', + 'method': _methodTasksUpdate, + 'params': { + '_meta': _statelessRequestMeta( + capabilities: const ClientCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + 'taskId': 'task-1', + 'inputResponses': {}, + }, + }, + ), + ); + + final response = await request.close(); + final responseBody = + jsonDecode(await utf8.decodeStream(response)) as Map; + + if (response.statusCode != HttpStatus.ok) { + throw StateError( + 'Expected HTTP 200 for task request with Mcp-Name, got ' + '${response.statusCode}: $responseBody.', + ); + } + if (responseBody['id'] != 'http-task-update-name') { + throw StateError( + 'Expected JSON-RPC response id http-task-update-name, got ' + "${responseBody['id']}.", + ); + } + final result = responseBody['result']; + if (result is! Map || result['resultType'] != _resultTypeComplete) { + throw StateError('Expected complete task acknowledgement, got $result.'); + } + } finally { + httpClient.close(force: true); + await serverSubscription.cancel(); + await httpServer.close(force: true); + await server.close(); + } +} + +Future _validatesStatelessHttpParameterHeaders() async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + enableDnsRebindingProtection: false, + enableJsonResponse: true, + ), + ); + // Keep this dynamic so mcp_dart_cli remains analyzable against the published + // mcp_dart lower bound until this SDK branch is released. + (transport as dynamic).setToolParameterHeaderMappings( + const >{ + 'execute': { + 'count': 'Count', + 'dryRun': 'Dry-Run', + 'region': 'Region', + }, + }, + ); + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final httpClient = HttpClient(); + + transport.onmessage = (message) { + if (message is JsonRpcCallToolRequest) { + unawaited( + transport.send( + JsonRpcResponse( + id: message.id, + result: const CallToolResult(content: []).toJson(), + ), + ), + ); + } + }; + + await transport.start(); + final serverSubscription = httpServer.listen((request) { + unawaited(transport.handleRequest(request)); + }); + + Future> postToolCall({ + required String id, + required Map headers, + Map arguments = const { + 'dryRun': false, + 'region': 'us-east1', + }, + }) async { + final request = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + request.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', _draftProtocolVersion2026_07_28) + ..set('Mcp-Method', Method.toolsCall) + ..set('Mcp-Name', 'execute'); + headers.forEach(request.headers.set); + request.write( + jsonEncode( + JsonRpcCallToolRequest( + id: id, + params: { + 'name': 'execute', + 'arguments': arguments, + }, + meta: _statelessRequestMeta(), + ).toJson(), + ), + ); + + final response = await request.close(); + final responseBody = + jsonDecode(await utf8.decodeStream(response)) as Map; + return { + 'statusCode': response.statusCode, + 'body': responseBody, + }; + } + + void expectHeaderMismatch( + Map response, { + required String id, + required String messageFragment, + }) { + final statusCode = response['statusCode']; + final responseBody = response['body'] as Map; + if (statusCode != HttpStatus.badRequest) { + throw StateError( + 'Expected HTTP 400 for parameter header mismatch, got ' + '$statusCode: $responseBody.', + ); + } + if (responseBody['id'] != id) { + throw StateError( + 'Expected JSON-RPC error id $id, got ${responseBody['id']}.', + ); + } + final error = responseBody['error']; + if (error is! Map || error['code'] != _headerMismatchCode) { + throw StateError('Expected HeaderMismatch error, got $error.'); + } + final message = error['message']; + if (message is! String || !message.contains(messageFragment)) { + throw StateError( + 'Expected diagnostic containing $messageFragment, got $message.', + ); + } + } + + try { + expectHeaderMismatch( + await postToolCall( + id: 'http-missing-param-header', + headers: const { + 'Mcp-Param-Region': 'us-east1', + }, + ), + id: 'http-missing-param-header', + messageFragment: 'Mcp-Param-Dry-Run header is required', + ); + + expectHeaderMismatch( + await postToolCall( + id: 'http-mismatched-param-header', + headers: const { + 'Mcp-Param-Dry-Run': 'true', + 'Mcp-Param-Region': 'us-east1', + }, + ), + id: 'http-mismatched-param-header', + messageFragment: "body argument 'dryRun'", + ); + + final success = await postToolCall( + id: 'http-matched-param-headers', + arguments: const { + 'count': 42, + 'dryRun': false, + 'region': 'us-east1', + }, + headers: const { + 'Mcp-Param-Count': '42', + 'Mcp-Param-Dry-Run': 'false', + 'Mcp-Param-Region': 'us-east1', + }, + ); + final statusCode = success['statusCode']; + final responseBody = success['body'] as Map; + if (statusCode != HttpStatus.ok) { + throw StateError( + 'Expected HTTP 200 for matching parameter headers, got ' + '$statusCode: $responseBody.', + ); + } + if (responseBody['id'] != 'http-matched-param-headers') { + throw StateError('Unexpected matched parameter response $responseBody.'); + } + final result = responseBody['result']; + if (result is! Map || result['content'] is! List) { + throw StateError('Expected successful tool result, got $result.'); + } + } finally { + httpClient.close(force: true); + await serverSubscription.cancel(); + await httpServer.close(force: true); + await transport.close(); + } +} + +Future _omitsInvalidNumericParameterHeaders() async { + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final receivedHeaders = Completer>(); + final responseMessage = Completer(); + final transport = StreamableHttpClientTransport( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + )..protocolVersion = _draftProtocolVersion2026_07_28; + // Keep this dynamic so mcp_dart_cli remains analyzable against the published + // mcp_dart lower bound until this SDK branch is released. + (transport as dynamic).setToolParameterHeaderMappings( + const >{ + 'calculate': { + 'limit': 'Limit', + 'ratio': 'Ratio', + 'unsafe': 'Unsafe', + }, + }, + ); + + final serverSubscription = httpServer.listen((request) async { + if (!receivedHeaders.isCompleted) { + receivedHeaders.complete( + { + 'limit': request.headers.value('mcp-param-limit'), + 'ratio': request.headers.value('mcp-param-ratio'), + 'unsafe': request.headers.value('mcp-param-unsafe'), + }, + ); + } + await request.drain(); + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write( + jsonEncode( + const JsonRpcResponse( + id: 'number-headers', + result: { + 'resultType': _resultTypeComplete, + 'content': [], + }, + ).toJson(), + ), + ); + await request.response.close(); + }); + + transport.onmessage = responseMessage.complete; + await transport.start(); + + try { + await transport.send( + JsonRpcCallToolRequest( + id: 'number-headers', + params: const { + 'name': 'calculate', + 'arguments': { + 'limit': 42, + 'ratio': 1.5, + 'unsafe': 9007199254740992, + }, + }, + meta: _statelessRequestMeta(), + ), + ); + + final headers = await receivedHeaders.future.timeout( + const Duration(seconds: 5), + ); + if (headers['limit'] != '42') { + throw StateError( + 'Expected safe integer header 42, got ${headers['limit']}.', + ); + } + if (headers['ratio'] != null) { + throw StateError( + 'Expected fractional number header to be omitted, got ' + "${headers['ratio']}.", + ); + } + if (headers['unsafe'] != null) { + throw StateError( + 'Expected unsafe integer header to be omitted, got ' + "${headers['unsafe']}.", + ); + } + + final response = await responseMessage.future.timeout( + const Duration(seconds: 5), + ); + if (response is! JsonRpcResponse || response.id != 'number-headers') { + throw StateError('Expected JSON-RPC response, got $response.'); + } + } finally { + await transport.close(); + await serverSubscription.cancel(); + await httpServer.close(force: true); + } +} + +Future _encodesStatelessHttpParameterHeaderValues() async { + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final receivedHeaders = Completer>(); + final responseMessage = Completer(); + final transport = StreamableHttpClientTransport( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + )..protocolVersion = _draftProtocolVersion2026_07_28; + // Keep this dynamic so mcp_dart_cli remains analyzable against the published + // mcp_dart lower bound until this SDK branch is released. + (transport as dynamic).setToolParameterHeaderMappings( + const >{ + 'echo': { + 'greeting': 'Greeting', + 'plain': 'Plain', + 'sentinel': 'Sentinel', + 'spaced': 'Spaced', + }, + }, + ); + + final serverSubscription = httpServer.listen((request) async { + if (!receivedHeaders.isCompleted) { + receivedHeaders.complete( + { + 'greeting': request.headers.value('mcp-param-greeting'), + 'plain': request.headers.value('mcp-param-plain'), + 'sentinel': request.headers.value('mcp-param-sentinel'), + 'spaced': request.headers.value('mcp-param-spaced'), + }, + ); + } + await request.drain(); + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write( + jsonEncode( + const JsonRpcResponse( + id: 'encoded-headers', + result: { + 'resultType': _resultTypeComplete, + 'content': [], + }, + ).toJson(), + ), + ); + await request.response.close(); + }); + + transport.onmessage = responseMessage.complete; + await transport.start(); + + String encodedHeaderValue(String value) => + '=?base64?${base64Encode(utf8.encode(value))}?='; + + final nonAsciiGreeting = 'Hello, ${String.fromCharCodes( + const [0x4e16, 0x754c], + )}'; + + try { + await transport.send( + JsonRpcCallToolRequest( + id: 'encoded-headers', + params: { + 'name': 'echo', + 'arguments': { + 'greeting': nonAsciiGreeting, + 'plain': 'us-east1', + 'sentinel': '=?base64?literal?=', + 'spaced': ' padded ', + }, + }, + meta: _statelessRequestMeta(), + ), + ); + + final headers = await receivedHeaders.future.timeout( + const Duration(seconds: 5), + ); + final expectedGreeting = encodedHeaderValue(nonAsciiGreeting); + if (headers['greeting'] != expectedGreeting) { + throw StateError( + 'Expected non-ASCII string header $expectedGreeting, got ' + "${headers['greeting']}.", + ); + } + if (headers['plain'] != 'us-east1') { + throw StateError( + 'Expected plain string header us-east1, got ${headers['plain']}.', + ); + } + final expectedSentinel = encodedHeaderValue('=?base64?literal?='); + if (headers['sentinel'] != expectedSentinel) { + throw StateError( + 'Expected sentinel-looking string header $expectedSentinel, got ' + "${headers['sentinel']}.", + ); + } + final expectedSpaced = encodedHeaderValue(' padded '); + if (headers['spaced'] != expectedSpaced) { + throw StateError( + 'Expected trim-sensitive string header $expectedSpaced, got ' + "${headers['spaced']}.", + ); + } + + final response = await responseMessage.future.timeout( + const Duration(seconds: 5), + ); + if (response is! JsonRpcResponse || response.id != 'encoded-headers') { + throw StateError('Expected JSON-RPC response, got $response.'); + } + } finally { + await transport.close(); + await serverSubscription.cancel(); + await httpServer.close(force: true); + } +} + +Future _acceptsStatelessHttpResponsePosts() async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + enableDnsRebindingProtection: false, + enableJsonResponse: true, + ), + ); + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final httpClient = HttpClient(); + final receivedMessage = Completer(); + + transport.onmessage = receivedMessage.complete; + + await transport.start(); + final serverSubscription = httpServer.listen((request) { + unawaited(transport.handleRequest(request)); + }); + + try { + final request = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + request.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', _draftProtocolVersion2026_07_28); + request.write( + jsonEncode( + const JsonRpcResponse( + id: 'http-input-response', + result: {'ok': true}, + ).toJson(), + ), + ); + + final response = await request.close(); + final responseBody = await utf8.decodeStream(response); + + if (response.statusCode != HttpStatus.accepted) { + throw StateError( + 'Expected HTTP 202 for stateless response POST, got ' + '${response.statusCode}: $responseBody.', + ); + } + if (responseBody.isNotEmpty) { + throw StateError( + 'Expected empty stateless response POST body, got $responseBody.', + ); + } + + final message = await receivedMessage.future.timeout( + const Duration(seconds: 5), + ); + if (message is! JsonRpcResponse) { + throw StateError( + 'Expected server transport to receive JsonRpcResponse, got ' + '${message.runtimeType}.', + ); + } + if (message.id != 'http-input-response' || message.result['ok'] != true) { + throw StateError('Unexpected stateless response POST message $message.'); + } + } finally { + httpClient.close(force: true); + await serverSubscription.cancel(); + await httpServer.close(force: true); + await transport.close(); + } +} + +Future _statelessHttpOmitsSessionHeaderAfterInitialize() async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => 'stateful-session-id', + enableDnsRebindingProtection: false, + enableJsonResponse: true, + ), + ); + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final httpClient = HttpClient(); + + transport.onmessage = (message) { + if (message is JsonRpcInitializeRequest) { + unawaited( + transport.send( + JsonRpcResponse( + id: message.id, + result: const InitializeResult( + protocolVersion: latestProtocolVersion, + capabilities: ServerCapabilities(), + serverInfo: Implementation( + name: 'conformance-server', + version: '1.0.0', + ), + ).toJson(), + ), + ), + ); + } else if (message is JsonRpcListToolsRequest) { + unawaited( + transport.send( + JsonRpcResponse( + id: message.id, + result: const ListToolsResult(tools: []).toJson(), + ), + ), + ); + } + }; + + await transport.start(); + final serverSubscription = httpServer.listen((request) { + unawaited(transport.handleRequest(request)); + }); + + try { + final initRequest = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + initRequest.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream'); + initRequest.write( + jsonEncode( + JsonRpcInitializeRequest( + id: 'initialize-session', + initParams: const InitializeRequest( + protocolVersion: latestProtocolVersion, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'client', version: '1.0.0'), + ), + ).toJson(), + ), + ); + + final initResponse = await initRequest.close(); + await utf8.decodeStream(initResponse); + final sessionId = initResponse.headers.value('mcp-session-id'); + if (initResponse.statusCode != HttpStatus.ok || + sessionId != 'stateful-session-id') { + throw StateError( + 'Expected stateful initialize to create a session, got ' + '${initResponse.statusCode} with session $sessionId.', + ); + } + final confirmedSessionId = sessionId!; + + final statelessRequest = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + statelessRequest.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', _draftProtocolVersion2026_07_28) + ..set('Mcp-Method', Method.toolsList) + ..set('Mcp-Session-Id', confirmedSessionId); + statelessRequest.write( + jsonEncode( + JsonRpcListToolsRequest( + id: 'stateless-tools', + meta: _statelessRequestMeta(), + ).toJson(), + ), + ); + + final statelessResponse = await statelessRequest.close(); + final responseBody = jsonDecode(await utf8.decodeStream(statelessResponse)) + as Map; + if (statelessResponse.statusCode != HttpStatus.ok) { + throw StateError( + 'Expected stateless request to succeed, got ' + '${statelessResponse.statusCode}: $responseBody.', + ); + } + if (statelessResponse.headers.value('mcp-session-id') != null) { + throw StateError( + 'Expected stateless response to omit Mcp-Session-Id, got ' + '${statelessResponse.headers.value('mcp-session-id')}.', + ); + } + if (responseBody['id'] != 'stateless-tools') { + throw StateError('Unexpected stateless response body $responseBody.'); + } + } finally { + httpClient.close(force: true); + await serverSubscription.cancel(); + await httpServer.close(force: true); + await transport.close(); + } +} + +Future _taskSubscriptionRequiresClientCapability() async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + enableDnsRebindingProtection: false, + ), + ); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ); + final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final httpClient = HttpClient(); + + await server.connect(transport); + final serverSubscription = httpServer.listen((request) { + unawaited(transport.handleRequest(request)); + }); + + try { + final request = await httpClient.postUrl( + Uri.parse('http://127.0.0.1:${httpServer.port}/mcp'), + ); + request.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', _draftProtocolVersion2026_07_28) + ..set('Mcp-Method', _methodSubscriptionsListen); + request.write( + jsonEncode( + { + 'jsonrpc': jsonRpcVersion, + 'id': 'http-task-subscription-capability', + 'method': _methodSubscriptionsListen, + 'params': { + '_meta': _statelessRequestMeta(), + 'notifications': { + 'taskIds': ['task-1'], + }, + }, + }, + ), + ); + + final response = await request.close(); + final responseBody = + jsonDecode(await utf8.decodeStream(response)) as Map; + + if (response.statusCode != HttpStatus.badRequest) { + throw StateError( + 'Expected HTTP 400 for missing stateless task extension capability, got ' + '${response.statusCode}.', + ); + } + if (responseBody['id'] != 'http-task-subscription-capability') { + throw StateError( + 'Expected JSON-RPC error id http-task-subscription-capability, got ' + "${responseBody['id']}.", + ); + } + final error = responseBody['error']; + if (error is! Map || + error['code'] != ErrorCode.missingRequiredClientCapability.value) { + throw StateError( + 'Expected MissingRequiredClientCapability error, got $error.', + ); + } + if (!'${error['message']}'.contains('Missing required client capability')) { + throw StateError( + 'Expected MissingRequiredClientCapability message, ' + 'got ${error['message']}.', + ); + } + _expectMissingTasksExtensionCapabilityData(error['data']); + } finally { + httpClient.close(force: true); + await serverSubscription.cancel(); + await httpServer.close(force: true); + await server.close(); + } +} + +Future _relatedTaskUsesExplicitIdAcrossTransports() async { + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ); + var handlerCalls = 0; + final seenTaskIds = []; + final errors = []; + server.onerror = errors.add; + server.setRequestHandler( + _methodTasksUpdate, + (request, extra) async { + if (extra.sessionId != null) { + throw StateError('Stateless task request unexpectedly had a session.'); + } + final params = request.params; + if (params == null) { + throw StateError('Expected task update params.'); + } + final taskId = params['taskId']; + if (taskId is! String) { + throw StateError('Expected task update params to include taskId.'); + } + final inputResponses = params['inputResponses']; + if (inputResponses is! Map || inputResponses.isNotEmpty) { + throw StateError('Expected empty task inputResponses.'); + } + handlerCalls += 1; + seenTaskIds.add(taskId); + return const EmptyResult(); + }, + (id, params, meta) => JsonRpcRequest( + id: id, + method: _methodTasksUpdate, + params: params, + meta: meta, + ), + ); + + Future> updateTaskOverNewTransport(int id) async { + final transport = _ConformanceTransport(); + await server.connect(transport); + transport.emit( + JsonRpcRequest( + id: id, + method: _methodTasksUpdate, + params: const { + 'taskId': 'task-connection', + 'inputResponses': {}, + }, + meta: _statelessRequestMeta( + capabilities: const ClientCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 100)); + if (transport.sentMessages.isEmpty && errors.isNotEmpty) { + throw StateError('Server errors: $errors.'); + } + final response = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: id, + ); + await server.close(); + return response.result; + } + + try { + final firstResult = await updateTaskOverNewTransport(201); + final secondResult = await updateTaskOverNewTransport(202); + + if (seenTaskIds.length != 2 || + seenTaskIds.any((taskId) => taskId != 'task-connection')) { + throw StateError( + 'Expected both task updates to use the explicit task ID, got ' + '$seenTaskIds.', + ); + } + if (firstResult['resultType'] != _resultTypeComplete || + secondResult['resultType'] != _resultTypeComplete) { + throw StateError( + 'Expected stateless task updates to receive complete acknowledgements, ' + 'got $firstResult and $secondResult.', + ); + } + if (handlerCalls != 2) { + throw StateError('Expected two task handler calls, got $handlerCalls.'); + } + } finally { + await server.close(); + } +} + +Future _statelessIgnoresLegacyTaskParameter() async { + final transport = _ConformanceTransport(); + RequestHandlerExtra? receivedExtra; + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + tasks: ServerCapabilitiesTasks( + requests: ServerCapabilitiesTasksRequests( + tools: ServerCapabilitiesTasksTools( + call: ServerCapabilitiesTasksToolsCall(), + ), + ), + ), + ), + ), + ); + server.setRequestHandler( + Method.toolsCall, + (request, extra) async { + receivedExtra = extra; + return const CallToolResult( + content: [TextContent(text: 'ok')], + ); + }, + (id, params, meta) => JsonRpcCallToolRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + + await server.connect(transport); + transport.emit( + JsonRpcCallToolRequest( + id: 'legacy-task-param', + params: { + ...const CallToolRequest(name: 'legacy-task').toJson(), + 'task': {'ttl': 1000}, + }, + meta: _statelessRequestMeta(), + ), + ); + await _settle(); + + final response = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: 'legacy-task-param', + ); + if (response.result['resultType'] != _resultTypeComplete || + receivedExtra?.taskRequestedTtl != null) { + throw StateError( + 'Expected stateless request to ignore legacy task parameter; result ' + '${response.result}, taskRequestedTtl ' + '${receivedExtra?.taskRequestedTtl}.', + ); + } + + await server.close(); +} + +Future _statelessClientRejectsLegacyTaskOptions() async { + final transport = _DiscoveringConformanceTransport( + toolsListResult: const { + 'resultType': _resultTypeComplete, + 'tools': [], + 'ttlMs': 0, + 'cacheScope': CacheScope.private, + }, + toolsCallResult: const { + 'resultType': _resultTypeComplete, + 'content': [], + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + + await client.connect(transport); + final sentBeforeCall = transport.sentMessages.length; + + try { + await client.callTool( + const CallToolRequest(name: 'legacy-task'), + options: const RequestOptions(task: TaskCreation(ttl: 1000)), + ); + } on McpError catch (error) { + if (error.code != ErrorCode.invalidRequest.value || + !error.message.contains('RequestOptions.task')) { + throw StateError( + 'Expected InvalidRequest for RequestOptions.task, got ' + '${error.code}: ${error.message}.', + ); + } + final toolsCallRequests = transport.sentMessages + .skip(sentBeforeCall) + .whereType() + .where((request) => request.method == Method.toolsCall) + .toList(); + if (toolsCallRequests.isNotEmpty) { + throw StateError( + 'Expected no stateless tools/call request after legacy task option, ' + 'got ${toolsCallRequests.single.toJson()}.', + ); + } + await client.close(); + return; + } + + await client.close(); + throw StateError('Expected stateless client to reject RequestOptions.task.'); +} + +Future _statelessAddsResultTypeAndCacheDefaults() async { + final transport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + prompts: ServerCapabilitiesPrompts(), + resources: ServerCapabilitiesResources(), + tools: ServerCapabilitiesTools(), + ), + ), + ); + server.setRequestHandler( + Method.toolsList, + (request, extra) async => const ListToolsResult( + tools: [], + ttlMs: 300000, + cacheScope: CacheScope.public, + ), + (id, params, meta) => JsonRpcListToolsRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsList, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + server.setRequestHandler( + Method.promptsList, + (request, extra) async => const ListPromptsResult(prompts: []), + (id, params, meta) => JsonRpcListPromptsRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.promptsList, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + server.setRequestHandler( + Method.resourcesList, + (request, extra) async => const ListResourcesResult( + resources: [], + ), + (id, params, meta) => JsonRpcListResourcesRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.resourcesList, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + server.setRequestHandler( + Method.resourcesTemplatesList, + (request, extra) async => const ListResourceTemplatesResult( + resourceTemplates: [], + ), + (id, params, meta) => JsonRpcListResourceTemplatesRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.resourcesTemplatesList, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + server.setRequestHandler( + Method.resourcesRead, + (request, extra) async => const ReadResourceResult( + contents: [ + TextResourceContents(uri: 'file:///a.txt', text: 'a'), + ], + ), + (id, params, meta) => JsonRpcReadResourceRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.resourcesRead, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + + await server.connect(transport); + final requests = [ + JsonRpcListToolsRequest( + id: 'tools-list', + meta: _statelessRequestMeta(), + ), + JsonRpcListPromptsRequest( + id: 'prompts-list', + meta: _statelessRequestMeta(), + ), + JsonRpcListResourcesRequest( + id: 'resources-list', + meta: _statelessRequestMeta(), + ), + JsonRpcListResourceTemplatesRequest( + id: 'resource-templates-list', + meta: _statelessRequestMeta(), + ), + JsonRpcReadResourceRequest( + id: 'resources-read', + readParams: const ReadResourceRequest(uri: 'file:///a.txt'), + meta: _statelessRequestMeta(), + ), + ]; + for (final request in requests) { + transport.emit(request); + await _settle(); + } + + final responses = transport.sentMessages.cast().toList(); + if (responses.length != requests.length) { + throw StateError( + 'Expected ${requests.length} cacheable responses, got ' + '${responses.length}: ${transport.sentMessages}.', + ); + } + + for (final response in responses) { + final result = response.result; + if (result['resultType'] != _resultTypeComplete) { + throw StateError( + 'Expected stateless ${response.id} resultType complete, got $result.', + ); + } + } + + final toolsResult = responses.first.result; + if (toolsResult['ttlMs'] != 300000 || + toolsResult['cacheScope'] != CacheScope.public) { + throw StateError( + 'Expected explicit tools/list cache hints to be preserved, got ' + '$toolsResult.', + ); + } + + for (final response in responses.skip(1)) { + final result = response.result; + if (result['ttlMs'] != 0 || result['cacheScope'] != _cacheScopePrivate) { + throw StateError( + 'Expected stateless ${response.id} cache defaults, got $result.', + ); + } + } + + await server.close(); +} + +Future _statelessToolsListReturnsDeterministicOrder() async { + final transport = _ConformanceTransport(); + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), + ); + for (final name in const ['zeta', 'alpha', 'middle']) { + server.registerTool( + name, + callback: (args, extra) async { + return const CallToolResult( + content: [TextContent(text: 'ok')], + ); + }, + ); + } + + await server.connect(transport); + transport.emit( + JsonRpcListToolsRequest( + id: 'tools-order', + meta: _statelessRequestMeta(), + ), + ); + await _settle(); + + final response = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: 'tools-order', + ); + final tools = response.result['tools']; + if (tools is! List) { + throw StateError('Expected tools/list result tools array, got $tools.'); + } + final names = tools.map((tool) { + if (tool is! Map) { + throw StateError('Expected tool object, got $tool.'); + } + final name = tool['name']; + if (name is! String) { + throw StateError('Expected tool name string, got $tool.'); + } + return name; + }).toList(growable: false); + const expectedNames = ['alpha', 'middle', 'zeta']; + if (!_stringListEquals(names, expectedNames)) { + throw StateError( + 'Expected deterministic tools/list order $expectedNames, got $names.', + ); + } + + await server.close(); +} + +Future _statelessToolsListOmitsLegacyExecution() async { + final transport = _ConformanceTransport(); + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), + ); + server.server.setRequestHandler( + Method.toolsList, + (request, extra) async => const ListToolsResult( + tools: [ + Tool( + name: 'task-tool', + inputSchema: JsonObject(), + execution: ToolExecution(taskSupport: 'required'), + ), + ], + ), + (id, params, meta) => JsonRpcListToolsRequest( + id: id, + params: params, + meta: meta, + ), + ); + + await server.connect(transport); + transport.emit( + JsonRpcListToolsRequest( + id: 'tools-execution', + meta: _statelessRequestMeta(), + ), + ); + await _settle(); + + final response = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: 'tools-execution', + ); + final tools = response.result['tools']; + if (tools is! List || tools.length != 1 || tools.single is! Map) { + throw StateError('Expected one tool object, got $tools.'); + } + final tool = tools.single as Map; + if (tool.containsKey('execution')) { + throw StateError( + 'Expected stateless tools/list to omit legacy execution, got $tool.', + ); + } + + await server.close(); +} + +Future _missingResourceErrorCodeByVersion() async { + final legacyTransport = _ConformanceTransport(); + final legacyServer = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + legacyServer.registerResource( + 'Known Resource', + 'memory://known', + null, + (uri, extra) async => ReadResourceResult( + contents: [ + TextResourceContents(uri: uri.toString(), text: 'known'), + ], + ), + ); + + await _initializeMcpServer(legacyServer, legacyTransport); + legacyTransport.emit( + JsonRpcReadResourceRequest( + id: 'legacy-missing-resource', + readParams: const ReadResourceRequest(uri: 'memory://missing'), + ), + ); + await _settle(); + + var error = _expectSingleError( + legacyTransport.sentMessages, + id: 'legacy-missing-resource', + code: ErrorCode.resourceNotFound.value, + messageContains: 'Resource not found', + ); + if (error.error.data is! Map || + (error.error.data as Map)['uri'] != 'memory://missing') { + throw StateError( + 'Expected legacy missing resource URI in error data, got ' + '${error.error.data}.', + ); + } + await legacyServer.close(); + + final statelessTransport = _ConformanceTransport(); + final statelessServer = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + statelessServer.registerResource( + 'Known Resource', + 'memory://known', + null, + (uri, extra) async => ReadResourceResult( + contents: [ + TextResourceContents(uri: uri.toString(), text: 'known'), + ], + ), + ); + + await statelessServer.connect(statelessTransport); + statelessTransport.emit( + JsonRpcReadResourceRequest( + id: 'stateless-missing-resource', + readParams: const ReadResourceRequest(uri: 'memory://missing'), + meta: _statelessRequestMeta(), + ), + ); + await _settle(); + + error = _expectSingleError( + statelessTransport.sentMessages, + id: 'stateless-missing-resource', + code: ErrorCode.invalidParams.value, + messageContains: 'Resource not found', + ); + if (error.error.data is! Map || + (error.error.data as Map)['uri'] != 'memory://missing') { + throw StateError( + 'Expected stateless missing resource URI in error data, got ' + '${error.error.data}.', + ); + } + + await statelessServer.close(); +} + +Future _statelessRejectsUnrecognizedResultType() async { + final transport = _DiscoveringConformanceTransport( + toolsListResult: const { + 'resultType': _resultTypeFutureExtension, + 'tools': [], + 'ttlMs': 0, + 'cacheScope': _cacheScopePrivate, + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + + try { + await client.connect(transport); + try { + await client.listTools(); + } on McpError catch (error) { + if (error.code != ErrorCode.internalError.value) { + throw StateError( + 'Expected internal error for unrecognized resultType, got ' + '${error.code}.', + ); + } + final data = error.data.toString(); + if (!data.contains( + 'Unrecognized MCP resultType "$_resultTypeFutureExtension"', + )) { + throw StateError( + 'Expected unrecognized resultType diagnostic, got ${error.data}.', + ); + } + return; + } + + throw StateError( + 'Expected unrecognized stateless resultType to be rejected.', + ); + } finally { + await client.close(); + } +} + +Future _mrtrInputRequiredSupportedRequests() async { + final transport = _ConformanceTransport(); + // Raw protocol conformance needs the low-level server so resultType + // validation is exercised directly at the JSON-RPC boundary. + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + prompts: ServerCapabilitiesPrompts(), + resources: ServerCapabilitiesResources(), + tools: ServerCapabilitiesTools(), + ), + ), + ); + + server.setRequestHandler( + Method.toolsCall, + (request, extra) async => + const InputRequiredResult(requestState: 'tool-state'), + (id, params, meta) => JsonRpcCallToolRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + server.setRequestHandler( + Method.promptsGet, + (request, extra) async => + const InputRequiredResult(requestState: 'prompt-state'), + (id, params, meta) => JsonRpcGetPromptRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.promptsGet, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + server.setRequestHandler( + Method.resourcesRead, + (request, extra) async => + const InputRequiredResult(requestState: 'resource-state'), + (id, params, meta) => JsonRpcReadResourceRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.resourcesRead, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + + try { + await server.connect(transport); + + final scenarios = >[ + MapEntry( + JsonRpcCallToolRequest( + id: 'mrtr-tool', + params: const CallToolRequest(name: 'needs-input').toJson(), + meta: _statelessRequestMeta(), + ), + 'tool-state', + ), + MapEntry( + JsonRpcGetPromptRequest( + id: 'mrtr-prompt', + getParams: const GetPromptRequest(name: 'needs_input'), + meta: _statelessRequestMeta(), + ), + 'prompt-state', + ), + MapEntry( + JsonRpcReadResourceRequest( + id: 'mrtr-resource', + readParams: const ReadResourceRequest(uri: 'memory://needs-input'), + meta: _statelessRequestMeta(), + ), + 'resource-state', + ), + ]; + + for (final scenario in scenarios) { + transport.sentMessages.clear(); + transport.emit(scenario.key); + await _settle(); + + final response = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: scenario.key.id, + ); + if (response.result['resultType'] != _resultTypeInputRequired || + response.result['requestState'] != scenario.value) { + throw StateError( + 'Expected ${scenario.key.method} to allow input_required with ' + 'requestState ${scenario.value}, got ${response.result}.', + ); + } + } + } finally { + await server.close(); + } +} + +Future _mrtrRejectsUnsupportedInputRequiredResults() async { + final transport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), + ), + ); + server.setRequestHandler( + Method.toolsList, + (request, extra) async => + const InputRequiredResult(requestState: 'list-state'), + (id, params, meta) => JsonRpcListToolsRequest( + id: id, + params: params, + meta: meta, + ), + ); + + try { + await server.connect(transport); + transport.emit( + JsonRpcListToolsRequest( + id: 'mrtr-list-tools', + meta: _statelessRequestMeta(), + ), + ); + await _settle(); + + _expectSingleError( + transport.sentMessages, + id: 'mrtr-list-tools', + code: ErrorCode.invalidParams.value, + messageContains: 'InputRequiredResult', + ); + } finally { + await server.close(); + } +} + +Future _mrtrInputRequestsRequireClientCapabilities() async { + final transport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), + ), + ); + server.setRequestHandler( + Method.toolsCall, + (request, extra) async { + final inputRequest = switch (request.callParams.name) { + 'needs-form' => InputRequest.elicit( + ElicitRequest.form( + message: 'Enter name', + requestedSchema: JsonSchema.object( + properties: { + 'name': JsonSchema.string(), + }, + required: const ['name'], + ), + ), + ), + 'needs-url' => InputRequest.elicit( + const ElicitRequest.url( + message: 'Open browser', + url: 'https://example.com/authorize', + elicitationId: 'auth-1', + ), + ), + 'needs-roots' => InputRequest.listRoots(), + 'needs-sampling-tools' => InputRequest.createMessage( + const CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Search'), + ), + ], + maxTokens: 16, + tools: [ + Tool(name: 'lookup', inputSchema: JsonObject()), + ], + ), + ), + _ => throw StateError('Unknown tool ${request.callParams.name}'), + }; + + return InputRequiredResult( + inputRequests: { + request.callParams.name: inputRequest, + }, + ); + }, + (id, params, meta) => JsonRpcCallToolRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + + final missingCapabilityScenarios = <_MissingCapabilityScenario>[ + _MissingCapabilityScenario( + name: 'needs-form', + capabilities: const ClientCapabilities(), + method: Method.elicitationCreate, + requiredCapabilities: const { + 'elicitation': { + 'form': {}, + }, + }, + ), + _MissingCapabilityScenario( + name: 'needs-url', + capabilities: const ClientCapabilities( + elicitation: ClientElicitation.formOnly(), + ), + method: Method.elicitationCreate, + requiredCapabilities: const { + 'elicitation': { + 'url': {}, + }, + }, + ), + _MissingCapabilityScenario( + name: 'needs-roots', + capabilities: const ClientCapabilities(), + method: Method.rootsList, + requiredCapabilities: const { + 'roots': {}, + }, + ), + _MissingCapabilityScenario( + name: 'needs-sampling-tools', + capabilities: const ClientCapabilities( + sampling: ClientCapabilitiesSampling(), + ), + method: Method.samplingCreateMessage, + requiredCapabilities: const { + 'sampling': { + 'tools': {}, + }, + }, + ), + ]; + + try { + await server.connect(transport); + + for (final scenario in missingCapabilityScenarios) { + transport.sentMessages.clear(); + transport.emit( + JsonRpcCallToolRequest( + id: scenario.name, + params: CallToolRequest(name: scenario.name).toJson(), + meta: _statelessRequestMeta(capabilities: scenario.capabilities), + ), + ); + await _settle(); + + final error = _expectSingleError( + transport.sentMessages, + id: scenario.name, + code: ErrorCode.missingRequiredClientCapability.value, + messageContains: 'Missing required client capability', + ); + final data = error.error.data; + if (data is! Map || + data['inputRequest'] != scenario.name || + data['method'] != scenario.method || + !_mapsDeepEqual( + data['requiredCapabilities'], + scenario.requiredCapabilities, + )) { + throw StateError( + 'Expected missing client capability details for ${scenario.name}, ' + 'got $data.', + ); + } + } + + transport.sentMessages.clear(); + transport.emit( + JsonRpcCallToolRequest( + id: 'mrtr-allowed-form', + params: const CallToolRequest(name: 'needs-form').toJson(), + meta: _statelessRequestMeta( + capabilities: const ClientCapabilities( + elicitation: ClientElicitation.formOnly(), + ), + ), + ), + ); + await _settle(); + + final allowedResponse = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: 'mrtr-allowed-form', + ); + if (allowedResponse.result['resultType'] != _resultTypeInputRequired) { + throw StateError( + 'Expected declared form elicitation capability to allow MRTR input ' + 'request, got ${allowedResponse.result}.', + ); + } + } finally { + await server.close(); + } +} + +Future _callToolResultCannotSpoofTaskResult() async { + final transport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ); + + server.setRequestHandler( + Method.toolsCall, + (request, extra) async => const CallToolResult( + content: [TextContent(text: 'spoof')], + extra: { + 'resultType': 'task', + 'taskId': 'spoofed-task', + }, + ), + (id, params, meta) => JsonRpcCallToolRequest.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }, + ), + ); + + try { + await server.connect(transport); + transport.emit( + JsonRpcCallToolRequest( + id: 'spoof-task-result', + params: const CallToolRequest(name: 'spoof').toJson(), + meta: _statelessRequestMeta( + capabilities: const ClientCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ), + ); + await _settle(); + + _expectSingleError( + transport.sentMessages, + id: 'spoof-task-result', + code: ErrorCode.invalidParams.value, + messageContains: 'CallToolResult cannot set MCP resultType', + ); + } finally { + await server.close(); + } +} + +Future _taskResultRequiresClientExtension() async { + final transport = _DiscoveringConformanceTransport( + capabilities: const { + 'tools': {}, + 'extensions': { + _tasksExtensionId: {}, + }, + }, + toolsListResult: const { + 'resultType': _resultTypeComplete, + 'tools': [], + 'ttlMs': 0, + 'cacheScope': _cacheScopePrivate, + }, + toolsCallResult: const { + 'resultType': 'task', + 'taskId': 'task-without-client-extension', + 'status': 'working', + 'createdAt': '2026-07-28T00:00:00Z', + 'lastUpdatedAt': '2026-07-28T00:00:00Z', + 'ttlMs': null, + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + + try { + await client.connect(transport); + try { + await client.callTool(const CallToolRequest(name: 'delayed')); + } on McpError catch (error) { + if (error.code != ErrorCode.internalError.value) { + throw StateError( + 'Expected internal error for unnegotiated task result, got ' + '${error.code}.', + ); + } + final data = error.data.toString(); + if (!data.contains('MCP resultType "task" is not valid for tools/call')) { + throw StateError( + 'Expected unnegotiated task result diagnostic, got ${error.data}.', + ); + } + return; + } + + throw StateError( + 'Expected unnegotiated task resultType to be rejected.', + ); + } finally { + await client.close(); + } +} + +Future _rejectsRemovedStatelessCoreRpcs() async { + final transport = _ConformanceTransport(); + // Raw protocol conformance needs the low-level server so removed core RPCs + // are not intercepted by high-level convenience handlers. + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + ); + await server.connect(transport); + + final removedRequests = [ + JsonRpcRequest( + id: 1, + method: Method.initialize, + params: const { + 'protocolVersion': _draftProtocolVersion2026_07_28, + 'capabilities': {}, + 'clientInfo': { + 'name': 'client', + 'version': '1.0.0', + }, + }, + meta: _statelessRequestMeta(), + ), + JsonRpcRequest( + id: 2, + method: Method.ping, + meta: _statelessRequestMeta(), + ), + JsonRpcRequest( + id: 3, + method: Method.loggingSetLevel, + params: const {'level': 'info'}, + meta: _statelessRequestMeta(), + ), + JsonRpcRequest( + id: 4, + method: Method.resourcesSubscribe, + params: const {'uri': 'file:///tmp/example.txt'}, + meta: _statelessRequestMeta(), + ), + JsonRpcRequest( + id: 5, + method: Method.resourcesUnsubscribe, + params: const {'uri': 'file:///tmp/example.txt'}, + meta: _statelessRequestMeta(), + ), + ]; + + for (final request in removedRequests) { + transport.sentMessages.clear(); + + transport.emit(request); + await _settle(); + + _expectSingleError( + transport.sentMessages, + id: request.id, + code: ErrorCode.methodNotFound.value, + messageContains: request.method, + ); + } + + await server.close(); +} + +Future _rejectsRemovedStatelessCoreNotifications() async { + final transport = _ConformanceTransport(); + final errors = []; + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + )..onerror = errors.add; + await server.connect(transport); + + final removedNotifications = [ + _notificationFromWire( + { + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsInitialized, + 'params': { + '_meta': _statelessRequestMeta(), + }, + }, + ), + _notificationFromWire( + { + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsRootsListChanged, + 'params': { + '_meta': _statelessRequestMeta(), + }, + }, + ), + _notificationFromWire( + { + 'jsonrpc': jsonRpcVersion, + 'method': _methodNotificationsTasksStatus, + 'params': { + '_meta': _statelessRequestMeta(), + 'taskId': 'task-1', + 'status': 'working', + 'ttl': null, + 'createdAt': '2026-07-28T00:00:00Z', + 'lastUpdatedAt': '2026-07-28T00:00:00Z', + }, + }, + ), + ]; + + for (final notification in removedNotifications) { + errors.clear(); + transport.sentMessages.clear(); + + transport.emit(notification); + await _settle(); + + _expectSingleProtocolError( + errors, + code: ErrorCode.methodNotFound.value, + messageContains: notification.method, + ); + if (transport.sentMessages.isNotEmpty) { + throw StateError( + 'Removed stateless notification ${notification.method} sent a response.', + ); + } + } + + await server.close(); +} + +Future _statelessLoggingRequiresRequestLogLevel() async { + final transport = _ConformanceTransport(); + // Raw protocol conformance needs the low-level server so request-scoped + // logging can be emitted from inside the registered handler. + // ignore: deprecated_member_use + late final Server server; + // ignore: deprecated_member_use + server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + logging: {}, + tools: ServerCapabilitiesTools(), + ), + ), + ); + server.setRequestHandler( + Method.toolsList, + (request, extra) async { + await server.sendLoggingMessage( + const LoggingMessageNotification( + level: LoggingLevel.debug, + data: 'below-threshold', + ), + requestMeta: extra.meta, + ); + await server.sendLoggingMessage( + const LoggingMessageNotification( + level: LoggingLevel.warning, + data: 'threshold-match', + ), + requestMeta: extra.meta, + ); + return const ListToolsResult(tools: []); + }, + (id, params, meta) => JsonRpcListToolsRequest( + id: id, + params: params, + meta: meta, + ), + ); + await server.connect(transport); + + transport.emit( + JsonRpcListToolsRequest( + id: 'without-log-level', + meta: _statelessRequestMeta(), + ), + ); + await _settle(); + + if (transport.sentMessages.length != 1 || + transport.sentMessages.single is! JsonRpcResponse) { + throw StateError( + 'Expected only a tools/list response without stateless logLevel, got ' + '${transport.sentMessages}.', + ); + } + + transport.sentMessages.clear(); + transport.emit( + JsonRpcListToolsRequest( + id: 'with-log-level', + meta: { + ..._statelessRequestMeta(), + McpMetaKey.logLevel: 'warning', + }, + ), + ); + await _settle(); + + final loggingNotifications = transport.sentMessages + .whereType() + .where((message) => message.method == Method.notificationsMessage) + .toList(); + if (loggingNotifications.length != 1) { + throw StateError( + 'Expected exactly one threshold-matching stateless log notification, got ' + '$loggingNotifications.', + ); + } + final loggingParams = loggingNotifications.single.params; + if (loggingParams?['level'] != LoggingLevel.warning.name || + loggingParams?['data'] != 'threshold-match') { + throw StateError( + 'Expected warning threshold log notification, got ' + '$loggingParams.', + ); + } + final responses = + transport.sentMessages.whereType().toList(); + _expectSingleErrorFreeResponse(responses, id: 'with-log-level'); + + await server.close(); +} + +Future _taskLifecycleMethodsAllowResumedClientCapability() async { + final transport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ); + await server.connect(transport); + + final requests = [ + JsonRpcRequest( + id: 'task-get', + method: _methodTasksGet, + params: const {'taskId': 'task-1'}, + meta: _statelessRequestMeta(), + ), + JsonRpcRequest( + id: 'task-update', + method: _methodTasksUpdate, + params: const { + 'taskId': 'task-1', + 'inputResponses': {}, + }, + meta: _statelessRequestMeta(), + ), + JsonRpcRequest( + id: 'task-cancel', + method: Method.tasksCancel, + params: const {'taskId': 'task-1'}, + meta: _statelessRequestMeta(), + ), + ]; + + for (final request in requests) { + transport.sentMessages.clear(); + transport.emit(request); + await _settle(); + + _expectSingleError( + transport.sentMessages, + id: request.id, + code: ErrorCode.methodNotFound.value, + messageContains: request.method, + ); + } + + await server.close(); +} + +Future _taskStoreUsesTaskExtensionResultShapes() async { + final store = InMemoryTaskStore(); + final completedTask = await store.createTask( + const TaskCreation(ttl: 60000), + 'source-request', + const { + 'method': Method.toolsCall, + 'params': {'name': 'long-running'}, + }, + null, + ); + await store.storeTaskResult( + completedTask.taskId, + TaskStatus.completed, + const CallToolResult( + content: [TextContent(text: 'task complete')], + ), + ); + final workingTask = await store.createTask( + const TaskCreation(ttl: null), + 'cancel-request', + const { + 'method': Method.toolsCall, + 'params': {'name': 'cancel-me'}, + }, + null, + ); + final transport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: _mcpServerOptionsWithTaskStore( + capabilities: const ServerCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + taskStore: store, + ), + ); + + try { + await server.connect(transport); + transport.emit( + JsonRpcRequest( + id: 'task-store-get', + method: _methodTasksGet, + params: {'taskId': completedTask.taskId}, + meta: _statelessRequestMeta( + capabilities: const ClientCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ), + ); + await _settle(); + + final getResponse = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: 'task-store-get', + ); + final getResult = getResponse.result; + if (getResult['resultType'] != _resultTypeComplete || + getResult['taskId'] != completedTask.taskId || + getResult['status'] != 'completed' || + getResult['ttlMs'] != 60000 || + getResult.containsKey('ttl') || + (getResult['result'] as Map?)?['content'] == null) { + throw StateError( + 'Expected built-in tasks/get to use the task extension result shape, ' + 'got $getResult.', + ); + } + + transport.sentMessages.clear(); + transport.emit( + JsonRpcRequest( + id: 'task-store-cancel', + method: Method.tasksCancel, + params: {'taskId': workingTask.taskId}, + meta: _statelessRequestMeta( + capabilities: const ClientCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ), + ); + await _settle(); + + final cancelResponse = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: 'task-store-cancel', + ); + if (cancelResponse.result.length != 1 || + cancelResponse.result['resultType'] != _resultTypeComplete) { + throw StateError( + 'Expected built-in tasks/cancel to acknowledge with complete result, ' + 'got ${cancelResponse.result}.', + ); + } + final cancelledTask = await store.getTask(workingTask.taskId); + if (cancelledTask?.status != TaskStatus.cancelled) { + throw StateError( + 'Expected task ${workingTask.taskId} to be cancelled, ' + 'got ${cancelledTask?.status}.', + ); + } + } finally { + await server.close(); + store.dispose(); + } +} + +McpServerOptions _mcpServerOptionsWithTaskStore({ + required ServerCapabilities capabilities, + required InMemoryTaskStore taskStore, +}) { + // Keep this dynamic so mcp_dart_cli remains analyzable against the published + // mcp_dart lower bound until this SDK branch is released. + return Function.apply( + McpServerOptions.new, + const [], + { + #capabilities: capabilities, + #taskStore: taskStore, + }, + ) as McpServerOptions; +} + +Future _subscriptionTaskIdsRequireClientCapability() async { + final transport = _ConformanceTransport(); + // Raw map parsing exercises the wire shape without depending on draft-only + // subscription request symbols in the hosted CLI package analysis. + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + extensions: >{ + _tasksExtensionId: {}, + }, + ), + ), + ); + await server.connect(transport); + + final request = JsonRpcMessage.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': 'task-subscription-capability', + 'method': _methodSubscriptionsListen, + 'params': { + '_meta': _statelessRequestMeta(), + 'notifications': { + 'taskIds': ['task-1'], + }, + }, + }, + ); + if (request is! JsonRpcRequest) { + throw StateError( + 'Expected subscriptions/listen to parse as a request, got ' + '${request.runtimeType}.', + ); + } + + transport.emit(request); + await _settle(); + + final error = _expectSingleError( + transport.sentMessages, + id: 'task-subscription-capability', + code: ErrorCode.missingRequiredClientCapability.value, + messageContains: 'Missing required client capability', + ); + _expectMissingTasksExtensionCapabilityData(error.error.data); + + await server.close(); +} + +Future _subscriptionsListenRequiresRequestMeta() async { + _expectThrowsFormatException( + () => JsonRpcMessage.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': 'missing-subscription-meta', + 'method': _methodSubscriptionsListen, + 'params': { + 'notifications': { + 'toolsListChanged': true, + }, + }, + }, + ), + ); + + final parsed = JsonRpcMessage.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': 'subscription-meta', + 'method': _methodSubscriptionsListen, + 'params': { + '_meta': _statelessRequestMeta(), + 'notifications': { + 'toolsListChanged': true, + }, + }, + }, + ); + if (parsed is! JsonRpcRequest || + parsed.meta?[_protocolVersionMetaKey] != + _draftProtocolVersion2026_07_28) { + throw StateError( + 'Expected subscriptions/listen request to preserve params._meta, got ' + '$parsed.', + ); + } +} + +Future _subscriptionsListenRequiresResourceSubscribeCapability() async { + final request = JsonRpcMessage.fromJson( + { + 'jsonrpc': jsonRpcVersion, + 'id': 'resource-subscription-capability', + 'method': _methodSubscriptionsListen, + 'params': { + '_meta': _statelessRequestMeta(), + 'notifications': { + 'resourceSubscriptions': ['file:///project/config.json'], + }, + }, + }, + ); + if (request is! JsonRpcRequest) { + throw StateError( + 'Expected subscriptions/listen to parse as a request, got ' + '${request.runtimeType}.', + ); + } + final notifications = request.params?['notifications']; + if (notifications is! Map) { + throw StateError( + 'Expected subscriptions/listen notifications object, got ' + '$notifications.', + ); + } + + final unacknowledged = _acknowledgeResourceSubscriptions( + notifications, + resourcesSubscribe: false, + ); + if (unacknowledged.containsKey('resourceSubscriptions')) { + throw StateError( + 'Expected resourceSubscriptions to be omitted when resources.subscribe ' + 'is not advertised, got $unacknowledged.', + ); + } + + final acknowledged = _acknowledgeResourceSubscriptions( + notifications, + resourcesSubscribe: true, + ); + final acknowledgedResources = acknowledged['resourceSubscriptions']; + if (acknowledgedResources is! List || + acknowledgedResources.single != 'file:///project/config.json') { + throw StateError( + 'Expected resourceSubscriptions to be acknowledged when ' + 'resources.subscribe is advertised, got $acknowledged.', + ); + } + + if (!_allowsResourceSubscription( + 'file:///project/config.json', + ['file:///project'], + )) { + throw StateError( + 'Expected resourceSubscriptions to allow notifications for ' + 'sub-resources of a subscribed URI.', + ); + } + if (_allowsResourceSubscription( + 'file:///project-other/config.json', + ['file:///project'], + )) { + throw StateError( + 'Expected resourceSubscriptions to reject sibling resources that only ' + 'share a string prefix.', + ); + } +} + +Future _subscriptionsAcknowledgedRejectsWrapperMismatch() async { + for (final message in const [ + { + 'jsonrpc': '1.0', + 'method': Method.notificationsSubscriptionsAcknowledged, + 'params': { + 'notifications': {}, + }, + }, + { + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsProgress, + 'params': { + 'notifications': {}, + }, + }, + ]) { + _expectThrowsFormatException( + () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson(message), + ); + } + + final parsed = JsonRpcSubscriptionsAcknowledgedNotification.fromJson( + const { + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsSubscriptionsAcknowledged, + 'params': { + 'notifications': { + 'toolsListChanged': true, + }, + }, + }, + ); + if (parsed.acknowledgedParams.notifications.toolsListChanged != true) { + throw StateError('Expected acknowledged toolsListChanged to parse.'); + } +} + +Map _acknowledgeResourceSubscriptions( + Map notifications, { + required bool resourcesSubscribe, +}) { + if (!resourcesSubscribe) { + return {}; + } + + final resourceSubscriptions = notifications['resourceSubscriptions']; + if (resourceSubscriptions is! List || + resourceSubscriptions.any((value) => value is! String)) { + return {}; + } + + return { + 'resourceSubscriptions': [ + for (final value in resourceSubscriptions) value as String, + ], + }; +} + +bool _allowsResourceSubscription(String uri, List subscribedUris) { + for (final subscribedUri in subscribedUris) { + if (uri == subscribedUri || _isSubResourceUri(uri, subscribedUri)) { + return true; + } + } + return false; +} + +bool _isSubResourceUri(String uri, String subscribedUri) { + final parsedUri = Uri.tryParse(uri); + final parsedSubscribedUri = Uri.tryParse(subscribedUri); + if (parsedUri == null || parsedSubscribedUri == null) { + return false; + } + if (parsedUri.scheme != parsedSubscribedUri.scheme || + parsedUri.authority != parsedSubscribedUri.authority) { + return false; + } + + final subscribedPath = parsedSubscribedUri.path; + final path = parsedUri.path; + if (subscribedPath.isEmpty || !path.startsWith(subscribedPath)) { + return false; + } + if (subscribedPath.endsWith('/')) { + return true; + } + return path.length > subscribedPath.length && + path.codeUnitAt(subscribedPath.length) == 0x2f; +} + +Future _rejectsUnnegotiatedSamplingTools() async { + final transport = _ConformanceTransport(); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + capabilities: ClientCapabilities( + sampling: ClientCapabilitiesSampling(), + ), + ), + ); + var handlerCalled = false; + client.onSamplingRequest = (params) async { + handlerCalled = true; + return const CreateMessageResult( + model: 'conformance-model', + role: SamplingMessageRole.assistant, + content: SamplingTextContent(text: 'unexpected'), + ); + }; + + await _initializeClient(client, transport); + transport.emit( + JsonRpcCreateMessageRequest( + id: 101, + createParams: const CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Use a tool'), + ), + ], + maxTokens: 4, + tools: [ + Tool(name: 'search', inputSchema: JsonObject()), + ], + ), + ), + ); + await _settle(); + + if (handlerCalled) { + throw StateError('sampling handler ran without sampling.tools capability.'); + } + _expectSingleError( + transport.sentMessages, + id: 101, + code: ErrorCode.methodNotFound.value, + messageContains: 'sampling.tools', + ); + await client.close(); +} + +Future _rejectsUnnegotiatedSamplingContext() async { + final transport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + ); + await server.connect(transport); + transport.emit( + _initializeRequest( + capabilities: const ClientCapabilities( + sampling: ClientCapabilitiesSampling(), + ), + ), + ); + await _settle(); + _expectSingleErrorFreeResponse(transport.sentMessages, id: 1); + transport.sentMessages.clear(); + transport.emit(const JsonRpcInitializedNotification()); + await _settle(); + transport.sentMessages.clear(); + + _expectMcpError( + () => server.createMessage( + const CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Use server context'), + ), + ], + includeContext: IncludeContext.thisServer, + maxTokens: 4, + ), + ), + code: ErrorCode.methodNotFound.value, + messageContains: 'sampling context', + ); + + final samplingRequests = transport.sentMessages + .whereType() + .where((message) => message.method == Method.samplingCreateMessage); + if (samplingRequests.isNotEmpty) { + throw StateError( + 'sampling/createMessage was sent without sampling.context capability.', + ); + } + await server.close(); +} + +Future _unadvertisedPeerMethodsUseMethodNotFound() async { + final clientTransport = _ConformanceTransport(); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + await _initializeClient(client, clientTransport); + _expectMcpError( + () => client.assertCapabilityForMethod(Method.toolsList), + code: ErrorCode.methodNotFound.value, + messageContains: 'tools', + ); + await client.close(); + + final statelessClientTransport = _DiscoveringConformanceTransport( + toolsListResult: const { + 'resultType': _resultTypeComplete, + 'tools': [], + 'ttlMs': 0, + 'cacheScope': _cacheScopePrivate, + }, + ); + final statelessClient = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + await statelessClient.connect(statelessClientTransport); + statelessClientTransport.sentMessages.clear(); + statelessClientTransport.onmessage?.call( + const JsonRpcListRootsRequest(id: 'roots-list'), + ); + await _settle(); + _expectSingleError( + statelessClientTransport.sentMessages, + id: 'roots-list', + code: ErrorCode.methodNotFound.value, + messageContains: 'roots', + ); + await statelessClient.close(); + + final serverTransport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities(), + ), + ); + await server.connect(serverTransport); + serverTransport.emit(_initializeRequest()); + await _settle(); + _expectSingleErrorFreeResponse(serverTransport.sentMessages, id: 1); + serverTransport.sentMessages.clear(); + serverTransport.emit(const JsonRpcInitializedNotification()); + await _settle(); + serverTransport.sentMessages.clear(); + _expectMcpError( + () => server.assertCapabilityForMethod(Method.rootsList), + code: ErrorCode.methodNotFound.value, + messageContains: 'roots', + ); + await server.close(); +} + +Future _taskScopedPeerMethodsUseMethodNotFound() async { + final clientTransport = _ConformanceTransport(); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + await _initializeClient( + client, + clientTransport, + serverCapabilities: const ServerCapabilities( + tools: ServerCapabilitiesTools(), + tasks: ServerCapabilitiesTasks(), + ), + ); + _expectMcpError( + () => client.assertTaskCapability(Method.toolsCall), + code: ErrorCode.methodNotFound.value, + messageContains: 'tasks.requests.tools.call', + ); + await client.close(); + + final serverTransport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + ); + await server.connect(serverTransport); + serverTransport.emit( + _initializeRequest( + capabilities: const ClientCapabilities( + sampling: ClientCapabilitiesSampling(), + tasks: ClientCapabilitiesTasks(), + ), + ), + ); + await _settle(); + _expectSingleErrorFreeResponse(serverTransport.sentMessages, id: 1); + serverTransport.sentMessages.clear(); + serverTransport.emit(const JsonRpcInitializedNotification()); + await _settle(); + _expectMcpError( + () => server.assertTaskCapability(Method.samplingCreateMessage), + code: ErrorCode.methodNotFound.value, + messageContains: 'tasks.requests.sampling.createMessage', + ); + await server.close(); +} + +Future _statelessOmitsLegacyTaskCapabilities() async { + const clientCapabilities = ClientCapabilities( + sampling: ClientCapabilitiesSampling(tools: true), + roots: ClientCapabilitiesRoots(listChanged: true), + tasks: ClientCapabilitiesTasks( + cancel: true, + list: true, + requests: ClientCapabilitiesTasksRequests( + sampling: ClientCapabilitiesTasksSampling( + createMessage: ClientCapabilitiesTasksSamplingCreateMessage(), + ), + ), + ), + extensions: >{ + _tasksExtensionId: {}, + }, + ); + if (!clientCapabilities.toJson().containsKey('tasks')) { + throw StateError('Expected stable client capabilities to include tasks.'); + } + + final statelessMeta = _statelessRequestMeta(capabilities: clientCapabilities); + final statelessCapabilities = + statelessMeta[_clientCapabilitiesMetaKey] as Map; + if (statelessCapabilities.containsKey('tasks')) { + throw StateError( + 'Expected 2026 request metadata to omit legacy tasks capability, got ' + '$statelessCapabilities.', + ); + } + final statelessRoots = statelessCapabilities['roots']; + if (statelessRoots is! Map || statelessRoots.containsKey('listChanged')) { + throw StateError( + 'Expected 2026 request metadata to omit legacy roots.listChanged ' + 'capability, got $statelessCapabilities.', + ); + } + final statelessExtensions = statelessCapabilities['extensions']; + if (statelessExtensions is! Map || + statelessExtensions[_tasksExtensionId] is! Map) { + throw StateError( + 'Expected 2026 request metadata to retain tasks extension, got ' + '$statelessCapabilities.', + ); + } + + final stableMeta = _statelessRequestMeta( + protocolVersion: latestProtocolVersion, + capabilities: clientCapabilities, + ); + final stableCapabilities = + stableMeta[_clientCapabilitiesMetaKey] as Map; + if (!stableCapabilities.containsKey('tasks')) { throw StateError( - 'Expected JsonRpcRequest, got ${parsed.runtimeType}.', + 'Expected stable request metadata to keep legacy tasks capability, got ' + '$stableCapabilities.', ); } - if (parsed.meta?[_protocolVersionMetaKey] != - _draftProtocolVersion2026_07_28) { - throw StateError('Expected server/discover metadata to be preserved.'); + final stableRoots = stableCapabilities['roots']; + if (stableRoots is! Map || stableRoots['listChanged'] != true) { + throw StateError( + 'Expected stable request metadata to keep roots.listChanged capability, ' + 'got $stableCapabilities.', + ); } -} -Future _rejectsUnnegotiatedSamplingTools() async { - final transport = _ConformanceTransport(); + final clientTransport = _DiscoveringConformanceTransport( + capabilities: const { + 'tools': {}, + 'extensions': { + _tasksExtensionId: {}, + }, + }, + toolsListResult: const { + 'resultType': _resultTypeComplete, + 'tools': [], + 'ttlMs': 0, + 'cacheScope': _cacheScopePrivate, + }, + ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: const McpClientOptions( - capabilities: ClientCapabilities( - sampling: ClientCapabilitiesSampling(), - ), + capabilities: clientCapabilities, + useServerDiscover: true, ), ); - var handlerCalled = false; - client.onSamplingRequest = (params) async { - handlerCalled = true; - return const CreateMessageResult( - model: 'conformance-model', - role: SamplingMessageRole.assistant, - content: SamplingTextContent(text: 'unexpected'), + await client.connect(clientTransport); + final discoverRequest = clientTransport.sentMessages + .whereType() + .firstWhere((message) => message.method == _serverDiscoverMethod); + final discoverClientCapabilities = + discoverRequest.meta?[_clientCapabilitiesMetaKey] as Map; + if (discoverClientCapabilities.containsKey('tasks')) { + throw StateError( + 'Expected client-generated server/discover metadata to omit legacy ' + 'tasks capability, got $discoverClientCapabilities.', ); - }; + } + await client.close(); - await _initializeClient(client, transport); - transport.emit( - JsonRpcCreateMessageRequest( - id: 101, - createParams: const CreateMessageRequest( - messages: [ - SamplingMessage( - role: SamplingMessageRole.user, - content: SamplingTextContent(text: 'Use a tool'), - ), - ], - maxTokens: 4, - tools: [ - Tool(name: 'search', inputSchema: JsonObject()), - ], + const serverCapabilities = ServerCapabilities( + tools: ServerCapabilitiesTools(), + tasks: ServerCapabilitiesTasks( + list: true, + cancel: true, + requests: ServerCapabilitiesTasksRequests( + tools: ServerCapabilitiesTasksTools( + call: ServerCapabilitiesTasksToolsCall(), + ), ), ), + extensions: >{ + _tasksExtensionId: {}, + }, ); - await _settle(); - - if (handlerCalled) { - throw StateError('sampling handler ran without sampling.tools capability.'); + if (!serverCapabilities.toJson().containsKey('tasks')) { + throw StateError('Expected stable server capabilities to include tasks.'); } - _expectSingleError( - transport.sentMessages, - id: 101, - code: ErrorCode.invalidRequest.value, - messageContains: 'sampling.tools', + + final serverTransport = _ConformanceTransport(); + // ignore: deprecated_member_use + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions(capabilities: serverCapabilities), ); - await client.close(); + await server.connect(serverTransport); + serverTransport.emit( + JsonRpcServerDiscoverRequest( + id: 'discover-capabilities', + meta: _statelessRequestMeta(), + ), + ); + await _settle(); + final response = _expectSingleErrorFreeResponse( + serverTransport.sentMessages, + id: 'discover-capabilities', + ); + final discoveredCapabilities = + response.result['capabilities'] as Map; + if (discoveredCapabilities.containsKey('tasks')) { + throw StateError( + 'Expected server/discover result to omit legacy tasks capability, got ' + '$discoveredCapabilities.', + ); + } + final discoveredExtensions = discoveredCapabilities['extensions']; + if (discoveredExtensions is! Map || + discoveredExtensions[_tasksExtensionId] is! Map) { + throw StateError( + 'Expected server/discover result to retain tasks extension, got ' + '$discoveredCapabilities.', + ); + } + await server.close(); } Future _rejectsInvalidElicitationVariantPayload() async { @@ -705,6 +5027,60 @@ Future _rejectsInvalidElicitationVariantPayload() async { ); } +Future _acceptsNumericElicitationNumberSchemaKeywords() async { + final parsed = JsonRpcMessage.fromJson(const { + 'jsonrpc': jsonRpcVersion, + 'id': 104, + 'method': Method.elicitationCreate, + 'params': { + 'mode': 'form', + 'message': 'Configure ratio', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'ratio': { + 'type': 'number', + 'minimum': 0.1, + 'maximum': 0.9, + 'default': 0.5, + }, + }, + }, + '_meta': { + _protocolVersionMetaKey: _draftProtocolVersion2026_07_28, + }, + }, + }); + if (parsed is! JsonRpcElicitRequest) { + throw StateError( + 'Expected JsonRpcElicitRequest, got ${parsed.runtimeType}.'); + } + + _expectThrowsFormatException( + () => JsonRpcMessage.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 104, + 'method': Method.elicitationCreate, + 'params': { + 'mode': 'form', + 'message': 'Configure ratio', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'ratio': { + 'type': 'number', + 'maximum': double.infinity, + }, + }, + }, + '_meta': { + _protocolVersionMetaKey: _draftProtocolVersion2026_07_28, + }, + }, + }), + ); +} + Future _stripsUnnegotiatedRelatedTaskMetadata() async { final transport = _ConformanceTransport(); final server = McpServer( @@ -873,6 +5249,50 @@ Future _rejectsResultErrorJsonRpcResponse() async { ); } +Future _rejectsMethodResponseJsonRpcEnvelope() async { + final messages = >[ + { + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': 'unknown/request', + 'result': {'ok': true}, + }, + { + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': 'unknown/request', + 'error': { + 'code': ErrorCode.invalidRequest.value, + 'message': 'Invalid request', + }, + }, + ]; + + for (final message in messages) { + _expectThrowsFormatException(() => JsonRpcMessage.fromJson(message)); + } + _expectThrowsFormatException( + () => JsonRpcError.fromJson(messages.last), + ); + _expectThrowsFormatException( + () => JsonRpcPingRequest.fromJson(messages.first), + ); + _expectThrowsFormatException( + () => JsonRpcProgressNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsProgress, + 'params': { + 'progressToken': 'progress-1', + 'progress': 1, + }, + 'error': { + 'code': ErrorCode.invalidRequest.value, + 'message': 'Invalid request', + }, + }), + ); +} + Future _rejectsMalformedJsonRpcErrorObject() async { _expectThrowsFormatException( () => JsonRpcMessage.fromJson(const { @@ -899,6 +5319,26 @@ Future _rejectsNullJsonRpcErrorResponseId() async { ); } +Future _acceptsOmittedJsonRpcErrorResponseId() async { + final message = JsonRpcMessage.fromJson(const { + 'jsonrpc': jsonRpcVersion, + 'error': { + 'code': -32600, + 'message': 'Invalid request', + }, + }); + + if (message is! JsonRpcError) { + throw StateError('Expected JsonRpcError, got ${message.runtimeType}.'); + } + if (message.id != null) { + throw StateError('Expected omitted error response ID to stay absent.'); + } + if (message.toJson().containsKey('id')) { + throw StateError('Expected serialized error response to omit id.'); + } +} + Future _rejectsNullJsonRpcParamsMember() async { for (final message in const [ { @@ -917,6 +5357,17 @@ Future _rejectsNullJsonRpcParamsMember() async { } } +Future _requiresCallToolRequestParams() async { + const message = { + 'jsonrpc': jsonRpcVersion, + 'id': 'call-1', + 'method': Method.toolsCall, + }; + + _expectThrowsFormatException(() => JsonRpcCallToolRequest.fromJson(message)); + _expectThrowsFormatException(() => JsonRpcMessage.fromJson(message)); +} + Future _rejectsFractionalIdsAndProgressTokens() async { for (final message in const [ { @@ -1039,6 +5490,104 @@ JsonRpcError _expectSingleError( return message; } +void _expectMissingTasksExtensionCapabilityData(Object? data) { + if (data is! Map) { + throw StateError('Expected missing-capability error data, got $data.'); + } + final requiredCapabilities = data['requiredCapabilities']; + if (requiredCapabilities is! Map) { + throw StateError( + 'Expected requiredCapabilities in missing-capability data, got $data.', + ); + } + final extensions = requiredCapabilities['extensions']; + if (extensions is! Map || extensions[_tasksExtensionId] is! Map) { + throw StateError( + 'Expected requiredCapabilities.extensions.$_tasksExtensionId, got $data.', + ); + } +} + +void _expectSingleProtocolError( + List errors, { + required int code, + required String messageContains, +}) { + if (errors.length != 1) { + throw StateError('Expected one protocol error, got ${errors.length}.'); + } + final error = errors.single; + if (error is! McpError) { + throw StateError('Expected McpError, got ${error.runtimeType}.'); + } + if (error.code != code) { + throw StateError('Expected error code $code, got ${error.code}.'); + } + if (!error.message.contains(messageContains)) { + throw StateError( + "Expected error message to contain '$messageContains', got " + "'${error.message}'.", + ); + } +} + +void _expectMcpError( + void Function() callback, { + required int code, + required String messageContains, +}) { + try { + callback(); + } on McpError catch (error) { + if (error.code != code) { + throw StateError('Expected error code $code, got ${error.code}.'); + } + if (!error.message.contains(messageContains)) { + throw StateError( + "Expected error message to contain '$messageContains', got " + "'${error.message}'.", + ); + } + return; + } + + throw StateError('Expected McpError.'); +} + +void _expectUnsupportedProtocolVersionData( + JsonRpcError error, { + required String requested, +}) { + final data = error.error.data; + if (data is! Map) { + throw StateError('Expected unsupported version error data, got $data.'); + } + final supported = data['supported']; + if (supported is! List || + !supported.contains(_draftProtocolVersion2026_07_28) || + !supported.contains('2025-11-25')) { + throw StateError( + 'Expected supported protocol versions in error data, got $supported.', + ); + } + if (data['requested'] != requested) { + throw StateError( + 'Expected requested protocol version $requested, got ' + "${data['requested']}.", + ); + } +} + +JsonRpcNotification _notificationFromWire(Map json) { + final message = JsonRpcMessage.fromJson(json); + if (message is! JsonRpcNotification) { + throw StateError( + 'Expected JsonRpcNotification, got ${message.runtimeType}.', + ); + } + return message; +} + Future _preservesStringProgressToken() async { final message = JsonRpcMessage.fromJson(const { 'jsonrpc': jsonRpcVersion, @@ -1101,6 +5650,47 @@ Future _advertisesLatestProtocolVersion() async { } } +Future _advertisesDraftProtocolVersion() async { + final transport = _ConformanceTransport(); + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities(), + ), + ); + + await server.connect(transport); + transport.emit( + JsonRpcRequest( + id: 'draft-version', + method: _serverDiscoverMethod, + meta: _statelessRequestMeta(), + ), + ); + await _settle(); + + final response = _expectSingleErrorFreeResponse( + transport.sentMessages, + id: 'draft-version', + ); + final supportedVersions = response.result['supportedVersions']; + if (supportedVersions is! List) { + throw StateError('Expected server/discover supportedVersions list.'); + } + if (supportedVersions.firstOrNull != _draftProtocolVersion2026_07_28) { + throw StateError( + 'Expected $_draftProtocolVersion2026_07_28 to be advertised first.', + ); + } + if (!supportedVersions.contains(_draftProtocolVersion2026_07_28)) { + throw StateError( + 'Expected server/discover to advertise $_draftProtocolVersion2026_07_28.', + ); + } + + await server.close(); +} + void _expectThrowsFormatException(void Function() callback) { try { callback(); @@ -1156,6 +5746,33 @@ void _expectProgressTokenRoundTrip(Map message) { } } +bool _mapsDeepEqual(Object? a, Object? b) { + if (a is Map && b is Map) { + if (a.length != b.length) { + return false; + } + for (final entry in a.entries) { + if (!b.containsKey(entry.key) || + !_mapsDeepEqual(entry.value, b[entry.key])) { + return false; + } + } + return true; + } + if (a is List && b is List) { + if (a.length != b.length) { + return false; + } + for (var i = 0; i < a.length; i++) { + if (!_mapsDeepEqual(a[i], b[i])) { + return false; + } + } + return true; + } + return a == b; +} + void _expectIdRoundTrip( RequestId actualId, Object expectedId, Map json) { if (actualId != expectedId) { diff --git a/packages/mcp_dart_cli/test/fixtures/raw_stdio_server.dart b/packages/mcp_dart_cli/test/fixtures/raw_stdio_server.dart index 85b0aba6..04a10439 100644 --- a/packages/mcp_dart_cli/test/fixtures/raw_stdio_server.dart +++ b/packages/mcp_dart_cli/test/fixtures/raw_stdio_server.dart @@ -72,7 +72,11 @@ Future main(List args) async { break; default: if (id != null) { - await _writeResponse(id, const {}); + await _writeErrorResponse( + id, + -32601, + 'Method not found', + ); } } } @@ -87,6 +91,22 @@ Future _writeResponse(Object? id, Map result) async { await stdout.flush(); } +Future _writeErrorResponse( + Object? id, + int code, + String message, +) async { + stdout.writeln(jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'error': { + 'code': code, + 'message': message, + }, + })); + await stdout.flush(); +} + Future _writeLoggingNotification() async { stdout.writeln(jsonEncode({ 'jsonrpc': '2.0', diff --git a/packages/mcp_dart_cli/test/src/conformance_command_test.dart b/packages/mcp_dart_cli/test/src/conformance_command_test.dart index 71eb2708..4faf8729 100644 --- a/packages/mcp_dart_cli/test/src/conformance_command_test.dart +++ b/packages/mcp_dart_cli/test/src/conformance_command_test.dart @@ -22,20 +22,25 @@ void main() { 'jsonrpc.rejects-malformed-message', 'jsonrpc.rejects-non-string-method', 'jsonrpc.rejects-result-error-response', + 'jsonrpc.rejects-method-response-envelope', 'jsonrpc.rejects-malformed-error-object', 'jsonrpc.rejects-null-error-response-id', + 'jsonrpc.accepts-omitted-error-response-id', 'jsonrpc.rejects-null-params-member', + 'tools-call.requires-params', 'jsonrpc.preserves-string-response-id', 'jsonrpc.preserves-integer-response-id', 'jsonrpc.preserves-string-progress-token', 'jsonrpc.preserves-integer-progress-token', 'jsonrpc.rejects-fractional-ids-and-progress-tokens', 'protocol-version.advertises-latest-2025-11-25', + 'protocol-version.advertises-draft-2026-07-28', ]), ); }); - test('spec suite covers MCP 2025-11-25 high-risk wire cases', () async { + test('spec suite covers high-risk wire cases across spec versions', + () async { final result = await ConformanceRunner().runSpecSuite(); expect(result.passed, isTrue); @@ -44,9 +49,55 @@ void main() { result.caseNames, containsAll([ 'lifecycle.rejects-pre-initialize-request', + 'lifecycle.gates-until-initialized-notification', + 'lifecycle.does-not-cancel-initialize', + 'cancellation.requires-request-id', 'server-discover.requires-request-meta', + 'server-discover.returns-draft-capabilities', + 'protocol-version.rejects-unsupported-stateless-version', + 'stateless.requires-complete-request-meta', + 'protocol-version.http-modern-400-retries-discovery', + 'capabilities.http-modern-400-does-not-fallback', + 'protocol-version.initialize-negotiates-stateful-version', + 'capabilities.stateless-does-not-infer-initialize-extensions', + 'stateless-http.rejects-mismatched-routing-headers', + 'stateless-http.requires-routing-headers', + 'stateless-http.rejects-non-post-methods', + 'stateless-http.rejects-batch-payloads', + 'stateless-http.task-requests-require-name-header', + 'stateless-http.validates-parameter-headers', + 'stateless-http.omits-invalid-numeric-parameter-headers', + 'stateless-http.encodes-parameter-header-values', + 'stateless-http.accepts-response-posts', + 'stateless-http.task-subscription-requires-client-capability', + 'stateless-http.omits-session-header-after-initialize', + 'stateless.related-task-uses-explicit-id-across-transports', + 'stateless.ignores-legacy-task-parameter', + 'stateless.adds-result-type-and-cache-defaults', + 'tools-list.stateless-returns-deterministic-order', + 'resources.missing-resource-error-code-by-version', + 'stateless.rejects-unrecognized-result-type', + 'mrtr.input-required-supported-requests', + 'mrtr.rejects-unsupported-input-required-results', + 'mrtr.input-requests-require-client-capabilities', + 'stateless.rejects-removed-core-rpcs', + 'stateless.rejects-removed-core-notifications', + 'logging.stateless-requires-request-log-level', + 'tasks-extension.lifecycle-methods-do-not-require-repeated-capability', + 'tasks-extension.task-store-uses-extension-result-shapes', + 'tasks-extension.call-tool-result-cannot-spoof-task-result', + 'tasks-extension.task-result-requires-client-extension', + 'subscriptions-listen.task-ids-require-client-capability', + 'subscriptions-listen.requires-request-meta', + 'subscriptions-listen.resource-subscriptions-require-capability', + 'subscriptions-acknowledged.rejects-wrapper-mismatch', 'capabilities.rejects-unnegotiated-sampling-tools', + 'capabilities.rejects-unnegotiated-sampling-context', + 'capabilities.unadvertised-peer-methods-use-method-not-found', + 'capabilities.task-scoped-peer-methods-use-method-not-found', + 'capabilities.stateless-omits-legacy-task-capabilities', 'elicitation.rejects-invalid-form-url-union', + 'elicitation.accepts-numeric-number-schema-keywords', 'tasks.strips-unnegotiated-related-task-metadata', 'progress.rejects-malformed-progress-token', 'progress.dispatches-integer-progress-token', diff --git a/test/client/client_test.dart b/test/client/client_test.dart index abbce2be..78c0ac29 100644 --- a/test/client/client_test.dart +++ b/test/client/client_test.dart @@ -187,11 +187,23 @@ void main() { // Should throw for unsupported capabilities expect( () => limitedClient.assertCapabilityForMethod("logging/setLevel"), - throwsA(isA()), + throwsA( + isA().having( + (e) => e.code, + 'code', + ErrorCode.methodNotFound.value, + ), + ), ); expect( () => limitedClient.assertCapabilityForMethod("prompts/list"), - throwsA(isA()), + throwsA( + isA().having( + (e) => e.code, + 'code', + ErrorCode.methodNotFound.value, + ), + ), ); expect( () => limitedClient.assertCapabilityForMethod( @@ -933,6 +945,7 @@ void _addCriticalPathTests() { () => client.assertCapabilityForMethod('resources/read'), throwsA( isA() + .having((e) => e.code, 'code', ErrorCode.methodNotFound.value) .having((e) => e.message, 'message', contains('resources')), ), ); @@ -1190,7 +1203,7 @@ void _addCriticalPathTests() { expect(transport.sentMessages.single, isA()); final error = transport.sentMessages.single as JsonRpcError; expect(error.id, 'sample-1'); - expect(error.error.code, ErrorCode.invalidRequest.value); + expect(error.error.code, ErrorCode.methodNotFound.value); expect(error.error.message, contains('sampling.tools')); }); @@ -1244,7 +1257,7 @@ void _addCriticalPathTests() { expect(transport.sentMessages.single, isA()); final error = transport.sentMessages.single as JsonRpcError; expect(error.id, 7); - expect(error.error.code, ErrorCode.invalidRequest.value); + expect(error.error.code, ErrorCode.methodNotFound.value); expect(error.error.message, contains('sampling.tools')); }); diff --git a/test/client/client_tool_validation_test.dart b/test/client/client_tool_validation_test.dart index fc77c58b..e377c424 100644 --- a/test/client/client_tool_validation_test.dart +++ b/test/client/client_tool_validation_test.dart @@ -260,7 +260,9 @@ void main() { await client.connect(transport); final result = await client.listTools(); - expect(result.tools.map((tool) => tool.name), ['valid_headers']); + expect(result.tools.map((tool) => tool.name), [ + 'valid_headers', + ]); expect(transport.toolParameterHeaderMappings, { 'valid_headers': { 'region': 'Region', @@ -365,11 +367,17 @@ void main() { expect( () => client.assertTaskCapability(Method.toolsCall), throwsA( - isA().having( - (e) => e.message, - 'message', - contains('tasks.requests.tools.call'), - ), + isA() + .having( + (e) => e.code, + 'code', + ErrorCode.methodNotFound.value, + ) + .having( + (e) => e.message, + 'message', + contains('tasks.requests.tools.call'), + ), ), ); }); @@ -386,11 +394,17 @@ void main() { expect( () => client.assertTaskCapability(Method.completionComplete), throwsA( - isA().having( - (e) => e.message, - 'message', - contains('tasks.requests.completion/complete'), - ), + isA() + .having( + (e) => e.code, + 'code', + ErrorCode.methodNotFound.value, + ) + .having( + (e) => e.message, + 'message', + contains('tasks.requests.completion/complete'), + ), ), ); }); diff --git a/test/client/streamable_https_test.dart b/test/client/streamable_https_test.dart index 835e42a2..133e7399 100644 --- a/test/client/streamable_https_test.dart +++ b/test/client/streamable_https_test.dart @@ -1265,7 +1265,7 @@ void main() { }); }); - test('send adds task id as 2026 stateless task name header', () async { + test('send adds 2026 stateless task name header', () async { final capturedHeaders = {}; final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); addTearDown(() => server.close(force: true)); @@ -1495,7 +1495,7 @@ void main() { '=?base64?${base64Encode(utf8.encode('Hello, ไธ–็•Œ'))}?=', ); expect(capturedHeaders['limit'], '42'); - expect(capturedHeaders['rounded'], '42'); + expect(capturedHeaders['rounded'], isNull); expect(capturedHeaders['unsafe'], isNull); expect(capturedHeaders['ratio'], isNull); expect(capturedHeaders['dryRun'], 'false'); diff --git a/test/docs/markdown_docs_test.dart b/test/docs/markdown_docs_test.dart index 5fef4523..33d8590f 100644 --- a/test/docs/markdown_docs_test.dart +++ b/test/docs/markdown_docs_test.dart @@ -79,7 +79,7 @@ final _markdownLinkPattern = RegExp( ); final _dartRunFilePattern = RegExp( - r'dart run (?(?:example|packages|test|bin)/[^\s`]+\.dart)', + r'dart run (?(?:example|packages|test|bin|tool)/[^\s`]+\.dart)', ); Iterable _markdownFiles(String repoRoot) sync* { diff --git a/test/elicitation_test.dart b/test/elicitation_test.dart index fa1c5cb0..7595c5cc 100644 --- a/test/elicitation_test.dart +++ b/test/elicitation_test.dart @@ -890,7 +890,7 @@ void main() { expect(request.toJson()['requestedSchema'], isA>()); }); - test('Form elicitation defaults to stable number schema keywords', () { + test('Form elicitation accepts numeric number schema keywords', () { Map requestWithProperty( String name, Map property, @@ -929,33 +929,44 @@ void main() { 'maximum': 10.5, }, }.entries) { + final params = ElicitRequestParams.fromJson( + requestWithProperty(property.key, property.value), + ); expect( - () => ElicitRequestParams.fromJson( - requestWithProperty(property.key, property.value), - ), - throwsA(isA()), + params.requestedSchema!.toJson()['properties'][property.key], + containsPair(property.value.keys.last, property.value.values.last), ); } + final serialized = ElicitRequestParams.form( + message: 'Configure deployment', + requestedSchema: JsonSchema.object( + properties: { + 'ratio': JsonSchema.number( + minimum: 0.1, + maximum: 0.9, + defaultValue: 0.5, + ), + }, + ), + ).toJson(); + final ratioSchema = serialized['requestedSchema']['properties']['ratio']; + expect(ratioSchema['minimum'], 0.1); + expect(ratioSchema['maximum'], 0.9); + expect(ratioSchema['default'], 0.5); + expect( - () => ElicitRequestParams.form( - message: 'Configure deployment', - requestedSchema: JsonSchema.object( - properties: { - 'ratio': JsonSchema.number( - minimum: 0.1, - maximum: 0.9, - defaultValue: 0.5, - ), - }, - ), - ).toJson(), + () => ElicitRequestParams.fromJson( + requestWithProperty('notFinite', { + 'type': 'number', + 'default': double.nan, + }), + ), throwsA(isA()), ); }); - test('Draft form elicitation accepts fractional number schema keywords', - () { + test('Draft form elicitation accepts numeric number schema keywords', () { final params = { 'mode': 'form', 'message': 'Configure deployment', @@ -978,49 +989,77 @@ void main() { }, }; - final request = ElicitRequestParams.fromJson( + final parsed = ElicitRequestParams.fromJson( params, protocolVersion: draftProtocolVersion2026_07_28, ); - final draftJson = request.toJson( + final parsedJson = parsed.toJson( protocolVersion: draftProtocolVersion2026_07_28, ); - final schema = draftJson['requestedSchema'] as Map; - final properties = schema['properties'] as Map; - expect( - (properties['ratio'] as Map)['default'], - 0.5, - ); - expect( - (properties['count'] as Map)['default'], - 1.5, + parsedJson['requestedSchema']['properties']['ratio']['minimum'], + 0.1, ); expect( - (properties['count'] as Map)['maximum'], + parsedJson['requestedSchema']['properties']['count']['maximum'], 10.5, ); + + final serialized = ElicitRequestParams.form( + message: 'Configure deployment', + requestedSchema: JsonSchema.object( + properties: { + 'ratio': JsonSchema.number( + minimum: 0.1, + maximum: 0.9, + defaultValue: 0.5, + ), + }, + ), + ).toJson(protocolVersion: draftProtocolVersion2026_07_28); expect( - () => request.toJson(), - throwsA(isA()), + serialized['requestedSchema']['properties']['ratio']['default'], + 0.5, ); - final rpc = JsonRpcElicitRequest.fromJson({ + final request = JsonRpcElicitRequest.fromJson({ 'jsonrpc': jsonRpcVersion, 'id': 1, 'method': Method.elicitationCreate, 'params': { ...params, - '_meta': { - McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, - }, + '_meta': {McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28}, }, }); - final rpcSchema = rpc.params!['requestedSchema'] as Map; - final rpcProperties = rpcSchema['properties'] as Map; expect( - (rpcProperties['ratio'] as Map)['minimum'], - 0.1, + request.toJson()['params']['requestedSchema']['properties']['count'] + ['minimum'], + 0.5, + ); + + expect( + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': { + 'mode': 'form', + 'message': 'Configure deployment', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'ratio': { + 'type': 'number', + 'maximum': double.infinity, + }, + }, + }, + '_meta': { + McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, + }, + }, + }), + throwsA(isA()), ); }); @@ -1104,7 +1143,7 @@ void main() { }, 'badIntegerDefault': { 'type': 'integer', - 'default': 1.5, + 'default': double.nan, }, 'badBooleanDefault': { 'type': 'boolean', diff --git a/test/interop/ts_client_with_dart_server_test.dart b/test/interop/ts_client_with_dart_server_test.dart index dd33b7ba..2f14c996 100644 --- a/test/interop/ts_client_with_dart_server_test.dart +++ b/test/interop/ts_client_with_dart_server_test.dart @@ -361,15 +361,14 @@ void main() { test( 'official TS Streamable HTTP client lists tools immediately after lifecycle', () async { - final port = await _findAvailablePort(); - final baseUrl = 'http://127.0.0.1:$port/mcp'; final streamableServer = StreamableMcpServer( serverFactory: (_) => createServer(), host: '127.0.0.1', - port: port, + port: 0, ); await streamableServer.start(); + final baseUrl = 'http://127.0.0.1:${streamableServer.boundPort}/mcp'; try { final result = await Process.run( 'node', @@ -644,15 +643,14 @@ void main() { test( 'Dart Streamable HTTP server rejects operations before initialized', () async { - final port = await _findAvailablePort(); - final baseUrl = 'http://127.0.0.1:$port/mcp'; final streamableServer = StreamableMcpServer( serverFactory: (_) => createServer(), host: '127.0.0.1', - port: port, + port: 0, ); await streamableServer.start(); + final baseUrl = 'http://127.0.0.1:${streamableServer.boundPort}/mcp'; try { final initRes = await http.post( Uri.parse(baseUrl), @@ -739,8 +737,6 @@ void main() { test( 'official TS client resumes Dart server SSE replay by Last-Event-ID', () async { - final port = await _findAvailablePort(); - final baseUrl = 'http://127.0.0.1:$port/mcp'; final servers = {}; final streamableServer = StreamableMcpServer( @@ -752,7 +748,7 @@ void main() { return mcpServer; }, host: '127.0.0.1', - port: port, + port: 0, eventStore: InMemoryEventStore(), ); @@ -769,6 +765,7 @@ void main() { } await streamableServer.start(); + final baseUrl = 'http://127.0.0.1:${streamableServer.boundPort}/mcp'; try { final initRes = await http.post( Uri.parse(baseUrl), diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index 7e35bdaf..f59dbcad 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1673,16 +1673,30 @@ void main() { ); expect(request.toJson()['requestedSchema']['type'], 'object'); + final fractionalBounds = ElicitRequest.form( + message: 'Fractional bounds', + requestedSchema: JsonSchema.object( + properties: { + 'ratio': JsonSchema.number( + minimum: 0.1, + maximum: 0.9, + defaultValue: 0.5, + ), + }, + ), + ).toJson(); + final ratioSchema = + fractionalBounds['requestedSchema']['properties']['ratio']; + expect(ratioSchema['minimum'], 0.1); + expect(ratioSchema['maximum'], 0.9); + expect(ratioSchema['default'], 0.5); + expect( () => ElicitRequest.form( - message: 'Fractional bounds', + message: 'Non-finite bound', requestedSchema: JsonSchema.object( properties: { - 'ratio': JsonSchema.number( - minimum: 0.1, - maximum: 0.9, - defaultValue: 0.5, - ), + 'ratio': JsonSchema.number(maximum: double.infinity), }, ), ).toJson(), @@ -2473,6 +2487,35 @@ void main() { throwsA(isA()), ); }); + + test('strict incoming tools/call requests require params', () { + final json = { + 'jsonrpc': '2.0', + 'id': 'call-1', + 'method': Method.toolsCall, + }; + + expect( + () => JsonRpcCallToolRequest.fromJson(json), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('params'), + ), + ), + ); + expect( + () => JsonRpcMessage.fromJson(json), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('params'), + ), + ), + ); + }); }); }); } diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 26ce510a..cf85fe73 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -3,19 +3,23 @@ import 'dart:async'; import 'package:mcp_dart/src/client/client.dart'; import 'package:mcp_dart/src/server/mcp_server.dart'; import 'package:mcp_dart/src/server/server.dart'; -import 'package:mcp_dart/src/server/tasks/handler.dart'; +import 'package:mcp_dart/src/server/tasks.dart'; import 'package:mcp_dart/src/shared/protocol.dart'; import 'package:mcp_dart/src/shared/transport.dart'; import 'package:mcp_dart/src/types.dart'; import 'package:test/test.dart'; class RecordingTransport extends Transport { + RecordingTransport({this.sessionIdValue}); + final List sentMessages = []; + final List sentRelatedRequestIds = []; + final String? sessionIdValue; bool started = false; bool closed = false; @override - String? get sessionId => null; + String? get sessionId => sessionIdValue; @override Future close() async { @@ -26,6 +30,7 @@ class RecordingTransport extends Transport { @override Future send(JsonRpcMessage message, {int? relatedRequestId}) async { sentMessages.add(message); + sentRelatedRequestIds.add(relatedRequestId); } @override @@ -38,6 +43,33 @@ class RecordingTransport extends Transport { } } +class SessionRecordingTaskStore extends InMemoryTaskStore { + final List createTaskSessionIds = []; + final List updateTaskStatusSessionIds = []; + + @override + Future createTask( + TaskCreation taskParams, + RequestId requestId, + Map requestData, + String? sessionId, + ) { + createTaskSessionIds.add(sessionId); + return super.createTask(taskParams, requestId, requestData, sessionId); + } + + @override + Future updateTaskStatus( + String taskId, + TaskStatus status, [ + String? statusMessage, + String? sessionId, + ]) { + updateTaskStatusSessionIds.add(sessionId); + return super.updateTaskStatus(taskId, status, statusMessage, sessionId); + } +} + class DiscoveringClientTransport extends Transport implements ProtocolVersionAwareTransport { DiscoveringClientTransport({ @@ -208,20 +240,24 @@ class LegacyFallbackTransport extends Transport } class CompletedTaskHandler extends CancelTaskResultHandler { + RequestHandlerExtra? lastCreateTaskExtra; + @override Future createTask( Map? args, RequestHandlerExtra? extra, - ) async => - const CreateTaskResult( - task: Task( - taskId: 'task-1', - status: TaskStatus.completed, - ttl: null, - createdAt: '2026-07-28T00:00:00Z', - lastUpdatedAt: '2026-07-28T00:01:00Z', - ), - ); + ) async { + lastCreateTaskExtra = extra; + return const CreateTaskResult( + task: Task( + taskId: 'task-1', + status: TaskStatus.completed, + ttl: null, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:01:00Z', + ), + ); + } @override Future getTask(String taskId, RequestHandlerExtra? extra) async => Task( @@ -266,6 +302,28 @@ Map _clientMeta({ Future _pump() => Future.delayed(Duration.zero); +void _registerTaskGetExtensionHandler(Server server) { + server.setRequestHandler( + Method.tasksGet, + (request, extra) async => GetTaskExtensionResult( + task: TaskExtensionTask( + taskId: request.getParams.taskId, + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:00Z', + ttlMs: null, + ), + ), + (id, params, meta) => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.tasksGet, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); +} + void main() { group('MCP 2026-07-28 RC protocol foundation', () { test('defines draft protocol version separately from stable default', () { @@ -400,7 +458,7 @@ void main() { ); }); - test('allows fractional elicitation number schema keywords', () { + test('accepts fractional elicitation number schema keywords', () { final request = ElicitRequestParams.form( message: 'Configure ratio', requestedSchema: JsonSchema.object( @@ -410,31 +468,69 @@ void main() { maximum: 0.9, defaultValue: 0.5, ), - 'count': JsonSchema.integer( - minimum: 0.5, - maximum: 10.5, - defaultValue: 1.5, - ), }, ), ); - final json = request.toJson( + final requestJson = request.toJson( protocolVersion: draftProtocolVersion2026_07_28, ); - final schema = json['requestedSchema'] as Map; - final properties = schema['properties'] as Map; - expect((properties['ratio'] as Map)['minimum'], 0.1); - expect((properties['count'] as Map)['default'], 1.5); - expect((properties['count'] as Map)['maximum'], 10.5); + final ratioSchema = requestJson['requestedSchema']['properties']['ratio']; + expect(ratioSchema['minimum'], 0.1); + expect(ratioSchema['maximum'], 0.9); + expect(ratioSchema['default'], 0.5); + + final inputRequestJson = InputRequest.elicit(request).toJson(); + final inputRatioSchema = + inputRequestJson['params']['requestedSchema']['properties']['ratio']; + expect(inputRatioSchema['minimum'], 0.1); + + final parsed = JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': { + 'message': 'Configure ratio', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'count': { + 'type': 'integer', + 'maximum': 10.5, + }, + }, + }, + '_meta': { + McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, + }, + }, + }); + final countSchema = + parsed.elicitParams.requestedSchema!.toJson()['properties']['count']; + expect(countSchema['maximum'], 10.5); - final inputRequest = InputRequest.elicit(request); - final inputSchema = - inputRequest.params!['requestedSchema'] as Map; - final inputProperties = inputSchema['properties'] as Map; expect( - (inputProperties['ratio'] as Map)['default'], - 0.5, + () => JsonRpcElicitRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.elicitationCreate, + 'params': { + 'message': 'Configure ratio', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'count': { + 'type': 'integer', + 'maximum': double.infinity, + }, + }, + }, + '_meta': { + McpMetaKey.protocolVersion: draftProtocolVersion2026_07_28, + }, + }, + }), + throwsA(isA()), ); }); @@ -937,6 +1033,75 @@ void main() { ); }); + test('stateless metadata omits legacy task capabilities', () { + const clientCapabilities = ClientCapabilities( + sampling: ClientCapabilitiesSampling(tools: true), + roots: ClientCapabilitiesRoots(listChanged: true), + tasks: ClientCapabilitiesTasks( + cancel: true, + list: true, + requests: ClientCapabilitiesTasksRequests( + sampling: ClientCapabilitiesTasksSampling( + createMessage: ClientCapabilitiesTasksSamplingCreateMessage(), + ), + ), + ), + extensions: {mcpTasksExtensionId: {}}, + ); + expect(clientCapabilities.toJson(), contains('tasks')); + + final draftMeta = buildProtocolRequestMeta( + protocolVersion: draftProtocolVersion2026_07_28, + clientInfo: const Implementation(name: 'client', version: '1.0.0'), + clientCapabilities: clientCapabilities, + ); + final draftCapabilities = + draftMeta[McpMetaKey.clientCapabilities] as Map; + expect(draftCapabilities, isNot(contains('tasks'))); + expect(draftCapabilities['roots'], isNot(contains('listChanged'))); + expect( + (draftCapabilities['extensions'] as Map)[mcpTasksExtensionId], + isEmpty, + ); + + final stableMeta = buildProtocolRequestMeta( + protocolVersion: stableProtocolVersion2025_11_25, + clientInfo: const Implementation(name: 'client', version: '1.0.0'), + clientCapabilities: clientCapabilities, + ); + final stableCapabilities = + stableMeta[McpMetaKey.clientCapabilities] as Map; + expect(stableCapabilities, contains('tasks')); + expect(stableCapabilities['roots'], contains('listChanged')); + }); + + test('server/discover result omits legacy task capabilities', () { + const serverCapabilities = ServerCapabilities( + tools: ServerCapabilitiesTools(), + tasks: ServerCapabilitiesTasks( + list: true, + cancel: true, + requests: ServerCapabilitiesTasksRequests( + tools: ServerCapabilitiesTasksTools( + call: ServerCapabilitiesTasksToolsCall(), + ), + ), + ), + extensions: {mcpTasksExtensionId: {}}, + ); + expect(serverCapabilities.toJson(), contains('tasks')); + + final json = const DiscoverResult( + supportedVersions: [draftProtocolVersion2026_07_28], + capabilities: serverCapabilities, + serverInfo: Implementation(name: 'server', version: '1.0.0'), + ).toJson(); + final capabilities = json['capabilities'] as Map; + expect(capabilities, isNot(contains('tasks'))); + expect(capabilities, contains('tools')); + expect((capabilities['extensions'] as Map)[mcpTasksExtensionId], isEmpty); + }); + test('server/discover and capability fields reject malformed wire shapes', () { final result = { @@ -1341,6 +1506,13 @@ void main() { ], task: TaskCreation(ttl: 1000), maxTokens: 100, + tools: [ + Tool( + name: 'lookup', + inputSchema: JsonObject(), + execution: ToolExecution(taskSupport: 'optional'), + ), + ], ), ), 'roots': InputRequest.listRoots(), @@ -1369,6 +1541,10 @@ void main() { json['inputRequests']['capital_of_france']['params'], isNot(contains('task')), ); + expect( + json['inputRequests']['capital_of_france']['params']['tools'][0], + isNot(contains('execution')), + ); expect(json['inputRequests']['roots'], {'method': Method.rootsList}); final parsed = InputRequiredResult.fromJson(json); @@ -1911,6 +2087,41 @@ void main() { test('rejects malformed subscription wire shapes', () { for (final parse in [ + () => JsonRpcSubscriptionsListenRequest.fromJson({ + 'jsonrpc': '1.0', + 'id': 1, + 'method': Method.subscriptionsListen, + 'params': { + '_meta': _clientMeta(), + 'notifications': {}, + }, + }), + () => JsonRpcSubscriptionsListenRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.toolsList, + 'params': { + '_meta': _clientMeta(), + 'notifications': {}, + }, + }), + () => JsonRpcSubscriptionsListenRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.subscriptionsListen, + 'params': { + 'notifications': {}, + }, + }), + () => JsonRpcSubscriptionsListenRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': 1, + 'method': Method.subscriptionsListen, + 'params': { + '_meta': 'bad', + 'notifications': {}, + }, + }), () => JsonRpcSubscriptionsListenRequest.fromJson({ 'jsonrpc': jsonRpcVersion, 'id': 1, @@ -1931,6 +2142,20 @@ void main() { 1: true, }, }), + () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson({ + 'jsonrpc': '1.0', + 'method': Method.notificationsSubscriptionsAcknowledged, + 'params': { + 'notifications': {}, + }, + }), + () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'method': Method.notificationsProgress, + 'params': { + 'notifications': {}, + }, + }), () => JsonRpcSubscriptionsAcknowledgedNotification.fromJson({ 'jsonrpc': jsonRpcVersion, 'method': Method.notificationsSubscriptionsAcknowledged, @@ -1951,6 +2176,71 @@ void main() { } }); + test('serializes subscriptions/listen with required request metadata', () { + final request = JsonRpcSubscriptionsListenRequest( + id: 'sub-1', + listenParams: const SubscriptionsListenRequest( + notifications: SubscriptionFilter(toolsListChanged: true), + ), + meta: _clientMeta(), + ); + + final json = request.toJson(); + expect(json['method'], Method.subscriptionsListen); + expect(json['params']['notifications'], {'toolsListChanged': true}); + expect( + json['params']['_meta'][McpMetaKey.protocolVersion], + draftProtocolVersion2026_07_28, + ); + expect( + json['params']['_meta'][McpMetaKey.clientCapabilities], + {}, + ); + + final parsed = JsonRpcSubscriptionsListenRequest.fromJson(json); + expect(parsed.id, 'sub-1'); + expect(parsed.meta, _clientMeta()); + expect(parsed.listenParams.notifications.toolsListChanged, isTrue); + expect( + () => JsonRpcSubscriptionsListenRequest( + id: 'missing-meta', + listenParams: const SubscriptionsListenRequest( + notifications: SubscriptionFilter(toolsListChanged: true), + ), + ).toJson(), + throwsFormatException, + ); + }); + + test('resource subscriptions require resources.subscribe capability', () { + const requested = SubscriptionFilter( + resourceSubscriptions: ['file:///project/config.json'], + ); + + expect( + requested + .acknowledgedBy( + const ServerCapabilities( + resources: ServerCapabilitiesResources(), + ), + ) + .toJson(), + isEmpty, + ); + expect( + requested + .acknowledgedBy( + const ServerCapabilities( + resources: ServerCapabilitiesResources(subscribe: true), + ), + ) + .toJson(), + { + 'resourceSubscriptions': ['file:///project/config.json'], + }, + ); + }); + test('server acknowledges subscriptions/listen with subscription id', () async { final server = Server( @@ -1958,7 +2248,7 @@ void main() { options: const McpServerOptions( capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(listChanged: true), - resources: ServerCapabilitiesResources(), + resources: ServerCapabilitiesResources(subscribe: true), ), ), ); @@ -1969,7 +2259,7 @@ void main() { request.listenParams.notifications.acknowledgedBy( const ServerCapabilities( tools: ServerCapabilitiesTools(listChanged: true), - resources: ServerCapabilitiesResources(), + resources: ServerCapabilitiesResources(subscribe: true), ), ), ); @@ -2276,65 +2566,19 @@ void main() { ErrorCode.missingRequiredClientCapability.value, ); expect( - response.error.data['requiredCapabilities']['extensions'] - [mcpTasksExtensionId], - isEmpty, - ); - }); - - test('server rejects task extension methods without client capability', - () async { - final server = Server( - const Implementation(name: 'server', version: '1.0.0'), - options: const McpServerOptions( - capabilities: ServerCapabilities( - extensions: {mcpTasksExtensionId: {}}, - ), - ), - ); - final transport = RecordingTransport(); - await server.connect(transport); - - transport - ..receive( - JsonRpcGetTaskRequest( - id: 'get-task', - getParams: const GetTaskRequest(taskId: 'task-1'), - meta: _clientMeta(), - ), - ) - ..receive( - JsonRpcCancelTaskRequest( - id: 'cancel-task', - cancelParams: const CancelTaskRequest(taskId: 'task-1'), - meta: _clientMeta(), - ), - ) - ..receive( - JsonRpcUpdateTaskRequest( - id: 'update-task', - updateParams: const UpdateTaskRequest( - taskId: 'task-1', - inputResponses: {}, - ), - meta: _clientMeta(), - ), - ); - await _pump(); - - final errors = transport.sentMessages.cast(); - expect( - errors.map((response) => response.error.code), - everyElement(ErrorCode.missingRequiredClientCapability.value), - ); - expect( - errors.first.error.data['requiredCapabilities']['extensions'] - [mcpTasksExtensionId], - isEmpty, + response.error.message, + contains('Missing required client capability'), ); + expect(response.error.data, { + 'requiredCapabilities': { + 'extensions': { + mcpTasksExtensionId: {}, + }, + }, + }); }); - test('server handles task extension methods with 2026 result shapes', + test('server handles task extension methods without per-request capability', () async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), @@ -2392,25 +2636,21 @@ void main() { ); final transport = RecordingTransport(); await server.connect(transport); - final taskExtensionMeta = _clientMeta( - clientCapabilities: const ClientCapabilities( - extensions: {mcpTasksExtensionId: {}}, - ), - ); + final statelessMeta = _clientMeta(); transport ..receive( JsonRpcGetTaskRequest( id: 'get-task', getParams: const GetTaskRequest(taskId: 'task-1'), - meta: taskExtensionMeta, + meta: statelessMeta, ), ) ..receive( JsonRpcCancelTaskRequest( id: 'cancel-task', cancelParams: const CancelTaskRequest(taskId: 'task-1'), - meta: taskExtensionMeta, + meta: statelessMeta, ), ) ..receive( @@ -2420,7 +2660,7 @@ void main() { taskId: 'task-1', inputResponses: {}, ), - meta: taskExtensionMeta, + meta: statelessMeta, ), ); await _pump(); @@ -2435,21 +2675,99 @@ void main() { expect(responses[2].result, {'resultType': resultTypeComplete}); }); - test('server does not expose legacy task handlers as task extension', + test('server task store uses task extension results for stateless requests', () async { - final server = McpServer( + final store = InMemoryTaskStore(); + addTearDown(store.dispose); + final completedTask = await store.createTask( + const TaskCreation(ttl: 60000), + 'source-request', + const { + 'method': Method.toolsCall, + 'params': {'name': 'long'}, + }, + null, + ); + await store.storeTaskResult( + completedTask.taskId, + TaskStatus.completed, + const CallToolResult(content: [TextContent(text: 'done')]), + ); + final workingTask = await store.createTask( + const TaskCreation(ttl: null), + 'cancel-request', + const { + 'method': Method.toolsCall, + 'params': {'name': 'cancel-me'}, + }, + null, + ); + final server = Server( const Implementation(name: 'server', version: '1.0.0'), + options: McpServerOptions( + capabilities: const ServerCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + taskStore: store, + ), ); - var handlerCalled = false; - server.experimental.onGetTask((taskId, extra) async { - handlerCalled = true; - return Task( - taskId: taskId, - status: TaskStatus.completed, - ttl: null, - createdAt: '2026-07-28T00:00:00Z', - lastUpdatedAt: '2026-07-28T00:01:00Z', - ); + final transport = RecordingTransport(); + await server.connect(transport); + + final meta = _clientMeta( + clientCapabilities: const ClientCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + ); + transport + ..receive( + JsonRpcGetTaskRequest( + id: 'get-task', + getParams: GetTaskRequest(taskId: completedTask.taskId), + meta: meta, + ), + ) + ..receive( + JsonRpcCancelTaskRequest( + id: 'cancel-task', + cancelParams: CancelTaskRequest(taskId: workingTask.taskId), + meta: meta, + ), + ); + await _pump(); + + final responses = transport.sentMessages.cast().toList(); + expect(responses, hasLength(2)); + expect(responses[0].result['resultType'], resultTypeComplete); + expect(responses[0].result['taskId'], completedTask.taskId); + expect(responses[0].result['status'], TaskStatus.completed.name); + expect(responses[0].result['ttlMs'], 60000); + expect(responses[0].result, isNot(contains('ttl'))); + expect(responses[0].result['result']['content'], [ + {'type': 'text', 'text': 'done'}, + ]); + expect(responses[1].result, {'resultType': resultTypeComplete}); + expect( + (await store.getTask(workingTask.taskId))?.status, + TaskStatus.cancelled, + ); + }); + + test('server does not expose legacy task handlers as task extension', + () async { + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + var handlerCalled = false; + server.experimental.onGetTask((taskId, extra) async { + handlerCalled = true; + return Task( + taskId: taskId, + status: TaskStatus.completed, + ttl: null, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:01:00Z', + ); }); final transport = RecordingTransport(); await server.connect(transport); @@ -2647,6 +2965,7 @@ void main() { ), ), ); + _registerTaskGetExtensionHandler(server); server.setRequestHandler( Method.toolsCall, (request, extra) async => const CreateTaskExtensionResult( @@ -2687,6 +3006,302 @@ void main() { expect(response.result['taskId'], 'task-1'); }); + test('stateless task support is not inferred from initialize capabilities', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ); + _registerTaskGetExtensionHandler(server); + server.setRequestHandler( + Method.toolsCall, + (request, extra) async => const CreateTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:00Z', + ttlMs: null, + ), + ), + (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcInitializeRequest( + id: 'init', + initParams: const InitializeRequest( + protocolVersion: stableProtocolVersion2025_11_25, + capabilities: ClientCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + clientInfo: Implementation(name: 'client', version: '1.0.0'), + ), + ), + ); + await _pump(); + transport.sentMessages.clear(); + + transport.receive( + JsonRpcCallToolRequest( + id: 'call-1', + params: const CallToolRequest(name: 'long').toJson(), + meta: _clientMeta(), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect( + response.error.code, + ErrorCode.missingRequiredClientCapability.value, + ); + expect( + response.error.message, + contains('Missing required client capability'), + ); + expect(response.error.data, { + 'requiredCapabilities': { + 'extensions': { + mcpTasksExtensionId: {}, + }, + }, + }); + }); + + test('stateless tools/call rejects task result without server extension', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), + ), + ); + server.setRequestHandler( + Method.toolsCall, + (request, extra) async => const CreateTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:00Z', + ttlMs: null, + ), + ), + (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcCallToolRequest( + id: 'call-1', + params: const CallToolRequest(name: 'long').toJson(), + meta: _clientMeta( + clientCapabilities: const ClientCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect(response.error.code, ErrorCode.invalidParams.value); + expect(response.error.message, contains(mcpTasksExtensionId)); + }); + + test('stateless tools/call rejects task result without tasks/get handler', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ); + server.setRequestHandler( + Method.toolsCall, + (request, extra) async => const CreateTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:00Z', + ttlMs: null, + ), + ), + (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcCallToolRequest( + id: 'call-1', + params: const CallToolRequest(name: 'long').toJson(), + meta: _clientMeta( + clientCapabilities: const ClientCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect(response.error.code, ErrorCode.invalidParams.value); + expect(response.error.message, contains('tasks/get handler')); + }); + + test('stateless tools/call rejects task result before task is readable', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ); + var getTaskCalled = false; + server.setRequestHandler( + Method.tasksGet, + (request, extra) async { + getTaskCalled = true; + throw McpError( + ErrorCode.invalidParams.value, + 'Task not found', + ); + }, + (id, params, meta) => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.tasksGet, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + server.setRequestHandler( + Method.toolsCall, + (request, extra) async => const CreateTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-1', + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:00Z', + ttlMs: null, + ), + ), + (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcCallToolRequest( + id: 'call-1', + params: const CallToolRequest(name: 'long').toJson(), + meta: _clientMeta( + clientCapabilities: const ClientCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect(getTaskCalled, isTrue); + expect(response.error.code, ErrorCode.invalidParams.value); + expect(response.error.message, contains('must be resolvable')); + expect(response.error.data, contains('Task not found')); + }); + + test('stateless tools/call rejects CallToolResult resultType spoof', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ); + server.setRequestHandler( + Method.toolsCall, + (request, extra) async => const CallToolResult( + content: [TextContent(text: 'spoof')], + extra: { + 'resultType': resultTypeTask, + 'taskId': 'spoofed-task', + }, + ), + (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcCallToolRequest( + id: 'call-1', + params: const CallToolRequest(name: 'spoof').toJson(), + meta: _clientMeta( + clientCapabilities: const ClientCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect(response.error.code, ErrorCode.invalidParams.value); + expect(response.error.message, contains('CallToolResult')); + expect(response.error.message, contains('resultType')); + }); + test( 'stateless tools/call rejects task extension result without capability', () async { @@ -2735,6 +3350,17 @@ void main() { response.error.code, ErrorCode.missingRequiredClientCapability.value, ); + expect( + response.error.message, + contains('Missing required client capability'), + ); + expect(response.error.data, { + 'requiredCapabilities': { + 'extensions': { + mcpTasksExtensionId: {}, + }, + }, + }); }); test('stateless tools/call permits input required results', () async { @@ -3017,9 +3643,10 @@ void main() { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), ); + final handler = CompletedTaskHandler(); server.experimental.registerToolTask( 'long', - handler: CompletedTaskHandler(), + handler: handler, ); final transport = RecordingTransport(); await server.connect(transport); @@ -3027,7 +3654,10 @@ void main() { transport.receive( JsonRpcCallToolRequest( id: 'call-1', - params: const CallToolRequest(name: 'long').toJson(), + params: { + ...const CallToolRequest(name: 'long').toJson(), + 'task': {'ttl': 1000}, + }, meta: _clientMeta(), ), ); @@ -3035,6 +3665,7 @@ void main() { final response = transport.sentMessages.single as JsonRpcResponse; expect(response.result['content'][0]['text'], 'task complete'); + expect(handler.lastCreateTaskExtra?.taskRequestedTtl, isNull); }); test('stateless tools/list omits legacy task execution metadata', () async { @@ -3059,6 +3690,48 @@ void main() { expect(tool, isNot(contains('execution'))); }); + test('stateless custom tools/list handlers omit legacy execution metadata', + () async { + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), + ); + server.server.setRequestHandler( + Method.toolsList, + (request, extra) async => const ListToolsResult( + tools: [ + Tool( + name: 'task-tool', + inputSchema: JsonObject(), + execution: ToolExecution(taskSupport: 'required'), + ), + ], + ), + (id, params, meta) => JsonRpcListToolsRequest( + id: id, + params: params, + meta: meta, + ), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport + .receive(JsonRpcListToolsRequest(id: 'tools', meta: _clientMeta())); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + final tool = (response.result['tools'] as List).single as Map; + expect(tool, isNot(contains('execution'))); + expect(response.result['resultType'], resultTypeComplete); + expect(response.result['ttlMs'], 0); + expect(response.result['cacheScope'], CacheScope.private); + }); + test('stateless tools/list returns tools sorted by name', () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), @@ -3105,11 +3778,136 @@ void main() { if (meta != null) '_meta': meta, }), ), - throwsStateError, + throwsStateError, + ); + }); + + test('server handles server/discover before legacy initialization', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + instructions: 'Discovery instructions.', + ), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcServerDiscoverRequest(id: 'discover-1', meta: _clientMeta()), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + expect(response.id, 'discover-1'); + expect( + response.result['supportedVersions'], + contains(draftProtocolVersion2026_07_28), + ); + expect(response.result['serverInfo']['name'], 'server'); + expect(response.result['instructions'], 'Discovery instructions.'); + }); + + test('server accepts stateless requests without initialize', () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), + ); + String? receivedProtocolVersion; + Implementation? receivedClientInfo; + ClientCapabilities? receivedClientCapabilities; + server.setRequestHandler( + Method.toolsList, + (request, extra) async { + receivedProtocolVersion = extra.protocolVersion; + receivedClientInfo = extra.clientInfo; + receivedClientCapabilities = extra.clientCapabilities; + return const ListToolsResult( + tools: [ + Tool(name: 'echo', inputSchema: JsonObject()), + ], + ); + }, + (id, params, meta) => JsonRpcListToolsRequest( + id: id, + params: params, + meta: meta, + ), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive(JsonRpcListToolsRequest(id: 1, meta: _clientMeta())); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + final tools = response.result['tools'] as List; + expect(tools.single['name'], 'echo'); + expect(receivedProtocolVersion, draftProtocolVersion2026_07_28); + expect(receivedClientInfo?.name, 'client'); + expect(receivedClientInfo?.version, '1.0.0'); + expect(receivedClientCapabilities?.toJson(), isEmpty); + }); + + test('stateless handlers do not inherit transport session identity', + () async { + final taskStore = SessionRecordingTaskStore(); + addTearDown(taskStore.dispose); + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: McpServerOptions( + capabilities: const ServerCapabilities( + tools: ServerCapabilitiesTools(), + extensions: {mcpTasksExtensionId: {}}, + ), + taskStore: taskStore, + ), + ); + RequestHandlerExtra? receivedExtra; + server.setRequestHandler( + Method.toolsCall, + (request, extra) async { + receivedExtra = extra; + await extra.taskStore!.createTask(const TaskCreation(ttl: 1000)); + return const CallToolResult( + content: [TextContent(text: 'ok')], + ); + }, + (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + final transport = + RecordingTransport(sessionIdValue: 'stateful-session-id'); + await server.connect(transport); + + transport.receive( + JsonRpcCallToolRequest( + id: 'call-1', + params: const CallToolRequest(name: 'tool').toJson(), + meta: _clientMeta(), + ), ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + expect(response.result['resultType'], resultTypeComplete); + expect(receivedExtra?.sessionId, isNull); + expect(taskStore.createTaskSessionIds, [isNull]); }); - test('server handles server/discover before legacy initialization', + test('server handler client requests stay associated with origin request', () async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), @@ -3117,64 +3915,118 @@ void main() { capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), ), - instructions: 'Discovery instructions.', ), ); + server.setRequestHandler( + Method.toolsCall, + (request, extra) async { + final result = await extra.sendRequest( + JsonRpcElicitRequest( + id: -1, + elicitParams: ElicitRequest.form( + message: 'Approve tool execution?', + requestedSchema: JsonSchema.object( + properties: {'approved': JsonSchema.boolean()}, + required: ['approved'], + ), + ), + ), + ElicitResult.fromJson, + const RequestOptions(timeout: Duration(seconds: 1)), + ); + expect(result.accepted, isTrue); + return const CallToolResult( + content: [TextContent(text: 'approved')], + ); + }, + (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); final transport = RecordingTransport(); await server.connect(transport); transport.receive( - JsonRpcServerDiscoverRequest(id: 'discover-1', meta: _clientMeta()), + JsonRpcInitializeRequest( + id: 1, + initParams: const InitializeRequestParams( + protocolVersion: stableProtocolVersion2025_11_25, + capabilities: ClientCapabilities( + elicitation: ClientElicitation.formOnly(), + ), + clientInfo: Implementation(name: 'client', version: '1.0.0'), + ), + ), ); await _pump(); + transport + ..sentMessages.clear() + ..sentRelatedRequestIds.clear() + ..receive(const JsonRpcInitializedNotification()); + await _pump(); - final response = transport.sentMessages.single as JsonRpcResponse; - expect(response.id, 'discover-1'); - expect( - response.result['supportedVersions'], - contains(draftProtocolVersion2026_07_28), + transport.receive( + JsonRpcCallToolRequest( + id: 42, + params: const CallToolRequest(name: 'needs-approval').toJson(), + ), ); - expect(response.result['serverInfo']['name'], 'server'); - expect(response.result['instructions'], 'Discovery instructions.'); + await _pump(); + + final nestedRequest = transport.sentMessages.single as JsonRpcRequest; + expect(nestedRequest.method, Method.elicitationCreate); + expect(transport.sentRelatedRequestIds.single, 42); + + transport.receive( + JsonRpcResponse( + id: nestedRequest.id, + result: const ElicitResult( + action: 'accept', + content: {'approved': true}, + ).toJson(), + ), + ); + await _pump(); + + expect(transport.sentMessages, hasLength(2)); + final response = transport.sentMessages.last as JsonRpcResponse; + expect(response.id, 42); + expect(response.result['content'][0]['text'], 'approved'); }); - test('server accepts stateless requests without initialize', () async { + test('server initialize never negotiates stateless draft version', + () async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), - options: const McpServerOptions( - capabilities: ServerCapabilities( - tools: ServerCapabilitiesTools(), - ), - ), - ); - server.setRequestHandler( - Method.toolsList, - (request, extra) async { - expect( - extra.meta?[McpMetaKey.protocolVersion], - draftProtocolVersion2026_07_28, - ); - return const ListToolsResult( - tools: [ - Tool(name: 'echo', inputSchema: JsonObject()), - ], - ); - }, - (id, params, meta) => JsonRpcListToolsRequest( - id: id, - params: params, - meta: meta, - ), ); final transport = RecordingTransport(); await server.connect(transport); - transport.receive(JsonRpcListToolsRequest(id: 1, meta: _clientMeta())); + transport.receive( + JsonRpcInitializeRequest( + id: 1, + initParams: const InitializeRequestParams( + protocolVersion: draftProtocolVersion2026_07_28, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'client', version: '1.0.0'), + ), + ), + ); await _pump(); final response = transport.sentMessages.single as JsonRpcResponse; - final tools = response.result['tools'] as List; - expect(tools.single['name'], 'echo'); + expect( + response.result['protocolVersion'], + stableProtocolVersion2025_11_25, + ); + expect( + response.result['protocolVersion'], + isNot(latestDraftProtocolVersion), + ); }); test('server returns unsupported protocol version for stateless metadata', @@ -3561,6 +4413,44 @@ void main() { expect(listRequest.meta?[McpMetaKey.clientCapabilities], {}); }); + test('stateless client rejects legacy task request options before send', + () async { + final transport = DiscoveringClientTransport(); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + ); + + await client.connect(transport); + final sentBeforeCall = transport.sentMessages.length; + + await expectLater( + client.callTool( + const CallToolRequest(name: 'echo'), + options: const RequestOptions(task: TaskCreation(ttl: 1000)), + ), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.invalidRequest.value, + ) + .having( + (error) => error.message, + 'message', + contains('RequestOptions.task'), + ) + .having( + (error) => error.message, + 'message', + contains(mcpTasksExtensionId), + ), + ), + ); + + expect(transport.sentMessages, hasLength(sentBeforeCall)); + }); + test('client can opt out of discovery for legacy initialization', () async { final transport = LegacyFallbackTransport(); final client = McpClient( @@ -3584,6 +4474,13 @@ void main() { .map((message) => message.method), contains(Method.initialize), ); + final initializeRequest = transport.sentMessages + .whereType() + .singleWhere((message) => message.method == Method.initialize); + expect( + initializeRequest.params?['protocolVersion'], + stableProtocolVersion2025_11_25, + ); }); test('client falls back when legacy HTTP rejects discovery before init', @@ -3596,6 +4493,10 @@ void main() { '"message":"Bad Request: Server not initialized"},"id":null}', ), McpError(0, 'Error POSTing to endpoint (HTTP 400): '), + McpError( + ErrorCode.invalidParams.value, + 'Invalid request parameters', + ), ]; for (final error in errors) { @@ -3784,6 +4685,26 @@ void main() { expect(response.error.message, contains('inputRequests')); }); + test( + 'stateless client reports method not found for unadvertised peer method', + () async { + final transport = DiscoveringClientTransport(); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(useServerDiscover: true), + ); + await client.connect(transport); + transport.sentMessages.clear(); + + transport.onmessage?.call(const JsonRpcListRootsRequest(id: 'roots-1')); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect(response.id, 'roots-1'); + expect(response.error.code, ErrorCode.methodNotFound.value); + expect(response.error.message, contains('roots')); + }); + test('client retries tools/call after fulfilling input_required requests', () async { late DiscoveringClientTransport transport; @@ -3963,6 +4884,128 @@ void main() { ]); }); + test('client handles input_required before task resultType tools/call', + () async { + late DiscoveringClientTransport transport; + final requests = []; + transport = DiscoveringClientTransport( + capabilities: ServerCapabilities( + tools: const ServerCapabilitiesTools(), + extensions: withMcpTasksExtension(null), + ), + onRequest: (request) { + requests.add(request); + switch (request.method) { + case Method.toolsCall: + if (requests + .where((sent) => sent.method == Method.toolsCall) + .length == + 1) { + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: InputRequiredResult( + requestState: 'approved-state', + inputRequests: { + 'approval': InputRequest.elicit( + ElicitRequest.form( + message: 'Approve async work?', + requestedSchema: JsonSchema.object( + properties: { + 'approved': JsonSchema.boolean(), + }, + required: ['approved'], + ), + ), + ), + }, + ).toJson(), + ), + ); + return; + } + + expect(request.params?['requestState'], 'approved-state'); + expect( + request.params?['inputResponses']['approval'], + { + 'action': 'accept', + 'content': {'approved': true}, + }, + ); + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: const CreateTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-after-mrtr', + status: TaskStatus.working, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:01Z', + ttlMs: null, + pollIntervalMs: 1, + ), + ).toJson(), + ), + ); + break; + + case Method.tasksGet: + expect(request.params?['taskId'], 'task-after-mrtr'); + transport.onmessage?.call( + JsonRpcResponse( + id: request.id, + result: const GetTaskExtensionResult( + task: TaskExtensionTask( + taskId: 'task-after-mrtr', + status: TaskStatus.completed, + createdAt: '2026-07-28T00:00:00Z', + lastUpdatedAt: '2026-07-28T00:00:02Z', + ttlMs: null, + result: { + 'content': [ + {'type': 'text', 'text': 'approved task done'}, + ], + }, + ), + ).toJson(), + ), + ); + break; + } + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: McpClientOptions( + capabilities: ClientCapabilities( + elicitation: const ClientElicitation.formOnly(), + extensions: withMcpTasksExtension(null), + ), + ), + ); + client.onElicitRequest = (params) async { + expect(params.message, 'Approve async work?'); + return const ElicitResult( + action: 'accept', + content: {'approved': true}, + ); + }; + await client.connect(transport); + transport.sentMessages.clear(); + + final result = await client.callTool( + const CallToolRequest(name: 'async-approval-tool'), + ); + + expect((result.content.single as TextContent).text, 'approved task done'); + expect(requests.map((request) => request.method), [ + Method.toolsCall, + Method.toolsCall, + Method.tasksGet, + ]); + }); + test('client updates task input requests once while polling', () async { late DiscoveringClientTransport transport; var getCount = 0; @@ -4891,9 +5934,9 @@ void main() { 'inputSchema': { 'type': 'object', 'properties': { - 'ratio': { - 'type': 'number', - 'x-mcp-header': 'Ratio', + 'payload': { + 'type': 'object', + 'x-mcp-header': 'Payload', }, }, }, @@ -5078,6 +6121,13 @@ void main() { .map((message) => message.method), containsAllInOrder([Method.serverDiscover, Method.initialize]), ); + final initializeRequest = transport.sentMessages + .whereType() + .singleWhere((message) => message.method == Method.initialize); + expect( + initializeRequest.params?['protocolVersion'], + stableProtocolVersion2025_11_25, + ); expect( transport.sentMessages.whereType(), isEmpty, diff --git a/test/server/server_test.dart b/test/server/server_test.dart index 4cebec5f..4a17b7d6 100644 --- a/test/server/server_test.dart +++ b/test/server/server_test.dart @@ -292,11 +292,17 @@ void main() { expect( () => server.assertTaskCapability(Method.samplingCreateMessage), throwsA( - isA().having( - (e) => e.message, - 'message', - contains('tasks.requests.sampling.createMessage'), - ), + isA() + .having( + (e) => e.code, + 'code', + ErrorCode.methodNotFound.value, + ) + .having( + (e) => e.message, + 'message', + contains('tasks.requests.sampling.createMessage'), + ), ), ); }); @@ -354,11 +360,17 @@ void main() { expect( () => server.assertTaskCapability(Method.rootsList), throwsA( - isA().having( - (e) => e.message, - 'message', - contains('tasks.requests.roots/list'), - ), + isA() + .having( + (e) => e.code, + 'code', + ErrorCode.methodNotFound.value, + ) + .having( + (e) => e.message, + 'message', + contains('tasks.requests.roots/list'), + ), ), ); }); @@ -455,10 +467,157 @@ void main() { // Attempt to send create message request should throw synchronously expect( () => server.assertCapabilityForMethod('sampling/createMessage'), - throwsA(isA()), + throwsA( + isA().having( + (e) => e.code, + 'code', + ErrorCode.methodNotFound.value, + ), + ), ); }); + test('Cannot send tool-enabled sampling without sampling.tools capability', + () async { + await server.connect(transport); + await _initializeClient(transport, server, withSampling: true); + + const createParams = CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Use a tool'), + ), + ], + maxTokens: 100, + tools: [ + Tool(name: 'search', inputSchema: JsonObject()), + ], + ); + + expect( + () => server.createMessage(createParams), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.methodNotFound.value, + ) + .having( + (error) => error.message, + 'message', + contains('sampling tools capability'), + ), + ), + ); + expect( + transport.sentMessages + .whereType() + .where((message) => message.method == Method.samplingCreateMessage), + isEmpty, + ); + }); + + test( + 'Can send sampling with includeContext none without context capability', + () async { + await server.connect(transport); + await _initializeClient(transport, server, withSampling: true); + + const createParams = CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Use no context'), + ), + ], + includeContext: IncludeContext.none, + maxTokens: 100, + ); + + final result = await server.createMessage(createParams); + + expect(result.role, equals(SamplingMessageRole.assistant)); + expect( + transport.sentMessages + .whereType() + .where((message) => message.method == Method.samplingCreateMessage), + hasLength(1), + ); + }); + + test('Cannot send deprecated sampling context without context capability', + () async { + await server.connect(transport); + await _initializeClient(transport, server, withSampling: true); + + const createParams = CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Use server context'), + ), + ], + includeContext: IncludeContext.thisServer, + maxTokens: 100, + ); + + expect( + () => server.createMessage(createParams), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.methodNotFound.value, + ) + .having( + (error) => error.message, + 'message', + contains('sampling context capability'), + ), + ), + ); + expect( + transport.sentMessages + .whereType() + .where((message) => message.method == Method.samplingCreateMessage), + isEmpty, + ); + }); + + test('Can send deprecated sampling context with context capability', + () async { + await server.connect(transport); + await _initializeClient( + transport, + server, + withSampling: true, + withSamplingContext: true, + ); + + const createParams = CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: 'Use all context'), + ), + ], + includeContext: IncludeContext.allServers, + maxTokens: 100, + ); + + final result = await server.createMessage(createParams); + + expect(result.role, equals(SamplingMessageRole.assistant)); + final request = + transport.sentMessages.whereType().singleWhere( + (message) => message.method == Method.samplingCreateMessage, + ); + expect(request.params?['includeContext'], IncludeContext.allServers.name); + }); + test('Can send listRoots request when client has roots capability', () async { await server.connect(transport); @@ -492,7 +651,13 @@ void main() { // Attempt to check capability directly should throw expect( () => server.assertCapabilityForMethod('roots/list'), - throwsA(isA()), + throwsA( + isA().having( + (e) => e.code, + 'code', + ErrorCode.methodNotFound.value, + ), + ), ); }); @@ -616,11 +781,14 @@ Future _initializeClient( MockTransport transport, Server server, { bool withSampling = false, + bool withSamplingContext = false, bool withRoots = false, bool withElicitation = false, }) async { final clientCapabilities = ClientCapabilities( - sampling: withSampling ? const ClientCapabilitiesSampling() : null, + sampling: withSampling + ? ClientCapabilitiesSampling(context: withSamplingContext) + : null, roots: withRoots ? const ClientCapabilitiesRoots() : null, elicitation: withElicitation ? const ClientElicitation.formOnly() : null, ); @@ -669,6 +837,7 @@ void _addCriticalPathTests() { () => server.assertCapabilityForMethod('elicitation/create'), throwsA( isA() + .having((e) => e.code, 'code', ErrorCode.methodNotFound.value) .having((e) => e.message, 'message', contains('elicitation')), ), ); diff --git a/test/server/streamable_https_test.dart b/test/server/streamable_https_test.dart index 6038152b..327a9e81 100644 --- a/test/server/streamable_https_test.dart +++ b/test/server/streamable_https_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:mcp_dart/src/server/server.dart'; import 'package:mcp_dart/src/server/streamable_https.dart'; +import 'package:mcp_dart/src/shared/protocol.dart'; import 'package:mcp_dart/src/shared/uuid.dart'; import 'package:mcp_dart/src/types.dart'; import 'package:test/test.dart'; @@ -625,6 +626,114 @@ void main() { timeout: const Timeout(Duration(seconds: 5)), ); + test( + 'routes handler client requests on originating POST SSE stream', + () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => null, + ), + ); + addTearDown(transport.close); + + final server = Server( + const Implementation(name: 'TestServer', version: '1.0.0'), + ); + addTearDown(server.close); + server.setRequestHandler( + 'test/nested-request', + (request, extra) async { + await extra.sendRequest( + const JsonRpcRequest( + id: 0, + method: 'test/client-question', + params: {'prompt': 'confirm'}, + ), + EmptyResult.fromJson, + const RequestOptions(timeout: Duration(seconds: 2)), + ); + return const EmptyResult(); + }, + (id, params, meta) => JsonRpcRequest( + id: id, + method: 'test/nested-request', + params: params, + meta: meta, + ), + ); + await server.connect(transport); + transports['/mcp'] = transport; + + Future postJsonRpc(JsonRpcMessage message) async { + final client = HttpClient(); + addTearDown(() => client.close(force: true)); + + final request = await client.postUrl(Uri.parse('$serverUrlBase/mcp')); + request.headers + ..contentType = ContentType.json + ..set( + HttpHeaders.acceptHeader, + 'application/json, text/event-stream', + ); + request.write(jsonEncode(message.toJson())); + return request.close(); + } + + final initResponse = await postJsonRpc( + JsonRpcInitializeRequest( + id: 1, + initParams: const InitializeRequestParams( + protocolVersion: latestProtocolVersion, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'TestClient', version: '1.0.0'), + ), + ), + ); + expect(initResponse.statusCode, HttpStatus.ok); + expect( + _decodeSseJsonMessages(await utf8.decodeStream(initResponse)).single, + containsPair('id', 1), + ); + + final initializedResponse = await postJsonRpc( + const JsonRpcInitializedNotification(), + ); + expect(initializedResponse.statusCode, HttpStatus.accepted); + await initializedResponse.drain(); + + final response = await postJsonRpc( + const JsonRpcRequest( + id: 'originating-request', + method: 'test/nested-request', + ), + ); + expect(response.statusCode, HttpStatus.ok); + expect(response.headers.contentType?.mimeType, 'text/event-stream'); + final lines = StreamIterator( + response.transform(utf8.decoder).transform(const LineSplitter()), + ); + addTearDown(lines.cancel); + + final nestedRequest = await _readSseJsonEvent(lines); + expect(nestedRequest.json['method'], 'test/client-question'); + expect(nestedRequest.json['params'], {'prompt': 'confirm'}); + + final nestedResponse = await postJsonRpc( + JsonRpcResponse( + id: nestedRequest.json['id'], + result: const EmptyResult().toJson(), + ), + ); + expect(nestedResponse.statusCode, HttpStatus.accepted); + await nestedResponse.drain(); + + final finalResponse = await _readSseJsonEvent(lines); + expect(finalResponse.json['id'], 'originating-request'); + expect(finalResponse.json['result'], const EmptyResult().toJson()); + }, + timeout: const Timeout(Duration(seconds: 5)), + ); + test('enableJsonResponse option is accepted', () async { // Create a transport with JSON response enabled final transport = StreamableHTTPServerTransport( @@ -2159,7 +2268,7 @@ void main() { expect(body['error']['message'], contains('Mcp-Name header value')); }); - test('2026 stateless HTTP requires task id name header for task requests', + test('2026 stateless HTTP accepts task requests with matching name header', () async { final transport = StreamableHTTPServerTransport( options: StreamableHTTPServerTransportOptions( @@ -2184,6 +2293,49 @@ void main() { } }; + final client = HttpClient(); + addTearDown(() => client.close(force: true)); + final request = await client.postUrl(Uri.parse('$serverUrlBase/mcp')); + request.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', draftProtocolVersion2026_07_28) + ..set('Mcp-Method', Method.tasksUpdate) + ..set('Mcp-Name', 'task-1'); + request.write( + jsonEncode( + JsonRpcUpdateTaskRequest( + id: 4, + updateParams: const UpdateTaskRequest( + taskId: 'task-1', + inputResponses: {}, + ), + meta: _statelessMeta(), + ), + ), + ); + + final response = await request.close(); + + expect(response.statusCode, HttpStatus.ok); + final body = + jsonDecode(await utf8.decodeStream(response)) as Map; + expect(body['id'], 4); + expect(body['result'], {'resultType': resultTypeComplete}); + }); + + test('2026 stateless HTTP rejects task requests without name header', + () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => "unused-session-id", + enableJsonResponse: true, + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + final client = HttpClient(); addTearDown(() => client.close(force: true)); final request = await client.postUrl(Uri.parse('$serverUrlBase/mcp')); @@ -2212,7 +2364,50 @@ void main() { jsonDecode(await utf8.decodeStream(response)) as Map; expect(body['id'], 4); expect(body['error']['code'], ErrorCode.headerMismatch.value); - expect(body['error']['message'], contains('Mcp-Name header is required')); + expect(body['error']['message'], contains('Mcp-Name header')); + }); + + test('2026 stateless HTTP accepts response posts without body metadata', + () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => "unused-session-id", + enableJsonResponse: true, + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + + final receivedMessage = Completer(); + transport.onmessage = receivedMessage.complete; + + final client = HttpClient(); + addTearDown(() => client.close(force: true)); + final request = await client.postUrl(Uri.parse('$serverUrlBase/mcp')); + request.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', draftProtocolVersion2026_07_28); + request.write( + jsonEncode( + const JsonRpcResponse( + id: 'input-response', + result: {'ok': true}, + ).toJson(), + ), + ); + + final response = await request.close(); + + expect(response.statusCode, HttpStatus.accepted); + expect(await utf8.decodeStream(response), isEmpty); + final message = + await receivedMessage.future.timeout(const Duration(seconds: 5)); + expect(message, isA()); + final jsonRpcResponse = message as JsonRpcResponse; + expect(jsonRpcResponse.id, 'input-response'); + expect(jsonRpcResponse.result, {'ok': true}); }); test('2026 stateless HTTP ignores session header', () async { @@ -2262,6 +2457,94 @@ void main() { expect(body['result']['tools'], isEmpty); }); + test('2026 stateless HTTP omits existing transport session header', + () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => 'stateful-session-id', + enableJsonResponse: true, + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + + transport.onmessage = (message) { + if (message is JsonRpcInitializeRequest) { + unawaited( + transport.send( + JsonRpcResponse( + id: message.id, + result: const InitializeResult( + protocolVersion: latestProtocolVersion, + capabilities: ServerCapabilities(), + serverInfo: Implementation( + name: 'StatefulServer', + version: '1.0.0', + ), + ).toJson(), + ), + ), + ); + } else if (message is JsonRpcListToolsRequest) { + unawaited( + transport.send( + JsonRpcResponse( + id: message.id, + result: const ListToolsResult(tools: []).toJson(), + ), + ), + ); + } + }; + + final client = HttpClient(); + addTearDown(() => client.close(force: true)); + final initRequest = await client.postUrl(Uri.parse('$serverUrlBase/mcp')); + initRequest.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream'); + initRequest.write( + jsonEncode( + JsonRpcInitializeRequest( + id: 1, + initParams: const InitializeRequest( + protocolVersion: latestProtocolVersion, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'client', version: '1.0.0'), + ), + ), + ), + ); + + final initResponse = await initRequest.close(); + expect(initResponse.statusCode, HttpStatus.ok); + final sessionId = initResponse.headers.value('mcp-session-id'); + expect(sessionId, 'stateful-session-id'); + await utf8.decodeStream(initResponse); + + final statelessRequest = + await client.postUrl(Uri.parse('$serverUrlBase/mcp')); + statelessRequest.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..set('MCP-Protocol-Version', draftProtocolVersion2026_07_28) + ..set('Mcp-Method', Method.toolsList) + ..set('Mcp-Session-Id', sessionId!); + statelessRequest.write( + jsonEncode(JsonRpcListToolsRequest(id: 6, meta: _statelessMeta())), + ); + + final statelessResponse = await statelessRequest.close(); + + expect(statelessResponse.statusCode, HttpStatus.ok); + expect(statelessResponse.headers.value('mcp-session-id'), isNull); + final body = jsonDecode(await utf8.decodeStream(statelessResponse)) + as Map; + expect(body['id'], 6); + expect(body['result']['tools'], isEmpty); + }); + test('2026 stateless HTTP accepts matching standard and parameter headers', () async { final transport = StreamableHTTPServerTransport( @@ -2297,6 +2580,7 @@ void main() { ..set('Mcp-Method', Method.toolsCall) ..set('Mcp-Name', 'execute') ..set('Mcp-Param-region', 'us-east1') + ..set('Mcp-Param-ratio', '1.5') ..set('Mcp-Param-dryRun', 'false'); request.write( jsonEncode( @@ -2306,6 +2590,7 @@ void main() { 'name': 'execute', 'arguments': { 'region': 'us-east1', + 'ratio': 1.5, 'dryRun': false, }, }, @@ -2490,9 +2775,8 @@ void main() { transport.setToolParameterHeaderMappings( const { 'execute': { + 'count': 'Count', 'dryRun': 'Dry-Run', - 'rounded': 'Rounded', - 'ratio': 'Ratio', 'region': 'Region', 'sentinel': 'Sentinel', '/location/zone': 'Zone', @@ -2603,33 +2887,50 @@ void main() { (statusCode, body) = await postToolCall( id: 34, arguments: const { + 'count': 42, 'dryRun': false, - 'ratio': 1.5, 'region': 'us-east1', }, headers: const { + 'Mcp-Param-Count': '42', 'Mcp-Param-Dry-Run': 'false', - 'Mcp-Param-Ratio': '1.5', 'Mcp-Param-Region': 'us-east1', }, ); - expect(statusCode, HttpStatus.badRequest); + expect(statusCode, HttpStatus.ok); expect(body['id'], 34); + expect(body['result']['content'], isEmpty); + + (statusCode, body) = await postToolCall( + id: 38, + arguments: const { + 'count': 42, + 'dryRun': false, + 'region': 'us-east1', + }, + headers: const { + 'Mcp-Param-Count': '43', + 'Mcp-Param-Dry-Run': 'false', + 'Mcp-Param-Region': 'us-east1', + }, + ); + expect(statusCode, HttpStatus.badRequest); + expect(body['id'], 38); expect( body['error']['message'], - contains('no matching primitive body argument'), + contains("body argument 'count'"), ); (statusCode, body) = await postToolCall( id: 35, arguments: const { + 'count': 42, 'dryRun': false, - 'rounded': 42.0, 'region': 'us-east1', }, headers: const { + 'Mcp-Param-Count': '42.0', 'Mcp-Param-Dry-Run': 'false', - 'Mcp-Param-Rounded': '42.0', 'Mcp-Param-Region': 'us-east1', }, ); @@ -3052,6 +3353,28 @@ void main() { patchBody['error']['message'], 'Method not allowed for stateless MCP requests.', ); + + final deleteRequest = await client.deleteUrl( + Uri.parse('$serverUrlBase/mcp'), + ); + deleteRequest.headers + ..set('MCP-Protocol-Version', draftProtocolVersion2026_07_28) + ..set('Mcp-Session-Id', 'ignored-stateless-session'); + + final deleteResponse = await deleteRequest.close(); + final deleteBody = jsonDecode( + await utf8.decodeStream(deleteResponse), + ) as Map; + + expect(deleteResponse.statusCode, HttpStatus.methodNotAllowed); + expect(deleteResponse.headers.contentType?.mimeType, 'application/json'); + expect(deleteResponse.headers.value(HttpHeaders.allowHeader), 'POST'); + expect(deleteResponse.headers.value('mcp-session-id'), isNull); + expect(deleteBody['error']['code'], ErrorCode.connectionClosed.value); + expect( + deleteBody['error']['message'], + 'Method not allowed for stateless MCP requests.', + ); }); test('close cleans up all resources', () async { diff --git a/test/server/streamable_mcp_server_test.dart b/test/server/streamable_mcp_server_test.dart index 3bba902e..60f32ec6 100644 --- a/test/server/streamable_mcp_server_test.dart +++ b/test/server/streamable_mcp_server_test.dart @@ -362,6 +362,172 @@ void main() { expect(messages.single['result']['tools'][0]['name'], 'echo'); }); + test('handles 2026 stateless request with unknown session ID', () async { + await server.stop(); + server = StreamableMcpServer( + serverFactory: (sessionId) { + final mcpServer = McpServer( + const Implementation(name: 'StatelessServer', version: '1.0.0'), + ); + mcpServer.registerTool( + 'echo', + inputSchema: const ToolInputSchema(), + callback: (args, extra) async => const CallToolResult(content: []), + ); + return mcpServer; + }, + host: host, + port: port, + ); + await server.start(); + + final response = await http.post( + Uri.parse(baseUrl), + body: jsonEncode( + JsonRpcListToolsRequest(id: 2, meta: statelessMeta()).toJson(), + ), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.toolsList, + 'mcp-session-id': 'unknown-legacy-session', + }, + ); + + expect(response.statusCode, HttpStatus.ok); + expect(response.headers['mcp-session-id'], isNull); + final messages = _decodeSseJsonMessages(response.body); + expect(messages.single['id'], 2); + expect(messages.single['result']['tools'][0]['name'], 'echo'); + }); + + test('handles stateless task lookup across independent requests', () async { + const taskId = 'task-http-1'; + final tasks = {}; + await server.stop(); + server = StreamableMcpServer( + serverFactory: (sessionId) { + final mcpServer = McpServer( + const Implementation(name: 'StatelessServer', version: '1.0.0'), + options: const McpServerOptions( + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + extensions: {mcpTasksExtensionId: {}}, + ), + ), + ); + mcpServer.server.setRequestHandler( + Method.toolsCall, + (request, extra) async { + final task = TaskExtensionTask( + taskId: taskId, + status: TaskStatus.working, + createdAt: DateTime.utc(2026, 7, 28).toIso8601String(), + lastUpdatedAt: DateTime.utc(2026, 7, 28).toIso8601String(), + ttlMs: 60000, + ); + tasks[task.taskId] = task; + return CreateTaskExtensionResult(task: task); + }, + (id, params, meta) => JsonRpcCallToolRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.toolsCall, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + mcpServer.server.setRequestHandler( + Method.tasksGet, + (request, extra) async { + final task = tasks[request.getParams.taskId]; + if (task == null) { + throw McpError( + ErrorCode.invalidParams.value, + 'Task not found', + ); + } + return GetTaskExtensionResult(task: task); + }, + (id, params, meta) => JsonRpcGetTaskRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.tasksGet, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + return mcpServer; + }, + host: host, + port: port, + ); + await server.start(); + + final meta = buildProtocolRequestMeta( + protocolVersion: draftProtocolVersion2026_07_28, + clientInfo: const Implementation(name: 'Client', version: '1.0'), + clientCapabilities: const ClientCapabilities( + extensions: {mcpTasksExtensionId: {}}, + ), + ); + final createResponse = await http.post( + Uri.parse(baseUrl), + body: jsonEncode( + JsonRpcCallToolRequest( + id: 'call-task', + params: const CallToolRequest(name: 'long').toJson(), + meta: meta, + ).toJson(), + ), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.toolsCall, + 'Mcp-Name': 'long', + }, + ); + + expect(createResponse.statusCode, HttpStatus.ok); + expect(createResponse.headers['mcp-session-id'], isNull); + final createMessages = _decodeSseJsonMessages(createResponse.body); + expect(createMessages.single['result']['resultType'], resultTypeTask); + expect(createMessages.single['result']['taskId'], taskId); + + final lookupResponse = await http.post( + Uri.parse(baseUrl), + body: jsonEncode( + JsonRpcGetTaskRequest( + id: 'get-task', + getParams: const GetTaskRequest(taskId: taskId), + meta: meta, + ).toJson(), + ), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.tasksGet, + 'Mcp-Name': taskId, + 'mcp-session-id': 'unknown-legacy-session', + }, + ); + + expect(lookupResponse.statusCode, HttpStatus.ok); + expect(lookupResponse.headers['mcp-session-id'], isNull); + final lookupMessages = _decodeSseJsonMessages(lookupResponse.body); + expect(lookupMessages.single['id'], 'get-task'); + expect(lookupMessages.single['result']['resultType'], resultTypeComplete); + expect(lookupMessages.single['result']['taskId'], taskId); + expect( + lookupMessages.single['result']['status'], + TaskStatus.working.name, + ); + expect(lookupMessages.single['result']['ttlMs'], 60000); + }); + test('detects stateless requests from nested metadata before top-level', () async { await server.stop(); @@ -1372,6 +1538,21 @@ void main() { test('server port is exposed correctly', () async { expect(server.port, equals(port)); + expect(server.boundPort, equals(port)); + }); + + test('bound port exposes OS-assigned port', () async { + await server.stop(); + server = StreamableMcpServer( + serverFactory: (sid) => + McpServer(const Implementation(name: 'PortServer', version: '1.0')), + host: host, + port: 0, + ); + await server.start(); + + expect(server.port, equals(0)); + expect(server.boundPort, isNot(0)); }); }); } diff --git a/test/shared/json_schema_from_json_test.dart b/test/shared/json_schema_from_json_test.dart index c19165e3..0311e777 100644 --- a/test/shared/json_schema_from_json_test.dart +++ b/test/shared/json_schema_from_json_test.dart @@ -80,6 +80,42 @@ void main() { expect(s.toJson(), json); }); + test('rejects invalid explicit type values', () { + expect( + () => JsonSchema.fromJson({'type': 'unknown'}), + throwsA(isA()), + ); + expect( + () => JsonSchema.fromJson({ + 'type': ['string', 'unknown'], + }), + throwsA(isA()), + ); + expect( + () => JsonSchema.fromJson({ + 'type': ['string', 'string'], + }), + throwsA(isA()), + ); + expect( + () => JsonSchema.fromJson({'type': 1}), + throwsA(isA()), + ); + }); + + test('keeps schemas without explicit type as any schemas', () { + final schema = JsonSchema.fromJson({ + 'title': 'Any JSON value', + 'description': 'No type restriction.', + }); + + expect(schema, isA()); + expect(schema.toJson(), { + 'title': 'Any JSON value', + 'description': 'No type restriction.', + }); + }); + test('parses const schema', () { final json = {'const': 'DELETE'}; final schema = JsonSchema.fromJson(json); diff --git a/test/shared/json_schema_validator_test.dart b/test/shared/json_schema_validator_test.dart index 22cc927f..ec46f11f 100644 --- a/test/shared/json_schema_validator_test.dart +++ b/test/shared/json_schema_validator_test.dart @@ -669,7 +669,7 @@ void main() { } }); - test('rejects malformed raw type arrays', () { + test('rejects malformed raw type arrays at parse time', () { final schemas = [ { 'type': ['string', 1], @@ -688,11 +688,9 @@ void main() { ]; for (final json in schemas) { - final schema = JsonSchema.fromJson(json); - expect(schema.toJson(), json); expect( - () => schema.validate('value'), - throwsA(isA()), + () => JsonSchema.fromJson(json), + throwsA(isA()), ); } }); diff --git a/test/shared/protocol_test.dart b/test/shared/protocol_test.dart index 3cd2cf9d..60f37a47 100644 --- a/test/shared/protocol_test.dart +++ b/test/shared/protocol_test.dart @@ -2739,6 +2739,57 @@ void main() { } }); + test('does not send cancellation notification for initialize request', + () async { + await protocol.connect(transport); + + final controller = BasicAbortController(); + final requestFuture = protocol + .request( + JsonRpcInitializeRequest( + id: 0, + initParams: const InitializeRequest( + protocolVersion: latestProtocolVersion, + capabilities: ClientCapabilities(), + clientInfo: Implementation( + name: 'test-client', + version: '1.0.0', + ), + ), + ), + InitializeResult.fromJson, + RequestOptions( + signal: controller.signal, + timeoutEnabled: false, + ), + ) + .timeout(const Duration(seconds: 5)); + + expect(transport.sentMessages, hasLength(1)); + expect( + (transport.sentMessages.single as JsonRpcRequest).method, + Method.initialize, + ); + + controller.abort('User cancelled initialize'); + + await expectLater( + requestFuture, + throwsA( + predicate( + (error) => error.toString().contains('User cancelled initialize'), + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 20)); + + expect( + transport.sentMessages.whereType(), + isEmpty, + ); + expect(transport.sentMessages, hasLength(1)); + }); + test('enforces strict capabilities when enabled', () { // We avoid using a transport connection in this test and just verify the capability check directly final strictProtocol = TestProtocol( diff --git a/test/tool/spec_example_audit_test.dart b/test/tool/spec_example_audit_test.dart new file mode 100644 index 00000000..9ea6f47f --- /dev/null +++ b/test/tool/spec_example_audit_test.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + group('spec_example_audit', () { + late Directory examplesDir; + + setUp(() { + examplesDir = Directory.systemTemp.createTempSync( + 'mcp_spec_example_audit_test_', + ); + }); + + tearDown(() { + if (examplesDir.existsSync()) { + examplesDir.deleteSync(recursive: true); + } + }); + + test('accepts representative upstream example shapes', () async { + _writeExample( + examplesDir, + 'Tool', + 'tool-with-array-output-schema.json', + { + 'name': 'get_tags', + 'description': 'Returns tags', + 'inputSchema': { + 'type': 'object', + 'properties': {}, + }, + 'outputSchema': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + }, + ); + _writeExample( + examplesDir, + 'CallToolResultResponse', + 'call-tool-result-response.json', + { + 'jsonrpc': '2.0', + 'id': 'call-tool-example', + 'result': { + 'resultType': 'complete', + 'content': [ + {'type': 'text', 'text': 'ok'}, + ], + }, + }, + ); + _writeExample( + examplesDir, + 'MissingRequiredClientCapabilityError', + 'missing-elicitation-capability.json', + { + 'jsonrpc': '2.0', + 'id': 1, + 'error': { + 'code': -32003, + 'message': + 'Server requires the elicitation capability for this request', + 'data': { + 'requiredCapabilities': { + 'elicitation': {}, + }, + }, + }, + }, + ); + _writeExample( + examplesDir, + 'ListRootsRequest', + 'list-roots-request.json', + { + 'id': 'list-roots-example', + 'method': 'roots/list', + }, + ); + _writeExample( + examplesDir, + 'InputRequests', + 'elicitation-and-sampling-input-requests.json', + { + 'github_login': { + 'method': 'elicitation/create', + 'params': { + 'mode': 'form', + 'message': 'Please provide your GitHub username', + 'requestedSchema': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + }, + 'required': ['name'], + }, + }, + }, + 'capital_of_france': { + 'method': 'sampling/createMessage', + 'params': { + 'messages': [ + { + 'role': 'user', + 'content': { + 'type': 'text', + 'text': 'What is the capital of France?', + }, + }, + ], + 'maxTokens': 100, + }, + }, + }, + ); + + final result = await _runAudit(examplesDir); + + expect(result.exitCode, 0, reason: _processOutput(result)); + expect(result.stdout, contains('examples=5 parsed=5 missing=0')); + }); + + test('fails when an upstream example group has no parser mapping', + () async { + _writeExample( + examplesDir, + 'FutureSpecThing', + 'future.json', + {'example': true}, + ); + + final result = await _runAudit(examplesDir); + + expect(result.exitCode, 1); + expect(result.stdout, contains('missing parser groups:')); + expect(result.stdout, contains('FutureSpecThing: 1')); + }); + + test('fails when a known example no longer matches the typed parser', + () async { + _writeExample( + examplesDir, + 'CallToolResult', + 'missing-content.json', + {'resultType': 'complete'}, + ); + + final result = await _runAudit(examplesDir); + + expect(result.exitCode, 1); + expect(result.stdout, contains('failures:')); + expect(result.stdout, contains('CallToolResult/missing-content.json')); + expect(result.stdout, contains('CallToolResult.content is required')); + }); + }); +} + +Future _runAudit(Directory examplesDir) { + return Process.run( + Platform.resolvedExecutable, + ['run', 'tool/spec_example_audit.dart', examplesDir.path], + workingDirectory: Directory.current.path, + ); +} + +void _writeExample( + Directory root, + String group, + String name, + Map json, +) { + final directory = Directory(p.join(root.path, group))..createSync(); + File(p.join(directory.path, name)).writeAsStringSync(jsonEncode(json)); +} + +String _processOutput(ProcessResult result) { + return 'stdout:\n${result.stdout}\nstderr:\n${result.stderr}'; +} diff --git a/test/tool_schema_test.dart b/test/tool_schema_test.dart index b3e42154..14664f13 100644 --- a/test/tool_schema_test.dart +++ b/test/tool_schema_test.dart @@ -3,7 +3,7 @@ import 'package:test/test.dart'; void main() { group('Tool parameter header annotations', () { - test('primitive schemas preserve x-mcp-header round-trip', () { + test('schema objects preserve x-mcp-header round-trip', () { final schema = JsonSchema.object( properties: { 'region': JsonSchema.string(mcpHeader: 'Region'), diff --git a/test/types/subscriptions_test.dart b/test/types/subscriptions_test.dart index 1004bbca..83f18972 100644 --- a/test/types/subscriptions_test.dart +++ b/test/types/subscriptions_test.dart @@ -57,7 +57,7 @@ void main() { const capabilities = ServerCapabilities( extensions: {mcpTasksExtensionId: {}}, tools: ServerCapabilitiesTools(listChanged: true), - resources: ServerCapabilitiesResources(), + resources: ServerCapabilitiesResources(subscribe: true), ); final acknowledged = requested.acknowledgedBy(capabilities); @@ -66,6 +66,18 @@ void main() { 'resourceSubscriptions': ['file:///project/config.json'], 'taskIds': ['task-1'], }); + + final withoutResourceSubscribe = requested.acknowledgedBy( + const ServerCapabilities( + extensions: {mcpTasksExtensionId: {}}, + tools: ServerCapabilitiesTools(listChanged: true), + resources: ServerCapabilitiesResources(), + ), + ); + expect(withoutResourceSubscribe.toJson(), { + 'toolsListChanged': true, + 'taskIds': ['task-1'], + }); }); test('acknowledgedBy omits task filters without task extension support', @@ -125,6 +137,30 @@ void main() { ), isTrue, ); + expect( + const SubscriptionFilter( + resourceSubscriptions: ['file:///project'], + ).allowsNotification( + JsonRpcResourceUpdatedNotification( + updatedParams: const ResourceUpdatedNotification( + uri: 'file:///project/config.json', + ), + ), + ), + isTrue, + ); + expect( + const SubscriptionFilter( + resourceSubscriptions: ['file:///project'], + ).allowsNotification( + JsonRpcResourceUpdatedNotification( + updatedParams: const ResourceUpdatedNotification( + uri: 'file:///project-other/config.json', + ), + ), + ), + isFalse, + ); expect( acknowledged.allowsNotification( JsonRpcResourceUpdatedNotification( @@ -268,6 +304,46 @@ void main() { } }); + test('experimental completion list changed validates wrapper directly', () { + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + final valid = JsonRpcCompletionListChangedNotification.fromJson({ + 'jsonrpc': '2.0', + 'method': Method.notificationsExperimentalCompletionsListChanged, + 'params': { + '_meta': {McpMetaKey.subscriptionId: 'sub-1'}, + }, + }); + expect(valid.meta?[McpMetaKey.subscriptionId], 'sub-1'); + + for (final json in [ + { + 'jsonrpc': '1.0', + 'method': Method.notificationsExperimentalCompletionsListChanged, + }, + { + 'jsonrpc': '2.0', + // ignore: deprecated_member_use + 'method': Method.notificationsCompletionsListChanged, + }, + { + 'jsonrpc': '2.0', + 'method': Method.notificationsExperimentalCompletionsListChanged, + 'result': {'ok': true}, + }, + { + 'jsonrpc': '2.0', + 'method': Method.notificationsExperimentalCompletionsListChanged, + 'error': {'code': -32600, 'message': 'Invalid request'}, + }, + ]) { + expect( + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + () => JsonRpcCompletionListChangedNotification.fromJson(json), + throwsA(isA()), + ); + } + }); + test('serializes and parses subscription acknowledgments', () { final notification = JsonRpcSubscriptionsAcknowledgedNotification( acknowledgedParams: const SubscriptionsAcknowledgedNotification( diff --git a/test/types_edge_cases_test.dart b/test/types_edge_cases_test.dart index 13e7aaf4..4ba7c40d 100644 --- a/test/types_edge_cases_test.dart +++ b/test/types_edge_cases_test.dart @@ -140,6 +140,30 @@ void main() { ); } }); + + test('JsonRpcError validates JSON-RPC envelope fields directly', () { + for (final json in [ + { + 'jsonrpc': '1.0', + 'error': {'code': -32600, 'message': 'Bad version'}, + }, + { + 'jsonrpc': '2.0', + 'method': 'unexpected/request', + 'error': {'code': -32600, 'message': 'Bad kind'}, + }, + { + 'jsonrpc': '2.0', + 'result': {'ok': true}, + 'error': {'code': -32603, 'message': 'Internal error'}, + }, + ]) { + expect( + () => JsonRpcError.fromJson(json), + throwsA(isA()), + ); + } + }); }); group('JsonRpcCancelledNotification Edge Cases', () { @@ -196,18 +220,29 @@ void main() { expect(json.containsKey('reason'), isFalse); }); - test('allows omitted requestId per notification wire schema', () { - final parsed = JsonRpcCancelledNotification.fromJson({ - 'jsonrpc': '2.0', - 'method': 'notifications/cancelled', - 'params': {'reason': 'Task cancellation uses tasks/cancel'}, - }); + test('rejects omitted requestId per cancellation semantics', () { + expect( + () => JsonRpcCancelledNotification.fromJson({ + 'jsonrpc': '2.0', + 'method': 'notifications/cancelled', + 'params': {'reason': 'Task cancellation uses tasks/cancel'}, + }), + throwsA( + isA() + .having((e) => e.message, 'message', contains('requestId')), + ), + ); - expect(parsed.cancelParams.requestId, isNull); - expect(parsed.cancelParams.reason, 'Task cancellation uses tasks/cancel'); - expect(parsed.toJson()['params'], { - 'reason': 'Task cancellation uses tasks/cancel', - }); + expect( + () => const CancelledNotificationParams( + requestId: null, + reason: 'missing request id', + ).toJson(), + throwsA( + isA() + .having((e) => e.message, 'message', contains('requestId')), + ), + ); }); test('rejects malformed requestId wire values', () { @@ -891,6 +926,72 @@ void main() { ); }); + test('rejects message envelopes mixing method with response fields', () { + for (final json in [ + { + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'unknown/request', + 'result': {'ok': true}, + }, + { + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'unknown/request', + 'error': {'code': -32600, 'message': 'Invalid request'}, + }, + ]) { + expect( + () => JsonRpcMessage.fromJson(json), + throwsA( + isA() + .having((e) => e.message, 'message', contains('method')) + .having((e) => e.message, 'message', contains('result')) + .having((e) => e.message, 'message', contains('error')), + ), + ); + } + }); + + test('typed parsers reject response fields directly', () { + for (final parse in [ + () => JsonRpcPingRequest.fromJson({ + 'jsonrpc': '2.0', + 'id': 1, + 'method': Method.ping, + 'result': {'ok': true}, + }), + () => JsonRpcPingRequest.fromJson({ + 'jsonrpc': '2.0', + 'id': 1, + 'method': Method.ping, + 'error': {'code': -32600, 'message': 'Invalid request'}, + }), + () => JsonRpcProgressNotification.fromJson({ + 'jsonrpc': '2.0', + 'method': Method.notificationsProgress, + 'params': {'progressToken': 'p1', 'progress': 1}, + 'result': {'ok': true}, + }), + () => JsonRpcProgressNotification.fromJson({ + 'jsonrpc': '2.0', + 'method': Method.notificationsProgress, + 'params': {'progressToken': 'p1', 'progress': 1}, + 'error': {'code': -32600, 'message': 'Invalid request'}, + }), + ]) { + expect( + parse, + throwsA( + isA() + .having((e) => e.message, 'message', contains('method')) + .having((e) => e.message, 'message', contains('result')) + .having((e) => e.message, 'message', contains('error')), + ), + ); + } + }); + test('handles error with omitted id', () { final json = { 'jsonrpc': '2.0', diff --git a/test/types_test.dart b/test/types_test.dart index 1051f831..e6c1f3bf 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -609,6 +609,24 @@ void main() { }); group('ToolExecution Tests', () { + test('Tool serialization preserves execution by default', () { + final json = const Tool( + name: 'task-tool', + inputSchema: JsonObject(), + execution: ToolExecution(taskSupport: 'optional'), + ).toJson(); + + expect(json['execution'], {'taskSupport': 'optional'}); + expect( + const Tool( + name: 'task-tool', + inputSchema: JsonObject(), + execution: ToolExecution(taskSupport: 'optional'), + ).toJson(omitExecution: true), + isNot(contains('execution')), + ); + }); + test('rejects invalid taskSupport while parsing wire JSON', () { expect( () => ToolExecution.fromJson({'taskSupport': 'sometimes'}), @@ -2171,23 +2189,11 @@ void main() { expect(restored.defaultValue, equals('medium')); }); - // Removed test for invalid type because JsonSchema might handle unknown types differently or throw different error. - // But testing for 'type': 'unknown' should usually fail or be generic. - // JsonSchema.fromJson throws format exception for valid types mismatch, but unknown? - // Let's testing unknown type throwing exception. test('JsonSchema factory throws on invalid type', () { - // Assuming implementation throws for completely unknown type if strictly typed? - // Currently JsonSchema.fromJson handles known types. Fallback? - // Let's assume it might throw or return generic. - // Based on previous code, I'll keep expectation if it throws. - final json = {'type': 'unknown'}; - try { - JsonSchema.fromJson(json); - // If it doesn't throw, we might need to adjust test expectation or implementation. - // For now, removing this specific assertion if behavior is undefined. - } catch (e) { - expect(e, isA()); - } + expect( + () => JsonSchema.fromJson({'type': 'unknown'}), + throwsA(isA()), + ); }); }); diff --git a/tool/spec_example_audit.dart b/tool/spec_example_audit.dart new file mode 100644 index 00000000..b55352dc --- /dev/null +++ b/tool/spec_example_audit.dart @@ -0,0 +1,287 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:mcp_dart/mcp_dart.dart'; + +typedef Parser = void Function(Map json); + +JsonRpcResponse _response(Map json) { + final message = JsonRpcMessage.fromJson(json); + if (message is! JsonRpcResponse) { + throw FormatException('Expected JsonRpcResponse, got $message'); + } + return message; +} + +void _parseErrorDataOrWrapper(Map json) { + if (json.containsKey('error')) { + JsonRpcError.fromJson(json); + return; + } + JsonRpcErrorData.fromJson(json); +} + +void _parseRequestLike(Map json) { + if (json.containsKey('jsonrpc')) { + JsonRpcMessage.fromJson(json); + return; + } + + final method = json['method']; + if (method is! String) { + throw const FormatException('Expected request-like method'); + } + + final params = json['params']; + final paramsJson = params is Map ? Map.from(params) : null; + + switch (method) { + case Method.elicitationCreate: + if (paramsJson == null) { + throw const FormatException('elicitation/create params are required'); + } + ElicitRequest.fromJson( + paramsJson, + protocolVersion: latestDraftProtocolVersion, + ); + return; + case Method.samplingCreateMessage: + if (paramsJson == null) { + throw const FormatException( + 'sampling/createMessage params are required', + ); + } + CreateMessageRequest.fromJson(paramsJson); + return; + case Method.rootsList: + if (paramsJson != null && paramsJson.isNotEmpty) { + throw const FormatException('roots/list input request has no params'); + } + return; + default: + throw FormatException('No request-like parser for method $method'); + } +} + +void _parseJsonRpc(Map json) { + JsonRpcMessage.fromJson(json); +} + +void _parseSchema(Map json) { + JsonSchema.fromJson(json); +} + +void _parseInputResponses(Map json) { + InputResponse.mapFromJson(json, 'InputResponses'); +} + +final Map _parsers = { + 'AudioContent': (json) => AudioContent.fromJson(json), + 'BlobResourceContents': (json) => ResourceContents.fromJson(json), + 'BooleanSchema': _parseSchema, + 'CallToolRequest': _parseJsonRpc, + 'CallToolRequestParams': (json) => CallToolRequest.fromJson(json), + 'CallToolResult': (json) => CallToolResult.fromJson(json), + 'CallToolResultResponse': (json) { + CallToolResult.fromJson(_response(json).result); + }, + 'CancelledNotification': _parseJsonRpc, + 'CancelledNotificationParams': (json) { + CancelledNotification.fromJson(json); + }, + 'ClientCapabilities': (json) => ClientCapabilities.fromJson(json), + 'CompleteRequest': _parseJsonRpc, + 'CompleteRequestParams': (json) => CompleteRequest.fromJson(json), + 'CompleteResult': (json) => CompleteResult.fromJson(json), + 'CompleteResultResponse': (json) { + CompleteResult.fromJson(_response(json).result); + }, + 'CreateMessageRequest': _parseRequestLike, + 'CreateMessageRequestParams': (json) { + CreateMessageRequest.fromJson(json); + }, + 'CreateMessageResult': (json) => CreateMessageResult.fromJson(json), + 'DiscoverRequest': _parseJsonRpc, + 'DiscoverResult': (json) => DiscoverResult.fromJson(json), + 'DiscoverResultResponse': (json) { + DiscoverResult.fromJson(_response(json).result); + }, + 'ElicitRequest': _parseRequestLike, + 'ElicitRequestFormParams': (json) { + ElicitRequest.fromJson( + json, + protocolVersion: latestDraftProtocolVersion, + ); + }, + 'ElicitRequestURLParams': (json) { + ElicitRequest.fromJson( + json, + protocolVersion: latestDraftProtocolVersion, + ); + }, + 'ElicitResult': (json) => ElicitResult.fromJson(json), + 'ElicitationCompleteNotification': _parseJsonRpc, + 'EmbeddedResource': (json) => EmbeddedResource.fromJson(json), + 'GetPromptRequest': _parseJsonRpc, + 'GetPromptRequestParams': (json) => GetPromptRequest.fromJson(json), + 'GetPromptResult': (json) => GetPromptResult.fromJson(json), + 'GetPromptResultResponse': (json) { + GetPromptResult.fromJson(_response(json).result); + }, + 'ImageContent': (json) => ImageContent.fromJson(json), + 'InputRequiredResult': (json) => InputRequiredResult.fromJson(json), + 'InputRequests': (json) { + InputRequest.mapFromJson(json, 'InputRequests'); + }, + 'InputResponses': _parseInputResponses, + 'InternalError': _parseErrorDataOrWrapper, + 'InvalidParamsError': _parseErrorDataOrWrapper, + 'InvalidRequestError': _parseErrorDataOrWrapper, + 'ListPromptsRequest': _parseJsonRpc, + 'ListPromptsResult': (json) => ListPromptsResult.fromJson(json), + 'ListPromptsResultResponse': (json) { + ListPromptsResult.fromJson(_response(json).result); + }, + 'ListResourceTemplatesRequest': _parseJsonRpc, + 'ListResourceTemplatesResult': (json) { + ListResourceTemplatesResult.fromJson(json); + }, + 'ListResourceTemplatesResultResponse': (json) { + ListResourceTemplatesResult.fromJson(_response(json).result); + }, + 'ListResourcesRequest': _parseJsonRpc, + 'ListResourcesResult': (json) => ListResourcesResult.fromJson(json), + 'ListResourcesResultResponse': (json) { + ListResourcesResult.fromJson(_response(json).result); + }, + 'ListRootsRequest': _parseRequestLike, + 'ListRootsResult': (json) => ListRootsResult.fromJson(json), + 'ListToolsRequest': _parseJsonRpc, + 'ListToolsResult': (json) => ListToolsResult.fromJson(json), + 'ListToolsResultResponse': (json) { + ListToolsResult.fromJson(_response(json).result); + }, + 'LoggingMessageNotification': _parseJsonRpc, + 'LoggingMessageNotificationParams': (json) { + LoggingMessageNotification.fromJson(json); + }, + 'MethodNotFoundError': _parseErrorDataOrWrapper, + 'MissingRequiredClientCapabilityError': _parseErrorDataOrWrapper, + 'ModelPreferences': (json) => ModelPreferences.fromJson(json), + 'NumberSchema': _parseSchema, + 'PaginatedRequestParams': (json) => ListToolsRequest.fromJson(json), + 'ParseError': _parseErrorDataOrWrapper, + 'ProgressNotification': _parseJsonRpc, + 'ProgressNotificationParams': (json) { + ProgressNotification.fromJson(json); + }, + 'PromptListChangedNotification': _parseJsonRpc, + 'ReadResourceRequest': _parseJsonRpc, + 'ReadResourceResult': (json) => ReadResourceResult.fromJson(json), + 'ReadResourceResultResponse': (json) { + ReadResourceResult.fromJson(_response(json).result); + }, + 'Resource': (json) => Resource.fromJson(json), + 'ResourceLink': (json) => ResourceLink.fromJson(json), + 'ResourceListChangedNotification': _parseJsonRpc, + 'ResourceUpdatedNotification': _parseJsonRpc, + 'ResourceUpdatedNotificationParams': (json) { + ResourceUpdatedNotification.fromJson(json); + }, + 'Root': (json) => Root.fromJson(json), + 'SamplingMessage': (json) => SamplingMessage.fromJson(json), + 'ServerCapabilities': (json) => ServerCapabilities.fromJson(json), + 'StringSchema': _parseSchema, + 'SubscriptionsAcknowledgedNotification': _parseJsonRpc, + 'SubscriptionsListenRequest': _parseJsonRpc, + 'TextContent': (json) => TextContent.fromJson(json), + 'TextResourceContents': (json) => ResourceContents.fromJson(json), + 'TitledMultiSelectEnumSchema': _parseSchema, + 'TitledSingleSelectEnumSchema': _parseSchema, + 'Tool': (json) => Tool.fromJson(json), + 'ToolListChangedNotification': _parseJsonRpc, + 'ToolResultContent': (json) => SamplingContent.fromJson(json), + 'ToolUseContent': (json) => SamplingContent.fromJson(json), + 'UnsupportedProtocolVersionError': _parseErrorDataOrWrapper, + 'UntitledMultiSelectEnumSchema': _parseSchema, + 'UntitledSingleSelectEnumSchema': _parseSchema, +}; + +void main(List args) { + if (args.length != 1) { + stderr.writeln( + 'usage: dart run tool/spec_example_audit.dart ', + ); + exitCode = 64; + return; + } + + final root = Directory(args.single); + if (!root.existsSync()) { + stderr.writeln('examples directory does not exist: ${root.path}'); + exitCode = 66; + return; + } + + final files = root + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.json')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + + final failures = []; + final missing = {}; + var parsed = 0; + + for (final file in files) { + final relative = file.path.substring(root.path.length + 1); + final group = relative.split(Platform.pathSeparator).first; + final parser = _parsers[group]; + if (parser == null) { + missing[group] = (missing[group] ?? 0) + 1; + continue; + } + + try { + final decoded = jsonDecode(file.readAsStringSync()); + if (decoded is! Map) { + throw FormatException( + 'Expected object root, got ${decoded.runtimeType}', + ); + } + parser(Map.from(decoded)); + parsed++; + } catch (error, stackTrace) { + failures.add( + '$relative\n' + ' $error\n' + ' ${stackTrace.toString().split('\n').first}', + ); + } + } + + stdout.writeln( + 'examples=${files.length} parsed=$parsed ' + 'missing=${missing.values.fold(0, (sum, count) => sum + count)}', + ); + + if (missing.isNotEmpty) { + stdout.writeln('missing parser groups:'); + for (final entry in missing.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key))) { + stdout.writeln(' ${entry.key}: ${entry.value}'); + } + } + + if (failures.isNotEmpty) { + stdout.writeln('failures:'); + for (final failure in failures) { + stdout.writeln(failure); + } + } + + if (missing.isNotEmpty || failures.isNotEmpty) { + exitCode = 1; + } +} From 9a57c75095930692d4d65075703bdd74fbb5bd3b Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Tue, 2 Jun 2026 21:44:16 -0400 Subject: [PATCH 39/68] Add official conformance CI coverage --- .github/workflows/test_core.yml | 27 + CHANGELOG.md | 3 + lib/src/client/client.dart | 22 +- lib/src/client/streamable_https.dart | 372 ++++++++- lib/src/server/mcp_server.dart | 4 +- lib/src/server/server.dart | 14 +- lib/src/server/streamable_https.dart | 135 +++- lib/src/server/streamable_mcp_server.dart | 39 +- lib/src/shared/json_schema/json_schema.dart | 45 ++ .../json_schema/json_schema_validator.dart | 4 + lib/src/shared/protocol.dart | 34 + lib/src/types/elicitation.dart | 11 +- lib/src/types/json_rpc.dart | 5 + .../client_elicitation_defaults_test.dart | 22 +- test/client/client_tool_validation_test.dart | 4 +- test/client/streamable_https_test.dart | 4 +- .../2026_rc_client_expected_failures.txt | 7 + .../conformance/2026_rc_expected_failures.txt | 5 + test/conformance/README.md | 74 ++ test/conformance/mcp_2025_server.dart | 657 +++++++++++++++ test/conformance/mcp_2026_rc_client.dart | 764 ++++++++++++++++++ test/conformance/mcp_2026_rc_server.dart | 410 ++++++++++ .../run_2025_server_conformance.dart | 265 ++++++ .../run_2026_rc_client_conformance.dart | 347 ++++++++ .../run_2026_rc_server_conformance.dart | 432 ++++++++++ test/elicitation_test.dart | 17 +- test/interop/test_dart_server.dart | 5 + test/mcp_2025_11_25_test.dart | 12 +- test/mcp_2026_07_28_test.dart | 105 ++- test/server/streamable_mcp_server_test.dart | 242 ++++++ test/shared/json_schema_from_json_test.dart | 48 ++ 31 files changed, 4037 insertions(+), 98 deletions(-) create mode 100644 test/conformance/2026_rc_client_expected_failures.txt create mode 100644 test/conformance/2026_rc_expected_failures.txt create mode 100644 test/conformance/README.md create mode 100644 test/conformance/mcp_2025_server.dart create mode 100644 test/conformance/mcp_2026_rc_client.dart create mode 100644 test/conformance/mcp_2026_rc_server.dart create mode 100644 test/conformance/run_2025_server_conformance.dart create mode 100644 test/conformance/run_2026_rc_client_conformance.dart create mode 100644 test/conformance/run_2026_rc_server_conformance.dart diff --git a/.github/workflows/test_core.yml b/.github/workflows/test_core.yml index 82b20df7..9728e764 100644 --- a/.github/workflows/test_core.yml +++ b/.github/workflows/test_core.yml @@ -43,6 +43,33 @@ jobs: working-directory: packages/mcp_dart_cli run: dart run bin/mcp_dart.dart conformance --suite all --json + - name: Run official MCP 2025 server conformance + run: > + dart run test/conformance/run_2025_server_conformance.dart + --timeout-seconds 90 + --output-dir .dart_tool/conformance/ci_2025_server + + - name: Run official MCP 2025 client conformance + run: > + npx -y @modelcontextprotocol/conformance@0.2.0-alpha.1 client + --command "dart run test/conformance/mcp_2026_rc_client.dart" + --suite all + --spec-version 2025-11-25 + --verbose + -o .dart_tool/conformance/ci_2025_client + + - name: Run official MCP 2026 RC server conformance + run: > + dart run test/conformance/run_2026_rc_server_conformance.dart + --timeout-seconds 90 + --output-dir .dart_tool/conformance/ci_2026_server + + - name: Run official MCP 2026 RC client conformance + run: > + dart run test/conformance/run_2026_rc_client_conformance.dart + --timeout-seconds 90 + --output-dir .dart_tool/conformance/ci_2026_client + - name: Run interop test suite run: dart test -t interop diff --git a/CHANGELOG.md b/CHANGELOG.md index b89b447e..906138be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -259,6 +259,9 @@ - Accepted numeric `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`, and `default` values on JSON Schema `integer` schemas, matching the stable and MCP 2026 schema definitions. +- Preserved object-level JSON Schema 2020-12 keywords on `JsonObject` + round-trips and added official MCP conformance gates for stable 2025 and + 2026 RC client/server coverage in core CI. ## 2.2.0 diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index c97329a6..eb4f79d2 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -68,6 +68,20 @@ class McpSubscription { } } +ElicitResult _withElicitationDefaults( + ElicitResult result, + JsonSchema schema, +) { + final content = _deepCopy(result.content ?? const {}) + as Map; + _applyElicitationDefaults(schema, content); + return ElicitResult( + action: result.action, + content: content, + meta: result.meta, + ); +} + // Recursively applies default values from a JSON Schema to a data object. void _applyElicitationDefaults(JsonSchema schema, Map data) { if (schema is! JsonObject) return; @@ -207,17 +221,16 @@ class McpClient extends Protocol { "No elicit handler registered", ); } - final result = await onElicitRequest!(request.elicitParams); + var result = await onElicitRequest!(request.elicitParams); // Apply defaults if client supports it and it's a form elicitation if (request.elicitParams.isFormMode && result.action == 'accept' && - result.content is Map && request.elicitParams.requestedSchema != null && _capabilities.elicitation?.form?.applyDefaults == true) { - _applyElicitationDefaults( + result = _withElicitationDefaults( + result, request.elicitParams.requestedSchema!, - result.content!, ); } return result; @@ -1618,6 +1631,7 @@ class McpClient extends Protocol { bool _isToolParameterHeaderPrimitive(JsonSchema schema) { return schema is JsonString || + schema is JsonNumber || schema is JsonInteger || schema is JsonBoolean; } diff --git a/lib/src/client/streamable_https.dart b/lib/src/client/streamable_https.dart index 044f3c97..cfc6ca13 100644 --- a/lib/src/client/streamable_https.dart +++ b/lib/src/client/streamable_https.dart @@ -150,6 +150,8 @@ class StreamableHttpClientTransport final StreamableHttpReconnectionOptions _reconnectionOptions; bool _isClosed = false; _PendingOAuthAuthorization? _pendingOAuthAuthorization; + final Map _oauthRegistrations = {}; + final Set _oauthRequestedScopes = {}; @override void Function()? onclose; @@ -251,9 +253,15 @@ class StreamableHttpClientTransport ); } - final scope = challenge?.scope ?? - (provider.scopes.isEmpty ? null : provider.scopes.join(' ')) ?? - protectedResourceMetadata.scopesSupported?.join(' '); + final clientRegistration = await _resolveOAuthClientRegistration( + provider, + authorizationServerMetadata, + ); + final scope = _authorizationScope( + challenge, + provider, + protectedResourceMetadata, + ); final codeVerifier = _generatePkceCodeVerifier(); final codeChallenge = _generatePkceS256Challenge(codeVerifier); final state = _generateOAuthState(); @@ -262,7 +270,7 @@ class StreamableHttpClientTransport queryParameters: { ...authorizationEndpoint.queryParameters, 'response_type': 'code', - 'client_id': provider.clientId, + 'client_id': clientRegistration.clientId, 'redirect_uri': provider.redirectUri.toString(), 'code_challenge': codeChallenge, 'code_challenge_method': 'S256', @@ -284,15 +292,183 @@ class StreamableHttpClientTransport _pendingOAuthAuthorization = _PendingOAuthAuthorization( tokenEndpoint: tokenEndpoint, codeVerifier: codeVerifier, - clientId: provider.clientId, - clientSecret: provider.clientSecret, + clientId: clientRegistration.clientId, + clientSecret: clientRegistration.clientSecret, + tokenEndpointAuthMethod: clientRegistration.tokenEndpointAuthMethod, redirectUri: provider.redirectUri, resource: protectedResourceMetadata.resource, + issuer: authorizationServerMetadata.issuer.toString(), + state: state, + scope: scope, + authorizationResponseIssParameterSupported: authorizationServerMetadata + .authorizationResponseIssParameterSupported, ); return authorizationRequest; } + String? _authorizationScope( + OAuthBearerChallengeParameters? challenge, + OAuthAuthorizationCodeProvider provider, + OAuthProtectedResourceMetadataDocument protectedResourceMetadata, + ) { + final requestedScopes = {..._oauthRequestedScopes}; + final challengedScopes = _splitOAuthScopes(challenge?.scope); + if (challengedScopes.isNotEmpty) { + requestedScopes.addAll(challengedScopes); + return requestedScopes.join(' '); + } + + if (provider.scopes.isNotEmpty) { + requestedScopes.addAll(provider.scopes); + return requestedScopes.join(' '); + } + + final supportedScopes = protectedResourceMetadata.scopesSupported; + if (supportedScopes != null && supportedScopes.isNotEmpty) { + requestedScopes.addAll(supportedScopes); + return requestedScopes.join(' '); + } + + return null; + } + + List _splitOAuthScopes(String? scope) { + if (scope == null || scope.trim().isEmpty) { + return const []; + } + return scope + .split(RegExp(r'\s+')) + .where((value) => value.isNotEmpty) + .toList(); + } + + Future<_OAuthClientRegistration> _resolveOAuthClientRegistration( + OAuthAuthorizationCodeProvider provider, + OAuthAuthorizationServerMetadataDocument authorizationServerMetadata, + ) async { + final issuerKey = authorizationServerMetadata.issuer.toString(); + if (authorizationServerMetadata.clientIdMetadataDocumentSupported == true && + _isAbsoluteHttpUri(provider.clientId)) { + return _OAuthClientRegistration( + clientId: provider.clientId, + clientSecret: provider.clientSecret, + tokenEndpointAuthMethod: _selectTokenEndpointAuthMethod( + authorizationServerMetadata, + provider.clientSecret, + ), + ); + } + + final registrationEndpoint = + authorizationServerMetadata.registrationEndpoint; + if (registrationEndpoint != null) { + final existingRegistration = _oauthRegistrations[issuerKey]; + if (existingRegistration != null) { + return existingRegistration; + } + + final registration = await _registerOAuthClient( + provider, + authorizationServerMetadata, + registrationEndpoint, + ); + _oauthRegistrations[issuerKey] = registration; + return registration; + } + + return _OAuthClientRegistration( + clientId: provider.clientId, + clientSecret: provider.clientSecret, + tokenEndpointAuthMethod: _selectTokenEndpointAuthMethod( + authorizationServerMetadata, + provider.clientSecret, + ), + ); + } + + bool _isAbsoluteHttpUri(String value) { + final uri = Uri.tryParse(value); + return uri != null && + (uri.scheme == 'http' || uri.scheme == 'https') && + uri.host.isNotEmpty; + } + + Future<_OAuthClientRegistration> _registerOAuthClient( + OAuthAuthorizationCodeProvider provider, + OAuthAuthorizationServerMetadataDocument authorizationServerMetadata, + Uri registrationEndpoint, + ) async { + final tokenEndpointAuthMethod = _selectTokenEndpointAuthMethod( + authorizationServerMetadata, + provider.clientSecret, + ); + final response = await _httpClient.post( + registrationEndpoint, + headers: const { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: jsonEncode({ + 'client_name': provider.clientId, + 'redirect_uris': [provider.redirectUri.toString()], + 'grant_types': ['authorization_code', 'refresh_token'], + 'response_types': ['code'], + 'application_type': 'native', + 'token_endpoint_auth_method': tokenEndpointAuthMethod, + }), + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw UnauthorizedError( + 'Dynamic client registration failed with HTTP ${response.statusCode}', + ); + } + + final json = jsonDecode(response.body); + if (json is! Map) { + throw UnauthorizedError('Client registration response must be an object'); + } + final clientId = json['client_id']; + if (clientId is! String || clientId.isEmpty) { + throw UnauthorizedError('Client registration did not include client_id'); + } + final clientSecret = json['client_secret']; + final registeredAuthMethod = json['token_endpoint_auth_method']; + return _OAuthClientRegistration( + clientId: clientId, + clientSecret: clientSecret is String ? clientSecret : null, + tokenEndpointAuthMethod: registeredAuthMethod is String + ? registeredAuthMethod + : tokenEndpointAuthMethod, + ); + } + + String _selectTokenEndpointAuthMethod( + OAuthAuthorizationServerMetadataDocument metadata, + String? clientSecret, + ) { + final supportedMethods = + metadata.tokenEndpointAuthMethodsSupported ?? const ['none']; + if (clientSecret != null) { + if (supportedMethods.contains('client_secret_basic')) { + return 'client_secret_basic'; + } + if (supportedMethods.contains('client_secret_post')) { + return 'client_secret_post'; + } + } + if (supportedMethods.contains('none')) { + return 'none'; + } + if (supportedMethods.contains('client_secret_basic')) { + return 'client_secret_basic'; + } + if (supportedMethods.contains('client_secret_post')) { + return 'client_secret_post'; + } + return supportedMethods.isEmpty ? 'none' : supportedMethods.first; + } + Future _discoverProtectedResourceMetadata( OAuthBearerChallengeParameters? challenge, @@ -360,7 +536,28 @@ class StreamableHttpClientTransport 'Protected-resource metadata must be a JSON object', ); } - return OAuthProtectedResourceMetadataDocument.fromJson(json); + final metadata = OAuthProtectedResourceMetadataDocument.fromJson(json); + if (!_isProtectedResourceForEndpoint(metadata.resource)) { + throw UnauthorizedError( + 'Protected-resource metadata resource does not match server URL', + ); + } + return metadata; + } + + bool _isProtectedResourceForEndpoint(Uri resource) { + if (resource.fragment.isNotEmpty) { + return false; + } + if (resource.scheme != _url.scheme || + resource.host != _url.host || + resource.port != _url.port) { + return false; + } + + final resourcePath = resource.path.isEmpty ? '/' : resource.path; + final endpointPath = _url.path.isEmpty ? '/' : _url.path; + return resourcePath == endpointPath || resourcePath == '/'; } Future @@ -368,7 +565,13 @@ class StreamableHttpClientTransport final errors = []; for (final uri in _authorizationServerMetadataCandidates(issuer)) { try { - return await _fetchAuthorizationServerMetadata(uri); + final metadata = await _fetchAuthorizationServerMetadata(uri); + if (metadata.issuer.toString() != issuer.toString()) { + throw UnauthorizedError( + 'Authorization-server metadata issuer does not match $issuer', + ); + } + return metadata; } catch (error) { errors.add(error); } @@ -438,22 +641,52 @@ class StreamableHttpClientTransport String authorizationCode, _PendingOAuthAuthorization pendingAuthorization, ) async { + final headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }; + final body = { + 'grant_type': 'authorization_code', + 'code': authorizationCode, + 'redirect_uri': pendingAuthorization.redirectUri.toString(), + 'client_id': pendingAuthorization.clientId, + 'code_verifier': pendingAuthorization.codeVerifier, + 'resource': pendingAuthorization.resource.toString(), + }; + switch (pendingAuthorization.tokenEndpointAuthMethod) { + case 'client_secret_basic': + final clientSecret = pendingAuthorization.clientSecret; + if (clientSecret == null) { + throw UnauthorizedError( + 'Token endpoint requires client_secret_basic but no secret is available', + ); + } + headers['Authorization'] = _basicAuthorizationHeader( + pendingAuthorization.clientId, + clientSecret, + ); + break; + case 'client_secret_post': + final clientSecret = pendingAuthorization.clientSecret; + if (clientSecret == null) { + throw UnauthorizedError( + 'Token endpoint requires client_secret_post but no secret is available', + ); + } + body['client_secret'] = clientSecret; + break; + case 'none': + break; + default: + if (pendingAuthorization.clientSecret != null) { + body['client_secret'] = pendingAuthorization.clientSecret!; + } + } + final response = await _httpClient.post( pendingAuthorization.tokenEndpoint, - headers: const { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - }, - body: { - 'grant_type': 'authorization_code', - 'code': authorizationCode, - 'redirect_uri': pendingAuthorization.redirectUri.toString(), - 'client_id': pendingAuthorization.clientId, - if (pendingAuthorization.clientSecret != null) - 'client_secret': pendingAuthorization.clientSecret!, - 'code_verifier': pendingAuthorization.codeVerifier, - 'resource': pendingAuthorization.resource.toString(), - }, + headers: headers, + body: body, ); if (response.statusCode != 200) { throw UnauthorizedError( @@ -477,10 +710,17 @@ class StreamableHttpClientTransport expiresIn: _parseExpiresIn(json['expires_in']), scope: json['scope'] as String?, ); + _oauthRequestedScopes.addAll(_splitOAuthScopes(pendingAuthorization.scope)); + _oauthRequestedScopes.addAll(_splitOAuthScopes(tokens.scope)); await provider.saveTokens(tokens); return tokens; } + String _basicAuthorizationHeader(String clientId, String clientSecret) { + final credentials = base64Encode(utf8.encode('$clientId:$clientSecret')); + return 'Basic $credentials'; + } + int? _parseExpiresIn(Object? value) { if (value == null) { return null; @@ -649,6 +889,12 @@ class StreamableHttpClientTransport } return value.toString(); } + if (value is double) { + if (!value.isFinite) { + return null; + } + return value.toString(); + } return switch (value) { String() => value, @@ -1120,7 +1366,11 @@ class StreamableHttpClientTransport /// Call this method after the user has finished authorizing via their user agent and is redirected /// back to the MCP client application. This will exchange the authorization code for an access token, /// enabling the next connection attempt to successfully auth. - Future finishAuth(String authorizationCode) async { + Future finishAuth( + String authorizationCode, { + String? state, + String? issuer, + }) async { if (_authProvider == null) { throw UnauthorizedError("No auth provider"); } @@ -1129,6 +1379,11 @@ class StreamableHttpClientTransport final pendingAuthorization = _pendingOAuthAuthorization; if (authProvider is OAuthAuthorizationCodeProvider && pendingAuthorization != null) { + _validateOAuthAuthorizationRedirect( + pendingAuthorization, + state: state, + issuer: issuer, + ); await _exchangeAuthorizationCode( authProvider, authorizationCode, @@ -1148,6 +1403,30 @@ class StreamableHttpClientTransport } } + void _validateOAuthAuthorizationRedirect( + _PendingOAuthAuthorization pendingAuthorization, { + String? state, + String? issuer, + }) { + if (state != null && state != pendingAuthorization.state) { + throw UnauthorizedError('Authorization redirect state mismatch'); + } + + if (pendingAuthorization.authorizationResponseIssParameterSupported == + true && + (issuer == null || issuer.isEmpty)) { + throw UnauthorizedError( + 'Authorization response did not include required iss parameter', + ); + } + + if (issuer != null && issuer != pendingAuthorization.issuer) { + throw UnauthorizedError( + 'Authorization response issuer does not match authorization server', + ); + } + } + @override Future close() async { _isClosed = true; @@ -1344,7 +1623,8 @@ class StreamableHttpClientTransport response, StartSseOptions( onResumptionToken: onResumptionToken, - shouldReconnect: false, // Do not reconnect for POST responses + replayMessageId: message.id, + shouldReconnect: !isStatelessRequest, rejectServerRequests: isStatelessRequest, ), ); @@ -1662,14 +1942,22 @@ class OAuthAuthorizationServerMetadataDocument { final Uri issuer; final Uri? authorizationEndpoint; final Uri? tokenEndpoint; + final Uri? registrationEndpoint; final List? codeChallengeMethodsSupported; + final List? tokenEndpointAuthMethodsSupported; + final bool? clientIdMetadataDocumentSupported; + final bool? authorizationResponseIssParameterSupported; final Map additionalFields; const OAuthAuthorizationServerMetadataDocument({ required this.issuer, this.authorizationEndpoint, this.tokenEndpoint, + this.registrationEndpoint, this.codeChallengeMethodsSupported, + this.tokenEndpointAuthMethodsSupported, + this.clientIdMetadataDocumentSupported, + this.authorizationResponseIssParameterSupported, this.additionalFields = const {}, }); @@ -1691,15 +1979,29 @@ class OAuthAuthorizationServerMetadataDocument { ? Uri.parse(authorizationEndpoint) : null, tokenEndpoint: tokenEndpoint is String ? Uri.parse(tokenEndpoint) : null, + registrationEndpoint: json['registration_endpoint'] is String + ? Uri.parse(json['registration_endpoint'] as String) + : null, codeChallengeMethodsSupported: (json['code_challenge_methods_supported'] as List?)?.cast(), + tokenEndpointAuthMethodsSupported: + (json['token_endpoint_auth_methods_supported'] as List?) + ?.cast(), + clientIdMetadataDocumentSupported: + json['client_id_metadata_document_supported'] as bool?, + authorizationResponseIssParameterSupported: + json['authorization_response_iss_parameter_supported'] as bool?, additionalFields: Map.from(json) ..removeWhere( (key, value) => { 'issuer', 'authorization_endpoint', 'token_endpoint', + 'registration_endpoint', 'code_challenge_methods_supported', + 'token_endpoint_auth_methods_supported', + 'client_id_metadata_document_supported', + 'authorization_response_iss_parameter_supported', }.contains(key), ), ); @@ -1760,16 +2062,38 @@ class _PendingOAuthAuthorization { final String codeVerifier; final String clientId; final String? clientSecret; + final String tokenEndpointAuthMethod; final Uri redirectUri; final Uri resource; + final String issuer; + final String state; + final String? scope; + final bool? authorizationResponseIssParameterSupported; const _PendingOAuthAuthorization({ required this.tokenEndpoint, required this.codeVerifier, required this.clientId, required this.clientSecret, + required this.tokenEndpointAuthMethod, required this.redirectUri, required this.resource, + required this.issuer, + required this.state, + required this.scope, + required this.authorizationResponseIssParameterSupported, + }); +} + +class _OAuthClientRegistration { + final String clientId; + final String? clientSecret; + final String tokenEndpointAuthMethod; + + const _OAuthClientRegistration({ + required this.clientId, + required this.clientSecret, + required this.tokenEndpointAuthMethod, }); } diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index ceec896f..302c6ff5 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -143,13 +143,13 @@ class CompletableField { } /// Function signature for a tool implementation. -typedef ToolFunction = FutureOr Function( +typedef ToolFunction = FutureOr Function( Map args, RequestHandlerExtra extra, ); /// Legacy callback signature for tools (deprecated style). -typedef LegacyToolCallback = FutureOr Function({ +typedef LegacyToolCallback = FutureOr Function({ Map? args, RequestHandlerExtra? extra, }); diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 543222fc..b44fa6de 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -172,7 +172,7 @@ class Server extends Protocol { json_rpc.validateRequestMeta(meta, validateKeys: true); } on FormatException catch (error) { return McpError( - ErrorCode.invalidRequest.value, + ErrorCode.invalidParams.value, 'Invalid stateless request metadata.', error.message, ); @@ -181,7 +181,7 @@ class Server extends Protocol { final requestedVersion = meta?[McpMetaKey.protocolVersion]; if (requestedVersion is! String || requestedVersion.isEmpty) { return McpError( - ErrorCode.invalidRequest.value, + ErrorCode.invalidParams.value, 'Missing required request metadata: ${McpMetaKey.protocolVersion}', ); } @@ -190,7 +190,7 @@ class Server extends Protocol { } if (!isStatelessProtocolVersion(requestedVersion)) { return McpError( - ErrorCode.invalidRequest.value, + ErrorCode.invalidParams.value, 'server/discover and stateless requests require a stateless protocol version.', ); } @@ -198,7 +198,7 @@ class Server extends Protocol { final clientInfo = meta?[McpMetaKey.clientInfo]; if (clientInfo is! Map) { return McpError( - ErrorCode.invalidRequest.value, + ErrorCode.invalidParams.value, 'Missing required request metadata: ${McpMetaKey.clientInfo}', ); } @@ -206,7 +206,7 @@ class Server extends Protocol { final clientCapabilities = meta?[McpMetaKey.clientCapabilities]; if (clientCapabilities is! Map) { return McpError( - ErrorCode.invalidRequest.value, + ErrorCode.invalidParams.value, 'Missing required request metadata: ${McpMetaKey.clientCapabilities}', ); } @@ -216,7 +216,7 @@ class Server extends Protocol { ClientCapabilities.fromJson(clientCapabilities.cast()); } catch (error) { return McpError( - ErrorCode.invalidRequest.value, + ErrorCode.invalidParams.value, 'Invalid stateless request metadata.', error.toString(), ); @@ -225,7 +225,7 @@ class Server extends Protocol { final logLevel = meta?[McpMetaKey.logLevel]; if (logLevel != null && _parseLoggingLevel(logLevel) == null) { return McpError( - ErrorCode.invalidRequest.value, + ErrorCode.invalidParams.value, 'Invalid stateless request metadata: ${McpMetaKey.logLevel}', ); } diff --git a/lib/src/server/streamable_https.dart b/lib/src/server/streamable_https.dart index 101f497a..48a2c67b 100644 --- a/lib/src/server/streamable_https.dart +++ b/lib/src/server/streamable_https.dart @@ -5,6 +5,7 @@ import 'dart:typed_data'; import 'package:mcp_dart/src/shared/mcp_header_validation.dart'; import 'package:mcp_dart/src/shared/uuid.dart'; +import 'package:mcp_dart/src/types/json_rpc.dart' as json_rpc; import '../shared/transport.dart'; import '../types.dart'; @@ -161,6 +162,14 @@ class StreamableHTTPServerTransport RequestIdAwareTransport, IncomingRequestValidationAwareTransport, ToolParameterHeaderAwareTransport { + static const Set _statelessRemovedRequestMethods = { + Method.initialize, + Method.ping, + Method.loggingSetLevel, + Method.resourcesSubscribe, + Method.resourcesUnsubscribe, + }; + // when sessionId is not set (null), it means the transport is in stateless mode final String? Function()? _sessionIdGenerator; bool _started = false; @@ -266,7 +275,11 @@ class StreamableHTTPServerTransport return; } - if (!await _validateProtocolVersionHeader(req, req.response)) { + if (!await _validateProtocolVersionHeader( + req, + req.response, + parsedBody: parsedBody, + )) { return; } @@ -285,8 +298,9 @@ class StreamableHTTPServerTransport Future _validateProtocolVersionHeader( HttpRequest req, - HttpResponse res, - ) async { + HttpResponse res, { + dynamic parsedBody, + }) async { if (!_strictProtocolVersionHeaderValidation) { return true; } @@ -306,6 +320,7 @@ class StreamableHTTPServerTransport httpStatus: HttpStatus.badRequest, errorCode: ErrorCode.unsupportedProtocolVersion, message: 'Unsupported protocol version', + id: _requestIdFromParsedBody(parsedBody), data: { 'requested': requestedVersion, 'supported': supportedProtocolVersionsWithDraft, @@ -449,6 +464,65 @@ class StreamableHTTPServerTransport return null; } + RequestId? _requestIdFromParsedBody(dynamic parsedBody) { + if (parsedBody is! Map || !parsedBody.containsKey('id')) { + return null; + } + + try { + return json_rpc.parseRequestId(parsedBody['id']); + } catch (_) { + return null; + } + } + + RequestId? _rawRequestId(Map messageJson) { + if (!messageJson.containsKey('id')) { + return null; + } + try { + return json_rpc.parseRequestId(messageJson['id']); + } catch (_) { + return null; + } + } + + bool _isStatelessServerDiscoverJson( + HttpRequest req, + Map messageJson, + ) { + if (messageJson['method'] != Method.serverDiscover) { + return false; + } + + return _isStatelessRequestJson(req, messageJson); + } + + bool _isStatelessRemovedRequestJson( + HttpRequest req, + Map messageJson, + ) { + final method = messageJson['method']; + return method is String && + _statelessRemovedRequestMethods.contains(method) && + messageJson.containsKey('id') && + _isStatelessRequestJson(req, messageJson); + } + + bool _isStatelessRequestJson( + HttpRequest req, + Map messageJson, + ) { + final headerVersion = req.headers.value('mcp-protocol-version')?.trim(); + if (headerVersion != null && isStatelessProtocolVersion(headerVersion)) { + return true; + } + + final metadataVersion = _nestedMetadataProtocolVersion(messageJson); + return metadataVersion != null && + isStatelessProtocolVersion(metadataVersion); + } + bool _usesStatelessHttpValidation( HttpRequest req, List messages, @@ -799,14 +873,15 @@ class StreamableHTTPServerTransport final metadataVersion = _nestedMetadataProtocolVersion(messageJson); if (metadataVersion == null) { - await _writeHeaderMismatchResponse( - req.response, - message, - 'MCP-Protocol-Version header has no matching request _meta protocol version in params._meta', - ); - return false; - } - if (protocolHeader != metadataVersion) { + if (message is! JsonRpcServerDiscoverRequest) { + await _writeHeaderMismatchResponse( + req.response, + message, + 'MCP-Protocol-Version header has no matching request _meta protocol version in params._meta', + ); + return false; + } + } else if (protocolHeader != metadataVersion) { await _writeHeaderMismatchResponse( req.response, message, @@ -1369,9 +1444,37 @@ class StreamableHTTPServerTransport final messageJson = rawItem is Map ? rawItem : rawItem.cast(); + if (_isStatelessRemovedRequestJson(req, messageJson)) { + final method = messageJson['method'] as String; + await _writeJsonRpcErrorResponse( + req.response, + httpStatus: HttpStatus.notFound, + errorCode: ErrorCode.methodNotFound, + id: _rawRequestId(messageJson), + message: + '$method is not part of MCP stateless protocol versions.', + ); + return; + } messageJsons.add(messageJson); messages.add(JsonRpcMessage.fromJson(messageJson)); } catch (e) { + final messageJson = rawItem is Map + ? rawItem + : rawItem.cast(); + if (_isStatelessServerDiscoverJson(req, messageJson)) { + await _writeJsonRpcErrorResponse( + req.response, + httpStatus: HttpStatus.badRequest, + errorCode: ErrorCode.invalidParams, + id: _rawRequestId(messageJson), + message: 'Invalid params', + data: e.toString(), + ); + onerror?.call(e is Error ? e : StateError(e.toString())); + return; + } + await _writeJsonRpcErrorResponse( req.response, httpStatus: HttpStatus.badRequest, @@ -1827,7 +1930,7 @@ class StreamableHTTPServerTransport } } - if (_isJsonRpcResponse(message)) { + if (_isJsonRpcResponse(message) || _isJsonRpcError(message)) { if (!_requestToStreamMapping.containsKey(requestId)) { return; } @@ -1866,6 +1969,14 @@ class StreamableHTTPServerTransport final responses = relatedIds.map((id) => _requestResponseMap[id]!).toList(); + if (isStatelessResponse && + responses.length == 1 && + responses.single is JsonRpcError) { + final error = responses.single as JsonRpcError; + response!.statusCode = + _statelessHttpStatusForErrorCode(error.error.code); + } + headers.forEach((key, value) { response!.headers.set(key, value); }); diff --git a/lib/src/server/streamable_mcp_server.dart b/lib/src/server/streamable_mcp_server.dart index d0d711b1..fee32dd4 100644 --- a/lib/src/server/streamable_mcp_server.dart +++ b/lib/src/server/streamable_mcp_server.dart @@ -305,6 +305,10 @@ class StreamableMcpServer { /// If true, reject JSON-RPC batch payloads for Streamable HTTP POST requests. final bool rejectBatchJsonRpcPayloads; + /// If true, return JSON responses instead of SSE streams for request/response + /// interactions. + final bool enableJsonResponse; + final Set _defaultDnsRebindingAllowedHosts; HttpServer? _httpServer; @@ -326,6 +330,7 @@ class StreamableMcpServer { this.allowedOrigins, this.strictProtocolVersionHeaderValidation = true, this.rejectBatchJsonRpcPayloads = true, + this.enableJsonResponse = false, }) : _serverFactory = serverFactory, _defaultDnsRebindingAllowedHosts = { normalizeDnsHost(host), @@ -439,7 +444,7 @@ class StreamableMcpServer { try { if (request.method == 'POST') { await _handlePostRequest(request); - } else if (_isStatelessProtocolVersionRequest(request)) { + } else if (_requiresStatelessTransport(request)) { await _createStatelessTransport().handleRequest(request); } else if (request.method == 'GET') { await _handleGetRequest(request); @@ -486,7 +491,7 @@ class StreamableMcpServer { } catch (e) { if (sessionId != null && !_transports.containsKey(sessionId) && - !_isStatelessProtocolVersionRequest(request)) { + !_requiresStatelessTransport(request)) { await _respondWithJsonRpcError( request.response, httpStatus: HttpStatus.notFound, @@ -571,7 +576,7 @@ class StreamableMcpServer { } Future _handleGetRequest(HttpRequest request) async { - if (_isStatelessProtocolVersionRequest(request)) { + if (_requiresStatelessTransport(request)) { await _createStatelessTransport().handleRequest(request); return; } @@ -597,7 +602,7 @@ class StreamableMcpServer { } Future _handleDeleteRequest(HttpRequest request) async { - if (_isStatelessProtocolVersionRequest(request)) { + if (_requiresStatelessTransport(request)) { await _createStatelessTransport().handleRequest(request); return; } @@ -632,6 +637,7 @@ class StreamableMcpServer { enableDnsRebindingProtection: enableDnsRebindingProtection, allowedHosts: allowedHosts ?? {host}, allowedOrigins: allowedOrigins, + enableJsonResponse: enableJsonResponse, strictProtocolVersionHeaderValidation: strictProtocolVersionHeaderValidation, rejectBatchJsonRpcPayloads: rejectBatchJsonRpcPayloads, @@ -682,6 +688,7 @@ class StreamableMcpServer { enableDnsRebindingProtection: enableDnsRebindingProtection, allowedHosts: allowedHosts ?? {host}, allowedOrigins: allowedOrigins, + enableJsonResponse: enableJsonResponse, strictProtocolVersionHeaderValidation: strictProtocolVersionHeaderValidation, rejectBatchJsonRpcPayloads: rejectBatchJsonRpcPayloads, @@ -689,24 +696,36 @@ class StreamableMcpServer { ); } - bool _isStatelessProtocolVersionRequest(HttpRequest request) { + bool _requiresStatelessTransport(HttpRequest request) { final versionHeader = request.headers.value('mcp-protocol-version'); - return versionHeader != null && - isStatelessProtocolVersion(versionHeader.trim()); + if (versionHeader == null || versionHeader.trim().isEmpty) { + return false; + } + + final version = versionHeader.trim(); + return isStatelessProtocolVersion(version) || + strictProtocolVersionHeaderValidation && + !supportedProtocolVersionsWithDraft.contains(version); } bool _isStatelessRequest(HttpRequest request, dynamic body) { - if (_isStatelessProtocolVersionRequest(request)) { + if (_requiresStatelessTransport(request)) { return true; } if (body is Map) { final version = _bodyProtocolVersion(body); - return version != null && isStatelessProtocolVersion(version); + return version != null && + (isStatelessProtocolVersion(version) || + strictProtocolVersionHeaderValidation && + !supportedProtocolVersionsWithDraft.contains(version)); } if (body is List) { return body.whereType>().any((item) { final version = _bodyProtocolVersion(item); - return version != null && isStatelessProtocolVersion(version); + return version != null && + (isStatelessProtocolVersion(version) || + strictProtocolVersionHeaderValidation && + !supportedProtocolVersionsWithDraft.contains(version)); }); } return false; diff --git a/lib/src/shared/json_schema/json_schema.dart b/lib/src/shared/json_schema/json_schema.dart index 731b7825..29515e59 100644 --- a/lib/src/shared/json_schema/json_schema.dart +++ b/lib/src/shared/json_schema/json_schema.dart @@ -66,6 +66,10 @@ sealed class JsonSchema { return conjunctiveSchema; } + if (type == 'object') { + return JsonObject.fromJson(json); + } + if (json.containsKey('const')) { return JsonConst.fromJson(json); } @@ -117,6 +121,12 @@ sealed class JsonSchema { return null; } + if (json['type'] == 'object' && + primaryKeys.length == 1 && + _jsonSchemaCompositionKeys.contains(primaryKeys.single)) { + return null; + } + final siblingKeys = json.keys .where( (key) => @@ -206,6 +216,13 @@ sealed class JsonSchema { 'object', }; + static const Set _jsonSchemaCompositionKeys = { + 'allOf', + 'anyOf', + 'oneOf', + 'not', + }; + static bool _hasMcpHeaderOnNonPrimitiveSchema(Map json) { if (!json.containsKey('x-mcp-header')) { return false; @@ -923,11 +940,18 @@ class JsonObject extends JsonSchema { final Object? additionalProperties; final Map>? dependentRequired; + /// Object-level JSON Schema keywords not modeled by the typed convenience API. + /// + /// This preserves wire-level schema keywords such as `$schema`, `$defs`, + /// `allOf`, `if`, `then`, and `else` during parse/serialize round-trips. + final Map? extra; + const JsonObject({ this.properties, this.required, this.additionalProperties, this.dependentRequired, + this.extra, this.defaultValue, super.title, super.description, @@ -938,6 +962,7 @@ class JsonObject extends JsonSchema { this.required, this.additionalProperties, this.dependentRequired, + this.extra, this.defaultValue, super.title, super.description, @@ -967,6 +992,7 @@ class JsonObject extends JsonSchema { additionalProperties: parsedAdditionalProps, dependentRequired: (json['dependentRequired'] as Map?) ?.map((key, value) => MapEntry(key, (value as List).cast())), + extra: _jsonObjectExtra(json), title: json['title'] as String?, description: json['description'] as String?, defaultValue: json['default'] as Map?, @@ -989,10 +1015,28 @@ class JsonObject extends JsonSchema { ? (additionalProperties as JsonSchema).toJson() : additionalProperties, if (dependentRequired != null) 'dependentRequired': dependentRequired, + ...?extra, }; } } +Map? _jsonObjectExtra(Map json) { + final extra = Map.from(json) + ..removeWhere(_isKnownJsonObjectKey); + return extra.isEmpty ? null : Map.unmodifiable(extra); +} + +bool _isKnownJsonObjectKey(String key, dynamic value) { + return key == 'title' || + key == 'description' || + key == 'default' || + key == 'type' || + key == 'properties' || + key == 'required' || + key == 'additionalProperties' || + key == 'dependentRequired'; +} + /// A schema that accepts any value, potentially with additional constraints not captured by other types. class JsonAny extends JsonSchema { final Map properties; @@ -1218,6 +1262,7 @@ class JsonUnion extends JsonSchema { required: null, additionalProperties: null, dependentRequired: null, + extra: null, ) => 'object', _ => null, diff --git a/lib/src/shared/json_schema/json_schema_validator.dart b/lib/src/shared/json_schema/json_schema_validator.dart index 8264cc04..b3bbf745 100644 --- a/lib/src/shared/json_schema/json_schema_validator.dart +++ b/lib/src/shared/json_schema/json_schema_validator.dart @@ -317,6 +317,10 @@ extension JsonSchemaValidation on JsonSchema { _validate(apSchema, data[key], [...path, key]); } } + + if (schema.extra != null) { + _validateCompositionKeywords(schema.extra!, data, path); + } } void _validateEnum(JsonEnum schema, dynamic data, List path) { diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index d4717c86..0f8eb67d 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -128,6 +128,12 @@ class RequestHandlerExtra { /// Metadata from the original request. final Map? meta; + /// Client responses to MRTR input requests when retrying this request. + final InputResponses? inputResponses; + + /// Opaque MRTR state returned by the server and echoed by the client on retry. + final String? requestState; + /// MCP protocol version from the request metadata, when present. String? get protocolVersion { final value = meta?[McpMetaKey.protocolVersion]; @@ -194,6 +200,8 @@ class RequestHandlerExtra { this.sessionId, required this.requestId, this.meta, + this.inputResponses, + this.requestState, this.authInfo, this.requestInfo, this.taskId, @@ -533,6 +541,28 @@ abstract class Protocol { ) => isRecognizedResultType(resultType); + InputResponses? _inputResponsesFromRequest(JsonRpcRequest request) { + return switch (request) { + final JsonRpcCallToolRequest request => request.callParams.inputResponses, + final JsonRpcGetPromptRequest request => request.getParams.inputResponses, + final JsonRpcReadResourceRequest request => + request.readParams.inputResponses, + final JsonRpcUpdateTaskRequest request => + request.updateParams.inputResponses, + _ => null, + }; + } + + String? _requestStateFromRequest(JsonRpcRequest request) { + return switch (request) { + final JsonRpcCallToolRequest request => request.callParams.requestState, + final JsonRpcGetPromptRequest request => request.getParams.requestState, + final JsonRpcReadResourceRequest request => + request.readParams.requestState, + _ => null, + }; + } + bool _usesStatelessResultTypes(JsonRpcRequest request) { final requestProtocolVersion = request.meta?[McpMetaKey.protocolVersion]; if (requestProtocolVersion is String && @@ -1260,6 +1290,8 @@ abstract class Protocol { sessionId: _transport?.sessionId, requestId: request.id, meta: request.meta, + inputResponses: _inputResponsesFromRequest(request), + requestState: _requestStateFromRequest(request), sendNotification: (notification, {relatedTask}) { return _notificationWithRequestId( notification, @@ -1486,6 +1518,8 @@ abstract class Protocol { sessionId: requestSessionId, requestId: request.id, meta: request.meta, + inputResponses: _inputResponsesFromRequest(request), + requestState: _requestStateFromRequest(request), taskId: relatedTaskId, taskStore: _taskStore != null ? _RequestTaskStoreImpl( diff --git a/lib/src/types/elicitation.dart b/lib/src/types/elicitation.dart index d59e14ee..601e794e 100644 --- a/lib/src/types/elicitation.dart +++ b/lib/src/types/elicitation.dart @@ -818,10 +818,9 @@ Map? _normalizeElicitResultContent( normalized[entry.key] = value; continue; } - if (value is double && - value.isFinite && - value == value.truncateToDouble()) { - normalized[entry.key] = value.toInt(); + if (value is double && value.isFinite) { + normalized[entry.key] = + value == value.truncateToDouble() ? value.toInt() : value; continue; } if (value is List && value.every((item) => item is String)) { @@ -830,13 +829,13 @@ Map? _normalizeElicitResultContent( } if (formatException) { throw FormatException( - 'ElicitResult.content.${entry.key} must be string, integer, boolean, or string[]', + 'ElicitResult.content.${entry.key} must be string, number, boolean, or string[]', ); } throw ArgumentError.value( value, 'content.${entry.key}', - 'ElicitResult content values must be string, integer, boolean, or string[]', + 'ElicitResult content values must be string, number, boolean, or string[]', ); } return normalized; diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index fe4c3b11..50dc659d 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -15,6 +15,9 @@ import 'validation.dart'; /// The draft/RC MCP protocol version being prepared for the next major release. const draftProtocolVersion2026_07_28 = "2026-07-28"; +/// Upstream conformance-suite alias for the in-progress 2026 draft. +const draftProtocolVersion2026V1 = "DRAFT-2026-v1"; + /// The latest stable version of the Model Context Protocol supported. const stableProtocolVersion2025_11_25 = "2025-11-25"; @@ -36,12 +39,14 @@ const supportedProtocolVersions = [ /// Protocol versions supported by the 2026 RC development branch. const supportedProtocolVersionsWithDraft = [ latestDraftProtocolVersion, + draftProtocolVersion2026V1, ...supportedProtocolVersions, ]; /// Protocol versions that use per-request metadata instead of initialization. const statelessProtocolVersions = [ draftProtocolVersion2026_07_28, + draftProtocolVersion2026V1, ]; /// Returns true when [version] uses the 2026 stateless request model. diff --git a/test/client/client_elicitation_defaults_test.dart b/test/client/client_elicitation_defaults_test.dart index 29b0cf0e..5a7a30ae 100644 --- a/test/client/client_elicitation_defaults_test.dart +++ b/test/client/client_elicitation_defaults_test.dart @@ -56,6 +56,11 @@ class MockTransport extends Transport { Future start() async {} } +Map _lastElicitContent(MockTransport transport) { + final response = transport.sentMessages.whereType().last; + return response.result['content'] as Map; +} + void main() { group('Client - Elicitation Defaults', () { late Client client; @@ -111,11 +116,15 @@ void main() { const Duration(milliseconds: 10), ); // Allow microtasks to run - // Verify that defaults were applied to the `receivedContent` + // Verify that defaults were applied to the submitted response without + // mutating the callback-owned map. expect(receivedContent, isNotNull); - expect(receivedContent!['name'], equals('John Doe')); - expect(receivedContent!['age'], equals(30)); - expect(receivedContent!['addressStreet'], equals('Main St')); + expect(receivedContent, isEmpty); + + final submittedContent = _lastElicitContent(transport); + expect(submittedContent['name'], equals('John Doe')); + expect(submittedContent['age'], equals(30)); + expect(submittedContent['addressStreet'], equals('Main St')); }); test('does not override existing values with defaults', () async { @@ -151,7 +160,10 @@ void main() { receivedContent!['name'], equals('Jane Smith'), ); // Should retain existing - expect(receivedContent!['age'], equals(30)); // Default should be applied + + final submittedContent = _lastElicitContent(transport); + expect(submittedContent['name'], equals('Jane Smith')); + expect(submittedContent['age'], equals(30)); }); test('does not apply defaults if applyDefaults is false', () async { diff --git a/test/client/client_tool_validation_test.dart b/test/client/client_tool_validation_test.dart index e377c424..401a11b5 100644 --- a/test/client/client_tool_validation_test.dart +++ b/test/client/client_tool_validation_test.dart @@ -262,6 +262,7 @@ void main() { expect(result.tools.map((tool) => tool.name), [ 'valid_headers', + 'number_header', ]); expect(transport.toolParameterHeaderMappings, { 'valid_headers': { @@ -271,10 +272,11 @@ void main() { 'count': 'Count', '/auth/tenant': 'Tenant', }, + 'number_header': {'ratio': 'Ratio'}, }); expect( warnings.where((message) => message.contains('Rejecting tool')), - hasLength(6), + hasLength(5), ); }); diff --git a/test/client/streamable_https_test.dart b/test/client/streamable_https_test.dart index 133e7399..1a1ce4b4 100644 --- a/test/client/streamable_https_test.dart +++ b/test/client/streamable_https_test.dart @@ -1495,9 +1495,9 @@ void main() { '=?base64?${base64Encode(utf8.encode('Hello, ไธ–็•Œ'))}?=', ); expect(capturedHeaders['limit'], '42'); - expect(capturedHeaders['rounded'], isNull); + expect(capturedHeaders['rounded'], '42.0'); expect(capturedHeaders['unsafe'], isNull); - expect(capturedHeaders['ratio'], isNull); + expect(capturedHeaders['ratio'], '1.5'); expect(capturedHeaders['dryRun'], 'false'); expect(capturedHeaders['text'], '=?base64?IHBhZGRlZCA=?='); expect(capturedHeaders['payload'], isNull); diff --git a/test/conformance/2026_rc_client_expected_failures.txt b/test/conformance/2026_rc_client_expected_failures.txt new file mode 100644 index 00000000..8db6db09 --- /dev/null +++ b/test/conformance/2026_rc_client_expected_failures.txt @@ -0,0 +1,7 @@ +# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.1 +# against the 2026 RC/DRAFT client suite. +# +# Keep this list scenario-based so the baseline is easy to review. When a +# scenario turns green, remove it from this file in the same PR as the fix. +# +# No expected client failures are currently tracked. diff --git a/test/conformance/2026_rc_expected_failures.txt b/test/conformance/2026_rc_expected_failures.txt new file mode 100644 index 00000000..4f802aca --- /dev/null +++ b/test/conformance/2026_rc_expected_failures.txt @@ -0,0 +1,5 @@ +# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.1 +# against the 2026 RC/DRAFT server suite. +# +# Keep this list scenario-based so the baseline is easy to review. When a +# scenario turns green, remove it from this file in the same PR as the fix. diff --git a/test/conformance/README.md b/test/conformance/README.md new file mode 100644 index 00000000..66ceb498 --- /dev/null +++ b/test/conformance/README.md @@ -0,0 +1,74 @@ +# MCP Conformance + +This directory contains conformance harnesses for stable MCP 2025-11-25 and the +unreleased MCP 2026 RC suite. These fixtures are intentionally separate from the +cross-SDK interop tests because the official conformance package calls +hard-coded diagnostic tools, prompts, and resources. + +## CI Coverage + +Core CI runs the official stable 2025 and 2026 RC client/server conformance +suites from `.github/workflows/test_core.yml`. The server suites use dedicated +fixtures because the official conformance package calls hard-coded diagnostic +tools, prompts, and resources. + +The 2026 suite still targets an RC/alpha spec package. If the official suite +changes before the spec is final, record intentional temporary gaps in +`2026_rc_expected_failures.txt` or `2026_rc_client_expected_failures.txt` so CI +distinguishes known RC churn from regressions. + +## Stable MCP 2025-11-25 + +Run the stable server suite from the repository root: + +```bash +dart run test/conformance/run_2025_server_conformance.dart +``` + +The runner starts `mcp_2025_server.dart`, runs +`@modelcontextprotocol/conformance@0.2.0-alpha.1 server --suite all +--spec-version 2025-11-25`, and writes artifacts under +`.dart_tool/conformance/2025_server/`. + +Run the stable client suite from the repository root: + +```bash +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.1 client \ + --command "dart run test/conformance/mcp_2026_rc_client.dart" \ + --suite all \ + --spec-version 2025-11-25 \ + --verbose \ + -o .dart_tool/conformance/2025_client +``` + +The stable client suite reuses the dual-stack conformance client fixture because +the fixture negotiates whichever protocol version the conformance scenario +server offers. + +## MCP 2026 RC + +Run the current server baseline from the repository root: + +```bash +dart run test/conformance/run_2026_rc_server_conformance.dart +``` + +The runner starts a local `StreamableMcpServer` with JSON stateless responses +enabled, runs the draft server scenarios from +`@modelcontextprotocol/conformance@0.2.0-alpha.1` one by one, and writes per-run +artifacts under `.dart_tool/conformance/2026_rc/`. + +Expected failures live in `2026_rc_expected_failures.txt`. When a scenario is +fixed, remove it from that file so the baseline remains useful. + +Run the current client baseline from the repository root: + +```bash +dart run test/conformance/run_2026_rc_client_conformance.dart +``` + +The client runner invokes `mcp_2026_rc_client.dart` against the conformance +package's scenario servers and writes per-run artifacts under +`.dart_tool/conformance/2026_rc_client/`. + +Client expected failures live in `2026_rc_client_expected_failures.txt`. diff --git a/test/conformance/mcp_2025_server.dart b/test/conformance/mcp_2025_server.dart new file mode 100644 index 00000000..21328934 --- /dev/null +++ b/test/conformance/mcp_2025_server.dart @@ -0,0 +1,657 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:mcp_dart/mcp_dart.dart'; + +const _png1x1 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='; +const _wavSilence = + 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAESsAACJWAAACABAAZGF0YQAAAAA='; + +/// Dedicated HTTP server fixture for the stable MCP 2025-11-25 conformance +/// suite. +/// +/// The conformance package calls hard-coded diagnostic tools, prompts, and +/// resources. Keep those names isolated here so the cross-SDK interop fixture +/// remains representative of a normal application server. +Future main(List args) async { + var host = 'localhost'; + var port = 0; + + for (var i = 0; i < args.length; i++) { + switch (args[i]) { + case '--host': + if (i + 1 < args.length) { + host = args[++i]; + } + case '--port': + if (i + 1 < args.length) { + final parsed = int.tryParse(args[++i]); + if (parsed != null) { + port = parsed; + } + } + case '--help': + _printUsage(); + return; + } + } + + final server = StreamableMcpServer( + serverFactory: (_) => _createConformanceServer(), + host: host, + port: port, + ); + + await server.start(); + stdout.writeln( + 'MCP 2025 conformance server listening on ' + 'http://$host:${server.boundPort}${server.path}', + ); + + await Future.any([ + ProcessSignal.sigint.watch().first, + ProcessSignal.sigterm.watch().first, + ]); + await server.stop(); +} + +McpServer _createConformanceServer() { + final server = McpServer( + const Implementation( + name: 'dart-2025-conformance-server', + version: '1.0.0', + ), + options: const McpServerOptions( + capabilities: ServerCapabilities( + logging: {}, + resources: ServerCapabilitiesResources( + subscribe: true, + listChanged: true, + ), + prompts: ServerCapabilitiesPrompts(listChanged: true), + tools: ServerCapabilitiesTools(listChanged: true), + completions: ServerCapabilitiesCompletions(), + ), + ), + ); + + _registerTools(server); + _registerResources(server); + _registerPrompts(server); + _registerResourceSubscriptions(server); + + return server; +} + +void _registerTools(McpServer server) { + server.registerTool( + 'test_simple_text', + description: 'Returns a simple text content block', + callback: (args, extra) async => _textResult( + 'This is a simple text response for testing.', + ), + ); + + server.registerTool( + 'test_image_content', + description: 'Returns image content', + callback: (args, extra) async => const CallToolResult( + content: [ImageContent(data: _png1x1, mimeType: 'image/png')], + ), + ); + + server.registerTool( + 'test_audio_content', + description: 'Returns audio content', + callback: (args, extra) async => const CallToolResult( + content: [AudioContent(data: _wavSilence, mimeType: 'audio/wav')], + ), + ); + + server.registerTool( + 'test_embedded_resource', + description: 'Returns an embedded resource content block', + callback: (args, extra) async => const CallToolResult( + content: [ + EmbeddedResource( + resource: TextResourceContents( + uri: 'test://embedded-resource', + mimeType: 'text/plain', + text: 'This is an embedded resource content.', + ), + ), + ], + ), + ); + + server.registerTool( + 'test_multiple_content_types', + description: 'Returns text, image, and embedded resource content', + callback: (args, extra) async => CallToolResult( + content: [ + const TextContent(text: 'Multiple content types test:'), + const ImageContent(data: _png1x1, mimeType: 'image/png'), + EmbeddedResource( + resource: TextResourceContents( + uri: 'test://mixed-content-resource', + mimeType: 'application/json', + text: jsonEncode({'test': 'data', 'value': 123}), + ), + ), + ], + ), + ); + + server.registerTool( + 'test_tool_with_logging', + description: 'Sends log messages during tool execution', + callback: (args, extra) async { + await _sendLog(server, extra, 'Tool execution started'); + await Future.delayed(const Duration(milliseconds: 50)); + await _sendLog(server, extra, 'Tool processing data'); + await Future.delayed(const Duration(milliseconds: 50)); + await _sendLog(server, extra, 'Tool execution completed'); + return _textResult('Tool execution completed'); + }, + ); + + server.registerTool( + 'test_error_handling', + description: 'Returns a tool error result', + callback: (args, extra) async => const CallToolResult( + isError: true, + content: [ + TextContent( + text: 'This tool intentionally returns an error for testing', + ), + ], + ), + ); + + server.registerTool( + 'test_tool_with_progress', + description: 'Sends progress notifications during tool execution', + callback: (args, extra) async { + for (final progress in const [0.0, 50.0, 100.0]) { + await extra.sendProgress(progress, total: 100.0); + if (progress != 100) { + await Future.delayed(const Duration(milliseconds: 50)); + } + } + return _textResult('Progress completed'); + }, + ); + + server.registerTool( + 'test_sampling', + description: 'Requests sampling from the client', + inputSchema: JsonSchema.object( + properties: { + 'prompt': JsonSchema.string(description: 'Prompt to send to the LLM'), + }, + required: ['prompt'], + ), + callback: (args, extra) async { + final prompt = args['prompt'] as String? ?? 'Test prompt for sampling'; + final result = await server.server.createMessage( + CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: prompt), + ), + ], + maxTokens: 100, + ), + ); + final text = result.contentBlocks + .whereType() + .map((content) => content.text) + .join('\n'); + return _textResult('LLM response: ${text.isEmpty ? result.model : text}'); + }, + ); + + server.registerTool( + 'test_elicitation', + description: 'Requests structured input from the client', + inputSchema: JsonSchema.object( + properties: { + 'message': + JsonSchema.string(description: 'Message to show to the user'), + }, + required: ['message'], + ), + callback: (args, extra) async { + final message = + args['message'] as String? ?? 'Please provide your information'; + final result = await server.server.elicitInput( + ElicitRequest.form( + message: message, + requestedSchema: JsonSchema.fromJson({ + 'type': 'object', + 'properties': { + 'username': { + 'type': 'string', + 'description': "User's response", + }, + 'email': { + 'type': 'string', + 'description': "User's email address", + }, + }, + 'required': ['username', 'email'], + }), + ), + ); + return _textResult('User response: ${jsonEncode(result.toJson())}'); + }, + ); + + server.registerTool( + 'json_schema_2020_12_tool', + description: 'Tool with JSON Schema 2020-12 features', + inputSchema: JsonObject.fromJson(_jsonSchema2020_12), + callback: (args, extra) async => _textResult('schema-ok'), + ); + + server.registerTool( + 'test_elicitation_sep1034_defaults', + description: 'Requests elicitation with primitive default values', + callback: (args, extra) async { + final result = await server.server.elicitInput( + ElicitRequest.form( + message: 'Please confirm default values', + requestedSchema: JsonSchema.fromJson(_elicitationDefaultsSchema), + ), + ); + return _textResult( + 'Elicitation completed: ${jsonEncode(result.toJson())}', + ); + }, + ); + + server.registerTool( + 'test_elicitation_sep1330_enums', + description: 'Requests elicitation with enum schemas', + callback: (args, extra) async { + final result = await server.server.elicitInput( + ElicitRequest.form( + message: 'Please choose enum values', + requestedSchema: JsonSchema.fromJson(_elicitationEnumSchema), + ), + ); + return _textResult( + 'Elicitation completed: ${jsonEncode(result.toJson())}', + ); + }, + ); +} + +void _registerResources(McpServer server) { + server.registerResource( + 'Static Text', + 'test://static-text', + (description: 'Static text resource', mimeType: 'text/plain'), + (uri, extra) async => ReadResourceResult( + contents: [ + TextResourceContents( + uri: uri.toString(), + mimeType: 'text/plain', + text: 'This is a static text resource for conformance testing.', + ), + ], + ), + ); + + server.registerResource( + 'Static Binary', + 'test://static-binary', + (description: 'Static binary resource', mimeType: 'image/png'), + (uri, extra) async => ReadResourceResult( + contents: [ + BlobResourceContents( + uri: uri.toString(), + mimeType: 'image/png', + blob: _png1x1, + ), + ], + ), + ); + + server.registerResource( + 'Watched Resource', + 'test://watched-resource', + (description: 'Subscribable resource', mimeType: 'text/plain'), + (uri, extra) async => ReadResourceResult( + contents: [ + TextResourceContents( + uri: uri.toString(), + mimeType: 'text/plain', + text: 'Watched resource content', + ), + ], + ), + ); + + server.registerResourceTemplate( + 'Template Data', + ResourceTemplateRegistration( + 'test://template/{id}/data', + listCallback: null, + ), + (description: 'Template resource', mimeType: 'application/json'), + (uri, variables, extra) async { + final id = variables['id'] ?? ''; + return ReadResourceResult( + contents: [ + TextResourceContents( + uri: uri.toString(), + mimeType: 'application/json', + text: jsonEncode({ + 'id': id, + 'templateTest': true, + 'data': 'Data for ID: $id', + }), + ), + ], + ); + }, + ); +} + +void _registerPrompts(McpServer server) { + server.registerPrompt( + 'test_simple_prompt', + description: 'Simple conformance prompt', + callback: (args, extra) async => const GetPromptResult( + messages: [ + PromptMessage( + role: PromptMessageRole.user, + content: TextContent(text: 'This is a simple prompt for testing.'), + ), + ], + ), + ); + + server.registerPrompt( + 'test_prompt_with_arguments', + description: 'Conformance prompt with arguments', + argsSchema: { + 'arg1': const PromptArgumentDefinition( + description: 'First test argument', + required: true, + completable: CompletableField( + def: CompletableDef( + complete: _completeTestArg, + ), + ), + ), + 'arg2': const PromptArgumentDefinition( + description: 'Second test argument', + required: true, + ), + }, + callback: (args, extra) async { + final arg1 = args?['arg1'] ?? ''; + final arg2 = args?['arg2'] ?? ''; + return GetPromptResult( + messages: [ + PromptMessage( + role: PromptMessageRole.user, + content: TextContent( + text: "Prompt with arguments: arg1='$arg1', arg2='$arg2'", + ), + ), + ], + ); + }, + ); + + server.registerPrompt( + 'test_prompt_with_embedded_resource', + description: 'Conformance prompt with embedded resource', + argsSchema: { + 'resourceUri': const PromptArgumentDefinition( + description: 'URI of the resource to embed', + required: true, + ), + }, + callback: (args, extra) async { + final resourceUri = args?['resourceUri'] ?? 'test://example-resource'; + return GetPromptResult( + messages: [ + PromptMessage( + role: PromptMessageRole.user, + content: EmbeddedResource( + resource: TextResourceContents( + uri: resourceUri, + mimeType: 'text/plain', + text: 'Embedded resource content for testing.', + ), + ), + ), + const PromptMessage( + role: PromptMessageRole.user, + content: TextContent( + text: 'Please process the embedded resource above.', + ), + ), + ], + ); + }, + ); + + server.registerPrompt( + 'test_prompt_with_image', + description: 'Conformance prompt with image content', + callback: (args, extra) async => const GetPromptResult( + messages: [ + PromptMessage( + role: PromptMessageRole.user, + content: ImageContent(data: _png1x1, mimeType: 'image/png'), + ), + PromptMessage( + role: PromptMessageRole.user, + content: TextContent(text: 'Please analyze the image above.'), + ), + ], + ), + ); +} + +void _registerResourceSubscriptions(McpServer server) { + final subscribedUris = {}; + + server.server.setRequestHandler( + Method.resourcesSubscribe, + (request, extra) async { + subscribedUris.add(request.subParams.uri); + return const EmptyResult(); + }, + (id, params, meta) => JsonRpcSubscribeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.resourcesSubscribe, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); + + server.server.setRequestHandler( + Method.resourcesUnsubscribe, + (request, extra) async { + subscribedUris.remove(request.unsubParams.uri); + return const EmptyResult(); + }, + (id, params, meta) => JsonRpcUnsubscribeRequest.fromJson({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': Method.resourcesUnsubscribe, + 'params': params, + if (meta != null) '_meta': meta, + }), + ); +} + +Future _sendLog( + McpServer server, + RequestHandlerExtra extra, + String message, +) { + return server.sendLoggingMessage( + LoggingMessageNotification( + level: LoggingLevel.info, + logger: 'conformance', + data: message, + ), + sessionId: extra.sessionId, + requestMeta: extra.meta, + ); +} + +List _completeTestArg(String value) { + return ['testValue1', 'testOption'] + .where((candidate) => candidate.startsWith(value)) + .toList(); +} + +CallToolResult _textResult(String text) { + return CallToolResult(content: [TextContent(text: text)]); +} + +const _jsonSchema2020_12 = { + r'$schema': 'https://json-schema.org/draft/2020-12/schema', + 'type': 'object', + r'$defs': { + 'address': { + r'$anchor': 'addressDef', + 'type': 'object', + 'properties': { + 'street': {'type': 'string'}, + 'city': {'type': 'string'}, + }, + }, + }, + 'properties': { + 'name': {'type': 'string'}, + 'address': {r'$ref': '#/\$defs/address'}, + 'contactMethod': { + 'type': 'string', + 'enum': ['phone', 'email'], + }, + 'phone': {'type': 'string'}, + 'email': {'type': 'string'}, + }, + 'allOf': [ + { + 'anyOf': [ + { + 'required': ['phone'], + }, + { + 'required': ['email'], + }, + ], + }, + ], + 'if': { + 'properties': { + 'contactMethod': {'const': 'phone'}, + }, + 'required': ['contactMethod'], + }, + 'then': { + 'required': ['phone'], + }, + 'else': { + 'required': ['email'], + }, + 'additionalProperties': false, +}; + +const _elicitationDefaultsSchema = { + 'type': 'object', + 'properties': { + 'name': { + 'type': 'string', + 'default': 'John Doe', + }, + 'age': { + 'type': 'integer', + 'default': 30, + }, + 'score': { + 'type': 'number', + 'default': 95.5, + }, + 'status': { + 'type': 'string', + 'enum': ['active', 'inactive', 'pending'], + 'default': 'active', + }, + 'verified': { + 'type': 'boolean', + 'default': true, + }, + }, + 'required': ['name', 'age', 'score', 'status', 'verified'], +}; + +const _elicitationEnumSchema = { + 'type': 'object', + 'properties': { + 'untitledSingle': { + 'type': 'string', + 'enum': ['option1', 'option2', 'option3'], + }, + 'titledSingle': { + 'type': 'string', + 'oneOf': [ + {'const': 'value1', 'title': 'First Option'}, + {'const': 'value2', 'title': 'Second Option'}, + ], + }, + 'legacyEnum': { + 'type': 'string', + 'enum': ['opt1', 'opt2', 'opt3'], + 'enumNames': ['Option One', 'Option Two', 'Option Three'], + }, + 'untitledMulti': { + 'type': 'array', + 'items': { + 'type': 'string', + 'enum': ['option1', 'option2', 'option3'], + }, + }, + 'titledMulti': { + 'type': 'array', + 'items': { + 'anyOf': [ + {'const': 'value1', 'title': 'First Choice'}, + {'const': 'value2', 'title': 'Second Choice'}, + ], + }, + }, + }, + 'required': [ + 'untitledSingle', + 'titledSingle', + 'legacyEnum', + 'untitledMulti', + 'titledMulti', + ], +}; + +void _printUsage() { + stdout.writeln(''' +Usage: dart run test/conformance/mcp_2025_server.dart [options] + +Options: + --host Host to bind, default: localhost. + --port Port to bind, default: 0. + --help Show this help. +'''); +} diff --git a/test/conformance/mcp_2026_rc_client.dart b/test/conformance/mcp_2026_rc_client.dart new file mode 100644 index 00000000..5ac61078 --- /dev/null +++ b/test/conformance/mcp_2026_rc_client.dart @@ -0,0 +1,764 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:mcp_dart/mcp_dart.dart'; + +const _clientInfo = Implementation( + name: 'mcp-dart-2026-rc-conformance-client', + version: '0.0.0', +); + +Future main(List args) async { + if (args.isEmpty || args.contains('--help')) { + _printUsage(); + return; + } + + final serverUrl = Uri.parse(args.last); + final scenario = Platform.environment['MCP_CONFORMANCE_SCENARIO']; + final protocolVersion = + Platform.environment['MCP_CONFORMANCE_PROTOCOL_VERSION'] ?? + draftProtocolVersion2026V1; + final context = _readContext(); + + switch (scenario) { + case 'initialize': + await _withClient(serverUrl, protocolVersion: latestProtocolVersion); + case 'tools_call': + await _withClient( + serverUrl, + protocolVersion: latestProtocolVersion, + action: (client) async { + await client.listTools(); + await client.callTool( + const CallToolRequest( + name: 'add_numbers', + arguments: {'a': 2, 'b': 3}, + ), + ); + }, + ); + case 'elicitation-sep1034-client-defaults': + await _runElicitationDefaults(serverUrl); + case 'request-metadata': + await _runRequestMetadata(serverUrl, protocolVersion); + case 'sep-2322-client-request-state': + await _runMrtrRequestState(serverUrl, protocolVersion); + case 'http-standard-headers': + await _runStandardHeaders(serverUrl, protocolVersion); + case 'http-custom-headers': + await _runCustomHeaders(serverUrl, protocolVersion, context); + case 'http-invalid-tool-headers': + await _runInvalidToolHeaders(serverUrl, protocolVersion); + case 'json-schema-ref-no-deref': + await _runSchemaRefNoDeref(serverUrl, latestProtocolVersion); + case 'sse-retry': + await _withClient( + serverUrl, + protocolVersion: latestProtocolVersion, + action: (client) async { + await client.listTools(); + await client.callTool( + const CallToolRequest(name: 'test_reconnection'), + options: const RequestOptions(timeout: Duration(seconds: 5)), + ); + }, + ); + default: + if (scenario != null && scenario.startsWith('auth/')) { + await _runAuthScenario(serverUrl, protocolVersion, scenario, context); + } else { + stderr.writeln('Unsupported conformance client scenario: $scenario'); + } + } + exit(0); +} + +const _draftCapabilities = ClientCapabilities( + roots: ClientCapabilitiesRoots(listChanged: true), + sampling: ClientCapabilitiesSampling(tools: true), + elicitation: ClientElicitation( + form: ClientElicitationForm(applyDefaults: true), + ), +); + +Map _readContext() { + final raw = Platform.environment['MCP_CONFORMANCE_CONTEXT']; + if (raw == null || raw.isEmpty) { + return const {}; + } + final decoded = jsonDecode(raw); + return decoded is Map ? decoded : const {}; +} + +Future _withClient( + Uri serverUrl, { + required String protocolVersion, + ClientCapabilities capabilities = const ClientCapabilities(), + Future Function(McpClient client)? action, +}) async { + final transport = StreamableHttpClientTransport(serverUrl); + final client = McpClient( + _clientInfo, + options: McpClientOptions( + capabilities: capabilities, + protocolVersion: protocolVersion, + useServerDiscover: isStatelessProtocolVersion(protocolVersion), + ), + ); + if (capabilities.roots != null) { + client.setRequestHandler( + Method.rootsList, + (request, extra) async => ListRootsResult( + roots: [Root(uri: Directory.current.uri.toString(), name: 'workspace')], + ), + (id, params, meta) => JsonRpcListRootsRequest(id: id, meta: meta), + ); + } + client.onSamplingRequest = (params) async { + final firstText = params.messages + .expand((message) => message.contentBlocks) + .whereType() + .map((content) => content.text) + .firstOrNull; + return CreateMessageResult( + role: SamplingMessageRole.assistant, + model: 'mcp-dart-conformance-model', + content: SamplingTextContent(text: firstText ?? 'ok'), + ); + }; + client.onElicitRequest = (params) async { + final content = {}; + return ElicitResult(action: 'accept', content: content); + }; + + try { + await client.connect(transport); + await action?.call(client); + } finally { + await client.close(); + } +} + +Future _runElicitationDefaults(Uri serverUrl) async { + await _withClient( + serverUrl, + protocolVersion: latestProtocolVersion, + capabilities: const ClientCapabilities( + elicitation: ClientElicitation( + form: ClientElicitationForm(applyDefaults: true), + ), + ), + action: (client) async { + await client.listTools(); + await client.callTool( + const CallToolRequest(name: 'test_client_elicitation_defaults'), + ); + }, + ); +} + +Future _runMrtrRequestState(Uri serverUrl, String protocolVersion) async { + final client = _RawStatelessClient(serverUrl, protocolVersion); + await client.callToolResolvingInputRequired('test_mrtr_echo_state'); + await client.callToolResolvingInputRequired('test_mrtr_no_state'); + await client.callTool('test_mrtr_unrelated'); + await client.callToolResolvingInputRequired('test_mrtr_no_result_type'); +} + +Future _runRequestMetadata(Uri serverUrl, String protocolVersion) async { + await _RawStatelessClient( + serverUrl, + latestDraftProtocolVersion, + ).request(Method.serverDiscover, const {}); + await _RawStatelessClient( + serverUrl, + protocolVersion, + ).request(Method.serverDiscover, const {}); +} + +Future _runStandardHeaders(Uri serverUrl, String protocolVersion) async { + final transport = await _startedTransport(serverUrl, protocolVersion); + try { + await transport.send( + JsonRpcInitializeRequest( + id: 1, + initParams: InitializeRequest( + protocolVersion: protocolVersion, + capabilities: _draftCapabilities, + clientInfo: _clientInfo, + ), + ), + ); + await transport.send(const JsonRpcInitializedNotification()); + await transport.send(const JsonRpcListToolsRequest(id: 2)); + await transport.send( + JsonRpcCallToolRequest( + id: 3, + params: const CallToolRequest(name: 'test_headers').toJson(), + ), + ); + await transport.send(JsonRpcListResourcesRequest(id: 4)); + await transport.send( + JsonRpcReadResourceRequest( + id: 5, + readParams: const ReadResourceRequest( + uri: 'file:///path/to/file%20name.txt', + ), + ), + ); + await transport.send(JsonRpcListPromptsRequest(id: 6)); + await transport.send( + JsonRpcGetPromptRequest( + id: 7, + getParams: const GetPromptRequest(name: 'test_prompt'), + ), + ); + } finally { + await transport.close(); + } +} + +Future _runCustomHeaders( + Uri serverUrl, + String protocolVersion, + Map context, +) async { + final transport = await _startedTransport(serverUrl, protocolVersion); + transport.setToolParameterHeaderMappings(const { + 'test_custom_headers': { + 'region': 'Region', + 'priority': 'Priority', + 'verbose': 'Verbose', + 'debug': 'Debug', + 'empty_val': 'EmptyVal', + 'method_val': 'Method', + 'float_val': 'FloatVal', + 'non_ascii_val': 'NonAscii', + 'whitespace_val': 'Whitespace', + 'leading_space_val': 'LeadingSpace', + 'trailing_space_val': 'TrailingSpace', + 'internal_space_val': 'InternalSpace', + 'control_char_val': 'ControlChar', + 'crlf_val': 'CrLf', + 'tab_val': 'Tab', + }, + 'test_custom_headers_null': { + 'region': 'Region', + 'priority': 'Priority', + 'verbose': 'Verbose', + }, + }); + + try { + await transport.send( + JsonRpcInitializeRequest( + id: 1, + initParams: InitializeRequest( + protocolVersion: protocolVersion, + capabilities: _draftCapabilities, + clientInfo: _clientInfo, + ), + ), + ); + await transport.send(const JsonRpcInitializedNotification()); + await transport.send(const JsonRpcListToolsRequest(id: 2)); + + final toolCalls = context['toolCalls']; + if (toolCalls is List) { + var id = 3; + for (final call in toolCalls.whereType()) { + final name = call['name']; + if (name is! String) { + continue; + } + final arguments = call['arguments']; + await transport.send( + JsonRpcCallToolRequest( + id: id++, + params: CallToolRequest( + name: name, + arguments: arguments is Map + ? arguments.cast() + : const {}, + ).toJson(), + ), + ); + } + } + } finally { + await transport.close(); + } +} + +Future _runInvalidToolHeaders( + Uri serverUrl, + String protocolVersion, +) async { + final transport = await _startedTransport(serverUrl, protocolVersion); + transport.setToolParameterHeaderMappings(const { + 'valid_tool': {'region': 'Region'}, + }); + try { + await transport.send( + JsonRpcInitializeRequest( + id: 1, + initParams: InitializeRequest( + protocolVersion: protocolVersion, + capabilities: _draftCapabilities, + clientInfo: _clientInfo, + ), + ), + ); + await transport.send(const JsonRpcInitializedNotification()); + await transport.send(const JsonRpcListToolsRequest(id: 2)); + await transport.send( + JsonRpcCallToolRequest( + id: 3, + params: const CallToolRequest( + name: 'valid_tool', + arguments: {'region': 'us-west1'}, + ).toJson(), + ), + ); + } finally { + await transport.close(); + } +} + +Future _runSchemaRefNoDeref( + Uri serverUrl, + String protocolVersion, +) async { + final transport = await _startedTransport(serverUrl, protocolVersion); + try { + await transport.send( + JsonRpcInitializeRequest( + id: 1, + initParams: InitializeRequest( + protocolVersion: protocolVersion, + capabilities: _draftCapabilities, + clientInfo: _clientInfo, + ), + ), + ); + await transport.send(const JsonRpcInitializedNotification()); + await transport.send(const JsonRpcListToolsRequest(id: 2)); + } finally { + await transport.close(); + } +} + +Future _runAuthScenario( + Uri serverUrl, + String protocolVersion, + String scenario, + Map context, +) async { + final provider = _ConformanceOAuthProvider(scenario, context); + final client = _RawOAuthClient(serverUrl, latestProtocolVersion, provider); + const allowClientErrorScenarios = { + 'auth/resource-mismatch', + 'auth/scope-retry-limit', + 'auth/iss-supported-missing', + 'auth/iss-wrong-issuer', + 'auth/iss-unexpected', + 'auth/iss-normalized', + 'auth/metadata-issuer-mismatch', + }; + + try { + await client.start(); + await client.initialize(); + + switch (scenario) { + case 'auth/authorization-server-migration': + await client.callTool('test-tool'); + await client.callTool('test-tool'); + case 'auth/scope-step-up': + await client.listTools(); + await client.callTool('test-tool'); + case 'auth/scope-retry-limit': + try { + await client.listTools(maxAuthAttempts: 2); + } catch (_) { + // The scenario only needs to observe a bounded number of auth + // retries; the server intentionally never grants the scope. + } + default: + await client.listTools(); + await client.callTool('test-tool'); + } + } catch (error) { + if (!allowClientErrorScenarios.contains(scenario)) { + rethrow; + } + } finally { + await client.close(); + } +} + +Future _startedTransport( + Uri serverUrl, + String protocolVersion, +) async { + final transport = StreamableHttpClientTransport(serverUrl); + transport.protocolVersion = protocolVersion; + await transport.start(); + return transport; +} + +class _RawStatelessClient { + final Uri serverUrl; + final String protocolVersion; + final HttpClient _httpClient = HttpClient(); + var _nextId = 1; + + _RawStatelessClient(this.serverUrl, this.protocolVersion); + + Future> callTool( + String name, { + Map arguments = const {}, + InputResponses? inputResponses, + String? requestState, + }) { + return request( + Method.toolsCall, + { + 'name': name, + 'arguments': arguments, + if (inputResponses != null) + 'inputResponses': InputResponse.mapToJson(inputResponses), + if (requestState != null) 'requestState': requestState, + }, + ); + } + + Future> callToolResolvingInputRequired( + String name, + ) async { + InputResponses? inputResponses; + String? requestState; + for (var attempt = 0; attempt < 4; attempt++) { + final response = await callTool( + name, + inputResponses: inputResponses, + requestState: requestState, + ); + final result = response['result']; + if (result is! Map || + result['resultType'] != resultTypeInputRequired) { + return response; + } + + final inputRequired = InputRequiredResult.fromJson(result); + inputResponses = _resolveInputRequests(inputRequired.inputRequests); + requestState = inputRequired.requestState; + } + throw StateError('Exceeded input_required retries for $name'); + } + + InputResponses? _resolveInputRequests(InputRequests? inputRequests) { + if (inputRequests == null) { + return null; + } + return { + for (final entry in inputRequests.entries) + entry.key: InputResponse.fromResult(_resolveInputRequest(entry.value)), + }; + } + + BaseResultData _resolveInputRequest(InputRequest request) { + return switch (request.method) { + Method.elicitationCreate => const ElicitResult( + action: 'accept', + content: {'confirmed': true}, + ), + Method.samplingCreateMessage => const CreateMessageResult( + role: SamplingMessageRole.assistant, + model: 'mcp-dart-conformance-model', + content: SamplingTextContent(text: 'ok'), + ), + Method.rootsList => ListRootsResult( + roots: [Root(uri: Directory.current.uri.toString())], + ), + _ => + throw UnsupportedError('Unsupported input request ${request.method}'), + }; + } + + Future> request( + String method, + Map params, + ) async { + final id = _nextId++; + final request = await _httpClient.postUrl(serverUrl); + request.headers.contentType = ContentType.json; + request.headers.set(HttpHeaders.acceptHeader, 'application/json'); + request.headers.set('MCP-Protocol-Version', protocolVersion); + request.headers.set('Mcp-Method', method); + final name = _mcpName(method, params); + if (name != null) { + request.headers.set('Mcp-Name', name); + } + request.write( + jsonEncode({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': method, + 'params': { + ...params, + '_meta': buildProtocolRequestMeta( + protocolVersion: protocolVersion, + clientInfo: _clientInfo, + clientCapabilities: _draftCapabilities, + ), + }, + }), + ); + final response = await request.close(); + final body = await response.transform(utf8.decoder).join(); + if (body.isEmpty) { + return const {}; + } + final decoded = jsonDecode(body); + if (decoded is! Map) { + throw FormatException('Expected JSON object response, got $decoded'); + } + return decoded; + } + + String? _mcpName(String method, Map params) { + return switch (method) { + Method.toolsCall => params['name'] as String?, + Method.promptsGet => params['name'] as String?, + Method.resourcesRead => params['uri'] as String?, + _ => null, + }; + } +} + +void _printUsage() { + stdout.writeln( + 'Usage: dart run test/conformance/mcp_2026_rc_client.dart ', + ); +} + +class _AuthorizationRedirect { + final String code; + final String? state; + final String? issuer; + + const _AuthorizationRedirect({ + required this.code, + required this.state, + required this.issuer, + }); +} + +class _ConformanceOAuthProvider implements OAuthAuthorizationCodeProvider { + static const _clientMetadataDocumentUrl = + 'https://conformance-test.local/client-metadata.json'; + + final String scenario; + final Map context; + OAuthTokens? _tokens; + _AuthorizationRedirect? _redirect; + + _ConformanceOAuthProvider(this.scenario, this.context); + + @override + String get clientId { + final contextClientId = context['client_id']; + if (contextClientId is String && contextClientId.isNotEmpty) { + return contextClientId; + } + if (scenario == 'auth/basic-cimd') { + return _clientMetadataDocumentUrl; + } + return 'mcp-dart-conformance-client'; + } + + @override + Uri get redirectUri => Uri.parse('http://127.0.0.1/oauth/callback'); + + @override + String? get clientSecret { + final contextClientSecret = context['client_secret']; + return contextClientSecret is String ? contextClientSecret : null; + } + + @override + List get scopes => const []; + + @override + Future tokens() async => _tokens; + + @override + Future redirectToAuthorization() async { + throw UnauthorizedError('Authorization-code redirect is required'); + } + + @override + Future redirectToAuthorizationUrl(Uri authorizationUri) async { + _redirect = await _performAuthorizationRedirect(authorizationUri); + } + + @override + Future saveTokens(OAuthTokens tokens) async { + _tokens = tokens; + } + + _AuthorizationRedirect takeRedirect() { + final redirect = _redirect; + if (redirect == null) { + throw UnauthorizedError('Authorization redirect did not return a code'); + } + _redirect = null; + return redirect; + } + + Future<_AuthorizationRedirect> _performAuthorizationRedirect(Uri uri) async { + final httpClient = HttpClient(); + try { + final request = await httpClient.getUrl(uri); + request.followRedirects = false; + final response = await request.close(); + await response.drain(); + final location = response.headers.value(HttpHeaders.locationHeader); + if (location == null || location.isEmpty) { + throw UnauthorizedError( + 'Authorization endpoint did not redirect with a code', + ); + } + final redirectUri = uri.resolve(location); + final code = redirectUri.queryParameters['code']; + if (code == null || code.isEmpty) { + throw UnauthorizedError('Authorization redirect did not include code'); + } + return _AuthorizationRedirect( + code: code, + state: redirectUri.queryParameters['state'], + issuer: redirectUri.queryParameters['iss'], + ); + } finally { + httpClient.close(force: true); + } + } +} + +class _RawOAuthClient { + final Uri serverUrl; + final String protocolVersion; + final _ConformanceOAuthProvider authProvider; + late final StreamableHttpClientTransport transport; + final Map> _pending = {}; + var _nextId = 1; + + _RawOAuthClient(this.serverUrl, this.protocolVersion, this.authProvider); + + Future start() async { + transport = StreamableHttpClientTransport( + serverUrl, + opts: StreamableHttpClientTransportOptions(authProvider: authProvider), + ); + transport.protocolVersion = protocolVersion; + transport.onmessage = (message) { + switch (message) { + case JsonRpcResponse(:final id): + _pending.remove(id)?.complete(message); + case JsonRpcError(:final id) when id != null: + _pending.remove(id)?.complete(message); + default: + break; + } + }; + await transport.start(); + } + + Future close() => transport.close(); + + Future initialize() async { + final id = _nextId++; + await _request( + JsonRpcInitializeRequest( + id: id, + initParams: InitializeRequest( + protocolVersion: protocolVersion, + capabilities: _draftCapabilities, + clientInfo: _clientInfo, + ), + ), + ); + await transport.send(const JsonRpcInitializedNotification()); + } + + Future> listTools({ + int maxAuthAttempts = 4, + }) { + return _request( + JsonRpcListToolsRequest(id: _nextId++), + maxAuthAttempts: maxAuthAttempts, + ); + } + + Future> callTool( + String name, { + int maxAuthAttempts = 4, + }) { + return _request( + JsonRpcCallToolRequest( + id: _nextId++, + params: CallToolRequest(name: name).toJson(), + ), + maxAuthAttempts: maxAuthAttempts, + ); + } + + Future> _request( + JsonRpcRequest request, { + int maxAuthAttempts = 4, + }) async { + var authAttempts = 0; + while (true) { + final completer = Completer(); + _pending[request.id] = completer; + try { + await transport.send(request); + } on UnauthorizedError { + _pending.remove(request.id); + if (authAttempts >= maxAuthAttempts) { + rethrow; + } + authAttempts += 1; + await _finishAuth(); + continue; + } catch (_) { + _pending.remove(request.id); + rethrow; + } + + final message = await completer.future.timeout( + const Duration(seconds: 8), + ); + switch (message) { + case JsonRpcResponse(:final result): + return result; + case JsonRpcError(:final error): + throw McpError(error.code, error.message, error.data); + default: + throw StateError('Unexpected response message $message'); + } + } + } + + Future _finishAuth() async { + final redirect = authProvider.takeRedirect(); + await transport.finishAuth( + redirect.code, + state: redirect.state, + issuer: redirect.issuer, + ); + } +} diff --git a/test/conformance/mcp_2026_rc_server.dart b/test/conformance/mcp_2026_rc_server.dart new file mode 100644 index 00000000..14cee0b6 --- /dev/null +++ b/test/conformance/mcp_2026_rc_server.dart @@ -0,0 +1,410 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:mcp_dart/mcp_dart.dart'; + +import '../interop/test_dart_server.dart' as interop; + +/// Dedicated HTTP server fixture for the MCP 2026 RC conformance package. +/// +/// This deliberately starts from the existing cross-SDK interop server and +/// enables JSON stateless responses. Conformance-specific diagnostic tools can +/// be added here without changing the stable interop fixture. +Future main(List args) async { + var host = 'localhost'; + var port = 0; + + for (var i = 0; i < args.length; i++) { + switch (args[i]) { + case '--host': + if (i + 1 < args.length) { + host = args[++i]; + } + case '--port': + if (i + 1 < args.length) { + final parsed = int.tryParse(args[++i]); + if (parsed != null) { + port = parsed; + } + } + case '--help': + _printUsage(); + return; + } + } + + final server = StreamableMcpServer( + serverFactory: (_) => _createConformanceServer(), + host: host, + port: port, + enableJsonResponse: true, + ); + + await server.start(); + stdout.writeln( + 'MCP 2026 RC conformance server listening on ' + 'http://$host:${server.boundPort}${server.path}', + ); + + await Future.any([ + ProcessSignal.sigint.watch().first, + ProcessSignal.sigterm.watch().first, + ]); + await server.stop(); +} + +McpServer _createConformanceServer() { + final server = interop.createServer(); + + server.registerTool( + 'a_header_probe', + description: 'No-op tool for HTTP header conformance checks', + callback: (args, extra) async => const CallToolResult(content: []), + ); + + _registerInputRequiredDiagnostics(server); + + return server; +} + +void _registerInputRequiredDiagnostics(McpServer server) { + server.registerTool( + 'test_input_required_result_elicitation', + description: 'Exercises an elicitation InputRequiredResult retry flow', + callback: (args, extra) async { + final content = _acceptedContent(extra.inputResponses, 'user_name'); + final name = content?['name']; + if (name is String) { + return _textResult('Hello, $name!'); + } + + return InputRequiredResult( + inputRequests: { + 'user_name': _elicitationInput( + message: 'What is your name?', + properties: {'name': JsonSchema.string()}, + required: ['name'], + ), + }, + ); + }, + ); + + server.registerTool( + 'test_input_required_result_sampling', + description: 'Exercises a sampling InputRequiredResult retry flow', + callback: (args, extra) async { + final answer = _samplingText(extra.inputResponses, 'capital_question'); + if (answer != null) { + return _textResult(answer); + } + + return InputRequiredResult( + inputRequests: { + 'capital_question': _samplingInput( + 'What is the capital of France?', + maxTokens: 100, + ), + }, + ); + }, + ); + + server.registerTool( + 'test_input_required_result_list_roots', + description: 'Exercises a roots/list InputRequiredResult retry flow', + callback: (args, extra) async { + final roots = _roots(extra.inputResponses, 'client_roots'); + if (roots != null) { + return _textResult('Received ${roots.length} roots.'); + } + + return InputRequiredResult( + inputRequests: { + 'client_roots': InputRequest.listRoots(params: const {}), + }, + ); + }, + ); + + server.registerTool( + 'test_input_required_result_request_state', + description: 'Exercises requestState echo validation', + callback: (args, extra) async { + const state = 'request-state-v1'; + final content = _acceptedContent(extra.inputResponses, 'confirm'); + if (content != null) { + _requireRequestState(extra.requestState, state); + return _textResult('state-ok'); + } + + return InputRequiredResult( + requestState: state, + inputRequests: { + 'confirm': _elicitationInput( + message: 'Please confirm', + properties: {'ok': JsonSchema.boolean()}, + required: ['ok'], + ), + }, + ); + }, + ); + + server.registerTool( + 'test_input_required_result_multiple_inputs', + description: 'Exercises multiple simultaneous InputRequiredResult requests', + callback: (args, extra) async { + const state = 'multiple-inputs-v1'; + final responses = extra.inputResponses; + final user = _acceptedContent(responses, 'user_name'); + final greeting = _samplingText(responses, 'greeting'); + final roots = _roots(responses, 'client_roots'); + if (user != null && greeting != null && roots != null) { + _requireRequestState(extra.requestState, state); + return _textResult( + 'Hello ${user['name'] ?? 'there'}: $greeting (${roots.length} roots)', + ); + } + + return InputRequiredResult( + requestState: state, + inputRequests: { + 'user_name': _elicitationInput( + message: 'What is your name?', + properties: {'name': JsonSchema.string()}, + required: ['name'], + ), + 'greeting': _samplingInput('Generate a greeting', maxTokens: 50), + 'client_roots': InputRequest.listRoots(params: const {}), + }, + ); + }, + ); + + server.registerTool( + 'test_input_required_result_multi_round', + description: 'Exercises a multi-round InputRequiredResult flow', + callback: (args, extra) async { + switch (extra.requestState) { + case null: + return InputRequiredResult( + requestState: 'multi-round-1', + inputRequests: { + 'step1': _elicitationInput( + message: 'Step 1: What is your name?', + properties: {'name': JsonSchema.string()}, + required: ['name'], + ), + }, + ); + case 'multi-round-1': + if (_acceptedContent(extra.inputResponses, 'step1') == null) { + return InputRequiredResult( + requestState: 'multi-round-1', + inputRequests: { + 'step1': _elicitationInput( + message: 'Step 1: What is your name?', + properties: {'name': JsonSchema.string()}, + required: ['name'], + ), + }, + ); + } + return InputRequiredResult( + requestState: 'multi-round-2', + inputRequests: { + 'step2': _elicitationInput( + message: 'Step 2: What is your favorite color?', + properties: {'color': JsonSchema.string()}, + required: ['color'], + ), + }, + ); + case 'multi-round-2': + if (_acceptedContent(extra.inputResponses, 'step2') == null) { + return InputRequiredResult( + requestState: 'multi-round-2', + inputRequests: { + 'step2': _elicitationInput( + message: 'Step 2: What is your favorite color?', + properties: {'color': JsonSchema.string()}, + required: ['color'], + ), + }, + ); + } + return _textResult('multi-round complete'); + default: + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid requestState', + ); + } + }, + ); + + server.registerTool( + 'test_input_required_result_tampered_state', + description: 'Rejects modified requestState values', + callback: (args, extra) async { + const state = 'tamper-proof-state-v1'; + final content = _acceptedContent(extra.inputResponses, 'confirm'); + if (content != null) { + _requireRequestState(extra.requestState, state); + return _textResult('tamper state accepted'); + } + + return InputRequiredResult( + requestState: state, + inputRequests: { + 'confirm': _elicitationInput( + message: 'Please confirm', + properties: {'ok': JsonSchema.boolean()}, + required: ['ok'], + ), + }, + ); + }, + ); + + server.registerTool( + 'test_input_required_result_capabilities', + description: 'Only emits input requests supported by client capabilities', + callback: (args, extra) async { + final capabilities = extra.clientCapabilities; + final inputRequests = {}; + if (capabilities?.sampling != null) { + inputRequests['sampling'] = _samplingInput( + 'Generate a capability-safe response', + maxTokens: 50, + ); + } + if (capabilities?.elicitation != null) { + inputRequests['elicitation'] = _elicitationInput( + message: 'Provide context', + properties: {'context': JsonSchema.string()}, + required: ['context'], + ); + } + if (capabilities?.roots != null) { + inputRequests['roots'] = InputRequest.listRoots(params: const {}); + } + if (inputRequests.isEmpty) { + return _textResult('No declared input capabilities.'); + } + + return InputRequiredResult(inputRequests: inputRequests); + }, + ); + + server.registerPrompt( + 'test_input_required_result_prompt', + description: 'Exercises InputRequiredResult from prompts/get', + callback: (args, extra) async { + final content = _acceptedContent(extra?.inputResponses, 'user_context'); + final context = content?['context']; + if (context is String) { + return GetPromptResult( + messages: [ + PromptMessage( + role: PromptMessageRole.user, + content: TextContent(text: 'Use this context: $context'), + ), + ], + ); + } + + return InputRequiredResult( + inputRequests: { + 'user_context': _elicitationInput( + message: 'What context should the prompt use?', + properties: {'context': JsonSchema.string()}, + required: ['context'], + ), + }, + ); + }, + ); +} + +InputRequest _elicitationInput({ + required String message, + required Map properties, + required List required, +}) { + return InputRequest.elicit( + ElicitRequest.form( + message: message, + requestedSchema: JsonSchema.object( + properties: properties, + required: required, + ), + ), + ); +} + +InputRequest _samplingInput(String text, {required int maxTokens}) { + return InputRequest.createMessage( + CreateMessageRequest( + messages: [ + SamplingMessage( + role: SamplingMessageRole.user, + content: SamplingTextContent(text: text), + ), + ], + maxTokens: maxTokens, + ), + ); +} + +CallToolResult _textResult(String text) { + return CallToolResult(content: [TextContent(text: text)]); +} + +Map? _acceptedContent( + InputResponses? responses, + String key, +) { + final response = responses?[key]?.toJson(); + if (response == null || response['action'] != 'accept') { + return null; + } + final content = response['content']; + if (content is! Map) { + return null; + } + return content.cast(); +} + +String? _samplingText(InputResponses? responses, String key) { + final response = responses?[key]?.toJson(); + final content = response?['content']; + if (content is Map && content['type'] == 'text') { + final text = content['text']; + return text is String ? text : null; + } + return null; +} + +List? _roots(InputResponses? responses, String key) { + final response = responses?[key]?.toJson(); + final roots = response?['roots']; + return roots is List ? roots : null; +} + +void _requireRequestState(String? actual, String expected) { + if (actual != expected) { + throw McpError( + ErrorCode.invalidParams.value, + 'Invalid requestState', + ); + } +} + +void _printUsage() { + stdout.writeln( + 'Usage: dart run test/conformance/mcp_2026_rc_server.dart ' + '[--host localhost] [--port 33125]', + ); +} diff --git a/test/conformance/run_2025_server_conformance.dart b/test/conformance/run_2025_server_conformance.dart new file mode 100644 index 00000000..ed58e366 --- /dev/null +++ b/test/conformance/run_2025_server_conformance.dart @@ -0,0 +1,265 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +const _defaultConformancePackage = + '@modelcontextprotocol/conformance@0.2.0-alpha.1'; +const _defaultTimeout = Duration(seconds: 60); + +Future main(List args) async { + final options = _Options.parse(args); + if (options.help) { + _printUsage(); + return; + } + + final outputRoot = await _createOutputRoot(options.outputDir); + + Process? serverProcess; + var serverOutputSubscriptions = >[]; + late final Uri serverUrl; + try { + if (options.url == null) { + final port = options.port ?? await _findFreePort(); + serverUrl = Uri.parse('http://localhost:$port/mcp'); + serverProcess = await Process.start( + Platform.resolvedExecutable, + [ + 'test/conformance/mcp_2025_server.dart', + '--port', + '$port', + ], + workingDirectory: Directory.current.path, + ); + serverOutputSubscriptions = _pipeServerOutput(serverProcess); + await _waitForPort('localhost', port); + } else { + serverUrl = Uri.parse(options.url!); + } + + stdout.writeln('2025 conformance URL: $serverUrl'); + stdout.writeln('Conformance package: ${options.conformancePackage}'); + stdout.writeln('Output: ${outputRoot.path}'); + stdout.writeln(''); + + final result = await _runConformance( + serverUrl: serverUrl, + outputRoot: outputRoot, + conformancePackage: options.conformancePackage, + scenario: options.scenario, + timeout: options.timeout, + ); + + exitCode = result.exitCode ?? 1; + if (result.timedOut) { + stdout.writeln('Timed out after ${options.timeout.inSeconds}s.'); + exitCode = 1; + } + } finally { + if (serverProcess != null) { + await _stopProcess(serverProcess); + for (final subscription in serverOutputSubscriptions) { + unawaited(subscription.cancel()); + } + } + } + + exit(exitCode); +} + +Future _createOutputRoot(String? outputDir) async { + final root = outputDir == null + ? Directory( + '.dart_tool/conformance/2025_server/' + '${DateTime.now().toUtc().toIso8601String().replaceAll(':', '-')}', + ) + : Directory(outputDir); + await root.create(recursive: true); + return root; +} + +Future _findFreePort() async { + final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final port = socket.port; + await socket.close(); + return port; +} + +List> _pipeServerOutput(Process process) { + // ignore: cancel_subscriptions + final stdoutSubscription = process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) => stdout.writeln('[server] $line')); + // ignore: cancel_subscriptions + final stderrSubscription = process.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) => stderr.writeln('[server] $line')); + return [stdoutSubscription, stderrSubscription]; +} + +Future _stopProcess(Process process) async { + process.kill(ProcessSignal.sigterm); + try { + await process.exitCode.timeout(const Duration(seconds: 3)); + return; + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + } + await process.exitCode.timeout( + const Duration(seconds: 3), + onTimeout: () => -1, + ); +} + +Future _waitForPort(String host, int port) async { + final deadline = DateTime.now().add(const Duration(seconds: 15)); + while (DateTime.now().isBefore(deadline)) { + try { + final socket = await Socket.connect( + host, + port, + timeout: const Duration(milliseconds: 300), + ); + await socket.close(); + return; + } catch (_) { + await Future.delayed(const Duration(milliseconds: 150)); + } + } + throw StateError('Timed out waiting for $host:$port'); +} + +Future<_RunResult> _runConformance({ + required Uri serverUrl, + required Directory outputRoot, + required String conformancePackage, + required String? scenario, + required Duration timeout, +}) async { + final process = await Process.start( + 'npx', + [ + '-y', + conformancePackage, + 'server', + '--url', + serverUrl.toString(), + '--suite', + 'all', + '--spec-version', + '2025-11-25', + if (scenario != null) ...[ + '--scenario', + scenario, + ], + '--verbose', + '-o', + outputRoot.path, + ], + workingDirectory: Directory.current.path, + ); + + final stdoutDone = process.stdout.listen(stdout.add).asFuture(); + final stderrDone = process.stderr.listen(stderr.add).asFuture(); + + try { + final code = await process.exitCode.timeout(timeout); + await Future.wait([stdoutDone, stderrDone]); + return _RunResult(exitCode: code, timedOut: false); + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + await Future.wait([ + stdoutDone.catchError((_) {}), + stderrDone.catchError((_) {}), + ]); + return const _RunResult(exitCode: null, timedOut: true); + } +} + +void _printUsage() { + stdout.writeln(''' +Usage: dart run test/conformance/run_2025_server_conformance.dart [options] + +Options: + --scenario Run one scenario instead of the full suite. + --url Use an already-running server. + --port Port for the local fixture server. + --output-dir Directory for conformance artifacts. + --conformance-package Conformance npm package. + --timeout-seconds Overall conformance command timeout. + --help Show this help. +'''); +} + +class _Options { + final String? scenario; + final String? url; + final int? port; + final String? outputDir; + final String conformancePackage; + final Duration timeout; + final bool help; + + const _Options({ + required this.scenario, + required this.url, + required this.port, + required this.outputDir, + required this.conformancePackage, + required this.timeout, + required this.help, + }); + + factory _Options.parse(List args) { + String? scenario; + String? url; + int? port; + String? outputDir; + var conformancePackage = _defaultConformancePackage; + var timeout = _defaultTimeout; + var help = false; + + for (var i = 0; i < args.length; i++) { + switch (args[i]) { + case '--scenario': + scenario = args[++i]; + case '--url': + url = args[++i]; + case '--port': + port = int.parse(args[++i]); + case '--output-dir': + outputDir = args[++i]; + case '--conformance-package': + conformancePackage = args[++i]; + case '--timeout-seconds': + timeout = Duration(seconds: int.parse(args[++i])); + case '--help': + help = true; + default: + throw ArgumentError('Unknown argument: ${args[i]}'); + } + } + + return _Options( + scenario: scenario, + url: url, + port: port, + outputDir: outputDir, + conformancePackage: conformancePackage, + timeout: timeout, + help: help, + ); + } +} + +class _RunResult { + final int? exitCode; + final bool timedOut; + + const _RunResult({ + required this.exitCode, + required this.timedOut, + }); +} diff --git a/test/conformance/run_2026_rc_client_conformance.dart b/test/conformance/run_2026_rc_client_conformance.dart new file mode 100644 index 00000000..7006e509 --- /dev/null +++ b/test/conformance/run_2026_rc_client_conformance.dart @@ -0,0 +1,347 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +const _defaultConformancePackage = + '@modelcontextprotocol/conformance@0.2.0-alpha.1'; +const _defaultTimeout = Duration(seconds: 30); + +const _draftClientScenarios = [ + 'initialize', + 'tools_call', + 'elicitation-sep1034-client-defaults', + 'sse-retry', + 'request-metadata', + 'auth/metadata-default', + 'auth/metadata-var1', + 'auth/metadata-var2', + 'auth/metadata-var3', + 'auth/basic-cimd', + 'auth/scope-from-www-authenticate', + 'auth/scope-from-scopes-supported', + 'auth/scope-omitted-when-undefined', + 'auth/scope-step-up', + 'auth/scope-retry-limit', + 'auth/token-endpoint-auth-basic', + 'auth/token-endpoint-auth-post', + 'auth/token-endpoint-auth-none', + 'auth/pre-registration', + 'auth/resource-mismatch', + 'auth/offline-access-scope', + 'auth/offline-access-not-supported', + 'auth/authorization-server-migration', + 'auth/iss-supported', + 'auth/iss-not-advertised', + 'auth/iss-supported-missing', + 'auth/iss-wrong-issuer', + 'auth/iss-unexpected', + 'auth/iss-normalized', + 'auth/metadata-issuer-mismatch', + 'sep-2322-client-request-state', + 'http-standard-headers', + 'http-custom-headers', + 'http-invalid-tool-headers', + 'json-schema-ref-no-deref', +]; + +Future main(List args) async { + final options = _Options.parse(args); + if (options.help) { + _printUsage(); + return; + } + + final expectedFailures = await _readExpectedFailures( + options.expectedFailuresPath, + ); + final outputRoot = await _createOutputRoot(options.outputDir); + final scenarios = + options.scenario == null ? _draftClientScenarios : [options.scenario!]; + + stdout.writeln('Conformance package: ${options.conformancePackage}'); + stdout.writeln('Output: ${outputRoot.path}'); + stdout.writeln(''); + + final results = <_ScenarioResult>[]; + for (final scenario in scenarios) { + final result = await _runScenario( + scenario: scenario, + outputRoot: outputRoot, + conformancePackage: options.conformancePackage, + timeout: options.timeout, + ); + results.add(result); + _printScenarioResult(result, expectedFailures); + } + + await _writeSummary(outputRoot, results, expectedFailures); + final unexpectedFailures = results + .where( + (result) => + !result.passed && !expectedFailures.contains(result.scenario), + ) + .toList(); + final unexpectedPasses = results + .where( + (result) => result.passed && expectedFailures.contains(result.scenario), + ) + .toList(); + + stdout.writeln(''); + stdout.writeln( + 'Summary: ${results.where((result) => result.passed).length} passed, ' + '${results.where((result) => !result.passed).length} failed/timeout.', + ); + + if (unexpectedFailures.isNotEmpty) { + stdout.writeln('Unexpected failures:'); + for (final result in unexpectedFailures) { + stdout.writeln(' - ${result.scenario} (${result.status})'); + } + } + if (unexpectedPasses.isNotEmpty) { + stdout.writeln('Unexpected passes; remove these from expected failures:'); + for (final result in unexpectedPasses) { + stdout.writeln(' - ${result.scenario}'); + } + } + + exitCode = unexpectedFailures.isEmpty && unexpectedPasses.isEmpty ? 0 : 1; + exit(exitCode); +} + +Future> _readExpectedFailures(String path) async { + final file = File(path); + if (!await file.exists()) { + return const {}; + } + + final entries = {}; + for (final line in await file.readAsLines()) { + final trimmed = line.trim(); + if (trimmed.isEmpty || trimmed.startsWith('#')) { + continue; + } + entries.add(trimmed); + } + return entries; +} + +Future _createOutputRoot(String? outputDir) async { + final root = outputDir == null + ? Directory( + '.dart_tool/conformance/2026_rc_client/' + '${DateTime.now().toUtc().toIso8601String().replaceAll(':', '-')}', + ) + : Directory(outputDir); + await root.create(recursive: true); + return root; +} + +Future<_ScenarioResult> _runScenario({ + required String scenario, + required Directory outputRoot, + required String conformancePackage, + required Duration timeout, +}) async { + final outputDir = Directory('${outputRoot.path}/${_sanitize(scenario)}'); + await outputDir.create(recursive: true); + + final process = await Process.start( + 'npx', + [ + '-y', + conformancePackage, + 'client', + '--command', + 'dart run test/conformance/mcp_2026_rc_client.dart', + '--scenario', + scenario, + '--spec-version', + 'DRAFT-2026-v1', + '--verbose', + '-o', + outputDir.path, + ], + workingDirectory: Directory.current.path, + ); + + final stdoutBuffer = StringBuffer(); + final stderrBuffer = StringBuffer(); + final stdoutDone = process.stdout + .transform(utf8.decoder) + .listen(stdoutBuffer.write) + .asFuture(); + final stderrDone = process.stderr + .transform(utf8.decoder) + .listen(stderrBuffer.write) + .asFuture(); + + try { + final code = await process.exitCode.timeout(timeout); + await Future.wait([stdoutDone, stderrDone]); + return _ScenarioResult( + scenario: scenario, + exitCode: code, + timedOut: false, + stdout: stdoutBuffer.toString(), + stderr: stderrBuffer.toString(), + ); + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + await Future.wait([ + stdoutDone.catchError((_) {}), + stderrDone.catchError((_) {}), + ]); + return _ScenarioResult( + scenario: scenario, + exitCode: null, + timedOut: true, + stdout: stdoutBuffer.toString(), + stderr: stderrBuffer.toString(), + ); + } +} + +String _sanitize(String scenario) { + return scenario.replaceAll(RegExp(r'[^a-zA-Z0-9_.-]+'), '_'); +} + +void _printScenarioResult( + _ScenarioResult result, + Set expectedFailures, +) { + final expected = expectedFailures.contains(result.scenario); + final label = result.passed + ? expected + ? 'UNEXPECTED PASS' + : 'PASS' + : expected + ? 'EXPECTED FAIL' + : 'FAIL'; + stdout.writeln('${label.padRight(18)} ${result.scenario}'); +} + +Future _writeSummary( + Directory outputRoot, + List<_ScenarioResult> results, + Set expectedFailures, +) async { + final summary = { + 'package': _defaultConformancePackage, + 'expectedFailures': expectedFailures.toList()..sort(), + 'results': [ + for (final result in results) + { + 'scenario': result.scenario, + 'status': result.status, + 'exitCode': result.exitCode, + 'timedOut': result.timedOut, + }, + ], + }; + await File('${outputRoot.path}/summary.json').writeAsString( + '${const JsonEncoder.withIndent(' ').convert(summary)}\n', + ); +} + +void _printUsage() { + stdout.writeln(''' +Usage: dart run test/conformance/run_2026_rc_client_conformance.dart [options] + +Options: + --scenario Run one scenario instead of the full draft list. + --expected-failures Expected-failure list. + --output-dir Directory for conformance artifacts. + --conformance-package Conformance npm package. + --timeout-seconds Per-scenario timeout. + --help Show this help. +'''); +} + +class _ScenarioResult { + final String scenario; + final int? exitCode; + final bool timedOut; + final String stdout; + final String stderr; + + const _ScenarioResult({ + required this.scenario, + required this.exitCode, + required this.timedOut, + required this.stdout, + required this.stderr, + }); + + bool get passed => !timedOut && exitCode == 0; + String get status => timedOut ? 'timeout' : 'exit ${exitCode ?? 'unknown'}'; +} + +class _Options { + final String? scenario; + final String expectedFailuresPath; + final String? outputDir; + final String conformancePackage; + final Duration timeout; + final bool help; + + const _Options({ + required this.scenario, + required this.expectedFailuresPath, + required this.outputDir, + required this.conformancePackage, + required this.timeout, + required this.help, + }); + + static _Options parse(List args) { + String? scenario; + var expectedFailuresPath = + 'test/conformance/2026_rc_client_expected_failures.txt'; + String? outputDir; + var conformancePackage = _defaultConformancePackage; + var timeout = _defaultTimeout; + var help = false; + + for (var i = 0; i < args.length; i++) { + switch (args[i]) { + case '--scenario': + if (i + 1 < args.length) { + scenario = args[++i]; + } + case '--expected-failures': + if (i + 1 < args.length) { + expectedFailuresPath = args[++i]; + } + case '--output-dir': + if (i + 1 < args.length) { + outputDir = args[++i]; + } + case '--conformance-package': + if (i + 1 < args.length) { + conformancePackage = args[++i]; + } + case '--timeout-seconds': + if (i + 1 < args.length) { + final seconds = int.tryParse(args[++i]); + if (seconds != null) { + timeout = Duration(seconds: seconds); + } + } + case '--help': + case '-h': + help = true; + } + } + + return _Options( + scenario: scenario, + expectedFailuresPath: expectedFailuresPath, + outputDir: outputDir, + conformancePackage: conformancePackage, + timeout: timeout, + help: help, + ); + } +} diff --git a/test/conformance/run_2026_rc_server_conformance.dart b/test/conformance/run_2026_rc_server_conformance.dart new file mode 100644 index 00000000..c5e08eff --- /dev/null +++ b/test/conformance/run_2026_rc_server_conformance.dart @@ -0,0 +1,432 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +const _defaultConformancePackage = + '@modelcontextprotocol/conformance@0.2.0-alpha.1'; +const _defaultTimeout = Duration(seconds: 25); + +const _draftServerScenarios = [ + 'server-stateless', + 'caching', + 'sep-2164-resource-not-found', + 'http-header-validation', + 'http-custom-header-server-validation', + 'input-required-result-missing-input-response', + 'input-required-result-basic-elicitation', + 'input-required-result-basic-sampling', + 'input-required-result-basic-list-roots', + 'input-required-result-request-state', + 'input-required-result-multiple-input-requests', + 'input-required-result-multi-round', + 'input-required-result-non-tool-request', + 'input-required-result-result-type', + 'input-required-result-tampered-state', + 'input-required-result-capability-check', + 'input-required-result-unsupported-methods', + 'input-required-result-ignore-extra-params', + 'input-required-result-validate-input', +]; + +Future main(List args) async { + final options = _Options.parse(args); + if (options.help) { + _printUsage(); + return; + } + + final expectedFailures = await _readExpectedFailures( + options.expectedFailuresPath, + ); + final outputRoot = await _createOutputRoot(options.outputDir); + final scenarios = + options.scenario == null ? _draftServerScenarios : [options.scenario!]; + + Process? serverProcess; + var serverOutputSubscriptions = >[]; + late final Uri serverUrl; + try { + if (options.url == null) { + final port = options.port ?? await _findFreePort(); + serverUrl = Uri.parse('http://localhost:$port/mcp'); + serverProcess = await Process.start( + Platform.resolvedExecutable, + [ + 'test/conformance/mcp_2026_rc_server.dart', + '--port', + '$port', + ], + workingDirectory: Directory.current.path, + ); + serverOutputSubscriptions = _pipeServerOutput(serverProcess); + await _waitForPort('localhost', port); + } else { + serverUrl = Uri.parse(options.url!); + } + + stdout.writeln('2026 RC conformance URL: $serverUrl'); + stdout.writeln('Conformance package: ${options.conformancePackage}'); + stdout.writeln('Output: ${outputRoot.path}'); + stdout.writeln(''); + + final results = <_ScenarioResult>[]; + for (final scenario in scenarios) { + final result = await _runScenario( + scenario: scenario, + serverUrl: serverUrl, + outputRoot: outputRoot, + conformancePackage: options.conformancePackage, + timeout: options.timeout, + ); + results.add(result); + _printScenarioResult(result, expectedFailures); + } + + await _writeSummary(outputRoot, results, expectedFailures); + final unexpectedFailures = results + .where( + (result) => + !result.passed && !expectedFailures.contains(result.scenario), + ) + .toList(); + final unexpectedPasses = results + .where( + (result) => + result.passed && expectedFailures.contains(result.scenario), + ) + .toList(); + + stdout.writeln(''); + stdout.writeln( + 'Summary: ${results.where((result) => result.passed).length} passed, ' + '${results.where((result) => !result.passed).length} failed/timeout.', + ); + + if (unexpectedFailures.isNotEmpty) { + stdout.writeln('Unexpected failures:'); + for (final result in unexpectedFailures) { + stdout.writeln(' - ${result.scenario} (${result.status})'); + } + } + if (unexpectedPasses.isNotEmpty) { + stdout.writeln('Unexpected passes; remove these from expected failures:'); + for (final result in unexpectedPasses) { + stdout.writeln(' - ${result.scenario}'); + } + } + + exitCode = unexpectedFailures.isEmpty && unexpectedPasses.isEmpty ? 0 : 1; + } finally { + if (serverProcess != null) { + await _stopProcess(serverProcess); + for (final subscription in serverOutputSubscriptions) { + unawaited(subscription.cancel()); + } + } + } + + exit(exitCode); +} + +Future> _readExpectedFailures(String path) async { + final file = File(path); + if (!await file.exists()) { + return const {}; + } + + final entries = {}; + for (final line in await file.readAsLines()) { + final trimmed = line.trim(); + if (trimmed.isEmpty || trimmed.startsWith('#')) { + continue; + } + entries.add(trimmed); + } + return entries; +} + +Future _createOutputRoot(String? outputDir) async { + final root = outputDir == null + ? Directory( + '.dart_tool/conformance/2026_rc/' + '${DateTime.now().toUtc().toIso8601String().replaceAll(':', '-')}', + ) + : Directory(outputDir); + await root.create(recursive: true); + return root; +} + +Future _findFreePort() async { + final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final port = socket.port; + await socket.close(); + return port; +} + +List> _pipeServerOutput(Process process) { + // ignore: cancel_subscriptions + final stdoutSubscription = process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) => stdout.writeln('[server] $line')); + // ignore: cancel_subscriptions + final stderrSubscription = process.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) => stderr.writeln('[server] $line')); + return [stdoutSubscription, stderrSubscription]; +} + +Future _stopProcess(Process process) async { + process.kill(ProcessSignal.sigterm); + try { + await process.exitCode.timeout(const Duration(seconds: 3)); + return; + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + } + await process.exitCode.timeout( + const Duration(seconds: 3), + onTimeout: () => -1, + ); +} + +Future _waitForPort(String host, int port) async { + final deadline = DateTime.now().add(const Duration(seconds: 15)); + while (DateTime.now().isBefore(deadline)) { + try { + final socket = await Socket.connect( + host, + port, + timeout: const Duration(milliseconds: 300), + ); + await socket.close(); + return; + } catch (_) { + await Future.delayed(const Duration(milliseconds: 150)); + } + } + throw StateError('Timed out waiting for $host:$port'); +} + +Future<_ScenarioResult> _runScenario({ + required String scenario, + required Uri serverUrl, + required Directory outputRoot, + required String conformancePackage, + required Duration timeout, +}) async { + final outputDir = Directory('${outputRoot.path}/${_sanitize(scenario)}'); + await outputDir.create(recursive: true); + + final process = await Process.start( + 'npx', + [ + '-y', + conformancePackage, + 'server', + '--url', + serverUrl.toString(), + '--suite', + 'draft', + '--scenario', + scenario, + '--verbose', + '-o', + outputDir.path, + ], + workingDirectory: Directory.current.path, + ); + + final stdoutBuffer = StringBuffer(); + final stderrBuffer = StringBuffer(); + final stdoutDone = process.stdout + .transform(utf8.decoder) + .listen(stdoutBuffer.write) + .asFuture(); + final stderrDone = process.stderr + .transform(utf8.decoder) + .listen(stderrBuffer.write) + .asFuture(); + + try { + final code = await process.exitCode.timeout(timeout); + await Future.wait([stdoutDone, stderrDone]); + return _ScenarioResult( + scenario: scenario, + exitCode: code, + timedOut: false, + stdout: stdoutBuffer.toString(), + stderr: stderrBuffer.toString(), + ); + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + await process.exitCode; + return _ScenarioResult( + scenario: scenario, + exitCode: null, + timedOut: true, + stdout: stdoutBuffer.toString(), + stderr: stderrBuffer.toString(), + ); + } +} + +void _printScenarioResult( + _ScenarioResult result, + Set expectedFailures, +) { + final expected = expectedFailures.contains(result.scenario); + final marker = result.passed + ? expected + ? 'UNEXPECTED PASS' + : 'PASS' + : expected + ? 'EXPECTED ${result.status.toUpperCase()}' + : 'FAIL'; + stdout.writeln('${marker.padRight(18)} ${result.scenario}'); +} + +Future _writeSummary( + Directory outputRoot, + List<_ScenarioResult> results, + Set expectedFailures, +) async { + final summary = { + 'expectedFailures': expectedFailures.toList()..sort(), + 'results': [ + for (final result in results) + { + 'scenario': result.scenario, + 'status': result.status, + 'exitCode': result.exitCode, + 'expectedFailure': expectedFailures.contains(result.scenario), + }, + ], + }; + await File('${outputRoot.path}/summary.json').writeAsString( + const JsonEncoder.withIndent(' ').convert(summary), + ); +} + +String _sanitize(String value) { + return value.replaceAll(RegExp('[^A-Za-z0-9_.-]'), '_'); +} + +void _printUsage() { + stdout.writeln( + 'Usage: dart run test/conformance/run_2026_rc_server_conformance.dart ' + '[--url http://localhost:33125/mcp] [--scenario scenario-name] ' + '[--timeout-seconds 25]', + ); +} + +class _ScenarioResult { + final String scenario; + final int? exitCode; + final bool timedOut; + final String stdout; + final String stderr; + + const _ScenarioResult({ + required this.scenario, + required this.exitCode, + required this.timedOut, + required this.stdout, + required this.stderr, + }); + + bool get passed => !timedOut && exitCode == 0; + + String get status { + if (timedOut) { + return 'timeout'; + } + if (exitCode == 0) { + return 'pass'; + } + return 'exit-$exitCode'; + } +} + +class _Options { + final bool help; + final String? url; + final int? port; + final String? scenario; + final String? outputDir; + final String expectedFailuresPath; + final String conformancePackage; + final Duration timeout; + + const _Options({ + required this.help, + required this.url, + required this.port, + required this.scenario, + required this.outputDir, + required this.expectedFailuresPath, + required this.conformancePackage, + required this.timeout, + }); + + factory _Options.parse(List args) { + var help = false; + String? url; + int? port; + String? scenario; + String? outputDir; + var expectedFailuresPath = 'test/conformance/2026_rc_expected_failures.txt'; + var conformancePackage = _defaultConformancePackage; + var timeout = _defaultTimeout; + + for (var i = 0; i < args.length; i++) { + switch (args[i]) { + case '--help': + help = true; + case '--url': + if (i + 1 < args.length) { + url = args[++i]; + } + case '--port': + if (i + 1 < args.length) { + port = int.tryParse(args[++i]); + } + case '--scenario': + if (i + 1 < args.length) { + scenario = args[++i]; + } + case '--output-dir': + if (i + 1 < args.length) { + outputDir = args[++i]; + } + case '--expected-failures': + if (i + 1 < args.length) { + expectedFailuresPath = args[++i]; + } + case '--conformance-package': + if (i + 1 < args.length) { + conformancePackage = args[++i]; + } + case '--timeout-seconds': + if (i + 1 < args.length) { + final seconds = int.tryParse(args[++i]); + if (seconds != null && seconds > 0) { + timeout = Duration(seconds: seconds); + } + } + } + } + + return _Options( + help: help, + url: url, + port: port, + scenario: scenario, + outputDir: outputDir, + expectedFailuresPath: expectedFailuresPath, + conformancePackage: conformancePackage, + timeout: timeout, + ); + } +} diff --git a/test/elicitation_test.dart b/test/elicitation_test.dart index 7595c5cc..1787cb41 100644 --- a/test/elicitation_test.dart +++ b/test/elicitation_test.dart @@ -1290,6 +1290,7 @@ void main() { 'content': { 'text': 'value', 'count': 3.0, + 'ratio': 0.5, 'confirmed': true, 'selections': ['a', 'b'], }, @@ -1297,6 +1298,7 @@ void main() { }); expect(parsed.toJson()['content'], containsPair('count', 3)); + expect(parsed.toJson()['content'], containsPair('ratio', 0.5)); expect(parsed.toJson()['_meta'], containsPair('trace', 'abc')); expect( @@ -1315,15 +1317,6 @@ void main() { }), throwsA(isA()), ); - expect( - () => ElicitResult.fromJson({ - 'action': 'accept', - 'content': { - 'ratio': 0.5, - }, - }), - throwsA(isA()), - ); expect( () => ElicitResult.fromJson({ 'action': 'decline', @@ -1343,13 +1336,13 @@ void main() { throwsA(isA()), ); expect( - () => const ElicitResult( + const ElicitResult( action: 'accept', content: { 'ratio': 0.5, }, - ).toJson(), - throwsA(isA()), + ).toJson()['content'], + containsPair('ratio', 0.5), ); expect( const ElicitResult( diff --git a/test/interop/test_dart_server.dart b/test/interop/test_dart_server.dart index 8eab4e08..4366cb70 100644 --- a/test/interop/test_dart_server.dart +++ b/test/interop/test_dart_server.dart @@ -536,6 +536,7 @@ void main(List args) async { // Parse args var transportType = 'stdio'; + var enableJsonResponse = false; int? port; for (var i = 0; i < args.length; i++) { @@ -545,6 +546,9 @@ void main(List args) async { if (args[i] == '--port' && i + 1 < args.length) { port = int.tryParse(args[i + 1]); } + if (args[i] == '--json-response') { + enableJsonResponse = true; + } } // Start Server @@ -560,6 +564,7 @@ void main(List args) async { final transport = StreamableMcpServer( serverFactory: (sessionId) => createServer(), port: port, + enableJsonResponse: enableJsonResponse, ); await transport.start(); // Keep alive? StreamableMcpServer listens on http diff --git a/test/mcp_2025_11_25_test.dart b/test/mcp_2025_11_25_test.dart index f59dbcad..3859ccd8 100644 --- a/test/mcp_2025_11_25_test.dart +++ b/test/mcp_2025_11_25_test.dart @@ -1730,22 +1730,22 @@ void main() { throwsA(isA()), ); expect( - () => ElicitResult.fromJson({ + ElicitResult.fromJson({ 'action': 'accept', 'content': { 'fractional': 1.5, }, - }), - throwsA(isA()), + }).content, + containsPair('fractional', 1.5), ); expect( - () => const ElicitResult( + const ElicitResult( action: 'accept', content: { 'fractional': 1.5, }, - ).toJson(), - throwsA(isA()), + ).toJson()['content'], + containsPair('fractional', 1.5), ); expect( () => URLElicitationRequiredErrorData.fromJson({ diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index cf85fe73..dbea0fe1 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -333,7 +333,12 @@ void main() { supportedProtocolVersionsWithDraft, contains(draftProtocolVersion2026_07_28), ); + expect( + supportedProtocolVersionsWithDraft, + contains(draftProtocolVersion2026V1), + ); expect(isStatelessProtocolVersion(draftProtocolVersion2026_07_28), true); + expect(isStatelessProtocolVersion(draftProtocolVersion2026V1), true); expect(isStatelessProtocolVersion(latestProtocolVersion), false); }); @@ -680,11 +685,11 @@ void main() { throwsA(isA()), ); expect( - () => ElicitResult.fromJson({ + ElicitResult.fromJson({ 'action': 'accept', 'content': {'score': 1.5}, - }), - throwsA(isA()), + }).content, + containsPair('score', 1.5), ); expect( () => const ElicitResult( @@ -694,11 +699,11 @@ void main() { throwsA(isA()), ); expect( - () => const ElicitResult( + const ElicitResult( action: 'accept', content: {'score': 1.5}, - ).toJson(), - throwsA(isA()), + ).toJson()['content'], + containsPair('score', 1.5), ); }); @@ -3399,6 +3404,92 @@ void main() { expect(response.result['requestState'], 'retry-state'); }); + test('stateless registerTool receives input responses and request state', + () async { + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + ); + server.registerTool( + 'needs_input', + callback: (args, extra) { + final response = extra.inputResponses?['profile']; + if (response == null) { + expect(extra.requestState, isNull); + return InputRequiredResult( + inputRequests: { + 'profile': InputRequest.elicit( + ElicitRequest.form( + message: 'Enter profile details', + requestedSchema: JsonSchema.object( + properties: {'name': JsonSchema.string()}, + required: ['name'], + ), + ), + ), + }, + requestState: 'state-1', + ); + } + + expect(extra.requestState, 'state-1'); + final responseJson = response.toJson(); + final content = responseJson['content'] as Map; + return CallToolResult( + content: [TextContent(text: 'Hello ${content['name']}')], + ); + }, + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcCallToolRequest( + id: 'call-1', + params: const CallToolRequest(name: 'needs_input').toJson(), + meta: _clientMeta( + clientCapabilities: const ClientCapabilities( + elicitation: ClientElicitation.formOnly(), + ), + ), + ), + ); + await _pump(); + + final inputRequired = transport.sentMessages.single as JsonRpcResponse; + expect(inputRequired.result['resultType'], resultTypeInputRequired); + expect(inputRequired.result['requestState'], 'state-1'); + expect(inputRequired.result['inputRequests'], contains('profile')); + + transport.sentMessages.clear(); + transport.receive( + JsonRpcCallToolRequest( + id: 'call-2', + params: CallToolRequest( + name: 'needs_input', + inputResponses: { + 'profile': InputResponse.fromResult( + const ElicitResult( + action: 'accept', + content: {'name': 'Alice'}, + ), + ), + }, + requestState: 'state-1', + ).toJson(), + meta: _clientMeta( + clientCapabilities: const ClientCapabilities( + elicitation: ClientElicitation.formOnly(), + ), + ), + ), + ); + await _pump(); + + final completed = transport.sentMessages.single as JsonRpcResponse; + expect(completed.result['resultType'], resultTypeComplete); + expect(completed.result['content'][0]['text'], 'Hello Alice'); + }); + test('stateless input required requests require client capabilities', () async { final server = Server( @@ -4160,7 +4251,7 @@ void main() { .having( (error) => error.code, 'code', - ErrorCode.invalidRequest.value, + ErrorCode.invalidParams.value, ) .having( (error) => error.data, diff --git a/test/server/streamable_mcp_server_test.dart b/test/server/streamable_mcp_server_test.dart index 60f32ec6..369375eb 100644 --- a/test/server/streamable_mcp_server_test.dart +++ b/test/server/streamable_mcp_server_test.dart @@ -362,6 +362,248 @@ void main() { expect(messages.single['result']['tools'][0]['name'], 'echo'); }); + test('can return JSON responses for stateless requests', () async { + await server.stop(); + server = StreamableMcpServer( + serverFactory: (sessionId) { + final mcpServer = McpServer( + const Implementation(name: 'JsonStatelessServer', version: '1.0.0'), + ); + mcpServer.registerTool( + 'echo', + inputSchema: const ToolInputSchema(), + callback: (args, extra) async => const CallToolResult(content: []), + ); + return mcpServer; + }, + host: host, + port: port, + enableJsonResponse: true, + ); + await server.start(); + + final response = await http.post( + Uri.parse(baseUrl), + body: jsonEncode( + JsonRpcListToolsRequest(id: 3, meta: statelessMeta()).toJson(), + ), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.toolsList, + }, + ); + + expect(response.statusCode, HttpStatus.ok); + expect(response.headers['content-type'], startsWith('application/json')); + final message = jsonDecode(response.body) as Map; + expect(message['id'], 3); + expect(message['result']['tools'][0]['name'], 'echo'); + }); + + test('can return JSON errors for stateless request handlers', () async { + await server.stop(); + server = StreamableMcpServer( + serverFactory: (sessionId) { + final mcpServer = McpServer( + const Implementation(name: 'JsonStatelessServer', version: '1.0.0'), + ); + mcpServer.registerTool( + 'echo', + inputSchema: const ToolInputSchema(), + callback: (args, extra) async => const CallToolResult(content: []), + ); + return mcpServer; + }, + host: host, + port: port, + enableJsonResponse: true, + ); + await server.start(); + + final response = await http.post( + Uri.parse(baseUrl), + body: jsonEncode( + JsonRpcCallToolRequest( + id: 4, + params: const { + 'name': 'missing_tool', + 'arguments': {}, + }, + meta: statelessMeta(), + ).toJson(), + ), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.toolsCall, + 'Mcp-Name': 'missing_tool', + }, + ); + + expect(response.statusCode, HttpStatus.badRequest); + expect(response.headers['content-type'], startsWith('application/json')); + final message = jsonDecode(response.body) as Map; + expect(message['id'], 4); + expect(message['error']['code'], ErrorCode.invalidParams.value); + expect(message['error']['message'], contains('missing_tool')); + }); + + test('rejects unsupported stateless version before session routing', + () async { + await server.stop(); + server = StreamableMcpServer( + serverFactory: (sessionId) { + return McpServer( + const Implementation(name: 'VersionServer', version: '1.0.0'), + ); + }, + host: host, + port: port, + enableJsonResponse: true, + ); + await server.start(); + + final meta = statelessMeta()..[McpMetaKey.protocolVersion] = 'v999.0.0'; + final response = await http.post( + Uri.parse(baseUrl), + body: jsonEncode({ + 'jsonrpc': jsonRpcVersion, + 'id': 'discover-unsupported', + 'method': Method.serverDiscover, + 'params': {'_meta': meta}, + }), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'MCP-Protocol-Version': 'v999.0.0', + 'Mcp-Method': Method.serverDiscover, + }, + ); + + expect(response.statusCode, HttpStatus.badRequest); + final message = jsonDecode(response.body) as Map; + expect( + message['error']['code'], + ErrorCode.unsupportedProtocolVersion.value, + ); + expect(message['id'], 'discover-unsupported'); + expect(message['error']['data']['requested'], 'v999.0.0'); + }); + + test('preserves id for malformed stateless server discover metadata', + () async { + await server.stop(); + server = StreamableMcpServer( + serverFactory: (sessionId) { + return McpServer( + const Implementation(name: 'DiscoverServer', version: '1.0.0'), + ); + }, + host: host, + port: port, + enableJsonResponse: true, + ); + await server.start(); + + Future> postDiscover( + Map body, + ) async { + final response = await http.post( + Uri.parse(baseUrl), + body: jsonEncode(body), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'MCP-Protocol-Version': draftProtocolVersion2026V1, + 'Mcp-Method': Method.serverDiscover, + }, + ); + + expect(response.statusCode, HttpStatus.badRequest); + return jsonDecode(response.body) as Map; + } + + var message = await postDiscover({ + 'jsonrpc': jsonRpcVersion, + 'id': 101, + 'method': Method.serverDiscover, + 'params': {}, + }); + expect(message['id'], 101); + expect(message['error']['code'], ErrorCode.invalidParams.value); + + message = await postDiscover({ + 'jsonrpc': jsonRpcVersion, + 'id': 102, + 'method': Method.serverDiscover, + 'params': {'_meta': {}}, + }); + expect(message['id'], 102); + expect(message['error']['code'], ErrorCode.invalidParams.value); + expect( + message['error']['message'], + contains(McpMetaKey.protocolVersion), + ); + }); + + test('rejects removed stateless request methods before legacy parsing', + () async { + await server.stop(); + server = StreamableMcpServer( + serverFactory: (sessionId) { + return McpServer( + const Implementation(name: 'RemovedMethodsServer', version: '1.0'), + ); + }, + host: host, + port: port, + enableJsonResponse: true, + ); + await server.start(); + + final methods = [ + Method.initialize, + Method.ping, + Method.loggingSetLevel, + Method.resourcesSubscribe, + Method.resourcesUnsubscribe, + ]; + for (var i = 0; i < methods.length; i++) { + final method = methods[i]; + final id = 200 + i; + final response = await http.post( + Uri.parse(baseUrl), + body: jsonEncode({ + 'jsonrpc': jsonRpcVersion, + 'id': id, + 'method': method, + 'params': { + '_meta': statelessMeta(), + if (method == Method.loggingSetLevel) 'level': 'info', + if (method == Method.resourcesSubscribe || + method == Method.resourcesUnsubscribe) + 'uri': 'file:///tmp/example.txt', + }, + }), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'MCP-Protocol-Version': draftProtocolVersion2026V1, + 'Mcp-Method': method, + }, + ); + + expect(response.statusCode, HttpStatus.notFound); + final message = jsonDecode(response.body) as Map; + expect(message['id'], id); + expect(message['error']['code'], ErrorCode.methodNotFound.value); + expect(message['error']['message'], contains(method)); + } + }); + test('handles 2026 stateless request with unknown session ID', () async { await server.stop(); server = StreamableMcpServer( diff --git a/test/shared/json_schema_from_json_test.dart b/test/shared/json_schema_from_json_test.dart index 0311e777..153a9d71 100644 --- a/test/shared/json_schema_from_json_test.dart +++ b/test/shared/json_schema_from_json_test.dart @@ -308,6 +308,54 @@ void main() { }); }); + test('preserves object-level JSON Schema extension keywords', () { + final json = { + r'$schema': 'https://json-schema.org/draft/2020-12/schema', + 'type': 'object', + r'$defs': { + 'address': { + r'$anchor': 'addressDef', + 'type': 'object', + }, + }, + 'properties': { + 'contactMethod': { + 'type': 'string', + 'enum': ['phone', 'email'], + }, + }, + 'allOf': [ + { + 'anyOf': [ + { + 'required': ['phone'], + }, + { + 'required': ['email'], + }, + ], + }, + ], + 'if': { + 'properties': { + 'contactMethod': {'const': 'phone'}, + }, + }, + 'then': { + 'required': ['phone'], + }, + 'else': { + 'required': ['email'], + }, + 'additionalProperties': false, + }; + + final schema = JsonSchema.fromJson(json); + + expect(schema, isA()); + expect(schema.toJson(), json); + }); + test('parses object schema with additionalProperties as schema', () { final json = { 'type': 'object', From 3f52eaf919e6c27aa0126fc2f87a614f98327a18 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Tue, 2 Jun 2026 22:00:04 -0400 Subject: [PATCH 40/68] Align header conformance with 2026 RC --- CHANGELOG.md | 5 ++--- lib/src/client/client.dart | 2 +- lib/src/client/streamable_https.dart | 4 ++++ lib/src/server/mcp_server.dart | 5 +++-- lib/src/shared/json_schema/json_schema.dart | 5 ++--- .../mcp_dart_cli/lib/src/conformance_runner.dart | 14 +++++++------- .../test/src/conformance_command_test.dart | 2 +- test/server/mcp_server_test.dart | 14 ++++---------- 8 files changed, 24 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 906138be..aa38f139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,9 +38,8 @@ - Synced nested 2026 `x-mcp-header` mappings into Streamable HTTP transports using JSON Pointer selectors for nested tool arguments. - Limited 2026 Streamable HTTP `x-mcp-header` mirroring to string, boolean, - and JavaScript-safe integer argument values; fractional numbers and unsafe - integers are omitted, and `number` schemas are rejected from advertised - header mappings. + finite number, and JavaScript-safe integer argument values; unsafe integers + and non-finite numbers are omitted. - Returned HTTP 404 with JSON-RPC `Method not found` for unsupported or removed 2026 stateless Streamable HTTP request methods before opening response streams. diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index eb4f79d2..f793745c 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -1596,7 +1596,7 @@ class McpClient extends Protocol { if (!_isToolParameterHeaderPrimitive(entry.value)) { return 'parameter "$parameterName" uses x-mcp-header on a schema that ' - 'is not string, integer, or boolean'; + 'is not string, number, integer, or boolean'; } mappings[_toolParameterHeaderSelector(parameterPath)] = rawHeader; diff --git a/lib/src/client/streamable_https.dart b/lib/src/client/streamable_https.dart index cfc6ca13..0065070f 100644 --- a/lib/src/client/streamable_https.dart +++ b/lib/src/client/streamable_https.dart @@ -893,6 +893,10 @@ class StreamableHttpClientTransport if (!value.isFinite) { return null; } + if (value == value.truncateToDouble() && + (value < _minSafeHeaderInteger || value > _maxSafeHeaderInteger)) { + return null; + } return value.toString(); } diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index 302c6ff5..751d43cd 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -1302,8 +1302,8 @@ class McpServer { if (!_isToolParameterHeaderPrimitive(entry.value)) { return 'Ignoring x-mcp-header mapping for tool "$toolName" parameter ' - '"$parameterName": only string, integer, and boolean schemas can ' - 'be mirrored.'; + '"$parameterName": only string, number, integer, and boolean ' + 'schemas can be mirrored.'; } mappings[_toolParameterHeaderSelector(parameterPath)] = rawHeader; @@ -1338,6 +1338,7 @@ class McpServer { bool _isToolParameterHeaderPrimitive(JsonSchema schema) { return schema is JsonString || + schema is JsonNumber || schema is JsonInteger || schema is JsonBoolean; } diff --git a/lib/src/shared/json_schema/json_schema.dart b/lib/src/shared/json_schema/json_schema.dart index 29515e59..258298b8 100644 --- a/lib/src/shared/json_schema/json_schema.dart +++ b/lib/src/shared/json_schema/json_schema.dart @@ -597,9 +597,8 @@ class JsonNumber extends JsonSchema { /// MCP `x-mcp-header` extension metadata. /// - /// This is preserved for schema round-tripping. MCP 2026 stateless - /// Streamable HTTP header mirroring only accepts string, integer, and boolean - /// schemas, so number schemas carrying this metadata are not mirrored. + /// MCP 2026 stateless Streamable HTTP clients mirror finite number argument + /// values into `Mcp-Param-*` headers when this metadata is present. final String? mcpHeader; const JsonNumber({ diff --git a/packages/mcp_dart_cli/lib/src/conformance_runner.dart b/packages/mcp_dart_cli/lib/src/conformance_runner.dart index fff05f62..7658bf6c 100644 --- a/packages/mcp_dart_cli/lib/src/conformance_runner.dart +++ b/packages/mcp_dart_cli/lib/src/conformance_runner.dart @@ -365,10 +365,10 @@ class ConformanceRunner { ), _ConformanceCase( suite: _specSuite, - name: 'stateless-http.omits-invalid-numeric-parameter-headers', + name: 'stateless-http.omits-unsafe-numeric-parameter-headers', description: - 'Omits fractional and unsafe integer x-mcp-header values while preserving safe integers.', - check: _omitsInvalidNumericParameterHeaders, + 'Mirrors finite numeric x-mcp-header values while omitting unsafe integers.', + check: _omitsUnsafeNumericParameterHeaders, ), _ConformanceCase( suite: _specSuite, @@ -1418,7 +1418,7 @@ Future _statelessRequestsRequireCompleteRequestMeta() async { _expectSingleError( transport.sentMessages, id: scenario.id, - code: ErrorCode.invalidRequest.value, + code: ErrorCode.invalidParams.value, messageContains: scenario.missing, ); transport.sentMessages.clear(); @@ -2411,7 +2411,7 @@ Future _validatesStatelessHttpParameterHeaders() async { } } -Future _omitsInvalidNumericParameterHeaders() async { +Future _omitsUnsafeNumericParameterHeaders() async { final httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final receivedHeaders = Completer>(); final responseMessage = Completer(); @@ -2485,9 +2485,9 @@ Future _omitsInvalidNumericParameterHeaders() async { 'Expected safe integer header 42, got ${headers['limit']}.', ); } - if (headers['ratio'] != null) { + if (headers['ratio'] != '1.5') { throw StateError( - 'Expected fractional number header to be omitted, got ' + 'Expected fractional number header 1.5, got ' "${headers['ratio']}.", ); } diff --git a/packages/mcp_dart_cli/test/src/conformance_command_test.dart b/packages/mcp_dart_cli/test/src/conformance_command_test.dart index 4faf8729..aaa821e0 100644 --- a/packages/mcp_dart_cli/test/src/conformance_command_test.dart +++ b/packages/mcp_dart_cli/test/src/conformance_command_test.dart @@ -66,7 +66,7 @@ void main() { 'stateless-http.rejects-batch-payloads', 'stateless-http.task-requests-require-name-header', 'stateless-http.validates-parameter-headers', - 'stateless-http.omits-invalid-numeric-parameter-headers', + 'stateless-http.omits-unsafe-numeric-parameter-headers', 'stateless-http.encodes-parameter-header-values', 'stateless-http.accepts-response-posts', 'stateless-http.task-subscription-requires-client-capability', diff --git a/test/server/mcp_server_test.dart b/test/server/mcp_server_test.dart index a11519f5..fbbb9896 100644 --- a/test/server/mcp_server_test.dart +++ b/test/server/mcp_server_test.dart @@ -139,6 +139,7 @@ void main() { properties: { 'dryRun': JsonBoolean(mcpHeader: 'Dry-Run'), 'region': JsonString(mcpHeader: 'Region'), + 'ratio': JsonNumber(mcpHeader: 'Ratio'), 'auth': JsonObject( properties: { 'tenant': JsonString(mcpHeader: 'Tenant'), @@ -159,6 +160,7 @@ void main() { 'header-tool': { 'dryRun': 'Dry-Run', 'region': 'Region', + 'ratio': 'Ratio', '/auth/tenant': 'Tenant', }, }, @@ -177,6 +179,7 @@ void main() { final properties = inputSchema['properties'] as Map; final authProperties = (properties['auth'] as Map)['properties'] as Map; expect((properties['region'] as Map)['x-mcp-header'], 'Region'); + expect((properties['ratio'] as Map)['x-mcp-header'], 'Ratio'); expect((authProperties['tenant'] as Map)['x-mcp-header'], 'Tenant'); }); @@ -242,15 +245,6 @@ void main() { ), callback: (args, extra) async => const CallToolResult(content: []), ); - server.registerTool( - 'number-header-tool', - inputSchema: const ToolInputSchema( - properties: { - 'value': JsonNumber(mcpHeader: 'Value'), - }, - ), - callback: (args, extra) async => const CallToolResult(content: []), - ); server.registerTool( 'duplicate-header-tool', inputSchema: const ToolInputSchema( @@ -285,7 +279,7 @@ void main() { final response = transport.sentMessages.last as JsonRpcResponse; final tools = response.result['tools'] as List; - expect(tools, hasLength(6)); + expect(tools, hasLength(5)); for (final tool in tools.cast()) { expect(_containsMcpHeader(tool['inputSchema']), isFalse); } From 44333da6b5276dd8a6869339be1b29479f41e057 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Tue, 2 Jun 2026 22:02:21 -0400 Subject: [PATCH 41/68] Use Node 24 for conformance CI --- .github/workflows/test_cli.yml | 2 +- .github/workflows/test_core.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test_cli.yml b/.github/workflows/test_cli.yml index 4300cf84..3433123d 100644 --- a/.github/workflows/test_cli.yml +++ b/.github/workflows/test_cli.yml @@ -44,7 +44,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' - name: Set up Python uses: actions/setup-python@v6 diff --git a/.github/workflows/test_core.yml b/.github/workflows/test_core.yml index 9728e764..af71ddd3 100644 --- a/.github/workflows/test_core.yml +++ b/.github/workflows/test_core.yml @@ -22,7 +22,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' - name: Install TS Interop dependencies working-directory: test/interop/ts From 5be81d6eb50ea5c042f1d97c5afbdd6765427594 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Wed, 3 Jun 2026 09:05:53 -0400 Subject: [PATCH 42/68] Add typed protocol profile opt-in --- CHANGELOG.md | 8 +- README.md | 27 ++- doc/client-guide.md | 20 +++ doc/mcp-2026-rc.md | 93 ++++++++++ doc/server-guide.md | 22 +++ lib/src/client/client.dart | 59 ++++-- lib/src/server/server.dart | 42 ++++- lib/src/types/json_rpc.dart | 65 +++++++ test/client/client_test.dart | 2 +- test/client/streamable_https_test.dart | 1 - test/conformance/mcp_2026_rc_server.dart | 6 +- test/interop/test_dart_server.dart | 3 +- test/mcp_2026_07_28_test.dart | 189 +++++++++++++++++--- test/server/mcp_server_test.dart | 25 +++ test/server/output_validation_test.dart | 27 +++ test/server/streamable_https_test.dart | 3 + test/server/streamable_mcp_server_test.dart | 16 ++ 17 files changed, 561 insertions(+), 47 deletions(-) create mode 100644 doc/mcp-2026-rc.md diff --git a/CHANGELOG.md b/CHANGELOG.md index aa38f139..7cc51a45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,11 @@ - Added server-side `server/discover` handling before legacy initialization and initial stateless request validation for per-request protocol version, client identity, and client capability metadata. -- Added opt-in client discovery via `McpClientOptions(useServerDiscover: true)` - while keeping the stable `initialize` flow as the default until the 2026 - stateless transport and MRTR implementation is complete. +- Added explicit protocol profiles via + `McpClientOptions(protocol: McpProtocol.preview2026)` and + `McpServerOptions(protocol: McpProtocol.preview2026)` while keeping the stable + `initialize` flow as the default. The lower-level `protocolVersion` and + `useServerDiscover` options remain available for interoperability testing. - Added 2026 cacheable result support for `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, and `resources/read`, including stateless server defaults for `resultType`, `ttlMs`, and `cacheScope` while diff --git a/README.md b/README.md index cbf64d12..bdf9aefd 100644 --- a/README.md +++ b/README.md @@ -87,10 +87,34 @@ Use this comparison as a starting point, not a permanent verdict: both packages ## Model Context Protocol Version -The current version of the protocol is `2025-11-25`. This library is designed to be compatible with this version, and any future updates will be made to ensure continued compatibility. +The default protocol profile is MCP `2025-11-25`. This library is designed to +be compatible with this version, and any future updates will preserve an +explicit stable profile. It's also backward compatible with previous versions including `2025-06-18`, `2025-03-26`, `2024-11-05`, and `2024-10-07`. +MCP `2026-07-28` RC support is available behind an explicit preview profile: + +```dart +final client = McpClient( + const Implementation(name: 'my-client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), +); + +final server = McpServer( + const Implementation(name: 'my-server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), +); +``` + +Use the preview profile while the spec is still an RC. See the +[MCP 2026 RC transition guide](https://github.com/leehack/mcp_dart/blob/main/doc/mcp-2026-rc.md) +for opt-in behavior, fallback rules, and 2026-only APIs. + ## Documentation ### Getting Started @@ -112,6 +136,7 @@ It's also backward compatible with previous versions including `2025-06-18`, `20 - ๐Ÿงช **[SDK Interoperability Matrix](https://github.com/leehack/mcp_dart/blob/main/doc/interoperability.md)** - Verified Dart/TypeScript and documented cross-SDK scenarios - โœ… **[MCP 2025-11-25 Spec Coverage Matrix](https://github.com/leehack/mcp_dart/blob/main/doc/spec-coverage-2025-11-25.md)** - Auditable coverage map with CLI conformance cases and known gaps +- ๐Ÿงญ **[MCP 2026 RC Transition Guide](https://github.com/leehack/mcp_dart/blob/main/doc/mcp-2026-rc.md)** - Opt-in profile, fallback behavior, and draft-only APIs - ๐Ÿ”’ **[Transport Security Recipes](https://github.com/leehack/mcp_dart/blob/main/doc/transports.md#dns-rebinding-protection)** - Host/Origin allowlists, OAuth layering, and compatibility-toggle trade-offs - ๐Ÿ“ฑ **[Flutter Recipes](https://github.com/leehack/mcp_dart/blob/main/doc/flutter-recipes.md)** - Flutter Web, mobile, and desktop host/client guidance - ๐Ÿ” **[Migration Cookbooks](https://github.com/leehack/mcp_dart/blob/main/doc/migration-cookbooks.md)** - TypeScript SDK, `dart_mcp`, stdio-to-HTTP, and version migration paths diff --git a/doc/client-guide.md b/doc/client-guide.md index a9d4fed9..c13e6b2e 100644 --- a/doc/client-guide.md +++ b/doc/client-guide.md @@ -60,6 +60,26 @@ final client = McpClient( // Set up handlers after client creation if needed ``` +### Protocol Profile + +Clients use the stable MCP `2025-11-25` profile by default. Opt into MCP +`2026-07-28` RC behavior with the preview profile: + +```dart +final client = McpClient( + const Implementation(name: 'my-client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), +); +``` + +`McpClientOptions(protocol: McpProtocol.preview2026)` enables +`server/discover` and stateless request metadata, then falls back to the stable +`initialize` flow when discovery is not available. Use +`McpClientOptions(protocol: McpProtocol.require2026)` when fallback should be +treated as an error. + ## Client Capabilities Declare what your client supports: diff --git a/doc/mcp-2026-rc.md b/doc/mcp-2026-rc.md new file mode 100644 index 00000000..58ea430b --- /dev/null +++ b/doc/mcp-2026-rc.md @@ -0,0 +1,93 @@ +# MCP 2026 RC Transition Guide + +`mcp_dart` defaults to the latest stable MCP specification, currently +`2025-11-25`. MCP `2026-07-28` RC support is available through explicit +protocol profiles so applications can adopt the draft without changing stable +deployments. + +## Client opt-in + +Use the preview profile when you want the client to prefer MCP `2026-07-28` RC +and fall back to stable MCP servers when discovery is unavailable: + +```dart +final client = McpClient( + const Implementation(name: 'my-client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), +); +``` + +`McpClientOptions(protocol: McpProtocol.preview2026)` enables +`server/discover`, sends the 2026 stateless request metadata, and falls back to +the legacy `initialize` flow when the peer looks like a stable-only MCP server. + +Use the strict profile for conformance tests or deployments where fallback is +not acceptable: + +```dart +final client = McpClient( + const Implementation(name: 'my-client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.require2026, + ), +); +``` + +## Server opt-in + +Use the server preview profile to advertise and accept MCP `2026-07-28` RC +stateless requests: + +```dart +final server = McpServer( + const Implementation(name: 'my-server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), +); +``` + +`McpServerOptions()` remains stable by default and does not advertise draft +stateless protocol versions. + +## Profile summary + +| Profile | Default? | Client behavior | Server behavior | +| ------- | -------- | --------------- | --------------- | +| `McpProtocol.stable` | Yes | Uses stable `initialize` | Advertises stable protocol versions | +| `McpProtocol.preview2026` | No | Tries `server/discover`, then falls back to `initialize` | Advertises stable and 2026 RC protocol versions | +| `McpProtocol.require2026` | No | Requires 2026 RC discovery | Advertises only stateless 2026 RC protocol versions | + +## Low-level overrides + +The existing low-level options remain available for advanced callers: + +```dart +final client = McpClient( + const Implementation(name: 'my-client', version: '1.0.0'), + options: const McpClientOptions( + protocolVersion: draftProtocolVersion2026_07_28, + useServerDiscover: true, + ), +); +``` + +Prefer the `protocol` profile unless you need to target a specific protocol +version for tests or interoperability debugging. + +## 2026-only API areas + +The following features are MCP `2026-07-28` RC behavior and should be used only +after opting into a 2026 profile: + +- `server/discover` negotiation and stateless per-request metadata. +- `subscriptions/listen` stateless notification streams. +- Multi-result tool/resource/prompt flows such as `input_required`. +- MCP Tasks extension flows using `io.modelcontextprotocol/tasks`. +- Non-object `structuredContent` values and broader tool `outputSchema` shapes. +- Stateless result metadata such as `resultType`, `ttlMs`, and `cacheScope`. + +The RC API surface may still change before the official spec release. Keep +applications on the stable profile unless they specifically need RC behavior. diff --git a/doc/server-guide.md b/doc/server-guide.md index dd88d84a..4f97744b 100644 --- a/doc/server-guide.md +++ b/doc/server-guide.md @@ -63,6 +63,28 @@ final server = McpServer( ); ``` +### Protocol Profile + +Servers use the stable MCP `2025-11-25` profile by default. Opt into MCP +`2026-07-28` RC behavior with the preview profile: + +```dart +final server = McpServer( + const Implementation(name: 'my-server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), +); +``` + +`McpServerOptions(protocol: McpProtocol.preview2026)` advertises and accepts +2026 RC stateless protocol versions, including `server/discover`. Use +`McpServerOptions(protocol: McpProtocol.require2026)` when the server should +reject stable initialization. + ## Server Capabilities The server automatically advertises its capabilities based on what you register: diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index f793745c..863ede6b 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -15,23 +15,62 @@ class McpClientOptions extends ProtocolOptions { /// Capabilities to advertise as being supported by this client. final ClientCapabilities? capabilities; - /// Preferred protocol version for `server/discover` negotiation. - final String protocolVersion; + /// High-level protocol compatibility profile. + /// + /// Defaults to [McpProtocol.stable], which uses MCP 2025-11-25 behavior. + /// Set this to [McpProtocol.preview2026] to opt into MCP 2026-07-28 RC + /// negotiation with stable fallback. + final McpProtocol protocol; + + final String? _protocolVersion; + + /// Preferred protocol version for negotiation. + /// + /// When omitted, this is derived from [protocol]. Passing this explicitly is + /// a low-level override; most callers should prefer [protocol]. + String get protocolVersion { + final protocolVersion = _protocolVersion; + if (protocolVersion != null) { + return protocolVersion; + } + if (protocol == McpProtocol.stable && _useServerDiscover == true) { + return latestDraftProtocolVersion; + } + return protocol.preferredProtocolVersion; + } + + final bool? _useServerDiscover; /// Whether [McpClient.connect] should probe with `server/discover` first. - final bool useServerDiscover; + /// + /// When omitted, this is derived from [protocol]. Stable clients use the + /// legacy `initialize` flow by default; 2026 preview clients probe with + /// `server/discover`. + bool get useServerDiscover => + _useServerDiscover ?? protocol.useServerDiscoverByDefault; /// Whether a failed `server/discover` probe should fall back to the legacy /// `initialize` handshake when the peer looks like a pre-discovery server. - final bool allowLegacyInitializationFallback; + final bool? _allowLegacyInitializationFallback; + + /// Whether a failed `server/discover` probe should fall back to `initialize`. + /// + /// When omitted, this is derived from [protocol]. [McpProtocol.require2026] + /// disables fallback. + bool get allowLegacyInitializationFallback => + _allowLegacyInitializationFallback ?? + protocol.allowLegacyInitializationFallbackByDefault; const McpClientOptions({ super.enforceStrictCapabilities, this.capabilities, - this.protocolVersion = latestDraftProtocolVersion, - this.useServerDiscover = true, - this.allowLegacyInitializationFallback = true, - }); + this.protocol = McpProtocol.stable, + String? protocolVersion, + bool? useServerDiscover, + bool? allowLegacyInitializationFallback, + }) : _protocolVersion = protocolVersion, + _useServerDiscover = useServerDiscover, + _allowLegacyInitializationFallback = allowLegacyInitializationFallback; } /// Deprecated alias for [McpClientOptions]. @@ -191,8 +230,8 @@ class McpClient extends Protocol { McpClient(this._clientInfo, {McpClientOptions? options}) : _capabilities = options?.capabilities ?? const ClientCapabilities(), _preferredProtocolVersion = - options?.protocolVersion ?? latestDraftProtocolVersion, - _useServerDiscover = options?.useServerDiscover ?? true, + options?.protocolVersion ?? latestProtocolVersion, + _useServerDiscover = options?.useServerDiscover ?? false, _allowLegacyInitializationFallback = options?.allowLegacyInitializationFallback ?? true, super(options) { diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index b44fa6de..ab42a824 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -23,6 +23,17 @@ class McpServerOptions extends ProtocolOptions { /// Optional instructions describing how to use the server and its features. final String? instructions; + /// High-level protocol compatibility profile. + /// + /// Defaults to [McpProtocol.stable], which advertises stable MCP versions and + /// keeps MCP 2026 RC stateless behavior disabled unless explicitly requested. + /// Set this to [McpProtocol.preview2026] to enable draft-only stateless + /// methods such as `server/discover`. + final McpProtocol protocol; + + /// Protocol versions this server advertises and accepts for this profile. + List get supportedVersions => protocol.supportedVersions; + const McpServerOptions({ super.enforceStrictCapabilities, super.taskStore, @@ -31,6 +42,7 @@ class McpServerOptions extends ProtocolOptions { super.maxTaskQueueSize, this.capabilities, this.instructions, + this.protocol = McpProtocol.stable, }); } @@ -53,6 +65,7 @@ class Server extends Protocol { ServerCapabilities _capabilities; final String? _instructions; final Implementation _serverInfo; + final List _supportedVersions; /// Map of session IDs to their configured logging level. final Map _loggingLevels = {}; @@ -97,6 +110,8 @@ class Server extends Protocol { Server(this._serverInfo, {McpServerOptions? options}) : _capabilities = options?.capabilities ?? const ServerCapabilities(), _instructions = options?.instructions, + _supportedVersions = + options?.supportedVersions ?? McpProtocol.stable.supportedVersions, super(options) { setRequestHandler( Method.serverDiscover, @@ -160,7 +175,7 @@ class Server extends Protocol { ErrorCode.unsupportedProtocolVersion.value, 'Unsupported protocol version', { - 'supported': supportedProtocolVersionsWithDraft, + 'supported': _supportedVersions, 'requested': requestedVersion, }, ); @@ -185,7 +200,7 @@ class Server extends Protocol { 'Missing required request metadata: ${McpMetaKey.protocolVersion}', ); } - if (!supportedProtocolVersionsWithDraft.contains(requestedVersion)) { + if (!_supportedVersions.contains(requestedVersion)) { return _unsupportedProtocolVersionError(requestedVersion); } if (!isStatelessProtocolVersion(requestedVersion)) { @@ -732,8 +747,7 @@ class Server extends Protocol { final requestedProtocolVersion = request.meta?[McpMetaKey.protocolVersion]; if (requestedProtocolVersion is String && - !supportedProtocolVersionsWithDraft - .contains(requestedProtocolVersion)) { + !_supportedVersions.contains(requestedProtocolVersion)) { return _unsupportedProtocolVersionError(requestedProtocolVersion); } if (requestedProtocolVersion is String && @@ -752,6 +766,9 @@ class Server extends Protocol { } if (request.method == Method.initialize) { + if (!_supportsLegacyInitialization) { + return _unsupportedProtocolVersionError(latestProtocolVersion); + } if (_lifecycleState != _ServerLifecycleState.uninitialized) { return McpError( ErrorCode.invalidRequest.value, @@ -1053,9 +1070,14 @@ class Server extends Protocol { _clientCapabilities = params.capabilities; _clientVersion = params.clientInfo; - final protocolVersion = supportedProtocolVersions.contains(requestedVersion) + final stableSupportedVersions = _supportedVersions + .where((version) => !isStatelessProtocolVersion(version)) + .toList(); + final protocolVersion = stableSupportedVersions.contains(requestedVersion) ? requestedVersion - : latestProtocolVersion; + : stableSupportedVersions.isNotEmpty + ? stableSupportedVersions.first + : latestProtocolVersion; return InitializeResult( protocolVersion: protocolVersion, @@ -1065,6 +1087,12 @@ class Server extends Protocol { ); } + bool get _supportsLegacyInitialization { + return _supportedVersions.any( + (version) => !isStatelessProtocolVersion(version), + ); + } + ServerCapabilities _discoveryCapabilities() { final json = getCapabilities().toJson(); json.remove('tasks'); @@ -1074,7 +1102,7 @@ class Server extends Protocol { /// Handles the client's `server/discover` request. Future _onDiscover() async { return DiscoverResult( - supportedVersions: supportedProtocolVersionsWithDraft, + supportedVersions: _supportedVersions, capabilities: _discoveryCapabilities(), serverInfo: _serverInfo, instructions: _instructions, diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index 50dc659d..68a1609f 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -27,6 +27,71 @@ const latestProtocolVersion = stableProtocolVersion2025_11_25; /// The latest draft/RC protocol version implemented behind opt-in paths. const latestDraftProtocolVersion = draftProtocolVersion2026_07_28; +/// High-level MCP protocol compatibility profiles. +/// +/// The SDK defaults to [stable], which keeps the 2025 initialization flow and +/// avoids draft-only behavior. Use [preview2026] to prefer the 2026 RC while +/// falling back to stable MCP servers where possible. Use [require2026] when a +/// peer must support the 2026 RC stateless protocol. +enum McpProtocol { + /// Stable MCP behavior using the latest released specification. + /// + /// This is the default SDK profile and currently targets MCP 2025-11-25. + stable, + + /// Prefer the MCP 2026-07-28 RC when a peer supports it. + /// + /// This profile enables draft-only behavior such as `server/discover`, + /// stateless request metadata, and stateless result types, while allowing + /// fallback to the stable `initialize` flow for older peers. + preview2026, + + /// Require the MCP 2026-07-28 RC stateless protocol. + /// + /// This profile is intended for conformance tests and deployments where + /// connecting to older MCP servers would be a configuration error. + require2026; + + /// Preferred protocol version for outgoing negotiation. + String get preferredProtocolVersion { + return switch (this) { + McpProtocol.stable => latestProtocolVersion, + McpProtocol.preview2026 || + McpProtocol.require2026 => + latestDraftProtocolVersion, + }; + } + + /// Protocol versions this profile advertises or accepts. + List get supportedVersions { + return switch (this) { + McpProtocol.stable => supportedProtocolVersions, + McpProtocol.preview2026 => supportedProtocolVersionsWithDraft, + McpProtocol.require2026 => statelessProtocolVersions, + }; + } + + /// Whether clients should probe with `server/discover` by default. + bool get useServerDiscoverByDefault { + return switch (this) { + McpProtocol.stable => false, + McpProtocol.preview2026 || McpProtocol.require2026 => true, + }; + } + + /// Whether failed discovery should fall back to legacy initialization. + bool get allowLegacyInitializationFallbackByDefault { + return switch (this) { + McpProtocol.stable || McpProtocol.preview2026 => true, + McpProtocol.require2026 => false, + }; + } + + /// Whether this profile advertises support for stateless MCP versions. + bool get supportsStatelessProtocol => + supportedProtocolVersions.any(isStatelessProtocolVersion); +} + /// List of supported Model Context Protocol versions. const supportedProtocolVersions = [ latestProtocolVersion, diff --git a/test/client/client_test.dart b/test/client/client_test.dart index 78c0ac29..62dbbffd 100644 --- a/test/client/client_test.dart +++ b/test/client/client_test.dart @@ -112,7 +112,7 @@ void main() { transport.sentMessages .whereType() .map((message) => message.method), - containsAllInOrder([Method.serverDiscover, Method.initialize]), + [Method.initialize], ); // Verify that an initialized notification was sent diff --git a/test/client/streamable_https_test.dart b/test/client/streamable_https_test.dart index 1a1ce4b4..f819f6be 100644 --- a/test/client/streamable_https_test.dart +++ b/test/client/streamable_https_test.dart @@ -332,7 +332,6 @@ void main() { expect(initializeCount, 1); expect(initializedNotificationCount, 1); expect(capturedSessionHeaders, [ - null, preconfiguredSessionId, preconfiguredSessionId, ]); diff --git a/test/conformance/mcp_2026_rc_server.dart b/test/conformance/mcp_2026_rc_server.dart index 14cee0b6..737c657b 100644 --- a/test/conformance/mcp_2026_rc_server.dart +++ b/test/conformance/mcp_2026_rc_server.dart @@ -54,7 +54,11 @@ Future main(List args) async { } McpServer _createConformanceServer() { - final server = interop.createServer(); + final server = interop.createServer( + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), + ); server.registerTool( 'a_header_probe', diff --git a/test/interop/test_dart_server.dart b/test/interop/test_dart_server.dart index 4366cb70..a1d406dc 100644 --- a/test/interop/test_dart_server.dart +++ b/test/interop/test_dart_server.dart @@ -2,10 +2,11 @@ import 'dart:convert'; import 'dart:io'; import 'package:mcp_dart/mcp_dart.dart'; -McpServer createServer() { +McpServer createServer({McpServerOptions? options}) { // Define Server final server = McpServer( const Implementation(name: 'dart-test-server', version: '1.0.0'), + options: options, ); const metadataIcon = ImageContent( data: 'iVBORw0KGgo=', diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index dbea0fe1..5802836f 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -2251,6 +2251,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(listChanged: true), resources: ServerCapabilitiesResources(subscribe: true), @@ -2317,6 +2318,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(listChanged: true), ), @@ -2363,6 +2365,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(listChanged: true), ), @@ -2428,6 +2431,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( prompts: ServerCapabilitiesPrompts(), resources: ServerCapabilitiesResources(), @@ -2537,6 +2541,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( extensions: {mcpTasksExtensionId: {}}, ), @@ -2588,6 +2593,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( extensions: {mcpTasksExtensionId: {}}, ), @@ -2710,6 +2716,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: const ServerCapabilities( extensions: {mcpTasksExtensionId: {}}, ), @@ -2762,6 +2769,9 @@ void main() { () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); var handlerCalled = false; server.experimental.onGetTask((taskId, extra) async { @@ -2801,6 +2811,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( extensions: {mcpTasksExtensionId: {}}, ), @@ -2849,6 +2860,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tasks: ServerCapabilitiesTasks(list: true), extensions: {mcpTasksExtensionId: {}}, @@ -2900,6 +2912,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tasks: ServerCapabilitiesTasks( list: true, @@ -2933,6 +2946,9 @@ void main() { test('stateless tools/call ignores legacy task parameter', () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); server.registerTool( 'echo', @@ -2964,6 +2980,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), extensions: {mcpTasksExtensionId: {}}, @@ -3016,6 +3033,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), extensions: {mcpTasksExtensionId: {}}, @@ -3092,6 +3110,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), ), ); @@ -3140,6 +3159,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), extensions: {mcpTasksExtensionId: {}}, @@ -3191,6 +3211,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), extensions: {mcpTasksExtensionId: {}}, @@ -3262,6 +3283,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), extensions: {mcpTasksExtensionId: {}}, @@ -3313,6 +3335,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), extensions: {mcpTasksExtensionId: {}}, @@ -3372,6 +3395,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), ), ); @@ -3408,6 +3432,9 @@ void main() { () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); server.registerTool( 'needs_input', @@ -3495,6 +3522,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), ), ); @@ -3641,6 +3669,9 @@ void main() { test('stateless prompts/get permits input required results', () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); server.registerPrompt( 'needs_input', @@ -3667,6 +3698,9 @@ void main() { test('stateless resources/read permits input required results', () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); server.registerResource( 'needs_input', @@ -3697,6 +3731,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities(prompts: ServerCapabilitiesPrompts()), ), @@ -3733,6 +3768,9 @@ void main() { () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); final handler = CompletedTaskHandler(); server.experimental.registerToolTask( @@ -3762,6 +3800,9 @@ void main() { test('stateless tools/list omits legacy task execution metadata', () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); server.registerTool( 'echo', @@ -3786,6 +3827,7 @@ void main() { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), ), @@ -3826,6 +3868,9 @@ void main() { test('stateless tools/list returns tools sorted by name', () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); for (final name in ['zeta', 'alpha', 'middle']) { server.registerTool( @@ -3851,6 +3896,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tasks: ServerCapabilitiesTasks(), ), @@ -3878,6 +3924,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), ), @@ -3906,6 +3953,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), ), @@ -3954,6 +4002,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: const ServerCapabilities( tools: ServerCapabilitiesTools(), extensions: {mcpTasksExtensionId: {}}, @@ -4003,6 +4052,7 @@ void main() { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), ), @@ -4124,6 +4174,9 @@ void main() { () async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); final transport = RecordingTransport(); await server.connect(transport); @@ -4148,6 +4201,9 @@ void main() { test('server rejects malformed stateless request metadata', () { final server = Server( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); McpError? validateToolRequest(Map? meta) { @@ -4282,6 +4338,9 @@ void main() { test('server rejects core RPCs removed from stateless MCP', () async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); final transport = RecordingTransport(); await server.connect(transport); @@ -4338,6 +4397,9 @@ void main() { test('server rejects notifications removed from stateless MCP', () async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); final errors = []; server.onerror = errors.add; @@ -4382,6 +4444,7 @@ void main() { server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( logging: {}, tools: ServerCapabilitiesTools(), @@ -4439,6 +4502,7 @@ void main() { server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( logging: {}, tools: ServerCapabilitiesTools(), @@ -4473,11 +4537,14 @@ void main() { expect(transport.sentMessages.single, isA()); }); - test('client defaults to server/discover and sends stateless metadata', + test('preview client uses server/discover and sends stateless metadata', () async { final transport = DiscoveringClientTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); await client.connect(transport); @@ -4509,6 +4576,9 @@ void main() { final transport = DiscoveringClientTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); await client.connect(transport); @@ -4542,11 +4612,10 @@ void main() { expect(transport.sentMessages, hasLength(sentBeforeCall)); }); - test('client can opt out of discovery for legacy initialization', () async { + test('client uses legacy initialization by default', () async { final transport = LegacyFallbackTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: false), ); await client.connect(transport); @@ -4594,6 +4663,9 @@ void main() { final transport = LegacyFallbackTransport(discoveryError: error); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); await client.connect(transport); @@ -4614,7 +4686,10 @@ void main() { final transport = DiscoveringClientTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); transport.sentMessages.clear(); @@ -4698,7 +4773,10 @@ void main() { final transport = DiscoveringClientTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); transport.sentMessages.clear(); @@ -4759,6 +4837,7 @@ void main() { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: const McpClientOptions( + protocol: McpProtocol.preview2026, capabilities: ClientCapabilities(roots: ClientCapabilitiesRoots()), useServerDiscover: true, ), @@ -4782,7 +4861,10 @@ void main() { final transport = DiscoveringClientTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); transport.sentMessages.clear(); @@ -4859,6 +4941,7 @@ void main() { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: const McpClientOptions( + protocol: McpProtocol.preview2026, capabilities: ClientCapabilities( elicitation: ClientElicitation.formOnly(), roots: ClientCapabilitiesRoots(), @@ -4956,6 +5039,7 @@ void main() { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: McpClientOptions( + protocol: McpProtocol.preview2026, capabilities: ClientCapabilities( extensions: withMcpTasksExtension(null), ), @@ -5069,6 +5153,7 @@ void main() { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: McpClientOptions( + protocol: McpProtocol.preview2026, capabilities: ClientCapabilities( elicitation: const ClientElicitation.formOnly(), extensions: withMcpTasksExtension(null), @@ -5193,6 +5278,7 @@ void main() { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: McpClientOptions( + protocol: McpProtocol.preview2026, capabilities: ClientCapabilities( elicitation: const ClientElicitation.formOnly(), extensions: withMcpTasksExtension(null), @@ -5248,6 +5334,9 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); await client.connect(transport); @@ -5322,6 +5411,9 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); await client.connect(transport); transport.sentMessages.clear(); @@ -5360,7 +5452,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5469,7 +5564,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5520,7 +5618,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5569,7 +5670,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5626,7 +5730,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5682,7 +5789,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5725,7 +5835,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5773,7 +5886,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5805,7 +5921,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5842,7 +5961,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5874,7 +5996,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5947,7 +6072,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -5985,7 +6113,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -6039,7 +6170,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -6060,7 +6194,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await client.connect(transport); @@ -6077,7 +6214,10 @@ void main() { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), - options: const McpClientOptions(useServerDiscover: true), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), ); await expectLater( @@ -6101,6 +6241,7 @@ void main() { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: const McpClientOptions( + protocol: McpProtocol.preview2026, protocolVersion: '1900-01-01', useServerDiscover: true, ), @@ -6170,6 +6311,7 @@ void main() { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: McpClientOptions( + protocol: McpProtocol.preview2026, protocolVersion: scenario.requested, useServerDiscover: true, ), @@ -6200,6 +6342,9 @@ void main() { final transport = LegacyFallbackTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); await client.connect(transport); diff --git a/test/server/mcp_server_test.dart b/test/server/mcp_server_test.dart index fbbb9896..54515504 100644 --- a/test/server/mcp_server_test.dart +++ b/test/server/mcp_server_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:mcp_dart/src/server/mcp_server.dart'; +import 'package:mcp_dart/src/server/server.dart'; import 'package:mcp_dart/src/shared/transport.dart'; import 'package:mcp_dart/src/types.dart'; import 'package:test/test.dart'; @@ -133,6 +134,12 @@ void main() { test('connect syncs tool parameter header mappings to transports', () async { + server = McpServer( + const Implementation(name: 'test-server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), + ); server.registerTool( 'header-tool', inputSchema: const ToolInputSchema( @@ -216,6 +223,12 @@ void main() { }); test('invalid tool parameter header metadata is not synced', () async { + server = McpServer( + const Implementation(name: 'test-server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), + ); server.registerTool( 'non-string-header-tool', inputSchema: ToolInputSchema( @@ -287,6 +300,12 @@ void main() { test('invalid stateless header metadata is stripped from nested schemas', () async { + server = McpServer( + const Implementation(name: 'test-server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), + ); server.registerTool( 'nested-header-tool', inputSchema: ToolInputSchema.fromJson({ @@ -719,6 +738,12 @@ void main() { }); test('stateless resource miss uses 2026 invalid params error', () async { + server = McpServer( + const Implementation(name: 'test-server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), + ); server.registerResource( 'Known Resource', 'test://known', diff --git a/test/server/output_validation_test.dart b/test/server/output_validation_test.dart index 36eb82ac..4799381c 100644 --- a/test/server/output_validation_test.dart +++ b/test/server/output_validation_test.dart @@ -87,6 +87,15 @@ void main() { }); test('non-object output schema validates for MCP 2026 calls', () async { + mcpServer = McpServer( + const Implementation(name: 'TestServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), + ); mcpServer.registerTool( 'array_tool', outputSchema: JsonSchema.array(items: JsonSchema.string()), @@ -113,6 +122,15 @@ void main() { }); test('non-object output schema validation failures are rejected', () async { + mcpServer = McpServer( + const Implementation(name: 'TestServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), + ); mcpServer.registerTool( 'invalid_array_tool', outputSchema: JsonSchema.array(items: JsonSchema.string()), @@ -162,6 +180,15 @@ void main() { }); test('MCP 2026 tools/list includes non-object output schemas', () async { + mcpServer = McpServer( + const Implementation(name: 'TestServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), + ); mcpServer.registerTool( 'array_tool', outputSchema: JsonSchema.array(items: JsonSchema.string()), diff --git a/test/server/streamable_https_test.dart b/test/server/streamable_https_test.dart index 327a9e81..cce1f479 100644 --- a/test/server/streamable_https_test.dart +++ b/test/server/streamable_https_test.dart @@ -3187,6 +3187,9 @@ void main() { ); final server = Server( const Implementation(name: 'StatelessServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); addTearDown(server.close); await server.connect(transport); diff --git a/test/server/streamable_mcp_server_test.dart b/test/server/streamable_mcp_server_test.dart index 369375eb..7cc6083e 100644 --- a/test/server/streamable_mcp_server_test.dart +++ b/test/server/streamable_mcp_server_test.dart @@ -330,6 +330,9 @@ void main() { serverFactory: (sessionId) { final mcpServer = McpServer( const Implementation(name: 'StatelessServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); mcpServer.registerTool( 'echo', @@ -368,6 +371,9 @@ void main() { serverFactory: (sessionId) { final mcpServer = McpServer( const Implementation(name: 'JsonStatelessServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); mcpServer.registerTool( 'echo', @@ -408,6 +414,9 @@ void main() { serverFactory: (sessionId) { final mcpServer = McpServer( const Implementation(name: 'JsonStatelessServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); mcpServer.registerTool( 'echo', @@ -610,6 +619,9 @@ void main() { serverFactory: (sessionId) { final mcpServer = McpServer( const Implementation(name: 'StatelessServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); mcpServer.registerTool( 'echo', @@ -653,6 +665,7 @@ void main() { final mcpServer = McpServer( const Implementation(name: 'StatelessServer', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), extensions: {mcpTasksExtensionId: {}}, @@ -777,6 +790,9 @@ void main() { serverFactory: (sessionId) { final mcpServer = McpServer( const Implementation(name: 'StatelessServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); mcpServer.registerTool( 'echo', From dee1e7e52d431f2a877e37e74f81f228d2c672ee Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Wed, 3 Jun 2026 10:24:51 -0400 Subject: [PATCH 43/68] Fix CLI conformance profile fixtures --- .../lib/src/conformance_runner.dart | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/mcp_dart_cli/lib/src/conformance_runner.dart b/packages/mcp_dart_cli/lib/src/conformance_runner.dart index 7658bf6c..425b5cae 100644 --- a/packages/mcp_dart_cli/lib/src/conformance_runner.dart +++ b/packages/mcp_dart_cli/lib/src/conformance_runner.dart @@ -1303,6 +1303,7 @@ Future _serverDiscoverReturnsDraftCapabilities() async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), ), @@ -1354,6 +1355,9 @@ Future _rejectsUnsupportedStatelessProtocolVersion() async { final transport = _ConformanceTransport(); final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); await server.connect(transport); @@ -1403,6 +1407,9 @@ Future _statelessRequestsRequireCompleteRequestMeta() async { final transport = _ConformanceTransport(); final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); await server.connect(transport); @@ -1509,6 +1516,7 @@ Future _httpModernProtocolErrorsRetryDiscovery() async { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: const McpClientOptions( + protocol: McpProtocol.preview2026, protocolVersion: '1900-01-01', useServerDiscover: true, ), @@ -1599,6 +1607,7 @@ Future _httpModernMissingCapabilityErrorsDoNotFallback() async { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: const McpClientOptions( + protocol: McpProtocol.preview2026, protocolVersion: _draftProtocolVersion2026_07_28, useServerDiscover: true, ), @@ -1684,6 +1693,9 @@ Future _initializeNegotiatesStatefulProtocolVersion() async { final clientTransport = _ConformanceTransport(); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); final connectFuture = client.connect(clientTransport); await _settle(); @@ -1730,6 +1742,7 @@ Future _statelessDoesNotInferInitializeExtensions() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( extensions: >{ _tasksExtensionId: {}, @@ -2100,6 +2113,7 @@ Future _taskRequestsRequireStatelessHttpNameHeader() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( extensions: >{ _tasksExtensionId: {}, @@ -2833,6 +2847,7 @@ Future _taskSubscriptionRequiresClientCapability() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( extensions: >{ _tasksExtensionId: {}, @@ -2916,6 +2931,7 @@ Future _relatedTaskUsesExplicitIdAcrossTransports() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( extensions: >{ _tasksExtensionId: {}, @@ -3022,6 +3038,7 @@ Future _statelessIgnoresLegacyTaskParameter() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), tasks: ServerCapabilitiesTasks( @@ -3097,6 +3114,9 @@ Future _statelessClientRejectsLegacyTaskOptions() async { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); await client.connect(transport); @@ -3140,6 +3160,7 @@ Future _statelessAddsResultTypeAndCacheDefaults() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( prompts: ServerCapabilitiesPrompts(), resources: ServerCapabilitiesResources(), @@ -3297,6 +3318,7 @@ Future _statelessToolsListReturnsDeterministicOrder() async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), ), @@ -3355,6 +3377,7 @@ Future _statelessToolsListOmitsLegacyExecution() async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), ), @@ -3448,6 +3471,9 @@ Future _missingResourceErrorCodeByVersion() async { final statelessTransport = _ConformanceTransport(); final statelessServer = McpServer( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); statelessServer.registerResource( 'Known Resource', @@ -3498,6 +3524,9 @@ Future _statelessRejectsUnrecognizedResultType() async { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); try { @@ -3538,6 +3567,7 @@ Future _mrtrInputRequiredSupportedRequests() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( prompts: ServerCapabilitiesPrompts(), resources: ServerCapabilitiesResources(), @@ -3647,6 +3677,7 @@ Future _mrtrRejectsUnsupportedInputRequiredResults() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), ), ); @@ -3688,6 +3719,7 @@ Future _mrtrInputRequestsRequireClientCapabilities() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), ), ); @@ -3863,6 +3895,7 @@ Future _callToolResultCannotSpoofTaskResult() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( tools: ServerCapabilitiesTools(), extensions: >{ @@ -3945,6 +3978,9 @@ Future _taskResultRequiresClientExtension() async { ); final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); try { @@ -3982,6 +4018,9 @@ Future _rejectsRemovedStatelessCoreRpcs() async { // ignore: deprecated_member_use final server = Server( const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), ); await server.connect(transport); @@ -4117,6 +4156,7 @@ Future _statelessLoggingRequiresRequestLogLevel() async { server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( logging: {}, tools: ServerCapabilitiesTools(), @@ -4209,6 +4249,7 @@ Future _taskLifecycleMethodsAllowResumedClientCapability() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( extensions: >{ _tasksExtensionId: {}, @@ -4387,6 +4428,7 @@ McpServerOptions _mcpServerOptionsWithTaskStore({ { #capabilities: capabilities, #taskStore: taskStore, + #protocol: McpProtocol.preview2026, }, ) as McpServerOptions; } @@ -4399,6 +4441,7 @@ Future _subscriptionTaskIdsRequireClientCapability() async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( extensions: >{ _tasksExtensionId: {}, @@ -4768,6 +4811,9 @@ Future _unadvertisedPeerMethodsUseMethodNotFound() async { ); final statelessClient = McpClient( const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), ); await statelessClient.connect(statelessClientTransport); statelessClientTransport.sentMessages.clear(); @@ -4937,6 +4983,7 @@ Future _statelessOmitsLegacyTaskCapabilities() async { final client = McpClient( const Implementation(name: 'client', version: '1.0.0'), options: const McpClientOptions( + protocol: McpProtocol.preview2026, capabilities: clientCapabilities, useServerDiscover: true, ), @@ -4978,7 +5025,10 @@ Future _statelessOmitsLegacyTaskCapabilities() async { // ignore: deprecated_member_use final server = Server( const Implementation(name: 'server', version: '1.0.0'), - options: const McpServerOptions(capabilities: serverCapabilities), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + capabilities: serverCapabilities, + ), ); await server.connect(serverTransport); serverTransport.emit( @@ -5655,6 +5705,7 @@ Future _advertisesDraftProtocolVersion() async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities(), ), ); From 7f44ddbd6bb94c6d160f53d96ee482c48748123b Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Wed, 3 Jun 2026 11:52:20 -0400 Subject: [PATCH 44/68] Prepare 2026 draft dev release --- .github/workflows/publish.yml | 1 + CHANGELOG.md | 8 +- README.md | 13 +- doc/client-guide.md | 4 +- doc/getting-started.md | 2 +- doc/mcp-2026-rc.md | 51 +++-- doc/quick-reference.md | 2 +- doc/server-guide.md | 5 +- doc/spec-coverage-2025-11-25.md | 2 +- doc/tools.md | 2 +- lib/src/client/client.dart | 10 +- lib/src/client/task_client.dart | 9 +- lib/src/server/mcp_server.dart | 95 ++++++-- lib/src/server/mcp_ui.dart | 10 +- lib/src/server/server.dart | 6 +- lib/src/shared/json_schema/json_schema.dart | 206 +++++++++++++----- .../json_schema/json_schema_validator.dart | 24 +- lib/src/types.dart | 1 + lib/src/types/content.dart | 6 +- lib/src/types/initialization.dart | 5 +- lib/src/types/json_rpc.dart | 36 +-- lib/src/types/json_value.dart | 90 ++++++++ lib/src/types/sampling.dart | 48 +++- lib/src/types/tools.dart | 94 ++++++-- packages/mcp_dart_cli/CHANGELOG.md | 7 + packages/mcp_dart_cli/README.md | 19 ++ .../lib/src/inspect_server_command.dart | 2 +- packages/mcp_dart_cli/lib/src/version.dart | 2 +- packages/mcp_dart_cli/pubspec.yaml | 8 +- packages/mcp_dart_cli/pubspec_overrides.yaml | 3 + .../fixtures/dart_mcp_project/pubspec.yaml | 2 +- pubspec.yaml | 2 +- test/client/client_tool_validation_test.dart | 8 +- test/conformance/README.md | 24 +- test/server/output_validation_test.dart | 22 +- test/shared/json_schema_from_json_test.dart | 18 +- test/shared/json_schema_validator_test.dart | 15 +- test/tool_schema_test.dart | 10 +- test/types/sampling_test.dart | 16 +- tool/validate_cli_publish.dart | 205 +++++++++++++++++ 40 files changed, 861 insertions(+), 232 deletions(-) create mode 100644 lib/src/types/json_value.dart create mode 100644 packages/mcp_dart_cli/pubspec_overrides.yaml create mode 100644 tool/validate_cli_publish.dart diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 33c1e50b..80ab9c2d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -43,6 +43,7 @@ jobs: rm -rf "$PUBLISH_ROOT" mkdir -p "$PUBLISH_ROOT" rsync -a --exclude .git --exclude .dart_tool --exclude pubspec.lock ./ "$PUBLISH_ROOT/" + rm -f "$PUBLISH_ROOT/packages/mcp_dart_cli/pubspec_overrides.yaml" echo "working_directory=$PUBLISH_ROOT/${{ steps.package-info.outputs.working_directory }}" >> "$GITHUB_OUTPUT" else echo "working_directory=${{ steps.package-info.outputs.working_directory }}" >> "$GITHUB_OUTPUT" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cc51a45..50a2fe4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ -## Unreleased +## 2.3.0-dev.0 -### MCP 2026-07-28 RC +### MCP 2026-07-28 draft/RC - Started the MCP 2026-07-28 RC development line with opt-in protocol constants, stateless request metadata helpers, and `server/discover` request @@ -13,6 +13,10 @@ `McpServerOptions(protocol: McpProtocol.preview2026)` while keeping the stable `initialize` flow as the default. The lower-level `protocolVersion` and `useServerDiscover` options remain available for interoperability testing. +- Kept stable public tool result APIs object-rooted while adding explicit + draft-only APIs for non-object values: `JsonValue`, + `CallToolResult.fromStructuredArray()`, `structuredContentJson`, and + server `outputJsonSchema`. - Added 2026 cacheable result support for `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, and `resources/read`, including stateless server defaults for `resultType`, `ttlMs`, and `cacheScope` while diff --git a/README.md b/README.md index bdf9aefd..2aaf94cb 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - mcp_dart: ^2.2.0 + mcp_dart: ^2.3.0-dev.0 ``` Then install dependencies: @@ -93,7 +93,8 @@ explicit stable profile. It's also backward compatible with previous versions including `2025-06-18`, `2025-03-26`, `2024-11-05`, and `2024-10-07`. -MCP `2026-07-28` RC support is available behind an explicit preview profile: +MCP `2026-07-28` draft/RC support is available behind an explicit preview +profile: ```dart final client = McpClient( @@ -111,9 +112,9 @@ final server = McpServer( ); ``` -Use the preview profile while the spec is still an RC. See the -[MCP 2026 RC transition guide](https://github.com/leehack/mcp_dart/blob/main/doc/mcp-2026-rc.md) -for opt-in behavior, fallback rules, and 2026-only APIs. +Use the preview profile while the spec is still a draft/RC. See the +[MCP 2026-07-28 draft/RC transition guide](https://github.com/leehack/mcp_dart/blob/main/doc/mcp-2026-rc.md) +for opt-in behavior, fallback rules, and draft-only APIs. ## Documentation @@ -136,7 +137,7 @@ for opt-in behavior, fallback rules, and 2026-only APIs. - ๐Ÿงช **[SDK Interoperability Matrix](https://github.com/leehack/mcp_dart/blob/main/doc/interoperability.md)** - Verified Dart/TypeScript and documented cross-SDK scenarios - โœ… **[MCP 2025-11-25 Spec Coverage Matrix](https://github.com/leehack/mcp_dart/blob/main/doc/spec-coverage-2025-11-25.md)** - Auditable coverage map with CLI conformance cases and known gaps -- ๐Ÿงญ **[MCP 2026 RC Transition Guide](https://github.com/leehack/mcp_dart/blob/main/doc/mcp-2026-rc.md)** - Opt-in profile, fallback behavior, and draft-only APIs +- ๐Ÿงญ **[MCP 2026-07-28 Draft/RC Transition Guide](https://github.com/leehack/mcp_dart/blob/main/doc/mcp-2026-rc.md)** - Opt-in profile, fallback behavior, and draft-only APIs - ๐Ÿ”’ **[Transport Security Recipes](https://github.com/leehack/mcp_dart/blob/main/doc/transports.md#dns-rebinding-protection)** - Host/Origin allowlists, OAuth layering, and compatibility-toggle trade-offs - ๐Ÿ“ฑ **[Flutter Recipes](https://github.com/leehack/mcp_dart/blob/main/doc/flutter-recipes.md)** - Flutter Web, mobile, and desktop host/client guidance - ๐Ÿ” **[Migration Cookbooks](https://github.com/leehack/mcp_dart/blob/main/doc/migration-cookbooks.md)** - TypeScript SDK, `dart_mcp`, stdio-to-HTTP, and version migration paths diff --git a/doc/client-guide.md b/doc/client-guide.md index c13e6b2e..12460aee 100644 --- a/doc/client-guide.md +++ b/doc/client-guide.md @@ -63,7 +63,7 @@ final client = McpClient( ### Protocol Profile Clients use the stable MCP `2025-11-25` profile by default. Opt into MCP -`2026-07-28` RC behavior with the preview profile: +`2026-07-28` draft/RC behavior with the preview profile: ```dart final client = McpClient( @@ -178,7 +178,7 @@ final result = await client.callTool( ### Task-Augmented Tool Calls -For MCP 2026 stateless servers that advertise the +For MCP `2026-07-28` draft/RC stateless servers that advertise the `io.modelcontextprotocol/tasks` extension, task creation is server-directed. Call `client.callTool()` normally, or call `TaskClient.callToolStream()` without the legacy `task` argument; the client follows `resultType: "task"` with diff --git a/doc/getting-started.md b/doc/getting-started.md index b562e467..413a910a 100644 --- a/doc/getting-started.md +++ b/doc/getting-started.md @@ -8,7 +8,7 @@ Add the MCP Dart SDK to your `pubspec.yaml`: ```yaml dependencies: - mcp_dart: ^2.2.0 + mcp_dart: ^2.3.0-dev.0 ``` Then run: diff --git a/doc/mcp-2026-rc.md b/doc/mcp-2026-rc.md index 58ea430b..3f5b91e8 100644 --- a/doc/mcp-2026-rc.md +++ b/doc/mcp-2026-rc.md @@ -1,14 +1,14 @@ -# MCP 2026 RC Transition Guide +# MCP 2026-07-28 Draft/RC Transition Guide `mcp_dart` defaults to the latest stable MCP specification, currently -`2025-11-25`. MCP `2026-07-28` RC support is available through explicit +`2025-11-25`. MCP `2026-07-28` draft/RC support is available through explicit protocol profiles so applications can adopt the draft without changing stable deployments. ## Client opt-in -Use the preview profile when you want the client to prefer MCP `2026-07-28` RC -and fall back to stable MCP servers when discovery is unavailable: +Use the preview profile when you want the client to prefer MCP `2026-07-28` +draft/RC and fall back to stable MCP servers when discovery is unavailable: ```dart final client = McpClient( @@ -20,8 +20,9 @@ final client = McpClient( ``` `McpClientOptions(protocol: McpProtocol.preview2026)` enables -`server/discover`, sends the 2026 stateless request metadata, and falls back to -the legacy `initialize` flow when the peer looks like a stable-only MCP server. +`server/discover`, sends the `2026-07-28` draft/RC stateless request metadata, +and falls back to the legacy `initialize` flow when the peer looks like a +stable-only MCP server. Use the strict profile for conformance tests or deployments where fallback is not acceptable: @@ -37,7 +38,7 @@ final client = McpClient( ## Server opt-in -Use the server preview profile to advertise and accept MCP `2026-07-28` RC +Use the server preview profile to advertise and accept MCP `2026-07-28` draft/RC stateless requests: ```dart @@ -57,8 +58,8 @@ stateless protocol versions. | Profile | Default? | Client behavior | Server behavior | | ------- | -------- | --------------- | --------------- | | `McpProtocol.stable` | Yes | Uses stable `initialize` | Advertises stable protocol versions | -| `McpProtocol.preview2026` | No | Tries `server/discover`, then falls back to `initialize` | Advertises stable and 2026 RC protocol versions | -| `McpProtocol.require2026` | No | Requires 2026 RC discovery | Advertises only stateless 2026 RC protocol versions | +| `McpProtocol.preview2026` | No | Tries `server/discover`, then falls back to `initialize` | Advertises stable and `2026-07-28` draft/RC protocol versions | +| `McpProtocol.require2026` | No | Requires `2026-07-28` draft/RC discovery | Advertises only stateless `2026-07-28` draft/RC protocol versions | ## Low-level overrides @@ -77,17 +78,37 @@ final client = McpClient( Prefer the `protocol` profile unless you need to target a specific protocol version for tests or interoperability debugging. -## 2026-only API areas +## 2026-07-28 Draft-Only API Areas -The following features are MCP `2026-07-28` RC behavior and should be used only -after opting into a 2026 profile: +The following features are MCP `2026-07-28` draft/RC behavior and should be +used only after opting into a `2026-07-28` profile: - `server/discover` negotiation and stateless per-request metadata. - `subscriptions/listen` stateless notification streams. - Multi-result tool/resource/prompt flows such as `input_required`. - MCP Tasks extension flows using `io.modelcontextprotocol/tasks`. -- Non-object `structuredContent` values and broader tool `outputSchema` shapes. +- Non-object `structuredContent` values via `JsonValue` and broader server + `outputJsonSchema` shapes. - Stateless result metadata such as `resultType`, `ttlMs`, and `cacheScope`. -The RC API surface may still change before the official spec release. Keep -applications on the stable profile unless they specifically need RC behavior. +For non-object tool results, keep the stable object-root APIs for stable MCP +callers and use the explicitly named draft APIs: + +```dart +server.registerTool( + 'array-result', + outputJsonSchema: JsonSchema.array(items: JsonSchema.string()), + callback: (args, extra) { + return CallToolResult.fromStructuredArray(['alpha', 'beta']); + }, +); + +final result = await client.callTool( + const CallToolRequest(name: 'array-result'), +); +final items = result.structuredContentJson?.asArray; +``` + +The draft/RC API surface may still change before the official spec release. +Keep applications on the stable profile unless they specifically need draft +behavior. diff --git a/doc/quick-reference.md b/doc/quick-reference.md index b68aeedc..2644cf37 100644 --- a/doc/quick-reference.md +++ b/doc/quick-reference.md @@ -7,7 +7,7 @@ Fast lookup guide for common MCP Dart SDK operations. ```yaml # pubspec.yaml dependencies: - mcp_dart: ^2.2.0 + mcp_dart: ^2.3.0-dev.0 ``` ```bash diff --git a/doc/server-guide.md b/doc/server-guide.md index 4f97744b..be8ae899 100644 --- a/doc/server-guide.md +++ b/doc/server-guide.md @@ -66,7 +66,7 @@ final server = McpServer( ### Protocol Profile Servers use the stable MCP `2025-11-25` profile by default. Opt into MCP -`2026-07-28` RC behavior with the preview profile: +`2026-07-28` draft/RC behavior with the preview profile: ```dart final server = McpServer( @@ -81,7 +81,8 @@ final server = McpServer( ``` `McpServerOptions(protocol: McpProtocol.preview2026)` advertises and accepts -2026 RC stateless protocol versions, including `server/discover`. Use +`2026-07-28` draft/RC stateless protocol versions, including +`server/discover`. Use `McpServerOptions(protocol: McpProtocol.require2026)` when the server should reject stable initialization. diff --git a/doc/spec-coverage-2025-11-25.md b/doc/spec-coverage-2025-11-25.md index fa9a21f4..cc561264 100644 --- a/doc/spec-coverage-2025-11-25.md +++ b/doc/spec-coverage-2025-11-25.md @@ -25,7 +25,7 @@ cd ../../.. dart test -t interop ``` -For MCP 2026 RC/final release audits, also run the upstream +For MCP `2026-07-28` draft/RC or final release audits, also run the upstream machine-readable example corpus through the checked-in typed parsers after extracting the upstream `modelcontextprotocol` archive: diff --git a/doc/tools.md b/doc/tools.md index 88a24a1c..a2f57938 100644 --- a/doc/tools.md +++ b/doc/tools.md @@ -514,7 +514,7 @@ final server = McpServer( ``` Clients that call task-augmented tools can use `TaskClient.callToolStream()`. -With MCP 2026 stateless servers that advertise +With MCP `2026-07-28` draft/RC stateless servers that advertise `io.modelcontextprotocol/tasks`, omit the legacy `task` argument; task creation is server-directed and the client follows the extension polling flow transparently. diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 863ede6b..e899bcc9 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -18,8 +18,8 @@ class McpClientOptions extends ProtocolOptions { /// High-level protocol compatibility profile. /// /// Defaults to [McpProtocol.stable], which uses MCP 2025-11-25 behavior. - /// Set this to [McpProtocol.preview2026] to opt into MCP 2026-07-28 RC - /// negotiation with stable fallback. + /// Set this to [McpProtocol.preview2026] to opt into MCP `2026-07-28` + /// draft/RC negotiation with stable fallback. final McpProtocol protocol; final String? _protocolVersion; @@ -44,8 +44,8 @@ class McpClientOptions extends ProtocolOptions { /// Whether [McpClient.connect] should probe with `server/discover` first. /// /// When omitted, this is derived from [protocol]. Stable clients use the - /// legacy `initialize` flow by default; 2026 preview clients probe with - /// `server/discover`. + /// legacy `initialize` flow by default; `2026-07-28` draft/RC preview + /// clients probe with `server/discover`. bool get useServerDiscover => _useServerDiscover ?? protocol.useServerDiscoverByDefault; @@ -1475,7 +1475,7 @@ class McpClient extends Protocol { final outputSchema = _cachedToolOutputSchemas[params.name]; if (outputSchema != null && !result.isError) { try { - outputSchema.validate(result.structuredContent); + outputSchema.validate(result.structuredContentJson?.toJson()); } catch (e) { throw McpError( ErrorCode.invalidParams.value, diff --git a/lib/src/client/task_client.dart b/lib/src/client/task_client.dart index 14a628d1..ce395a99 100644 --- a/lib/src/client/task_client.dart +++ b/lib/src/client/task_client.dart @@ -57,10 +57,11 @@ class TaskClient { /// and long-running tasks (yielding [TaskCreatedMessage], multiple /// [TaskStatusMessage]s, and finally [TaskResultMessage]). /// - /// For MCP 2026 stateless sessions with the `io.modelcontextprotocol/tasks` - /// extension, task creation is server-directed and [task] must be omitted. - /// The call is routed through [McpClient.callTool], which transparently - /// follows the extension polling flow and yields the final tool result. + /// For MCP `2026-07-28` draft/RC stateless sessions with the + /// `io.modelcontextprotocol/tasks` extension, task creation is + /// server-directed and [task] must be omitted. The call is routed through + /// [McpClient.callTool], which transparently follows the extension polling + /// flow and yields the final tool result. /// /// For MCP 2025-11-25 legacy tasks, [task] is used for task augmentation. /// Pass task creation parameters (e.g., `{'ttl': 60000, 'pollInterval': 50}`) diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index 751d43cd..49b71356 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -67,21 +67,34 @@ JsonSchema? _outputSchemaForProtocol( return schema; } - // MCP 2025-11-25 restricts tool output schemas to object roots. MCP 2026 - // allows any JSON Schema, so omit non-object schemas for stable callers. + // MCP 2025-11-25 restricts tool output schemas to object roots. MCP + // 2026-07-28 draft/RC allows any JSON Schema, so omit non-object schemas for + // stable callers. if (schema.toJson()['type'] == 'object') { return schema; } return null; } +JsonSchema? _resolveToolOutputJsonSchema( + ToolOutputSchema? outputSchema, + JsonSchema? outputJsonSchema, +) { + if (outputSchema != null && outputJsonSchema != null) { + throw ArgumentError( + 'Specify only one of outputSchema or outputJsonSchema.', + ); + } + return outputJsonSchema ?? outputSchema; +} + CallToolResult _toolResultForProtocol( CallToolResult result, String? protocolVersion, ) { if (_isDraft2026Request(protocolVersion) || !result.hasStructuredContent || - _isStableStructuredContentValue(result.structuredContent)) { + _isStableStructuredContentValue(result.structuredContentJson?.toJson())) { return result; } @@ -278,7 +291,7 @@ CallToolResult _withRelatedTaskMeta(CallToolResult result, String taskId) { return CallToolResult( content: result.content, isError: result.isError, - structuredContent: result.structuredContent, + structuredContentJson: result.structuredContentJson, hasStructuredContent: result.hasStructuredContent, meta: meta, extra: result.extra, @@ -576,8 +589,18 @@ abstract class RegisteredTool { /// The input schema for the tool. ToolInputSchema? get inputSchema; - /// The output schema for the tool. - JsonSchema? get outputSchema; + /// The object-root output schema for stable MCP `2025-11-25` tool results. + /// + /// MCP `2026-07-28` draft/RC allows non-object JSON Schema roots. Use + /// [outputJsonSchema] for that wire-level schema. + ToolOutputSchema? get outputSchema; + + /// The wire-level output schema for this tool. + /// + /// This may be any JSON Schema when the server is using the explicit + /// MCP `2026-07-28` draft/RC profile. Stable MCP `2025-11-25` callers only + /// receive this schema when it has an object root. + JsonSchema? get outputJsonSchema; /// Annotations for the tool. ToolAnnotations? get annotations; @@ -606,7 +629,8 @@ abstract class RegisteredTool { String? title, String? description, ToolInputSchema? inputSchema, - JsonSchema? outputSchema, + ToolOutputSchema? outputSchema, + JsonSchema? outputJsonSchema, ToolAnnotations? annotations, ToolExecution? execution, ToolCallback? callback, @@ -623,8 +647,7 @@ class _RegisteredToolImpl implements RegisteredTool { String? description; @override ToolInputSchema? inputSchema; - @override - JsonSchema? outputSchema; + JsonSchema? _outputJsonSchema; @override ToolAnnotations? annotations; final ImageContent? icon; @@ -644,16 +667,28 @@ class _RegisteredToolImpl implements RegisteredTool { this.title, this.description, this.inputSchema, - this.outputSchema, + JsonSchema? outputJsonSchema, this.annotations, this.icon, this.meta, this.execution, required this.callback, - }) { + }) : _outputJsonSchema = outputJsonSchema { _server._registeredTools[name] = this; } + @override + ToolOutputSchema? get outputSchema { + final schema = _outputJsonSchema; + if (schema is JsonObject) { + return schema; + } + return null; + } + + @override + JsonSchema? get outputJsonSchema => _outputJsonSchema; + Tool toTool({ bool includeExecution = true, ToolInputSchema? inputSchemaOverride, @@ -665,7 +700,7 @@ class _RegisteredToolImpl implements RegisteredTool { description: description, inputSchema: inputSchemaOverride ?? inputSchema ?? const ToolInputSchema(), - outputSchema: _outputSchemaForProtocol(outputSchema, protocolVersion), + outputSchema: _outputSchemaForProtocol(outputJsonSchema, protocolVersion), annotations: annotations, icon: icon, icons: _iconsFromLegacyImage(icon), @@ -689,7 +724,8 @@ class _RegisteredToolImpl implements RegisteredTool { String? title, String? description, ToolInputSchema? inputSchema, - JsonSchema? outputSchema, + ToolOutputSchema? outputSchema, + JsonSchema? outputJsonSchema, ToolAnnotations? annotations, ToolExecution? execution, ToolCallback? callback, @@ -706,7 +742,11 @@ class _RegisteredToolImpl implements RegisteredTool { if (title != null) this.title = title; if (description != null) this.description = description; if (inputSchema != null) this.inputSchema = inputSchema; - if (outputSchema != null) this.outputSchema = outputSchema; + final nextOutputJsonSchema = + _resolveToolOutputJsonSchema(outputSchema, outputJsonSchema); + if (nextOutputJsonSchema != null) { + _outputJsonSchema = nextOutputJsonSchema; + } if (annotations != null) this.annotations = annotations; if (execution != null) this.execution = execution; if (callback != null) this.callback = callback; @@ -847,7 +887,8 @@ class ExperimentalMcpServerTasks { String? title, String? description, ToolInputSchema? inputSchema, - JsonSchema? outputSchema, + ToolOutputSchema? outputSchema, + JsonSchema? outputJsonSchema, ToolAnnotations? annotations, Map? meta, ToolExecution? execution, @@ -898,6 +939,7 @@ class ExperimentalMcpServerTasks { description: description, inputSchema: inputSchema, outputSchema: outputSchema, + outputJsonSchema: outputJsonSchema, annotations: annotations, meta: meta, execution: effectiveExecution, @@ -1611,11 +1653,12 @@ class McpServer { ); } - if (registeredTool.outputSchema != null && result is CallToolResult) { + if (registeredTool.outputJsonSchema != null && + result is CallToolResult) { if (result.isError != true) { try { - registeredTool.outputSchema!.validate( - result.structuredContent, + registeredTool.outputJsonSchema!.validate( + result.structuredContentJson?.toJson(), ); } catch (e) { throw McpError( @@ -2052,7 +2095,9 @@ class McpServer { /// [title] is a human-readable title. /// [description] explains what the tool does. /// [inputSchema] defines the expected arguments. - /// [outputSchema] defines the expected result structure. + /// [outputSchema] defines the stable object-root result structure. + /// [outputJsonSchema] defines an MCP `2026-07-28` draft/RC result structure + /// whose JSON Schema root may be any valid JSON Schema type. /// [annotations] provides additional metadata. /// [callback] is the function executed when the tool is called. RegisteredTool registerTool( @@ -2060,7 +2105,8 @@ class McpServer { String? title, String? description, ToolInputSchema? inputSchema, - JsonSchema? outputSchema, + ToolOutputSchema? outputSchema, + JsonSchema? outputJsonSchema, ToolAnnotations? annotations, Map? meta, required ToolFunction callback, @@ -2071,6 +2117,7 @@ class McpServer { description: description, inputSchema: inputSchema, outputSchema: outputSchema, + outputJsonSchema: outputJsonSchema, annotations: annotations, meta: meta, execution: const ToolExecution(taskSupport: 'forbidden'), @@ -2084,7 +2131,8 @@ class McpServer { String? title, String? description, ToolInputSchema? inputSchema, - JsonSchema? outputSchema, + ToolOutputSchema? outputSchema, + JsonSchema? outputJsonSchema, ToolAnnotations? annotations, Map? meta, ToolExecution? execution, @@ -2100,7 +2148,8 @@ class McpServer { title: title, description: description, inputSchema: inputSchema, - outputSchema: outputSchema, + outputJsonSchema: + _resolveToolOutputJsonSchema(outputSchema, outputJsonSchema), annotations: annotations, meta: meta, execution: execution, @@ -2227,7 +2276,7 @@ class McpServer { ), ) : null), - outputSchema: toolOutputSchema ?? + outputJsonSchema: toolOutputSchema ?? (outputSchemaProperties != null ? ToolOutputSchema( properties: outputSchemaProperties.map( diff --git a/lib/src/server/mcp_ui.dart b/lib/src/server/mcp_ui.dart index bf69e4c2..90a58671 100644 --- a/lib/src/server/mcp_ui.dart +++ b/lib/src/server/mcp_ui.dart @@ -10,7 +10,13 @@ class McpUiAppToolConfig { final String? title; final String? description; final ToolInputSchema? inputSchema; - final JsonSchema? outputSchema; + + /// Stable MCP `2025-11-25` object-root output schema. + final ToolOutputSchema? outputSchema; + + /// MCP `2026-07-28` draft/RC output schema with any JSON Schema root. + final JsonSchema? outputJsonSchema; + final ToolAnnotations? annotations; final Map meta; @@ -19,6 +25,7 @@ class McpUiAppToolConfig { this.description, this.inputSchema, this.outputSchema, + this.outputJsonSchema, this.annotations, required this.meta, }); @@ -90,6 +97,7 @@ RegisteredTool registerAppTool( description: config.description, inputSchema: config.inputSchema, outputSchema: config.outputSchema, + outputJsonSchema: config.outputJsonSchema, annotations: config.annotations, meta: normalizedMeta, callback: callback, diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index ab42a824..1809540d 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -26,9 +26,9 @@ class McpServerOptions extends ProtocolOptions { /// High-level protocol compatibility profile. /// /// Defaults to [McpProtocol.stable], which advertises stable MCP versions and - /// keeps MCP 2026 RC stateless behavior disabled unless explicitly requested. - /// Set this to [McpProtocol.preview2026] to enable draft-only stateless - /// methods such as `server/discover`. + /// keeps MCP `2026-07-28` draft/RC stateless behavior disabled unless + /// explicitly requested. Set this to [McpProtocol.preview2026] to enable + /// draft-only stateless methods such as `server/discover`. final McpProtocol protocol; /// Protocol versions this server advertises and accepts for this profile. diff --git a/lib/src/shared/json_schema/json_schema.dart b/lib/src/shared/json_schema/json_schema.dart index 258298b8..8b028197 100644 --- a/lib/src/shared/json_schema/json_schema.dart +++ b/lib/src/shared/json_schema/json_schema.dart @@ -13,6 +13,29 @@ int? _readOptionalInteger(Object? value, String field) { throw FormatException('$field must be an integer'); } +num? _readOptionalFiniteNumber(Object? value, String field) { + if (value == null) { + return null; + } + if (value is num && value.isFinite) { + return value; + } + throw FormatException('$field must be a finite JSON number'); +} + +int? _integerApiValue(num? value) { + if (value == null) { + return null; + } + if (value is int) { + return value; + } + if (value.isFinite && value == value.truncateToDouble()) { + return value.toInt(); + } + return null; +} + /// A builder for creating JSON Schemas in a type-safe way. sealed class JsonSchema { final String? title; @@ -20,8 +43,8 @@ sealed class JsonSchema { /// The default value for this schema. /// - /// The type of this value depends on the schema type (e.g., [String] for [JsonString], - /// [num] for [JsonNumber] and [JsonInteger], etc.). + /// The type of this value depends on the schema type (e.g., [String] for + /// [JsonString], [num] for [JsonNumber], [int] for [JsonInteger], etc.). dynamic get defaultValue; const JsonSchema({this.title, this.description}); @@ -302,14 +325,14 @@ sealed class JsonSchema { /// Creates an integer schema. static JsonInteger integer({ - num? minimum, - num? maximum, - num? exclusiveMinimum, - num? exclusiveMaximum, - num? multipleOf, + int? minimum, + int? maximum, + int? exclusiveMinimum, + int? exclusiveMaximum, + int? multipleOf, String? title, String? description, - num? defaultValue, + int? defaultValue, String? mcpHeader, }) { return JsonInteger( @@ -597,8 +620,9 @@ class JsonNumber extends JsonSchema { /// MCP `x-mcp-header` extension metadata. /// - /// MCP 2026 stateless Streamable HTTP clients mirror finite number argument - /// values into `Mcp-Param-*` headers when this metadata is present. + /// MCP `2026-07-28` draft/RC stateless Streamable HTTP clients mirror finite + /// number argument values into `Mcp-Param-*` headers when this metadata is + /// present. final String? mcpHeader; const JsonNumber({ @@ -675,60 +699,143 @@ class JsonInteger extends JsonSchema { final bool _hasDefault; final bool _hasMcpHeader; final Object? _rawMcpHeader; - final num? minimum; - final num? maximum; - final num? exclusiveMinimum; - final num? exclusiveMaximum; - final num? multipleOf; + final num? _minimum; + final num? _maximum; + final num? _exclusiveMinimum; + final num? _exclusiveMaximum; + final num? _multipleOf; + final num? _defaultValue; + + /// The stable Dart API value for the JSON Schema `minimum` constraint. + /// + /// This is `null` when a parsed wire schema uses a fractional numeric value. + /// Use [minimumJson] when validating or reserializing raw JSON Schema data. + int? get minimum => _integerApiValue(_minimum); + + /// The stable Dart API value for the JSON Schema `maximum` constraint. + /// + /// This is `null` when a parsed wire schema uses a fractional numeric value. + /// Use [maximumJson] when validating or reserializing raw JSON Schema data. + int? get maximum => _integerApiValue(_maximum); + + /// The stable Dart API value for the JSON Schema `exclusiveMinimum` + /// constraint. + /// + /// This is `null` when a parsed wire schema uses a fractional numeric value. + /// Use [exclusiveMinimumJson] when validating or reserializing raw JSON Schema + /// data. + int? get exclusiveMinimum => _integerApiValue(_exclusiveMinimum); + + /// The stable Dart API value for the JSON Schema `exclusiveMaximum` + /// constraint. + /// + /// This is `null` when a parsed wire schema uses a fractional numeric value. + /// Use [exclusiveMaximumJson] when validating or reserializing raw JSON Schema + /// data. + int? get exclusiveMaximum => _integerApiValue(_exclusiveMaximum); + + /// The stable Dart API value for the JSON Schema `multipleOf` constraint. + /// + /// This is `null` when a parsed wire schema uses a fractional numeric value. + /// Use [multipleOfJson] when validating or reserializing raw JSON Schema data. + int? get multipleOf => _integerApiValue(_multipleOf); + + /// Raw JSON Schema `minimum` constraint as parsed from the wire. + num? get minimumJson => _minimum; + + /// Raw JSON Schema `maximum` constraint as parsed from the wire. + num? get maximumJson => _maximum; + + /// Raw JSON Schema `exclusiveMinimum` constraint as parsed from the wire. + num? get exclusiveMinimumJson => _exclusiveMinimum; + + /// Raw JSON Schema `exclusiveMaximum` constraint as parsed from the wire. + num? get exclusiveMaximumJson => _exclusiveMaximum; + + /// Raw JSON Schema `multipleOf` constraint as parsed from the wire. + num? get multipleOfJson => _multipleOf; + + /// Raw JSON Schema `default` value as parsed from the wire. + num? get defaultValueJson => _defaultValue; /// MCP `x-mcp-header` extension for mirroring this parameter into HTTP. final String? mcpHeader; const JsonInteger({ - this.minimum, - this.maximum, - this.exclusiveMinimum, - this.exclusiveMaximum, - this.multipleOf, - this.defaultValue, + int? minimum, + int? maximum, + int? exclusiveMinimum, + int? exclusiveMaximum, + int? multipleOf, + int? defaultValue, super.title, super.description, this.mcpHeader, - }) : _hasDefault = defaultValue != null, + }) : _minimum = minimum, + _maximum = maximum, + _exclusiveMinimum = exclusiveMinimum, + _exclusiveMaximum = exclusiveMaximum, + _multipleOf = multipleOf, + _defaultValue = defaultValue, + _hasDefault = defaultValue != null, _hasMcpHeader = mcpHeader != null, _rawMcpHeader = mcpHeader; const JsonInteger._({ - this.minimum, - this.maximum, - this.exclusiveMinimum, - this.exclusiveMaximum, - this.multipleOf, - this.defaultValue, + num? minimum, + num? maximum, + num? exclusiveMinimum, + num? exclusiveMaximum, + num? multipleOf, + num? defaultValue, super.title, super.description, this.mcpHeader, required Object? rawMcpHeader, required bool hasDefault, required bool hasMcpHeader, - }) : _hasDefault = hasDefault, + }) : _minimum = minimum, + _maximum = maximum, + _exclusiveMinimum = exclusiveMinimum, + _exclusiveMaximum = exclusiveMaximum, + _multipleOf = multipleOf, + _defaultValue = defaultValue, + _hasDefault = hasDefault, _hasMcpHeader = hasMcpHeader, _rawMcpHeader = rawMcpHeader; @override - final num? defaultValue; + int? get defaultValue => _integerApiValue(_defaultValue); factory JsonInteger.fromJson(Map json) { final rawMcpHeader = json['x-mcp-header']; return JsonInteger._( - minimum: json['minimum'] as num?, - maximum: json['maximum'] as num?, - exclusiveMinimum: json['exclusiveMinimum'] as num?, - exclusiveMaximum: json['exclusiveMaximum'] as num?, - multipleOf: json['multipleOf'] as num?, + minimum: _readOptionalFiniteNumber( + json['minimum'], + 'JsonInteger.minimum', + ), + maximum: _readOptionalFiniteNumber( + json['maximum'], + 'JsonInteger.maximum', + ), + exclusiveMinimum: _readOptionalFiniteNumber( + json['exclusiveMinimum'], + 'JsonInteger.exclusiveMinimum', + ), + exclusiveMaximum: _readOptionalFiniteNumber( + json['exclusiveMaximum'], + 'JsonInteger.exclusiveMaximum', + ), + multipleOf: _readOptionalFiniteNumber( + json['multipleOf'], + 'JsonInteger.multipleOf', + ), title: json['title'] as String?, description: json['description'] as String?, - defaultValue: json['default'] as num?, + defaultValue: _readOptionalFiniteNumber( + json['default'], + 'JsonInteger.default', + ), mcpHeader: rawMcpHeader is String ? rawMcpHeader : null, rawMcpHeader: rawMcpHeader, hasDefault: json.containsKey('default'), @@ -741,13 +848,15 @@ class JsonInteger extends JsonSchema { return { if (title != null) 'title': title, if (description != null) 'description': description, - if (_hasDefault) 'default': defaultValue, + if (_hasDefault) 'default': defaultValueJson, 'type': 'integer', - if (minimum != null) 'minimum': minimum, - if (maximum != null) 'maximum': maximum, - if (exclusiveMinimum != null) 'exclusiveMinimum': exclusiveMinimum, - if (exclusiveMaximum != null) 'exclusiveMaximum': exclusiveMaximum, - if (multipleOf != null) 'multipleOf': multipleOf, + if (minimumJson != null) 'minimum': minimumJson, + if (maximumJson != null) 'maximum': maximumJson, + if (exclusiveMinimumJson != null) + 'exclusiveMinimum': exclusiveMinimumJson, + if (exclusiveMaximumJson != null) + 'exclusiveMaximum': exclusiveMaximumJson, + if (multipleOfJson != null) 'multipleOf': multipleOfJson, if (_hasMcpHeader) 'x-mcp-header': _rawMcpHeader, }; } @@ -1239,13 +1348,12 @@ class JsonUnion extends JsonSchema { multipleOf: null, ) => 'number', - JsonInteger( - minimum: null, - maximum: null, - exclusiveMinimum: null, - exclusiveMaximum: null, - multipleOf: null, - ) => + JsonInteger() + when schema.minimumJson == null && + schema.maximumJson == null && + schema.exclusiveMinimumJson == null && + schema.exclusiveMaximumJson == null && + schema.multipleOfJson == null => 'integer', JsonBoolean _ => 'boolean', JsonNull _ => 'null', diff --git a/lib/src/shared/json_schema/json_schema_validator.dart b/lib/src/shared/json_schema/json_schema_validator.dart index b3bbf745..38bdad82 100644 --- a/lib/src/shared/json_schema/json_schema_validator.dart +++ b/lib/src/shared/json_schema/json_schema_validator.dart @@ -159,38 +159,40 @@ extension JsonSchemaValidation on JsonSchema { ); } - if (schema.minimum != null && data < schema.minimum!) { + if (schema.minimumJson != null && data < schema.minimumJson!) { throw JsonSchemaValidationException( - 'Value must be >= ${schema.minimum}', + 'Value must be >= ${schema.minimumJson}', path, ); } - if (schema.maximum != null && data > schema.maximum!) { + if (schema.maximumJson != null && data > schema.maximumJson!) { throw JsonSchemaValidationException( - 'Value must be <= ${schema.maximum}', + 'Value must be <= ${schema.maximumJson}', path, ); } - if (schema.exclusiveMinimum != null && data <= schema.exclusiveMinimum!) { + if (schema.exclusiveMinimumJson != null && + data <= schema.exclusiveMinimumJson!) { throw JsonSchemaValidationException( - 'Value must be > ${schema.exclusiveMinimum}', + 'Value must be > ${schema.exclusiveMinimumJson}', path, ); } - if (schema.exclusiveMaximum != null && data >= schema.exclusiveMaximum!) { + if (schema.exclusiveMaximumJson != null && + data >= schema.exclusiveMaximumJson!) { throw JsonSchemaValidationException( - 'Value must be < ${schema.exclusiveMaximum}', + 'Value must be < ${schema.exclusiveMaximumJson}', path, ); } - if (schema.multipleOf != null) { - if ((data % schema.multipleOf!).abs() > 1e-10) { + if (schema.multipleOfJson != null) { + if ((data % schema.multipleOfJson!).abs() > 1e-10) { throw JsonSchemaValidationException( - 'Value must be multiple of ${schema.multipleOf}', + 'Value must be multiple of ${schema.multipleOfJson}', path, ); } diff --git a/lib/src/types.dart b/lib/src/types.dart index a8147418..efed2f4c 100644 --- a/lib/src/types.dart +++ b/lib/src/types.dart @@ -1,4 +1,5 @@ export 'types/content.dart'; +export 'types/json_value.dart'; export 'types/resources.dart'; export 'types/subscriptions.dart'; export 'types/prompts.dart'; diff --git a/lib/src/types/content.dart b/lib/src/types/content.dart index ba828516..39194249 100644 --- a/lib/src/types/content.dart +++ b/lib/src/types/content.dart @@ -305,9 +305,9 @@ class BlobResourceContents extends ResourceContents { /// Represents unknown or passthrough resource content types. /// -/// Stable MCP and MCP 2026 wire results require either text or blob content. -/// This class is retained for source compatibility, but serialization rejects -/// it because no current protocol result shape references bare +/// Stable MCP and MCP `2026-07-28` draft/RC wire results require either text or +/// blob content. This class is retained for source compatibility, but +/// serialization rejects it because no current protocol result shape references bare /// `ResourceContents`. class UnknownResourceContents extends ResourceContents { const UnknownResourceContents({ diff --git a/lib/src/types/initialization.dart b/lib/src/types/initialization.dart index 352c49b0..ee6ab405 100644 --- a/lib/src/types/initialization.dart +++ b/lib/src/types/initialization.dart @@ -952,8 +952,9 @@ class ServerCapabilitiesPrompts { class ServerCapabilitiesResources { /// Whether the server supports resource update subscriptions. /// - /// MCP 2025 uses `resources/subscribe` and `resources/unsubscribe`; MCP 2026 - /// uses `subscriptions/listen` with `resourceSubscriptions`. + /// MCP 2025 uses `resources/subscribe` and `resources/unsubscribe`; MCP + /// `2026-07-28` draft/RC uses `subscriptions/listen` with + /// `resourceSubscriptions`. final bool? subscribe; /// Whether the server supports `notifications/resources/list_changed`. diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index 68a1609f..e45cca09 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -30,23 +30,24 @@ const latestDraftProtocolVersion = draftProtocolVersion2026_07_28; /// High-level MCP protocol compatibility profiles. /// /// The SDK defaults to [stable], which keeps the 2025 initialization flow and -/// avoids draft-only behavior. Use [preview2026] to prefer the 2026 RC while -/// falling back to stable MCP servers where possible. Use [require2026] when a -/// peer must support the 2026 RC stateless protocol. +/// avoids draft-only behavior. Use [preview2026] to prefer MCP `2026-07-28` +/// draft/RC while falling back to stable MCP servers where possible. Use +/// [require2026] when a peer must support the `2026-07-28` draft/RC stateless +/// protocol. enum McpProtocol { /// Stable MCP behavior using the latest released specification. /// /// This is the default SDK profile and currently targets MCP 2025-11-25. stable, - /// Prefer the MCP 2026-07-28 RC when a peer supports it. + /// Prefer MCP `2026-07-28` draft/RC when a peer supports it. /// /// This profile enables draft-only behavior such as `server/discover`, /// stateless request metadata, and stateless result types, while allowing /// fallback to the stable `initialize` flow for older peers. preview2026, - /// Require the MCP 2026-07-28 RC stateless protocol. + /// Require the MCP `2026-07-28` draft/RC stateless protocol. /// /// This profile is intended for conformance tests and deployments where /// connecting to older MCP servers would be a configuration error. @@ -101,7 +102,7 @@ const supportedProtocolVersions = [ "2024-10-07", ]; -/// Protocol versions supported by the 2026 RC development branch. +/// Protocol versions supported by the `2026-07-28` draft/RC development branch. const supportedProtocolVersionsWithDraft = [ latestDraftProtocolVersion, draftProtocolVersion2026V1, @@ -114,7 +115,8 @@ const statelessProtocolVersions = [ draftProtocolVersion2026V1, ]; -/// Returns true when [version] uses the 2026 stateless request model. +/// Returns true when [version] uses the `2026-07-28` draft/RC stateless request +/// model. bool isStatelessProtocolVersion(String version) => statelessProtocolVersions.contains(version); @@ -132,7 +134,8 @@ String? negotiateProtocolVersion( return null; } -/// MCP-reserved `_meta` keys used by the 2026 stateless request model. +/// MCP-reserved `_meta` keys used by the `2026-07-28` draft/RC stateless +/// request model. class McpMetaKey { static const protocolVersion = 'io.modelcontextprotocol/protocolVersion'; static const clientInfo = 'io.modelcontextprotocol/clientInfo'; @@ -144,7 +147,8 @@ class McpMetaKey { const McpMetaKey._(); } -/// Builds request metadata required by the 2026 stateless request model. +/// Builds request metadata required by the `2026-07-28` draft/RC stateless +/// request model. Map buildProtocolRequestMeta({ required String protocolVersion, required Implementation clientInfo, @@ -333,11 +337,11 @@ final _metaNamePattern = RegExp( r'^(?:[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?)?$', ); -/// Validates an MCP 2026 `_meta` key name. +/// Validates an MCP `2026-07-28` draft/RC `_meta` key name. /// -/// MCP 2026 constrains metadata keys to an optional dot-separated prefix -/// followed by `/`, plus a name segment. Earlier protocol versions did not -/// define this grammar, so callers choose when to enforce it. +/// MCP `2026-07-28` draft/RC constrains metadata keys to an optional +/// dot-separated prefix followed by `/`, plus a name segment. Earlier protocol +/// versions did not define this grammar, so callers choose when to enforce it. void validateMetaKeyName(String key, {String fieldName = '_meta'}) { final slashIndex = key.indexOf('/'); final prefix = slashIndex == -1 ? null : key.substring(0, slashIndex); @@ -369,8 +373,8 @@ void validateMetaKeyName(String key, {String fieldName = '_meta'}) { /// Validates request metadata that can affect protocol behavior. /// /// `_meta.progressToken` is an MCP wire token and must be a string or integer -/// when present. [validateKeys] opts in to the MCP 2026 `_meta` -/// key-name grammar without changing stable/legacy request parsing. +/// when present. [validateKeys] opts in to the MCP `2026-07-28` draft/RC +/// `_meta` key-name grammar without changing stable/legacy request parsing. Map? validateRequestMeta( Map? meta, { bool validateKeys = false, @@ -1049,7 +1053,7 @@ void _validateInputResponse(Map json) { void _rejectInputResponseMeta(Map json, String resultName) { if (json.containsKey('_meta')) { throw FormatException( - 'InputResponse $resultName must not include _meta in MCP 2026', + 'InputResponse $resultName must not include _meta in MCP 2026-07-28', ); } } diff --git a/lib/src/types/json_value.dart b/lib/src/types/json_value.dart new file mode 100644 index 00000000..2e0d424a --- /dev/null +++ b/lib/src/types/json_value.dart @@ -0,0 +1,90 @@ +import 'validation.dart'; + +/// A validated JSON value. +/// +/// This represents the MCP `2026-07-28` draft/RC cases where protocol fields +/// may carry any JSON value instead of only an object. Prefer the typed +/// constructors such as [JsonValue.object], [JsonValue.array], or +/// [JsonValue.nullValue] at public API boundaries. +final class JsonValue { + final Object? _value; + + const JsonValue._(this._value); + + /// A JSON `null` value. + static const JsonValue nullValue = JsonValue._(null); + + /// Creates a JSON value from decoded JSON data. + factory JsonValue.fromJson(Object? value) { + return JsonValue._(readJsonValue(value, 'JsonValue')); + } + + /// Creates a JSON object value. + factory JsonValue.object(Map value) { + return JsonValue.fromJson(value); + } + + /// Creates a JSON array value. + factory JsonValue.array(List value) { + return JsonValue.fromJson(value); + } + + /// Creates a JSON string value. + factory JsonValue.string(String value) { + return JsonValue._(value); + } + + /// Creates a JSON number value. + factory JsonValue.number(num value) { + return JsonValue.fromJson(value); + } + + /// Creates a JSON boolean value. + factory JsonValue.boolean(bool value) { + return JsonValue._(value); + } + + /// Returns this value as a JSON object, or `null` for non-object values. + Map? get asObject { + final value = _value; + if (value is! Map) { + return null; + } + return readJsonObject(value, 'JsonValue'); + } + + /// Returns this value as a JSON array, or `null` for non-array values. + List? get asArray { + final value = _value; + if (value is! List) { + return null; + } + return List.unmodifiable( + value.map((item) => readJsonValue(item, 'JsonValue[]')), + ); + } + + /// Returns this value as a JSON string, or `null` for non-string values. + String? get asString { + final value = _value; + return value is String ? value : null; + } + + /// Returns this value as a JSON number, or `null` for non-number values. + num? get asNumber { + final value = _value; + return value is num ? value : null; + } + + /// Returns this value as a JSON boolean, or `null` for non-boolean values. + bool? get asBoolean { + final value = _value; + return value is bool ? value : null; + } + + /// Whether this value is JSON `null`. + bool get isNull => _value == null; + + /// Returns decoded JSON suitable for wire serialization. + Object? toJson() => readJsonValue(_value, 'JsonValue'); +} diff --git a/lib/src/types/sampling.dart b/lib/src/types/sampling.dart index 609942c7..87bfbbee 100644 --- a/lib/src/types/sampling.dart +++ b/lib/src/types/sampling.dart @@ -1,4 +1,5 @@ import 'content.dart'; +import 'json_value.dart'; import 'json_rpc.dart'; import 'tasks.dart'; import 'tools.dart'; @@ -390,7 +391,7 @@ sealed class SamplingContent { 'content': c.contentBlocks.map((item) => item.toJson()).toList(), if (c.hasStructuredContent) 'structuredContent': readJsonValue( - c.structuredContent, + c.structuredContentJson?.toJson(), 'SamplingToolResultContent.structuredContent', ), if (c.isError != null) 'isError': c.isError, @@ -545,7 +546,34 @@ class SamplingToolUseContent extends SamplingContent { class SamplingToolResultContent extends SamplingContent { final String toolUseId; final dynamic content; - final Object? structuredContent; + final Map? _structuredContent; + final JsonValue? _structuredContentValue; + + /// Object-root structured content returned by the tool. + /// + /// Stable MCP `2025-11-25` tool results use an object here. When working + /// with MCP `2026-07-28` draft/RC peers, use [structuredContentJson] to read + /// non-object JSON values such as arrays, strings, numbers, booleans, or an + /// explicit JSON `null`. + Map? get structuredContent { + return _structuredContentValue?.asObject ?? _structuredContent; + } + + /// Structured content returned by an MCP `2026-07-28` draft/RC tool result. + /// + /// This exposes the wire-level JSON value and may be an object, array, + /// string, number, boolean, or null. Use [hasStructuredContent] to distinguish + /// an omitted field from an explicit JSON `null`. + JsonValue? get structuredContentJson { + if (!hasStructuredContent) { + return null; + } + return _structuredContentValue ?? + (structuredContent == null + ? JsonValue.nullValue + : JsonValue.object(structuredContent!)); + } + final bool hasStructuredContent; final bool? isError; final Map? meta; @@ -553,12 +581,15 @@ class SamplingToolResultContent extends SamplingContent { const SamplingToolResultContent({ required this.toolUseId, required this.content, - this.structuredContent, + Map? structuredContent, + JsonValue? structuredContentJson, bool? hasStructuredContent, this.isError, this.meta, - }) : hasStructuredContent = - hasStructuredContent ?? structuredContent != null, + }) : hasStructuredContent = hasStructuredContent ?? + (structuredContentJson != null || structuredContent != null), + _structuredContent = structuredContent, + _structuredContentValue = structuredContentJson, super(type: 'tool_result'); /// Normalized content blocks for tool results. @@ -576,11 +607,8 @@ class SamplingToolResultContent extends SamplingContent { 'SamplingToolResultContent.toolUseId', ), content: _parseToolResultWireContent(json['content']), - structuredContent: json.containsKey('structuredContent') - ? readJsonValue( - json['structuredContent'], - 'SamplingToolResultContent.structuredContent', - ) + structuredContentJson: json.containsKey('structuredContent') + ? JsonValue.fromJson(json['structuredContent']) : null, hasStructuredContent: json.containsKey('structuredContent'), isError: readOptionalBool( diff --git a/lib/src/types/tools.dart b/lib/src/types/tools.dart index 9a48acea..5b1a7ad8 100644 --- a/lib/src/types/tools.dart +++ b/lib/src/types/tools.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import '../shared/json_schema/json_schema.dart'; import 'content.dart'; +import 'json_value.dart'; import 'json_rpc.dart'; import 'validation.dart'; @@ -11,8 +12,8 @@ typedef ToolInputSchema = JsonObject; /// Legacy alias for object-root tool output schemas. /// -/// MCP 2026-07-28 allows [Tool.outputSchema] to be any JSON Schema. Use -/// [JsonSchema] directly when the output schema root is not an object. +/// MCP `2026-07-28` draft/RC allows [Tool.outputSchema] to be any JSON Schema. +/// Use [JsonSchema] directly when the output schema root is not an object. typedef ToolOutputSchema = JsonObject; void _expectJsonRpcMethod( @@ -420,11 +421,33 @@ class CallToolResult implements BaseResultData { /// Whether the tool call returned an error. final bool isError; - /// Structured content returned by the tool. + final Map? _structuredContent; + final JsonValue? _structuredContentValue; + + /// Object-root structured content returned by the tool. /// - /// MCP 2026-07-28 allows any JSON value: object, array, string, number, - /// boolean, or null. - final Object? structuredContent; + /// Stable MCP `2025-11-25` tool results use an object here. When working + /// with MCP `2026-07-28` draft/RC peers, use [structuredContentJson] to read + /// non-object JSON values such as arrays, strings, numbers, booleans, or an + /// explicit JSON `null`. + Map? get structuredContent { + return _structuredContentValue?.asObject ?? _structuredContent; + } + + /// Structured content returned by an MCP `2026-07-28` draft/RC tool call. + /// + /// This exposes the wire-level JSON value and may be an object, array, + /// string, number, boolean, or null. Use [hasStructuredContent] to distinguish + /// an omitted field from an explicit JSON `null`. + JsonValue? get structuredContentJson { + if (!hasStructuredContent) { + return null; + } + return _structuredContentValue ?? + (structuredContent == null + ? JsonValue.nullValue + : JsonValue.object(structuredContent!)); + } /// Whether [structuredContent] was explicitly present. /// @@ -441,30 +464,68 @@ class CallToolResult implements BaseResultData { const CallToolResult({ required this.content, this.isError = false, - this.structuredContent, + Map? structuredContent, + JsonValue? structuredContentJson, bool? hasStructuredContent, this.meta, this.extra, - }) : hasStructuredContent = hasStructuredContent ?? structuredContent != null; + }) : _structuredContent = structuredContent, + _structuredContentValue = structuredContentJson, + hasStructuredContent = hasStructuredContent ?? + (structuredContentJson != null || structuredContent != null); /// Creates a result from a list of content items. factory CallToolResult.fromContent(List content) { return CallToolResult(content: content); } - /// Creates a result from arbitrary structured JSON data. + /// Creates a result from object-root structured content. /// /// Automatically populates [content] with a JSON-serialized version of /// [content] for backward compatibility with clients that do not support /// [structuredContent]. - factory CallToolResult.fromStructuredContent(Object? content) { + factory CallToolResult.fromStructuredContent(Map content) { + return CallToolResult.fromStructuredValue(JsonValue.object(content)); + } + + /// Creates a result from arbitrary MCP `2026-07-28` draft/RC structured JSON. + /// + /// This may be any JSON value, including arrays and explicit JSON `null`. + /// Stable MCP `2025-11-25` callers receive only the JSON-serialized + /// [content] fallback when the structured value is not an object. + factory CallToolResult.fromStructuredValue(JsonValue content) { return CallToolResult( - content: [TextContent(text: jsonEncode(content))], - structuredContent: content, + content: [TextContent(text: jsonEncode(content.toJson()))], + structuredContentJson: content, hasStructuredContent: true, ); } + /// Creates a result from MCP `2026-07-28` draft/RC array structured content. + factory CallToolResult.fromStructuredArray(List content) { + return CallToolResult.fromStructuredValue(JsonValue.array(content)); + } + + /// Creates a result from MCP `2026-07-28` draft/RC string structured content. + factory CallToolResult.fromStructuredString(String content) { + return CallToolResult.fromStructuredValue(JsonValue.string(content)); + } + + /// Creates a result from MCP `2026-07-28` draft/RC number structured content. + factory CallToolResult.fromStructuredNumber(num content) { + return CallToolResult.fromStructuredValue(JsonValue.number(content)); + } + + /// Creates a result from MCP `2026-07-28` draft/RC boolean structured content. + factory CallToolResult.fromStructuredBoolean(bool content) { + return CallToolResult.fromStructuredValue(JsonValue.boolean(content)); + } + + /// Creates a result from MCP `2026-07-28` draft/RC null structured content. + factory CallToolResult.fromStructuredNull() { + return CallToolResult.fromStructuredValue(JsonValue.nullValue); + } + factory CallToolResult.fromJson(Map json) { final knownKeys = {'content', 'isError', '_meta', 'structuredContent'}; final extra = Map.from(json) @@ -483,11 +544,8 @@ class CallToolResult implements BaseResultData { ], isError: readOptionalBool(json['isError'], 'CallToolResult.isError') ?? false, - structuredContent: json.containsKey('structuredContent') - ? readJsonValue( - json['structuredContent'], - 'CallToolResult.structuredContent', - ) + structuredContentJson: json.containsKey('structuredContent') + ? JsonValue.fromJson(json['structuredContent']) : null, hasStructuredContent: json.containsKey('structuredContent'), meta: readOptionalJsonObject(json['_meta'], 'CallToolResult._meta'), @@ -502,7 +560,7 @@ class CallToolResult implements BaseResultData { if (isError) 'isError': isError, if (hasStructuredContent) 'structuredContent': readJsonValue( - structuredContent, + structuredContentJson?.toJson(), 'CallToolResult.structuredContent', ), if (meta != null) '_meta': readJsonObject(meta, 'CallToolResult._meta'), diff --git a/packages/mcp_dart_cli/CHANGELOG.md b/packages/mcp_dart_cli/CHANGELOG.md index f1334eb3..561f34ce 100644 --- a/packages/mcp_dart_cli/CHANGELOG.md +++ b/packages/mcp_dart_cli/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.2.0-dev.0 + +- Prepare the CLI for the MCP `2026-07-28` draft/RC SDK dev line with a + dependency on `mcp_dart ^2.3.0-dev.0`. +- Keep the local monorepo SDK override in `pubspec_overrides.yaml` so published + CLI pubspec metadata does not expose path overrides. + ## 0.1.9 - Add `mcp_dart inspect-server` for structured MCP server inspection reports diff --git a/packages/mcp_dart_cli/README.md b/packages/mcp_dart_cli/README.md index e0ec836e..0f2df668 100644 --- a/packages/mcp_dart_cli/README.md +++ b/packages/mcp_dart_cli/README.md @@ -441,6 +441,25 @@ To run the tests for this package: dart test ``` +## Release Validation + +The CLI package lives under `packages/`, while the root SDK package excludes +that directory from its own pub archive. Run CLI publish validation from an +exported tree outside the monorepo git/.pubignore context: + +```bash +dart run tool/validate_cli_publish.dart +``` + +Before the matching `mcp_dart` SDK dev package is published, this uses +`pubspec_overrides.yaml` so the CLI can validate against the local SDK checkout. +After publishing the SDK package, validate the CLI against the pub.dev SDK +version: + +```bash +dart run tool/validate_cli_publish.dart --published-sdk +``` + ## Contributing Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to this project. diff --git a/packages/mcp_dart_cli/lib/src/inspect_server_command.dart b/packages/mcp_dart_cli/lib/src/inspect_server_command.dart index 5cac12cd..c9f4c2b0 100644 --- a/packages/mcp_dart_cli/lib/src/inspect_server_command.dart +++ b/packages/mcp_dart_cli/lib/src/inspect_server_command.dart @@ -588,7 +588,7 @@ class McpServerInspector { } try { - tool.outputSchema!.validate(result.structuredContent); + tool.outputSchema!.validate(result.structuredContentJson?.toJson()); checks.pass( 'tools.output-schema.${tool.name}', 'Structured output for ${tool.name} matched its outputSchema.', diff --git a/packages/mcp_dart_cli/lib/src/version.dart b/packages/mcp_dart_cli/lib/src/version.dart index b8e13b9f..20611918 100644 --- a/packages/mcp_dart_cli/lib/src/version.dart +++ b/packages/mcp_dart_cli/lib/src/version.dart @@ -1 +1 @@ -const packageVersion = '0.1.9'; +const packageVersion = '0.2.0-dev.0'; diff --git a/packages/mcp_dart_cli/pubspec.yaml b/packages/mcp_dart_cli/pubspec.yaml index 71e16ce6..fbc8dcc6 100644 --- a/packages/mcp_dart_cli/pubspec.yaml +++ b/packages/mcp_dart_cli/pubspec.yaml @@ -1,6 +1,6 @@ name: mcp_dart_cli description: Command-line tools for creating, serving, inspecting, and testing Dart Model Context Protocol (MCP) servers. -version: 0.1.9 +version: 0.2.0-dev.0 repository: https://github.com/leehack/mcp_dart homepage: https://github.com/leehack/mcp_dart/tree/main/packages/mcp_dart_cli issue_tracker: https://github.com/leehack/mcp_dart/issues @@ -27,15 +27,11 @@ dependencies: stream_transform: ^2.1.1 watcher: ^1.2.0 yaml: ^3.1.3 - mcp_dart: ^2.2.0 + mcp_dart: ^2.3.0-dev.0 mason_logger: ^0.3.3 meta: ^1.17.0 pub_updater: ^0.5.0 -dependency_overrides: - mcp_dart: - path: ../../ - dev_dependencies: build_runner: ^2.10.4 lints: ^6.0.0 diff --git a/packages/mcp_dart_cli/pubspec_overrides.yaml b/packages/mcp_dart_cli/pubspec_overrides.yaml new file mode 100644 index 00000000..fb7b9613 --- /dev/null +++ b/packages/mcp_dart_cli/pubspec_overrides.yaml @@ -0,0 +1,3 @@ +dependency_overrides: + mcp_dart: + path: ../../ diff --git a/packages/mcp_dart_cli/test/fixtures/dart_mcp_project/pubspec.yaml b/packages/mcp_dart_cli/test/fixtures/dart_mcp_project/pubspec.yaml index ffc40105..ae18d1d5 100644 --- a/packages/mcp_dart_cli/test/fixtures/dart_mcp_project/pubspec.yaml +++ b/packages/mcp_dart_cli/test/fixtures/dart_mcp_project/pubspec.yaml @@ -9,7 +9,7 @@ environment: dependencies: args: ^2.6.0 logging: ^1.3.0 - mcp_dart: ^2.2.0 + mcp_dart: ^2.3.0-dev.0 mcp_dart_cli: path: ../../.. diff --git a/pubspec.yaml b/pubspec.yaml index 7cdfae8d..cfaad5fa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: mcp_dart description: Dart and Flutter SDK for building Model Context Protocol (MCP) servers, clients, hosts, and AI tools. -version: 2.2.0 +version: 2.3.0-dev.0 repository: https://github.com/leehack/mcp_dart homepage: https://github.com/leehack/mcp_dart issue_tracker: https://github.com/leehack/mcp_dart/issues diff --git a/test/client/client_tool_validation_test.dart b/test/client/client_tool_validation_test.dart index 401a11b5..0970d7a5 100644 --- a/test/client/client_tool_validation_test.dart +++ b/test/client/client_tool_validation_test.dart @@ -74,15 +74,15 @@ class MockTransport extends Transport _respond( JsonRpcResponse( id: message.id, - result: CallToolResult.fromStructuredContent(['alpha', 'beta']) - .toJson(), + result: + CallToolResult.fromStructuredArray(['alpha', 'beta']).toJson(), ), ); } else if (name == 'broken_array_tool') { _respond( JsonRpcResponse( id: message.id, - result: CallToolResult.fromStructuredContent(['alpha', 1]).toJson(), + result: CallToolResult.fromStructuredArray(['alpha', 1]).toJson(), ), ); } else if (name == 'broken_tool') { @@ -300,7 +300,7 @@ void main() { const CallToolRequest(name: 'array_tool'), ); - expect(result.structuredContent, equals(['alpha', 'beta'])); + expect(result.structuredContentJson?.toJson(), equals(['alpha', 'beta'])); }); test('throws when non-object tool output validation fails', () async { diff --git a/test/conformance/README.md b/test/conformance/README.md index 66ceb498..35032fe3 100644 --- a/test/conformance/README.md +++ b/test/conformance/README.md @@ -1,21 +1,21 @@ # MCP Conformance -This directory contains conformance harnesses for stable MCP 2025-11-25 and the -unreleased MCP 2026 RC suite. These fixtures are intentionally separate from the -cross-SDK interop tests because the official conformance package calls -hard-coded diagnostic tools, prompts, and resources. +This directory contains conformance harnesses for stable MCP `2025-11-25` and +the unreleased MCP `2026-07-28` draft/RC suite. These fixtures are intentionally +separate from the cross-SDK interop tests because the official conformance +package calls hard-coded diagnostic tools, prompts, and resources. ## CI Coverage -Core CI runs the official stable 2025 and 2026 RC client/server conformance -suites from `.github/workflows/test_core.yml`. The server suites use dedicated -fixtures because the official conformance package calls hard-coded diagnostic -tools, prompts, and resources. +Core CI runs the official stable `2025-11-25` and `2026-07-28` draft/RC +client/server conformance suites from `.github/workflows/test_core.yml`. The +server suites use dedicated fixtures because the official conformance package +calls hard-coded diagnostic tools, prompts, and resources. -The 2026 suite still targets an RC/alpha spec package. If the official suite -changes before the spec is final, record intentional temporary gaps in +The 2026 suite still targets a draft/RC alpha spec package. If the official +suite changes before the spec is final, record intentional temporary gaps in `2026_rc_expected_failures.txt` or `2026_rc_client_expected_failures.txt` so CI -distinguishes known RC churn from regressions. +distinguishes known draft/RC churn from regressions. ## Stable MCP 2025-11-25 @@ -45,7 +45,7 @@ The stable client suite reuses the dual-stack conformance client fixture because the fixture negotiates whichever protocol version the conformance scenario server offers. -## MCP 2026 RC +## MCP 2026-07-28 Draft/RC Run the current server baseline from the repository root: diff --git a/test/server/output_validation_test.dart b/test/server/output_validation_test.dart index 4799381c..455f3805 100644 --- a/test/server/output_validation_test.dart +++ b/test/server/output_validation_test.dart @@ -98,9 +98,9 @@ void main() { ); mcpServer.registerTool( 'array_tool', - outputSchema: JsonSchema.array(items: JsonSchema.string()), + outputJsonSchema: JsonSchema.array(items: JsonSchema.string()), callback: (args, extra) async { - return CallToolResult.fromStructuredContent(['alpha', 'beta']); + return CallToolResult.fromStructuredArray(['alpha', 'beta']); }, ); @@ -118,7 +118,7 @@ void main() { expect(response, isA()); final successResponse = response as JsonRpcResponse; final result = CallToolResult.fromJson(successResponse.result); - expect(result.structuredContent, equals(['alpha', 'beta'])); + expect(result.structuredContentJson?.toJson(), equals(['alpha', 'beta'])); }); test('non-object output schema validation failures are rejected', () async { @@ -133,9 +133,9 @@ void main() { ); mcpServer.registerTool( 'invalid_array_tool', - outputSchema: JsonSchema.array(items: JsonSchema.string()), + outputJsonSchema: JsonSchema.array(items: JsonSchema.string()), callback: (args, extra) async { - return CallToolResult.fromStructuredContent(['alpha', 1]); + return CallToolResult.fromStructuredArray(['alpha', 1]); }, ); @@ -159,9 +159,9 @@ void main() { test('stable tools/list omits non-object output schemas', () async { mcpServer.registerTool( 'array_tool', - outputSchema: JsonSchema.array(items: JsonSchema.string()), + outputJsonSchema: JsonSchema.array(items: JsonSchema.string()), callback: (args, extra) async { - return CallToolResult.fromStructuredContent(['alpha', 'beta']); + return CallToolResult.fromStructuredArray(['alpha', 'beta']); }, ); @@ -191,9 +191,9 @@ void main() { ); mcpServer.registerTool( 'array_tool', - outputSchema: JsonSchema.array(items: JsonSchema.string()), + outputJsonSchema: JsonSchema.array(items: JsonSchema.string()), callback: (args, extra) async { - return CallToolResult.fromStructuredContent(['alpha', 'beta']); + return CallToolResult.fromStructuredArray(['alpha', 'beta']); }, ); @@ -219,9 +219,9 @@ void main() { test('stable tool calls omit non-object structured content', () async { mcpServer.registerTool( 'array_tool', - outputSchema: JsonSchema.array(items: JsonSchema.string()), + outputJsonSchema: JsonSchema.array(items: JsonSchema.string()), callback: (args, extra) async { - return CallToolResult.fromStructuredContent(['alpha', 'beta']); + return CallToolResult.fromStructuredArray(['alpha', 'beta']); }, ); diff --git a/test/shared/json_schema_from_json_test.dart b/test/shared/json_schema_from_json_test.dart index 153a9d71..77ccd5b9 100644 --- a/test/shared/json_schema_from_json_test.dart +++ b/test/shared/json_schema_from_json_test.dart @@ -218,12 +218,18 @@ void main() { final schema = JsonSchema.fromJson(json); expect(schema, isA()); final s = schema as JsonInteger; - expect(s.minimum, 1.5); - expect(s.maximum, 10.5); - expect(s.exclusiveMinimum, 0.5); - expect(s.exclusiveMaximum, 11.5); - expect(s.multipleOf, 0.5); - expect(s.defaultValue, 2.0); + expect(s.minimum, isNull); + expect(s.maximum, isNull); + expect(s.exclusiveMinimum, isNull); + expect(s.exclusiveMaximum, isNull); + expect(s.multipleOf, isNull); + expect(s.defaultValue, 2); + expect(s.minimumJson, 1.5); + expect(s.maximumJson, 10.5); + expect(s.exclusiveMinimumJson, 0.5); + expect(s.exclusiveMaximumJson, 11.5); + expect(s.multipleOfJson, 0.5); + expect(s.defaultValueJson, 2.0); expect(s.toJson(), json); }); diff --git a/test/shared/json_schema_validator_test.dart b/test/shared/json_schema_validator_test.dart index ec46f11f..f766efdc 100644 --- a/test/shared/json_schema_validator_test.dart +++ b/test/shared/json_schema_validator_test.dart @@ -160,7 +160,10 @@ void main() { }); test('validates exclusiveMinimum', () { - final schema = JsonSchema.integer(exclusiveMinimum: 5.5); + final schema = JsonSchema.fromJson({ + 'type': 'integer', + 'exclusiveMinimum': 5.5, + }); schema.validate(6); expect( () => schema.validate(5), @@ -169,7 +172,10 @@ void main() { }); test('validates exclusiveMaximum', () { - final schema = JsonSchema.integer(exclusiveMaximum: 10.5); + final schema = JsonSchema.fromJson({ + 'type': 'integer', + 'exclusiveMaximum': 10.5, + }); schema.validate(9); schema.validate(10); expect( @@ -179,7 +185,10 @@ void main() { }); test('validates multipleOf', () { - final schema = JsonSchema.integer(multipleOf: 1.5); + final schema = JsonSchema.fromJson({ + 'type': 'integer', + 'multipleOf': 1.5, + }); schema.validate(3); schema.validate(6); expect( diff --git a/test/tool_schema_test.dart b/test/tool_schema_test.dart index 14664f13..d127adef 100644 --- a/test/tool_schema_test.dart +++ b/test/tool_schema_test.dart @@ -212,24 +212,26 @@ void main() { ]; for (final value in values) { - final result = CallToolResult.fromStructuredContent(value); + final result = CallToolResult.fromStructuredValue( + JsonValue.fromJson(value), + ); final json = result.toJson(); expect(json['structuredContent'], equals(value)); final parsed = CallToolResult.fromJson(json); expect(parsed.hasStructuredContent, isTrue); - expect(parsed.structuredContent, equals(value)); + expect(parsed.structuredContentJson?.toJson(), equals(value)); } - final nullResult = CallToolResult.fromStructuredContent(null); + final nullResult = CallToolResult.fromStructuredNull(); final nullJson = nullResult.toJson(); expect(nullJson.containsKey('structuredContent'), isTrue); expect(nullJson['structuredContent'], isNull); final parsedNull = CallToolResult.fromJson(nullJson); expect(parsedNull.hasStructuredContent, isTrue); - expect(parsedNull.structuredContent, isNull); + expect(parsedNull.structuredContentJson?.toJson(), isNull); }); test('Tool JSON object fields reject non-JSON Dart map values', () { diff --git a/test/types/sampling_test.dart b/test/types/sampling_test.dart index 477edae9..d4a553c0 100644 --- a/test/types/sampling_test.dart +++ b/test/types/sampling_test.dart @@ -1,4 +1,5 @@ import 'package:mcp_dart/src/types/content.dart'; +import 'package:mcp_dart/src/types/json_value.dart'; import 'package:mcp_dart/src/types/json_rpc.dart'; import 'package:mcp_dart/src/types/sampling.dart'; import 'package:test/test.dart'; @@ -493,12 +494,12 @@ void main() { }); test('toJson preserves arbitrary structured JSON values', () { - const content = SamplingToolResultContent( + final content = SamplingToolResultContent( toolUseId: 'res1', content: [ - TextContent(text: 'array result'), + const TextContent(text: 'array result'), ], - structuredContent: ['alpha', 'beta'], + structuredContentJson: JsonValue.array(['alpha', 'beta']), ); final json = content.toJson(); expect(json['structuredContent'], equals(['alpha', 'beta'])); @@ -508,7 +509,7 @@ void main() { content: [ TextContent(text: 'null result'), ], - structuredContent: null, + structuredContentJson: JsonValue.nullValue, hasStructuredContent: true, ); final nullJson = nullContent.toJson(); @@ -549,7 +550,10 @@ void main() { expect(content, isA()); final result = content as SamplingToolResultContent; expect(result.hasStructuredContent, isTrue); - expect(result.structuredContent, equals(['alpha', 'beta'])); + expect( + result.structuredContentJson?.toJson(), + equals(['alpha', 'beta']), + ); final nullJson = { 'type': 'tool_result', @@ -562,7 +566,7 @@ void main() { final nullContent = SamplingContent.fromJson(nullJson) as SamplingToolResultContent; expect(nullContent.hasStructuredContent, isTrue); - expect(nullContent.structuredContent, isNull); + expect(nullContent.structuredContentJson?.toJson(), isNull); }); test('rejects malformed tool result wire fields', () { diff --git a/tool/validate_cli_publish.dart b/tool/validate_cli_publish.dart new file mode 100644 index 00000000..f4d2a2a4 --- /dev/null +++ b/tool/validate_cli_publish.dart @@ -0,0 +1,205 @@ +import 'dart:async'; +import 'dart:io'; + +Future main(List args) async { + final options = _Options.parse(args); + if (options.showHelp) { + _printUsage(); + return; + } + + final repoRoot = _repoRoot(); + final outputRoot = options.outputPath == null + ? Directory.systemTemp.createTempSync('mcp_dart_cli_publish_') + : Directory(options.outputPath!).absolute; + + if (outputRoot.existsSync()) { + if (outputRoot.listSync().isNotEmpty) { + stderr.writeln('Output directory must be empty: ${outputRoot.path}'); + exitCode = 64; + return; + } + } else { + outputRoot.createSync(recursive: true); + } + + _ensureOutputOutsideRepo(repoRoot, outputRoot); + _copyDirectory(repoRoot, outputRoot); + + final cliDir = Directory( + _join(outputRoot.path, ['packages', 'mcp_dart_cli']), + ); + + if (options.usePublishedSdk) { + final overrides = File(_join(cliDir.path, ['pubspec_overrides.yaml'])); + if (overrides.existsSync()) { + overrides.deleteSync(); + } + } + + stdout.writeln('Exported CLI publish tree to ${cliDir.path}'); + + if (!options.runDryRun) { + stdout.writeln('Run: cd ${cliDir.path} && dart pub publish --dry-run'); + return; + } + + await _run(['dart', 'pub', 'get'], workingDirectory: cliDir.path); + await _run( + ['dart', 'pub', 'publish', '--dry-run'], + workingDirectory: cliDir.path, + ); +} + +Directory _repoRoot() { + final script = File(Platform.script.toFilePath()); + return script.parent.parent.absolute; +} + +void _ensureOutputOutsideRepo(Directory repoRoot, Directory outputRoot) { + final repoPath = _normalized(repoRoot.path); + final outputPath = _normalized(outputRoot.path); + if (outputPath == repoPath || outputPath.startsWith('$repoPath/')) { + stderr.writeln( + 'Output directory must be outside the repository so parent .pubignore ' + 'files do not affect the nested CLI package archive.', + ); + exit(64); + } +} + +void _copyDirectory(Directory source, Directory target) { + for (final entity in source.listSync(followLinks: false)) { + final name = _basename(entity.path); + if (_excludedNames.contains(name)) { + continue; + } + + final targetPath = _join(target.path, [name]); + if (entity is Directory) { + final nextTarget = Directory(targetPath)..createSync(); + _copyDirectory(entity, nextTarget); + } else if (entity is File) { + entity.copySync(targetPath); + } else if (entity is Link) { + final link = Link(targetPath); + link.createSync(entity.targetSync(), recursive: true); + } + } +} + +Future _run( + List command, { + required String workingDirectory, +}) async { + stdout.writeln('Running: ${command.join(' ')}'); + final process = await Process.start( + command.first, + command.sublist(1), + workingDirectory: workingDirectory, + runInShell: Platform.isWindows, + ); + final stdoutDone = stdout.addStream(process.stdout); + final stderrDone = stderr.addStream(process.stderr); + final code = await process.exitCode; + await Future.wait([stdoutDone, stderrDone]); + + if (code != 0) { + exit(code); + } +} + +String _join(String first, List rest) { + var result = first; + for (final part in rest) { + if (result.endsWith(Platform.pathSeparator)) { + result = '$result$part'; + } else { + result = '$result${Platform.pathSeparator}$part'; + } + } + return result; +} + +String _basename(String path) { + final normalized = path.replaceAll('\\', '/'); + return normalized.substring(normalized.lastIndexOf('/') + 1); +} + +String _normalized(String path) { + return Directory(path).absolute.path.replaceAll('\\', '/'); +} + +void _printUsage() { + stdout.writeln(''' +Usage: dart run tool/validate_cli_publish.dart [options] + +Exports packages/mcp_dart_cli outside the monorepo git/.pubignore context and +runs dart pub publish --dry-run from that exported package. + +Options: + --output Export to an empty directory outside the repository. + --published-sdk Remove pubspec_overrides.yaml so the CLI resolves the + SDK version from pub.dev. Use after publishing mcp_dart. + --no-dry-run Export only; print the publish command. + --help Print this help. +'''); +} + +const _excludedNames = { + '.dart_tool', + '.git', + 'build', + 'coverage', + 'pubspec.lock', +}; + +class _Options { + final String? outputPath; + final bool runDryRun; + final bool usePublishedSdk; + final bool showHelp; + + const _Options({ + required this.outputPath, + required this.runDryRun, + required this.usePublishedSdk, + required this.showHelp, + }); + + factory _Options.parse(List args) { + String? outputPath; + var runDryRun = true; + var usePublishedSdk = false; + var showHelp = false; + + for (var i = 0; i < args.length; i += 1) { + final arg = args[i]; + switch (arg) { + case '--output': + if (i + 1 >= args.length) { + stderr.writeln('--output requires a directory path.'); + exit(64); + } + outputPath = args[++i]; + case '--published-sdk': + usePublishedSdk = true; + case '--no-dry-run': + runDryRun = false; + case '--help': + case '-h': + showHelp = true; + default: + stderr.writeln('Unknown option: $arg'); + exit(64); + } + } + + return _Options( + outputPath: outputPath, + runDryRun: runDryRun, + usePublishedSdk: usePublishedSdk, + showHelp: showHelp, + ); + } +} From d2d4a10222c369da87dfa82fa7dafda94f63eb11 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Wed, 3 Jun 2026 13:35:54 -0400 Subject: [PATCH 45/68] Document deprecated public APIs --- lib/src/server/mcp_server.dart | 15 ++++++++++++--- lib/src/server/sse.dart | 8 +++++++- lib/src/server/sse_server_manager.dart | 6 +++++- lib/src/types/json_rpc.dart | 8 ++++++++ lib/src/types/tools.dart | 2 ++ 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index 49b71356..bda5198a 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -161,7 +161,8 @@ typedef ToolFunction = FutureOr Function( RequestHandlerExtra extra, ); -/// Legacy callback signature for tools (deprecated style). +/// Legacy callback signature for the deprecated [McpServer.tool] helper. +@Deprecated('Use ToolFunction with registerTool instead') typedef LegacyToolCallback = FutureOr Function({ Map? args, RequestHandlerExtra? extra, @@ -2240,16 +2241,24 @@ class McpServer { ); } - /// Registers a tool the client can invoke. - /// Registers a tool the client can invoke. + /// Deprecated helper that registers a tool the client can invoke. + /// + /// Use [registerTool] with [ToolFunction]. The + /// [inputSchemaProperties] and [outputSchemaProperties] parameters are + /// deprecated compatibility shims for [toolInputSchema] and + /// [toolOutputSchema]. @Deprecated('Use registerTool instead') RegisteredTool tool( String name, { String? description, ToolInputSchema? toolInputSchema, ToolOutputSchema? toolOutputSchema, + + /// Deprecated schema-property map shim. Use [toolInputSchema]. @Deprecated('Use toolInputSchema instead') Map? inputSchemaProperties, + + /// Deprecated schema-property map shim. Use [toolOutputSchema]. @Deprecated('Use toolOutputSchema instead') Map? outputSchemaProperties, ToolAnnotations? annotations, diff --git a/lib/src/server/sse.dart b/lib/src/server/sse.dart index 47808731..66b49b85 100644 --- a/lib/src/server/sse.dart +++ b/lib/src/server/sse.dart @@ -13,7 +13,12 @@ final _logger = Logger("mcp_dart.server.sse"); /// Maximum size for incoming POST message bodies. const int _maximumMessageSize = 4 * 1024 * 1024; // 4MB in bytes -/// Server transport for SSE: sends messages over a persistent SSE connection +/// Legacy server transport for SSE. +/// +/// Prefer `StreamableHTTPServerTransport` for new servers. This transport is +/// retained for backward compatibility with existing SSE integrations. +/// +/// Sends messages over a persistent SSE connection /// ([HttpResponse]) and receives messages from separate HTTP POST requests /// handled by [handlePostMessage]. /// @@ -21,6 +26,7 @@ const int _maximumMessageSize = 4 * 1024 * 1024; // 4MB in bytes /// `HttpServer` or frameworks like Shelf/Alfred). The `start` method manages /// the SSE response stream, while `handlePostMessage` should be called from /// the server's routing logic for the designated message endpoint. +@Deprecated('Use StreamableHTTPServerTransport instead') class SseServerTransport implements Transport { StringConversionSink? _sink; final HttpResponse _sseResponse; diff --git a/lib/src/server/sse_server_manager.dart b/lib/src/server/sse_server_manager.dart index a427cf26..d081bd79 100644 --- a/lib/src/server/sse_server_manager.dart +++ b/lib/src/server/sse_server_manager.dart @@ -8,7 +8,11 @@ import 'sse.dart'; final _logger = Logger("mcp_dart.server.sse.manager"); -/// Manages Server-Sent Events (SSE) connections and routes HTTP requests. +/// Legacy manager for Server-Sent Events (SSE) connections. +/// +/// Prefer `StreamableHTTPServerTransport` for new servers. This manager is +/// retained for backward compatibility with existing SSE integrations. +@Deprecated('Use StreamableHTTPServerTransport instead') class SseServerManager { /// Map to store active SSE transports, keyed by session ID. final Map activeSseTransports = {}; diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index e45cca09..dc255007 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -211,6 +211,11 @@ class Method { "notifications/prompts/list_changed"; static const notificationsToolsListChanged = "notifications/tools/list_changed"; + + /// Deprecated completion list-change notification method. + /// + /// Stable MCP `2025-11-25` does not include this method. Use + /// [notificationsExperimentalCompletionsListChanged] for extension behavior. @Deprecated( 'notifications/completions/list_changed is not part of stable MCP 2025-11-25. ' 'Use notifications/experimental/completions/list_changed for extension behavior.', @@ -1179,6 +1184,9 @@ class JsonRpcListToolsRequest extends JsonRpcRequest { super.meta, }) : super(method: Method.toolsList); + /// Deprecated typed-params constructor retained for compatibility. + /// + /// Prefer passing `params?.toJson()` to [JsonRpcListToolsRequest]. @Deprecated( 'Use JsonRpcListToolsRequest(id: ..., params: params?.toJson(), meta: meta) instead.', ) diff --git a/lib/src/types/tools.dart b/lib/src/types/tools.dart index 5b1a7ad8..81cd392c 100644 --- a/lib/src/types/tools.dart +++ b/lib/src/types/tools.dart @@ -294,6 +294,7 @@ class ListToolsRequest { }; } +/// Deprecated alias for [ListToolsRequest]. @Deprecated('Use [ListToolsRequest] instead.') typedef ListToolsRequestParams = ListToolsRequest; @@ -362,6 +363,7 @@ class ListToolsResult implements CacheableResultData { } } +/// Deprecated alias for [CallToolRequest]. @Deprecated('Use [CallToolRequest] instead.') typedef CallToolRequestParams = CallToolRequest; From 0c5ca391ed6e0dc6a56f5d7f9081c67097125432 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Wed, 3 Jun 2026 13:43:45 -0400 Subject: [PATCH 46/68] Wait for both Codecov uploads --- codecov.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codecov.yml b/codecov.yml index e4f16e86..1c4e1af1 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,3 +1,7 @@ +codecov: + notify: + after_n_builds: 2 + coverage: status: project: From a32bdfc57ac4dd71e39311adb24e9fec4e287849 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Wed, 3 Jun 2026 14:21:26 -0400 Subject: [PATCH 47/68] Prepare dev release workflow --- .github/workflows/publish.yml | 4 ++++ .github/workflows/release.yml | 16 ++++++++++++++++ doc/mcp-2026-rc.md | 31 +++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 80ab9c2d..a70ea665 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -53,6 +53,10 @@ jobs: working-directory: ${{ steps.publish-dir.outputs.working_directory }} run: dart pub get + - name: Validate package for publishing + working-directory: ${{ steps.publish-dir.outputs.working_directory }} + run: dart pub publish --dry-run + - name: Publish to pub.dev working-directory: ${{ steps.publish-dir.outputs.working_directory }} run: dart pub publish --force diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4fa87594..11896b24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,6 +41,20 @@ jobs: echo "๐Ÿ“ฆ Package: ${{ inputs.package }}" echo "๐Ÿ“ฆ Detected version: $VERSION" + - name: Determine release type + id: release-type + run: | + VERSION="${{ steps.get-version.outputs.version }}" + if [[ "$VERSION" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + echo "make_latest=false" >> "$GITHUB_OUTPUT" + echo "๐Ÿ“ฆ Release type: prerelease" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + echo "make_latest=true" >> "$GITHUB_OUTPUT" + echo "๐Ÿ“ฆ Release type: stable" + fi + - name: Check if tag already exists run: | TAG="${{ steps.set-config.outputs.tag_prefix }}${{ steps.get-version.outputs.version }}" @@ -69,4 +83,6 @@ jobs: tag_name: ${{ steps.create-tag.outputs.tag }} name: ${{ inputs.package }} ${{ steps.get-version.outputs.version }} generate_release_notes: true + prerelease: ${{ steps.release-type.outputs.prerelease }} + make_latest: ${{ steps.release-type.outputs.make_latest }} draft: false diff --git a/doc/mcp-2026-rc.md b/doc/mcp-2026-rc.md index 3f5b91e8..f0bfee5f 100644 --- a/doc/mcp-2026-rc.md +++ b/doc/mcp-2026-rc.md @@ -112,3 +112,34 @@ final items = result.structuredContentJson?.asArray; The draft/RC API surface may still change before the official spec release. Keep applications on the stable profile unless they specifically need draft behavior. + +## Dev Release Checklist + +Use dev releases for MCP `2026-07-28` draft/RC testing until the official spec +is released. Dev versions must include a prerelease suffix such as +`2.3.0-dev.0` so pub.dev and GitHub treat them as preview builds. + +Before creating tags from `dev/2026-07-28-rc`, run: + +```sh +dart analyze +dart pub publish --dry-run +dart pub global run pana --no-warning +dart run tool/validate_cli_publish.dart +``` + +Publish the SDK package first by running the `Create Release` workflow for +`mcp_dart` from `dev/2026-07-28-rc`. The publish workflow runs a dry-run check +before `dart pub publish --force`, and prerelease versions are marked as GitHub +prereleases rather than repository latest releases. + +After `mcp_dart` is available on pub.dev, validate the CLI against the published +SDK package: + +```sh +dart run tool/validate_cli_publish.dart --published-sdk +``` + +Then run the `Create Release` workflow for `mcp_dart_cli` from +`dev/2026-07-28-rc`. The CLI publish workflow removes the local SDK override +before publishing so users receive the published SDK dependency. From 6cd000fa4602ae26943ff17fe6f83d3ebc117ed0 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Wed, 3 Jun 2026 16:03:29 -0400 Subject: [PATCH 48/68] Polish dev release docs --- CHANGELOG.md | 319 +++++------------------------ README.md | 48 ++--- doc/mcp-2026-rc.md | 18 ++ packages/mcp_dart_cli/CHANGELOG.md | 4 + packages/mcp_dart_cli/README.md | 13 ++ packages/mcp_dart_cli/pubspec.yaml | 4 +- pubspec.yaml | 2 +- 7 files changed, 114 insertions(+), 294 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50a2fe4f..500d9e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,272 +1,57 @@ ## 2.3.0-dev.0 -### MCP 2026-07-28 draft/RC - -- Started the MCP 2026-07-28 RC development line with opt-in protocol - constants, stateless request metadata helpers, and `server/discover` request - 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 explicit protocol profiles via - `McpClientOptions(protocol: McpProtocol.preview2026)` and - `McpServerOptions(protocol: McpProtocol.preview2026)` while keeping the stable - `initialize` flow as the default. The lower-level `protocolVersion` and - `useServerDiscover` options remain available for interoperability testing. -- Kept stable public tool result APIs object-rooted while adding explicit - draft-only APIs for non-object values: `JsonValue`, - `CallToolResult.fromStructuredArray()`, `structuredContentJson`, and - server `outputJsonSchema`. -- Added 2026 cacheable result support for `tools/list`, `prompts/list`, - `resources/list`, `resources/templates/list`, and `resources/read`, including - stateless server defaults for `resultType`, `ttlMs`, and `cacheScope` while - keeping legacy result serialization unchanged unless cache hints are set. -- Rejected core RPCs removed from stateless MCP 2026 requests - (`initialize`, `ping`, `logging/setLevel`, `resources/subscribe`, - `resources/unsubscribe`, `notifications/initialized`, and - `notifications/roots/list_changed`) while preserving legacy session behavior. -- Synced registered tool `x-mcp-header` metadata into Streamable HTTP server - transports so 2026 stateless `tools/call` requests reject missing or - mismatched `Mcp-Param-*` argument headers. -- Removed `Mcp-Session-Id` from 2026 stateless Streamable HTTP requests by - stripping it from client sends and ignoring it on stateless server POSTs. -- Prevented 2026 stateless request handlers and task-store operations from - inheriting transport session IDs, including direct Streamable HTTP transport - responses after a prior stateful initialization. -- Enforced 2026 stateless Streamable HTTP POST-only behavior by skipping - legacy client GET/DELETE session paths and returning `Allow: POST` for - stateless non-POST server requests. -- Rejected server-initiated JSON-RPC requests on 2026 stateless Streamable HTTP - response streams so client input is routed through MRTR input-required - results instead. -- Escaped sentinel-shaped `Mcp-Param-*` values with Base64 encoding so literal - values beginning with `=?base64?` and ending with `?=` round-trip correctly. -- Synced nested 2026 `x-mcp-header` mappings into Streamable HTTP transports - using JSON Pointer selectors for nested tool arguments. -- Limited 2026 Streamable HTTP `x-mcp-header` mirroring to string, boolean, - finite number, and JavaScript-safe integer argument values; unsafe integers - and non-finite numbers are omitted. -- Returned HTTP 404 with JSON-RPC `Method not found` for unsupported or removed - 2026 stateless Streamable HTTP request methods before opening response - streams. -- Sent and required `Mcp-Name` for MCP 2026 task lifecycle requests over - Streamable HTTP, using the request body `taskId` as the routing value. -- Accepted MCP 2026 stateless Streamable HTTP JSON-RPC response POSTs without - requiring request-only metadata in the response body. -- Treated client closure of a 2026 stateless Streamable HTTP SSE response stream - as cancellation of that pending request. -- Sorted 2026 stateless high-level `tools/list` responses by tool name for - deterministic list results while preserving legacy registration-order output. -- Omitted stable-only `Tool.execution` metadata from 2026 stateless - `tools/list` responses and embedded MRTR sampling tool definitions while - preserving stable/default serialization. -- Rejected legacy `RequestOptions.task` augmentation before sending 2026 - stateless requests while preserving stable task augmentation. -- Added `tool/spec_example_audit.dart` so upstream machine-readable spec - examples can be parsed through the checked-in typed SDK surfaces during - RC/final release audits. -- Gated 2026 stateless task extension methods on advertised server extension - support and rejected legacy task result shapes on extension `tasks/get`, - `tasks/update`, and `tasks/cancel` handlers. -- Ignored legacy `tools/call` `task` parameters on 2026 stateless requests so - handlers do not receive legacy task TTL hints through `RequestHandlerExtra`. -- Required 2026 stateless task creation results to be immediately resolvable - through `tasks/get` before returning `resultType: "task"`. -- Exposed task-store options on `McpServerOptions` and serialized built-in - task-store `tasks/get`/`tasks/cancel` handlers in the 2026 task-extension - wire shape for stateless requests. -- Added request-scoped stateless logging gating via - `io.modelcontextprotocol/logLevel` metadata so 2026 log notifications are - emitted only when the current request opts in. -- Rejected unrecognized 2026 stateless response `resultType` values on the - client while keeping absent `resultType` compatible with stable result parsing. -- Added `X-Accel-Buffering: no` to Streamable HTTP SSE responses and marked - JSON-RPC error bodies with `Content-Type: application/json`. -- Tightened `x-mcp-header` and `Mcp-Param-*` suffix validation to RFC 9110 - HTTP field-name token syntax. -- Removed invalid `x-mcp-header` annotations from 2026 stateless `tools/list` - responses when the server has already rejected those header mappings. -- Rejected server-initiated JSON-RPC requests received on 2026 stateless - Streamable HTTP client response streams; servers must use MRTR - `input_required` results instead. -- Enforced `subscriptions/listen` stream ordering and filters for 2026 - subscription notifications. -- Required nested request `_meta` on `subscriptions/listen` requests, matching - the 2026 schema's per-request metadata requirement. -- Rejected mismatched JSON-RPC `jsonrpc` and `method` wrapper constants when - parsing typed `notifications/subscriptions/acknowledged` notifications. -- Rejected mismatched JSON-RPC wrapper constants when directly parsing the - experimental completion list-changed notification. -- Rejected incoming `tools/call` JSON-RPC requests that omit the MCP-required - `params` object. -- Rejected JSON-RPC envelopes that mix request/notification `method` fields - with response `result` or `error` fields, including direct typed - request/notification/error parsing. -- Returned `MissingRequiredClientCapability` (`-32003`) with required task - extension capability data when 2026 task notification subscriptions omit the - per-request `io.modelcontextprotocol/tasks` client capability. -- Rejected deprecated sampling `includeContext` values unless the client - advertises `sampling.context`, while still allowing omitted context and - `includeContext: "none"`. -- Stopped sending `notifications/cancelled` when an outgoing `initialize` - request is aborted or times out, matching the stable lifecycle rule that - clients must not cancel initialization. -- Required `notifications/cancelled` payloads to carry a valid string-or-integer - `requestId` instead of accepting ID-less cancellation notifications. -- Retried `server/discover` with an advertised compatible stateless protocol - version after `UnsupportedProtocolVersionError` instead of falling back to - legacy initialization. -- Accepted whole-number JSON numeric values for integer wire fields such as - resource link sizes, completion totals, sampling `maxTokens`, task TTLs, and - JSON Schema length/item bounds while continuing to reject fractional values. -- Added client-side `subscriptions/listen` handles that correlate stream - notifications by `io.modelcontextprotocol/subscriptionId`, validate the - acknowledgment, and cancel long-lived streams with `notifications/cancelled`. -- Allowed MCP 2026 tool `outputSchema` declarations to use any JSON Schema and - `structuredContent` results to carry any JSON value, while omitting non-object - structured output from stable 2025 responses. -- Allowed MCP 2026 `prompts/get` and `resources/read` handlers to return - `InputRequiredResult`, and rejected MRTR input-required results on unsupported - request methods. -- Rejected MCP 2026 MRTR `inputRequests` whose embedded client request type is - not declared in the caller's per-request client capabilities. -- Rejected non-object `experimental` and `extensions` capability entries to - match the stable and MCP 2026 capability schemas. -- Returned version-appropriate resource-not-found errors from high-level - `resources/read` handlers: stable 2025 uses legacy `-32002`, while MCP 2026 - stateless requests use `-32602` with the missing `uri` in error data. -- Enforced MCP 2026 `_meta` key-name grammar on stateless request metadata and - the 2026 request metadata builder while preserving legacy metadata parsing. -- Exposed typed request-envelope accessors on `RequestHandlerExtra` for - per-request protocol version, client info, and client capabilities metadata. -- Rejected negative cacheable-result `ttlMs` values during parsing instead of - clamping malformed wire values to zero. -- Validated MRTR `inputResponses` as `CreateMessageResult`, `ListRootsResult`, - or `ElicitResult` instead of accepting arbitrary result objects. -- Restricted numeric `ElicitResult.content` values to integers, matching the - stable and MCP 2026 `string | integer | boolean | string[]` schemas while - still accepting whole-number JSON numeric values. -- Required integer `minimum`, `maximum`, and `default` values in form - elicitation number schemas for both stable 2025 and MCP 2026. -- Rejected MCP 2026 `CallToolResult.extra` attempts to spoof non-complete - `resultType` values, and added CLI conformance coverage for that guard. -- Rejected form elicitation schemas that provide legacy `enumNames` without the - required string `enum`. -- Rejected `ElicitResult.content` when the result action is `decline` or - `cancel`. -- Rejected URL elicitation values that are not absolute URIs to match the stable - and MCP 2026 `format: uri` schemas. -- Rejected non-absolute resource URIs and malformed resource URI templates to - match stable and MCP 2026 `format: uri` and `format: uri-template` schemas. -- Rejected malformed base64 payloads for image, audio, and blob resource - content to match stable and MCP 2026 `format: byte` schemas. -- Rejected malformed shared annotation fields, including non-role audiences, - out-of-range priorities, and non-string `lastModified` values. -- Rejected malformed `Role` values in prompt and sampling messages instead of - allowing raw enum lookup failures. -- Rejected malformed logging level, sampling `includeContext`, and sampling - `toolChoice.mode` enum values with protocol parse errors. -- Rejected malformed sampling string, boolean, and string-list wire fields with - protocol parse errors. -- Rejected malformed content and resource string/list wire fields with protocol - parse errors. -- Rejected malformed initialization and capability wire fields with protocol - parse errors. -- Rejected malformed prompt, completion, logging, and common notification wire - fields with protocol parse errors. -- Rejected malformed prompt JSON-RPC wrapper constants with protocol parse - errors. -- Rejected malformed resource JSON-RPC wrapper constants with protocol parse - errors. -- Rejected malformed tool JSON-RPC wrapper constants with protocol parse - errors. -- Rejected malformed root JSON-RPC wrapper constants with protocol parse - errors. -- Rejected malformed common notification and logging JSON-RPC wrapper - constants with protocol parse errors. -- Rejected malformed initialization and `server/discover` JSON-RPC wrapper - constants with protocol parse errors. -- Rejected malformed task and task-extension JSON-RPC wrapper constants with - protocol parse errors. -- Rejected malformed sampling and elicitation JSON-RPC wrapper constants while - preserving embedded MRTR input request parsing. -- Preserved `Result._meta` while parsing empty results for high-level ping, - logging, and subscription acknowledgments. -- Preserved MCP 2026 `tools/list` cache hints when client-side tool metadata - filtering removes invalid tool definitions. -- Rejected missing and mismatched completion reference type discriminators with - protocol parse errors. -- Rejected malformed completion JSON-RPC wrapper constants with protocol parse - errors. -- Rejected malformed tool definition, tool-list, and tool-call wire fields with - protocol parse errors. -- Rejected malformed root-list wire fields with protocol parse errors. -- Rejected malformed stable task and task-extension wire fields with protocol - parse errors. -- Rejected malformed elicitation request, result, completion, and URL-required - error wire fields with protocol parse errors. -- Rejected malformed subscription listen and acknowledgment wire fields with - protocol parse errors. -- Rejected malformed sampling tool-list, tool-choice, and tool-result content - wire fields with protocol parse errors. -- Rejected missing, unknown, and mismatched content block type discriminators - with protocol parse errors. -- Rejected missing and mismatched sampling content type discriminators with - protocol parse errors. -- Rejected resource content items that omit both `text` and `blob`, matching - the spec-defined `TextResourceContents | BlobResourceContents` union. -- Rejected non-finite numeric values for progress, annotation priority, model - priority, and sampling temperature fields so SDK-built payloads remain valid - JSON numbers. -- Rejected non-JSON values in sampling JSON object fields, including - `tool_use.input`, sampling metadata, annotations, and `_meta` maps. -- Rejected non-JSON values in common content/resource metadata fields and - `resource_link.annotations`. -- Reused shared JSON-object validation for MRTR, task extension, subscription, - and tool object fields. -- Rejected non-JSON values in JSON-RPC envelope and remaining typed result - metadata fields. -- Rejected non-JSON JSON-RPC error `data` values at parse and serialize - boundaries. -- Rejected JSON-RPC response envelopes that include both `result` and `error` - instead of silently treating them as successful responses. -- Rejected JSON-RPC request and notification envelopes whose `method` member is - not a string, and validated generic request `params` as JSON objects. -- Rejected malformed JSON-RPC `error` objects with missing or invalid `code` or - `message` fields instead of surfacing Dart cast errors. -- Rejected JSON-RPC error responses that include an explicit `id: null` member - while continuing to allow omitted IDs for malformed-request error cases. -- Rejected JSON-RPC request and notification envelopes that include an explicit - `params: null` member, since `params` must be an object when present. -- Prevented stateless MCP 2026 clients from sending core request and - notification methods removed from that protocol revision. -- Rejected server-initiated JSON-RPC requests received by stateless MCP 2026 - clients on generic transports. -- Omitted the removed `roots.listChanged` client capability from MCP 2026 - stateless request metadata while preserving it for stable 2025 metadata. -- Rejected stateless MCP 2026 responses that omit `resultType` or required - cacheable-result fields. -- Stripped caller-supplied `Mcp-Session-Id` headers case-insensitively from - MCP 2026 stateless Streamable HTTP requests. -- Derived MCP 2026 stateless Streamable HTTP headers from nested - `params._meta` metadata for direct JSON-RPC transport sends. -- Allowed Streamable MCP server CORS preflights for 2026 stateless routing and - tool parameter headers, including requested `Mcp-Param-*` headers. -- Serialized MRTR `ElicitResult` and `ListRootsResult` input responses with the - MCP 2026 embedded client-result shapes that omit common Result `_meta`. -- Accepted finite numeric JSON-RPC request IDs and progress tokens, matching - the stable and MCP 2026 `string | number` schema while continuing to reject - non-finite numbers. -- Allowed protocol progress handlers and `RequestHandlerExtra.sendProgress` to - dispatch finite numeric progress tokens end-to-end. -- Widened protocol `relatedRequestId` API parameters to preserve string and - finite numeric JSON-RPC request IDs through request and notification routing. -- Accepted numeric `minimum`, `maximum`, `exclusiveMinimum`, - `exclusiveMaximum`, `multipleOf`, and `default` values on JSON Schema - `integer` schemas, matching the stable and MCP 2026 schema definitions. -- Preserved object-level JSON Schema 2020-12 keywords on `JsonObject` - round-trips and added official MCP conformance gates for stable 2025 and - 2026 RC client/server coverage in core CI. +This is a dev preview for MCP `2026-07-28` draft/RC support. MCP +`2025-11-25` remains the default protocol profile; draft/RC behavior is enabled +explicitly and may still change before the official spec release. + +### MCP 2026-07-28 draft/RC preview + +- Added `McpProtocol.preview2026` and `McpProtocol.require2026` profiles for + clients and servers, with stable `initialize` behavior preserved by default. +- Added `server/discover` negotiation, per-request stateless metadata, + protocol/client/capability validation, and version-aware fallback behavior. +- Added stateless Streamable HTTP behavior for POST-only requests, no + `Mcp-Session-Id`, `Mcp-Name` task routing, `Mcp-Param-*` argument headers, + CORS preflights, SSE cancellation, and request-scoped logging. +- Added draft-only flows for `subscriptions/listen`, MCP Tasks extension + handlers, MRTR `input_required` results, cacheable list/read results, and + `input_required` prompt/resource responses. +- Added explicit typed APIs for non-object draft result data, including + `JsonValue`, `structuredContentJson`, + `CallToolResult.fromStructuredArray()`, and server `outputJsonSchema`. + +### Stable compatibility + +- Kept stable public tool-result APIs object-rooted and omitted non-object + structured output from stable MCP `2025-11-25` responses. +- Preserved stable session behavior, registration-order list output, legacy task + augmentation, stable-only `Tool.execution` metadata, and legacy resource error + codes outside the 2026 stateless profile. +- Preserved numeric JSON-RPC request IDs and progress tokens end-to-end while + continuing to reject non-finite numeric values. + +### Spec hardening + +- Tightened JSON-RPC envelope parsing, wrapper constant checks, error object + validation, `_meta` key validation, and mixed request/response rejection. +- Tightened typed parsing for content, resources, prompts, tools, roots, + sampling, elicitation, tasks, subscriptions, completions, capabilities, and + JSON Schema fields so malformed wire values fail with protocol errors instead + of Dart cast errors. +- Validated JSON-only metadata and result data across JSON-RPC, MRTR, task, + subscription, sampling, tool, resource, and content boundaries. + +### Conformance and release readiness + +- Added official MCP `2025-11-25` and MCP `2026-07-28` draft/RC client/server + conformance gates to core CI. +- Added `tool/spec_example_audit.dart` for parsing upstream machine-readable + spec examples through checked-in SDK types during RC/final release audits. +- Prepared the dev release workflow so prerelease tags are GitHub prereleases, + publish jobs run `dart pub publish --dry-run`, and the draft/RC transition + guide includes a dev release checklist. +- Pointed prerelease package documentation links at `dev/2026-07-28-rc` so + pub.dev users see the draft/RC docs that match the dev package. ## 2.2.0 diff --git a/README.md b/README.md index 2aaf94cb..aa5445a3 100644 --- a/README.md +++ b/README.md @@ -113,39 +113,39 @@ final server = McpServer( ``` Use the preview profile while the spec is still a draft/RC. See the -[MCP 2026-07-28 draft/RC transition guide](https://github.com/leehack/mcp_dart/blob/main/doc/mcp-2026-rc.md) +[MCP 2026-07-28 draft/RC transition guide](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/mcp-2026-rc.md) for opt-in behavior, fallback rules, and draft-only APIs. ## Documentation ### Getting Started -- ๐Ÿ“– **[Quick Start Guide](https://github.com/leehack/mcp_dart/blob/main/doc/getting-started.md)** - Get up and running in 5 minutes -- ๐Ÿ”ง **[Server Guide](https://github.com/leehack/mcp_dart/blob/main/doc/server-guide.md)** - Complete guide to building MCP servers -- ๐Ÿ’ป **[Client Guide](https://github.com/leehack/mcp_dart/blob/main/doc/client-guide.md)** - Complete guide to building MCP clients +- ๐Ÿ“– **[Quick Start Guide](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/getting-started.md)** - Get up and running in 5 minutes +- ๐Ÿ”ง **[Server Guide](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/server-guide.md)** - Complete guide to building MCP servers +- ๐Ÿ’ป **[Client Guide](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/client-guide.md)** - Complete guide to building MCP clients ### Core Concepts -- ๐Ÿ› ๏ธ **[Tools Documentation](https://github.com/leehack/mcp_dart/blob/main/doc/tools.md)** - Implementing executable tools -- ๐Ÿ”Œ **[Transport Options](https://github.com/leehack/mcp_dart/blob/main/doc/transports.md)** - Built-in and custom transport implementations -- ๐Ÿ“š **[Examples](https://github.com/leehack/mcp_dart/blob/main/doc/examples.md)** - Real-world usage examples -- โšก **[Quick Reference](https://github.com/leehack/mcp_dart/blob/main/doc/quick-reference.md)** - Fast lookup guide -- ๐Ÿชต **[Runtime Logging](https://github.com/leehack/mcp_dart/blob/main/doc/getting-started.md#sdk-runtime-logging)** - Configure and route internal SDK logs -- ๐Ÿงฉ **[MCP Apps Guide](https://github.com/leehack/mcp_dart/blob/main/doc/mcp-apps.md)** - Using `io.modelcontextprotocol/ui` metadata +- ๐Ÿ› ๏ธ **[Tools Documentation](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/tools.md)** - Implementing executable tools +- ๐Ÿ”Œ **[Transport Options](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/transports.md)** - Built-in and custom transport implementations +- ๐Ÿ“š **[Examples](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/examples.md)** - Real-world usage examples +- โšก **[Quick Reference](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/quick-reference.md)** - Fast lookup guide +- ๐Ÿชต **[Runtime Logging](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/getting-started.md#sdk-runtime-logging)** - Configure and route internal SDK logs +- ๐Ÿงฉ **[MCP Apps Guide](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/mcp-apps.md)** - Using `io.modelcontextprotocol/ui` metadata ### Recipes and Compatibility -- ๐Ÿงช **[SDK Interoperability Matrix](https://github.com/leehack/mcp_dart/blob/main/doc/interoperability.md)** - Verified Dart/TypeScript and documented cross-SDK scenarios -- โœ… **[MCP 2025-11-25 Spec Coverage Matrix](https://github.com/leehack/mcp_dart/blob/main/doc/spec-coverage-2025-11-25.md)** - Auditable coverage map with CLI conformance cases and known gaps -- ๐Ÿงญ **[MCP 2026-07-28 Draft/RC Transition Guide](https://github.com/leehack/mcp_dart/blob/main/doc/mcp-2026-rc.md)** - Opt-in profile, fallback behavior, and draft-only APIs -- ๐Ÿ”’ **[Transport Security Recipes](https://github.com/leehack/mcp_dart/blob/main/doc/transports.md#dns-rebinding-protection)** - Host/Origin allowlists, OAuth layering, and compatibility-toggle trade-offs -- ๐Ÿ“ฑ **[Flutter Recipes](https://github.com/leehack/mcp_dart/blob/main/doc/flutter-recipes.md)** - Flutter Web, mobile, and desktop host/client guidance -- ๐Ÿ” **[Migration Cookbooks](https://github.com/leehack/mcp_dart/blob/main/doc/migration-cookbooks.md)** - TypeScript SDK, `dart_mcp`, stdio-to-HTTP, and version migration paths +- ๐Ÿงช **[SDK Interoperability Matrix](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/interoperability.md)** - Verified Dart/TypeScript and documented cross-SDK scenarios +- โœ… **[MCP 2025-11-25 Spec Coverage Matrix](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/spec-coverage-2025-11-25.md)** - Auditable coverage map with CLI conformance cases and known gaps +- ๐Ÿงญ **[MCP 2026-07-28 Draft/RC Transition Guide](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/mcp-2026-rc.md)** - Opt-in profile, fallback behavior, and draft-only APIs +- ๐Ÿ”’ **[Transport Security Recipes](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/transports.md#dns-rebinding-protection)** - Host/Origin allowlists, OAuth layering, and compatibility-toggle trade-offs +- ๐Ÿ“ฑ **[Flutter Recipes](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/flutter-recipes.md)** - Flutter Web, mobile, and desktop host/client guidance +- ๐Ÿ” **[Migration Cookbooks](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/migration-cookbooks.md)** - TypeScript SDK, `dart_mcp`, stdio-to-HTTP, and version migration paths ### Advanced Features -- ๐Ÿ” **[OAuth Authentication](https://github.com/leehack/mcp_dart/tree/main/example/authentication)** - OAuth2 guides and examples -- ๐Ÿ” **[2025-11-25 Compatibility Migration](https://github.com/leehack/mcp_dart/blob/main/doc/migration_2025_11_25_compat.md)** - Backward-compatible API/runtime migration notes +- ๐Ÿ” **[OAuth Authentication](https://github.com/leehack/mcp_dart/tree/dev/2026-07-28-rc/example/authentication)** - OAuth2 guides and examples +- ๐Ÿ” **[2025-11-25 Compatibility Migration](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/migration_2025_11_25_compat.md)** - Backward-compatible API/runtime migration notes - ๐Ÿ“ For resources, prompts, and other features, see the Server and Client guides ## Quick Start with CLI @@ -183,7 +183,7 @@ mcp_dart inspect --tool add --json-args '{"a": 1, "b": 2}' # Call a tool | `inspect-client` | Run a stdio harness that inspects a connecting client | | `trace` | Proxy stdio client/server traffic and write a JSON trace | -๐Ÿ“– **[Full CLI Documentation](https://github.com/leehack/mcp_dart/tree/main/packages/mcp_dart_cli)** +๐Ÿ“– **[Full CLI Documentation](https://github.com/leehack/mcp_dart/tree/dev/2026-07-28-rc/packages/mcp_dart_cli)** ### Connecting to AI Hosts @@ -202,11 +202,11 @@ Configure your server with AI hosts like Claude Desktop: ``` > [!TIP] -> For manual server implementation or advanced use cases, see the [Server Guide](https://github.com/leehack/mcp_dart/blob/main/doc/server-guide.md). +> For manual server implementation or advanced use cases, see the [Server Guide](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/server-guide.md). ## Authentication -This library provides OAuth-aware client and server authentication hooks, including `OAuthClientProvider` for StreamableHTTP clients, optional `OAuthAuthorizationCodeProvider` discovery support, and server-side `authenticator` / `authenticationHandler` callbacks. For OAuth2/PKCE guides and examples, see the [OAuth Authentication documentation](https://github.com/leehack/mcp_dart/tree/main/example/authentication) and [transport authentication docs](https://github.com/leehack/mcp_dart/blob/main/doc/transports.md#streamable-http-authentication). +This library provides OAuth-aware client and server authentication hooks, including `OAuthClientProvider` for StreamableHTTP clients, optional `OAuthAuthorizationCodeProvider` discovery support, and server-side `authenticator` / `authenticationHandler` callbacks. For OAuth2/PKCE guides and examples, see the [OAuth Authentication documentation](https://github.com/leehack/mcp_dart/tree/dev/2026-07-28-rc/example/authentication) and [transport authentication docs](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/transports.md#streamable-http-authentication). ## Platform Support @@ -222,15 +222,15 @@ This library provides OAuth-aware client and server authentication hooks, includ For additional examples including authentication, HTTP clients, and advanced features: -- [All Examples](https://github.com/leehack/mcp_dart/tree/main/example) -- [Authentication Examples](https://github.com/leehack/mcp_dart/tree/main/example/authentication) +- [All Examples](https://github.com/leehack/mcp_dart/tree/dev/2026-07-28-rc/example) +- [Authentication Examples](https://github.com/leehack/mcp_dart/tree/dev/2026-07-28-rc/example/authentication) ## Community & Support - **Issues & Bug Reports**: [GitHub Issues](https://github.com/leehack/mcp_dart/issues) - **Package**: [pub.dev/packages/mcp_dart](https://pub.dev/packages/mcp_dart) - **API Docs**: [pub.dev documentation](https://pub.dev/documentation/mcp_dart/latest/) -- **Changelog**: [CHANGELOG.md](https://github.com/leehack/mcp_dart/blob/main/CHANGELOG.md) +- **Changelog**: [CHANGELOG.md](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/CHANGELOG.md) - **Protocol Spec**: [MCP Specification](https://modelcontextprotocol.io/specification/2025-11-25) ## Credits diff --git a/doc/mcp-2026-rc.md b/doc/mcp-2026-rc.md index f0bfee5f..1abd722b 100644 --- a/doc/mcp-2026-rc.md +++ b/doc/mcp-2026-rc.md @@ -128,6 +128,10 @@ dart pub global run pana --no-warning dart run tool/validate_cli_publish.dart ``` +For dev packages, keep package documentation links pointed at +`dev/2026-07-28-rc` until the draft work is ready to merge back to `main`. +Restore those links to `main` as part of the final spec release prep. + Publish the SDK package first by running the `Create Release` workflow for `mcp_dart` from `dev/2026-07-28-rc`. The publish workflow runs a dry-run check before `dart pub publish --force`, and prerelease versions are marked as GitHub @@ -143,3 +147,17 @@ dart run tool/validate_cli_publish.dart --published-sdk Then run the `Create Release` workflow for `mcp_dart_cli` from `dev/2026-07-28-rc`. The CLI publish workflow removes the local SDK override before publishing so users receive the published SDK dependency. + +Install the dev CLI explicitly by version: + +```sh +dart pub global activate mcp_dart_cli 0.2.0-dev.0 +``` + +The standalone install and update scripts intentionally track stable GitHub +releases; use Dart SDK activation when testing prerelease CLI builds. + +`mcp_dart create` continues to generate projects that resolve the stable SDK by +default. For draft/RC testing, update generated projects to depend on +`mcp_dart: ^2.3.0-dev.0` and opt into `McpProtocol.preview2026` or +`McpProtocol.require2026`. diff --git a/packages/mcp_dart_cli/CHANGELOG.md b/packages/mcp_dart_cli/CHANGELOG.md index 561f34ce..2d2eebca 100644 --- a/packages/mcp_dart_cli/CHANGELOG.md +++ b/packages/mcp_dart_cli/CHANGELOG.md @@ -4,6 +4,10 @@ dependency on `mcp_dart ^2.3.0-dev.0`. - Keep the local monorepo SDK override in `pubspec_overrides.yaml` so published CLI pubspec metadata does not expose path overrides. +- Point dev CLI package documentation metadata at the `dev/2026-07-28-rc` + branch and document explicit prerelease activation. +- Document that generated projects still resolve the stable SDK by default and + need an explicit `mcp_dart ^2.3.0-dev.0` dependency for draft/RC testing. ## 0.1.9 diff --git a/packages/mcp_dart_cli/README.md b/packages/mcp_dart_cli/README.md index 0f2df668..19cd4874 100644 --- a/packages/mcp_dart_cli/README.md +++ b/packages/mcp_dart_cli/README.md @@ -13,6 +13,13 @@ With the Dart SDK: dart pub global activate mcp_dart_cli ``` +For the MCP `2026-07-28` draft/RC dev release, pass the prerelease version +explicitly: + +```bash +dart pub global activate mcp_dart_cli 0.2.0-dev.0 +``` + Without the Dart SDK, install the latest standalone binary from GitHub Releases: ```bash @@ -32,6 +39,9 @@ same command upgrades the binary. Installed binaries can also run: mcp_dart update ``` +Standalone installers track stable GitHub releases. Use Dart SDK activation +with an explicit prerelease version when testing a dev CLI release. + ## Usage ### Create a new project @@ -48,6 +58,9 @@ mcp_dart create path/to/my_project If `directory` is omitted, the project will be created in the current directory with the name ``. +Generated projects resolve the stable `mcp_dart` SDK by default. For MCP +`2026-07-28` draft/RC testing, update the generated `pubspec.yaml` to depend on +`mcp_dart: ^2.3.0-dev.0`. ### Create from a specific template diff --git a/packages/mcp_dart_cli/pubspec.yaml b/packages/mcp_dart_cli/pubspec.yaml index fbc8dcc6..f52a6a48 100644 --- a/packages/mcp_dart_cli/pubspec.yaml +++ b/packages/mcp_dart_cli/pubspec.yaml @@ -2,9 +2,9 @@ name: mcp_dart_cli description: Command-line tools for creating, serving, inspecting, and testing Dart Model Context Protocol (MCP) servers. version: 0.2.0-dev.0 repository: https://github.com/leehack/mcp_dart -homepage: https://github.com/leehack/mcp_dart/tree/main/packages/mcp_dart_cli +homepage: https://github.com/leehack/mcp_dart/tree/dev/2026-07-28-rc/packages/mcp_dart_cli issue_tracker: https://github.com/leehack/mcp_dart/issues -documentation: https://github.com/leehack/mcp_dart/tree/main/packages/mcp_dart_cli +documentation: https://github.com/leehack/mcp_dart/tree/dev/2026-07-28-rc/packages/mcp_dart_cli topics: - mcp - ai diff --git a/pubspec.yaml b/pubspec.yaml index cfaad5fa..cf087f72 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ version: 2.3.0-dev.0 repository: https://github.com/leehack/mcp_dart homepage: https://github.com/leehack/mcp_dart issue_tracker: https://github.com/leehack/mcp_dart/issues -documentation: https://github.com/leehack/mcp_dart/tree/main/doc +documentation: https://github.com/leehack/mcp_dart/tree/dev/2026-07-28-rc/doc topics: - mcp - ai From a07d0a3244056a350ac2888ee771c55ee34f7b91 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Thu, 4 Jun 2026 06:06:52 -0400 Subject: [PATCH 49/68] Fix CLI binary workflow runner labels --- .github/workflows/cli_binaries.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cli_binaries.yml b/.github/workflows/cli_binaries.yml index 0a681be2..419b141d 100644 --- a/.github/workflows/cli_binaries.yml +++ b/.github/workflows/cli_binaries.yml @@ -24,11 +24,11 @@ jobs: include: - os: ubuntu-latest asset: mcp_dart-linux-x64 - - os: macos-13 + - os: macos-15-intel asset: mcp_dart-macos-x64 - os: macos-14 asset: mcp_dart-macos-arm64 - - os: windows-latest + - os: windows-2025 asset: mcp_dart-windows-x64.exe defaults: @@ -57,7 +57,7 @@ jobs: "./dist/${{ matrix.asset }}" --version - name: Upload binary artifact - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: ${{ matrix.asset }} path: packages/mcp_dart_cli/dist/${{ matrix.asset }} From dbf89e9bc98e6463117b9d8dddd942747c0f994e Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Thu, 4 Jun 2026 07:09:27 -0400 Subject: [PATCH 50/68] Update official conformance to alpha.2 --- .github/workflows/test_core.yml | 2 +- CHANGELOG.md | 9 ++ doc/mcp-2026-rc.md | 30 +++-- packages/mcp_dart_cli/README.md | 7 +- .../2026_rc_client_expected_failures.txt | 7 +- .../conformance/2026_rc_expected_failures.txt | 2 +- test/conformance/README.md | 9 +- test/conformance/mcp_2026_rc_client.dart | 118 +++++++++++++----- .../run_2025_server_conformance.dart | 2 +- .../run_2026_rc_client_conformance.dart | 2 +- .../run_2026_rc_server_conformance.dart | 4 +- 11 files changed, 137 insertions(+), 55 deletions(-) diff --git a/.github/workflows/test_core.yml b/.github/workflows/test_core.yml index af71ddd3..7de11047 100644 --- a/.github/workflows/test_core.yml +++ b/.github/workflows/test_core.yml @@ -51,7 +51,7 @@ jobs: - name: Run official MCP 2025 client conformance run: > - npx -y @modelcontextprotocol/conformance@0.2.0-alpha.1 client + npx -y @modelcontextprotocol/conformance@0.2.0-alpha.2 client --command "dart run test/conformance/mcp_2026_rc_client.dart" --suite all --spec-version 2025-11-25 diff --git a/CHANGELOG.md b/CHANGELOG.md index 500d9e8a..322e1b1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## Unreleased + +### Conformance and release readiness + +- Updated official conformance gates to + `@modelcontextprotocol/conformance@0.2.0-alpha.2`, with 2026 RC runs pinned + to `DRAFT-2026-v1` and the current upstream draft fixture gap tracked as an + expected failure. + ## 2.3.0-dev.0 This is a dev preview for MCP `2026-07-28` draft/RC support. MCP diff --git a/doc/mcp-2026-rc.md b/doc/mcp-2026-rc.md index 1abd722b..25cd1704 100644 --- a/doc/mcp-2026-rc.md +++ b/doc/mcp-2026-rc.md @@ -116,13 +116,22 @@ behavior. ## Dev Release Checklist Use dev releases for MCP `2026-07-28` draft/RC testing until the official spec -is released. Dev versions must include a prerelease suffix such as -`2.3.0-dev.0` so pub.dev and GitHub treat them as preview builds. +is released. The initial SDK dev release, `mcp_dart 2.3.0-dev.0`, is already +published on pub.dev. Follow-up dev versions must increment the prerelease +suffix, such as `2.3.0-dev.1`, so pub.dev and GitHub treat them as preview +builds. -Before creating tags from `dev/2026-07-28-rc`, run: +Before creating follow-up dev tags from `dev/2026-07-28-rc`, run: ```sh dart analyze +dart run test/conformance/run_2025_server_conformance.dart +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.2 client \ + --command "dart run test/conformance/mcp_2026_rc_client.dart" \ + --suite all \ + --spec-version 2025-11-25 +dart run test/conformance/run_2026_rc_server_conformance.dart +dart run test/conformance/run_2026_rc_client_conformance.dart dart pub publish --dry-run dart pub global run pana --no-warning dart run tool/validate_cli_publish.dart @@ -132,10 +141,11 @@ For dev packages, keep package documentation links pointed at `dev/2026-07-28-rc` until the draft work is ready to merge back to `main`. Restore those links to `main` as part of the final spec release prep. -Publish the SDK package first by running the `Create Release` workflow for -`mcp_dart` from `dev/2026-07-28-rc`. The publish workflow runs a dry-run check -before `dart pub publish --force`, and prerelease versions are marked as GitHub -prereleases rather than repository latest releases. +For follow-up dev releases, publish the SDK package first by running the +`Create Release` workflow for `mcp_dart` from `dev/2026-07-28-rc`. The publish +workflow runs a dry-run check before `dart pub publish --force`, and prerelease +versions are marked as GitHub prereleases rather than repository latest +releases. After `mcp_dart` is available on pub.dev, validate the CLI against the published SDK package: @@ -145,8 +155,10 @@ dart run tool/validate_cli_publish.dart --published-sdk ``` Then run the `Create Release` workflow for `mcp_dart_cli` from -`dev/2026-07-28-rc`. The CLI publish workflow removes the local SDK override -before publishing so users receive the published SDK dependency. +`dev/2026-07-28-rc` when a matching CLI dev release is needed. The initial CLI +dev release, `mcp_dart_cli 0.2.0-dev.0`, is already published. The CLI publish +workflow removes the local SDK override before publishing so users receive the +published SDK dependency. Install the dev CLI explicitly by version: diff --git a/packages/mcp_dart_cli/README.md b/packages/mcp_dart_cli/README.md index 19cd4874..95488c21 100644 --- a/packages/mcp_dart_cli/README.md +++ b/packages/mcp_dart_cli/README.md @@ -464,9 +464,10 @@ exported tree outside the monorepo git/.pubignore context: dart run tool/validate_cli_publish.dart ``` -Before the matching `mcp_dart` SDK dev package is published, this uses -`pubspec_overrides.yaml` so the CLI can validate against the local SDK checkout. -After publishing the SDK package, validate the CLI against the pub.dev SDK +For follow-up CLI dev releases whose matching `mcp_dart` SDK dev package is not +published yet, this uses `pubspec_overrides.yaml` so the CLI can validate +against the local SDK checkout. The initial `mcp_dart 2.3.0-dev.0` SDK package +is already published, so release validation should also cover the pub.dev SDK version: ```bash diff --git a/test/conformance/2026_rc_client_expected_failures.txt b/test/conformance/2026_rc_client_expected_failures.txt index 8db6db09..0ff0ef54 100644 --- a/test/conformance/2026_rc_client_expected_failures.txt +++ b/test/conformance/2026_rc_client_expected_failures.txt @@ -1,7 +1,10 @@ -# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.1 +# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.2 # against the 2026 RC/DRAFT client suite. # # Keep this list scenario-based so the baseline is easy to review. When a # scenario turns green, remove it from this file in the same PR as the fix. # -# No expected client failures are currently tracked. +# Upstream alpha.2 fixture gap: this scenario's mock server rejects +# DRAFT-2026-v1 with HTTP 400 and advertises only stable protocol versions. +# Keep it expected-fail until the conformance fixture is draft-capable. +json-schema-ref-no-deref diff --git a/test/conformance/2026_rc_expected_failures.txt b/test/conformance/2026_rc_expected_failures.txt index 4f802aca..cfadd3b2 100644 --- a/test/conformance/2026_rc_expected_failures.txt +++ b/test/conformance/2026_rc_expected_failures.txt @@ -1,4 +1,4 @@ -# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.1 +# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.2 # against the 2026 RC/DRAFT server suite. # # Keep this list scenario-based so the baseline is easy to review. When a diff --git a/test/conformance/README.md b/test/conformance/README.md index 35032fe3..270359a6 100644 --- a/test/conformance/README.md +++ b/test/conformance/README.md @@ -26,14 +26,14 @@ dart run test/conformance/run_2025_server_conformance.dart ``` The runner starts `mcp_2025_server.dart`, runs -`@modelcontextprotocol/conformance@0.2.0-alpha.1 server --suite all +`@modelcontextprotocol/conformance@0.2.0-alpha.2 server --suite all --spec-version 2025-11-25`, and writes artifacts under `.dart_tool/conformance/2025_server/`. Run the stable client suite from the repository root: ```bash -npx -y @modelcontextprotocol/conformance@0.2.0-alpha.1 client \ +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.2 client \ --command "dart run test/conformance/mcp_2026_rc_client.dart" \ --suite all \ --spec-version 2025-11-25 \ @@ -55,8 +55,9 @@ dart run test/conformance/run_2026_rc_server_conformance.dart The runner starts a local `StreamableMcpServer` with JSON stateless responses enabled, runs the draft server scenarios from -`@modelcontextprotocol/conformance@0.2.0-alpha.1` one by one, and writes per-run -artifacts under `.dart_tool/conformance/2026_rc/`. +`@modelcontextprotocol/conformance@0.2.0-alpha.2` one by one with +`--spec-version DRAFT-2026-v1`, and writes per-run artifacts under +`.dart_tool/conformance/2026_rc/`. Expected failures live in `2026_rc_expected_failures.txt`. When a scenario is fixed, remove it from that file so the baseline remains useful. diff --git a/test/conformance/mcp_2026_rc_client.dart b/test/conformance/mcp_2026_rc_client.dart index 5ac61078..5925500c 100644 --- a/test/conformance/mcp_2026_rc_client.dart +++ b/test/conformance/mcp_2026_rc_client.dart @@ -24,23 +24,37 @@ Future main(List args) async { switch (scenario) { case 'initialize': - await _withClient(serverUrl, protocolVersion: latestProtocolVersion); + await _withClient(serverUrl, protocolVersion: protocolVersion); case 'tools_call': - await _withClient( - serverUrl, - protocolVersion: latestProtocolVersion, - action: (client) async { + if (isStatelessProtocolVersion(protocolVersion)) { + final client = _RawStatelessClient(serverUrl, protocolVersion); + try { + await client.request(Method.serverDiscover, const {}); await client.listTools(); await client.callTool( - const CallToolRequest( - name: 'add_numbers', - arguments: {'a': 2, 'b': 3}, - ), + 'add_numbers', + arguments: const {'a': 2, 'b': 3}, ); - }, - ); + } finally { + client.close(); + } + } else { + await _withClient( + serverUrl, + protocolVersion: protocolVersion, + action: (client) async { + await client.listTools(); + await client.callTool( + const CallToolRequest( + name: 'add_numbers', + arguments: {'a': 2, 'b': 3}, + ), + ); + }, + ); + } case 'elicitation-sep1034-client-defaults': - await _runElicitationDefaults(serverUrl); + await _runElicitationDefaults(serverUrl, protocolVersion); case 'request-metadata': await _runRequestMetadata(serverUrl, protocolVersion); case 'sep-2322-client-request-state': @@ -52,11 +66,11 @@ Future main(List args) async { case 'http-invalid-tool-headers': await _runInvalidToolHeaders(serverUrl, protocolVersion); case 'json-schema-ref-no-deref': - await _runSchemaRefNoDeref(serverUrl, latestProtocolVersion); + await _runSchemaRefNoDeref(serverUrl, protocolVersion); case 'sse-retry': await _withClient( serverUrl, - protocolVersion: latestProtocolVersion, + protocolVersion: protocolVersion, action: (client) async { await client.listTools(); await client.callTool( @@ -141,10 +155,13 @@ Future _withClient( } } -Future _runElicitationDefaults(Uri serverUrl) async { +Future _runElicitationDefaults( + Uri serverUrl, + String protocolVersion, +) async { await _withClient( serverUrl, - protocolVersion: latestProtocolVersion, + protocolVersion: protocolVersion, capabilities: const ClientCapabilities( elicitation: ClientElicitation( form: ClientElicitationForm(applyDefaults: true), @@ -357,7 +374,7 @@ Future _runAuthScenario( Map context, ) async { final provider = _ConformanceOAuthProvider(scenario, context); - final client = _RawOAuthClient(serverUrl, latestProtocolVersion, provider); + final client = _RawOAuthClient(serverUrl, protocolVersion, provider); const allowClientErrorScenarios = { 'auth/resource-mismatch', 'auth/scope-retry-limit', @@ -370,7 +387,9 @@ Future _runAuthScenario( try { await client.start(); - await client.initialize(); + await client.initialize( + maxAuthAttempts: scenario == 'auth/scope-retry-limit' ? 1 : 4, + ); switch (scenario) { case 'auth/authorization-server-migration': @@ -381,7 +400,7 @@ Future _runAuthScenario( await client.callTool('test-tool'); case 'auth/scope-retry-limit': try { - await client.listTools(maxAuthAttempts: 2); + await client.listTools(maxAuthAttempts: 1); } catch (_) { // The scenario only needs to observe a bounded number of auth // retries; the server intentionally never grants the scope. @@ -417,6 +436,14 @@ class _RawStatelessClient { _RawStatelessClient(this.serverUrl, this.protocolVersion); + void close() { + _httpClient.close(force: true); + } + + Future> listTools() { + return request(Method.toolsList, const {}); + } + Future> callTool( String name, { Map arguments = const {}, @@ -679,26 +706,41 @@ class _RawOAuthClient { Future close() => transport.close(); - Future initialize() async { - final id = _nextId++; - await _request( - JsonRpcInitializeRequest( - id: id, - initParams: InitializeRequest( - protocolVersion: protocolVersion, - capabilities: _draftCapabilities, - clientInfo: _clientInfo, + Future initialize({ + int maxAuthAttempts = 4, + }) async { + if (isStatelessProtocolVersion(protocolVersion)) { + await _request( + JsonRpcRequest( + id: _nextId++, + method: Method.serverDiscover, + meta: _requestMeta(), ), - ), - ); - await transport.send(const JsonRpcInitializedNotification()); + maxAuthAttempts: maxAuthAttempts, + ); + } else { + await _request( + JsonRpcInitializeRequest( + id: _nextId++, + initParams: InitializeRequest( + protocolVersion: protocolVersion, + capabilities: _draftCapabilities, + clientInfo: _clientInfo, + ), + ), + ); + await transport.send(const JsonRpcInitializedNotification()); + } } Future> listTools({ int maxAuthAttempts = 4, }) { return _request( - JsonRpcListToolsRequest(id: _nextId++), + JsonRpcListToolsRequest( + id: _nextId++, + meta: _requestMeta(), + ), maxAuthAttempts: maxAuthAttempts, ); } @@ -711,11 +753,23 @@ class _RawOAuthClient { JsonRpcCallToolRequest( id: _nextId++, params: CallToolRequest(name: name).toJson(), + meta: _requestMeta(), ), maxAuthAttempts: maxAuthAttempts, ); } + Map? _requestMeta() { + if (!isStatelessProtocolVersion(protocolVersion)) { + return null; + } + return buildProtocolRequestMeta( + protocolVersion: protocolVersion, + clientInfo: _clientInfo, + clientCapabilities: _draftCapabilities, + ); + } + Future> _request( JsonRpcRequest request, { int maxAuthAttempts = 4, diff --git a/test/conformance/run_2025_server_conformance.dart b/test/conformance/run_2025_server_conformance.dart index ed58e366..b5b17183 100644 --- a/test/conformance/run_2025_server_conformance.dart +++ b/test/conformance/run_2025_server_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.1'; + '@modelcontextprotocol/conformance@0.2.0-alpha.2'; const _defaultTimeout = Duration(seconds: 60); Future main(List args) async { diff --git a/test/conformance/run_2026_rc_client_conformance.dart b/test/conformance/run_2026_rc_client_conformance.dart index 7006e509..3659c4e8 100644 --- a/test/conformance/run_2026_rc_client_conformance.dart +++ b/test/conformance/run_2026_rc_client_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.1'; + '@modelcontextprotocol/conformance@0.2.0-alpha.2'; const _defaultTimeout = Duration(seconds: 30); const _draftClientScenarios = [ diff --git a/test/conformance/run_2026_rc_server_conformance.dart b/test/conformance/run_2026_rc_server_conformance.dart index c5e08eff..bcd5021f 100644 --- a/test/conformance/run_2026_rc_server_conformance.dart +++ b/test/conformance/run_2026_rc_server_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.1'; + '@modelcontextprotocol/conformance@0.2.0-alpha.2'; const _defaultTimeout = Duration(seconds: 25); const _draftServerScenarios = [ @@ -229,6 +229,8 @@ Future<_ScenarioResult> _runScenario({ serverUrl.toString(), '--suite', 'draft', + '--spec-version', + 'DRAFT-2026-v1', '--scenario', scenario, '--verbose', From 734ae3276583f82252bdb716ea09897871509019 Mon Sep 17 00:00:00 2001 From: Megakoresh Date: Fri, 12 Jun 2026 15:52:27 +0300 Subject: [PATCH 51/68] Preserve JSON Schema boolean subschemas Accept and preserve JSON Schema 2020-12 boolean subschemas in MCP tool schemas, including correct true/false validation semantics and regression coverage. --- CHANGELOG.md | 3 + lib/src/shared/json_schema/json_schema.dart | 93 +++++++++++++--- .../json_schema/json_schema_validator.dart | 47 ++++---- test/shared/json_schema_from_json_test.dart | 101 ++++++++++++++++++ test/shared/json_schema_validator_test.dart | 77 +++++++++++++ test/tool_schema_test.dart | 25 +++++ 6 files changed, 300 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 322e1b1d..10d49f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,9 @@ explicitly and may still change before the official spec release. - Tightened JSON-RPC envelope parsing, wrapper constant checks, error object validation, `_meta` key validation, and mixed request/response rejection. +- Accepted and preserved JSON Schema 2020-12 boolean subschemas in nested + schema positions such as object properties, array items, composition + keywords, and `not`. - Tightened typed parsing for content, resources, prompts, tools, roots, sampling, elicitation, tasks, subscriptions, completions, capabilities, and JSON Schema fields so malformed wire values fail with protocol errors instead diff --git a/lib/src/shared/json_schema/json_schema.dart b/lib/src/shared/json_schema/json_schema.dart index 8b028197..a6689f46 100644 --- a/lib/src/shared/json_schema/json_schema.dart +++ b/lib/src/shared/json_schema/json_schema.dart @@ -40,6 +40,7 @@ int? _integerApiValue(num? value) { sealed class JsonSchema { final String? title; final String? description; + final bool? _rawBooleanSubschema; /// The default value for this schema. /// @@ -47,13 +48,38 @@ sealed class JsonSchema { /// [JsonString], [num] for [JsonNumber], [int] for [JsonInteger], etc.). dynamic get defaultValue; - const JsonSchema({this.title, this.description}); + const JsonSchema({ + this.title, + this.description, + bool? rawBooleanSubschema, + }) : _rawBooleanSubschema = rawBooleanSubschema; /// Creates a [JsonSchema] from a JSON map. factory JsonSchema.fromJson(Map json) { return _fromJson(json); } + /// Creates a [JsonSchema] from a JSON Schema value. + /// + /// JSON Schema 2020-12 subschemas can be either schema objects or boolean + /// schemas. This parser accepts both forms for nested schema positions. + static JsonSchema fromJsonValue(Object? json) { + return _fromJsonValue(json, 'JsonSchema'); + } + + static JsonSchema _fromJsonValue(Object? json, String field) { + if (json is bool) { + return json ? const JsonAny._booleanSubschema() : const JsonNot._never(); + } + if (json is Map) { + return JsonSchema.fromJson(json); + } + if (json is Map) { + return JsonSchema.fromJson(Map.from(json)); + } + throw FormatException('$field must be a JSON Schema object or boolean'); + } + static JsonSchema _fromJson(Map json) { if (JsonEnum._canParse(json)) { return JsonEnum.fromJson(json); @@ -271,6 +297,13 @@ sealed class JsonSchema { /// Converts the schema to a JSON map. Map toJson(); + /// Converts the schema to a JSON Schema value. + /// + /// This preserves JSON Schema boolean schemas parsed with [fromJsonValue]. + Object toJsonValue() { + return _jsonSchemaValue(this); + } + /// Creates a string schema. static JsonString string({ int? minLength, @@ -509,6 +542,14 @@ sealed class JsonSchema { } } +dynamic _jsonSchemaValue(JsonSchema schema) { + final rawBooleanSubschema = schema._rawBooleanSubschema; + if (rawBooleanSubschema != null) { + return rawBooleanSubschema; + } + return schema.toJson(); +} + /// A schema for string values. class JsonString extends JsonSchema { final bool _hasDefault; @@ -995,7 +1036,7 @@ class JsonArray extends JsonSchema { factory JsonArray.fromJson(Map json) { return JsonArray._( items: json['items'] != null - ? JsonSchema.fromJson(json['items'] as Map) + ? JsonSchema._fromJsonValue(json['items'], 'JsonArray.items') : null, minItems: _readOptionalInteger( json['minItems'], @@ -1019,6 +1060,8 @@ class JsonArray extends JsonSchema { final serializedItems = itemSchema == null ? null : switch (itemSchema) { + final JsonSchema schema when schema._rawBooleanSubschema != null => + schema._rawBooleanSubschema, final JsonEnum enumItems => enumItems._toJson( titledStringConstListKeyword: 'anyOf', ), @@ -1086,15 +1129,18 @@ class JsonObject extends JsonSchema { if (additionalProps is bool) { parsedAdditionalProps = additionalProps; } else if (additionalProps is Map) { - parsedAdditionalProps = JsonSchema.fromJson( - Map.from(additionalProps), + parsedAdditionalProps = JsonSchema._fromJsonValue( + additionalProps, + 'JsonObject.additionalProperties', ); } return JsonObject._( properties: (json['properties'] as Map?)?.map( - (key, value) => - MapEntry(key, JsonSchema.fromJson(value as Map)), + (key, value) => MapEntry( + key, + JsonSchema._fromJsonValue(value, 'JsonObject.properties.$key'), + ), ), required: (json['required'] as List?)?.cast(), additionalProperties: parsedAdditionalProps, @@ -1116,11 +1162,12 @@ class JsonObject extends JsonSchema { if (_hasDefault) 'default': defaultValue, 'type': 'object', if (properties != null) - 'properties': properties!.map((k, v) => MapEntry(k, v.toJson())), + 'properties': + properties!.map((k, v) => MapEntry(k, _jsonSchemaValue(v))), if (required != null && required!.isNotEmpty) 'required': required, if (additionalProperties != null) 'additionalProperties': additionalProperties is JsonSchema - ? (additionalProperties as JsonSchema).toJson() + ? _jsonSchemaValue(additionalProperties as JsonSchema) : additionalProperties, if (dependentRequired != null) 'dependentRequired': dependentRequired, ...?extra, @@ -1167,6 +1214,12 @@ class JsonAny extends JsonSchema { }) : _hasDefault = hasDefault, super(title: title, description: description); + const JsonAny._booleanSubschema() + : properties = const {}, + _hasDefault = false, + defaultValue = null, + super(rawBooleanSubschema: true); + @override final dynamic defaultValue; @@ -1307,7 +1360,7 @@ class JsonUnion extends JsonSchema { if (typeNames != null) 'type': typeNames else - 'anyOf': schemas.map((schema) => schema.toJson()).toList(), + 'anyOf': schemas.map(_jsonSchemaValue).toList(), }; } @@ -1403,7 +1456,7 @@ class JsonAllOf extends JsonSchema { factory JsonAllOf.fromJson(Map json) { return JsonAllOf._( (json['allOf'] as List) - .map((e) => JsonSchema.fromJson(e as Map)) + .map((e) => JsonSchema._fromJsonValue(e, 'JsonAllOf.allOf')) .toList(), title: json['title'] as String?, description: json['description'] as String?, @@ -1418,7 +1471,7 @@ class JsonAllOf extends JsonSchema { if (title != null) 'title': title, if (description != null) 'description': description, if (_hasDefault) 'default': defaultValue, - 'allOf': schemas.map((s) => s.toJson()).toList(), + 'allOf': schemas.map(_jsonSchemaValue).toList(), }; } } @@ -1449,7 +1502,7 @@ class JsonAnyOf extends JsonSchema { factory JsonAnyOf.fromJson(Map json) { return JsonAnyOf._( (json['anyOf'] as List) - .map((e) => JsonSchema.fromJson(e as Map)) + .map((e) => JsonSchema._fromJsonValue(e, 'JsonAnyOf.anyOf')) .toList(), title: json['title'] as String?, description: json['description'] as String?, @@ -1464,7 +1517,7 @@ class JsonAnyOf extends JsonSchema { if (title != null) 'title': title, if (description != null) 'description': description, if (_hasDefault) 'default': defaultValue, - 'anyOf': schemas.map((s) => s.toJson()).toList(), + 'anyOf': schemas.map(_jsonSchemaValue).toList(), }; } } @@ -1495,7 +1548,7 @@ class JsonOneOf extends JsonSchema { factory JsonOneOf.fromJson(Map json) { return JsonOneOf._( (json['oneOf'] as List) - .map((e) => JsonSchema.fromJson(e as Map)) + .map((e) => JsonSchema._fromJsonValue(e, 'JsonOneOf.oneOf')) .toList(), title: json['title'] as String?, description: json['description'] as String?, @@ -1510,7 +1563,7 @@ class JsonOneOf extends JsonSchema { if (title != null) 'title': title, if (description != null) 'description': description, if (_hasDefault) 'default': defaultValue, - 'oneOf': schemas.map((s) => s.toJson()).toList(), + 'oneOf': schemas.map(_jsonSchemaValue).toList(), }; } } @@ -1535,12 +1588,18 @@ class JsonNot extends JsonSchema { required bool hasDefault, }) : _hasDefault = hasDefault; + const JsonNot._never() + : schema = const JsonAny(), + defaultValue = null, + _hasDefault = false, + super(rawBooleanSubschema: false); + @override final dynamic defaultValue; factory JsonNot.fromJson(Map json) { return JsonNot._( - JsonSchema.fromJson(json['not'] as Map), + JsonSchema._fromJsonValue(json['not'], 'JsonNot.not'), title: json['title'] as String?, description: json['description'] as String?, defaultValue: json['default'], @@ -1554,7 +1613,7 @@ class JsonNot extends JsonSchema { if (title != null) 'title': title, if (description != null) 'description': description, if (_hasDefault) 'default': defaultValue, - 'not': schema.toJson(), + 'not': _jsonSchemaValue(schema), }; } } diff --git a/lib/src/shared/json_schema/json_schema_validator.dart b/lib/src/shared/json_schema/json_schema_validator.dart index 38bdad82..4945bf4a 100644 --- a/lib/src/shared/json_schema/json_schema_validator.dart +++ b/lib/src/shared/json_schema/json_schema_validator.dart @@ -550,24 +550,17 @@ extension JsonSchemaValidation on JsonSchema { ) { final allOf = keywords['allOf']; if (keywords.containsKey('allOf')) { - for (final entry in _compositionSchemaList('allOf', allOf, path)) { - _validate( - JsonSchema.fromJson(entry), - data, - path, - ); + for (final schema in _compositionSchemaList('allOf', allOf, path)) { + _validate(schema, data, path); } } final anyOf = keywords['anyOf']; if (keywords.containsKey('anyOf')) { - final matches = _compositionSchemaList('anyOf', anyOf, path).any((entry) { + final matches = + _compositionSchemaList('anyOf', anyOf, path).any((schema) { try { - _validate( - JsonSchema.fromJson(entry), - data, - path, - ); + _validate(schema, data, path); return true; } on JsonSchemaValidationException { return false; @@ -584,13 +577,9 @@ extension JsonSchemaValidation on JsonSchema { final oneOf = keywords['oneOf']; if (keywords.containsKey('oneOf')) { final matches = - _compositionSchemaList('oneOf', oneOf, path).where((entry) { + _compositionSchemaList('oneOf', oneOf, path).where((schema) { try { - _validate( - JsonSchema.fromJson(entry), - data, - path, - ); + _validate(schema, data, path); return true; } on JsonSchemaValidationException { return false; @@ -606,18 +595,17 @@ extension JsonSchemaValidation on JsonSchema { final not = keywords['not']; if (keywords.containsKey('not')) { - if (not is! Map) { + final JsonSchema notSchema; + try { + notSchema = JsonSchema.fromJsonValue(not); + } on FormatException { throw JsonSchemaValidationException( - 'not must be a schema object', + 'not must be a schema object or boolean', path, ); } try { - _validate( - JsonSchema.fromJson(Map.from(not)), - data, - path, - ); + _validate(notSchema, data, path); } on JsonSchemaValidationException { return; } @@ -625,7 +613,7 @@ extension JsonSchemaValidation on JsonSchema { } } - List> _compositionSchemaList( + List _compositionSchemaList( String keyword, dynamic value, List path, @@ -634,13 +622,14 @@ extension JsonSchemaValidation on JsonSchema { throw JsonSchemaValidationException('$keyword must be a list', path); } return value.map((entry) { - if (entry is! Map) { + try { + return JsonSchema.fromJsonValue(entry); + } on FormatException { throw JsonSchemaValidationException( - '$keyword entries must be schema objects', + '$keyword entries must be schema objects or booleans', path, ); } - return Map.from(entry); }).toList(); } diff --git a/test/shared/json_schema_from_json_test.dart b/test/shared/json_schema_from_json_test.dart index 77ccd5b9..ec9520e0 100644 --- a/test/shared/json_schema_from_json_test.dart +++ b/test/shared/json_schema_from_json_test.dart @@ -239,6 +239,31 @@ void main() { expect(schema, isA()); }); + test('round trips boolean schema values', () { + expect(JsonSchema.fromJsonValue(true).toJsonValue(), true); + expect(JsonSchema.fromJsonValue(false).toJsonValue(), false); + }); + + test('parses object properties with boolean subschemas', () { + final json = { + 'type': 'object', + 'properties': { + 'allowed': true, + 'denied': false, + 'named': {'type': 'string'}, + }, + }; + + final schema = JsonSchema.fromJson(json); + + expect(schema, isA()); + final object = schema as JsonObject; + expect(object.properties!['allowed'], isA()); + expect(object.properties!['denied'], isA()); + expect(object.properties!['named'], isA()); + expect(object.toJson(), json); + }); + test('parses null schema', () { final json = {'type': 'null'}; final schema = JsonSchema.fromJson(json); @@ -288,6 +313,18 @@ void main() { ); }); + test('parses array items with boolean subschemas', () { + for (final json in [ + {'type': 'array', 'items': true}, + {'type': 'array', 'items': false}, + ]) { + final schema = JsonSchema.fromJson(json); + + expect(schema, isA()); + expect(schema.toJson(), json); + } + }); + test('parses object schema', () { final json = { 'type': 'object', @@ -421,6 +458,32 @@ void main() { expect(s.schemas[1].toJson(), {'minLength': 5}); }); + test('parses composition keywords with boolean subschemas', () { + final schemas = [ + { + 'allOf': [ + true, + {'type': 'string'}, + ], + }, + { + 'anyOf': [ + false, + {'type': 'integer'}, + ], + }, + { + 'oneOf': [true, false], + }, + {'not': false}, + {'not': true}, + ]; + + for (final json in schemas) { + expect(JsonSchema.fromJson(json).toJson(), json); + } + }); + test('parses anyOf schema', () { final json = { 'anyOf': [ @@ -502,6 +565,30 @@ void main() { expect(parsed.toJson(), json); }); + test('nested boolean subschemas round trip', () { + final schemas = [ + { + 'type': 'object', + 'properties': { + 'allowed': true, + 'denied': false, + }, + }, + {'type': 'array', 'items': false}, + { + 'allOf': [ + true, + {'type': 'string'}, + ], + }, + {'not': true}, + ]; + + for (final json in schemas) { + expect(JsonSchema.fromJson(json).toJson(), json); + } + }); + test('const round trip', () { final original = JsonSchema.constValue('DELETE'); final json = original.toJson(); @@ -519,6 +606,20 @@ void main() { expect(parsed.toJson(), json); }); + test('union with boolean subschema branches round trip', () { + final original = JsonSchema.union([ + JsonSchema.fromJsonValue(true), + JsonSchema.fromJsonValue(false), + ]); + final json = original.toJson(); + final parsed = JsonSchema.fromJson(json); + + expect(json, { + 'anyOf': [true, false], + }); + expect(parsed.toJson(), json); + }); + test('type array union with sibling constraints preserves wire shape', () { final json = { 'title': 'Optional mode', diff --git a/test/shared/json_schema_validator_test.dart b/test/shared/json_schema_validator_test.dart index f766efdc..dba16751 100644 --- a/test/shared/json_schema_validator_test.dart +++ b/test/shared/json_schema_validator_test.dart @@ -277,6 +277,24 @@ void main() { ); }); + test('validates array items with boolean subschemas', () { + final alwaysValid = JsonSchema.fromJson({ + 'type': 'array', + 'items': true, + }); + alwaysValid.validate([1, 'two', null]); + + final alwaysInvalid = JsonSchema.fromJson({ + 'type': 'array', + 'items': false, + }); + alwaysInvalid.validate([]); + expect( + () => alwaysInvalid.validate([1]), + throwsA(isA()), + ); + }); + test('uniqueItems works with objects', () { final schema = JsonSchema.array(uniqueItems: true); schema.validate([ @@ -403,6 +421,24 @@ void main() { "nested": {"a": true}, }); }); + + test('validates object properties with boolean subschemas', () { + final schema = JsonSchema.fromJson({ + 'type': 'object', + 'properties': { + 'allowed': true, + 'denied': false, + }, + }); + + schema.validate({'allowed': 'anything'}); + schema.validate({'allowed': null}); + + expect( + () => schema.validate({'denied': 'anything'}), + throwsA(isA()), + ); + }); }); group('enum validation', () { @@ -627,6 +663,47 @@ void main() { ); }); + test('validates composition keywords with boolean subschemas', () { + final allOfTrue = JsonSchema.fromJson({ + 'allOf': [ + true, + {'type': 'string'}, + ], + }); + allOfTrue.validate('value'); + expect( + () => allOfTrue.validate(1), + throwsA(isA()), + ); + + final anyOfFalse = JsonSchema.fromJson({ + 'anyOf': [ + false, + {'type': 'integer'}, + ], + }); + anyOfFalse.validate(1); + expect( + () => anyOfFalse.validate('value'), + throwsA(isA()), + ); + + final oneOfTrueFalse = JsonSchema.fromJson({ + 'oneOf': [true, false], + }); + oneOfTrueFalse.validate('value'); + oneOfTrueFalse.validate(null); + + final notFalse = JsonSchema.fromJson({'not': false}); + notFalse.validate('value'); + + final notTrue = JsonSchema.fromJson({'not': true}); + expect( + () => notTrue.validate('value'), + throwsA(isA()), + ); + }); + test('preserves sibling assertions around const composition lists', () { final schema = JsonSchema.fromJson({ 'type': 'string', diff --git a/test/tool_schema_test.dart b/test/tool_schema_test.dart index d127adef..698df422 100644 --- a/test/tool_schema_test.dart +++ b/test/tool_schema_test.dart @@ -366,6 +366,31 @@ void main() { ); }); + test('Tool preserves boolean subschema properties end-to-end', () { + final json = { + 'name': 'boolean-subschemas', + 'inputSchema': { + 'type': 'object', + 'properties': { + 'allowed': true, + 'denied': false, + }, + }, + }; + + final tool = Tool.fromJson(json); + + expect(tool.toJson(), json); + expect( + ListToolsResult.fromJson({ + 'tools': [json], + }).toJson(), + { + 'tools': [json], + }, + ); + }); + test('Real-world MCP server tool schema example', () { // Example from a real MCP server like Hugging Face final serverResponse = { From 7d2da048e27b2300c63da5caf9b5f4d7e21e20e7 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Fri, 12 Jun 2026 10:29:33 -0400 Subject: [PATCH 52/68] Preserve boolean schemas in legacy tool shims --- lib/src/server/mcp_server.dart | 6 ++++-- test/server/mcp_server_test.dart | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index bda5198a..9447e376 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -2281,7 +2281,8 @@ class McpServer { (inputSchemaProperties != null ? ToolInputSchema( properties: inputSchemaProperties.map( - (key, value) => MapEntry(key, JsonSchema.fromJson(value)), + (key, value) => + MapEntry(key, JsonSchema.fromJsonValue(value)), ), ) : null), @@ -2289,7 +2290,8 @@ class McpServer { (outputSchemaProperties != null ? ToolOutputSchema( properties: outputSchemaProperties.map( - (key, value) => MapEntry(key, JsonSchema.fromJson(value)), + (key, value) => + MapEntry(key, JsonSchema.fromJsonValue(value)), ), ) : null), diff --git a/test/server/mcp_server_test.dart b/test/server/mcp_server_test.dart index 54515504..9625046c 100644 --- a/test/server/mcp_server_test.dart +++ b/test/server/mcp_server_test.dart @@ -132,6 +132,43 @@ void main() { expect(tools.first['name'], equals('test-tool')); }); + test('legacy tool schema shims preserve boolean subschemas', () async { + server.tool( + 'legacy-boolean-schema-tool', + inputSchemaProperties: { + 'allowed': true, + 'denied': false, + 'named': {'type': 'string'}, + }, + outputSchemaProperties: { + 'allowed': true, + 'denied': false, + }, + callback: ({args, extra}) async => const CallToolResult(content: []), + ); + + await server.connect(transport); + + transport.receiveMessage(const JsonRpcListToolsRequest(id: 1)); + await Future.delayed(const Duration(milliseconds: 100)); + + final response = transport.sentMessages.last as JsonRpcResponse; + final tools = response.result['tools'] as List; + final tool = tools.single as Map; + final inputSchema = tool['inputSchema'] as Map; + final outputSchema = tool['outputSchema'] as Map; + + expect(inputSchema['properties'], { + 'allowed': true, + 'denied': false, + 'named': {'type': 'string'}, + }); + expect(outputSchema['properties'], { + 'allowed': true, + 'denied': false, + }); + }); + test('connect syncs tool parameter header mappings to transports', () async { server = McpServer( From ab1745adcceaeb48d3517a9be81ecd4510e0eb7f Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Fri, 12 Jun 2026 16:38:27 -0400 Subject: [PATCH 53/68] Update conformance alpha3 draft target --- .github/workflows/test_core.yml | 2 +- CHANGELOG.md | 7 +++++-- doc/mcp-2026-rc.md | 7 ++++++- lib/src/server/server.dart | 12 +++++++++-- lib/src/server/streamable_https.dart | 3 ++- lib/src/types/json_rpc.dart | 21 ++++++++++++++----- .../2026_rc_client_expected_failures.txt | 6 +++--- .../conformance/2026_rc_expected_failures.txt | 2 +- test/conformance/README.md | 8 +++---- test/conformance/mcp_2026_rc_client.dart | 2 +- .../run_2025_server_conformance.dart | 2 +- .../run_2026_rc_client_conformance.dart | 4 ++-- .../run_2026_rc_server_conformance.dart | 4 ++-- test/mcp_2026_07_28_test.dart | 13 ++++++++++-- test/server/streamable_mcp_server_test.dart | 6 ++++-- 15 files changed, 69 insertions(+), 30 deletions(-) diff --git a/.github/workflows/test_core.yml b/.github/workflows/test_core.yml index 7de11047..ca462337 100644 --- a/.github/workflows/test_core.yml +++ b/.github/workflows/test_core.yml @@ -51,7 +51,7 @@ jobs: - name: Run official MCP 2025 client conformance run: > - npx -y @modelcontextprotocol/conformance@0.2.0-alpha.2 client + npx -y @modelcontextprotocol/conformance@0.2.0-alpha.3 client --command "dart run test/conformance/mcp_2026_rc_client.dart" --suite all --spec-version 2025-11-25 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ca95e99..27359fac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,12 @@ ### Conformance and release readiness - Updated official conformance gates to - `@modelcontextprotocol/conformance@0.2.0-alpha.2`, with 2026 RC runs pinned - to `DRAFT-2026-v1` and the current upstream draft fixture gap tracked as an + `@modelcontextprotocol/conformance@0.2.0-alpha.3`, with 2026 RC runs pinned + to `2026-07-28` and the current upstream draft fixture gap tracked as an expected failure. +- Stopped advertising the legacy `DRAFT-2026-v1` draft alias from 2026 + protocol profiles. The alias remains recognized as a deprecated inbound + compatibility marker for early conformance alpha runs. ## 2.3.0-dev.0 diff --git a/doc/mcp-2026-rc.md b/doc/mcp-2026-rc.md index 25cd1704..ab680914 100644 --- a/doc/mcp-2026-rc.md +++ b/doc/mcp-2026-rc.md @@ -78,6 +78,11 @@ final client = McpClient( Prefer the `protocol` profile unless you need to target a specific protocol version for tests or interoperability debugging. +Use `draftProtocolVersion2026_07_28` for MCP `2026-07-28` draft/RC testing. +The older `draftProtocolVersion2026V1` alias is deprecated and kept only as an +inbound compatibility marker for early conformance alpha fixtures; 2026 +profiles do not advertise it. + ## 2026-07-28 Draft-Only API Areas The following features are MCP `2026-07-28` draft/RC behavior and should be @@ -126,7 +131,7 @@ Before creating follow-up dev tags from `dev/2026-07-28-rc`, run: ```sh dart analyze dart run test/conformance/run_2025_server_conformance.dart -npx -y @modelcontextprotocol/conformance@0.2.0-alpha.2 client \ +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.3 client \ --command "dart run test/conformance/mcp_2026_rc_client.dart" \ --suite all \ --spec-version 2025-11-25 diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 1809540d..f3352f2f 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -181,6 +181,14 @@ class Server extends Protocol { ); } + bool _acceptsProtocolVersion(String version) { + if (_supportedVersions.contains(version)) { + return true; + } + return isStatelessProtocolVersion(version) && + _supportedVersions.any(isStatelessProtocolVersion); + } + McpError? _validateStatelessRequestMetadata(JsonRpcRequest request) { final meta = request.meta; try { @@ -200,7 +208,7 @@ class Server extends Protocol { 'Missing required request metadata: ${McpMetaKey.protocolVersion}', ); } - if (!_supportedVersions.contains(requestedVersion)) { + if (!_acceptsProtocolVersion(requestedVersion)) { return _unsupportedProtocolVersionError(requestedVersion); } if (!isStatelessProtocolVersion(requestedVersion)) { @@ -747,7 +755,7 @@ class Server extends Protocol { final requestedProtocolVersion = request.meta?[McpMetaKey.protocolVersion]; if (requestedProtocolVersion is String && - !_supportedVersions.contains(requestedProtocolVersion)) { + !_acceptsProtocolVersion(requestedProtocolVersion)) { return _unsupportedProtocolVersionError(requestedProtocolVersion); } if (requestedProtocolVersion is String && diff --git a/lib/src/server/streamable_https.dart b/lib/src/server/streamable_https.dart index 48a2c67b..5755267f 100644 --- a/lib/src/server/streamable_https.dart +++ b/lib/src/server/streamable_https.dart @@ -311,7 +311,8 @@ class StreamableHTTPServerTransport } final requestedVersion = versionHeader.trim(); - if (supportedProtocolVersionsWithDraft.contains(requestedVersion)) { + if (supportedProtocolVersionsWithDraft.contains(requestedVersion) || + isStatelessProtocolVersion(requestedVersion)) { return true; } diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index dc255007..9f927c10 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -15,8 +15,16 @@ import 'validation.dart'; /// The draft/RC MCP protocol version being prepared for the next major release. const draftProtocolVersion2026_07_28 = "2026-07-28"; -/// Upstream conformance-suite alias for the in-progress 2026 draft. -const draftProtocolVersion2026V1 = "DRAFT-2026-v1"; +const _legacyDraftProtocolVersion2026V1 = "DRAFT-2026-v1"; + +/// Legacy upstream conformance-suite alias for the in-progress 2026 draft. +/// +/// This alias was used by early `@modelcontextprotocol/conformance` +/// `0.2.0-alpha` releases before the draft wire version changed to +/// `2026-07-28`. New clients and servers should advertise +/// [draftProtocolVersion2026_07_28]. +@Deprecated('Use draftProtocolVersion2026_07_28 instead') +const draftProtocolVersion2026V1 = _legacyDraftProtocolVersion2026V1; /// The latest stable version of the Model Context Protocol supported. const stableProtocolVersion2025_11_25 = "2025-11-25"; @@ -105,20 +113,23 @@ const supportedProtocolVersions = [ /// Protocol versions supported by the `2026-07-28` draft/RC development branch. const supportedProtocolVersionsWithDraft = [ latestDraftProtocolVersion, - draftProtocolVersion2026V1, ...supportedProtocolVersions, ]; /// Protocol versions that use per-request metadata instead of initialization. const statelessProtocolVersions = [ draftProtocolVersion2026_07_28, - draftProtocolVersion2026V1, +]; + +const _legacyStatelessProtocolVersions = [ + _legacyDraftProtocolVersion2026V1, ]; /// Returns true when [version] uses the `2026-07-28` draft/RC stateless request /// model. bool isStatelessProtocolVersion(String version) => - statelessProtocolVersions.contains(version); + statelessProtocolVersions.contains(version) || + _legacyStatelessProtocolVersions.contains(version); /// Selects the first locally preferred version supported by a peer. String? negotiateProtocolVersion( diff --git a/test/conformance/2026_rc_client_expected_failures.txt b/test/conformance/2026_rc_client_expected_failures.txt index 0ff0ef54..938489d6 100644 --- a/test/conformance/2026_rc_client_expected_failures.txt +++ b/test/conformance/2026_rc_client_expected_failures.txt @@ -1,10 +1,10 @@ -# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.2 +# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.3 # against the 2026 RC/DRAFT client suite. # # Keep this list scenario-based so the baseline is easy to review. When a # scenario turns green, remove it from this file in the same PR as the fix. # -# Upstream alpha.2 fixture gap: this scenario's mock server rejects -# DRAFT-2026-v1 with HTTP 400 and advertises only stable protocol versions. +# Upstream alpha.3 fixture gap: this scenario's mock server rejects +# 2026-07-28 with HTTP 400 and advertises only stable protocol versions. # Keep it expected-fail until the conformance fixture is draft-capable. json-schema-ref-no-deref diff --git a/test/conformance/2026_rc_expected_failures.txt b/test/conformance/2026_rc_expected_failures.txt index cfadd3b2..adca743f 100644 --- a/test/conformance/2026_rc_expected_failures.txt +++ b/test/conformance/2026_rc_expected_failures.txt @@ -1,4 +1,4 @@ -# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.2 +# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.3 # against the 2026 RC/DRAFT server suite. # # Keep this list scenario-based so the baseline is easy to review. When a diff --git a/test/conformance/README.md b/test/conformance/README.md index 270359a6..27246069 100644 --- a/test/conformance/README.md +++ b/test/conformance/README.md @@ -26,14 +26,14 @@ dart run test/conformance/run_2025_server_conformance.dart ``` The runner starts `mcp_2025_server.dart`, runs -`@modelcontextprotocol/conformance@0.2.0-alpha.2 server --suite all +`@modelcontextprotocol/conformance@0.2.0-alpha.3 server --suite all --spec-version 2025-11-25`, and writes artifacts under `.dart_tool/conformance/2025_server/`. Run the stable client suite from the repository root: ```bash -npx -y @modelcontextprotocol/conformance@0.2.0-alpha.2 client \ +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.3 client \ --command "dart run test/conformance/mcp_2026_rc_client.dart" \ --suite all \ --spec-version 2025-11-25 \ @@ -55,8 +55,8 @@ dart run test/conformance/run_2026_rc_server_conformance.dart The runner starts a local `StreamableMcpServer` with JSON stateless responses enabled, runs the draft server scenarios from -`@modelcontextprotocol/conformance@0.2.0-alpha.2` one by one with -`--spec-version DRAFT-2026-v1`, and writes per-run artifacts under +`@modelcontextprotocol/conformance@0.2.0-alpha.3` one by one with +`--spec-version 2026-07-28`, and writes per-run artifacts under `.dart_tool/conformance/2026_rc/`. Expected failures live in `2026_rc_expected_failures.txt`. When a scenario is diff --git a/test/conformance/mcp_2026_rc_client.dart b/test/conformance/mcp_2026_rc_client.dart index 5925500c..c4a2f742 100644 --- a/test/conformance/mcp_2026_rc_client.dart +++ b/test/conformance/mcp_2026_rc_client.dart @@ -19,7 +19,7 @@ Future main(List args) async { final scenario = Platform.environment['MCP_CONFORMANCE_SCENARIO']; final protocolVersion = Platform.environment['MCP_CONFORMANCE_PROTOCOL_VERSION'] ?? - draftProtocolVersion2026V1; + draftProtocolVersion2026_07_28; final context = _readContext(); switch (scenario) { diff --git a/test/conformance/run_2025_server_conformance.dart b/test/conformance/run_2025_server_conformance.dart index b5b17183..fe7e80db 100644 --- a/test/conformance/run_2025_server_conformance.dart +++ b/test/conformance/run_2025_server_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.2'; + '@modelcontextprotocol/conformance@0.2.0-alpha.3'; const _defaultTimeout = Duration(seconds: 60); Future main(List args) async { diff --git a/test/conformance/run_2026_rc_client_conformance.dart b/test/conformance/run_2026_rc_client_conformance.dart index 3659c4e8..b3a19b34 100644 --- a/test/conformance/run_2026_rc_client_conformance.dart +++ b/test/conformance/run_2026_rc_client_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.2'; + '@modelcontextprotocol/conformance@0.2.0-alpha.3'; const _defaultTimeout = Duration(seconds: 30); const _draftClientScenarios = [ @@ -158,7 +158,7 @@ Future<_ScenarioResult> _runScenario({ '--scenario', scenario, '--spec-version', - 'DRAFT-2026-v1', + '2026-07-28', '--verbose', '-o', outputDir.path, diff --git a/test/conformance/run_2026_rc_server_conformance.dart b/test/conformance/run_2026_rc_server_conformance.dart index bcd5021f..2d9ef028 100644 --- a/test/conformance/run_2026_rc_server_conformance.dart +++ b/test/conformance/run_2026_rc_server_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.2'; + '@modelcontextprotocol/conformance@0.2.0-alpha.3'; const _defaultTimeout = Duration(seconds: 25); const _draftServerScenarios = [ @@ -230,7 +230,7 @@ Future<_ScenarioResult> _runScenario({ '--suite', 'draft', '--spec-version', - 'DRAFT-2026-v1', + '2026-07-28', '--scenario', scenario, '--verbose', diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 5802836f..179a2bba 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -9,6 +9,8 @@ import 'package:mcp_dart/src/shared/transport.dart'; import 'package:mcp_dart/src/types.dart'; import 'package:test/test.dart'; +const _legacyDraftProtocolVersion2026V1 = 'DRAFT-2026-v1'; + class RecordingTransport extends Transport { RecordingTransport({this.sessionIdValue}); @@ -335,10 +337,17 @@ void main() { ); expect( supportedProtocolVersionsWithDraft, - contains(draftProtocolVersion2026V1), + isNot(contains(_legacyDraftProtocolVersion2026V1)), + ); + expect( + statelessProtocolVersions, + isNot(contains(_legacyDraftProtocolVersion2026V1)), ); expect(isStatelessProtocolVersion(draftProtocolVersion2026_07_28), true); - expect(isStatelessProtocolVersion(draftProtocolVersion2026V1), true); + expect( + isStatelessProtocolVersion(_legacyDraftProtocolVersion2026V1), + true, + ); expect(isStatelessProtocolVersion(latestProtocolVersion), false); }); diff --git a/test/server/streamable_mcp_server_test.dart b/test/server/streamable_mcp_server_test.dart index 7cc6083e..0f372092 100644 --- a/test/server/streamable_mcp_server_test.dart +++ b/test/server/streamable_mcp_server_test.dart @@ -6,6 +6,8 @@ import 'package:http/http.dart' as http; import 'package:mcp_dart/mcp_dart.dart'; import 'package:test/test.dart'; +const _legacyDraftProtocolVersion2026V1 = 'DRAFT-2026-v1'; + class _SseEvent { final String? id; final String data; @@ -526,7 +528,7 @@ void main() { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream', - 'MCP-Protocol-Version': draftProtocolVersion2026V1, + 'MCP-Protocol-Version': _legacyDraftProtocolVersion2026V1, 'Mcp-Method': Method.serverDiscover, }, ); @@ -600,7 +602,7 @@ void main() { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream', - 'MCP-Protocol-Version': draftProtocolVersion2026V1, + 'MCP-Protocol-Version': _legacyDraftProtocolVersion2026V1, 'Mcp-Method': method, }, ); From 86c42c72eaf4d131be931f134abfdac492ee2bfa Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Fri, 12 Jun 2026 17:22:30 -0400 Subject: [PATCH 54/68] Remove legacy 2026 draft alias --- CHANGELOG.md | 5 ++--- doc/mcp-2026-rc.md | 5 ++--- lib/src/server/server.dart | 12 ++---------- lib/src/server/streamable_https.dart | 3 +-- lib/src/types/json_rpc.dart | 18 +----------------- test/mcp_2026_07_28_test.dart | 15 +-------------- test/server/streamable_mcp_server_test.dart | 6 ++---- 7 files changed, 11 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27359fac..55c9b91d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,8 @@ `@modelcontextprotocol/conformance@0.2.0-alpha.3`, with 2026 RC runs pinned to `2026-07-28` and the current upstream draft fixture gap tracked as an expected failure. -- Stopped advertising the legacy `DRAFT-2026-v1` draft alias from 2026 - protocol profiles. The alias remains recognized as a deprecated inbound - compatibility marker for early conformance alpha runs. +- Removed the legacy `DRAFT-2026-v1` draft alias now that official conformance + targets the `2026-07-28` wire version. ## 2.3.0-dev.0 diff --git a/doc/mcp-2026-rc.md b/doc/mcp-2026-rc.md index ab680914..987c5096 100644 --- a/doc/mcp-2026-rc.md +++ b/doc/mcp-2026-rc.md @@ -79,9 +79,8 @@ Prefer the `protocol` profile unless you need to target a specific protocol version for tests or interoperability debugging. Use `draftProtocolVersion2026_07_28` for MCP `2026-07-28` draft/RC testing. -The older `draftProtocolVersion2026V1` alias is deprecated and kept only as an -inbound compatibility marker for early conformance alpha fixtures; 2026 -profiles do not advertise it. +The earlier `DRAFT-2026-v1` conformance alias is no longer exposed or accepted +by the SDK. ## 2026-07-28 Draft-Only API Areas diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index f3352f2f..1809540d 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -181,14 +181,6 @@ class Server extends Protocol { ); } - bool _acceptsProtocolVersion(String version) { - if (_supportedVersions.contains(version)) { - return true; - } - return isStatelessProtocolVersion(version) && - _supportedVersions.any(isStatelessProtocolVersion); - } - McpError? _validateStatelessRequestMetadata(JsonRpcRequest request) { final meta = request.meta; try { @@ -208,7 +200,7 @@ class Server extends Protocol { 'Missing required request metadata: ${McpMetaKey.protocolVersion}', ); } - if (!_acceptsProtocolVersion(requestedVersion)) { + if (!_supportedVersions.contains(requestedVersion)) { return _unsupportedProtocolVersionError(requestedVersion); } if (!isStatelessProtocolVersion(requestedVersion)) { @@ -755,7 +747,7 @@ class Server extends Protocol { final requestedProtocolVersion = request.meta?[McpMetaKey.protocolVersion]; if (requestedProtocolVersion is String && - !_acceptsProtocolVersion(requestedProtocolVersion)) { + !_supportedVersions.contains(requestedProtocolVersion)) { return _unsupportedProtocolVersionError(requestedProtocolVersion); } if (requestedProtocolVersion is String && diff --git a/lib/src/server/streamable_https.dart b/lib/src/server/streamable_https.dart index 5755267f..48a2c67b 100644 --- a/lib/src/server/streamable_https.dart +++ b/lib/src/server/streamable_https.dart @@ -311,8 +311,7 @@ class StreamableHTTPServerTransport } final requestedVersion = versionHeader.trim(); - if (supportedProtocolVersionsWithDraft.contains(requestedVersion) || - isStatelessProtocolVersion(requestedVersion)) { + if (supportedProtocolVersionsWithDraft.contains(requestedVersion)) { return true; } diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index 9f927c10..5ef5a289 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -15,17 +15,6 @@ import 'validation.dart'; /// The draft/RC MCP protocol version being prepared for the next major release. const draftProtocolVersion2026_07_28 = "2026-07-28"; -const _legacyDraftProtocolVersion2026V1 = "DRAFT-2026-v1"; - -/// Legacy upstream conformance-suite alias for the in-progress 2026 draft. -/// -/// This alias was used by early `@modelcontextprotocol/conformance` -/// `0.2.0-alpha` releases before the draft wire version changed to -/// `2026-07-28`. New clients and servers should advertise -/// [draftProtocolVersion2026_07_28]. -@Deprecated('Use draftProtocolVersion2026_07_28 instead') -const draftProtocolVersion2026V1 = _legacyDraftProtocolVersion2026V1; - /// The latest stable version of the Model Context Protocol supported. const stableProtocolVersion2025_11_25 = "2025-11-25"; @@ -121,15 +110,10 @@ const statelessProtocolVersions = [ draftProtocolVersion2026_07_28, ]; -const _legacyStatelessProtocolVersions = [ - _legacyDraftProtocolVersion2026V1, -]; - /// Returns true when [version] uses the `2026-07-28` draft/RC stateless request /// model. bool isStatelessProtocolVersion(String version) => - statelessProtocolVersions.contains(version) || - _legacyStatelessProtocolVersions.contains(version); + statelessProtocolVersions.contains(version); /// Selects the first locally preferred version supported by a peer. String? negotiateProtocolVersion( diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 179a2bba..26f86c3a 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -9,8 +9,6 @@ import 'package:mcp_dart/src/shared/transport.dart'; import 'package:mcp_dart/src/types.dart'; import 'package:test/test.dart'; -const _legacyDraftProtocolVersion2026V1 = 'DRAFT-2026-v1'; - class RecordingTransport extends Transport { RecordingTransport({this.sessionIdValue}); @@ -335,19 +333,8 @@ void main() { supportedProtocolVersionsWithDraft, contains(draftProtocolVersion2026_07_28), ); - expect( - supportedProtocolVersionsWithDraft, - isNot(contains(_legacyDraftProtocolVersion2026V1)), - ); - expect( - statelessProtocolVersions, - isNot(contains(_legacyDraftProtocolVersion2026V1)), - ); + expect(statelessProtocolVersions, [draftProtocolVersion2026_07_28]); expect(isStatelessProtocolVersion(draftProtocolVersion2026_07_28), true); - expect( - isStatelessProtocolVersion(_legacyDraftProtocolVersion2026V1), - true, - ); expect(isStatelessProtocolVersion(latestProtocolVersion), false); }); diff --git a/test/server/streamable_mcp_server_test.dart b/test/server/streamable_mcp_server_test.dart index 0f372092..0fab3953 100644 --- a/test/server/streamable_mcp_server_test.dart +++ b/test/server/streamable_mcp_server_test.dart @@ -6,8 +6,6 @@ import 'package:http/http.dart' as http; import 'package:mcp_dart/mcp_dart.dart'; import 'package:test/test.dart'; -const _legacyDraftProtocolVersion2026V1 = 'DRAFT-2026-v1'; - class _SseEvent { final String? id; final String data; @@ -528,7 +526,7 @@ void main() { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream', - 'MCP-Protocol-Version': _legacyDraftProtocolVersion2026V1, + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, 'Mcp-Method': Method.serverDiscover, }, ); @@ -602,7 +600,7 @@ void main() { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream', - 'MCP-Protocol-Version': _legacyDraftProtocolVersion2026V1, + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, 'Mcp-Method': method, }, ); From fe89a116031a0308cf1a3a0ed2ee996e85a79382 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Fri, 12 Jun 2026 17:25:38 -0400 Subject: [PATCH 55/68] Cover removed draft protocol alias --- test/mcp_2026_07_28_test.dart | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 26f86c3a..c8fa041e 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -9,6 +9,8 @@ import 'package:mcp_dart/src/shared/transport.dart'; import 'package:mcp_dart/src/types.dart'; import 'package:test/test.dart'; +const _removedDraftProtocolVersion2026V1 = 'DRAFT-2026-v1'; + class RecordingTransport extends Transport { RecordingTransport({this.sessionIdValue}); @@ -335,6 +337,10 @@ void main() { ); expect(statelessProtocolVersions, [draftProtocolVersion2026_07_28]); expect(isStatelessProtocolVersion(draftProtocolVersion2026_07_28), true); + expect( + isStatelessProtocolVersion(_removedDraftProtocolVersion2026V1), + false, + ); expect(isStatelessProtocolVersion(latestProtocolVersion), false); }); @@ -4166,8 +4172,7 @@ void main() { ); }); - test('server returns unsupported protocol version for stateless metadata', - () async { + test('server rejects removed draft protocol alias', () async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), options: const McpServerOptions( @@ -4180,14 +4185,18 @@ void main() { transport.receive( JsonRpcListToolsRequest( id: 1, - meta: _clientMeta(protocolVersion: '1900-01-01'), + meta: + _clientMeta(protocolVersion: _removedDraftProtocolVersion2026V1), ), ); 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['requested'], + _removedDraftProtocolVersion2026V1, + ); expect( response.error.data['supported'], contains(draftProtocolVersion2026_07_28), From 1959708b7e0631e8b24e0cbc0cc8e33e418d52b5 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Fri, 12 Jun 2026 19:11:30 -0400 Subject: [PATCH 56/68] Run full 2026 server conformance --- CHANGELOG.md | 3 +- doc/mcp-2026-rc.md | 5 +++ .../conformance/2026_rc_expected_failures.txt | 2 +- test/conformance/README.md | 8 ++--- test/conformance/mcp_2025_server.dart | 22 +++++++++++-- test/conformance/mcp_2026_rc_server.dart | 13 ++++++-- .../run_2026_rc_server_conformance.dart | 33 +++++++++++++++---- 7 files changed, 68 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55c9b91d..5c4550d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ - Updated official conformance gates to `@modelcontextprotocol/conformance@0.2.0-alpha.3`, with 2026 RC runs pinned - to `2026-07-28` and the current upstream draft fixture gap tracked as an + to `2026-07-28`, the full 2026 server scenario list covered in CI, and the + current upstream client fixture gap tracked as an expected failure. - Removed the legacy `DRAFT-2026-v1` draft alias now that official conformance targets the `2026-07-28` wire version. diff --git a/doc/mcp-2026-rc.md b/doc/mcp-2026-rc.md index 987c5096..37b17aaf 100644 --- a/doc/mcp-2026-rc.md +++ b/doc/mcp-2026-rc.md @@ -141,6 +141,11 @@ dart pub global run pana --no-warning dart run tool/validate_cli_publish.dart ``` +The `run_2026_rc_server_conformance.dart` gate runs the full +`@modelcontextprotocol/conformance@0.2.0-alpha.3` server scenario list for +`--spec-version 2026-07-28`, including the stable-style tool, resource, prompt, +completion, and JSON Schema scenarios that the alpha package tags for the RC. + For dev packages, keep package documentation links pointed at `dev/2026-07-28-rc` until the draft work is ready to merge back to `main`. Restore those links to `main` as part of the final spec release prep. diff --git a/test/conformance/2026_rc_expected_failures.txt b/test/conformance/2026_rc_expected_failures.txt index adca743f..26b28059 100644 --- a/test/conformance/2026_rc_expected_failures.txt +++ b/test/conformance/2026_rc_expected_failures.txt @@ -1,5 +1,5 @@ # Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.3 -# against the 2026 RC/DRAFT server suite. +# against the full 2026-07-28 RC/DRAFT server suite. # # Keep this list scenario-based so the baseline is easy to review. When a # scenario turns green, remove it from this file in the same PR as the fix. diff --git a/test/conformance/README.md b/test/conformance/README.md index 27246069..d66a2d38 100644 --- a/test/conformance/README.md +++ b/test/conformance/README.md @@ -53,10 +53,10 @@ Run the current server baseline from the repository root: dart run test/conformance/run_2026_rc_server_conformance.dart ``` -The runner starts a local `StreamableMcpServer` with JSON stateless responses -enabled, runs the draft server scenarios from -`@modelcontextprotocol/conformance@0.2.0-alpha.3` one by one with -`--spec-version 2026-07-28`, and writes per-run artifacts under +The runner starts a local `StreamableMcpServer` in default Streamable HTTP SSE +response mode, runs the full `2026-07-28` server scenario list from +`@modelcontextprotocol/conformance@0.2.0-alpha.3` one by one with `--suite all` +and `--spec-version 2026-07-28`, and writes per-run artifacts under `.dart_tool/conformance/2026_rc/`. Expected failures live in `2026_rc_expected_failures.txt`. When a scenario is diff --git a/test/conformance/mcp_2025_server.dart b/test/conformance/mcp_2025_server.dart index 21328934..abcfdf93 100644 --- a/test/conformance/mcp_2025_server.dart +++ b/test/conformance/mcp_2025_server.dart @@ -77,12 +77,28 @@ McpServer _createConformanceServer() { ), ); + registerStableConformanceFeatures(server); + + return server; +} + +/// Registers the stable conformance package's diagnostic tools, resources, and +/// prompts on [server]. +/// +/// The 2026 fixture reuses these registrations because the alpha conformance +/// package tags several stable scenarios for `2026-07-28`. Resource +/// subscription handlers remain 2025-only unless [includeResourceSubscriptions] +/// is enabled. +void registerStableConformanceFeatures( + McpServer server, { + bool includeResourceSubscriptions = true, +}) { _registerTools(server); _registerResources(server); _registerPrompts(server); - _registerResourceSubscriptions(server); - - return server; + if (includeResourceSubscriptions) { + _registerResourceSubscriptions(server); + } } void _registerTools(McpServer server) { diff --git a/test/conformance/mcp_2026_rc_server.dart b/test/conformance/mcp_2026_rc_server.dart index 737c657b..208ec020 100644 --- a/test/conformance/mcp_2026_rc_server.dart +++ b/test/conformance/mcp_2026_rc_server.dart @@ -4,12 +4,14 @@ import 'dart:io'; import 'package:mcp_dart/mcp_dart.dart'; import '../interop/test_dart_server.dart' as interop; +import 'mcp_2025_server.dart' as stable_conformance; /// Dedicated HTTP server fixture for the MCP 2026 RC conformance package. /// /// This deliberately starts from the existing cross-SDK interop server and -/// enables JSON stateless responses. Conformance-specific diagnostic tools can -/// be added here without changing the stable interop fixture. +/// uses the default Streamable HTTP SSE response mode so request-scoped +/// progress notifications remain observable. Conformance-specific diagnostic +/// tools can be added here without changing the stable interop fixture. Future main(List args) async { var host = 'localhost'; var port = 0; @@ -37,7 +39,7 @@ Future main(List args) async { serverFactory: (_) => _createConformanceServer(), host: host, port: port, - enableJsonResponse: true, + enableJsonResponse: false, ); await server.start(); @@ -60,6 +62,11 @@ McpServer _createConformanceServer() { ), ); + stable_conformance.registerStableConformanceFeatures( + server, + includeResourceSubscriptions: false, + ); + server.registerTool( 'a_header_probe', description: 'No-op tool for HTTP header conformance checks', diff --git a/test/conformance/run_2026_rc_server_conformance.dart b/test/conformance/run_2026_rc_server_conformance.dart index 2d9ef028..08ab935b 100644 --- a/test/conformance/run_2026_rc_server_conformance.dart +++ b/test/conformance/run_2026_rc_server_conformance.dart @@ -6,24 +6,45 @@ const _defaultConformancePackage = '@modelcontextprotocol/conformance@0.2.0-alpha.3'; const _defaultTimeout = Duration(seconds: 25); -const _draftServerScenarios = [ +const _serverScenarios = [ 'server-stateless', - 'caching', + 'completion-complete', + 'tools-list', + 'tools-call-simple-text', + 'tools-call-image', + 'tools-call-audio', + 'tools-call-embedded-resource', + 'tools-call-mixed-content', + 'tools-call-error', + 'tools-call-with-progress', + 'json-schema-2020-12', + 'server-sse-multiple-streams', + 'resources-list', + 'resources-read-text', + 'resources-read-binary', + 'resources-templates-read', 'sep-2164-resource-not-found', + 'prompts-list', + 'prompts-get-simple', + 'prompts-get-with-args', + 'prompts-get-embedded-resource', + 'prompts-get-with-image', + 'dns-rebinding-protection', + 'caching', 'http-header-validation', 'http-custom-header-server-validation', - 'input-required-result-missing-input-response', 'input-required-result-basic-elicitation', 'input-required-result-basic-sampling', 'input-required-result-basic-list-roots', 'input-required-result-request-state', 'input-required-result-multiple-input-requests', 'input-required-result-multi-round', + 'input-required-result-missing-input-response', 'input-required-result-non-tool-request', 'input-required-result-result-type', + 'input-required-result-unsupported-methods', 'input-required-result-tampered-state', 'input-required-result-capability-check', - 'input-required-result-unsupported-methods', 'input-required-result-ignore-extra-params', 'input-required-result-validate-input', ]; @@ -40,7 +61,7 @@ Future main(List args) async { ); final outputRoot = await _createOutputRoot(options.outputDir); final scenarios = - options.scenario == null ? _draftServerScenarios : [options.scenario!]; + options.scenario == null ? _serverScenarios : [options.scenario!]; Process? serverProcess; var serverOutputSubscriptions = >[]; @@ -228,7 +249,7 @@ Future<_ScenarioResult> _runScenario({ '--url', serverUrl.toString(), '--suite', - 'draft', + 'all', '--spec-version', '2026-07-28', '--scenario', From a96474a885dbf2bbed4b65f43a3661b46af06bfb Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Wed, 17 Jun 2026 07:54:13 -0400 Subject: [PATCH 57/68] Update conformance alpha4 baseline --- .github/workflows/test_core.yml | 2 +- CHANGELOG.md | 8 ++++---- test/conformance/2026_rc_client_expected_failures.txt | 4 ++-- test/conformance/2026_rc_expected_failures.txt | 2 +- test/conformance/README.md | 9 ++++++--- test/conformance/run_2025_server_conformance.dart | 2 +- test/conformance/run_2026_rc_client_conformance.dart | 5 +---- test/conformance/run_2026_rc_server_conformance.dart | 2 +- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/test_core.yml b/.github/workflows/test_core.yml index ca462337..24eb6487 100644 --- a/.github/workflows/test_core.yml +++ b/.github/workflows/test_core.yml @@ -51,7 +51,7 @@ jobs: - name: Run official MCP 2025 client conformance run: > - npx -y @modelcontextprotocol/conformance@0.2.0-alpha.3 client + npx -y @modelcontextprotocol/conformance@0.2.0-alpha.4 client --command "dart run test/conformance/mcp_2026_rc_client.dart" --suite all --spec-version 2025-11-25 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c4550d7..96ee735c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,10 @@ ### Conformance and release readiness - Updated official conformance gates to - `@modelcontextprotocol/conformance@0.2.0-alpha.3`, with 2026 RC runs pinned - to `2026-07-28`, the full 2026 server scenario list covered in CI, and the - current upstream client fixture gap tracked as an - expected failure. + `@modelcontextprotocol/conformance@0.2.0-alpha.4`, with 2026 RC runs pinned + to `2026-07-28`, the full 2026 server scenario list covered in CI, the 2026 + client wrapper aligned with alpha.4's spec-filtered scenario list, and the + current upstream client fixture gap tracked as an expected failure. - Removed the legacy `DRAFT-2026-v1` draft alias now that official conformance targets the `2026-07-28` wire version. diff --git a/test/conformance/2026_rc_client_expected_failures.txt b/test/conformance/2026_rc_client_expected_failures.txt index 938489d6..50099442 100644 --- a/test/conformance/2026_rc_client_expected_failures.txt +++ b/test/conformance/2026_rc_client_expected_failures.txt @@ -1,10 +1,10 @@ -# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.3 +# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.4 # against the 2026 RC/DRAFT client suite. # # Keep this list scenario-based so the baseline is easy to review. When a # scenario turns green, remove it from this file in the same PR as the fix. # -# Upstream alpha.3 fixture gap: this scenario's mock server rejects +# Upstream alpha.4 fixture gap: this scenario's mock server still rejects # 2026-07-28 with HTTP 400 and advertises only stable protocol versions. # Keep it expected-fail until the conformance fixture is draft-capable. json-schema-ref-no-deref diff --git a/test/conformance/2026_rc_expected_failures.txt b/test/conformance/2026_rc_expected_failures.txt index 26b28059..efc3ef81 100644 --- a/test/conformance/2026_rc_expected_failures.txt +++ b/test/conformance/2026_rc_expected_failures.txt @@ -1,4 +1,4 @@ -# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.3 +# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.4 # against the full 2026-07-28 RC/DRAFT server suite. # # Keep this list scenario-based so the baseline is easy to review. When a diff --git a/test/conformance/README.md b/test/conformance/README.md index d66a2d38..70460924 100644 --- a/test/conformance/README.md +++ b/test/conformance/README.md @@ -26,14 +26,14 @@ dart run test/conformance/run_2025_server_conformance.dart ``` The runner starts `mcp_2025_server.dart`, runs -`@modelcontextprotocol/conformance@0.2.0-alpha.3 server --suite all +`@modelcontextprotocol/conformance@0.2.0-alpha.4 server --suite all --spec-version 2025-11-25`, and writes artifacts under `.dart_tool/conformance/2025_server/`. Run the stable client suite from the repository root: ```bash -npx -y @modelcontextprotocol/conformance@0.2.0-alpha.3 client \ +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.4 client \ --command "dart run test/conformance/mcp_2026_rc_client.dart" \ --suite all \ --spec-version 2025-11-25 \ @@ -55,7 +55,7 @@ dart run test/conformance/run_2026_rc_server_conformance.dart The runner starts a local `StreamableMcpServer` in default Streamable HTTP SSE response mode, runs the full `2026-07-28` server scenario list from -`@modelcontextprotocol/conformance@0.2.0-alpha.3` one by one with `--suite all` +`@modelcontextprotocol/conformance@0.2.0-alpha.4` one by one with `--suite all` and `--spec-version 2026-07-28`, and writes per-run artifacts under `.dart_tool/conformance/2026_rc/`. @@ -73,3 +73,6 @@ package's scenario servers and writes per-run artifacts under `.dart_tool/conformance/2026_rc_client/`. Client expected failures live in `2026_rc_client_expected_failures.txt`. +The 2026 client wrapper is aligned with the scenarios returned by +`conformance list --client --spec-version 2026-07-28`; stable-only client +scenarios remain covered by the stable `2025-11-25` client suite above. diff --git a/test/conformance/run_2025_server_conformance.dart b/test/conformance/run_2025_server_conformance.dart index fe7e80db..cfc32e7f 100644 --- a/test/conformance/run_2025_server_conformance.dart +++ b/test/conformance/run_2025_server_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.3'; + '@modelcontextprotocol/conformance@0.2.0-alpha.4'; const _defaultTimeout = Duration(seconds: 60); Future main(List args) async { diff --git a/test/conformance/run_2026_rc_client_conformance.dart b/test/conformance/run_2026_rc_client_conformance.dart index b3a19b34..c677c16a 100644 --- a/test/conformance/run_2026_rc_client_conformance.dart +++ b/test/conformance/run_2026_rc_client_conformance.dart @@ -3,14 +3,11 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.3'; + '@modelcontextprotocol/conformance@0.2.0-alpha.4'; const _defaultTimeout = Duration(seconds: 30); const _draftClientScenarios = [ - 'initialize', 'tools_call', - 'elicitation-sep1034-client-defaults', - 'sse-retry', 'request-metadata', 'auth/metadata-default', 'auth/metadata-var1', diff --git a/test/conformance/run_2026_rc_server_conformance.dart b/test/conformance/run_2026_rc_server_conformance.dart index 08ab935b..cd8aa7f7 100644 --- a/test/conformance/run_2026_rc_server_conformance.dart +++ b/test/conformance/run_2026_rc_server_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.3'; + '@modelcontextprotocol/conformance@0.2.0-alpha.4'; const _defaultTimeout = Duration(seconds: 25); const _serverScenarios = [ From be65f06a1b228458a0b7fff9e83a22bb07e74e2e Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Fri, 19 Jun 2026 09:34:45 -0400 Subject: [PATCH 58/68] Add TS 2026 RC interop smoke Add TS 2026 RC interop smoke --- CHANGELOG.md | 5 + doc/interoperability.md | 17 ++ doc/mcp-2026-rc.md | 10 +- lib/src/server/server.dart | 1 + lib/src/shared/protocol.dart | 1 + lib/src/types/initialization.dart | 21 ++- .../lib/src/conformance_runner.dart | 25 +-- test/interop/ts_2026_rc/.gitignore | 3 + test/interop/ts_2026_rc/README.md | 35 +++++ test/interop/ts_2026_rc/package-lock.json | 148 ++++++++++++++++++ test/interop/ts_2026_rc/package.json | 17 ++ test/interop/ts_2026_rc/src/client.mjs | 69 ++++++++ test/mcp_2026_07_28_test.dart | 19 ++- test/server/streamable_mcp_server_test.dart | 41 +++++ tool/testing/run_ts_2026_rc_interop.dart | 119 ++++++++++++++ 15 files changed, 517 insertions(+), 14 deletions(-) create mode 100644 test/interop/ts_2026_rc/.gitignore create mode 100644 test/interop/ts_2026_rc/README.md create mode 100644 test/interop/ts_2026_rc/package-lock.json create mode 100644 test/interop/ts_2026_rc/package.json create mode 100644 test/interop/ts_2026_rc/src/client.mjs create mode 100644 tool/testing/run_ts_2026_rc_interop.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 96ee735c..331810c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ### Conformance and release readiness +- Added a manual TypeScript SDK 2026 RC interop fixture pinned to the upstream + PR #2327 preview package, covering modern negotiation, `tools/list`, and + `tools/call` against the Dart 2026 RC conformance server. +- Marked `server/discover` as a 2026 cacheable result so stateless responses + include default `ttlMs` and `cacheScope` hints. - Updated official conformance gates to `@modelcontextprotocol/conformance@0.2.0-alpha.4`, with 2026 RC runs pinned to `2026-07-28`, the full 2026 server scenario list covered in CI, the 2026 diff --git a/doc/interoperability.md b/doc/interoperability.md index cbbc701c..77b0384c 100644 --- a/doc/interoperability.md +++ b/doc/interoperability.md @@ -21,6 +21,7 @@ For requirement-level MCP 2025-11-25 coverage, see the | Dart client -> TypeScript SDK server | Streamable HTTP | `2025-11-25` | [`test/interop/dart_client_with_ts_server_test.dart`](../test/interop/dart_client_with_ts_server_test.dart), [`test/interop/ts/`](../test/interop/ts/) | Verified | Covers tool calls and stale preconfigured session-id recovery. | | TypeScript SDK client -> Dart server | stdio | `2025-11-25` | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/test_dart_server.dart`](../test/interop/test_dart_server.dart) | Verified | Runs the compiled TypeScript client fixture against a Dart server process and checks that an official TS client can list tools immediately after the lifecycle handshake. | | TypeScript SDK client -> Dart server | Streamable HTTP | `2025-11-25` | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/test_dart_server.dart`](../test/interop/test_dart_server.dart) | Verified | Includes official TS Streamable HTTP client lifecycle coverage, pre-`initialized` operation rejection, GET SSE streams, and `Last-Event-ID` replay behavior. | +| TypeScript SDK preview client -> Dart server | Streamable HTTP | `2026-07-28` draft/RC | [`test/interop/ts_2026_rc/`](../test/interop/ts_2026_rc/), [`tool/testing/run_ts_2026_rc_interop.dart`](../tool/testing/run_ts_2026_rc_interop.dart) | Experimental manual check | Uses a pinned `pkg.pr.new` preview from TypeScript SDK PR #2327 because published TS packages do not yet advertise `2026-07-28`. Covers modern negotiation, `tools/list`, and `tools/call` against the Dart 2026 RC conformance server. Not a CI gate yet. | | Dart client -> Python MCP server | stdio | Server-dependent | [`doc/transports.md`](transports.md#connect-to-python-server) | Documented recipe | The transport can spawn Python servers over stdio, but this repo does not yet include an automated Python SDK fixture. | | Flutter/Web client -> Dart server | Streamable HTTP | `2025-11-25` | [`example/flutter_http_client/`](../example/flutter_http_client/), [`doc/flutter-recipes.md`](flutter-recipes.md) | Documented recipe | Flutter Web cannot spawn stdio servers; use Streamable HTTP or another browser-safe transport. | | MCP Apps host/client metadata | stdio or Streamable HTTP | `2025-11-25` plus `io.modelcontextprotocol/ui` extension | [`doc/mcp-apps.md`](mcp-apps.md), [`example/mcp_apps_helpers_server.dart`](../example/mcp_apps_helpers_server.dart), [`test/types/mcp_ui_test.dart`](../test/types/mcp_ui_test.dart), [`test/server/mcp_ui_test.dart`](../test/server/mcp_ui_test.dart) | Verified | Verified coverage is limited to SDK metadata helpers, serialization, and checked-in examples; host rendering behavior varies by host, so verify UI metadata against your target host. | @@ -42,6 +43,20 @@ dart test --tags interop If the compiled fixtures are missing, local test runs skip the interop groups; CI should fail when required fixtures are unavailable. +The TypeScript 2026 RC fixture is manual while the upstream SDK support remains +unreleased and split across preview PRs: + +```bash +# From repository root +cd test/interop/ts_2026_rc +npm install +cd ../../.. +dart run tool/testing/run_ts_2026_rc_interop.dart +``` + +This starts the Dart 2026 RC conformance server and runs the pinned TypeScript +SDK preview client against it. + The CLI spec conformance gate covers raw-wire negative cases that do not need a cross-SDK fixture, including stable MCP 2025-11-25 checks and MCP 2026-07-28 RC stateless/discovery/task-extension checks: @@ -65,6 +80,8 @@ When adding a new interoperability claim: ## Known gaps worth tracking - Automated Python SDK fixture coverage. +- CI promotion for the TypeScript 2026 RC interop fixture after the TypeScript + SDK publishes a 2026-compatible alpha package. - Host-specific MCP Apps rendering compatibility notes. - More OAuth-protected remote server scenarios beyond the checked-in examples. - A broader compatibility table once additional SDKs expose stable 2025-11-25 fixtures. diff --git a/doc/mcp-2026-rc.md b/doc/mcp-2026-rc.md index 37b17aaf..69b9254a 100644 --- a/doc/mcp-2026-rc.md +++ b/doc/mcp-2026-rc.md @@ -130,7 +130,7 @@ Before creating follow-up dev tags from `dev/2026-07-28-rc`, run: ```sh dart analyze dart run test/conformance/run_2025_server_conformance.dart -npx -y @modelcontextprotocol/conformance@0.2.0-alpha.3 client \ +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.4 client \ --command "dart run test/conformance/mcp_2026_rc_client.dart" \ --suite all \ --spec-version 2025-11-25 @@ -142,10 +142,16 @@ dart run tool/validate_cli_publish.dart ``` The `run_2026_rc_server_conformance.dart` gate runs the full -`@modelcontextprotocol/conformance@0.2.0-alpha.3` server scenario list for +`@modelcontextprotocol/conformance@0.2.0-alpha.4` server scenario list for `--spec-version 2026-07-28`, including the stable-style tool, resource, prompt, completion, and JSON Schema scenarios that the alpha package tags for the RC. +For cross-SDK smoke coverage against the TypeScript SDK 2026 preview client, +run the manual fixture documented in +[`doc/interoperability.md`](interoperability.md#running-interop-checks-locally). +Keep that fixture out of CI until upstream publishes a 2026-compatible alpha +package instead of requiring a `pkg.pr.new` PR preview. + For dev packages, keep package documentation links pointed at `dev/2026-07-28-rc` until the draft work is ready to merge back to `main`. Restore those links to `main` as part of the final spec release prep. diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 1809540d..8ab76bbf 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -873,6 +873,7 @@ class Server extends Protocol { bool _requiresCacheableResult(String method) { return switch (method) { + Method.serverDiscover || Method.toolsList || Method.promptsList || Method.resourcesList || diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 0f8eb67d..8ea09720 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -16,6 +16,7 @@ bool _isProgressToken(Object? token) => (token is double && token.isFinite && token == token.truncateToDouble()); const Set _statelessCacheableResultMethods = { + Method.serverDiscover, Method.toolsList, Method.promptsList, Method.resourcesList, diff --git a/lib/src/types/initialization.dart b/lib/src/types/initialization.dart index ee6ab405..81f93c57 100644 --- a/lib/src/types/initialization.dart +++ b/lib/src/types/initialization.dart @@ -1345,7 +1345,7 @@ class InitializeResult implements BaseResultData { } /// Result data for a successful `server/discover` request. -class DiscoverResult implements BaseResultData { +class DiscoverResult implements CacheableResultData { /// Result discriminator used by the 2026 result model. final String resultType; @@ -1361,6 +1361,14 @@ class DiscoverResult implements BaseResultData { /// Instructions describing how to use the server and its features. final String? instructions; + /// How long, in milliseconds, the client may consider this result fresh. + @override + final int? ttlMs; + + /// Intended cache visibility: `public` or `private`. + @override + final String? cacheScope; + /// Optional metadata. @override final Map? meta; @@ -1371,6 +1379,8 @@ class DiscoverResult implements BaseResultData { required this.capabilities, required this.serverInfo, this.instructions, + this.ttlMs, + this.cacheScope, this.meta, }); @@ -1407,12 +1417,19 @@ class DiscoverResult implements BaseResultData { json['instructions'], 'DiscoverResult.instructions', ), + ttlMs: readOptionalTtlMs(json['ttlMs'], 'DiscoverResult.ttlMs'), + cacheScope: readOptionalCacheScope( + json['cacheScope'], + 'DiscoverResult.cacheScope', + ), meta: readOptionalJsonObject(json['_meta'], 'DiscoverResult._meta'), ); } @override Map toJson() { + validateTtlMs(ttlMs, 'DiscoverResult.ttlMs'); + validateCacheScope(cacheScope, 'DiscoverResult.cacheScope'); if (resultType != resultTypeComplete) { throw ArgumentError.value( resultType, @@ -1427,6 +1444,8 @@ class DiscoverResult implements BaseResultData { 'capabilities': capabilities.toJson(omitLegacyTasks: true), 'serverInfo': serverInfo.toJson(), if (instructions != null) 'instructions': instructions, + if (ttlMs != null) 'ttlMs': ttlMs, + if (cacheScope != null) 'cacheScope': cacheScope, if (meta != null) '_meta': readJsonObject(meta, 'DiscoverResult._meta'), }; } diff --git a/packages/mcp_dart_cli/lib/src/conformance_runner.dart b/packages/mcp_dart_cli/lib/src/conformance_runner.dart index 425b5cae..a3262ab2 100644 --- a/packages/mcp_dart_cli/lib/src/conformance_runner.dart +++ b/packages/mcp_dart_cli/lib/src/conformance_runner.dart @@ -922,6 +922,8 @@ class _DiscoveringConformanceTransport extends Transport 'name': 'conformance-server', 'version': '1.0.0', }, + 'ttlMs': 0, + 'cacheScope': _cacheScopePrivate, }, ), ); @@ -1480,18 +1482,21 @@ Future _httpModernProtocolErrorsRetryDiscovery() async { jsonEncode( JsonRpcResponse( id: id, - result: const DiscoverResult( - supportedVersions: [ + result: const { + 'resultType': _resultTypeComplete, + 'supportedVersions': [ _draftProtocolVersion2026_07_28, ], - capabilities: ServerCapabilities( - tools: ServerCapabilitiesTools(), - ), - serverInfo: Implementation( - name: 'modern-http-server', - version: '1.0.0', - ), - ).toJson(), + 'capabilities': { + 'tools': {}, + }, + 'serverInfo': { + 'name': 'modern-http-server', + 'version': '1.0.0', + }, + 'ttlMs': 0, + 'cacheScope': _cacheScopePrivate, + }, ).toJson(), ), ); diff --git a/test/interop/ts_2026_rc/.gitignore b/test/interop/ts_2026_rc/.gitignore new file mode 100644 index 00000000..5f77b197 --- /dev/null +++ b/test/interop/ts_2026_rc/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +npm-debug.log* + diff --git a/test/interop/ts_2026_rc/README.md b/test/interop/ts_2026_rc/README.md new file mode 100644 index 00000000..90259249 --- /dev/null +++ b/test/interop/ts_2026_rc/README.md @@ -0,0 +1,35 @@ +# TypeScript SDK 2026 RC Interop + +This fixture is an experimental smoke test for the unreleased MCP +`2026-07-28` draft/RC path against the official TypeScript SDK work in +progress. + +It is intentionally separate from `test/interop/ts`, which tracks the published +stable TypeScript SDK and MCP `2025-11-25` behavior. The published split +TypeScript packages still do not advertise `2026-07-28`, so this fixture pins a +`pkg.pr.new` preview package from TypeScript SDK PR #2327. That PR includes the +modern Streamable HTTP `Mcp-Name` header support needed to interoperate with the +Dart 2026 RC server. + +## Run + +From the repository root: + +```bash +cd test/interop/ts_2026_rc +npm install +cd ../../.. +dart run tool/testing/run_ts_2026_rc_interop.dart +``` + +The runner starts `test/conformance/mcp_2026_rc_server.dart`, waits for its +bound local URL, and then runs `src/client.mjs` against it. The smoke asserts: + +- TypeScript client negotiation selects the modern `2026-07-28` era. +- `tools/list` returns the Dart fixture tools. +- `tools/call` can invoke the Dart `echo` tool over modern Streamable HTTP. + +Keep this as a manual, non-blocking check until the TypeScript SDK publishes a +stable 2026-compatible alpha package or the upstream PR stack lands on the +`v2-2026-07-28` branch. + diff --git a/test/interop/ts_2026_rc/package-lock.json b/test/interop/ts_2026_rc/package-lock.json new file mode 100644 index 00000000..e5667cb7 --- /dev/null +++ b/test/interop/ts_2026_rc/package-lock.json @@ -0,0 +1,148 @@ +{ + "name": "mcp-dart-ts-2026-rc-interop", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mcp-dart-ts-2026-rc-interop", + "version": "0.0.0", + "dependencies": { + "@modelcontextprotocol/client": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@87ce6c5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0-alpha.2", + "resolved": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@87ce6c5", + "integrity": "sha512-jZ5kzgPjUtC270gykOsntgX/o5v7yGeV46cn2mMvpovxbFDrDvQ08TNRioqbsHl2jKN+HTeXatpnxvDRoQ1+Qw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/test/interop/ts_2026_rc/package.json b/test/interop/ts_2026_rc/package.json new file mode 100644 index 00000000..8d57bc32 --- /dev/null +++ b/test/interop/ts_2026_rc/package.json @@ -0,0 +1,17 @@ +{ + "name": "mcp-dart-ts-2026-rc-interop", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Manual TypeScript SDK 2026 RC interop fixture for mcp_dart.", + "scripts": { + "client": "node src/client.mjs" + }, + "dependencies": { + "@modelcontextprotocol/client": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@87ce6c5" + }, + "engines": { + "node": ">=20" + } +} + diff --git a/test/interop/ts_2026_rc/src/client.mjs b/test/interop/ts_2026_rc/src/client.mjs new file mode 100644 index 00000000..3f763b19 --- /dev/null +++ b/test/interop/ts_2026_rc/src/client.mjs @@ -0,0 +1,69 @@ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +function readArg(args, name) { + const index = args.indexOf(name); + if (index < 0 || index + 1 >= args.length) { + return undefined; + } + return args[index + 1]; +} + +async function main() { + const urlValue = readArg(process.argv.slice(2), '--url'); + if (!urlValue) { + throw new Error('--url is required'); + } + + const client = new Client( + { name: 'mcp-dart-ts-2026-rc-client', version: '0.0.0' }, + { + capabilities: {}, + versionNegotiation: { mode: { pin: '2026-07-28' } }, + }, + ); + const transport = new StreamableHTTPClientTransport(new URL(urlValue)); + + try { + await client.connect(transport); + + const era = client.getProtocolEra(); + const version = client.getNegotiatedProtocolVersion(); + if (era !== 'modern' || version !== '2026-07-28') { + throw new Error(`Expected modern 2026-07-28, got ${era}/${version}`); + } + + const tools = await client.listTools(); + const toolNames = tools.tools.map((tool) => tool.name); + if (!toolNames.includes('echo')) { + throw new Error(`Expected echo tool, got ${toolNames.join(', ')}`); + } + + const message = 'from TypeScript 2026 RC preview'; + const result = await client.callTool({ + name: 'echo', + arguments: { message }, + }); + const content = Array.isArray(result.content) ? result.content : []; + const first = content[0]; + if (!first || first.type !== 'text' || first.text !== message) { + throw new Error(`Unexpected echo result: ${JSON.stringify(result)}`); + } + + console.log( + JSON.stringify({ + protocolEra: era, + protocolVersion: version, + toolCount: toolNames.length, + echo: first.text, + }), + ); + } finally { + await client.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); + diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index c8fa041e..3b52d017 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -143,6 +143,8 @@ class DiscoveringClientTransport extends Transport supportedVersions: discoverVersions, capabilities: capabilities, serverInfo: const Implementation(name: 'server', version: '1.0.0'), + ttlMs: 0, + cacheScope: CacheScope.private, ).toJson(), ), ); @@ -1029,15 +1031,22 @@ void main() { capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), serverInfo: Implementation(name: 'server', version: '1.0.0'), instructions: 'Use the tools.', + ttlMs: 1000, + cacheScope: CacheScope.public, ); final resultJson = result.toJson(); expect(resultJson['resultType'], 'complete'); expect(resultJson['supportedVersions'], [draftProtocolVersion2026_07_28]); expect(resultJson['capabilities'], {'tools': {}}); + expect(resultJson['ttlMs'], 1000); + expect(resultJson['cacheScope'], CacheScope.public); + final parsedResult = DiscoverResult.fromJson(resultJson); expect( - DiscoverResult.fromJson(resultJson).instructions, + parsedResult.instructions, 'Use the tools.', ); + expect(parsedResult.ttlMs, 1000); + expect(parsedResult.cacheScope, CacheScope.public); }); test('stateless metadata omits legacy task capabilities', () { @@ -1135,6 +1144,14 @@ void main() { ...result, 'instructions': 1, }), + () => DiscoverResult.fromJson({ + ...result, + 'ttlMs': -1, + }), + () => DiscoverResult.fromJson({ + ...result, + 'cacheScope': 'global', + }), () => ClientCapabilitiesSampling.fromJson({ 'tools': {'bad': Object()}, }), diff --git a/test/server/streamable_mcp_server_test.dart b/test/server/streamable_mcp_server_test.dart index 0fab3953..e8acb735 100644 --- a/test/server/streamable_mcp_server_test.dart +++ b/test/server/streamable_mcp_server_test.dart @@ -558,6 +558,47 @@ void main() { ); }); + test('adds cache hints to stateless server discover responses', () async { + await server.stop(); + server = StreamableMcpServer( + serverFactory: (sessionId) { + return McpServer( + const Implementation(name: 'DiscoverServer', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), + ); + }, + host: host, + port: port, + enableJsonResponse: true, + ); + await server.start(); + + final response = await http.post( + Uri.parse(baseUrl), + body: jsonEncode({ + 'jsonrpc': jsonRpcVersion, + 'id': 'discover-cache', + 'method': Method.serverDiscover, + 'params': {'_meta': statelessMeta()}, + }), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'MCP-Protocol-Version': draftProtocolVersion2026_07_28, + 'Mcp-Method': Method.serverDiscover, + }, + ); + + expect(response.statusCode, HttpStatus.ok); + final message = jsonDecode(response.body) as Map; + final result = message['result'] as Map; + expect(result['resultType'], resultTypeComplete); + expect(result['ttlMs'], 0); + expect(result['cacheScope'], CacheScope.private); + }); + test('rejects removed stateless request methods before legacy parsing', () async { await server.stop(); diff --git a/tool/testing/run_ts_2026_rc_interop.dart b/tool/testing/run_ts_2026_rc_interop.dart new file mode 100644 index 00000000..a2f83849 --- /dev/null +++ b/tool/testing/run_ts_2026_rc_interop.dart @@ -0,0 +1,119 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +Future main(List args) async { + final repoRoot = Directory.current; + final fixtureDir = Directory('test/interop/ts_2026_rc'); + final clientPackage = File( + 'test/interop/ts_2026_rc/node_modules/' + '@modelcontextprotocol/client/package.json', + ); + + if (!File('pubspec.yaml').existsSync() || !fixtureDir.existsSync()) { + stderr.writeln( + 'Run this command from the mcp_dart repository root.', + ); + exitCode = 64; + return; + } + + if (!clientPackage.existsSync()) { + stderr.writeln( + 'Missing TypeScript fixture dependencies. Run:\n' + ' cd test/interop/ts_2026_rc\n' + ' npm install', + ); + exitCode = 64; + return; + } + + final server = await Process.start( + Platform.resolvedExecutable, + [ + 'run', + 'test/conformance/mcp_2026_rc_server.dart', + '--host', + '127.0.0.1', + '--port', + '0', + ], + workingDirectory: repoRoot.path, + ); + + final serverUrl = Completer(); + final urlPattern = RegExp(r'(http://[^\s]+)'); + + final serverStdout = _pipeLines( + server.stdout, + stdout, + '[dart-server]', + onLine: (line) { + if (serverUrl.isCompleted || + !line.contains('MCP 2026 RC conformance server listening on')) { + return; + } + final match = urlPattern.firstMatch(line); + if (match != null) { + serverUrl.complete(match.group(1)!); + } + }, + ); + final serverStderr = _pipeLines(server.stderr, stderr, '[dart-server]'); + + try { + final url = await serverUrl.future.timeout( + const Duration(seconds: 20), + onTimeout: () { + throw TimeoutException('Timed out waiting for Dart server URL'); + }, + ); + + final client = await Process.start( + 'node', + ['src/client.mjs', '--url', url], + workingDirectory: fixtureDir.path, + ); + final clientStdout = _pipeLines(client.stdout, stdout, '[ts-client]'); + final clientStderr = _pipeLines(client.stderr, stderr, '[ts-client]'); + final clientExit = await client.exitCode.timeout( + const Duration(seconds: 30), + ); + await Future.wait([clientStdout, clientStderr]); + + if (clientExit != 0) { + exitCode = clientExit; + return; + } + } on Object catch (error) { + stderr.writeln('TS 2026 RC interop failed: $error'); + exitCode = 1; + } finally { + await _terminate(server); + await Future.wait([serverStdout, serverStderr]); + } +} + +Future _pipeLines( + Stream> stream, + IOSink sink, + String prefix, { + void Function(String line)? onLine, +}) async { + await for (final line + in stream.transform(utf8.decoder).transform(const LineSplitter())) { + onLine?.call(line); + sink.writeln('$prefix $line'); + } +} + +Future _terminate(Process process) async { + final exitFuture = process.exitCode; + process.kill(ProcessSignal.sigterm); + try { + await exitFuture.timeout(const Duration(seconds: 5)); + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + await exitFuture; + } +} From ef97422c4a5f6cc4d861b5ce3f53cbf50c493f5d Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Fri, 19 Jun 2026 13:53:51 -0400 Subject: [PATCH 59/68] Expand TS 2026 RC interop critical paths (#287) * Expand TS 2026 RC interop coverage * Expand 2026 RC interop critical paths * Tighten 2026 RC progress interop assertion --- CHANGELOG.md | 19 +- doc/interoperability.md | 11 +- lib/src/types/json_rpc.dart | 9 +- .../lib/src/conformance_runner.dart | 5 +- .../conformance/2026_rc_expected_failures.txt | 5 + test/conformance/README.md | 6 + test/conformance/mcp_2026_rc_server.dart | 97 ++++ test/interop/ts_2026_rc/README.md | 51 +- test/interop/ts_2026_rc/package-lock.json | 30 +- test/interop/ts_2026_rc/package.json | 5 +- test/interop/ts_2026_rc/src/client.mjs | 532 +++++++++++++++++- test/interop/ts_2026_rc/src/server.mjs | 187 ++++++ test/types_edge_cases_test.dart | 9 + tool/testing/run_ts_2026_rc_interop.dart | 182 +++++- 14 files changed, 1089 insertions(+), 59 deletions(-) create mode 100644 test/interop/ts_2026_rc/src/server.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 331810c1..ac08921e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,22 @@ ### Conformance and release readiness -- Added a manual TypeScript SDK 2026 RC interop fixture pinned to the upstream - PR #2327 preview package, covering modern negotiation, `tools/list`, and - `tools/call` against the Dart 2026 RC conformance server. +- Expanded the manual TypeScript SDK 2026 RC interop fixture pinned to the + upstream PR #2327 preview package, covering modern negotiation, + `server/discover` cache metadata, `tools/list`, `tools/call`, + `x-mcp-header` mirroring, progress notifications, raw HTTP header, + unsupported-version, and removed core RPC rejection, `subscriptions/listen`, + and Streamable HTTP SSE cancellation against the Dart 2026 RC conformance + server. +- Added a diagnostic Dart preview client -> TypeScript server alpha path and + documented the current TS alpha gaps around mandatory `server/discover` and + stateless `resultType` responses. +- Aligned 2026 draft protocol-defined error codes with the live draft: + `HeaderMismatch` is now `-32020`, + `MissingRequiredClientCapability` is now `-32021`, and + `UnsupportedProtocolVersion` is now `-32022`. The conformance alpha.4 server + scenarios that still expect the old `HeaderMismatch` code are tracked as + expected failures. - Marked `server/discover` as a 2026 cacheable result so stateless responses include default `ttlMs` and `cacheScope` hints. - Updated official conformance gates to diff --git a/doc/interoperability.md b/doc/interoperability.md index 77b0384c..efcff08d 100644 --- a/doc/interoperability.md +++ b/doc/interoperability.md @@ -21,7 +21,8 @@ For requirement-level MCP 2025-11-25 coverage, see the | Dart client -> TypeScript SDK server | Streamable HTTP | `2025-11-25` | [`test/interop/dart_client_with_ts_server_test.dart`](../test/interop/dart_client_with_ts_server_test.dart), [`test/interop/ts/`](../test/interop/ts/) | Verified | Covers tool calls and stale preconfigured session-id recovery. | | TypeScript SDK client -> Dart server | stdio | `2025-11-25` | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/test_dart_server.dart`](../test/interop/test_dart_server.dart) | Verified | Runs the compiled TypeScript client fixture against a Dart server process and checks that an official TS client can list tools immediately after the lifecycle handshake. | | TypeScript SDK client -> Dart server | Streamable HTTP | `2025-11-25` | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/test_dart_server.dart`](../test/interop/test_dart_server.dart) | Verified | Includes official TS Streamable HTTP client lifecycle coverage, pre-`initialized` operation rejection, GET SSE streams, and `Last-Event-ID` replay behavior. | -| TypeScript SDK preview client -> Dart server | Streamable HTTP | `2026-07-28` draft/RC | [`test/interop/ts_2026_rc/`](../test/interop/ts_2026_rc/), [`tool/testing/run_ts_2026_rc_interop.dart`](../tool/testing/run_ts_2026_rc_interop.dart) | Experimental manual check | Uses a pinned `pkg.pr.new` preview from TypeScript SDK PR #2327 because published TS packages do not yet advertise `2026-07-28`. Covers modern negotiation, `tools/list`, and `tools/call` against the Dart 2026 RC conformance server. Not a CI gate yet. | +| TypeScript SDK preview client -> Dart server | Streamable HTTP | `2026-07-28` draft/RC | [`test/interop/ts_2026_rc/`](../test/interop/ts_2026_rc/), [`tool/testing/run_ts_2026_rc_interop.dart`](../tool/testing/run_ts_2026_rc_interop.dart) | Experimental manual check | Uses a pinned `pkg.pr.new` preview from TypeScript SDK PR #2327. Covers modern negotiation, cache metadata, `tools/list`, `tools/call`, `x-mcp-header` mirroring, raw header and unsupported-version rejection, removed core RPC rejection, progress notifications, `subscriptions/listen`, and HTTP SSE cancellation against the Dart 2026 RC conformance server. Not a CI gate yet. | +| Dart preview client -> TypeScript SDK alpha server | Streamable HTTP | `2026-07-28` draft/RC | [`test/interop/ts_2026_rc/src/server.mjs`](../test/interop/ts_2026_rc/src/server.mjs), [`tool/testing/run_ts_2026_rc_interop.dart`](../tool/testing/run_ts_2026_rc_interop.dart) | Diagnostic only | The fixture attempts the reverse path, but the current TS server alpha does not yet answer mandatory `server/discover` and omits `resultType` on stateless `tools/list`; the runner reports this as a TS-alpha gap instead of a Dart failure. | | Dart client -> Python MCP server | stdio | Server-dependent | [`doc/transports.md`](transports.md#connect-to-python-server) | Documented recipe | The transport can spawn Python servers over stdio, but this repo does not yet include an automated Python SDK fixture. | | Flutter/Web client -> Dart server | Streamable HTTP | `2025-11-25` | [`example/flutter_http_client/`](../example/flutter_http_client/), [`doc/flutter-recipes.md`](flutter-recipes.md) | Documented recipe | Flutter Web cannot spawn stdio servers; use Streamable HTTP or another browser-safe transport. | | MCP Apps host/client metadata | stdio or Streamable HTTP | `2025-11-25` plus `io.modelcontextprotocol/ui` extension | [`doc/mcp-apps.md`](mcp-apps.md), [`example/mcp_apps_helpers_server.dart`](../example/mcp_apps_helpers_server.dart), [`test/types/mcp_ui_test.dart`](../test/types/mcp_ui_test.dart), [`test/server/mcp_ui_test.dart`](../test/server/mcp_ui_test.dart) | Verified | Verified coverage is limited to SDK metadata helpers, serialization, and checked-in examples; host rendering behavior varies by host, so verify UI metadata against your target host. | @@ -54,8 +55,9 @@ cd ../../.. dart run tool/testing/run_ts_2026_rc_interop.dart ``` -This starts the Dart 2026 RC conformance server and runs the pinned TypeScript -SDK preview client against it. +This starts the Dart 2026 RC conformance server, runs the pinned TypeScript SDK +preview client against it, then attempts the reverse Dart-client diagnostic +against the TypeScript server alpha and reports known TS-alpha spec gaps. The CLI spec conformance gate covers raw-wire negative cases that do not need a cross-SDK fixture, including stable MCP 2025-11-25 checks and MCP 2026-07-28 RC @@ -81,7 +83,8 @@ When adding a new interoperability claim: - Automated Python SDK fixture coverage. - CI promotion for the TypeScript 2026 RC interop fixture after the TypeScript - SDK publishes a 2026-compatible alpha package. + SDK publishes a 2026-compatible alpha package whose server answers + `server/discover` and includes `resultType` on stateless results. - Host-specific MCP Apps rendering compatibility notes. - More OAuth-protected remote server scenarios beyond the checked-in examples. - A broader compatibility table once additional SDKs expose stable 2025-11-25 fixtures. diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index 5ef5a289..6bb7e5da 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -695,19 +695,16 @@ enum ErrorCode { 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), + headerMismatch(-32020), /// Resource not found in stable MCP 2025-11-25. resourceNotFound(-32002), /// Required per-request client capabilities were not declared. - missingRequiredClientCapability(-32003), + missingRequiredClientCapability(-32021), /// The requested protocol version is unsupported by the receiver. - unsupportedProtocolVersion(-32004), + unsupportedProtocolVersion(-32022), /// URL mode elicitation is required before the request can be processed. /// The error data contains elicitations that must be completed. diff --git a/packages/mcp_dart_cli/lib/src/conformance_runner.dart b/packages/mcp_dart_cli/lib/src/conformance_runner.dart index a3262ab2..4ea3cc0c 100644 --- a/packages/mcp_dart_cli/lib/src/conformance_runner.dart +++ b/packages/mcp_dart_cli/lib/src/conformance_runner.dart @@ -24,8 +24,9 @@ const String _methodTasksGet = 'tasks/get'; const String _methodTasksUpdate = 'tasks/update'; const String _methodSubscriptionsListen = 'subscriptions/listen'; const String _methodNotificationsTasksStatus = 'notifications/tasks/status'; -const int _headerMismatchCode = -32001; -const int _unsupportedProtocolVersionCode = -32004; +final int _headerMismatchCode = ErrorCode.headerMismatch.value; +final int _unsupportedProtocolVersionCode = + ErrorCode.unsupportedProtocolVersion.value; const List conformanceSuiteNames = [ _fixtureSuite, diff --git a/test/conformance/2026_rc_expected_failures.txt b/test/conformance/2026_rc_expected_failures.txt index efc3ef81..6483c191 100644 --- a/test/conformance/2026_rc_expected_failures.txt +++ b/test/conformance/2026_rc_expected_failures.txt @@ -3,3 +3,8 @@ # # Keep this list scenario-based so the baseline is easy to review. When a # scenario turns green, remove it from this file in the same PR as the fix. + +# alpha.4 still expects the pre-renumber HeaderMismatch error code -32001. +# The live 2026-07-28 draft now assigns HeaderMismatch to -32020. +server-stateless +http-custom-header-server-validation diff --git a/test/conformance/README.md b/test/conformance/README.md index 70460924..0a9146c4 100644 --- a/test/conformance/README.md +++ b/test/conformance/README.md @@ -62,6 +62,12 @@ and `--spec-version 2026-07-28`, and writes per-run artifacts under Expected failures live in `2026_rc_expected_failures.txt`. When a scenario is fixed, remove it from that file so the baseline remains useful. +As of `@modelcontextprotocol/conformance@0.2.0-alpha.4`, the server expected +failure file includes scenarios where the conformance package still expects the +pre-renumber `HeaderMismatch` code `-32001`. The live `2026-07-28` draft assigns +`HeaderMismatch` to `-32020`, so the SDK follows the draft and keeps those +alpha.4 scenarios expected until the conformance package catches up. + Run the current client baseline from the repository root: ```bash diff --git a/test/conformance/mcp_2026_rc_server.dart b/test/conformance/mcp_2026_rc_server.dart index 208ec020..91b2c93d 100644 --- a/test/conformance/mcp_2026_rc_server.dart +++ b/test/conformance/mcp_2026_rc_server.dart @@ -6,6 +6,8 @@ import 'package:mcp_dart/mcp_dart.dart'; import '../interop/test_dart_server.dart' as interop; import 'mcp_2025_server.dart' as stable_conformance; +int _streamCancellationCount = 0; + /// Dedicated HTTP server fixture for the MCP 2026 RC conformance package. /// /// This deliberately starts from the existing cross-SDK interop server and @@ -66,6 +68,11 @@ McpServer _createConformanceServer() { server, includeResourceSubscriptions: false, ); + server.server.registerCapabilities( + const ServerCapabilities( + tools: ServerCapabilitiesTools(listChanged: true), + ), + ); server.registerTool( 'a_header_probe', @@ -73,11 +80,101 @@ McpServer _createConformanceServer() { callback: (args, extra) async => const CallToolResult(content: []), ); + server.registerTool( + 'test_custom_headers_valid', + description: 'Exercises valid 2026 x-mcp-header parameter mirroring', + inputSchema: JsonSchema.object( + properties: { + 'region': JsonSchema.string(mcpHeader: 'Region'), + 'count': JsonSchema.integer(mcpHeader: 'Count'), + 'dryRun': JsonSchema.boolean(mcpHeader: 'Dry-Run'), + 'auth': JsonSchema.object( + properties: { + 'tenant': JsonSchema.string(mcpHeader: 'Tenant'), + }, + required: ['tenant'], + ), + }, + required: ['region', 'count', 'dryRun', 'auth'], + ), + callback: (args, extra) async { + return const CallToolResult( + content: [TextContent(text: 'custom-header-ok')], + ); + }, + ); + + _registerStreamDiagnostics(server); _registerInputRequiredDiagnostics(server); return server; } +void _registerStreamDiagnostics(McpServer server) { + server.registerTool( + 'test_stream_cancellation', + description: 'Keeps an SSE response open until the HTTP client aborts it', + callback: (args, extra) async { + final observed = Completer(); + final abortSub = extra.signal.onAbort.listen((_) { + _streamCancellationCount++; + if (!observed.isCompleted) { + observed.complete(); + } + }); + + try { + await extra.sendProgress( + 1, + total: 1, + message: 'cancellation probe started', + ); + await observed.future.timeout( + const Duration(seconds: 10), + onTimeout: () { + if (!observed.isCompleted) { + observed.complete(); + } + }, + ); + } finally { + await abortSub.cancel(); + } + + return _textResult( + extra.signal.aborted ? 'cancelled' : 'not-cancelled', + ); + }, + ); + + server.registerTool( + 'test_stream_cancellation_status', + description: 'Reports observed HTTP stream cancellation count', + callback: (args, extra) async { + return _textResult(_streamCancellationCount.toString()); + }, + ); + + server.server.setRequestHandler( + Method.subscriptionsListen, + (request, extra) async { + final acknowledged = request.listenParams.notifications.acknowledgedBy( + server.server.getCapabilities(), + ); + await extra.sendSubscriptionAcknowledged(acknowledged); + await extra.sendSubscriptionNotification( + const JsonRpcToolListChangedNotification(), + ); + return const EmptyResult(); + }, + (id, params, meta) => JsonRpcSubscriptionsListenRequest( + id: id, + listenParams: SubscriptionsListenRequest.fromJson(params!), + meta: meta, + ), + ); +} + void _registerInputRequiredDiagnostics(McpServer server) { server.registerTool( 'test_input_required_result_elicitation', diff --git a/test/interop/ts_2026_rc/README.md b/test/interop/ts_2026_rc/README.md index 90259249..d95d71c9 100644 --- a/test/interop/ts_2026_rc/README.md +++ b/test/interop/ts_2026_rc/README.md @@ -5,11 +5,12 @@ This fixture is an experimental smoke test for the unreleased MCP progress. It is intentionally separate from `test/interop/ts`, which tracks the published -stable TypeScript SDK and MCP `2025-11-25` behavior. The published split -TypeScript packages still do not advertise `2026-07-28`, so this fixture pins a -`pkg.pr.new` preview package from TypeScript SDK PR #2327. That PR includes the -modern Streamable HTTP `Mcp-Name` header support needed to interoperate with the -Dart 2026 RC server. +stable TypeScript SDK and MCP `2025-11-25` behavior. The fixture pins a +`pkg.pr.new` client preview from TypeScript SDK PR #2327 for the modern +Streamable HTTP `Mcp-Name` header support needed to interoperate with the Dart +2026 RC server. It also installs `@modelcontextprotocol/server@2.0.0-alpha.2` +for a reverse-path diagnostic, but the server alpha is not yet a strict 2026 +interoperability gate. ## Run @@ -23,13 +24,47 @@ dart run tool/testing/run_ts_2026_rc_interop.dart ``` The runner starts `test/conformance/mcp_2026_rc_server.dart`, waits for its -bound local URL, and then runs `src/client.mjs` against it. The smoke asserts: +bound local URL, and then runs `src/client.mjs` against it. The fixture asserts: - TypeScript client negotiation selects the modern `2026-07-28` era. -- `tools/list` returns the Dart fixture tools. +- `server/discover` advertises `2026-07-28` and exposes cache metadata through + the TypeScript client API. +- `tools/list` returns the Dart fixture tools with 2026 cache metadata. +- Valid `x-mcp-header` annotations survive `tools/list` and the TypeScript + client mirrors string, integer, boolean, and nested string arguments into + `Mcp-Param-*` headers. The Dart server validates those headers against the + body before invoking the tool. - `tools/call` can invoke the Dart `echo` tool over modern Streamable HTTP. +- `tools/call` can complete a 2026 `input_required` elicitation retry flow + using the TypeScript client's registered `elicitation/create` handler. +- `notifications/progress` callbacks arrive for a long-running tool call and + report monotonic progress from `0` to `100`. +- Raw Streamable HTTP requests with missing or mismatched + `MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name`, and `Mcp-Param-*` headers + are rejected with the current draft `HeaderMismatch` error code `-32020`. +- Raw Streamable HTTP requests for removed 2026 core RPCs such as `ping` are + rejected with JSON-RPC `Method not found`. +- Raw Streamable HTTP requests for unsupported protocol versions are rejected + with the current draft `UnsupportedProtocolVersion` error code `-32022` and + include `requested` and `supported` version data. +- `subscriptions/listen` returns an acknowledgment before list-change + notifications and tags subscription notifications with + `io.modelcontextprotocol/subscriptionId`. +- Closing a 2026 HTTP SSE response stream cancels the in-flight Dart server + request without sending `notifications/cancelled`. + +The runner also starts `src/server.mjs` and attempts a Dart preview client +against the TypeScript server alpha. That reverse path is currently diagnostic: +the fixture shims `server/discover` because the TS server alpha does not answer +that mandatory 2026 method yet, and the Dart client then reports the current TS +alpha `tools/list` gap where stateless results omit `resultType`. + +Keep this fixture anchored to the official draft/RC behavior rather than the +preview TypeScript implementation alone. In particular, `x-mcp-header` tests use +only the draft-permitted primitive types: `string`, `integer`, and `boolean`. +When TypeScript preview behavior conflicts with the draft, keep the draft as the +assertion source and document the preview gap near the test. Keep this as a manual, non-blocking check until the TypeScript SDK publishes a stable 2026-compatible alpha package or the upstream PR stack lands on the `v2-2026-07-28` branch. - diff --git a/test/interop/ts_2026_rc/package-lock.json b/test/interop/ts_2026_rc/package-lock.json index e5667cb7..95f2d135 100644 --- a/test/interop/ts_2026_rc/package-lock.json +++ b/test/interop/ts_2026_rc/package-lock.json @@ -8,12 +8,20 @@ "name": "mcp-dart-ts-2026-rc-interop", "version": "0.0.0", "dependencies": { - "@modelcontextprotocol/client": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@87ce6c5" + "@cfworker/json-schema": "4.1.1", + "@modelcontextprotocol/client": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@87ce6c5", + "@modelcontextprotocol/server": "2.0.0-alpha.2" }, "engines": { "node": ">=20" } }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/client": { "version": "2.0.0-alpha.2", "resolved": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@87ce6c5", @@ -31,6 +39,26 @@ "node": ">=20" } }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0-alpha.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-alpha.2.tgz", + "integrity": "sha512-gmLgdHzlYM8L7Aw/+VE0kxjT25WKamtUSLNhdOgrJq5CrESvqVSoAfWSJJeNPUXNTluQ+dYDGFbKVitdsJtbPA==", + "license": "MIT", + "dependencies": { + "zod": "^4.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + } + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/test/interop/ts_2026_rc/package.json b/test/interop/ts_2026_rc/package.json index 8d57bc32..8b5c811a 100644 --- a/test/interop/ts_2026_rc/package.json +++ b/test/interop/ts_2026_rc/package.json @@ -8,10 +8,11 @@ "client": "node src/client.mjs" }, "dependencies": { - "@modelcontextprotocol/client": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@87ce6c5" + "@cfworker/json-schema": "4.1.1", + "@modelcontextprotocol/client": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@87ce6c5", + "@modelcontextprotocol/server": "2.0.0-alpha.2" }, "engines": { "node": ">=20" } } - diff --git a/test/interop/ts_2026_rc/src/client.mjs b/test/interop/ts_2026_rc/src/client.mjs index 3f763b19..d4f1f9aa 100644 --- a/test/interop/ts_2026_rc/src/client.mjs +++ b/test/interop/ts_2026_rc/src/client.mjs @@ -1,5 +1,8 @@ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +const PROTOCOL_VERSION = '2026-07-28'; +const CLIENT_INFO = { name: 'mcp-dart-ts-2026-rc-client', version: '0.0.0' }; + function readArg(args, name) { const index = args.indexOf(name); if (index < 0 || index + 1 >= args.length) { @@ -8,6 +11,446 @@ function readArg(args, name) { return args[index + 1]; } +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function assertCacheMetadata(result, method) { + assert( + Number.isInteger(result.ttlMs) && result.ttlMs >= 0, + `${method} expected non-negative integer ttlMs, got ${JSON.stringify(result)}`, + ); + assert( + result.cacheScope === 'public' || result.cacheScope === 'private', + `${method} expected cacheScope public/private, got ${JSON.stringify(result)}`, + ); +} + +function requireTool(tools, name) { + const tool = tools.find((candidate) => candidate.name === name); + assert( + tool, + `Expected ${name} tool, got ${tools.map((item) => item.name).join(', ')}`, + ); + return tool; +} + +function requireText(result, expected, label) { + const content = Array.isArray(result.content) ? result.content : []; + const first = content[0]; + assert( + first && first.type === 'text' && first.text === expected, + `${label} unexpected result: ${JSON.stringify(result)}`, + ); + return first.text; +} + +function firstText(result, label) { + const content = Array.isArray(result.content) ? result.content : []; + const first = content[0]; + assert( + first && first.type === 'text', + `${label} expected text content: ${JSON.stringify(result)}`, + ); + return first.text; +} + +function requestMeta(extra = {}) { + return { + 'io.modelcontextprotocol/protocolVersion': PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': CLIENT_INFO, + 'io.modelcontextprotocol/clientCapabilities': { elicitation: {} }, + ...extra, + }; +} + +async function rawRpc(urlValue, { + id, + method, + params = {}, + headers = {}, + removeHeaders = [], + signal, +}) { + const requestHeaders = new Headers({ + Accept: 'application/json, text/event-stream', + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': PROTOCOL_VERSION, + 'Mcp-Method': method, + ...headers, + }); + for (const header of removeHeaders) { + requestHeaders.delete(header); + } + + return fetch(urlValue, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify({ + jsonrpc: '2.0', + id, + method, + params, + }), + signal, + }); +} + +async function readJsonResponse(response, label) { + const text = await response.text(); + try { + return JSON.parse(text); + } catch (error) { + throw new Error(`${label} returned non-JSON body: ${text}`); + } +} + +async function expectHeaderMismatch(response, label) { + assert( + response.status === 400, + `${label} expected HTTP 400, got ${response.status}`, + ); + const body = await readJsonResponse(response, label); + assert( + body.error?.code === -32020, + `${label} expected HeaderMismatch -32020, got ${JSON.stringify(body)}`, + ); +} + +async function expectUnsupportedProtocolVersion(response, label) { + assert( + response.status === 400, + `${label} expected HTTP 400, got ${response.status}`, + ); + const body = await readJsonResponse(response, label); + assert( + body.error?.code === -32022, + `${label} expected UnsupportedProtocolVersion -32022, got ${JSON.stringify(body)}`, + ); + assert( + body.error?.data?.requested === '1900-01-01', + `${label} missing requested version in error data: ${JSON.stringify(body)}`, + ); + assert( + body.error?.data?.supported?.includes(PROTOCOL_VERSION), + `${label} missing supported ${PROTOCOL_VERSION} in error data: ${JSON.stringify(body)}`, + ); +} + +async function expectMethodNotFound(response, label) { + assert( + response.status === 404, + `${label} expected HTTP 404, got ${response.status}`, + ); + const body = await readJsonResponse(response, label); + assert( + body.error?.code === -32601, + `${label} expected MethodNotFound -32601, got ${JSON.stringify(body)}`, + ); +} + +async function assertRawHeaderValidation(urlValue) { + await expectHeaderMismatch( + await rawRpc(urlValue, { + id: 'missing-protocol-version-header', + method: 'server/discover', + params: { _meta: requestMeta() }, + removeHeaders: ['MCP-Protocol-Version'], + }), + 'missing MCP-Protocol-Version', + ); + + await expectHeaderMismatch( + await rawRpc(urlValue, { + id: 'missing-method-header', + method: 'server/discover', + params: { _meta: requestMeta() }, + removeHeaders: ['Mcp-Method'], + }), + 'missing Mcp-Method', + ); + + await expectHeaderMismatch( + await rawRpc(urlValue, { + id: 'protocol-header-mismatch', + method: 'server/discover', + params: { + _meta: requestMeta({ + 'io.modelcontextprotocol/protocolVersion': '2025-11-25', + }), + }, + }), + 'MCP-Protocol-Version mismatch', + ); + + await expectUnsupportedProtocolVersion( + await rawRpc(urlValue, { + id: 'unsupported-protocol-version', + method: 'server/discover', + params: { + _meta: requestMeta({ + 'io.modelcontextprotocol/protocolVersion': '1900-01-01', + }), + }, + headers: { 'MCP-Protocol-Version': '1900-01-01' }, + }), + 'unsupported MCP-Protocol-Version', + ); + + await expectHeaderMismatch( + await rawRpc(urlValue, { + id: 'name-header-mismatch', + method: 'tools/call', + params: { + name: 'a_header_probe', + arguments: {}, + _meta: requestMeta(), + }, + headers: { 'Mcp-Name': 'echo' }, + }), + 'Mcp-Name mismatch', + ); + + await expectHeaderMismatch( + await rawRpc(urlValue, { + id: 'param-header-mismatch', + method: 'tools/call', + params: { + name: 'test_custom_headers_valid', + arguments: { + region: 'us-east1', + count: 42, + dryRun: false, + auth: { tenant: 'tenant-a' }, + }, + _meta: requestMeta(), + }, + headers: { + 'Mcp-Name': 'test_custom_headers_valid', + 'Mcp-Param-Region': 'us-east1', + 'Mcp-Param-Count': '43', + 'Mcp-Param-Dry-Run': 'false', + 'Mcp-Param-Tenant': 'tenant-a', + }, + }), + 'Mcp-Param header mismatch', + ); +} + +async function assertRemovedCoreRequests(urlValue) { + await expectMethodNotFound( + await rawRpc(urlValue, { + id: 'removed-ping', + method: 'ping', + params: { _meta: requestMeta() }, + }), + 'removed ping', + ); +} + +function parseSseFrames(buffer, messages) { + let remaining = buffer; + for (;;) { + const separator = remaining.indexOf('\n\n'); + if (separator < 0) { + return remaining; + } + + const frame = remaining.slice(0, separator); + remaining = remaining.slice(separator + 2); + const data = frame + .split('\n') + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trimStart()) + .join('\n'); + if (data.length > 0) { + messages.push(JSON.parse(data)); + } + } +} + +async function collectSseMessages(response, expectedCount, label) { + assert( + response.headers.get('content-type')?.includes('text/event-stream'), + `${label} expected SSE response, got ${response.headers.get('content-type')}`, + ); + assert(response.body, `${label} response did not expose a body stream`); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const messages = []; + let buffer = ''; + const deadline = Date.now() + 5000; + + try { + while (messages.length < expectedCount) { + const remainingMs = Math.max(1, deadline - Date.now()); + const read = await Promise.race([ + reader.read(), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`${label} timed out waiting for SSE`)), + remainingMs, + ), + ), + ]); + if (read.done) { + break; + } + buffer = parseSseFrames( + buffer + decoder.decode(read.value, { stream: true }), + messages, + ); + } + } finally { + await reader.cancel().catch(() => {}); + } + + assert( + messages.length >= expectedCount, + `${label} expected ${expectedCount} SSE messages, got ${JSON.stringify(messages)}`, + ); + return messages; +} + +async function assertSubscriptionListen(urlValue) { + const response = await rawRpc(urlValue, { + id: 'listen-tools', + method: 'subscriptions/listen', + params: { + notifications: { toolsListChanged: true }, + _meta: requestMeta(), + }, + }); + assert( + response.status === 200, + `subscriptions/listen expected HTTP 200, got ${response.status}`, + ); + + const messages = await collectSseMessages( + response, + 2, + 'subscriptions/listen', + ); + assert( + messages[0].method === 'notifications/subscriptions/acknowledged', + `subscriptions/listen expected acknowledgment first, got ${JSON.stringify(messages[0])}`, + ); + assert( + messages[0].params?._meta?.['io.modelcontextprotocol/subscriptionId'] === + 'listen-tools', + `subscriptions/listen acknowledgment missing subscription id: ${JSON.stringify(messages[0])}`, + ); + assert( + messages[1].method === 'notifications/tools/list_changed', + `subscriptions/listen expected tools list_changed notification, got ${JSON.stringify(messages[1])}`, + ); + assert( + messages[1].params?._meta?.['io.modelcontextprotocol/subscriptionId'] === + 'listen-tools', + `subscriptions/listen notification missing subscription id: ${JSON.stringify(messages[1])}`, + ); +} + +async function cancellationCount(client) { + const status = await client.callTool({ + name: 'test_stream_cancellation_status', + arguments: {}, + }); + return Number.parseInt(firstText(status, 'cancellation status'), 10); +} + +async function assertStreamCancellation(urlValue, client) { + const before = await cancellationCount(client); + const controller = new AbortController(); + const response = await rawRpc(urlValue, { + id: 'cancel-stream', + method: 'tools/call', + params: { + name: 'test_stream_cancellation', + arguments: {}, + _meta: requestMeta({ progressToken: 'cancel-stream-progress' }), + }, + headers: { 'Mcp-Name': 'test_stream_cancellation' }, + signal: controller.signal, + }); + assert( + response.status === 200, + `stream cancellation expected HTTP 200, got ${response.status}`, + ); + + await collectSseMessages(response, 1, 'stream cancellation startup'); + controller.abort(); + + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + const after = await cancellationCount(client); + if (after > before) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error('stream cancellation was not observed by the Dart server'); +} + +async function assertProgressNotifications(client) { + const progressValues = []; + const result = await client.callTool( + { + name: 'progress_demo', + arguments: { steps: 3 }, + }, + { + timeout: 10000, + onprogress: (progress) => { + progressValues.push(progress.progress); + }, + }, + ); + + requireText( + result, + 'Completed 3 steps with progress notifications', + 'progress demo', + ); + assert( + progressValues.length >= 2, + `progress demo expected multiple progress callbacks, got ${JSON.stringify(progressValues)}`, + ); + assert( + progressValues[0] === 0 && progressValues.at(-1) === 100, + `progress demo expected 0..100 progress, got ${JSON.stringify(progressValues)}`, + ); + for (let index = 1; index < progressValues.length; index++) { + assert( + progressValues[index] > progressValues[index - 1], + `progress demo values did not strictly increase: ${JSON.stringify(progressValues)}`, + ); + } +} + +function assertCustomHeaderSchema(tool) { + const properties = tool.inputSchema?.properties ?? {}; + assert( + properties.region?.['x-mcp-header'] === 'Region', + `region x-mcp-header missing from ${tool.name}`, + ); + assert( + properties.count?.['x-mcp-header'] === 'Count', + `count x-mcp-header missing from ${tool.name}`, + ); + assert( + properties.dryRun?.['x-mcp-header'] === 'Dry-Run', + `dryRun x-mcp-header missing from ${tool.name}`, + ); + assert( + properties.auth?.properties?.tenant?.['x-mcp-header'] === 'Tenant', + `nested tenant x-mcp-header missing from ${tool.name}`, + ); +} + async function main() { const urlValue = readArg(process.argv.slice(2), '--url'); if (!urlValue) { @@ -15,12 +458,23 @@ async function main() { } const client = new Client( - { name: 'mcp-dart-ts-2026-rc-client', version: '0.0.0' }, + CLIENT_INFO, { - capabilities: {}, - versionNegotiation: { mode: { pin: '2026-07-28' } }, + capabilities: { elicitation: {} }, + versionNegotiation: { mode: { pin: PROTOCOL_VERSION } }, }, ); + client.setRequestHandler('elicitation/create', async (request) => { + assert( + request.params?.mode === 'form' || request.params?.mode === undefined, + `Expected form elicitation, got ${JSON.stringify(request)}`, + ); + return { + action: 'accept', + content: { name: 'TypeScript Tester' }, + }; + }); + const transport = new StreamableHTTPClientTransport(new URL(urlValue)); try { @@ -28,33 +482,80 @@ async function main() { const era = client.getProtocolEra(); const version = client.getNegotiatedProtocolVersion(); - if (era !== 'modern' || version !== '2026-07-28') { - throw new Error(`Expected modern 2026-07-28, got ${era}/${version}`); - } + assert( + era === 'modern' && version === PROTOCOL_VERSION, + `Expected modern ${PROTOCOL_VERSION}, got ${era}/${version}`, + ); + + const discover = await client.discover(); + assertCacheMetadata(discover, 'server/discover'); + assert( + discover.supportedVersions?.includes(PROTOCOL_VERSION), + `server/discover did not advertise ${PROTOCOL_VERSION}: ${JSON.stringify(discover)}`, + ); + assert( + discover.serverInfo?.name === 'dart-test-server', + `server/discover returned unexpected serverInfo: ${JSON.stringify(discover.serverInfo)}`, + ); const tools = await client.listTools(); + assertCacheMetadata(tools, 'tools/list'); const toolNames = tools.tools.map((tool) => tool.name); - if (!toolNames.includes('echo')) { - throw new Error(`Expected echo tool, got ${toolNames.join(', ')}`); - } + requireTool(tools.tools, 'echo'); + assertCustomHeaderSchema( + requireTool(tools.tools, 'test_custom_headers_valid'), + ); + requireTool(tools.tools, 'test_input_required_result_elicitation'); + requireTool(tools.tools, 'progress_demo'); const message = 'from TypeScript 2026 RC preview'; const result = await client.callTool({ name: 'echo', arguments: { message }, }); - const content = Array.isArray(result.content) ? result.content : []; - const first = content[0]; - if (!first || first.type !== 'text' || first.text !== message) { - throw new Error(`Unexpected echo result: ${JSON.stringify(result)}`); - } + const echo = requireText(result, message, 'echo'); + + const customHeaders = await client.callTool({ + name: 'test_custom_headers_valid', + arguments: { + region: 'us-east1', + count: 42, + dryRun: false, + auth: { tenant: ' padded ' }, + }, + }); + requireText(customHeaders, 'custom-header-ok', 'custom header mirroring'); + + const elicitation = await client.callTool({ + name: 'test_input_required_result_elicitation', + arguments: {}, + }); + const elicitationText = requireText( + elicitation, + 'Hello, TypeScript Tester!', + 'input_required elicitation', + ); + + await assertRawHeaderValidation(urlValue); + await assertRemovedCoreRequests(urlValue); + await assertProgressNotifications(client); + await assertSubscriptionListen(urlValue); + await assertStreamCancellation(urlValue, client); console.log( JSON.stringify({ protocolEra: era, protocolVersion: version, + discoveredVersions: discover.supportedVersions, toolCount: toolNames.length, - echo: first.text, + echo, + customHeaders: 'ok', + inputRequired: elicitationText, + rawHeaderValidation: 'ok', + removedCoreRequests: 'ok', + progress: 'ok', + subscriptionsListen: 'ok', + streamCancellation: 'ok', }), ); } finally { @@ -66,4 +567,3 @@ main().catch((error) => { console.error(error); process.exit(1); }); - diff --git a/test/interop/ts_2026_rc/src/server.mjs b/test/interop/ts_2026_rc/src/server.mjs new file mode 100644 index 00000000..6c5dedc7 --- /dev/null +++ b/test/interop/ts_2026_rc/src/server.mjs @@ -0,0 +1,187 @@ +import { createServer } from 'node:http'; + +import { + McpServer, + WebStandardStreamableHTTPServerTransport, +} from '@modelcontextprotocol/server'; +import { z } from 'zod'; + +const PROTOCOL_VERSION = '2026-07-28'; + +function readArg(args, name) { + const index = args.indexOf(name); + if (index < 0 || index + 1 >= args.length) { + return undefined; + } + return args[index + 1]; +} + +function createInteropServer() { + const serverInfo = { name: 'ts-2026-rc-interop-server', version: '0.0.0' }; + const server = new McpServer( + serverInfo, + { + supportedProtocolVersions: [PROTOCOL_VERSION], + capabilities: { tools: {} }, + }, + ); + + server.registerTool( + 'ts_echo', + { + description: 'Echoes a message from the Dart 2026 RC client.', + inputSchema: z.object({ message: z.string() }), + }, + async ({ message }) => ({ + content: [{ type: 'text', text: message }], + }), + ); + + return server; +} + +async function readBody(req) { + const chunks = []; + for await (const chunk of req) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +function requestHeaders(req) { + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + if (Array.isArray(value)) { + for (const item of value) { + headers.append(name, item); + } + } else if (value !== undefined) { + headers.set(name, value); + } + } + return headers; +} + +function discoveryResponse(id) { + // The current TS server alpha does not answer server/discover yet, but the + // 2026 draft requires it. Keep this shim local to the diagnostic fixture. + return new Response( + JSON.stringify({ + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + supportedVersions: [PROTOCOL_VERSION], + capabilities: { tools: {} }, + serverInfo: { + name: 'ts-2026-rc-interop-server', + version: '0.0.0', + }, + ttlMs: 1000, + cacheScope: 'public', + }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); +} + +async function writeWebResponse(webResponse, res) { + res.writeHead( + webResponse.status, + Object.fromEntries(webResponse.headers.entries()), + ); + if (!webResponse.body) { + res.end(); + return; + } + + const reader = webResponse.body.getReader(); + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) { + break; + } + res.write(Buffer.from(value)); + } + } finally { + res.end(); + } +} + +async function main() { + const args = process.argv.slice(2); + const host = readArg(args, '--host') ?? '127.0.0.1'; + const port = Number.parseInt(readArg(args, '--port') ?? '0', 10); + const mcpServer = createInteropServer(); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: false, + supportedProtocolVersions: [PROTOCOL_VERSION], + }); + await mcpServer.connect(transport); + + const httpServer = createServer(async (req, res) => { + try { + const url = new URL(req.url ?? '/', `http://${req.headers.host}`); + if (url.pathname !== '/mcp') { + res.writeHead(404).end('Not found'); + return; + } + + const init = { + method: req.method, + headers: requestHeaders(req), + }; + let body; + if (req.method !== 'GET' && req.method !== 'HEAD') { + body = await readBody(req); + init.body = body; + } + + const webRequest = new Request(url, init); + if (body && body.length > 0) { + const message = JSON.parse(body.toString('utf8')); + if (message.method === 'server/discover') { + await writeWebResponse(discoveryResponse(message.id), res); + return; + } + } + + const webResponse = await transport.handleRequest(webRequest); + await writeWebResponse(webResponse, res); + } catch (error) { + console.error(error); + if (!res.headersSent) { + res.writeHead(500); + } + res.end(String(error)); + } + }); + + await new Promise((resolve) => httpServer.listen(port, host, resolve)); + const address = httpServer.address(); + const boundPort = typeof address === 'object' && address ? address.port : port; + console.log( + `TS 2026 RC interop server listening on http://${host}:${boundPort}/mcp`, + ); + + const stop = async () => { + await mcpServer.close().catch(() => {}); + await new Promise((resolve) => httpServer.close(resolve)); + }; + process.once('SIGTERM', () => { + stop().finally(() => process.exit(0)); + }); + process.once('SIGINT', () => { + stop().finally(() => process.exit(0)); + }); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/test/types_edge_cases_test.dart b/test/types_edge_cases_test.dart index 4ba7c40d..f080bbef 100644 --- a/test/types_edge_cases_test.dart +++ b/test/types_edge_cases_test.dart @@ -14,6 +14,15 @@ void main() { expect(ErrorCode.fromValue(-32000), equals(ErrorCode.connectionClosed)); expect(ErrorCode.fromValue(-32001), equals(ErrorCode.requestTimeout)); expect(ErrorCode.fromValue(-32002), equals(ErrorCode.resourceNotFound)); + expect(ErrorCode.fromValue(-32020), equals(ErrorCode.headerMismatch)); + expect( + ErrorCode.fromValue(-32021), + equals(ErrorCode.missingRequiredClientCapability), + ); + expect( + ErrorCode.fromValue(-32022), + equals(ErrorCode.unsupportedProtocolVersion), + ); expect(ErrorCode.fromValue(-32700), equals(ErrorCode.parseError)); expect(ErrorCode.fromValue(-32600), equals(ErrorCode.invalidRequest)); expect(ErrorCode.fromValue(-32601), equals(ErrorCode.methodNotFound)); diff --git a/tool/testing/run_ts_2026_rc_interop.dart b/tool/testing/run_ts_2026_rc_interop.dart index a2f83849..9ceaa5c0 100644 --- a/tool/testing/run_ts_2026_rc_interop.dart +++ b/tool/testing/run_ts_2026_rc_interop.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:mcp_dart/mcp_dart.dart'; + Future main(List args) async { final repoRoot = Directory.current; final fixtureDir = Directory('test/interop/ts_2026_rc'); @@ -9,6 +11,10 @@ Future main(List args) async { 'test/interop/ts_2026_rc/node_modules/' '@modelcontextprotocol/client/package.json', ); + final serverPackage = File( + 'test/interop/ts_2026_rc/node_modules/' + '@modelcontextprotocol/server/package.json', + ); if (!File('pubspec.yaml').existsSync() || !fixtureDir.existsSync()) { stderr.writeln( @@ -27,7 +33,29 @@ Future main(List args) async { exitCode = 64; return; } + if (!serverPackage.existsSync()) { + stderr.writeln( + 'Missing TypeScript server fixture dependencies. Run:\n' + ' cd test/interop/ts_2026_rc\n' + ' npm install', + ); + exitCode = 64; + return; + } + try { + await _runTsClientAgainstDartServer(repoRoot, fixtureDir); + await _runDartClientAgainstTsServer(repoRoot, fixtureDir); + } on Object catch (error) { + stderr.writeln('TS 2026 RC interop failed: $error'); + exitCode = 1; + } +} + +Future _runTsClientAgainstDartServer( + Directory repoRoot, + Directory fixtureDir, +) async { final server = await Process.start( Platform.resolvedExecutable, [ @@ -42,22 +70,15 @@ Future main(List args) async { ); final serverUrl = Completer(); - final urlPattern = RegExp(r'(http://[^\s]+)'); - final serverStdout = _pipeLines( server.stdout, stdout, '[dart-server]', - onLine: (line) { - if (serverUrl.isCompleted || - !line.contains('MCP 2026 RC conformance server listening on')) { - return; - } - final match = urlPattern.firstMatch(line); - if (match != null) { - serverUrl.complete(match.group(1)!); - } - }, + onLine: (line) => _completeUrlFromLine( + serverUrl, + line, + 'MCP 2026 RC conformance server listening on', + ), ); final serverStderr = _pipeLines(server.stderr, stderr, '[dart-server]'); @@ -82,18 +103,130 @@ Future main(List args) async { await Future.wait([clientStdout, clientStderr]); if (clientExit != 0) { - exitCode = clientExit; - return; + throw StateError('TypeScript 2026 RC client exited with $clientExit'); } - } on Object catch (error) { - stderr.writeln('TS 2026 RC interop failed: $error'); - exitCode = 1; } finally { await _terminate(server); await Future.wait([serverStdout, serverStderr]); } } +Future _runDartClientAgainstTsServer( + Directory repoRoot, + Directory fixtureDir, +) async { + final server = await Process.start( + 'node', + ['src/server.mjs', '--host', '127.0.0.1', '--port', '0'], + workingDirectory: fixtureDir.path, + ); + + final serverUrl = Completer(); + final serverStdout = _pipeLines( + server.stdout, + stdout, + '[ts-server]', + onLine: (line) => _completeUrlFromLine( + serverUrl, + line, + 'TS 2026 RC interop server listening on', + ), + ); + final serverStderr = _pipeLines(server.stderr, stderr, '[ts-server]'); + + try { + final url = await serverUrl.future.timeout( + const Duration(seconds: 20), + onTimeout: () { + throw TimeoutException('Timed out waiting for TypeScript server URL'); + }, + ); + try { + await _exerciseDartClient(url); + } on McpError catch (error) { + if (!_isKnownTypeScriptServerAlphaGap(error)) { + rethrow; + } + stdout.writeln( + '[dart-client] reverse TS server diagnostic skipped: ${error.message}', + ); + } + } finally { + await _terminate(server); + await Future.wait([serverStdout, serverStderr]); + } +} + +bool _isKnownTypeScriptServerAlphaGap(McpError error) { + final text = error.toString(); + return text.contains('MCP stateless responses must include resultType') || + text.contains('server/discover not available'); +} + +Future _exerciseDartClient(String url) async { + final transport = StreamableHttpClientTransport(Uri.parse(url)); + final client = McpClient( + const Implementation(name: 'mcp-dart-2026-rc-client', version: '0.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + capabilities: ClientCapabilities(), + ), + ); + + try { + await client.connect(transport).timeout(const Duration(seconds: 20)); + final version = client.getProtocolVersion(); + if (version != draftProtocolVersion2026_07_28) { + throw StateError('Expected 2026-07-28, got $version'); + } + final serverInfo = client.getServerVersion(); + if (serverInfo?.name != 'ts-2026-rc-interop-server') { + throw StateError('Unexpected TS server info: ${serverInfo?.toJson()}'); + } + + final tools = await client.listTools().timeout(const Duration(seconds: 10)); + if (!tools.tools.any((tool) => tool.name == 'ts_echo')) { + throw StateError( + 'TS server tools/list did not include ts_echo: ' + '${tools.tools.map((tool) => tool.name).toList()}', + ); + } + + const message = 'from Dart 2026 RC preview'; + final echo = await client + .callTool( + const CallToolRequest( + name: 'ts_echo', + arguments: {'message': message}, + ), + ) + .timeout(const Duration(seconds: 10)); + final text = _firstText(echo, 'ts_echo'); + if (text != message) { + throw StateError('Unexpected ts_echo result: $text'); + } + + stdout.writeln( + '[dart-client] ${jsonEncode({ + 'protocolVersion': version, + 'serverInfo': serverInfo?.toJson(), + 'toolCount': tools.tools.length, + 'echo': text, + })}', + ); + } finally { + await client.close(); + } +} + +String _firstText(CallToolResult result, String label) { + final content = result.content; + if (content.isEmpty || content.first is! TextContent) { + throw StateError('$label expected text content: ${result.toJson()}'); + } + return (content.first as TextContent).text; +} + Future _pipeLines( Stream> stream, IOSink sink, @@ -107,6 +240,21 @@ Future _pipeLines( } } +void _completeUrlFromLine( + Completer completer, + String line, + String marker, +) { + if (completer.isCompleted || !line.contains(marker)) { + return; + } + final urlPattern = RegExp(r'(http://[^\s]+)'); + final match = urlPattern.firstMatch(line); + if (match != null) { + completer.complete(match.group(1)!); + } +} + Future _terminate(Process process) async { final exitFuture = process.exitCode; process.kill(ProcessSignal.sigterm); From d01d081863180075cf24e72cac3b80fe0bce1182 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Sun, 21 Jun 2026 09:46:12 -0400 Subject: [PATCH 60/68] Prepare dev release 2.3.0-dev.1 (#288) --- CHANGELOG.md | 45 ++++++++++++---------- README.md | 2 +- doc/getting-started.md | 2 +- doc/mcp-2026-rc.md | 6 +-- doc/quick-reference.md | 2 +- packages/mcp_dart_cli/CHANGELOG.md | 10 +++++ packages/mcp_dart_cli/README.md | 9 ++--- packages/mcp_dart_cli/lib/src/version.dart | 2 +- packages/mcp_dart_cli/pubspec.yaml | 4 +- pubspec.yaml | 2 +- 10 files changed, 49 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac08921e..89ea1d61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,32 +1,37 @@ ## Unreleased -### Conformance and release readiness +## 2.3.0-dev.1 -- Expanded the manual TypeScript SDK 2026 RC interop fixture pinned to the - upstream PR #2327 preview package, covering modern negotiation, - `server/discover` cache metadata, `tools/list`, `tools/call`, - `x-mcp-header` mirroring, progress notifications, raw HTTP header, - unsupported-version, and removed core RPC rejection, `subscriptions/listen`, - and Streamable HTTP SSE cancellation against the Dart 2026 RC conformance - server. -- Added a diagnostic Dart preview client -> TypeScript server alpha path and - documented the current TS alpha gaps around mandatory `server/discover` and - stateless `resultType` responses. -- Aligned 2026 draft protocol-defined error codes with the live draft: +This dev preview refreshes MCP `2026-07-28` draft/RC support while keeping MCP +`2025-11-25` as the default protocol profile. + +### MCP 2026-07-28 draft/RC refresh + +- Aligned draft protocol-defined error codes with the live draft: `HeaderMismatch` is now `-32020`, `MissingRequiredClientCapability` is now `-32021`, and - `UnsupportedProtocolVersion` is now `-32022`. The conformance alpha.4 server - scenarios that still expect the old `HeaderMismatch` code are tracked as - expected failures. + `UnsupportedProtocolVersion` is now `-32022`. - Marked `server/discover` as a 2026 cacheable result so stateless responses include default `ttlMs` and `cacheScope` hints. -- Updated official conformance gates to - `@modelcontextprotocol/conformance@0.2.0-alpha.4`, with 2026 RC runs pinned - to `2026-07-28`, the full 2026 server scenario list covered in CI, the 2026 - client wrapper aligned with alpha.4's spec-filtered scenario list, and the - current upstream client fixture gap tracked as an expected failure. - Removed the legacy `DRAFT-2026-v1` draft alias now that official conformance targets the `2026-07-28` wire version. +- Ported the JSON Schema boolean-subschema preservation fix onto the RC dev + line, including legacy tool-schema shims. + +### Conformance and interoperability + +- Updated official conformance gates to + `@modelcontextprotocol/conformance@0.2.0-alpha.4`, with full 2026 RC server + scenario coverage and alpha.4's spec-filtered 2026 client scenario list in CI. +- Expanded the manual TypeScript SDK 2026 RC interop fixture pinned to the + upstream PR #2327 preview package, covering modern negotiation, + `server/discover` cache metadata, `tools/list`, `tools/call`, + `x-mcp-header` mirroring, progress notifications, raw HTTP header validation, + unsupported-version and removed core RPC rejection, `subscriptions/listen`, + and Streamable HTTP SSE cancellation. +- Added a diagnostic Dart preview client -> TypeScript server alpha path and + documented the current TS alpha gaps around mandatory `server/discover` and + stateless `resultType` responses. ## 2.3.0-dev.0 diff --git a/README.md b/README.md index aa5445a3..ce2fc8a7 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - mcp_dart: ^2.3.0-dev.0 + mcp_dart: ^2.3.0-dev.1 ``` Then install dependencies: diff --git a/doc/getting-started.md b/doc/getting-started.md index 413a910a..69f3fb0d 100644 --- a/doc/getting-started.md +++ b/doc/getting-started.md @@ -8,7 +8,7 @@ Add the MCP Dart SDK to your `pubspec.yaml`: ```yaml dependencies: - mcp_dart: ^2.3.0-dev.0 + mcp_dart: ^2.3.0-dev.1 ``` Then run: diff --git a/doc/mcp-2026-rc.md b/doc/mcp-2026-rc.md index 69b9254a..c0e78066 100644 --- a/doc/mcp-2026-rc.md +++ b/doc/mcp-2026-rc.md @@ -175,10 +175,10 @@ dev release, `mcp_dart_cli 0.2.0-dev.0`, is already published. The CLI publish workflow removes the local SDK override before publishing so users receive the published SDK dependency. -Install the dev CLI explicitly by version: +Install the current dev CLI explicitly by version: ```sh -dart pub global activate mcp_dart_cli 0.2.0-dev.0 +dart pub global activate mcp_dart_cli 0.2.0-dev.1 ``` The standalone install and update scripts intentionally track stable GitHub @@ -186,5 +186,5 @@ releases; use Dart SDK activation when testing prerelease CLI builds. `mcp_dart create` continues to generate projects that resolve the stable SDK by default. For draft/RC testing, update generated projects to depend on -`mcp_dart: ^2.3.0-dev.0` and opt into `McpProtocol.preview2026` or +`mcp_dart: ^2.3.0-dev.1` and opt into `McpProtocol.preview2026` or `McpProtocol.require2026`. diff --git a/doc/quick-reference.md b/doc/quick-reference.md index 2644cf37..97013cf4 100644 --- a/doc/quick-reference.md +++ b/doc/quick-reference.md @@ -7,7 +7,7 @@ Fast lookup guide for common MCP Dart SDK operations. ```yaml # pubspec.yaml dependencies: - mcp_dart: ^2.3.0-dev.0 + mcp_dart: ^2.3.0-dev.1 ``` ```bash diff --git a/packages/mcp_dart_cli/CHANGELOG.md b/packages/mcp_dart_cli/CHANGELOG.md index 2d2eebca..3d074732 100644 --- a/packages/mcp_dart_cli/CHANGELOG.md +++ b/packages/mcp_dart_cli/CHANGELOG.md @@ -1,3 +1,13 @@ +## 0.2.0-dev.1 + +- Update the dev CLI package to depend on `mcp_dart ^2.3.0-dev.1`. +- Refresh built-in 2026 RC conformance checks for the current draft error + codes and cacheable `server/discover` behavior. +- Keep CLI standalone binary release automation current with GitHub runner and + artifact action updates. +- Add installer fallback behavior for resolving the latest stable CLI GitHub + release when the GitHub Releases API is unavailable. + ## 0.2.0-dev.0 - Prepare the CLI for the MCP `2026-07-28` draft/RC SDK dev line with a diff --git a/packages/mcp_dart_cli/README.md b/packages/mcp_dart_cli/README.md index 95488c21..86b6f7e6 100644 --- a/packages/mcp_dart_cli/README.md +++ b/packages/mcp_dart_cli/README.md @@ -17,7 +17,7 @@ For the MCP `2026-07-28` draft/RC dev release, pass the prerelease version explicitly: ```bash -dart pub global activate mcp_dart_cli 0.2.0-dev.0 +dart pub global activate mcp_dart_cli 0.2.0-dev.1 ``` Without the Dart SDK, install the latest standalone binary from GitHub Releases: @@ -60,7 +60,7 @@ If `directory` is omitted, the project will be created in the current directory Generated projects resolve the stable `mcp_dart` SDK by default. For MCP `2026-07-28` draft/RC testing, update the generated `pubspec.yaml` to depend on -`mcp_dart: ^2.3.0-dev.0`. +`mcp_dart: ^2.3.0-dev.1`. ### Create from a specific template @@ -466,9 +466,8 @@ dart run tool/validate_cli_publish.dart For follow-up CLI dev releases whose matching `mcp_dart` SDK dev package is not published yet, this uses `pubspec_overrides.yaml` so the CLI can validate -against the local SDK checkout. The initial `mcp_dart 2.3.0-dev.0` SDK package -is already published, so release validation should also cover the pub.dev SDK -version: +against the local SDK checkout. After the matching SDK dev package is published, +also validate the CLI against the pub.dev SDK version: ```bash dart run tool/validate_cli_publish.dart --published-sdk diff --git a/packages/mcp_dart_cli/lib/src/version.dart b/packages/mcp_dart_cli/lib/src/version.dart index 20611918..26103d32 100644 --- a/packages/mcp_dart_cli/lib/src/version.dart +++ b/packages/mcp_dart_cli/lib/src/version.dart @@ -1 +1 @@ -const packageVersion = '0.2.0-dev.0'; +const packageVersion = '0.2.0-dev.1'; diff --git a/packages/mcp_dart_cli/pubspec.yaml b/packages/mcp_dart_cli/pubspec.yaml index f52a6a48..df68e28c 100644 --- a/packages/mcp_dart_cli/pubspec.yaml +++ b/packages/mcp_dart_cli/pubspec.yaml @@ -1,6 +1,6 @@ name: mcp_dart_cli description: Command-line tools for creating, serving, inspecting, and testing Dart Model Context Protocol (MCP) servers. -version: 0.2.0-dev.0 +version: 0.2.0-dev.1 repository: https://github.com/leehack/mcp_dart homepage: https://github.com/leehack/mcp_dart/tree/dev/2026-07-28-rc/packages/mcp_dart_cli issue_tracker: https://github.com/leehack/mcp_dart/issues @@ -27,7 +27,7 @@ dependencies: stream_transform: ^2.1.1 watcher: ^1.2.0 yaml: ^3.1.3 - mcp_dart: ^2.3.0-dev.0 + mcp_dart: ^2.3.0-dev.1 mason_logger: ^0.3.3 meta: ^1.17.0 pub_updater: ^0.5.0 diff --git a/pubspec.yaml b/pubspec.yaml index cf087f72..410fc896 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: mcp_dart description: Dart and Flutter SDK for building Model Context Protocol (MCP) servers, clients, hosts, and AI tools. -version: 2.3.0-dev.0 +version: 2.3.0-dev.1 repository: https://github.com/leehack/mcp_dart homepage: https://github.com/leehack/mcp_dart issue_tracker: https://github.com/leehack/mcp_dart/issues From f90d3f9e39177f0f9b20bef3c1620b8b603e372c Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Wed, 24 Jun 2026 06:26:46 -0400 Subject: [PATCH 61/68] Update conformance alpha5 and TS RC interop (#289) * Update conformance alpha5 and TS RC interop * Tighten TS 2026 RC interop fixture * Clarify 2026-07-28 RC fixture names --- .github/workflows/test_core.yml | 12 ++--- CHANGELOG.md | 20 ++++++- README.md | 4 +- doc/interoperability.md | 27 +++++----- doc/{mcp-2026-rc.md => mcp-2026-07-28-rc.md} | 16 +++--- packages/mcp_dart_cli/CHANGELOG.md | 2 +- ...026_07_28_rc_client_expected_failures.txt} | 6 +-- ...xt => 2026_07_28_rc_expected_failures.txt} | 9 ++-- test/conformance/README.md | 31 +++++------ ...ent.dart => mcp_2026_07_28_rc_client.dart} | 4 +- ...ver.dart => mcp_2026_07_28_rc_server.dart} | 6 +-- .../run_2025_server_conformance.dart | 2 +- ...run_2026_07_28_rc_client_conformance.dart} | 20 ++++--- ...run_2026_07_28_rc_server_conformance.dart} | 21 +++++--- .../.gitignore | 0 .../README.md | 33 ++++++------ .../package-lock.json | 26 ++++----- test/interop/ts_2026_07_28_rc/package.json | 18 +++++++ .../src/client.mjs | 4 +- .../src/server.mjs | 54 +++---------------- test/interop/ts_2026_rc/package.json | 18 ------- ...dart => run_ts_2026_07_28_rc_interop.dart} | 43 +++++---------- 22 files changed, 170 insertions(+), 206 deletions(-) rename doc/{mcp-2026-rc.md => mcp-2026-07-28-rc.md} (92%) rename test/conformance/{2026_rc_client_expected_failures.txt => 2026_07_28_rc_client_expected_failures.txt} (76%) rename test/conformance/{2026_rc_expected_failures.txt => 2026_07_28_rc_expected_failures.txt} (56%) rename test/conformance/{mcp_2026_rc_client.dart => mcp_2026_07_28_rc_client.dart} (99%) rename test/conformance/{mcp_2026_rc_server.dart => mcp_2026_07_28_rc_server.dart} (98%) rename test/conformance/{run_2026_rc_client_conformance.dart => run_2026_07_28_rc_client_conformance.dart} (94%) rename test/conformance/{run_2026_rc_server_conformance.dart => run_2026_07_28_rc_server_conformance.dart} (95%) rename test/interop/{ts_2026_rc => ts_2026_07_28_rc}/.gitignore (100%) rename test/interop/{ts_2026_rc => ts_2026_07_28_rc}/README.md (72%) rename test/interop/{ts_2026_rc => ts_2026_07_28_rc}/package-lock.json (86%) create mode 100644 test/interop/ts_2026_07_28_rc/package.json rename test/interop/{ts_2026_rc => ts_2026_07_28_rc}/src/client.mjs (99%) rename test/interop/{ts_2026_rc => ts_2026_07_28_rc}/src/server.mjs (67%) delete mode 100644 test/interop/ts_2026_rc/package.json rename tool/testing/{run_ts_2026_rc_interop.dart => run_ts_2026_07_28_rc_interop.dart} (83%) diff --git a/.github/workflows/test_core.yml b/.github/workflows/test_core.yml index 24eb6487..718e7742 100644 --- a/.github/workflows/test_core.yml +++ b/.github/workflows/test_core.yml @@ -51,22 +51,22 @@ jobs: - name: Run official MCP 2025 client conformance run: > - npx -y @modelcontextprotocol/conformance@0.2.0-alpha.4 client - --command "dart run test/conformance/mcp_2026_rc_client.dart" + npx -y @modelcontextprotocol/conformance@0.2.0-alpha.5 client + --command "dart run test/conformance/mcp_2026_07_28_rc_client.dart" --suite all --spec-version 2025-11-25 --verbose -o .dart_tool/conformance/ci_2025_client - - name: Run official MCP 2026 RC server conformance + - name: Run official MCP 2026-07-28 RC server conformance run: > - dart run test/conformance/run_2026_rc_server_conformance.dart + dart run test/conformance/run_2026_07_28_rc_server_conformance.dart --timeout-seconds 90 --output-dir .dart_tool/conformance/ci_2026_server - - name: Run official MCP 2026 RC client conformance + - name: Run official MCP 2026-07-28 RC client conformance run: > - dart run test/conformance/run_2026_rc_client_conformance.dart + dart run test/conformance/run_2026_07_28_rc_client_conformance.dart --timeout-seconds 90 --output-dir .dart_tool/conformance/ci_2026_client diff --git a/CHANGELOG.md b/CHANGELOG.md index 89ea1d61..460c2da7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ ## Unreleased +### Conformance and interoperability + +- Updated official conformance gates to + `@modelcontextprotocol/conformance@0.2.0-alpha.5`. The 2026-07-28 RC server suite + now has no expected failures; the 2026 client suite keeps only the upstream + `json-schema-ref-no-deref` fixture gap expected. +- Re-pinned the manual TypeScript SDK 2026-07-28 RC interop fixture to + `pkg.pr.new` previews from the merged `v2-2026-07-28` branch head for both + client and server packages. +- Switched the reverse Dart preview client -> TypeScript preview server fixture + to the TypeScript SDK's 2026 HTTP handler entry, making `server/discover`, + `tools/list`, and `tools/call` strict interop checks instead of diagnostic + skips. +- Recorded overridden conformance package names in 2026-07-28 RC summary artifacts so + ad hoc package-bump checks are auditable. + ## 2.3.0-dev.1 This dev preview refreshes MCP `2026-07-28` draft/RC support while keeping MCP @@ -21,9 +37,9 @@ This dev preview refreshes MCP `2026-07-28` draft/RC support while keeping MCP ### Conformance and interoperability - Updated official conformance gates to - `@modelcontextprotocol/conformance@0.2.0-alpha.4`, with full 2026 RC server + `@modelcontextprotocol/conformance@0.2.0-alpha.4`, with full 2026-07-28 RC server scenario coverage and alpha.4's spec-filtered 2026 client scenario list in CI. -- Expanded the manual TypeScript SDK 2026 RC interop fixture pinned to the +- Expanded the manual TypeScript SDK 2026-07-28 RC interop fixture pinned to the upstream PR #2327 preview package, covering modern negotiation, `server/discover` cache metadata, `tools/list`, `tools/call`, `x-mcp-header` mirroring, progress notifications, raw HTTP header validation, diff --git a/README.md b/README.md index ce2fc8a7..cb035ed8 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ final server = McpServer( ``` Use the preview profile while the spec is still a draft/RC. See the -[MCP 2026-07-28 draft/RC transition guide](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/mcp-2026-rc.md) +[MCP 2026-07-28 draft/RC transition guide](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/mcp-2026-07-28-rc.md) for opt-in behavior, fallback rules, and draft-only APIs. ## Documentation @@ -137,7 +137,7 @@ for opt-in behavior, fallback rules, and draft-only APIs. - ๐Ÿงช **[SDK Interoperability Matrix](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/interoperability.md)** - Verified Dart/TypeScript and documented cross-SDK scenarios - โœ… **[MCP 2025-11-25 Spec Coverage Matrix](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/spec-coverage-2025-11-25.md)** - Auditable coverage map with CLI conformance cases and known gaps -- ๐Ÿงญ **[MCP 2026-07-28 Draft/RC Transition Guide](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/mcp-2026-rc.md)** - Opt-in profile, fallback behavior, and draft-only APIs +- ๐Ÿงญ **[MCP 2026-07-28 Draft/RC Transition Guide](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/mcp-2026-07-28-rc.md)** - Opt-in profile, fallback behavior, and draft-only APIs - ๐Ÿ”’ **[Transport Security Recipes](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/transports.md#dns-rebinding-protection)** - Host/Origin allowlists, OAuth layering, and compatibility-toggle trade-offs - ๐Ÿ“ฑ **[Flutter Recipes](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/flutter-recipes.md)** - Flutter Web, mobile, and desktop host/client guidance - ๐Ÿ” **[Migration Cookbooks](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/migration-cookbooks.md)** - TypeScript SDK, `dart_mcp`, stdio-to-HTTP, and version migration paths diff --git a/doc/interoperability.md b/doc/interoperability.md index efcff08d..48d04bb9 100644 --- a/doc/interoperability.md +++ b/doc/interoperability.md @@ -21,8 +21,8 @@ For requirement-level MCP 2025-11-25 coverage, see the | Dart client -> TypeScript SDK server | Streamable HTTP | `2025-11-25` | [`test/interop/dart_client_with_ts_server_test.dart`](../test/interop/dart_client_with_ts_server_test.dart), [`test/interop/ts/`](../test/interop/ts/) | Verified | Covers tool calls and stale preconfigured session-id recovery. | | TypeScript SDK client -> Dart server | stdio | `2025-11-25` | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/test_dart_server.dart`](../test/interop/test_dart_server.dart) | Verified | Runs the compiled TypeScript client fixture against a Dart server process and checks that an official TS client can list tools immediately after the lifecycle handshake. | | TypeScript SDK client -> Dart server | Streamable HTTP | `2025-11-25` | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/test_dart_server.dart`](../test/interop/test_dart_server.dart) | Verified | Includes official TS Streamable HTTP client lifecycle coverage, pre-`initialized` operation rejection, GET SSE streams, and `Last-Event-ID` replay behavior. | -| TypeScript SDK preview client -> Dart server | Streamable HTTP | `2026-07-28` draft/RC | [`test/interop/ts_2026_rc/`](../test/interop/ts_2026_rc/), [`tool/testing/run_ts_2026_rc_interop.dart`](../tool/testing/run_ts_2026_rc_interop.dart) | Experimental manual check | Uses a pinned `pkg.pr.new` preview from TypeScript SDK PR #2327. Covers modern negotiation, cache metadata, `tools/list`, `tools/call`, `x-mcp-header` mirroring, raw header and unsupported-version rejection, removed core RPC rejection, progress notifications, `subscriptions/listen`, and HTTP SSE cancellation against the Dart 2026 RC conformance server. Not a CI gate yet. | -| Dart preview client -> TypeScript SDK alpha server | Streamable HTTP | `2026-07-28` draft/RC | [`test/interop/ts_2026_rc/src/server.mjs`](../test/interop/ts_2026_rc/src/server.mjs), [`tool/testing/run_ts_2026_rc_interop.dart`](../tool/testing/run_ts_2026_rc_interop.dart) | Diagnostic only | The fixture attempts the reverse path, but the current TS server alpha does not yet answer mandatory `server/discover` and omits `resultType` on stateless `tools/list`; the runner reports this as a TS-alpha gap instead of a Dart failure. | +| TypeScript SDK preview client -> Dart server | Streamable HTTP | `2026-07-28` draft/RC | [`test/interop/ts_2026_07_28_rc/`](../test/interop/ts_2026_07_28_rc/), [`tool/testing/run_ts_2026_07_28_rc_interop.dart`](../tool/testing/run_ts_2026_07_28_rc_interop.dart) | Experimental manual check | Uses pinned `pkg.pr.new` previews from the TypeScript SDK `v2-2026-07-28` branch head. Covers modern negotiation, cache metadata, `tools/list`, `tools/call`, `x-mcp-header` mirroring, raw header and unsupported-version rejection, removed core RPC rejection, progress notifications, `subscriptions/listen`, and HTTP SSE cancellation against the Dart 2026-07-28 RC conformance server. Not a CI gate yet. | +| Dart preview client -> TypeScript SDK preview server | Streamable HTTP | `2026-07-28` draft/RC | [`test/interop/ts_2026_07_28_rc/src/server.mjs`](../test/interop/ts_2026_07_28_rc/src/server.mjs), [`tool/testing/run_ts_2026_07_28_rc_interop.dart`](../tool/testing/run_ts_2026_07_28_rc_interop.dart) | Experimental manual check | Uses the pinned TypeScript SDK server preview through its `createMcpHandler` entry and covers `server/discover` negotiation, `tools/list`, and `tools/call`. Not a CI gate yet. | | Dart client -> Python MCP server | stdio | Server-dependent | [`doc/transports.md`](transports.md#connect-to-python-server) | Documented recipe | The transport can spawn Python servers over stdio, but this repo does not yet include an automated Python SDK fixture. | | Flutter/Web client -> Dart server | Streamable HTTP | `2025-11-25` | [`example/flutter_http_client/`](../example/flutter_http_client/), [`doc/flutter-recipes.md`](flutter-recipes.md) | Documented recipe | Flutter Web cannot spawn stdio servers; use Streamable HTTP or another browser-safe transport. | | MCP Apps host/client metadata | stdio or Streamable HTTP | `2025-11-25` plus `io.modelcontextprotocol/ui` extension | [`doc/mcp-apps.md`](mcp-apps.md), [`example/mcp_apps_helpers_server.dart`](../example/mcp_apps_helpers_server.dart), [`test/types/mcp_ui_test.dart`](../test/types/mcp_ui_test.dart), [`test/server/mcp_ui_test.dart`](../test/server/mcp_ui_test.dart) | Verified | Verified coverage is limited to SDK metadata helpers, serialization, and checked-in examples; host rendering behavior varies by host, so verify UI metadata against your target host. | @@ -44,20 +44,21 @@ dart test --tags interop If the compiled fixtures are missing, local test runs skip the interop groups; CI should fail when required fixtures are unavailable. -The TypeScript 2026 RC fixture is manual while the upstream SDK support remains -unreleased and split across preview PRs: +The TypeScript 2026-07-28 RC fixture is manual while the upstream SDK support remains +unreleased and pinned to `pkg.pr.new` previews from the TypeScript SDK +`v2-2026-07-28` branch: ```bash # From repository root -cd test/interop/ts_2026_rc +cd test/interop/ts_2026_07_28_rc npm install cd ../../.. -dart run tool/testing/run_ts_2026_rc_interop.dart +dart run tool/testing/run_ts_2026_07_28_rc_interop.dart ``` -This starts the Dart 2026 RC conformance server, runs the pinned TypeScript SDK -preview client against it, then attempts the reverse Dart-client diagnostic -against the TypeScript server alpha and reports known TS-alpha spec gaps. +This starts the Dart 2026-07-28 RC conformance server, runs the pinned TypeScript SDK +preview client against it, then runs the reverse Dart preview client smoke check +against the TypeScript preview server. The CLI spec conformance gate covers raw-wire negative cases that do not need a cross-SDK fixture, including stable MCP 2025-11-25 checks and MCP 2026-07-28 RC @@ -82,9 +83,11 @@ When adding a new interoperability claim: ## Known gaps worth tracking - Automated Python SDK fixture coverage. -- CI promotion for the TypeScript 2026 RC interop fixture after the TypeScript - SDK publishes a 2026-compatible alpha package whose server answers - `server/discover` and includes `resultType` on stateless results. +- CI promotion for the TypeScript 2026-07-28 RC interop fixture after the TypeScript + SDK publishes a 2026-07-28-compatible alpha package instead of requiring + `pkg.pr.new` previews. +- Broader reverse-path TypeScript preview server coverage beyond discovery, + `tools/list`, and `tools/call`. - Host-specific MCP Apps rendering compatibility notes. - More OAuth-protected remote server scenarios beyond the checked-in examples. - A broader compatibility table once additional SDKs expose stable 2025-11-25 fixtures. diff --git a/doc/mcp-2026-rc.md b/doc/mcp-2026-07-28-rc.md similarity index 92% rename from doc/mcp-2026-rc.md rename to doc/mcp-2026-07-28-rc.md index c0e78066..5cc79182 100644 --- a/doc/mcp-2026-rc.md +++ b/doc/mcp-2026-07-28-rc.md @@ -130,27 +130,27 @@ Before creating follow-up dev tags from `dev/2026-07-28-rc`, run: ```sh dart analyze dart run test/conformance/run_2025_server_conformance.dart -npx -y @modelcontextprotocol/conformance@0.2.0-alpha.4 client \ - --command "dart run test/conformance/mcp_2026_rc_client.dart" \ +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.5 client \ + --command "dart run test/conformance/mcp_2026_07_28_rc_client.dart" \ --suite all \ --spec-version 2025-11-25 -dart run test/conformance/run_2026_rc_server_conformance.dart -dart run test/conformance/run_2026_rc_client_conformance.dart +dart run test/conformance/run_2026_07_28_rc_server_conformance.dart +dart run test/conformance/run_2026_07_28_rc_client_conformance.dart dart pub publish --dry-run dart pub global run pana --no-warning dart run tool/validate_cli_publish.dart ``` -The `run_2026_rc_server_conformance.dart` gate runs the full -`@modelcontextprotocol/conformance@0.2.0-alpha.4` server scenario list for +The `run_2026_07_28_rc_server_conformance.dart` gate runs the full +`@modelcontextprotocol/conformance@0.2.0-alpha.5` server scenario list for `--spec-version 2026-07-28`, including the stable-style tool, resource, prompt, completion, and JSON Schema scenarios that the alpha package tags for the RC. For cross-SDK smoke coverage against the TypeScript SDK 2026 preview client, run the manual fixture documented in [`doc/interoperability.md`](interoperability.md#running-interop-checks-locally). -Keep that fixture out of CI until upstream publishes a 2026-compatible alpha -package instead of requiring a `pkg.pr.new` PR preview. +Keep that fixture out of CI until upstream publishes a 2026-07-28-compatible alpha +package instead of requiring a `pkg.pr.new` branch preview. For dev packages, keep package documentation links pointed at `dev/2026-07-28-rc` until the draft work is ready to merge back to `main`. diff --git a/packages/mcp_dart_cli/CHANGELOG.md b/packages/mcp_dart_cli/CHANGELOG.md index 3d074732..151a8389 100644 --- a/packages/mcp_dart_cli/CHANGELOG.md +++ b/packages/mcp_dart_cli/CHANGELOG.md @@ -1,7 +1,7 @@ ## 0.2.0-dev.1 - Update the dev CLI package to depend on `mcp_dart ^2.3.0-dev.1`. -- Refresh built-in 2026 RC conformance checks for the current draft error +- Refresh built-in 2026-07-28 RC conformance checks for the current draft error codes and cacheable `server/discover` behavior. - Keep CLI standalone binary release automation current with GitHub runner and artifact action updates. diff --git a/test/conformance/2026_rc_client_expected_failures.txt b/test/conformance/2026_07_28_rc_client_expected_failures.txt similarity index 76% rename from test/conformance/2026_rc_client_expected_failures.txt rename to test/conformance/2026_07_28_rc_client_expected_failures.txt index 50099442..82fbd945 100644 --- a/test/conformance/2026_rc_client_expected_failures.txt +++ b/test/conformance/2026_07_28_rc_client_expected_failures.txt @@ -1,10 +1,10 @@ -# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.4 -# against the 2026 RC/DRAFT client suite. +# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.5 +# against the 2026-07-28 RC/DRAFT client suite. # # Keep this list scenario-based so the baseline is easy to review. When a # scenario turns green, remove it from this file in the same PR as the fix. # -# Upstream alpha.4 fixture gap: this scenario's mock server still rejects +# Upstream alpha.5 fixture gap: this scenario's mock server still rejects # 2026-07-28 with HTTP 400 and advertises only stable protocol versions. # Keep it expected-fail until the conformance fixture is draft-capable. json-schema-ref-no-deref diff --git a/test/conformance/2026_rc_expected_failures.txt b/test/conformance/2026_07_28_rc_expected_failures.txt similarity index 56% rename from test/conformance/2026_rc_expected_failures.txt rename to test/conformance/2026_07_28_rc_expected_failures.txt index 6483c191..8e11852f 100644 --- a/test/conformance/2026_rc_expected_failures.txt +++ b/test/conformance/2026_07_28_rc_expected_failures.txt @@ -1,10 +1,7 @@ -# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.4 +# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.5 # against the full 2026-07-28 RC/DRAFT server suite. # # Keep this list scenario-based so the baseline is easy to review. When a # scenario turns green, remove it from this file in the same PR as the fix. - -# alpha.4 still expects the pre-renumber HeaderMismatch error code -32001. -# The live 2026-07-28 draft now assigns HeaderMismatch to -32020. -server-stateless -http-custom-header-server-validation +# +# No expected server failures are currently tracked. diff --git a/test/conformance/README.md b/test/conformance/README.md index 0a9146c4..45f8e08e 100644 --- a/test/conformance/README.md +++ b/test/conformance/README.md @@ -14,7 +14,7 @@ calls hard-coded diagnostic tools, prompts, and resources. The 2026 suite still targets a draft/RC alpha spec package. If the official suite changes before the spec is final, record intentional temporary gaps in -`2026_rc_expected_failures.txt` or `2026_rc_client_expected_failures.txt` so CI +`2026_07_28_rc_expected_failures.txt` or `2026_07_28_rc_client_expected_failures.txt` so CI distinguishes known draft/RC churn from regressions. ## Stable MCP 2025-11-25 @@ -26,15 +26,15 @@ dart run test/conformance/run_2025_server_conformance.dart ``` The runner starts `mcp_2025_server.dart`, runs -`@modelcontextprotocol/conformance@0.2.0-alpha.4 server --suite all +`@modelcontextprotocol/conformance@0.2.0-alpha.5 server --suite all --spec-version 2025-11-25`, and writes artifacts under `.dart_tool/conformance/2025_server/`. Run the stable client suite from the repository root: ```bash -npx -y @modelcontextprotocol/conformance@0.2.0-alpha.4 client \ - --command "dart run test/conformance/mcp_2026_rc_client.dart" \ +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.5 client \ + --command "dart run test/conformance/mcp_2026_07_28_rc_client.dart" \ --suite all \ --spec-version 2025-11-25 \ --verbose \ @@ -50,35 +50,32 @@ server offers. Run the current server baseline from the repository root: ```bash -dart run test/conformance/run_2026_rc_server_conformance.dart +dart run test/conformance/run_2026_07_28_rc_server_conformance.dart ``` The runner starts a local `StreamableMcpServer` in default Streamable HTTP SSE response mode, runs the full `2026-07-28` server scenario list from -`@modelcontextprotocol/conformance@0.2.0-alpha.4` one by one with `--suite all` +`@modelcontextprotocol/conformance@0.2.0-alpha.5` one by one with `--suite all` and `--spec-version 2026-07-28`, and writes per-run artifacts under -`.dart_tool/conformance/2026_rc/`. +`.dart_tool/conformance/2026_07_28_rc/`. -Expected failures live in `2026_rc_expected_failures.txt`. When a scenario is +Expected failures live in `2026_07_28_rc_expected_failures.txt`. When a scenario is fixed, remove it from that file so the baseline remains useful. -As of `@modelcontextprotocol/conformance@0.2.0-alpha.4`, the server expected -failure file includes scenarios where the conformance package still expects the -pre-renumber `HeaderMismatch` code `-32001`. The live `2026-07-28` draft assigns -`HeaderMismatch` to `-32020`, so the SDK follows the draft and keeps those -alpha.4 scenarios expected until the conformance package catches up. +As of `@modelcontextprotocol/conformance@0.2.0-alpha.5`, the full 2026-07-28 RC server +suite has no expected failures against the Dart fixture. Run the current client baseline from the repository root: ```bash -dart run test/conformance/run_2026_rc_client_conformance.dart +dart run test/conformance/run_2026_07_28_rc_client_conformance.dart ``` -The client runner invokes `mcp_2026_rc_client.dart` against the conformance +The client runner invokes `mcp_2026_07_28_rc_client.dart` against the conformance package's scenario servers and writes per-run artifacts under -`.dart_tool/conformance/2026_rc_client/`. +`.dart_tool/conformance/2026_07_28_rc_client/`. -Client expected failures live in `2026_rc_client_expected_failures.txt`. +Client expected failures live in `2026_07_28_rc_client_expected_failures.txt`. The 2026 client wrapper is aligned with the scenarios returned by `conformance list --client --spec-version 2026-07-28`; stable-only client scenarios remain covered by the stable `2025-11-25` client suite above. diff --git a/test/conformance/mcp_2026_rc_client.dart b/test/conformance/mcp_2026_07_28_rc_client.dart similarity index 99% rename from test/conformance/mcp_2026_rc_client.dart rename to test/conformance/mcp_2026_07_28_rc_client.dart index c4a2f742..b63ff3bf 100644 --- a/test/conformance/mcp_2026_rc_client.dart +++ b/test/conformance/mcp_2026_07_28_rc_client.dart @@ -5,7 +5,7 @@ import 'dart:io'; import 'package:mcp_dart/mcp_dart.dart'; const _clientInfo = Implementation( - name: 'mcp-dart-2026-rc-conformance-client', + name: 'mcp-dart-2026-07-28-rc-conformance-client', version: '0.0.0', ); @@ -568,7 +568,7 @@ class _RawStatelessClient { void _printUsage() { stdout.writeln( - 'Usage: dart run test/conformance/mcp_2026_rc_client.dart ', + 'Usage: dart run test/conformance/mcp_2026_07_28_rc_client.dart ', ); } diff --git a/test/conformance/mcp_2026_rc_server.dart b/test/conformance/mcp_2026_07_28_rc_server.dart similarity index 98% rename from test/conformance/mcp_2026_rc_server.dart rename to test/conformance/mcp_2026_07_28_rc_server.dart index 91b2c93d..868c4fe5 100644 --- a/test/conformance/mcp_2026_rc_server.dart +++ b/test/conformance/mcp_2026_07_28_rc_server.dart @@ -8,7 +8,7 @@ import 'mcp_2025_server.dart' as stable_conformance; int _streamCancellationCount = 0; -/// Dedicated HTTP server fixture for the MCP 2026 RC conformance package. +/// Dedicated HTTP server fixture for the MCP 2026-07-28 RC conformance package. /// /// This deliberately starts from the existing cross-SDK interop server and /// uses the default Streamable HTTP SSE response mode so request-scoped @@ -46,7 +46,7 @@ Future main(List args) async { await server.start(); stdout.writeln( - 'MCP 2026 RC conformance server listening on ' + 'MCP 2026-07-28 RC conformance server listening on ' 'http://$host:${server.boundPort}${server.path}', ); @@ -512,7 +512,7 @@ void _requireRequestState(String? actual, String expected) { void _printUsage() { stdout.writeln( - 'Usage: dart run test/conformance/mcp_2026_rc_server.dart ' + 'Usage: dart run test/conformance/mcp_2026_07_28_rc_server.dart ' '[--host localhost] [--port 33125]', ); } diff --git a/test/conformance/run_2025_server_conformance.dart b/test/conformance/run_2025_server_conformance.dart index cfc32e7f..24cf4660 100644 --- a/test/conformance/run_2025_server_conformance.dart +++ b/test/conformance/run_2025_server_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.4'; + '@modelcontextprotocol/conformance@0.2.0-alpha.5'; const _defaultTimeout = Duration(seconds: 60); Future main(List args) async { diff --git a/test/conformance/run_2026_rc_client_conformance.dart b/test/conformance/run_2026_07_28_rc_client_conformance.dart similarity index 94% rename from test/conformance/run_2026_rc_client_conformance.dart rename to test/conformance/run_2026_07_28_rc_client_conformance.dart index c677c16a..7ea6e841 100644 --- a/test/conformance/run_2026_rc_client_conformance.dart +++ b/test/conformance/run_2026_07_28_rc_client_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.4'; + '@modelcontextprotocol/conformance@0.2.0-alpha.5'; const _defaultTimeout = Duration(seconds: 30); const _draftClientScenarios = [ @@ -71,7 +71,12 @@ Future main(List args) async { _printScenarioResult(result, expectedFailures); } - await _writeSummary(outputRoot, results, expectedFailures); + await _writeSummary( + outputRoot, + results, + expectedFailures, + options.conformancePackage, + ); final unexpectedFailures = results .where( (result) => @@ -127,7 +132,7 @@ Future> _readExpectedFailures(String path) async { Future _createOutputRoot(String? outputDir) async { final root = outputDir == null ? Directory( - '.dart_tool/conformance/2026_rc_client/' + '.dart_tool/conformance/2026_07_28_rc_client/' '${DateTime.now().toUtc().toIso8601String().replaceAll(':', '-')}', ) : Directory(outputDir); @@ -151,7 +156,7 @@ Future<_ScenarioResult> _runScenario({ conformancePackage, 'client', '--command', - 'dart run test/conformance/mcp_2026_rc_client.dart', + 'dart run test/conformance/mcp_2026_07_28_rc_client.dart', '--scenario', scenario, '--spec-version', @@ -223,9 +228,10 @@ Future _writeSummary( Directory outputRoot, List<_ScenarioResult> results, Set expectedFailures, + String conformancePackage, ) async { final summary = { - 'package': _defaultConformancePackage, + 'package': conformancePackage, 'expectedFailures': expectedFailures.toList()..sort(), 'results': [ for (final result in results) @@ -244,7 +250,7 @@ Future _writeSummary( void _printUsage() { stdout.writeln(''' -Usage: dart run test/conformance/run_2026_rc_client_conformance.dart [options] +Usage: dart run test/conformance/run_2026_07_28_rc_client_conformance.dart [options] Options: --scenario Run one scenario instead of the full draft list. @@ -295,7 +301,7 @@ class _Options { static _Options parse(List args) { String? scenario; var expectedFailuresPath = - 'test/conformance/2026_rc_client_expected_failures.txt'; + 'test/conformance/2026_07_28_rc_client_expected_failures.txt'; String? outputDir; var conformancePackage = _defaultConformancePackage; var timeout = _defaultTimeout; diff --git a/test/conformance/run_2026_rc_server_conformance.dart b/test/conformance/run_2026_07_28_rc_server_conformance.dart similarity index 95% rename from test/conformance/run_2026_rc_server_conformance.dart rename to test/conformance/run_2026_07_28_rc_server_conformance.dart index cd8aa7f7..79fd27ab 100644 --- a/test/conformance/run_2026_rc_server_conformance.dart +++ b/test/conformance/run_2026_07_28_rc_server_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.4'; + '@modelcontextprotocol/conformance@0.2.0-alpha.5'; const _defaultTimeout = Duration(seconds: 25); const _serverScenarios = [ @@ -73,7 +73,7 @@ Future main(List args) async { serverProcess = await Process.start( Platform.resolvedExecutable, [ - 'test/conformance/mcp_2026_rc_server.dart', + 'test/conformance/mcp_2026_07_28_rc_server.dart', '--port', '$port', ], @@ -85,7 +85,7 @@ Future main(List args) async { serverUrl = Uri.parse(options.url!); } - stdout.writeln('2026 RC conformance URL: $serverUrl'); + stdout.writeln('2026-07-28 RC conformance URL: $serverUrl'); stdout.writeln('Conformance package: ${options.conformancePackage}'); stdout.writeln('Output: ${outputRoot.path}'); stdout.writeln(''); @@ -103,7 +103,12 @@ Future main(List args) async { _printScenarioResult(result, expectedFailures); } - await _writeSummary(outputRoot, results, expectedFailures); + await _writeSummary( + outputRoot, + results, + expectedFailures, + options.conformancePackage, + ); final unexpectedFailures = results .where( (result) => @@ -169,7 +174,7 @@ Future> _readExpectedFailures(String path) async { Future _createOutputRoot(String? outputDir) async { final root = outputDir == null ? Directory( - '.dart_tool/conformance/2026_rc/' + '.dart_tool/conformance/2026_07_28_rc/' '${DateTime.now().toUtc().toIso8601String().replaceAll(':', '-')}', ) : Directory(outputDir); @@ -314,8 +319,10 @@ Future _writeSummary( Directory outputRoot, List<_ScenarioResult> results, Set expectedFailures, + String conformancePackage, ) async { final summary = { + 'package': conformancePackage, 'expectedFailures': expectedFailures.toList()..sort(), 'results': [ for (final result in results) @@ -338,7 +345,7 @@ String _sanitize(String value) { void _printUsage() { stdout.writeln( - 'Usage: dart run test/conformance/run_2026_rc_server_conformance.dart ' + 'Usage: dart run test/conformance/run_2026_07_28_rc_server_conformance.dart ' '[--url http://localhost:33125/mcp] [--scenario scenario-name] ' '[--timeout-seconds 25]', ); @@ -399,7 +406,7 @@ class _Options { int? port; String? scenario; String? outputDir; - var expectedFailuresPath = 'test/conformance/2026_rc_expected_failures.txt'; + var expectedFailuresPath = 'test/conformance/2026_07_28_rc_expected_failures.txt'; var conformancePackage = _defaultConformancePackage; var timeout = _defaultTimeout; diff --git a/test/interop/ts_2026_rc/.gitignore b/test/interop/ts_2026_07_28_rc/.gitignore similarity index 100% rename from test/interop/ts_2026_rc/.gitignore rename to test/interop/ts_2026_07_28_rc/.gitignore diff --git a/test/interop/ts_2026_rc/README.md b/test/interop/ts_2026_07_28_rc/README.md similarity index 72% rename from test/interop/ts_2026_rc/README.md rename to test/interop/ts_2026_07_28_rc/README.md index d95d71c9..86a111b1 100644 --- a/test/interop/ts_2026_rc/README.md +++ b/test/interop/ts_2026_07_28_rc/README.md @@ -1,29 +1,29 @@ -# TypeScript SDK 2026 RC Interop +# TypeScript SDK 2026-07-28 RC Interop This fixture is an experimental smoke test for the unreleased MCP `2026-07-28` draft/RC path against the official TypeScript SDK work in progress. It is intentionally separate from `test/interop/ts`, which tracks the published -stable TypeScript SDK and MCP `2025-11-25` behavior. The fixture pins a -`pkg.pr.new` client preview from TypeScript SDK PR #2327 for the modern -Streamable HTTP `Mcp-Name` header support needed to interoperate with the Dart -2026 RC server. It also installs `@modelcontextprotocol/server@2.0.0-alpha.2` -for a reverse-path diagnostic, but the server alpha is not yet a strict 2026 -interoperability gate. +stable TypeScript SDK and MCP `2025-11-25` behavior. The fixture pins +`pkg.pr.new` client and server previews from the TypeScript SDK +`v2-2026-07-28` branch after PR #2327 landed. The TypeScript client path is a +draft-aligned smoke check against the Dart 2026-07-28 RC server. The reverse Dart +client path is a draft-aligned smoke check against the TypeScript preview +server. ## Run From the repository root: ```bash -cd test/interop/ts_2026_rc +cd test/interop/ts_2026_07_28_rc npm install cd ../../.. -dart run tool/testing/run_ts_2026_rc_interop.dart +dart run tool/testing/run_ts_2026_07_28_rc_interop.dart ``` -The runner starts `test/conformance/mcp_2026_rc_server.dart`, waits for its +The runner starts `test/conformance/mcp_2026_07_28_rc_server.dart`, waits for its bound local URL, and then runs `src/client.mjs` against it. The fixture asserts: - TypeScript client negotiation selects the modern `2026-07-28` era. @@ -53,11 +53,10 @@ bound local URL, and then runs `src/client.mjs` against it. The fixture asserts: - Closing a 2026 HTTP SSE response stream cancels the in-flight Dart server request without sending `notifications/cancelled`. -The runner also starts `src/server.mjs` and attempts a Dart preview client -against the TypeScript server alpha. That reverse path is currently diagnostic: -the fixture shims `server/discover` because the TS server alpha does not answer -that mandatory 2026 method yet, and the Dart client then reports the current TS -alpha `tools/list` gap where stateless results omit `resultType`. +The runner also starts `src/server.mjs` with the TypeScript preview +`createMcpHandler` entry and runs a Dart preview client against it. That reverse +path asserts `server/discover` negotiation, `tools/list`, and `tools/call` +against the TypeScript preview server; failures are treated as interop failures. Keep this fixture anchored to the official draft/RC behavior rather than the preview TypeScript implementation alone. In particular, `x-mcp-header` tests use @@ -66,5 +65,5 @@ When TypeScript preview behavior conflicts with the draft, keep the draft as the assertion source and document the preview gap near the test. Keep this as a manual, non-blocking check until the TypeScript SDK publishes a -stable 2026-compatible alpha package or the upstream PR stack lands on the -`v2-2026-07-28` branch. +stable 2026-07-28-compatible alpha package instead of requiring `pkg.pr.new` preview +artifacts. diff --git a/test/interop/ts_2026_rc/package-lock.json b/test/interop/ts_2026_07_28_rc/package-lock.json similarity index 86% rename from test/interop/ts_2026_rc/package-lock.json rename to test/interop/ts_2026_07_28_rc/package-lock.json index 95f2d135..d24447b0 100644 --- a/test/interop/ts_2026_rc/package-lock.json +++ b/test/interop/ts_2026_07_28_rc/package-lock.json @@ -1,16 +1,16 @@ { - "name": "mcp-dart-ts-2026-rc-interop", + "name": "mcp-dart-ts-2026-07-28-rc-interop", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "mcp-dart-ts-2026-rc-interop", + "name": "mcp-dart-ts-2026-07-28-rc-interop", "version": "0.0.0", "dependencies": { "@cfworker/json-schema": "4.1.1", - "@modelcontextprotocol/client": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@87ce6c5", - "@modelcontextprotocol/server": "2.0.0-alpha.2" + "@modelcontextprotocol/client": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@9fdb62e", + "@modelcontextprotocol/server": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@9fdb62e" }, "engines": { "node": ">=20" @@ -24,8 +24,8 @@ }, "node_modules/@modelcontextprotocol/client": { "version": "2.0.0-alpha.2", - "resolved": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@87ce6c5", - "integrity": "sha512-jZ5kzgPjUtC270gykOsntgX/o5v7yGeV46cn2mMvpovxbFDrDvQ08TNRioqbsHl2jKN+HTeXatpnxvDRoQ1+Qw==", + "resolved": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@9fdb62e", + "integrity": "sha512-A02KwaeB0p7WJ8TCQB83WLtPpGUK+1h3y4k6IMxfun3VmIiH7jrAw2y72TmSnofwO1GKYr53R142t78sCE5ycg==", "license": "MIT", "dependencies": { "cross-spawn": "^7.0.5", @@ -41,22 +41,14 @@ }, "node_modules/@modelcontextprotocol/server": { "version": "2.0.0-alpha.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-alpha.2.tgz", - "integrity": "sha512-gmLgdHzlYM8L7Aw/+VE0kxjT25WKamtUSLNhdOgrJq5CrESvqVSoAfWSJJeNPUXNTluQ+dYDGFbKVitdsJtbPA==", + "resolved": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@9fdb62e", + "integrity": "sha512-4yLquMq4S/d69ISm7XyDsyVDxtih7jGVKCXK1QDqBuxVgFGDN2jY4iZb13kFVD28Wv1IPQ93pHtCEEn7j+0iqw==", "license": "MIT", "dependencies": { - "zod": "^4.0" + "zod": "^4.2.0" }, "engines": { "node": ">=20" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - } } }, "node_modules/cross-spawn": { diff --git a/test/interop/ts_2026_07_28_rc/package.json b/test/interop/ts_2026_07_28_rc/package.json new file mode 100644 index 00000000..c85d9f4a --- /dev/null +++ b/test/interop/ts_2026_07_28_rc/package.json @@ -0,0 +1,18 @@ +{ + "name": "mcp-dart-ts-2026-07-28-rc-interop", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Manual TypeScript SDK 2026-07-28 RC interop fixture for mcp_dart.", + "scripts": { + "client": "node src/client.mjs" + }, + "dependencies": { + "@cfworker/json-schema": "4.1.1", + "@modelcontextprotocol/client": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@9fdb62e", + "@modelcontextprotocol/server": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@9fdb62e" + }, + "engines": { + "node": ">=20" + } +} diff --git a/test/interop/ts_2026_rc/src/client.mjs b/test/interop/ts_2026_07_28_rc/src/client.mjs similarity index 99% rename from test/interop/ts_2026_rc/src/client.mjs rename to test/interop/ts_2026_07_28_rc/src/client.mjs index d4f1f9aa..46318fcb 100644 --- a/test/interop/ts_2026_rc/src/client.mjs +++ b/test/interop/ts_2026_07_28_rc/src/client.mjs @@ -1,7 +1,7 @@ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; const PROTOCOL_VERSION = '2026-07-28'; -const CLIENT_INFO = { name: 'mcp-dart-ts-2026-rc-client', version: '0.0.0' }; +const CLIENT_INFO = { name: 'mcp-dart-ts-2026-07-28-rc-client', version: '0.0.0' }; function readArg(args, name) { const index = args.indexOf(name); @@ -508,7 +508,7 @@ async function main() { requireTool(tools.tools, 'test_input_required_result_elicitation'); requireTool(tools.tools, 'progress_demo'); - const message = 'from TypeScript 2026 RC preview'; + const message = 'from TypeScript 2026-07-28 RC preview'; const result = await client.callTool({ name: 'echo', arguments: { message }, diff --git a/test/interop/ts_2026_rc/src/server.mjs b/test/interop/ts_2026_07_28_rc/src/server.mjs similarity index 67% rename from test/interop/ts_2026_rc/src/server.mjs rename to test/interop/ts_2026_07_28_rc/src/server.mjs index 6c5dedc7..f9374a42 100644 --- a/test/interop/ts_2026_rc/src/server.mjs +++ b/test/interop/ts_2026_07_28_rc/src/server.mjs @@ -1,8 +1,8 @@ import { createServer } from 'node:http'; import { + createMcpHandler, McpServer, - WebStandardStreamableHTTPServerTransport, } from '@modelcontextprotocol/server'; import { z } from 'zod'; @@ -17,7 +17,7 @@ function readArg(args, name) { } function createInteropServer() { - const serverInfo = { name: 'ts-2026-rc-interop-server', version: '0.0.0' }; + const serverInfo = { name: 'ts-2026-07-28-rc-interop-server', version: '0.0.0' }; const server = new McpServer( serverInfo, { @@ -29,7 +29,7 @@ function createInteropServer() { server.registerTool( 'ts_echo', { - description: 'Echoes a message from the Dart 2026 RC client.', + description: 'Echoes a message from the Dart 2026-07-28 RC client.', inputSchema: z.object({ message: z.string() }), }, async ({ message }) => ({ @@ -62,32 +62,6 @@ function requestHeaders(req) { return headers; } -function discoveryResponse(id) { - // The current TS server alpha does not answer server/discover yet, but the - // 2026 draft requires it. Keep this shim local to the diagnostic fixture. - return new Response( - JSON.stringify({ - jsonrpc: '2.0', - id, - result: { - resultType: 'complete', - supportedVersions: [PROTOCOL_VERSION], - capabilities: { tools: {} }, - serverInfo: { - name: 'ts-2026-rc-interop-server', - version: '0.0.0', - }, - ttlMs: 1000, - cacheScope: 'public', - }, - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }, - ); -} - async function writeWebResponse(webResponse, res) { res.writeHead( webResponse.status, @@ -116,13 +90,9 @@ async function main() { const args = process.argv.slice(2); const host = readArg(args, '--host') ?? '127.0.0.1'; const port = Number.parseInt(readArg(args, '--port') ?? '0', 10); - const mcpServer = createInteropServer(); - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: false, - supportedProtocolVersions: [PROTOCOL_VERSION], + const handler = createMcpHandler(() => createInteropServer(), { + legacy: 'reject', }); - await mcpServer.connect(transport); const httpServer = createServer(async (req, res) => { try { @@ -143,15 +113,7 @@ async function main() { } const webRequest = new Request(url, init); - if (body && body.length > 0) { - const message = JSON.parse(body.toString('utf8')); - if (message.method === 'server/discover') { - await writeWebResponse(discoveryResponse(message.id), res); - return; - } - } - - const webResponse = await transport.handleRequest(webRequest); + const webResponse = await handler.fetch(webRequest); await writeWebResponse(webResponse, res); } catch (error) { console.error(error); @@ -166,11 +128,11 @@ async function main() { const address = httpServer.address(); const boundPort = typeof address === 'object' && address ? address.port : port; console.log( - `TS 2026 RC interop server listening on http://${host}:${boundPort}/mcp`, + `TS 2026-07-28 RC interop server listening on http://${host}:${boundPort}/mcp`, ); const stop = async () => { - await mcpServer.close().catch(() => {}); + await handler.close().catch(() => {}); await new Promise((resolve) => httpServer.close(resolve)); }; process.once('SIGTERM', () => { diff --git a/test/interop/ts_2026_rc/package.json b/test/interop/ts_2026_rc/package.json deleted file mode 100644 index 8b5c811a..00000000 --- a/test/interop/ts_2026_rc/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "mcp-dart-ts-2026-rc-interop", - "version": "0.0.0", - "private": true, - "type": "module", - "description": "Manual TypeScript SDK 2026 RC interop fixture for mcp_dart.", - "scripts": { - "client": "node src/client.mjs" - }, - "dependencies": { - "@cfworker/json-schema": "4.1.1", - "@modelcontextprotocol/client": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@87ce6c5", - "@modelcontextprotocol/server": "2.0.0-alpha.2" - }, - "engines": { - "node": ">=20" - } -} diff --git a/tool/testing/run_ts_2026_rc_interop.dart b/tool/testing/run_ts_2026_07_28_rc_interop.dart similarity index 83% rename from tool/testing/run_ts_2026_rc_interop.dart rename to tool/testing/run_ts_2026_07_28_rc_interop.dart index 9ceaa5c0..f3492e17 100644 --- a/tool/testing/run_ts_2026_rc_interop.dart +++ b/tool/testing/run_ts_2026_07_28_rc_interop.dart @@ -6,13 +6,13 @@ import 'package:mcp_dart/mcp_dart.dart'; Future main(List args) async { final repoRoot = Directory.current; - final fixtureDir = Directory('test/interop/ts_2026_rc'); + final fixtureDir = Directory('test/interop/ts_2026_07_28_rc'); final clientPackage = File( - 'test/interop/ts_2026_rc/node_modules/' + 'test/interop/ts_2026_07_28_rc/node_modules/' '@modelcontextprotocol/client/package.json', ); final serverPackage = File( - 'test/interop/ts_2026_rc/node_modules/' + 'test/interop/ts_2026_07_28_rc/node_modules/' '@modelcontextprotocol/server/package.json', ); @@ -27,7 +27,7 @@ Future main(List args) async { if (!clientPackage.existsSync()) { stderr.writeln( 'Missing TypeScript fixture dependencies. Run:\n' - ' cd test/interop/ts_2026_rc\n' + ' cd test/interop/ts_2026_07_28_rc\n' ' npm install', ); exitCode = 64; @@ -36,7 +36,7 @@ Future main(List args) async { if (!serverPackage.existsSync()) { stderr.writeln( 'Missing TypeScript server fixture dependencies. Run:\n' - ' cd test/interop/ts_2026_rc\n' + ' cd test/interop/ts_2026_07_28_rc\n' ' npm install', ); exitCode = 64; @@ -47,7 +47,7 @@ Future main(List args) async { await _runTsClientAgainstDartServer(repoRoot, fixtureDir); await _runDartClientAgainstTsServer(repoRoot, fixtureDir); } on Object catch (error) { - stderr.writeln('TS 2026 RC interop failed: $error'); + stderr.writeln('TS 2026-07-28 RC interop failed: $error'); exitCode = 1; } } @@ -60,7 +60,7 @@ Future _runTsClientAgainstDartServer( Platform.resolvedExecutable, [ 'run', - 'test/conformance/mcp_2026_rc_server.dart', + 'test/conformance/mcp_2026_07_28_rc_server.dart', '--host', '127.0.0.1', '--port', @@ -77,7 +77,7 @@ Future _runTsClientAgainstDartServer( onLine: (line) => _completeUrlFromLine( serverUrl, line, - 'MCP 2026 RC conformance server listening on', + 'MCP 2026-07-28 RC conformance server listening on', ), ); final serverStderr = _pipeLines(server.stderr, stderr, '[dart-server]'); @@ -103,7 +103,7 @@ Future _runTsClientAgainstDartServer( await Future.wait([clientStdout, clientStderr]); if (clientExit != 0) { - throw StateError('TypeScript 2026 RC client exited with $clientExit'); + throw StateError('TypeScript 2026-07-28 RC client exited with $clientExit'); } } finally { await _terminate(server); @@ -129,7 +129,7 @@ Future _runDartClientAgainstTsServer( onLine: (line) => _completeUrlFromLine( serverUrl, line, - 'TS 2026 RC interop server listening on', + 'TS 2026-07-28 RC interop server listening on', ), ); final serverStderr = _pipeLines(server.stderr, stderr, '[ts-server]'); @@ -141,32 +141,17 @@ Future _runDartClientAgainstTsServer( throw TimeoutException('Timed out waiting for TypeScript server URL'); }, ); - try { - await _exerciseDartClient(url); - } on McpError catch (error) { - if (!_isKnownTypeScriptServerAlphaGap(error)) { - rethrow; - } - stdout.writeln( - '[dart-client] reverse TS server diagnostic skipped: ${error.message}', - ); - } + await _exerciseDartClient(url); } finally { await _terminate(server); await Future.wait([serverStdout, serverStderr]); } } -bool _isKnownTypeScriptServerAlphaGap(McpError error) { - final text = error.toString(); - return text.contains('MCP stateless responses must include resultType') || - text.contains('server/discover not available'); -} - Future _exerciseDartClient(String url) async { final transport = StreamableHttpClientTransport(Uri.parse(url)); final client = McpClient( - const Implementation(name: 'mcp-dart-2026-rc-client', version: '0.0.0'), + const Implementation(name: 'mcp-dart-2026-07-28-rc-client', version: '0.0.0'), options: const McpClientOptions( protocol: McpProtocol.preview2026, capabilities: ClientCapabilities(), @@ -180,7 +165,7 @@ Future _exerciseDartClient(String url) async { throw StateError('Expected 2026-07-28, got $version'); } final serverInfo = client.getServerVersion(); - if (serverInfo?.name != 'ts-2026-rc-interop-server') { + if (serverInfo?.name != 'ts-2026-07-28-rc-interop-server') { throw StateError('Unexpected TS server info: ${serverInfo?.toJson()}'); } @@ -192,7 +177,7 @@ Future _exerciseDartClient(String url) async { ); } - const message = 'from Dart 2026 RC preview'; + const message = 'from Dart 2026-07-28 RC preview'; final echo = await client .callTool( const CallToolRequest( From 29a4cec5a32f04b8440ee2f0ccf500c0326d5dfb Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Wed, 24 Jun 2026 10:22:32 -0400 Subject: [PATCH 62/68] Refresh alpha6 conformance coverage (#290) --- .github/workflows/interop_2026_07_28.yml | 57 ++++++++++++++++ .github/workflows/test_core.yml | 2 +- CHANGELOG.md | 9 ++- README.md | 1 + doc/interoperability.md | 21 +++--- doc/mcp-2026-07-28-rc.md | 19 ++++-- doc/spec-coverage-2026-07-28-rc.md | 65 +++++++++++++++++++ ...2026_07_28_rc_client_expected_failures.txt | 4 +- .../2026_07_28_rc_expected_failures.txt | 2 +- test/conformance/README.md | 8 +-- .../run_2025_server_conformance.dart | 2 +- .../run_2026_07_28_rc_client_conformance.dart | 2 +- .../run_2026_07_28_rc_server_conformance.dart | 5 +- test/interop/ts_2026_07_28_rc/README.md | 8 ++- test/interop/ts_2026_07_28_rc/package.json | 2 +- .../testing/run_ts_2026_07_28_rc_interop.dart | 9 ++- 16 files changed, 182 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/interop_2026_07_28.yml create mode 100644 doc/spec-coverage-2026-07-28-rc.md diff --git a/.github/workflows/interop_2026_07_28.yml b/.github/workflows/interop_2026_07_28.yml new file mode 100644 index 00000000..bb90fb4a --- /dev/null +++ b/.github/workflows/interop_2026_07_28.yml @@ -0,0 +1,57 @@ +name: Run MCP 2026-07-28 TypeScript Interop + +permissions: + contents: read + +on: + workflow_dispatch: + schedule: + - cron: '17 9 * * *' + pull_request: + paths: + - 'lib/**' + - 'test/conformance/**' + - 'test/interop/ts_2026_07_28_rc/**' + - 'tool/testing/run_ts_2026_07_28_rc_interop.dart' + - '.github/workflows/interop_2026_07_28.yml' + - 'pubspec.yaml' + - 'pubspec.lock' + push: + branches: + - dev/2026-07-28-rc + paths: + - 'lib/**' + - 'test/conformance/**' + - 'test/interop/ts_2026_07_28_rc/**' + - 'tool/testing/run_ts_2026_07_28_rc_interop.dart' + - '.github/workflows/interop_2026_07_28.yml' + - 'pubspec.yaml' + - 'pubspec.lock' + +jobs: + ts-2026-07-28-interop: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Dart + uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + + - name: Install Dart dependencies + run: dart pub get + + - name: Install TypeScript 2026-07-28 fixture dependencies + working-directory: test/interop/ts_2026_07_28_rc + run: npm ci + + - name: Run TypeScript 2026-07-28 interop fixture + run: dart run tool/testing/run_ts_2026_07_28_rc_interop.dart diff --git a/.github/workflows/test_core.yml b/.github/workflows/test_core.yml index 718e7742..62ee4ef8 100644 --- a/.github/workflows/test_core.yml +++ b/.github/workflows/test_core.yml @@ -51,7 +51,7 @@ jobs: - name: Run official MCP 2025 client conformance run: > - npx -y @modelcontextprotocol/conformance@0.2.0-alpha.5 client + npx -y @modelcontextprotocol/conformance@0.2.0-alpha.6 client --command "dart run test/conformance/mcp_2026_07_28_rc_client.dart" --suite all --spec-version 2025-11-25 diff --git a/CHANGELOG.md b/CHANGELOG.md index 460c2da7..19c180d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,15 @@ ### Conformance and interoperability - Updated official conformance gates to - `@modelcontextprotocol/conformance@0.2.0-alpha.5`. The 2026-07-28 RC server suite + `@modelcontextprotocol/conformance@0.2.0-alpha.6`. The 2026-07-28 RC server suite now has no expected failures; the 2026 client suite keeps only the upstream `json-schema-ref-no-deref` fixture gap expected. -- Re-pinned the manual TypeScript SDK 2026-07-28 RC interop fixture to +- Added a dedicated CI workflow for the TypeScript SDK 2026-07-28 RC preview + interop fixture on relevant PRs, `dev/2026-07-28-rc` pushes, daily schedule, + and manual dispatch. +- Added an MCP 2026-07-28 draft/RC spec coverage matrix that maps the opt-in + profile to official conformance, local tests, and TypeScript preview interop. +- Re-pinned the TypeScript SDK 2026-07-28 RC interop fixture to `pkg.pr.new` previews from the merged `v2-2026-07-28` branch head for both client and server packages. - Switched the reverse Dart preview client -> TypeScript preview server fixture diff --git a/README.md b/README.md index cb035ed8..db660c1c 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ for opt-in behavior, fallback rules, and draft-only APIs. - ๐Ÿงช **[SDK Interoperability Matrix](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/interoperability.md)** - Verified Dart/TypeScript and documented cross-SDK scenarios - โœ… **[MCP 2025-11-25 Spec Coverage Matrix](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/spec-coverage-2025-11-25.md)** - Auditable coverage map with CLI conformance cases and known gaps +- ๐Ÿงช **[MCP 2026-07-28 Draft/RC Spec Coverage Matrix](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/spec-coverage-2026-07-28-rc.md)** - Opt-in draft/RC coverage map across official conformance and TypeScript preview interop - ๐Ÿงญ **[MCP 2026-07-28 Draft/RC Transition Guide](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/mcp-2026-07-28-rc.md)** - Opt-in profile, fallback behavior, and draft-only APIs - ๐Ÿ”’ **[Transport Security Recipes](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/transports.md#dns-rebinding-protection)** - Host/Origin allowlists, OAuth layering, and compatibility-toggle trade-offs - ๐Ÿ“ฑ **[Flutter Recipes](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/flutter-recipes.md)** - Flutter Web, mobile, and desktop host/client guidance diff --git a/doc/interoperability.md b/doc/interoperability.md index 48d04bb9..418cd36b 100644 --- a/doc/interoperability.md +++ b/doc/interoperability.md @@ -4,6 +4,8 @@ This page tracks the interoperability evidence that `mcp_dart` currently carries For requirement-level MCP 2025-11-25 coverage, see the [`spec-coverage-2025-11-25.md`](spec-coverage-2025-11-25.md) matrix. +For MCP 2026-07-28 draft/RC coverage, see the +[`spec-coverage-2026-07-28-rc.md`](spec-coverage-2026-07-28-rc.md) matrix. ## How to read the matrix @@ -21,8 +23,8 @@ For requirement-level MCP 2025-11-25 coverage, see the | Dart client -> TypeScript SDK server | Streamable HTTP | `2025-11-25` | [`test/interop/dart_client_with_ts_server_test.dart`](../test/interop/dart_client_with_ts_server_test.dart), [`test/interop/ts/`](../test/interop/ts/) | Verified | Covers tool calls and stale preconfigured session-id recovery. | | TypeScript SDK client -> Dart server | stdio | `2025-11-25` | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/test_dart_server.dart`](../test/interop/test_dart_server.dart) | Verified | Runs the compiled TypeScript client fixture against a Dart server process and checks that an official TS client can list tools immediately after the lifecycle handshake. | | TypeScript SDK client -> Dart server | Streamable HTTP | `2025-11-25` | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/test_dart_server.dart`](../test/interop/test_dart_server.dart) | Verified | Includes official TS Streamable HTTP client lifecycle coverage, pre-`initialized` operation rejection, GET SSE streams, and `Last-Event-ID` replay behavior. | -| TypeScript SDK preview client -> Dart server | Streamable HTTP | `2026-07-28` draft/RC | [`test/interop/ts_2026_07_28_rc/`](../test/interop/ts_2026_07_28_rc/), [`tool/testing/run_ts_2026_07_28_rc_interop.dart`](../tool/testing/run_ts_2026_07_28_rc_interop.dart) | Experimental manual check | Uses pinned `pkg.pr.new` previews from the TypeScript SDK `v2-2026-07-28` branch head. Covers modern negotiation, cache metadata, `tools/list`, `tools/call`, `x-mcp-header` mirroring, raw header and unsupported-version rejection, removed core RPC rejection, progress notifications, `subscriptions/listen`, and HTTP SSE cancellation against the Dart 2026-07-28 RC conformance server. Not a CI gate yet. | -| Dart preview client -> TypeScript SDK preview server | Streamable HTTP | `2026-07-28` draft/RC | [`test/interop/ts_2026_07_28_rc/src/server.mjs`](../test/interop/ts_2026_07_28_rc/src/server.mjs), [`tool/testing/run_ts_2026_07_28_rc_interop.dart`](../tool/testing/run_ts_2026_07_28_rc_interop.dart) | Experimental manual check | Uses the pinned TypeScript SDK server preview through its `createMcpHandler` entry and covers `server/discover` negotiation, `tools/list`, and `tools/call`. Not a CI gate yet. | +| TypeScript SDK preview client -> Dart server | Streamable HTTP | `2026-07-28` draft/RC | [`test/interop/ts_2026_07_28_rc/`](../test/interop/ts_2026_07_28_rc/), [`tool/testing/run_ts_2026_07_28_rc_interop.dart`](../tool/testing/run_ts_2026_07_28_rc_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Automated preview check | Uses pinned `pkg.pr.new` previews from the TypeScript SDK `v2-2026-07-28` branch head. Covers modern negotiation, cache metadata, `tools/list`, `tools/call`, `x-mcp-header` mirroring, raw header and unsupported-version rejection, removed core RPC rejection, progress notifications, `subscriptions/listen`, and HTTP SSE cancellation against the Dart 2026-07-28 RC conformance server. | +| Dart preview client -> TypeScript SDK preview server | Streamable HTTP | `2026-07-28` draft/RC | [`test/interop/ts_2026_07_28_rc/src/server.mjs`](../test/interop/ts_2026_07_28_rc/src/server.mjs), [`tool/testing/run_ts_2026_07_28_rc_interop.dart`](../tool/testing/run_ts_2026_07_28_rc_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Automated preview check | Uses the pinned TypeScript SDK server preview through its `createMcpHandler` entry and covers `server/discover` negotiation, `tools/list`, and `tools/call`. | | Dart client -> Python MCP server | stdio | Server-dependent | [`doc/transports.md`](transports.md#connect-to-python-server) | Documented recipe | The transport can spawn Python servers over stdio, but this repo does not yet include an automated Python SDK fixture. | | Flutter/Web client -> Dart server | Streamable HTTP | `2025-11-25` | [`example/flutter_http_client/`](../example/flutter_http_client/), [`doc/flutter-recipes.md`](flutter-recipes.md) | Documented recipe | Flutter Web cannot spawn stdio servers; use Streamable HTTP or another browser-safe transport. | | MCP Apps host/client metadata | stdio or Streamable HTTP | `2025-11-25` plus `io.modelcontextprotocol/ui` extension | [`doc/mcp-apps.md`](mcp-apps.md), [`example/mcp_apps_helpers_server.dart`](../example/mcp_apps_helpers_server.dart), [`test/types/mcp_ui_test.dart`](../test/types/mcp_ui_test.dart), [`test/server/mcp_ui_test.dart`](../test/server/mcp_ui_test.dart) | Verified | Verified coverage is limited to SDK metadata helpers, serialization, and checked-in examples; host rendering behavior varies by host, so verify UI metadata against your target host. | @@ -44,9 +46,8 @@ dart test --tags interop If the compiled fixtures are missing, local test runs skip the interop groups; CI should fail when required fixtures are unavailable. -The TypeScript 2026-07-28 RC fixture is manual while the upstream SDK support remains -unreleased and pinned to `pkg.pr.new` previews from the TypeScript SDK -`v2-2026-07-28` branch: +The TypeScript 2026-07-28 RC fixture uses unreleased `pkg.pr.new` previews from +the TypeScript SDK `v2-2026-07-28` branch: ```bash # From repository root @@ -60,6 +61,10 @@ This starts the Dart 2026-07-28 RC conformance server, runs the pinned TypeScrip preview client against it, then runs the reverse Dart preview client smoke check against the TypeScript preview server. +CI also runs this fixture in the dedicated +`Run MCP 2026-07-28 TypeScript Interop` workflow for relevant PRs, +`dev/2026-07-28-rc` pushes, daily scheduled drift checks, and manual dispatch. + The CLI spec conformance gate covers raw-wire negative cases that do not need a cross-SDK fixture, including stable MCP 2025-11-25 checks and MCP 2026-07-28 RC stateless/discovery/task-extension checks: @@ -83,9 +88,9 @@ When adding a new interoperability claim: ## Known gaps worth tracking - Automated Python SDK fixture coverage. -- CI promotion for the TypeScript 2026-07-28 RC interop fixture after the TypeScript - SDK publishes a 2026-07-28-compatible alpha package instead of requiring - `pkg.pr.new` previews. +- Re-pin the TypeScript 2026-07-28 RC interop fixture to a published upstream + alpha package once the TypeScript SDK no longer requires `pkg.pr.new` + previews. - Broader reverse-path TypeScript preview server coverage beyond discovery, `tools/list`, and `tools/call`. - Host-specific MCP Apps rendering compatibility notes. diff --git a/doc/mcp-2026-07-28-rc.md b/doc/mcp-2026-07-28-rc.md index 5cc79182..2924fb9f 100644 --- a/doc/mcp-2026-07-28-rc.md +++ b/doc/mcp-2026-07-28-rc.md @@ -130,27 +130,34 @@ Before creating follow-up dev tags from `dev/2026-07-28-rc`, run: ```sh dart analyze dart run test/conformance/run_2025_server_conformance.dart -npx -y @modelcontextprotocol/conformance@0.2.0-alpha.5 client \ +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.6 client \ --command "dart run test/conformance/mcp_2026_07_28_rc_client.dart" \ --suite all \ --spec-version 2025-11-25 dart run test/conformance/run_2026_07_28_rc_server_conformance.dart dart run test/conformance/run_2026_07_28_rc_client_conformance.dart +cd test/interop/ts_2026_07_28_rc +npm ci +cd ../../.. +dart run tool/testing/run_ts_2026_07_28_rc_interop.dart dart pub publish --dry-run dart pub global run pana --no-warning dart run tool/validate_cli_publish.dart ``` The `run_2026_07_28_rc_server_conformance.dart` gate runs the full -`@modelcontextprotocol/conformance@0.2.0-alpha.5` server scenario list for +`@modelcontextprotocol/conformance@0.2.0-alpha.6` server scenario list for `--spec-version 2026-07-28`, including the stable-style tool, resource, prompt, completion, and JSON Schema scenarios that the alpha package tags for the RC. -For cross-SDK smoke coverage against the TypeScript SDK 2026 preview client, -run the manual fixture documented in +For cross-SDK smoke coverage against the TypeScript SDK 2026 preview packages, +run the fixture documented in [`doc/interoperability.md`](interoperability.md#running-interop-checks-locally). -Keep that fixture out of CI until upstream publishes a 2026-07-28-compatible alpha -package instead of requiring a `pkg.pr.new` branch preview. +CI also runs it in the dedicated `Run MCP 2026-07-28 TypeScript Interop` +workflow on relevant PRs, `dev/2026-07-28-rc` pushes, a daily schedule, and +manual dispatch. Keep it pinned to the draft behavior rather than TypeScript +preview behavior alone; re-pin from `pkg.pr.new` to a published TypeScript SDK +alpha when upstream provides one. For dev packages, keep package documentation links pointed at `dev/2026-07-28-rc` until the draft work is ready to merge back to `main`. diff --git a/doc/spec-coverage-2026-07-28-rc.md b/doc/spec-coverage-2026-07-28-rc.md new file mode 100644 index 00000000..0c70330e --- /dev/null +++ b/doc/spec-coverage-2026-07-28-rc.md @@ -0,0 +1,65 @@ +# MCP 2026-07-28 Draft/RC Spec Coverage Matrix + +This matrix maps high-risk MCP `2026-07-28` draft/RC requirements to checked-in +`mcp_dart` coverage. MCP `2025-11-25` remains the default runtime profile; this +matrix only applies when callers opt into `McpProtocol.preview2026` or +`McpProtocol.require2026`. + +The protocol is still draft/RC. Treat this as release-prep evidence for the +`dev/2026-07-28-rc` branch, not as a final-spec guarantee. + +## Gates + +Run the official conformance gates from the repository root: + +```bash +dart run test/conformance/run_2025_server_conformance.dart +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.6 client \ + --command "dart run test/conformance/mcp_2026_07_28_rc_client.dart" \ + --suite all \ + --spec-version 2025-11-25 +dart run test/conformance/run_2026_07_28_rc_server_conformance.dart +dart run test/conformance/run_2026_07_28_rc_client_conformance.dart +``` + +Run the TypeScript preview interop gate from the repository root: + +```bash +cd test/interop/ts_2026_07_28_rc +npm ci +cd ../../.. +dart run tool/testing/run_ts_2026_07_28_rc_interop.dart +``` + +CI runs the official conformance gates in the core workflow. The +`Run MCP 2026-07-28 TypeScript Interop` workflow runs the TypeScript preview +interop fixture on relevant PRs, `dev/2026-07-28-rc` pushes, a daily schedule, +and manual dispatch. + +## Matrix + +| Spec area | Draft source | Requirement tracked here | Local coverage | Cross-SDK coverage | Official conformance | Status | +| --- | --- | --- | --- | --- | --- | --- | +| Opt-in profile and stable default | [Versioning and compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Stable MCP `2025-11-25` remains default, while 2026 behavior is selected explicitly with preview or require profiles. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`doc/mcp-2026-07-28-rc.md`](mcp-2026-07-28-rc.md) | TypeScript preview interop uses explicit 2026 clients and servers only. | 2025 and 2026 conformance both run in CI. | Verified | +| Version negotiation and discovery | [Discovery](https://modelcontextprotocol.io/specification/draft/server/discover), [Versioning](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Servers implement `server/discover`, advertise supported versions and capabilities, reject unsupported versions with draft error data, and clients retry or fall back according to transport-era rules. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart), [`test/conformance/mcp_2026_07_28_rc_client.dart`](../test/conformance/mcp_2026_07_28_rc_client.dart) | [`tool/testing/run_ts_2026_07_28_rc_interop.dart`](../tool/testing/run_ts_2026_07_28_rc_interop.dart) validates TypeScript preview client -> Dart server and Dart preview client -> TypeScript preview server discovery. | `protocol-version`, `server/discover`, and client negotiation scenarios in alpha.6. | Verified | +| Stateless request metadata | [Overview](https://modelcontextprotocol.io/specification/draft/basic), [Versioning](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Every 2026 request carries protocol version, client identity, and client capabilities in `_meta`; servers do not infer protocol state from a prior request. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | TypeScript preview client fixture exercises normal request paths with 2026 metadata. | `stateless` and `stateless-http` scenarios in alpha.6. | Verified | +| Streamable HTTP routing headers | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog), [Transports](https://modelcontextprotocol.io/specification/draft/basic/transports) | 2026 HTTP POST requests include required protocol, method, name, and parameter-routing headers; mismatches reject with draft header errors. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | TypeScript preview client fixture validates `x-mcp-header` mirroring and raw header rejection against the Dart server. | `stateless-http.requires-routing-headers`, `stateless-http.validates-parameter-headers`, and related alpha.6 cases. | Verified | +| Removed session and resumability behavior | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog) | 2026 Streamable HTTP omits protocol-level sessions, rejects removed GET/DELETE behaviors, rejects JSON-RPC batches, and treats closed SSE response streams as request cancellation without legacy redelivery. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_mcp_server_test.dart`](../test/server/streamable_mcp_server_test.dart) | TypeScript preview fixture closes the Dart SSE response stream and verifies no legacy `notifications/cancelled` side effect is required. | `stateless-http.rejects-non-post-methods`, `stateless-http.rejects-batch-payloads`, and related alpha.6 cases. | Verified | +| Cacheable results and deterministic lists | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog), [Discovery](https://modelcontextprotocol.io/specification/draft/server/discover) | `server/discover`, list, and read responses include `resultType`, `ttlMs`, and `cacheScope`; stateless `tools/list` is deterministic and omits stable-only tool execution metadata. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart) | TypeScript preview client fixture checks discovery cache metadata and `tools/list` cache metadata. | Cacheable-result and tools-list scenarios in alpha.6. | Verified | +| Tools and JSON Schema 2020-12 | [Tools](https://modelcontextprotocol.io/specification/draft/server/tools), [Overview JSON Schema usage](https://modelcontextprotocol.io/specification/draft/basic) | Tool schemas preserve JSON Schema 2020-12 constructs, including nested boolean schemas; stable root-object compatibility remains intact for 2025 behavior. | [`test/tool_schema_test.dart`](../test/tool_schema_test.dart), [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart) | TypeScript preview fixture validates `tools/list` and `tools/call`; deeper schema semantics are covered by local and conformance tests. | 2026 server suite is green; 2026 client suite keeps the upstream `json-schema-ref-no-deref` fixture gap expected. | Verified with one upstream client fixture gap | +| MRTR and elicitation | [Message patterns](https://modelcontextprotocol.io/specification/draft/basic) | 2026 `input_required` results are emitted only for supported requests and require advertised client capabilities. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/elicitation_test.dart`](../test/elicitation_test.dart) | TypeScript preview client fixture completes a 2026 `input_required` retry flow against the Dart server. | `mrtr` scenarios in alpha.6. | Verified | +| Subscriptions | [Subscriptions](https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions) | `subscriptions/listen` acknowledges before list-change notifications, filters unsupported notification types, and correlates notifications through `io.modelcontextprotocol/subscriptionId`. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart) | TypeScript preview fixture validates `subscriptions/listen` acknowledgment and list-change notification correlation against the Dart server. | Subscription scenarios in alpha.6. | Verified | +| Request-scoped logging and removed core RPCs | [Logging](https://modelcontextprotocol.io/specification/draft/server/utilities/logging), [Key changes](https://modelcontextprotocol.io/specification/draft/changelog) | 2026 stateless requests use request-scoped logging metadata, and removed stable-era core RPCs/notifications are rejected in the 2026 profile. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/server_advanced_test.dart`](../test/server/server_advanced_test.dart) | TypeScript preview fixture validates raw removed-RPC rejection against the Dart server. | Removed-RPC and logging scenarios in alpha.6. | Verified | +| Draft-only public APIs | [Schema reference](https://modelcontextprotocol.io/specification/draft/schema) | APIs that are useful only for 2026, such as non-object structured tool output and 2026 protocol profiles, are documented as draft/RC APIs and do not change stable defaults. | [`doc/mcp-2026-07-28-rc.md`](mcp-2026-07-28-rc.md), public dartdoc on protocol profiles and draft-only helpers. | Not cross-SDK specific. | Covered indirectly by 2026 conformance and local parser/serializer tests. | Verified | + +## Known Gaps + +- The official conformance package is still alpha. The 2026 client suite keeps + `json-schema-ref-no-deref` expected-failed because the alpha.6 mock server for + that scenario still behaves like a stable-only server. +- The TypeScript 2026-07-28 fixture depends on `pkg.pr.new` preview artifacts + from the TypeScript SDK branch. Keep the CI workflow, but re-pin it to a + published alpha package once upstream provides one. +- The reverse Dart preview client -> TypeScript preview server path currently + covers discovery, `tools/list`, and `tools/call`. Broader reverse-path + coverage should follow as the TypeScript preview server surface stabilizes. diff --git a/test/conformance/2026_07_28_rc_client_expected_failures.txt b/test/conformance/2026_07_28_rc_client_expected_failures.txt index 82fbd945..db45d99e 100644 --- a/test/conformance/2026_07_28_rc_client_expected_failures.txt +++ b/test/conformance/2026_07_28_rc_client_expected_failures.txt @@ -1,10 +1,10 @@ -# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.5 +# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.6 # against the 2026-07-28 RC/DRAFT client suite. # # Keep this list scenario-based so the baseline is easy to review. When a # scenario turns green, remove it from this file in the same PR as the fix. # -# Upstream alpha.5 fixture gap: this scenario's mock server still rejects +# Upstream alpha.6 fixture gap: this scenario's mock server still rejects # 2026-07-28 with HTTP 400 and advertises only stable protocol versions. # Keep it expected-fail until the conformance fixture is draft-capable. json-schema-ref-no-deref diff --git a/test/conformance/2026_07_28_rc_expected_failures.txt b/test/conformance/2026_07_28_rc_expected_failures.txt index 8e11852f..e1e36f25 100644 --- a/test/conformance/2026_07_28_rc_expected_failures.txt +++ b/test/conformance/2026_07_28_rc_expected_failures.txt @@ -1,4 +1,4 @@ -# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.5 +# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.6 # against the full 2026-07-28 RC/DRAFT server suite. # # Keep this list scenario-based so the baseline is easy to review. When a diff --git a/test/conformance/README.md b/test/conformance/README.md index 45f8e08e..0719ae33 100644 --- a/test/conformance/README.md +++ b/test/conformance/README.md @@ -26,14 +26,14 @@ dart run test/conformance/run_2025_server_conformance.dart ``` The runner starts `mcp_2025_server.dart`, runs -`@modelcontextprotocol/conformance@0.2.0-alpha.5 server --suite all +`@modelcontextprotocol/conformance@0.2.0-alpha.6 server --suite all --spec-version 2025-11-25`, and writes artifacts under `.dart_tool/conformance/2025_server/`. Run the stable client suite from the repository root: ```bash -npx -y @modelcontextprotocol/conformance@0.2.0-alpha.5 client \ +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.6 client \ --command "dart run test/conformance/mcp_2026_07_28_rc_client.dart" \ --suite all \ --spec-version 2025-11-25 \ @@ -55,14 +55,14 @@ dart run test/conformance/run_2026_07_28_rc_server_conformance.dart The runner starts a local `StreamableMcpServer` in default Streamable HTTP SSE response mode, runs the full `2026-07-28` server scenario list from -`@modelcontextprotocol/conformance@0.2.0-alpha.5` one by one with `--suite all` +`@modelcontextprotocol/conformance@0.2.0-alpha.6` one by one with `--suite all` and `--spec-version 2026-07-28`, and writes per-run artifacts under `.dart_tool/conformance/2026_07_28_rc/`. Expected failures live in `2026_07_28_rc_expected_failures.txt`. When a scenario is fixed, remove it from that file so the baseline remains useful. -As of `@modelcontextprotocol/conformance@0.2.0-alpha.5`, the full 2026-07-28 RC server +As of `@modelcontextprotocol/conformance@0.2.0-alpha.6`, the full 2026-07-28 RC server suite has no expected failures against the Dart fixture. Run the current client baseline from the repository root: diff --git a/test/conformance/run_2025_server_conformance.dart b/test/conformance/run_2025_server_conformance.dart index 24cf4660..49b57865 100644 --- a/test/conformance/run_2025_server_conformance.dart +++ b/test/conformance/run_2025_server_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.5'; + '@modelcontextprotocol/conformance@0.2.0-alpha.6'; const _defaultTimeout = Duration(seconds: 60); Future main(List args) async { diff --git a/test/conformance/run_2026_07_28_rc_client_conformance.dart b/test/conformance/run_2026_07_28_rc_client_conformance.dart index 7ea6e841..7915e1a4 100644 --- a/test/conformance/run_2026_07_28_rc_client_conformance.dart +++ b/test/conformance/run_2026_07_28_rc_client_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.5'; + '@modelcontextprotocol/conformance@0.2.0-alpha.6'; const _defaultTimeout = Duration(seconds: 30); const _draftClientScenarios = [ diff --git a/test/conformance/run_2026_07_28_rc_server_conformance.dart b/test/conformance/run_2026_07_28_rc_server_conformance.dart index 79fd27ab..8ff88e64 100644 --- a/test/conformance/run_2026_07_28_rc_server_conformance.dart +++ b/test/conformance/run_2026_07_28_rc_server_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.5'; + '@modelcontextprotocol/conformance@0.2.0-alpha.6'; const _defaultTimeout = Duration(seconds: 25); const _serverScenarios = [ @@ -406,7 +406,8 @@ class _Options { int? port; String? scenario; String? outputDir; - var expectedFailuresPath = 'test/conformance/2026_07_28_rc_expected_failures.txt'; + var expectedFailuresPath = + 'test/conformance/2026_07_28_rc_expected_failures.txt'; var conformancePackage = _defaultConformancePackage; var timeout = _defaultTimeout; diff --git a/test/interop/ts_2026_07_28_rc/README.md b/test/interop/ts_2026_07_28_rc/README.md index 86a111b1..00a38e3a 100644 --- a/test/interop/ts_2026_07_28_rc/README.md +++ b/test/interop/ts_2026_07_28_rc/README.md @@ -64,6 +64,8 @@ only the draft-permitted primitive types: `string`, `integer`, and `boolean`. When TypeScript preview behavior conflicts with the draft, keep the draft as the assertion source and document the preview gap near the test. -Keep this as a manual, non-blocking check until the TypeScript SDK publishes a -stable 2026-07-28-compatible alpha package instead of requiring `pkg.pr.new` preview -artifacts. +CI runs this fixture in the dedicated +`Run MCP 2026-07-28 TypeScript Interop` workflow for relevant PRs, +`dev/2026-07-28-rc` pushes, daily scheduled drift checks, and manual dispatch. +Keep the fixture pinned to a published TypeScript SDK alpha once upstream no +longer requires `pkg.pr.new` preview artifacts. diff --git a/test/interop/ts_2026_07_28_rc/package.json b/test/interop/ts_2026_07_28_rc/package.json index c85d9f4a..93f537a3 100644 --- a/test/interop/ts_2026_07_28_rc/package.json +++ b/test/interop/ts_2026_07_28_rc/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "Manual TypeScript SDK 2026-07-28 RC interop fixture for mcp_dart.", + "description": "TypeScript SDK 2026-07-28 RC interop fixture for mcp_dart.", "scripts": { "client": "node src/client.mjs" }, diff --git a/tool/testing/run_ts_2026_07_28_rc_interop.dart b/tool/testing/run_ts_2026_07_28_rc_interop.dart index f3492e17..6fed3206 100644 --- a/tool/testing/run_ts_2026_07_28_rc_interop.dart +++ b/tool/testing/run_ts_2026_07_28_rc_interop.dart @@ -103,7 +103,9 @@ Future _runTsClientAgainstDartServer( await Future.wait([clientStdout, clientStderr]); if (clientExit != 0) { - throw StateError('TypeScript 2026-07-28 RC client exited with $clientExit'); + throw StateError( + 'TypeScript 2026-07-28 RC client exited with $clientExit', + ); } } finally { await _terminate(server); @@ -151,7 +153,10 @@ Future _runDartClientAgainstTsServer( Future _exerciseDartClient(String url) async { final transport = StreamableHttpClientTransport(Uri.parse(url)); final client = McpClient( - const Implementation(name: 'mcp-dart-2026-07-28-rc-client', version: '0.0.0'), + const Implementation( + name: 'mcp-dart-2026-07-28-rc-client', + version: '0.0.0', + ), options: const McpClientOptions( protocol: McpProtocol.preview2026, capabilities: ClientCapabilities(), From 396beb6393bb71b17f080e1cda6fb65d946058eb Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Fri, 26 Jun 2026 14:12:35 -0400 Subject: [PATCH 63/68] Update conformance alpha7 baseline (#291) Update MCP conformance alpha7 references and expected failure baselines. --- .github/workflows/test_core.yml | 2 +- CHANGELOG.md | 2 +- doc/mcp-2026-07-28-rc.md | 4 ++-- doc/spec-coverage-2026-07-28-rc.md | 20 +++++++++---------- ...2026_07_28_rc_client_expected_failures.txt | 4 ++-- .../2026_07_28_rc_expected_failures.txt | 2 +- test/conformance/README.md | 8 ++++---- .../run_2025_server_conformance.dart | 2 +- .../run_2026_07_28_rc_client_conformance.dart | 2 +- .../run_2026_07_28_rc_server_conformance.dart | 2 +- 10 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/test_core.yml b/.github/workflows/test_core.yml index 62ee4ef8..c1f47e62 100644 --- a/.github/workflows/test_core.yml +++ b/.github/workflows/test_core.yml @@ -51,7 +51,7 @@ jobs: - name: Run official MCP 2025 client conformance run: > - npx -y @modelcontextprotocol/conformance@0.2.0-alpha.6 client + npx -y @modelcontextprotocol/conformance@0.2.0-alpha.7 client --command "dart run test/conformance/mcp_2026_07_28_rc_client.dart" --suite all --spec-version 2025-11-25 diff --git a/CHANGELOG.md b/CHANGELOG.md index 51b3ceaf..d80b10ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ### Conformance and interoperability - Updated official conformance gates to - `@modelcontextprotocol/conformance@0.2.0-alpha.6`. The 2026-07-28 RC server suite + `@modelcontextprotocol/conformance@0.2.0-alpha.7`. The 2026-07-28 RC server suite now has no expected failures; the 2026 client suite keeps only the upstream `json-schema-ref-no-deref` fixture gap expected. - Added a dedicated CI workflow for the TypeScript SDK 2026-07-28 RC preview diff --git a/doc/mcp-2026-07-28-rc.md b/doc/mcp-2026-07-28-rc.md index 2924fb9f..3ffdaa86 100644 --- a/doc/mcp-2026-07-28-rc.md +++ b/doc/mcp-2026-07-28-rc.md @@ -130,7 +130,7 @@ Before creating follow-up dev tags from `dev/2026-07-28-rc`, run: ```sh dart analyze dart run test/conformance/run_2025_server_conformance.dart -npx -y @modelcontextprotocol/conformance@0.2.0-alpha.6 client \ +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.7 client \ --command "dart run test/conformance/mcp_2026_07_28_rc_client.dart" \ --suite all \ --spec-version 2025-11-25 @@ -146,7 +146,7 @@ dart run tool/validate_cli_publish.dart ``` The `run_2026_07_28_rc_server_conformance.dart` gate runs the full -`@modelcontextprotocol/conformance@0.2.0-alpha.6` server scenario list for +`@modelcontextprotocol/conformance@0.2.0-alpha.7` server scenario list for `--spec-version 2026-07-28`, including the stable-style tool, resource, prompt, completion, and JSON Schema scenarios that the alpha package tags for the RC. diff --git a/doc/spec-coverage-2026-07-28-rc.md b/doc/spec-coverage-2026-07-28-rc.md index 0c70330e..4452a7c3 100644 --- a/doc/spec-coverage-2026-07-28-rc.md +++ b/doc/spec-coverage-2026-07-28-rc.md @@ -14,7 +14,7 @@ Run the official conformance gates from the repository root: ```bash dart run test/conformance/run_2025_server_conformance.dart -npx -y @modelcontextprotocol/conformance@0.2.0-alpha.6 client \ +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.7 client \ --command "dart run test/conformance/mcp_2026_07_28_rc_client.dart" \ --suite all \ --spec-version 2025-11-25 @@ -41,21 +41,21 @@ and manual dispatch. | Spec area | Draft source | Requirement tracked here | Local coverage | Cross-SDK coverage | Official conformance | Status | | --- | --- | --- | --- | --- | --- | --- | | Opt-in profile and stable default | [Versioning and compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Stable MCP `2025-11-25` remains default, while 2026 behavior is selected explicitly with preview or require profiles. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`doc/mcp-2026-07-28-rc.md`](mcp-2026-07-28-rc.md) | TypeScript preview interop uses explicit 2026 clients and servers only. | 2025 and 2026 conformance both run in CI. | Verified | -| Version negotiation and discovery | [Discovery](https://modelcontextprotocol.io/specification/draft/server/discover), [Versioning](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Servers implement `server/discover`, advertise supported versions and capabilities, reject unsupported versions with draft error data, and clients retry or fall back according to transport-era rules. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart), [`test/conformance/mcp_2026_07_28_rc_client.dart`](../test/conformance/mcp_2026_07_28_rc_client.dart) | [`tool/testing/run_ts_2026_07_28_rc_interop.dart`](../tool/testing/run_ts_2026_07_28_rc_interop.dart) validates TypeScript preview client -> Dart server and Dart preview client -> TypeScript preview server discovery. | `protocol-version`, `server/discover`, and client negotiation scenarios in alpha.6. | Verified | -| Stateless request metadata | [Overview](https://modelcontextprotocol.io/specification/draft/basic), [Versioning](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Every 2026 request carries protocol version, client identity, and client capabilities in `_meta`; servers do not infer protocol state from a prior request. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | TypeScript preview client fixture exercises normal request paths with 2026 metadata. | `stateless` and `stateless-http` scenarios in alpha.6. | Verified | -| Streamable HTTP routing headers | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog), [Transports](https://modelcontextprotocol.io/specification/draft/basic/transports) | 2026 HTTP POST requests include required protocol, method, name, and parameter-routing headers; mismatches reject with draft header errors. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | TypeScript preview client fixture validates `x-mcp-header` mirroring and raw header rejection against the Dart server. | `stateless-http.requires-routing-headers`, `stateless-http.validates-parameter-headers`, and related alpha.6 cases. | Verified | -| Removed session and resumability behavior | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog) | 2026 Streamable HTTP omits protocol-level sessions, rejects removed GET/DELETE behaviors, rejects JSON-RPC batches, and treats closed SSE response streams as request cancellation without legacy redelivery. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_mcp_server_test.dart`](../test/server/streamable_mcp_server_test.dart) | TypeScript preview fixture closes the Dart SSE response stream and verifies no legacy `notifications/cancelled` side effect is required. | `stateless-http.rejects-non-post-methods`, `stateless-http.rejects-batch-payloads`, and related alpha.6 cases. | Verified | -| Cacheable results and deterministic lists | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog), [Discovery](https://modelcontextprotocol.io/specification/draft/server/discover) | `server/discover`, list, and read responses include `resultType`, `ttlMs`, and `cacheScope`; stateless `tools/list` is deterministic and omits stable-only tool execution metadata. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart) | TypeScript preview client fixture checks discovery cache metadata and `tools/list` cache metadata. | Cacheable-result and tools-list scenarios in alpha.6. | Verified | +| Version negotiation and discovery | [Discovery](https://modelcontextprotocol.io/specification/draft/server/discover), [Versioning](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Servers implement `server/discover`, advertise supported versions and capabilities, reject unsupported versions with draft error data, and clients retry or fall back according to transport-era rules. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart), [`test/conformance/mcp_2026_07_28_rc_client.dart`](../test/conformance/mcp_2026_07_28_rc_client.dart) | [`tool/testing/run_ts_2026_07_28_rc_interop.dart`](../tool/testing/run_ts_2026_07_28_rc_interop.dart) validates TypeScript preview client -> Dart server and Dart preview client -> TypeScript preview server discovery. | `protocol-version`, `server/discover`, and client negotiation scenarios in alpha.7. | Verified | +| Stateless request metadata | [Overview](https://modelcontextprotocol.io/specification/draft/basic), [Versioning](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Every 2026 request carries protocol version, client identity, and client capabilities in `_meta`; servers do not infer protocol state from a prior request. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | TypeScript preview client fixture exercises normal request paths with 2026 metadata. | `stateless` and `stateless-http` scenarios in alpha.7. | Verified | +| Streamable HTTP routing headers | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog), [Transports](https://modelcontextprotocol.io/specification/draft/basic/transports) | 2026 HTTP POST requests include required protocol, method, name, and parameter-routing headers; mismatches reject with draft header errors. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | TypeScript preview client fixture validates `x-mcp-header` mirroring and raw header rejection against the Dart server. | `stateless-http.requires-routing-headers`, `stateless-http.validates-parameter-headers`, and related alpha.7 cases. | Verified | +| Removed session and resumability behavior | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog) | 2026 Streamable HTTP omits protocol-level sessions, rejects removed GET/DELETE behaviors, rejects JSON-RPC batches, and treats closed SSE response streams as request cancellation without legacy redelivery. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_mcp_server_test.dart`](../test/server/streamable_mcp_server_test.dart) | TypeScript preview fixture closes the Dart SSE response stream and verifies no legacy `notifications/cancelled` side effect is required. | `stateless-http.rejects-non-post-methods`, `stateless-http.rejects-batch-payloads`, and related alpha.7 cases. | Verified | +| Cacheable results and deterministic lists | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog), [Discovery](https://modelcontextprotocol.io/specification/draft/server/discover) | `server/discover`, list, and read responses include `resultType`, `ttlMs`, and `cacheScope`; stateless `tools/list` is deterministic and omits stable-only tool execution metadata. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart) | TypeScript preview client fixture checks discovery cache metadata and `tools/list` cache metadata. | Cacheable-result and tools-list scenarios in alpha.7. | Verified | | Tools and JSON Schema 2020-12 | [Tools](https://modelcontextprotocol.io/specification/draft/server/tools), [Overview JSON Schema usage](https://modelcontextprotocol.io/specification/draft/basic) | Tool schemas preserve JSON Schema 2020-12 constructs, including nested boolean schemas; stable root-object compatibility remains intact for 2025 behavior. | [`test/tool_schema_test.dart`](../test/tool_schema_test.dart), [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart) | TypeScript preview fixture validates `tools/list` and `tools/call`; deeper schema semantics are covered by local and conformance tests. | 2026 server suite is green; 2026 client suite keeps the upstream `json-schema-ref-no-deref` fixture gap expected. | Verified with one upstream client fixture gap | -| MRTR and elicitation | [Message patterns](https://modelcontextprotocol.io/specification/draft/basic) | 2026 `input_required` results are emitted only for supported requests and require advertised client capabilities. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/elicitation_test.dart`](../test/elicitation_test.dart) | TypeScript preview client fixture completes a 2026 `input_required` retry flow against the Dart server. | `mrtr` scenarios in alpha.6. | Verified | -| Subscriptions | [Subscriptions](https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions) | `subscriptions/listen` acknowledges before list-change notifications, filters unsupported notification types, and correlates notifications through `io.modelcontextprotocol/subscriptionId`. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart) | TypeScript preview fixture validates `subscriptions/listen` acknowledgment and list-change notification correlation against the Dart server. | Subscription scenarios in alpha.6. | Verified | -| Request-scoped logging and removed core RPCs | [Logging](https://modelcontextprotocol.io/specification/draft/server/utilities/logging), [Key changes](https://modelcontextprotocol.io/specification/draft/changelog) | 2026 stateless requests use request-scoped logging metadata, and removed stable-era core RPCs/notifications are rejected in the 2026 profile. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/server_advanced_test.dart`](../test/server/server_advanced_test.dart) | TypeScript preview fixture validates raw removed-RPC rejection against the Dart server. | Removed-RPC and logging scenarios in alpha.6. | Verified | +| MRTR and elicitation | [Message patterns](https://modelcontextprotocol.io/specification/draft/basic) | 2026 `input_required` results are emitted only for supported requests and require advertised client capabilities. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/elicitation_test.dart`](../test/elicitation_test.dart) | TypeScript preview client fixture completes a 2026 `input_required` retry flow against the Dart server. | `mrtr` scenarios in alpha.7. | Verified | +| Subscriptions | [Subscriptions](https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions) | `subscriptions/listen` acknowledges before list-change notifications, filters unsupported notification types, and correlates notifications through `io.modelcontextprotocol/subscriptionId`. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart) | TypeScript preview fixture validates `subscriptions/listen` acknowledgment and list-change notification correlation against the Dart server. | Subscription scenarios in alpha.7. | Verified | +| Request-scoped logging and removed core RPCs | [Logging](https://modelcontextprotocol.io/specification/draft/server/utilities/logging), [Key changes](https://modelcontextprotocol.io/specification/draft/changelog) | 2026 stateless requests use request-scoped logging metadata, and removed stable-era core RPCs/notifications are rejected in the 2026 profile. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/server_advanced_test.dart`](../test/server/server_advanced_test.dart) | TypeScript preview fixture validates raw removed-RPC rejection against the Dart server. | Removed-RPC and logging scenarios in alpha.7. | Verified | | Draft-only public APIs | [Schema reference](https://modelcontextprotocol.io/specification/draft/schema) | APIs that are useful only for 2026, such as non-object structured tool output and 2026 protocol profiles, are documented as draft/RC APIs and do not change stable defaults. | [`doc/mcp-2026-07-28-rc.md`](mcp-2026-07-28-rc.md), public dartdoc on protocol profiles and draft-only helpers. | Not cross-SDK specific. | Covered indirectly by 2026 conformance and local parser/serializer tests. | Verified | ## Known Gaps - The official conformance package is still alpha. The 2026 client suite keeps - `json-schema-ref-no-deref` expected-failed because the alpha.6 mock server for + `json-schema-ref-no-deref` expected-failed because the alpha.7 mock server for that scenario still behaves like a stable-only server. - The TypeScript 2026-07-28 fixture depends on `pkg.pr.new` preview artifacts from the TypeScript SDK branch. Keep the CI workflow, but re-pin it to a diff --git a/test/conformance/2026_07_28_rc_client_expected_failures.txt b/test/conformance/2026_07_28_rc_client_expected_failures.txt index db45d99e..fd91ef9e 100644 --- a/test/conformance/2026_07_28_rc_client_expected_failures.txt +++ b/test/conformance/2026_07_28_rc_client_expected_failures.txt @@ -1,10 +1,10 @@ -# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.6 +# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.7 # against the 2026-07-28 RC/DRAFT client suite. # # Keep this list scenario-based so the baseline is easy to review. When a # scenario turns green, remove it from this file in the same PR as the fix. # -# Upstream alpha.6 fixture gap: this scenario's mock server still rejects +# Upstream alpha.7 fixture gap: this scenario's mock server still rejects # 2026-07-28 with HTTP 400 and advertises only stable protocol versions. # Keep it expected-fail until the conformance fixture is draft-capable. json-schema-ref-no-deref diff --git a/test/conformance/2026_07_28_rc_expected_failures.txt b/test/conformance/2026_07_28_rc_expected_failures.txt index e1e36f25..20a0c852 100644 --- a/test/conformance/2026_07_28_rc_expected_failures.txt +++ b/test/conformance/2026_07_28_rc_expected_failures.txt @@ -1,4 +1,4 @@ -# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.6 +# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.7 # against the full 2026-07-28 RC/DRAFT server suite. # # Keep this list scenario-based so the baseline is easy to review. When a diff --git a/test/conformance/README.md b/test/conformance/README.md index 0719ae33..2f494251 100644 --- a/test/conformance/README.md +++ b/test/conformance/README.md @@ -26,14 +26,14 @@ dart run test/conformance/run_2025_server_conformance.dart ``` The runner starts `mcp_2025_server.dart`, runs -`@modelcontextprotocol/conformance@0.2.0-alpha.6 server --suite all +`@modelcontextprotocol/conformance@0.2.0-alpha.7 server --suite all --spec-version 2025-11-25`, and writes artifacts under `.dart_tool/conformance/2025_server/`. Run the stable client suite from the repository root: ```bash -npx -y @modelcontextprotocol/conformance@0.2.0-alpha.6 client \ +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.7 client \ --command "dart run test/conformance/mcp_2026_07_28_rc_client.dart" \ --suite all \ --spec-version 2025-11-25 \ @@ -55,14 +55,14 @@ dart run test/conformance/run_2026_07_28_rc_server_conformance.dart The runner starts a local `StreamableMcpServer` in default Streamable HTTP SSE response mode, runs the full `2026-07-28` server scenario list from -`@modelcontextprotocol/conformance@0.2.0-alpha.6` one by one with `--suite all` +`@modelcontextprotocol/conformance@0.2.0-alpha.7` one by one with `--suite all` and `--spec-version 2026-07-28`, and writes per-run artifacts under `.dart_tool/conformance/2026_07_28_rc/`. Expected failures live in `2026_07_28_rc_expected_failures.txt`. When a scenario is fixed, remove it from that file so the baseline remains useful. -As of `@modelcontextprotocol/conformance@0.2.0-alpha.6`, the full 2026-07-28 RC server +As of `@modelcontextprotocol/conformance@0.2.0-alpha.7`, the full 2026-07-28 RC server suite has no expected failures against the Dart fixture. Run the current client baseline from the repository root: diff --git a/test/conformance/run_2025_server_conformance.dart b/test/conformance/run_2025_server_conformance.dart index 49b57865..b77d0a7a 100644 --- a/test/conformance/run_2025_server_conformance.dart +++ b/test/conformance/run_2025_server_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.6'; + '@modelcontextprotocol/conformance@0.2.0-alpha.7'; const _defaultTimeout = Duration(seconds: 60); Future main(List args) async { diff --git a/test/conformance/run_2026_07_28_rc_client_conformance.dart b/test/conformance/run_2026_07_28_rc_client_conformance.dart index 7915e1a4..155254e9 100644 --- a/test/conformance/run_2026_07_28_rc_client_conformance.dart +++ b/test/conformance/run_2026_07_28_rc_client_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.6'; + '@modelcontextprotocol/conformance@0.2.0-alpha.7'; const _defaultTimeout = Duration(seconds: 30); const _draftClientScenarios = [ diff --git a/test/conformance/run_2026_07_28_rc_server_conformance.dart b/test/conformance/run_2026_07_28_rc_server_conformance.dart index 8ff88e64..321b0cb8 100644 --- a/test/conformance/run_2026_07_28_rc_server_conformance.dart +++ b/test/conformance/run_2026_07_28_rc_server_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.6'; + '@modelcontextprotocol/conformance@0.2.0-alpha.7'; const _defaultTimeout = Duration(seconds: 25); const _serverScenarios = [ From 17cedda5871e2df4f4651f69b8cf89635a57656b Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Sun, 28 Jun 2026 08:20:04 -0400 Subject: [PATCH 64/68] fix: return subscription listen close metadata --- CHANGELOG.md | 3 + doc/mcp-2026-07-28-rc.md | 3 +- doc/spec-coverage-2026-07-28-rc.md | 2 +- lib/src/client/client.dart | 34 +++++++---- lib/src/shared/protocol.dart | 21 ++++++- lib/src/types/subscriptions.dart | 54 ++++++++++++++++++ test/mcp_2026_07_28_test.dart | 91 +++++++++++++++++++++++++++++- 7 files changed, 192 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d80b10ee..36135b2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ skips. - Recorded overridden conformance package names in 2026-07-28 RC summary artifacts so ad hoc package-bump checks are auditable. +- Added `SubscriptionsListenResult` for graceful `subscriptions/listen` closure + and now include the required `io.modelcontextprotocol/subscriptionId` metadata + in Dart server responses and client `McpSubscription.done` results. ## 2.3.0-dev.1 diff --git a/doc/mcp-2026-07-28-rc.md b/doc/mcp-2026-07-28-rc.md index 3ffdaa86..a363d132 100644 --- a/doc/mcp-2026-07-28-rc.md +++ b/doc/mcp-2026-07-28-rc.md @@ -88,7 +88,8 @@ The following features are MCP `2026-07-28` draft/RC behavior and should be used only after opting into a `2026-07-28` profile: - `server/discover` negotiation and stateless per-request metadata. -- `subscriptions/listen` stateless notification streams. +- `subscriptions/listen` stateless notification streams, including + `SubscriptionsListenResult` metadata on graceful stream closure. - Multi-result tool/resource/prompt flows such as `input_required`. - MCP Tasks extension flows using `io.modelcontextprotocol/tasks`. - Non-object `structuredContent` values via `JsonValue` and broader server diff --git a/doc/spec-coverage-2026-07-28-rc.md b/doc/spec-coverage-2026-07-28-rc.md index 4452a7c3..a1de1dc2 100644 --- a/doc/spec-coverage-2026-07-28-rc.md +++ b/doc/spec-coverage-2026-07-28-rc.md @@ -48,7 +48,7 @@ and manual dispatch. | Cacheable results and deterministic lists | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog), [Discovery](https://modelcontextprotocol.io/specification/draft/server/discover) | `server/discover`, list, and read responses include `resultType`, `ttlMs`, and `cacheScope`; stateless `tools/list` is deterministic and omits stable-only tool execution metadata. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart) | TypeScript preview client fixture checks discovery cache metadata and `tools/list` cache metadata. | Cacheable-result and tools-list scenarios in alpha.7. | Verified | | Tools and JSON Schema 2020-12 | [Tools](https://modelcontextprotocol.io/specification/draft/server/tools), [Overview JSON Schema usage](https://modelcontextprotocol.io/specification/draft/basic) | Tool schemas preserve JSON Schema 2020-12 constructs, including nested boolean schemas; stable root-object compatibility remains intact for 2025 behavior. | [`test/tool_schema_test.dart`](../test/tool_schema_test.dart), [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart) | TypeScript preview fixture validates `tools/list` and `tools/call`; deeper schema semantics are covered by local and conformance tests. | 2026 server suite is green; 2026 client suite keeps the upstream `json-schema-ref-no-deref` fixture gap expected. | Verified with one upstream client fixture gap | | MRTR and elicitation | [Message patterns](https://modelcontextprotocol.io/specification/draft/basic) | 2026 `input_required` results are emitted only for supported requests and require advertised client capabilities. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/elicitation_test.dart`](../test/elicitation_test.dart) | TypeScript preview client fixture completes a 2026 `input_required` retry flow against the Dart server. | `mrtr` scenarios in alpha.7. | Verified | -| Subscriptions | [Subscriptions](https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions) | `subscriptions/listen` acknowledges before list-change notifications, filters unsupported notification types, and correlates notifications through `io.modelcontextprotocol/subscriptionId`. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart) | TypeScript preview fixture validates `subscriptions/listen` acknowledgment and list-change notification correlation against the Dart server. | Subscription scenarios in alpha.7. | Verified | +| Subscriptions | [Subscriptions](https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions), [Schema reference](https://modelcontextprotocol.io/specification/draft/schema#subscriptionslistenresult) | `subscriptions/listen` acknowledges before list-change notifications, filters unsupported notification types, correlates notifications through `io.modelcontextprotocol/subscriptionId`, and returns `SubscriptionsListenResult` with the same required subscription id metadata on graceful close. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart) | TypeScript preview fixture validates `subscriptions/listen` acknowledgment and list-change notification correlation against the Dart server. | Subscription scenarios in alpha.7. | Verified | | Request-scoped logging and removed core RPCs | [Logging](https://modelcontextprotocol.io/specification/draft/server/utilities/logging), [Key changes](https://modelcontextprotocol.io/specification/draft/changelog) | 2026 stateless requests use request-scoped logging metadata, and removed stable-era core RPCs/notifications are rejected in the 2026 profile. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/server_advanced_test.dart`](../test/server/server_advanced_test.dart) | TypeScript preview fixture validates raw removed-RPC rejection against the Dart server. | Removed-RPC and logging scenarios in alpha.7. | Verified | | Draft-only public APIs | [Schema reference](https://modelcontextprotocol.io/specification/draft/schema) | APIs that are useful only for 2026, such as non-object structured tool output and 2026 protocol profiles, are documented as draft/RC APIs and do not change stable defaults. | [`doc/mcp-2026-07-28-rc.md`](mcp-2026-07-28-rc.md), public dartdoc on protocol profiles and draft-only helpers. | Not cross-SDK specific. | Covered indirectly by 2026 conformance and local parser/serializer tests. | Verified | diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index e899bcc9..dbaab3ab 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -90,8 +90,8 @@ class McpSubscription { /// Notifications delivered on this subscription stream after acknowledgment. final Stream notifications; - /// Completes when the `subscriptions/listen` request ends. - final Future done; + /// Completes when the `subscriptions/listen` request ends gracefully. + final Future done; McpSubscription._({ required this.id, @@ -1432,10 +1432,10 @@ class McpClient extends Protocol { listenParams: params, meta: _usesStatelessProtocol ? _statelessRequestMeta(null) : null, ); - final requestDone = super.requestWithReservedId( + final requestDone = super.requestWithReservedId( requestId, requestData, - EmptyResult.fromJson, + SubscriptionsListenResult.fromJson, RequestOptions( signal: abortController.signal, timeoutEnabled: false, @@ -1695,7 +1695,8 @@ class _ClientSubscriptionState { StreamController.broadcast(); final Completer _acknowledged = Completer(); - final Completer _done = Completer(); + final Completer _done = + Completer(); SubscriptionFilter? _acknowledgedNotifications; bool _closed = false; @@ -1713,7 +1714,7 @@ class _ClientSubscriptionState { Stream get notifications => _notifications.stream; - Future get done => _done.future; + Future get done => _done.future; void handleNotification(JsonRpcNotification notification) { if (_closed) { @@ -1773,12 +1774,12 @@ class _ClientSubscriptionState { _notifications.add(notification); } - void trackRequest(Future requestDone) { + void trackRequest(Future requestDone) { requestDone.then( complete, onError: (Object error, StackTrace stackTrace) { if (_localCancellation) { - complete(const EmptyResult()); + complete(SubscriptionsListenResult(subscriptionId: id)); } else { fail(error, stackTrace, abort: false); } @@ -1796,10 +1797,10 @@ class _ClientSubscriptionState { _acknowledged.completeError(AbortError(reason), StackTrace.current); } abortController.abort(reason); - complete(const EmptyResult()); + complete(SubscriptionsListenResult(subscriptionId: id)); } - void complete(EmptyResult result) { + void complete(SubscriptionsListenResult result) { if (_closed) { return; } @@ -1820,6 +1821,19 @@ class _ClientSubscriptionState { return; } + if (!_localCancellation && result.subscriptionId != id) { + fail( + McpError( + ErrorCode.invalidRequest.value, + 'Subscription $id completed with mismatched ' + '${McpMetaKey.subscriptionId} ${result.subscriptionId}.', + ), + StackTrace.current, + abort: false, + ); + return; + } + _closed = true; onClose(); if (!_done.isCompleted) { diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 8ea09720..f3e60564 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -1255,8 +1255,25 @@ abstract class Protocol { Map serializeIncomingResult( JsonRpcRequest request, BaseResultData result, - ) => - result.toJson(); + ) { + final resultJson = result.toJson(); + if (request is! JsonRpcSubscriptionsListenRequest) { + return resultJson; + } + + final meta = { + ...readOptionalJsonObject( + resultJson['_meta'], + 'SubscriptionsListenResult._meta', + ) ?? + const {}, + McpMetaKey.subscriptionId: request.id, + }; + return { + ...resultJson, + '_meta': meta, + }; + } /// Handles an MRTR input request embedded in an `InputRequiredResult`. /// diff --git a/lib/src/types/subscriptions.dart b/lib/src/types/subscriptions.dart index 4c5d3ad9..3c520064 100644 --- a/lib/src/types/subscriptions.dart +++ b/lib/src/types/subscriptions.dart @@ -1,5 +1,6 @@ import 'initialization.dart'; import 'json_rpc.dart'; +import 'misc.dart'; import 'validation.dart'; /// Notification filter requested by `subscriptions/listen`. @@ -191,6 +192,59 @@ class SubscriptionsListenRequest { }; } +/// The response sent when a `subscriptions/listen` stream ends gracefully. +class SubscriptionsListenResult extends EmptyResult { + SubscriptionsListenResult({ + required RequestId subscriptionId, + Map? meta, + }) : super(meta: _subscriptionResultMeta(subscriptionId, meta)); + + factory SubscriptionsListenResult.fromJson(Map json) { + final meta = _readRequiredJsonObject( + json['_meta'], + 'SubscriptionsListenResult._meta', + ); + final subscriptionId = _readSubscriptionId( + meta, + 'SubscriptionsListenResult._meta.${McpMetaKey.subscriptionId}', + ); + + return SubscriptionsListenResult( + subscriptionId: subscriptionId, + meta: meta, + ); + } + + /// JSON-RPC request ID for the subscription stream this response closes. + RequestId get subscriptionId => _readSubscriptionId( + meta, + 'SubscriptionsListenResult._meta.${McpMetaKey.subscriptionId}', + ); +} + +Map _subscriptionResultMeta( + RequestId subscriptionId, + Map? meta, +) { + final parsedSubscriptionId = parseRequestId( + subscriptionId, + fieldName: 'SubscriptionsListenResult.subscriptionId', + ); + return { + ...?meta, + McpMetaKey.subscriptionId: parsedSubscriptionId, + }; +} + +RequestId _readSubscriptionId(Object? meta, String fieldName) { + final metaMap = + _readRequiredJsonObject(meta, 'SubscriptionsListenResult._meta'); + return parseRequestId( + metaMap[McpMetaKey.subscriptionId], + fieldName: fieldName, + ); +} + /// Request sent by a client to open a long-lived notification stream. class JsonRpcSubscriptionsListenRequest extends JsonRpcRequest { /// The listen request parameters. diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 3b52d017..6b21aea0 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -2236,6 +2236,32 @@ void main() { ); }); + test('serializes subscriptions/listen graceful-close result metadata', () { + final result = SubscriptionsListenResult(subscriptionId: 'sub-1'); + + expect(result.subscriptionId, 'sub-1'); + expect(result.toJson(), { + '_meta': {McpMetaKey.subscriptionId: 'sub-1'}, + }); + + final parsed = SubscriptionsListenResult.fromJson({ + '_meta': {McpMetaKey.subscriptionId: 7}, + }); + expect(parsed.subscriptionId, 7); + + for (final parse in [ + () => SubscriptionsListenResult.fromJson({}), + () => SubscriptionsListenResult.fromJson({ + '_meta': {}, + }), + () => SubscriptionsListenResult.fromJson({ + '_meta': {McpMetaKey.subscriptionId: true}, + }), + ]) { + expect(parse, throwsFormatException); + } + }); + test('resource subscriptions require resources.subscribe capability', () { const requested = SubscriptionFilter( resourceSubscriptions: ['file:///project/config.json'], @@ -2329,7 +2355,10 @@ void main() { 'resourceSubscriptions': ['file:///project/config.json'], }, ); - expect(transport.sentMessages.last, isA()); + final response = transport.sentMessages.last as JsonRpcResponse; + expect(response.result['_meta'], { + McpMetaKey.subscriptionId: 'sub-1', + }); }); test('server rejects subscription notifications before acknowledgment', @@ -5577,6 +5606,61 @@ void main() { ); }); + test('client listenSubscriptions preserves graceful-close subscription id', + () async { + late JsonRpcRequest listenRequest; + final transport = DiscoveringClientTransport( + capabilities: const ServerCapabilities( + tools: ServerCapabilitiesTools(listChanged: true), + ), + onRequest: (request) { + if (request.method == Method.subscriptionsListen) { + listenRequest = request; + } + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + useServerDiscover: true, + ), + ); + await client.connect(transport); + + final subscription = client.listenSubscriptions( + const SubscriptionsListenRequest( + notifications: SubscriptionFilter(toolsListChanged: true), + ), + ); + await _pump(); + expect(listenRequest.id, subscription.id); + + transport.onmessage?.call( + JsonRpcSubscriptionsAcknowledgedNotification( + acknowledgedParams: const SubscriptionsAcknowledgedNotification( + notifications: SubscriptionFilter(toolsListChanged: true), + ), + meta: {McpMetaKey.subscriptionId: subscription.id}, + ), + ); + await subscription.acknowledged; + + transport.onmessage?.call( + JsonRpcResponse( + id: subscription.id, + result: { + 'resultType': resultTypeComplete, + '_meta': {McpMetaKey.subscriptionId: subscription.id}, + }, + ), + ); + + final done = await subscription.done; + expect(done, isA()); + expect(done.subscriptionId, subscription.id); + }); + test('client subscription rejects notifications before acknowledgment', () async { final transport = DiscoveringClientTransport( @@ -5890,7 +5974,10 @@ void main() { transport.onmessage?.call( JsonRpcResponse( id: subscription.id, - result: const {'resultType': resultTypeComplete}, + result: { + 'resultType': resultTypeComplete, + '_meta': {McpMetaKey.subscriptionId: subscription.id}, + }, ), ); From 6dffa4deda9cc7bd34b98990668efdd4aa0154c3 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Sun, 28 Jun 2026 09:16:11 -0400 Subject: [PATCH 65/68] chore: clean up example analysis and 2026 previews --- CHANGELOG.md | 6 ++ analysis_options.yaml | 9 +++ doc/flutter-recipes.md | 1 + doc/interoperability.md | 6 +- example/elicitation_http_server.dart | 40 ++++++---- .../lib/services/streamable_mcp_service.dart | 3 +- .../lib/services/mcp_service.dart | 1 + example/mcp_apps_helpers_server.dart | 1 + example/mcp_apps_metadata_server.dart | 1 + .../client_streamable_https.dart | 1 + .../streamable_https/high_level_server.dart | 1 + .../server_streamable_https.dart | 47 +++++++----- lib/src/client/client.dart | 30 +++++++- test/mcp_2026_07_28_test.dart | 74 +++++++++++++++++++ 14 files changed, 181 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36135b2f..a4baf594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ - Added `SubscriptionsListenResult` for graceful `subscriptions/listen` closure and now include the required `io.modelcontextprotocol/subscriptionId` metadata in Dart server responses and client `McpSubscription.done` results. +- Cleaned up root analyzer coverage for standalone example packages and opted + Streamable HTTP, Flutter/Jaspr web client, and MCP Apps examples into the + `2026-07-28` preview protocol profile with stable fallback where applicable. +- Broadened preview client discovery fallback so servers that implement + `server/discover` but advertise only stable protocol versions can still + connect through the stable `initialize` flow. ## 2.3.0-dev.1 diff --git a/analysis_options.yaml b/analysis_options.yaml index db95fdcd..5f659e9c 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -3,6 +3,15 @@ include: package:lints/recommended.yaml analyzer: exclude: - packages/templates/simple/** + # Standalone packages resolve their own dependencies from their nested + # pubspecs. Analyze them from their package roots instead of the root + # package analyzer context. + - example/anthropic-client/** + - example/fetch-server/** + - example/flutter_http_client/** + - example/gemini-client/** + - example/jaspr-client/** + - packages/mcp_dart_cli/** language: # strict-casts: true # strict-inference: true diff --git a/doc/flutter-recipes.md b/doc/flutter-recipes.md index d30ff6b5..7ff65092 100644 --- a/doc/flutter-recipes.md +++ b/doc/flutter-recipes.md @@ -25,6 +25,7 @@ class McpClientController extends ChangeNotifier { Future connect(Uri serverUri) async { final client = McpClient( const Implementation(name: 'flutter-host', version: '1.0.0'), + options: const McpClientOptions(protocol: McpProtocol.preview2026), ); final transport = StreamableHttpClientTransport(serverUri); diff --git a/doc/interoperability.md b/doc/interoperability.md index 418cd36b..37ca1658 100644 --- a/doc/interoperability.md +++ b/doc/interoperability.md @@ -18,7 +18,7 @@ For MCP 2026-07-28 draft/RC coverage, see the | Scenario | Transport | Protocol version | Evidence | Status | Notes | | --- | --- | --- | --- | --- | --- | | Dart client -> Dart server | stdio | `2025-11-25` | [`test/integration/stdio_integration_test.dart`](../test/integration/stdio_integration_test.dart), [`example/server_stdio.dart`](../example/server_stdio.dart), [`example/client_stdio.dart`](../example/client_stdio.dart) | Verified | Covers local process startup, tool/resource/prompt flow, and request/response handling. | -| Dart client -> Dart server | Streamable HTTP | `2025-11-25` | [`test/client/streamable_https_test.dart`](../test/client/streamable_https_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart), [`example/streamable_https/`](../example/streamable_https/) | Verified | Includes session handling, strict header validation, stale-session recovery, and resumability coverage. | +| Dart client -> Dart server | Streamable HTTP | `2025-11-25` and `2026-07-28` draft/RC preview | [`test/client/streamable_https_test.dart`](../test/client/streamable_https_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart), [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`example/streamable_https/`](../example/streamable_https/) | Verified | Includes session handling, strict header validation, stale-session recovery, resumability coverage, and preview examples that use `server/discover` negotiation. | | Dart client -> TypeScript SDK server | stdio | `2025-11-25` | [`test/interop/dart_client_with_ts_server_test.dart`](../test/interop/dart_client_with_ts_server_test.dart), [`test/interop/ts/`](../test/interop/ts/) | Verified | Requires the TypeScript fixture to be built before running the tagged interop tests. | | Dart client -> TypeScript SDK server | Streamable HTTP | `2025-11-25` | [`test/interop/dart_client_with_ts_server_test.dart`](../test/interop/dart_client_with_ts_server_test.dart), [`test/interop/ts/`](../test/interop/ts/) | Verified | Covers tool calls and stale preconfigured session-id recovery. | | TypeScript SDK client -> Dart server | stdio | `2025-11-25` | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/test_dart_server.dart`](../test/interop/test_dart_server.dart) | Verified | Runs the compiled TypeScript client fixture against a Dart server process and checks that an official TS client can list tools immediately after the lifecycle handshake. | @@ -26,8 +26,8 @@ For MCP 2026-07-28 draft/RC coverage, see the | TypeScript SDK preview client -> Dart server | Streamable HTTP | `2026-07-28` draft/RC | [`test/interop/ts_2026_07_28_rc/`](../test/interop/ts_2026_07_28_rc/), [`tool/testing/run_ts_2026_07_28_rc_interop.dart`](../tool/testing/run_ts_2026_07_28_rc_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Automated preview check | Uses pinned `pkg.pr.new` previews from the TypeScript SDK `v2-2026-07-28` branch head. Covers modern negotiation, cache metadata, `tools/list`, `tools/call`, `x-mcp-header` mirroring, raw header and unsupported-version rejection, removed core RPC rejection, progress notifications, `subscriptions/listen`, and HTTP SSE cancellation against the Dart 2026-07-28 RC conformance server. | | Dart preview client -> TypeScript SDK preview server | Streamable HTTP | `2026-07-28` draft/RC | [`test/interop/ts_2026_07_28_rc/src/server.mjs`](../test/interop/ts_2026_07_28_rc/src/server.mjs), [`tool/testing/run_ts_2026_07_28_rc_interop.dart`](../tool/testing/run_ts_2026_07_28_rc_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Automated preview check | Uses the pinned TypeScript SDK server preview through its `createMcpHandler` entry and covers `server/discover` negotiation, `tools/list`, and `tools/call`. | | Dart client -> Python MCP server | stdio | Server-dependent | [`doc/transports.md`](transports.md#connect-to-python-server) | Documented recipe | The transport can spawn Python servers over stdio, but this repo does not yet include an automated Python SDK fixture. | -| Flutter/Web client -> Dart server | Streamable HTTP | `2025-11-25` | [`example/flutter_http_client/`](../example/flutter_http_client/), [`doc/flutter-recipes.md`](flutter-recipes.md) | Documented recipe | Flutter Web cannot spawn stdio servers; use Streamable HTTP or another browser-safe transport. | -| MCP Apps host/client metadata | stdio or Streamable HTTP | `2025-11-25` plus `io.modelcontextprotocol/ui` extension | [`doc/mcp-apps.md`](mcp-apps.md), [`example/mcp_apps_helpers_server.dart`](../example/mcp_apps_helpers_server.dart), [`test/types/mcp_ui_test.dart`](../test/types/mcp_ui_test.dart), [`test/server/mcp_ui_test.dart`](../test/server/mcp_ui_test.dart) | Verified | Verified coverage is limited to SDK metadata helpers, serialization, and checked-in examples; host rendering behavior varies by host, so verify UI metadata against your target host. | +| Flutter/Web client -> Dart server | Streamable HTTP | `2026-07-28` draft/RC preview with stable fallback | [`example/flutter_http_client/`](../example/flutter_http_client/), [`doc/flutter-recipes.md`](flutter-recipes.md) | Documented recipe | Flutter Web cannot spawn stdio servers; use Streamable HTTP or another browser-safe transport. The example opts into preview negotiation while retaining stable fallback. | +| MCP Apps host/client metadata | stdio or Streamable HTTP | `2026-07-28` draft/RC preview plus `io.modelcontextprotocol/ui` extension | [`doc/mcp-apps.md`](mcp-apps.md), [`example/mcp_apps_helpers_server.dart`](../example/mcp_apps_helpers_server.dart), [`test/types/mcp_ui_test.dart`](../test/types/mcp_ui_test.dart), [`test/server/mcp_ui_test.dart`](../test/server/mcp_ui_test.dart) | Verified | Verified coverage is limited to SDK metadata helpers, serialization, and checked-in examples; host rendering behavior varies by host, so verify UI metadata against your target host. | | OAuth-protected Streamable HTTP client | Streamable HTTP | `2025-11-25` | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/ts/src/oauth_client.ts`](../test/interop/ts/src/oauth_client.ts), [`test/example/oauth_client_example_test.dart`](../test/example/oauth_client_example_test.dart), [`test/server/streamable_security_harness_test.dart`](../test/server/streamable_security_harness_test.dart), [`example/authentication/`](../example/authentication/), [`doc/transports.md`](transports.md) | Verified | Covers official TypeScript Streamable HTTP client OAuth discovery, PKCE S256 authorization redirect, resource-bound token exchange, bearer reconnect, plus local Host/Origin and auth-gating deployment scenarios. | ## Running interop checks locally diff --git a/example/elicitation_http_server.dart b/example/elicitation_http_server.dart index eb363102..72d9679e 100644 --- a/example/elicitation_http_server.dart +++ b/example/elicitation_http_server.dart @@ -66,6 +66,7 @@ class InMemoryEventStore implements EventStore { McpServer getServer() { final server = McpServer( const Implementation(name: 'elicitation-example-server', version: '1.0.0'), + options: const McpServerOptions(protocol: McpProtocol.preview2026), ); // Example 1: Simple user registration tool @@ -602,11 +603,18 @@ void main() async { } } -// Check if a request is an initialization request bool _isInitializeRequest(dynamic body) { - return body is Map && - body.containsKey('method') && - body['method'] == 'initialize'; + return body is Map && body['method'] == Method.initialize; +} + +bool _isStatelessRequest(HttpRequest request) { + final protocolVersion = request.headers.value('mcp-protocol-version'); + return protocolVersion != null && + isStatelessProtocolVersion(protocolVersion.trim()); +} + +bool _canHandleSessionlessPost(HttpRequest request, dynamic body) { + return _isInitializeRequest(body) || _isStatelessRequest(request); } // Handle POST requests @@ -627,8 +635,8 @@ Future _handlePostRequest( if (sessionId != null && transports.containsKey(sessionId)) { // Reuse existing transport transport = transports[sessionId]!; - } else if (sessionId == null && _isInitializeRequest(body)) { - // New initialization request + } else if (sessionId == null && _canHandleSessionlessPost(request, body)) { + // New legacy session request or stateless 2026 request final eventStore = InMemoryEventStore(); transport = StreamableHTTPServerTransport( options: StreamableHTTPServerTransportOptions( @@ -657,9 +665,10 @@ Future _handlePostRequest( await transport.handleRequest(request, body); return; } else { - // Invalid request + // Invalid request - unknown session ID or non-initialize legacy request request.response - ..statusCode = HttpStatus.badRequest + ..statusCode = + sessionId == null ? HttpStatus.badRequest : HttpStatus.notFound ..headers.set(HttpHeaders.contentTypeHeader, 'application/json'); request.response.write( jsonEncode( @@ -667,8 +676,9 @@ Future _handlePostRequest( id: null, error: JsonRpcErrorData( code: ErrorCode.connectionClosed.value, - message: - 'Bad Request: No valid session ID provided or not an initialization request', + message: sessionId == null + ? 'Bad Request: sessionless requests must be initialize or include a stateless MCP-Protocol-Version' + : 'Session not found', ), ).toJson(), ), @@ -710,10 +720,11 @@ Future _handleGetRequest( ) async { final sessionId = request.headers.value('mcp-session-id'); if (sessionId == null || !transports.containsKey(sessionId)) { - request.response.statusCode = HttpStatus.badRequest; + request.response.statusCode = + sessionId == null ? HttpStatus.badRequest : HttpStatus.notFound; setCorsHeaders(request.response); request.response - ..write('Invalid or missing session ID') + ..write(sessionId == null ? 'Missing session ID' : 'Session not found') ..close(); return; } @@ -736,10 +747,11 @@ Future _handleDeleteRequest( ) async { final sessionId = request.headers.value('mcp-session-id'); if (sessionId == null || !transports.containsKey(sessionId)) { - request.response.statusCode = HttpStatus.badRequest; + request.response.statusCode = + sessionId == null ? HttpStatus.badRequest : HttpStatus.notFound; setCorsHeaders(request.response); request.response - ..write('Invalid or missing session ID') + ..write(sessionId == null ? 'Missing session ID' : 'Session not found') ..close(); return; } diff --git a/example/flutter_http_client/lib/services/streamable_mcp_service.dart b/example/flutter_http_client/lib/services/streamable_mcp_service.dart index 6b2c3519..a6d79361 100644 --- a/example/flutter_http_client/lib/services/streamable_mcp_service.dart +++ b/example/flutter_http_client/lib/services/streamable_mcp_service.dart @@ -76,7 +76,8 @@ class StreamableMcpService extends ChangeNotifier { try { // Create a new client _client = McpClient( - Implementation(name: 'flutter-mcp-client', version: '1.0.0'), + const Implementation(name: 'flutter-mcp-client', version: '1.0.0'), + options: const McpClientOptions(protocol: McpProtocol.preview2026), ); _client!.onerror = (error) { diff --git a/example/jaspr-client/lib/services/mcp_service.dart b/example/jaspr-client/lib/services/mcp_service.dart index 41d7da62..5cf2a0bb 100644 --- a/example/jaspr-client/lib/services/mcp_service.dart +++ b/example/jaspr-client/lib/services/mcp_service.dart @@ -200,6 +200,7 @@ class McpService { _client = McpClient( const Implementation(name: 'jaspr-mcp-client', version: '1.0.0'), options: const McpClientOptions( + protocol: McpProtocol.preview2026, capabilities: ClientCapabilities( elicitation: ClientElicitation.formOnly(), sampling: ClientCapabilitiesSampling(), diff --git a/example/mcp_apps_helpers_server.dart b/example/mcp_apps_helpers_server.dart index f185d0e1..7bb7456e 100644 --- a/example/mcp_apps_helpers_server.dart +++ b/example/mcp_apps_helpers_server.dart @@ -7,6 +7,7 @@ Future main() async { version: '1.0.0', ), options: McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( resources: const ServerCapabilitiesResources(), tools: const ServerCapabilitiesTools(), diff --git a/example/mcp_apps_metadata_server.dart b/example/mcp_apps_metadata_server.dart index 66712293..55740c59 100644 --- a/example/mcp_apps_metadata_server.dart +++ b/example/mcp_apps_metadata_server.dart @@ -7,6 +7,7 @@ Future main() async { version: '1.0.0', ), options: McpServerOptions( + protocol: McpProtocol.preview2026, capabilities: ServerCapabilities( resources: const ServerCapabilitiesResources(), tools: const ServerCapabilitiesTools(), diff --git a/example/streamable_https/client_streamable_https.dart b/example/streamable_https/client_streamable_https.dart index 07327655..ac5ac63d 100644 --- a/example/streamable_https/client_streamable_https.dart +++ b/example/streamable_https/client_streamable_https.dart @@ -189,6 +189,7 @@ Future connect([String? url]) async { // Create a new client client = McpClient( const Implementation(name: 'example-client', version: '1.0.0'), + options: const McpClientOptions(protocol: McpProtocol.preview2026), ); client!.onerror = (error) { print('\x1b[31mClient error: $error\x1b[0m'); diff --git a/example/streamable_https/high_level_server.dart b/example/streamable_https/high_level_server.dart index 8ba797ea..a5b90248 100644 --- a/example/streamable_https/high_level_server.dart +++ b/example/streamable_https/high_level_server.dart @@ -26,6 +26,7 @@ McpServer getServer() { name: 'simple-streamable-http-server', version: '1.0.0', ), + options: const McpServerOptions(protocol: McpProtocol.preview2026), ); // Register a simple tool that returns a greeting diff --git a/example/streamable_https/server_streamable_https.dart b/example/streamable_https/server_streamable_https.dart index e703b673..44e40e68 100644 --- a/example/streamable_https/server_streamable_https.dart +++ b/example/streamable_https/server_streamable_https.dart @@ -59,6 +59,7 @@ McpServer getServer() { name: 'simple-streamable-http-server', version: '1.0.0', ), + options: const McpServerOptions(protocol: McpProtocol.preview2026), ); // Register a simple tool that returns a greeting @@ -324,14 +325,18 @@ void main() async { } } -// Function to check if a request is an initialization request bool isInitializeRequest(dynamic body) { - if (body is Map && - body.containsKey('method') && - body['method'] == 'initialize') { - return true; - } - return false; + return body is Map && body['method'] == Method.initialize; +} + +bool isStatelessRequest(HttpRequest request) { + final protocolVersion = request.headers.value('mcp-protocol-version'); + return protocolVersion != null && + isStatelessProtocolVersion(protocolVersion.trim()); +} + +bool canHandleSessionlessPost(HttpRequest request, dynamic body) { + return isInitializeRequest(body) || isStatelessRequest(request); } // Handle POST requests @@ -354,8 +359,8 @@ Future handlePostRequest( if (sessionId != null && transports.containsKey(sessionId)) { // Reuse existing transport transport = transports[sessionId]!; - } else if (sessionId == null && isInitializeRequest(body)) { - // New initialization request + } else if (sessionId == null && canHandleSessionlessPost(request, body)) { + // New legacy session request or stateless 2026 request final eventStore = InMemoryEventStore(); transport = StreamableHTTPServerTransport( options: StreamableHTTPServerTransportOptions( @@ -384,13 +389,16 @@ Future handlePostRequest( final server = getServer(); await server.connect(transport); - print('Handling initialization request for a new session'); + final method = + body is Map ? body['method'] : ''; + print('Handling sessionless request: $method'); await transport.handleRequest(request, body); return; // Already handled } else { - // Invalid request - no session ID or not initialization request + // Invalid request - unknown session ID or non-initialize legacy request request.response - ..statusCode = HttpStatus.badRequest + ..statusCode = + sessionId == null ? HttpStatus.badRequest : HttpStatus.notFound ..headers.set(HttpHeaders.contentTypeHeader, 'application/json'); // Apply CORS headers to this specific response setCorsHeaders(request); @@ -400,8 +408,9 @@ Future handlePostRequest( id: null, error: JsonRpcErrorData( code: ErrorCode.connectionClosed.value, - message: - 'Bad Request: No valid session ID provided or not an initialization request', + message: sessionId == null + ? 'Bad Request: sessionless requests must be initialize or include a stateless MCP-Protocol-Version' + : 'Session not found', ), ).toJson(), ), @@ -453,11 +462,12 @@ Future handleGetRequest( ) async { final sessionId = request.headers.value('mcp-session-id'); if (sessionId == null || !transports.containsKey(sessionId)) { - request.response.statusCode = HttpStatus.badRequest; + request.response.statusCode = + sessionId == null ? HttpStatus.badRequest : HttpStatus.notFound; // Apply CORS headers setCorsHeaders(request); request.response - ..write('Invalid or missing session ID') + ..write(sessionId == null ? 'Missing session ID' : 'Session not found') ..close(); return; } @@ -481,11 +491,12 @@ Future handleDeleteRequest( ) async { final sessionId = request.headers.value('mcp-session-id'); if (sessionId == null || !transports.containsKey(sessionId)) { - request.response.statusCode = HttpStatus.badRequest; + request.response.statusCode = + sessionId == null ? HttpStatus.badRequest : HttpStatus.notFound; // Apply CORS headers setCorsHeaders(request); request.response - ..write('Invalid or missing session ID') + ..write(sessionId == null ? 'Missing session ID' : 'Session not found') ..close(); return; } diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index dbaab3ab..f9a5b84a 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -408,10 +408,7 @@ class McpClient extends Protocol { ); } - String? _retryableDiscoveryProtocolVersion( - McpError error, - String attemptedVersion, - ) { + List? _supportedVersionsFromUnsupportedProtocolError(McpError error) { if (error.code != ErrorCode.unsupportedProtocolVersion.value) { return null; } @@ -432,6 +429,16 @@ class McpClient extends Protocol { advertisedVersions.add(version); } } + return advertisedVersions; + } + + String? _retryableDiscoveryProtocolVersion( + McpError error, + String attemptedVersion, + ) { + final advertisedVersions = + _supportedVersionsFromUnsupportedProtocolError(error); + if (advertisedVersions == null) return null; final retryVersion = negotiateProtocolVersion( advertisedVersions, @@ -443,6 +450,18 @@ class McpClient extends Protocol { return retryVersion; } + bool _discoveryUnsupportedButStableCompatible(McpError error) { + final advertisedVersions = + _supportedVersionsFromUnsupportedProtocolError(error); + if (advertisedVersions == null) return false; + + return negotiateProtocolVersion( + advertisedVersions, + localSupportedVersions: supportedProtocolVersions, + ) != + null; + } + Future _discoverServerWithVersion( String protocolVersion, ) async { @@ -562,6 +581,9 @@ class McpClient extends Protocol { if (error.code == ErrorCode.methodNotFound.value) { return true; } + if (_discoveryUnsupportedButStableCompatible(error)) { + return true; + } if (error.code == ErrorCode.invalidParams.value && error.message.contains('Invalid request parameters')) { return true; diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 6b21aea0..91081152 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -6379,6 +6379,80 @@ void main() { ); }); + test('preview client falls back when discovery advertises stable versions', + () async { + final transport = LegacyFallbackTransport( + discoveryError: McpError( + ErrorCode.unsupportedProtocolVersion.value, + 'Unsupported protocol version', + const { + 'supported': supportedProtocolVersions, + 'requested': draftProtocolVersion2026_07_28, + }, + ), + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.preview2026, + ), + ); + + 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]), + ); + final initializeRequest = transport.sentMessages + .whereType() + .singleWhere((message) => message.method == Method.initialize); + expect( + initializeRequest.params?['protocolVersion'], + stableProtocolVersion2025_11_25, + ); + }); + + test('require2026 client does not fall back to stable versions', () async { + final transport = LegacyFallbackTransport( + discoveryError: McpError( + ErrorCode.unsupportedProtocolVersion.value, + 'Unsupported protocol version', + const { + 'supported': supportedProtocolVersions, + 'requested': draftProtocolVersion2026_07_28, + }, + ), + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.require2026, + ), + ); + + await expectLater( + client.connect(transport), + throwsA( + isA().having( + (error) => error.code, + 'code', + ErrorCode.unsupportedProtocolVersion.value, + ), + ), + ); + expect( + transport.sentMessages.whereType().map( + (message) => message.method, + ), + isNot(contains(Method.initialize)), + ); + }); + for (final scenario in [ ( name: 'malformed error data', From 010944b0b5b70de0bf38c1718a3a89e475c8f806 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Sun, 28 Jun 2026 22:18:46 -0400 Subject: [PATCH 66/68] ci: isolate 2025 server conformance scenarios --- .github/workflows/test_core.yml | 1 + .../run_2025_server_conformance.dart | 103 ++++++++++++++++-- 2 files changed, 96 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test_core.yml b/.github/workflows/test_core.yml index c1f47e62..3b1ec340 100644 --- a/.github/workflows/test_core.yml +++ b/.github/workflows/test_core.yml @@ -47,6 +47,7 @@ jobs: run: > dart run test/conformance/run_2025_server_conformance.dart --timeout-seconds 90 + --isolate-scenarios --output-dir .dart_tool/conformance/ci_2025_server - name: Run official MCP 2025 client conformance diff --git a/test/conformance/run_2025_server_conformance.dart b/test/conformance/run_2025_server_conformance.dart index b77d0a7a..e03fd9f4 100644 --- a/test/conformance/run_2025_server_conformance.dart +++ b/test/conformance/run_2025_server_conformance.dart @@ -6,6 +6,45 @@ const _defaultConformancePackage = '@modelcontextprotocol/conformance@0.2.0-alpha.7'; const _defaultTimeout = Duration(seconds: 60); +// The alpha.7 conformance CLI occasionally leaks or stalls server-initiated +// elicitation state when the complete 2025 server suite is run in one process +// on GitHub's Linux runners. Running each pinned scenario in a fresh +// conformance process preserves coverage while isolating CLI-side state. +const _serverScenarios = [ + 'server-initialize', + 'logging-set-level', + 'ping', + 'completion-complete', + 'tools-list', + 'tools-call-simple-text', + 'tools-call-image', + 'tools-call-audio', + 'tools-call-embedded-resource', + 'tools-call-mixed-content', + 'tools-call-with-logging', + 'tools-call-error', + 'tools-call-with-progress', + 'tools-call-sampling', + 'tools-call-elicitation', + 'json-schema-2020-12', + 'elicitation-sep1034-defaults', + 'server-sse-polling', + 'server-sse-multiple-streams', + 'elicitation-sep1330-enums', + 'resources-list', + 'resources-read-text', + 'resources-read-binary', + 'resources-templates-read', + 'resources-subscribe', + 'resources-unsubscribe', + 'prompts-list', + 'prompts-get-simple', + 'prompts-get-with-args', + 'prompts-get-embedded-resource', + 'prompts-get-with-image', + 'dns-rebinding-protection', +]; + Future main(List args) async { final options = _Options.parse(args); if (options.help) { @@ -42,13 +81,20 @@ Future main(List args) async { stdout.writeln('Output: ${outputRoot.path}'); stdout.writeln(''); - final result = await _runConformance( - serverUrl: serverUrl, - outputRoot: outputRoot, - conformancePackage: options.conformancePackage, - scenario: options.scenario, - timeout: options.timeout, - ); + final result = options.isolateScenarios && options.scenario == null + ? await _runIsolatedConformance( + serverUrl: serverUrl, + outputRoot: outputRoot, + conformancePackage: options.conformancePackage, + timeout: options.timeout, + ) + : await _runConformance( + serverUrl: serverUrl, + outputRoot: outputRoot, + conformancePackage: options.conformancePackage, + scenario: options.scenario, + timeout: options.timeout, + ); exitCode = result.exitCode ?? 1; if (result.timedOut) { @@ -178,6 +224,39 @@ Future<_RunResult> _runConformance({ } } +Future<_RunResult> _runIsolatedConformance({ + required Uri serverUrl, + required Directory outputRoot, + required String conformancePackage, + required Duration timeout, +}) async { + var failed = false; + var timedOut = false; + + for (final scenario in _serverScenarios) { + stdout.writeln(''); + stdout.writeln('=== Running isolated scenario: $scenario ==='); + final result = await _runConformance( + serverUrl: serverUrl, + outputRoot: Directory('${outputRoot.path}/$scenario'), + conformancePackage: conformancePackage, + scenario: scenario, + timeout: timeout, + ); + if (result.timedOut) { + timedOut = true; + } + if (result.exitCode != 0) { + failed = true; + } + } + + if (timedOut) { + return const _RunResult(exitCode: 1, timedOut: true); + } + return _RunResult(exitCode: failed ? 1 : 0, timedOut: false); +} + void _printUsage() { stdout.writeln(''' Usage: dart run test/conformance/run_2025_server_conformance.dart [options] @@ -188,7 +267,9 @@ Options: --port Port for the local fixture server. --output-dir Directory for conformance artifacts. --conformance-package Conformance npm package. - --timeout-seconds Overall conformance command timeout. + --timeout-seconds Conformance command timeout. + --isolate-scenarios Run each pinned 2025 scenario in a fresh + conformance process. --help Show this help. '''); } @@ -200,6 +281,7 @@ class _Options { final String? outputDir; final String conformancePackage; final Duration timeout; + final bool isolateScenarios; final bool help; const _Options({ @@ -209,6 +291,7 @@ class _Options { required this.outputDir, required this.conformancePackage, required this.timeout, + required this.isolateScenarios, required this.help, }); @@ -219,6 +302,7 @@ class _Options { String? outputDir; var conformancePackage = _defaultConformancePackage; var timeout = _defaultTimeout; + var isolateScenarios = false; var help = false; for (var i = 0; i < args.length; i++) { @@ -235,6 +319,8 @@ class _Options { conformancePackage = args[++i]; case '--timeout-seconds': timeout = Duration(seconds: int.parse(args[++i])); + case '--isolate-scenarios': + isolateScenarios = true; case '--help': help = true; default: @@ -249,6 +335,7 @@ class _Options { outputDir: outputDir, conformancePackage: conformancePackage, timeout: timeout, + isolateScenarios: isolateScenarios, help: help, ); } From 2e4be0149dacebf339fc137eac624e2db662613f Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 29 Jun 2026 06:11:41 -0400 Subject: [PATCH 67/68] docs: clarify TypeScript alpha interop blocker (#297) --- CHANGELOG.md | 3 +++ doc/interoperability.md | 14 ++++++++++++-- doc/mcp-2026-07-28-rc.md | 5 ++++- doc/spec-coverage-2026-07-28-rc.md | 5 ++++- test/interop/ts_2026_07_28_rc/README.md | 8 +++++++- 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4baf594..c60139b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ ### Conformance and interoperability +- Documented that published TypeScript SDK `2.0.0-alpha.3` packages are not yet + valid replacements for the `pkg.pr.new` 2026-07-28 RC interop fixture because + the alpha.3 client is missing the preview negotiation API used by the fixture. - Updated official conformance gates to `@modelcontextprotocol/conformance@0.2.0-alpha.7`. The 2026-07-28 RC server suite now has no expected failures; the 2026 client suite keeps only the upstream diff --git a/doc/interoperability.md b/doc/interoperability.md index 37ca1658..0ebefd48 100644 --- a/doc/interoperability.md +++ b/doc/interoperability.md @@ -61,6 +61,14 @@ This starts the Dart 2026-07-28 RC conformance server, runs the pinned TypeScrip preview client against it, then runs the reverse Dart preview client smoke check against the TypeScript preview server. +Published `@modelcontextprotocol/client@2.0.0-alpha.3` and +`@modelcontextprotocol/server@2.0.0-alpha.3` packages are not yet suitable +replacements for this fixture: the published alpha.3 client does not expose the +preview `versionNegotiation` / `getProtocolEra` APIs used here, and a direct +`supportedProtocolVersions: ["2026-07-28"]` repin fails the handshake. Keep the +`pkg.pr.new` pin until a published TypeScript SDK package exposes the 2026 draft +path and this interop runner passes against it. + CI also runs this fixture in the dedicated `Run MCP 2026-07-28 TypeScript Interop` workflow for relevant PRs, `dev/2026-07-28-rc` pushes, daily scheduled drift checks, and manual dispatch. @@ -89,8 +97,10 @@ When adding a new interoperability claim: - Automated Python SDK fixture coverage. - Re-pin the TypeScript 2026-07-28 RC interop fixture to a published upstream - alpha package once the TypeScript SDK no longer requires `pkg.pr.new` - previews. + alpha package once that package exposes the 2026 draft path. The published + `@modelcontextprotocol/client/server@2.0.0-alpha.3` packages are not enough + because the client is missing the preview negotiation API used by this + fixture. - Broader reverse-path TypeScript preview server coverage beyond discovery, `tools/list`, and `tools/call`. - Host-specific MCP Apps rendering compatibility notes. diff --git a/doc/mcp-2026-07-28-rc.md b/doc/mcp-2026-07-28-rc.md index a363d132..c6ad40ed 100644 --- a/doc/mcp-2026-07-28-rc.md +++ b/doc/mcp-2026-07-28-rc.md @@ -158,7 +158,10 @@ CI also runs it in the dedicated `Run MCP 2026-07-28 TypeScript Interop` workflow on relevant PRs, `dev/2026-07-28-rc` pushes, a daily schedule, and manual dispatch. Keep it pinned to the draft behavior rather than TypeScript preview behavior alone; re-pin from `pkg.pr.new` to a published TypeScript SDK -alpha when upstream provides one. +alpha only when the published package advertises `2026-07-28` support. The +published `@modelcontextprotocol/client/server@2.0.0-alpha.3` packages are +missing the preview negotiation API used by this fixture, so they are not a +replacement for the current preview pin. For dev packages, keep package documentation links pointed at `dev/2026-07-28-rc` until the draft work is ready to merge back to `main`. diff --git a/doc/spec-coverage-2026-07-28-rc.md b/doc/spec-coverage-2026-07-28-rc.md index a1de1dc2..95c3d29f 100644 --- a/doc/spec-coverage-2026-07-28-rc.md +++ b/doc/spec-coverage-2026-07-28-rc.md @@ -59,7 +59,10 @@ and manual dispatch. that scenario still behaves like a stable-only server. - The TypeScript 2026-07-28 fixture depends on `pkg.pr.new` preview artifacts from the TypeScript SDK branch. Keep the CI workflow, but re-pin it to a - published alpha package once upstream provides one. + published alpha package once upstream provides one that advertises the 2026 + draft path. The published `@modelcontextprotocol/client/server@2.0.0-alpha.3` + packages are missing the preview negotiation API used by this fixture, so + they are not a valid replacement for the current preview pin. - The reverse Dart preview client -> TypeScript preview server path currently covers discovery, `tools/list`, and `tools/call`. Broader reverse-path coverage should follow as the TypeScript preview server surface stabilizes. diff --git a/test/interop/ts_2026_07_28_rc/README.md b/test/interop/ts_2026_07_28_rc/README.md index 00a38e3a..8ed45e4c 100644 --- a/test/interop/ts_2026_07_28_rc/README.md +++ b/test/interop/ts_2026_07_28_rc/README.md @@ -68,4 +68,10 @@ CI runs this fixture in the dedicated `Run MCP 2026-07-28 TypeScript Interop` workflow for relevant PRs, `dev/2026-07-28-rc` pushes, daily scheduled drift checks, and manual dispatch. Keep the fixture pinned to a published TypeScript SDK alpha once upstream no -longer requires `pkg.pr.new` preview artifacts. +longer requires `pkg.pr.new` preview artifacts. Do not treat publication alone +as enough to re-pin: `@modelcontextprotocol/client@2.0.0-alpha.3` and +`@modelcontextprotocol/server@2.0.0-alpha.3` are published, but the published +client does not expose the preview `versionNegotiation` / `getProtocolEra` APIs +used here, and a direct `supportedProtocolVersions: ["2026-07-28"]` repin fails +the handshake. Keep this fixture on the `pkg.pr.new` preview until a published +package exposes the 2026 draft path and this runner passes against it. From 95fd778d0d253e3356e3d34e0b432733d2e09a7c Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Wed, 1 Jul 2026 08:19:42 -0400 Subject: [PATCH 68/68] test: update 2026 interop and conformance pins (#298) * test: repin TypeScript 2026 interop to beta packages * test: update MCP conformance to alpha.8 * test: cover tool-required client capabilities * ci: align 2025 client conformance package * docs: clarify TypeScript beta interop wording --- .github/workflows/test_core.yml | 2 +- CHANGELOG.md | 22 ++--- README.md | 2 +- doc/interoperability.md | 33 +++---- doc/mcp-2026-07-28-rc.md | 15 ++-- doc/spec-coverage-2026-07-28-rc.md | 38 ++++---- lib/src/server/mcp_server.dart | 87 ++++++++++++++++++- ...2026_07_28_rc_client_expected_failures.txt | 4 +- .../2026_07_28_rc_expected_failures.txt | 4 +- test/conformance/README.md | 8 +- .../conformance/mcp_2026_07_28_rc_client.dart | 1 - .../conformance/mcp_2026_07_28_rc_server.dart | 40 +++++++++ .../run_2025_server_conformance.dart | 4 +- .../run_2026_07_28_rc_client_conformance.dart | 2 +- .../run_2026_07_28_rc_server_conformance.dart | 2 +- test/interop/ts_2026_07_28_rc/README.md | 27 +++--- .../ts_2026_07_28_rc/package-lock.json | 16 ++-- test/interop/ts_2026_07_28_rc/package.json | 4 +- test/mcp_2026_07_28_test.dart | 71 +++++++++++++++ 19 files changed, 280 insertions(+), 102 deletions(-) diff --git a/.github/workflows/test_core.yml b/.github/workflows/test_core.yml index 3b1ec340..73896c30 100644 --- a/.github/workflows/test_core.yml +++ b/.github/workflows/test_core.yml @@ -52,7 +52,7 @@ jobs: - name: Run official MCP 2025 client conformance run: > - npx -y @modelcontextprotocol/conformance@0.2.0-alpha.7 client + npx -y @modelcontextprotocol/conformance@0.2.0-alpha.8 client --command "dart run test/conformance/mcp_2026_07_28_rc_client.dart" --suite all --spec-version 2025-11-25 diff --git a/CHANGELOG.md b/CHANGELOG.md index c60139b4..c41f63b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,22 +8,22 @@ ### Conformance and interoperability -- Documented that published TypeScript SDK `2.0.0-alpha.3` packages are not yet - valid replacements for the `pkg.pr.new` 2026-07-28 RC interop fixture because - the alpha.3 client is missing the preview negotiation API used by the fixture. +- Re-pinned the TypeScript SDK 2026-07-28 RC interop fixture from `pkg.pr.new` + previews to published `@modelcontextprotocol/client@2.0.0-beta.1` and + `@modelcontextprotocol/server@2.0.0-beta.1` packages after verifying both + Dart -> TypeScript and TypeScript -> Dart 2026 draft/RC paths. - Updated official conformance gates to - `@modelcontextprotocol/conformance@0.2.0-alpha.7`. The 2026-07-28 RC server suite - now has no expected failures; the 2026 client suite keeps only the upstream + `@modelcontextprotocol/conformance@0.2.0-alpha.8`, adding the new stateless + diagnostic probes for missing client capabilities, response-stream shape, and + request-scoped logging. The 2026-07-28 RC server suite now has no expected + failures; the 2026 client suite keeps only the upstream `json-schema-ref-no-deref` fixture gap expected. -- Added a dedicated CI workflow for the TypeScript SDK 2026-07-28 RC preview +- Added a dedicated CI workflow for the TypeScript SDK 2026-07-28 RC beta interop fixture on relevant PRs, `dev/2026-07-28-rc` pushes, daily schedule, and manual dispatch. - Added an MCP 2026-07-28 draft/RC spec coverage matrix that maps the opt-in - profile to official conformance, local tests, and TypeScript preview interop. -- Re-pinned the TypeScript SDK 2026-07-28 RC interop fixture to - `pkg.pr.new` previews from the merged `v2-2026-07-28` branch head for both - client and server packages. -- Switched the reverse Dart preview client -> TypeScript preview server fixture + profile to official conformance, local tests, and TypeScript SDK beta interop. +- Switched the reverse Dart 2026 client -> TypeScript SDK beta server fixture to the TypeScript SDK's 2026 HTTP handler entry, making `server/discover`, `tools/list`, and `tools/call` strict interop checks instead of diagnostic skips. diff --git a/README.md b/README.md index db660c1c..d7d3151a 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ for opt-in behavior, fallback rules, and draft-only APIs. - ๐Ÿงช **[SDK Interoperability Matrix](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/interoperability.md)** - Verified Dart/TypeScript and documented cross-SDK scenarios - โœ… **[MCP 2025-11-25 Spec Coverage Matrix](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/spec-coverage-2025-11-25.md)** - Auditable coverage map with CLI conformance cases and known gaps -- ๐Ÿงช **[MCP 2026-07-28 Draft/RC Spec Coverage Matrix](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/spec-coverage-2026-07-28-rc.md)** - Opt-in draft/RC coverage map across official conformance and TypeScript preview interop +- ๐Ÿงช **[MCP 2026-07-28 Draft/RC Spec Coverage Matrix](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/spec-coverage-2026-07-28-rc.md)** - Opt-in draft/RC coverage map across official conformance and TypeScript SDK beta interop - ๐Ÿงญ **[MCP 2026-07-28 Draft/RC Transition Guide](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/mcp-2026-07-28-rc.md)** - Opt-in profile, fallback behavior, and draft-only APIs - ๐Ÿ”’ **[Transport Security Recipes](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/transports.md#dns-rebinding-protection)** - Host/Origin allowlists, OAuth layering, and compatibility-toggle trade-offs - ๐Ÿ“ฑ **[Flutter Recipes](https://github.com/leehack/mcp_dart/blob/dev/2026-07-28-rc/doc/flutter-recipes.md)** - Flutter Web, mobile, and desktop host/client guidance diff --git a/doc/interoperability.md b/doc/interoperability.md index 0ebefd48..35ef7317 100644 --- a/doc/interoperability.md +++ b/doc/interoperability.md @@ -23,8 +23,8 @@ For MCP 2026-07-28 draft/RC coverage, see the | Dart client -> TypeScript SDK server | Streamable HTTP | `2025-11-25` | [`test/interop/dart_client_with_ts_server_test.dart`](../test/interop/dart_client_with_ts_server_test.dart), [`test/interop/ts/`](../test/interop/ts/) | Verified | Covers tool calls and stale preconfigured session-id recovery. | | TypeScript SDK client -> Dart server | stdio | `2025-11-25` | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/test_dart_server.dart`](../test/interop/test_dart_server.dart) | Verified | Runs the compiled TypeScript client fixture against a Dart server process and checks that an official TS client can list tools immediately after the lifecycle handshake. | | TypeScript SDK client -> Dart server | Streamable HTTP | `2025-11-25` | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/test_dart_server.dart`](../test/interop/test_dart_server.dart) | Verified | Includes official TS Streamable HTTP client lifecycle coverage, pre-`initialized` operation rejection, GET SSE streams, and `Last-Event-ID` replay behavior. | -| TypeScript SDK preview client -> Dart server | Streamable HTTP | `2026-07-28` draft/RC | [`test/interop/ts_2026_07_28_rc/`](../test/interop/ts_2026_07_28_rc/), [`tool/testing/run_ts_2026_07_28_rc_interop.dart`](../tool/testing/run_ts_2026_07_28_rc_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Automated preview check | Uses pinned `pkg.pr.new` previews from the TypeScript SDK `v2-2026-07-28` branch head. Covers modern negotiation, cache metadata, `tools/list`, `tools/call`, `x-mcp-header` mirroring, raw header and unsupported-version rejection, removed core RPC rejection, progress notifications, `subscriptions/listen`, and HTTP SSE cancellation against the Dart 2026-07-28 RC conformance server. | -| Dart preview client -> TypeScript SDK preview server | Streamable HTTP | `2026-07-28` draft/RC | [`test/interop/ts_2026_07_28_rc/src/server.mjs`](../test/interop/ts_2026_07_28_rc/src/server.mjs), [`tool/testing/run_ts_2026_07_28_rc_interop.dart`](../tool/testing/run_ts_2026_07_28_rc_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Automated preview check | Uses the pinned TypeScript SDK server preview through its `createMcpHandler` entry and covers `server/discover` negotiation, `tools/list`, and `tools/call`. | +| TypeScript SDK beta client -> Dart server | Streamable HTTP | `2026-07-28` draft/RC | [`test/interop/ts_2026_07_28_rc/`](../test/interop/ts_2026_07_28_rc/), [`tool/testing/run_ts_2026_07_28_rc_interop.dart`](../tool/testing/run_ts_2026_07_28_rc_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Automated 2026 check | Uses published `@modelcontextprotocol/client@2.0.0-beta.1` and `@modelcontextprotocol/server@2.0.0-beta.1` packages. Covers modern negotiation, cache metadata, `tools/list`, `tools/call`, `x-mcp-header` mirroring, raw header and unsupported-version rejection, removed core RPC rejection, progress notifications, `subscriptions/listen`, and HTTP SSE cancellation against the Dart 2026-07-28 RC conformance server. | +| Dart 2026 client -> TypeScript SDK beta server | Streamable HTTP | `2026-07-28` draft/RC | [`test/interop/ts_2026_07_28_rc/src/server.mjs`](../test/interop/ts_2026_07_28_rc/src/server.mjs), [`tool/testing/run_ts_2026_07_28_rc_interop.dart`](../tool/testing/run_ts_2026_07_28_rc_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Automated 2026 check | Uses the published TypeScript SDK beta server through its `createMcpHandler` entry and covers `server/discover` negotiation, `tools/list`, and `tools/call`. | | Dart client -> Python MCP server | stdio | Server-dependent | [`doc/transports.md`](transports.md#connect-to-python-server) | Documented recipe | The transport can spawn Python servers over stdio, but this repo does not yet include an automated Python SDK fixture. | | Flutter/Web client -> Dart server | Streamable HTTP | `2026-07-28` draft/RC preview with stable fallback | [`example/flutter_http_client/`](../example/flutter_http_client/), [`doc/flutter-recipes.md`](flutter-recipes.md) | Documented recipe | Flutter Web cannot spawn stdio servers; use Streamable HTTP or another browser-safe transport. The example opts into preview negotiation while retaining stable fallback. | | MCP Apps host/client metadata | stdio or Streamable HTTP | `2026-07-28` draft/RC preview plus `io.modelcontextprotocol/ui` extension | [`doc/mcp-apps.md`](mcp-apps.md), [`example/mcp_apps_helpers_server.dart`](../example/mcp_apps_helpers_server.dart), [`test/types/mcp_ui_test.dart`](../test/types/mcp_ui_test.dart), [`test/server/mcp_ui_test.dart`](../test/server/mcp_ui_test.dart) | Verified | Verified coverage is limited to SDK metadata helpers, serialization, and checked-in examples; host rendering behavior varies by host, so verify UI metadata against your target host. | @@ -46,8 +46,8 @@ dart test --tags interop If the compiled fixtures are missing, local test runs skip the interop groups; CI should fail when required fixtures are unavailable. -The TypeScript 2026-07-28 RC fixture uses unreleased `pkg.pr.new` previews from -the TypeScript SDK `v2-2026-07-28` branch: +The TypeScript 2026-07-28 RC fixture uses the published TypeScript SDK beta +packages: ```bash # From repository root @@ -57,17 +57,15 @@ cd ../../.. dart run tool/testing/run_ts_2026_07_28_rc_interop.dart ``` -This starts the Dart 2026-07-28 RC conformance server, runs the pinned TypeScript SDK -preview client against it, then runs the reverse Dart preview client smoke check -against the TypeScript preview server. +This starts the Dart 2026-07-28 RC conformance server, runs the pinned TypeScript +SDK beta client against it, then runs the reverse Dart 2026 client smoke check +against the TypeScript SDK beta server. -Published `@modelcontextprotocol/client@2.0.0-alpha.3` and -`@modelcontextprotocol/server@2.0.0-alpha.3` packages are not yet suitable -replacements for this fixture: the published alpha.3 client does not expose the -preview `versionNegotiation` / `getProtocolEra` APIs used here, and a direct -`supportedProtocolVersions: ["2026-07-28"]` repin fails the handshake. Keep the -`pkg.pr.new` pin until a published TypeScript SDK package exposes the 2026 draft -path and this interop runner passes against it. +The fixture previously depended on `pkg.pr.new` artifacts because published +`2.0.0-alpha.3` packages did not expose the preview negotiation API used here. +`@modelcontextprotocol/client@2.0.0-beta.1` and +`@modelcontextprotocol/server@2.0.0-beta.1` expose the required modern path and +the interop runner passes against them. CI also runs this fixture in the dedicated `Run MCP 2026-07-28 TypeScript Interop` workflow for relevant PRs, @@ -96,12 +94,7 @@ When adding a new interoperability claim: ## Known gaps worth tracking - Automated Python SDK fixture coverage. -- Re-pin the TypeScript 2026-07-28 RC interop fixture to a published upstream - alpha package once that package exposes the 2026 draft path. The published - `@modelcontextprotocol/client/server@2.0.0-alpha.3` packages are not enough - because the client is missing the preview negotiation API used by this - fixture. -- Broader reverse-path TypeScript preview server coverage beyond discovery, +- Broader reverse-path TypeScript SDK beta server coverage beyond discovery, `tools/list`, and `tools/call`. - Host-specific MCP Apps rendering compatibility notes. - More OAuth-protected remote server scenarios beyond the checked-in examples. diff --git a/doc/mcp-2026-07-28-rc.md b/doc/mcp-2026-07-28-rc.md index c6ad40ed..5431d2a7 100644 --- a/doc/mcp-2026-07-28-rc.md +++ b/doc/mcp-2026-07-28-rc.md @@ -131,7 +131,7 @@ Before creating follow-up dev tags from `dev/2026-07-28-rc`, run: ```sh dart analyze dart run test/conformance/run_2025_server_conformance.dart -npx -y @modelcontextprotocol/conformance@0.2.0-alpha.7 client \ +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.8 client \ --command "dart run test/conformance/mcp_2026_07_28_rc_client.dart" \ --suite all \ --spec-version 2025-11-25 @@ -147,21 +147,20 @@ dart run tool/validate_cli_publish.dart ``` The `run_2026_07_28_rc_server_conformance.dart` gate runs the full -`@modelcontextprotocol/conformance@0.2.0-alpha.7` server scenario list for +`@modelcontextprotocol/conformance@0.2.0-alpha.8` server scenario list for `--spec-version 2026-07-28`, including the stable-style tool, resource, prompt, completion, and JSON Schema scenarios that the alpha package tags for the RC. -For cross-SDK smoke coverage against the TypeScript SDK 2026 preview packages, +For cross-SDK smoke coverage against the TypeScript SDK 2026 beta packages, run the fixture documented in [`doc/interoperability.md`](interoperability.md#running-interop-checks-locally). CI also runs it in the dedicated `Run MCP 2026-07-28 TypeScript Interop` workflow on relevant PRs, `dev/2026-07-28-rc` pushes, a daily schedule, and manual dispatch. Keep it pinned to the draft behavior rather than TypeScript -preview behavior alone; re-pin from `pkg.pr.new` to a published TypeScript SDK -alpha only when the published package advertises `2026-07-28` support. The -published `@modelcontextprotocol/client/server@2.0.0-alpha.3` packages are -missing the preview negotiation API used by this fixture, so they are not a -replacement for the current preview pin. +preview behavior alone. The fixture is currently pinned to published +`@modelcontextprotocol/client@2.0.0-beta.1` and +`@modelcontextprotocol/server@2.0.0-beta.1`, which expose the 2026 negotiation +path and pass the checked-in interop runner. For dev packages, keep package documentation links pointed at `dev/2026-07-28-rc` until the draft work is ready to merge back to `main`. diff --git a/doc/spec-coverage-2026-07-28-rc.md b/doc/spec-coverage-2026-07-28-rc.md index 95c3d29f..0da5f300 100644 --- a/doc/spec-coverage-2026-07-28-rc.md +++ b/doc/spec-coverage-2026-07-28-rc.md @@ -14,7 +14,7 @@ Run the official conformance gates from the repository root: ```bash dart run test/conformance/run_2025_server_conformance.dart -npx -y @modelcontextprotocol/conformance@0.2.0-alpha.7 client \ +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.8 client \ --command "dart run test/conformance/mcp_2026_07_28_rc_client.dart" \ --suite all \ --spec-version 2025-11-25 @@ -22,7 +22,7 @@ dart run test/conformance/run_2026_07_28_rc_server_conformance.dart dart run test/conformance/run_2026_07_28_rc_client_conformance.dart ``` -Run the TypeScript preview interop gate from the repository root: +Run the TypeScript SDK beta interop gate from the repository root: ```bash cd test/interop/ts_2026_07_28_rc @@ -32,7 +32,7 @@ dart run tool/testing/run_ts_2026_07_28_rc_interop.dart ``` CI runs the official conformance gates in the core workflow. The -`Run MCP 2026-07-28 TypeScript Interop` workflow runs the TypeScript preview +`Run MCP 2026-07-28 TypeScript Interop` workflow runs the TypeScript SDK beta interop fixture on relevant PRs, `dev/2026-07-28-rc` pushes, a daily schedule, and manual dispatch. @@ -40,29 +40,23 @@ and manual dispatch. | Spec area | Draft source | Requirement tracked here | Local coverage | Cross-SDK coverage | Official conformance | Status | | --- | --- | --- | --- | --- | --- | --- | -| Opt-in profile and stable default | [Versioning and compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Stable MCP `2025-11-25` remains default, while 2026 behavior is selected explicitly with preview or require profiles. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`doc/mcp-2026-07-28-rc.md`](mcp-2026-07-28-rc.md) | TypeScript preview interop uses explicit 2026 clients and servers only. | 2025 and 2026 conformance both run in CI. | Verified | -| Version negotiation and discovery | [Discovery](https://modelcontextprotocol.io/specification/draft/server/discover), [Versioning](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Servers implement `server/discover`, advertise supported versions and capabilities, reject unsupported versions with draft error data, and clients retry or fall back according to transport-era rules. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart), [`test/conformance/mcp_2026_07_28_rc_client.dart`](../test/conformance/mcp_2026_07_28_rc_client.dart) | [`tool/testing/run_ts_2026_07_28_rc_interop.dart`](../tool/testing/run_ts_2026_07_28_rc_interop.dart) validates TypeScript preview client -> Dart server and Dart preview client -> TypeScript preview server discovery. | `protocol-version`, `server/discover`, and client negotiation scenarios in alpha.7. | Verified | -| Stateless request metadata | [Overview](https://modelcontextprotocol.io/specification/draft/basic), [Versioning](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Every 2026 request carries protocol version, client identity, and client capabilities in `_meta`; servers do not infer protocol state from a prior request. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | TypeScript preview client fixture exercises normal request paths with 2026 metadata. | `stateless` and `stateless-http` scenarios in alpha.7. | Verified | -| Streamable HTTP routing headers | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog), [Transports](https://modelcontextprotocol.io/specification/draft/basic/transports) | 2026 HTTP POST requests include required protocol, method, name, and parameter-routing headers; mismatches reject with draft header errors. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | TypeScript preview client fixture validates `x-mcp-header` mirroring and raw header rejection against the Dart server. | `stateless-http.requires-routing-headers`, `stateless-http.validates-parameter-headers`, and related alpha.7 cases. | Verified | -| Removed session and resumability behavior | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog) | 2026 Streamable HTTP omits protocol-level sessions, rejects removed GET/DELETE behaviors, rejects JSON-RPC batches, and treats closed SSE response streams as request cancellation without legacy redelivery. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_mcp_server_test.dart`](../test/server/streamable_mcp_server_test.dart) | TypeScript preview fixture closes the Dart SSE response stream and verifies no legacy `notifications/cancelled` side effect is required. | `stateless-http.rejects-non-post-methods`, `stateless-http.rejects-batch-payloads`, and related alpha.7 cases. | Verified | -| Cacheable results and deterministic lists | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog), [Discovery](https://modelcontextprotocol.io/specification/draft/server/discover) | `server/discover`, list, and read responses include `resultType`, `ttlMs`, and `cacheScope`; stateless `tools/list` is deterministic and omits stable-only tool execution metadata. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart) | TypeScript preview client fixture checks discovery cache metadata and `tools/list` cache metadata. | Cacheable-result and tools-list scenarios in alpha.7. | Verified | -| Tools and JSON Schema 2020-12 | [Tools](https://modelcontextprotocol.io/specification/draft/server/tools), [Overview JSON Schema usage](https://modelcontextprotocol.io/specification/draft/basic) | Tool schemas preserve JSON Schema 2020-12 constructs, including nested boolean schemas; stable root-object compatibility remains intact for 2025 behavior. | [`test/tool_schema_test.dart`](../test/tool_schema_test.dart), [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart) | TypeScript preview fixture validates `tools/list` and `tools/call`; deeper schema semantics are covered by local and conformance tests. | 2026 server suite is green; 2026 client suite keeps the upstream `json-schema-ref-no-deref` fixture gap expected. | Verified with one upstream client fixture gap | -| MRTR and elicitation | [Message patterns](https://modelcontextprotocol.io/specification/draft/basic) | 2026 `input_required` results are emitted only for supported requests and require advertised client capabilities. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/elicitation_test.dart`](../test/elicitation_test.dart) | TypeScript preview client fixture completes a 2026 `input_required` retry flow against the Dart server. | `mrtr` scenarios in alpha.7. | Verified | -| Subscriptions | [Subscriptions](https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions), [Schema reference](https://modelcontextprotocol.io/specification/draft/schema#subscriptionslistenresult) | `subscriptions/listen` acknowledges before list-change notifications, filters unsupported notification types, correlates notifications through `io.modelcontextprotocol/subscriptionId`, and returns `SubscriptionsListenResult` with the same required subscription id metadata on graceful close. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart) | TypeScript preview fixture validates `subscriptions/listen` acknowledgment and list-change notification correlation against the Dart server. | Subscription scenarios in alpha.7. | Verified | -| Request-scoped logging and removed core RPCs | [Logging](https://modelcontextprotocol.io/specification/draft/server/utilities/logging), [Key changes](https://modelcontextprotocol.io/specification/draft/changelog) | 2026 stateless requests use request-scoped logging metadata, and removed stable-era core RPCs/notifications are rejected in the 2026 profile. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/server_advanced_test.dart`](../test/server/server_advanced_test.dart) | TypeScript preview fixture validates raw removed-RPC rejection against the Dart server. | Removed-RPC and logging scenarios in alpha.7. | Verified | +| Opt-in profile and stable default | [Versioning and compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Stable MCP `2025-11-25` remains default, while 2026 behavior is selected explicitly with preview or require profiles. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`doc/mcp-2026-07-28-rc.md`](mcp-2026-07-28-rc.md) | TypeScript SDK beta interop uses explicit 2026 clients and servers only. | 2025 and 2026 conformance both run in CI. | Verified | +| Version negotiation and discovery | [Discovery](https://modelcontextprotocol.io/specification/draft/server/discover), [Versioning](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Servers implement `server/discover`, advertise supported versions and capabilities, reject unsupported versions with draft error data, and clients retry or fall back according to transport-era rules. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart), [`test/conformance/mcp_2026_07_28_rc_client.dart`](../test/conformance/mcp_2026_07_28_rc_client.dart) | [`tool/testing/run_ts_2026_07_28_rc_interop.dart`](../tool/testing/run_ts_2026_07_28_rc_interop.dart) validates TypeScript SDK beta client -> Dart server and Dart 2026 client -> TypeScript SDK beta server discovery. | `protocol-version`, `server/discover`, and client negotiation scenarios in alpha.8. | Verified | +| Stateless request metadata | [Overview](https://modelcontextprotocol.io/specification/draft/basic), [Versioning](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Every 2026 request carries protocol version, client identity, and client capabilities in `_meta`; servers do not infer protocol state from a prior request. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | TypeScript SDK beta client fixture exercises normal request paths with 2026 metadata. | `stateless` and `stateless-http` scenarios in alpha.8. | Verified | +| Streamable HTTP routing headers | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog), [Transports](https://modelcontextprotocol.io/specification/draft/basic/transports) | 2026 HTTP POST requests include required protocol, method, name, and parameter-routing headers; mismatches reject with draft header errors. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | TypeScript SDK beta client fixture validates `x-mcp-header` mirroring and raw header rejection against the Dart server. | `stateless-http.requires-routing-headers`, `stateless-http.validates-parameter-headers`, and related alpha.8 cases. | Verified | +| Removed session and resumability behavior | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog) | 2026 Streamable HTTP omits protocol-level sessions, rejects removed GET/DELETE behaviors, rejects JSON-RPC batches, and treats closed SSE response streams as request cancellation without legacy redelivery. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_mcp_server_test.dart`](../test/server/streamable_mcp_server_test.dart) | TypeScript SDK beta fixture closes the Dart SSE response stream and verifies no legacy `notifications/cancelled` side effect is required. | `stateless-http.rejects-non-post-methods`, `stateless-http.rejects-batch-payloads`, and related alpha.8 cases. | Verified | +| Cacheable results and deterministic lists | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog), [Discovery](https://modelcontextprotocol.io/specification/draft/server/discover) | `server/discover`, list, and read responses include `resultType`, `ttlMs`, and `cacheScope`; stateless `tools/list` is deterministic and omits stable-only tool execution metadata. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart) | TypeScript SDK beta client fixture checks discovery cache metadata and `tools/list` cache metadata. | Cacheable-result and tools-list scenarios in alpha.8. | Verified | +| Tools and JSON Schema 2020-12 | [Tools](https://modelcontextprotocol.io/specification/draft/server/tools), [Overview JSON Schema usage](https://modelcontextprotocol.io/specification/draft/basic) | Tool schemas preserve JSON Schema 2020-12 constructs, including nested boolean schemas; stable root-object compatibility remains intact for 2025 behavior. | [`test/tool_schema_test.dart`](../test/tool_schema_test.dart), [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart) | TypeScript SDK beta fixture validates `tools/list` and `tools/call`; deeper schema semantics are covered by local and conformance tests. | 2026 server suite is green; 2026 client suite keeps the upstream `json-schema-ref-no-deref` fixture gap expected. | Verified with one upstream client fixture gap | +| MRTR and elicitation | [Message patterns](https://modelcontextprotocol.io/specification/draft/basic) | 2026 `input_required` results are emitted only for supported requests and require advertised client capabilities. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/elicitation_test.dart`](../test/elicitation_test.dart) | TypeScript SDK beta client fixture completes a 2026 `input_required` retry flow against the Dart server. | `mrtr` scenarios in alpha.8. | Verified | +| Subscriptions | [Subscriptions](https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions), [Schema reference](https://modelcontextprotocol.io/specification/draft/schema#subscriptionslistenresult) | `subscriptions/listen` acknowledges before list-change notifications, filters unsupported notification types, correlates notifications through `io.modelcontextprotocol/subscriptionId`, and returns `SubscriptionsListenResult` with the same required subscription id metadata on graceful close. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_rc_server.dart`](../test/conformance/mcp_2026_07_28_rc_server.dart) | TypeScript SDK beta fixture validates `subscriptions/listen` acknowledgment and list-change notification correlation against the Dart server. | Subscription scenarios in alpha.8. | Verified | +| Request-scoped logging and removed core RPCs | [Logging](https://modelcontextprotocol.io/specification/draft/server/utilities/logging), [Key changes](https://modelcontextprotocol.io/specification/draft/changelog) | 2026 stateless requests use request-scoped logging metadata, and removed stable-era core RPCs/notifications are rejected in the 2026 profile. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/server_advanced_test.dart`](../test/server/server_advanced_test.dart) | TypeScript SDK beta fixture validates raw removed-RPC rejection against the Dart server. | Removed-RPC and logging scenarios in alpha.8. | Verified | | Draft-only public APIs | [Schema reference](https://modelcontextprotocol.io/specification/draft/schema) | APIs that are useful only for 2026, such as non-object structured tool output and 2026 protocol profiles, are documented as draft/RC APIs and do not change stable defaults. | [`doc/mcp-2026-07-28-rc.md`](mcp-2026-07-28-rc.md), public dartdoc on protocol profiles and draft-only helpers. | Not cross-SDK specific. | Covered indirectly by 2026 conformance and local parser/serializer tests. | Verified | ## Known Gaps - The official conformance package is still alpha. The 2026 client suite keeps - `json-schema-ref-no-deref` expected-failed because the alpha.7 mock server for + `json-schema-ref-no-deref` expected-failed because the alpha.8 mock server for that scenario still behaves like a stable-only server. -- The TypeScript 2026-07-28 fixture depends on `pkg.pr.new` preview artifacts - from the TypeScript SDK branch. Keep the CI workflow, but re-pin it to a - published alpha package once upstream provides one that advertises the 2026 - draft path. The published `@modelcontextprotocol/client/server@2.0.0-alpha.3` - packages are missing the preview negotiation API used by this fixture, so - they are not a valid replacement for the current preview pin. -- The reverse Dart preview client -> TypeScript preview server path currently +- The reverse Dart 2026 client -> TypeScript SDK beta server path currently covers discovery, `tools/list`, and `tools/call`. Broader reverse-path - coverage should follow as the TypeScript preview server surface stabilizes. + coverage should follow as the TypeScript SDK beta server surface stabilizes. diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index 9447e376..5e5ebd89 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -15,6 +15,9 @@ import 'tasks.dart'; final _logger = Logger("mcp_dart.server.mcp"); +const _requiredClientCapabilitiesMetaKey = + 'io.modelcontextprotocol/requiredClientCapabilities'; + /// Callback capable of providing completions for a partial value. typedef CompleteCallback = FutureOr> Function(String value); @@ -1083,7 +1086,18 @@ class McpServer { /// Connects the server to a communication [transport]. Future connect(Transport transport) async { _syncToolParameterHeaderMappings(transport); - return await server.connect(transport); + await server.connect(transport); + if (transport is IncomingRequestValidationAwareTransport) { + final validationAwareTransport = + transport as IncomingRequestValidationAwareTransport; + validationAwareTransport.setIncomingRequestValidator((request) { + final protocolError = server.validateIncomingRequest(request); + if (protocolError != null) { + return protocolError; + } + return _validateToolClientCapabilities(request); + }); + } } /// Closes the server connection. @@ -1094,6 +1108,77 @@ class McpServer { /// Checks if the server is connected to a transport. bool get isConnected => server.transport != null; + McpError? _validateToolClientCapabilities(JsonRpcRequest request) { + if (request is! JsonRpcCallToolRequest || + !_isDraft2026Request(request.meta?[McpMetaKey.protocolVersion])) { + return null; + } + + final tool = _registeredTools[request.callParams.name]; + if (tool == null) { + return null; + } + + final requiredCapabilities = _requiredClientCapabilities(tool); + if (requiredCapabilities.isEmpty) { + return null; + } + + final clientCapabilitiesValue = + request.meta?[McpMetaKey.clientCapabilities]; + if (clientCapabilitiesValue is! Map) { + return McpError( + ErrorCode.invalidParams.value, + 'Missing required request metadata: ${McpMetaKey.clientCapabilities}', + ); + } + + final clientCapabilities = ClientCapabilities.fromJson( + clientCapabilitiesValue.cast(), + ); + final missingCapabilities = requiredCapabilities + .where( + (capability) => !_hasClientCapability(clientCapabilities, capability), + ) + .toList(growable: false); + if (missingCapabilities.isEmpty) { + return null; + } + + return McpError( + ErrorCode.missingRequiredClientCapability.value, + 'Missing required client capability', + {'requiredCapabilities': missingCapabilities}, + ); + } + + List _requiredClientCapabilities(_RegisteredToolImpl tool) { + final value = tool.meta?[_requiredClientCapabilitiesMetaKey]; + if (value is String) { + return [value]; + } + if (value is Iterable) { + return value.whereType().toList(growable: false); + } + return const []; + } + + bool _hasClientCapability( + ClientCapabilities capabilities, + String capability, + ) { + return switch (capability) { + 'sampling' => capabilities.sampling != null, + 'roots' => capabilities.roots != null, + 'elicitation' => capabilities.elicitation != null, + 'tasks' => capabilities.tasks != null, + _ => capabilities.additionalCapabilities?.containsKey(capability) ?? + capabilities.experimental?.containsKey(capability) ?? + capabilities.extensions?.containsKey(capability) ?? + false, + }; + } + /// Sends a logging message to the client, if connected. /// /// For stateless MCP requests, pass [requestMeta] from diff --git a/test/conformance/2026_07_28_rc_client_expected_failures.txt b/test/conformance/2026_07_28_rc_client_expected_failures.txt index fd91ef9e..2cbf84be 100644 --- a/test/conformance/2026_07_28_rc_client_expected_failures.txt +++ b/test/conformance/2026_07_28_rc_client_expected_failures.txt @@ -1,10 +1,10 @@ -# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.7 +# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.8 # against the 2026-07-28 RC/DRAFT client suite. # # Keep this list scenario-based so the baseline is easy to review. When a # scenario turns green, remove it from this file in the same PR as the fix. # -# Upstream alpha.7 fixture gap: this scenario's mock server still rejects +# Upstream alpha.8 fixture gap: this scenario's mock server still rejects # 2026-07-28 with HTTP 400 and advertises only stable protocol versions. # Keep it expected-fail until the conformance fixture is draft-capable. json-schema-ref-no-deref diff --git a/test/conformance/2026_07_28_rc_expected_failures.txt b/test/conformance/2026_07_28_rc_expected_failures.txt index 20a0c852..21abbea0 100644 --- a/test/conformance/2026_07_28_rc_expected_failures.txt +++ b/test/conformance/2026_07_28_rc_expected_failures.txt @@ -1,6 +1,8 @@ -# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.7 +# Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.8 # against the full 2026-07-28 RC/DRAFT server suite. # +# The alpha.8 server suite has no expected failures against the Dart fixture. +# # Keep this list scenario-based so the baseline is easy to review. When a # scenario turns green, remove it from this file in the same PR as the fix. # diff --git a/test/conformance/README.md b/test/conformance/README.md index 2f494251..e7a5c6b7 100644 --- a/test/conformance/README.md +++ b/test/conformance/README.md @@ -26,14 +26,14 @@ dart run test/conformance/run_2025_server_conformance.dart ``` The runner starts `mcp_2025_server.dart`, runs -`@modelcontextprotocol/conformance@0.2.0-alpha.7 server --suite all +`@modelcontextprotocol/conformance@0.2.0-alpha.8 server --suite all --spec-version 2025-11-25`, and writes artifacts under `.dart_tool/conformance/2025_server/`. Run the stable client suite from the repository root: ```bash -npx -y @modelcontextprotocol/conformance@0.2.0-alpha.7 client \ +npx -y @modelcontextprotocol/conformance@0.2.0-alpha.8 client \ --command "dart run test/conformance/mcp_2026_07_28_rc_client.dart" \ --suite all \ --spec-version 2025-11-25 \ @@ -55,14 +55,14 @@ dart run test/conformance/run_2026_07_28_rc_server_conformance.dart The runner starts a local `StreamableMcpServer` in default Streamable HTTP SSE response mode, runs the full `2026-07-28` server scenario list from -`@modelcontextprotocol/conformance@0.2.0-alpha.7` one by one with `--suite all` +`@modelcontextprotocol/conformance@0.2.0-alpha.8` one by one with `--suite all` and `--spec-version 2026-07-28`, and writes per-run artifacts under `.dart_tool/conformance/2026_07_28_rc/`. Expected failures live in `2026_07_28_rc_expected_failures.txt`. When a scenario is fixed, remove it from that file so the baseline remains useful. -As of `@modelcontextprotocol/conformance@0.2.0-alpha.7`, the full 2026-07-28 RC server +As of `@modelcontextprotocol/conformance@0.2.0-alpha.8`, the full 2026-07-28 RC server suite has no expected failures against the Dart fixture. Run the current client baseline from the repository root: diff --git a/test/conformance/mcp_2026_07_28_rc_client.dart b/test/conformance/mcp_2026_07_28_rc_client.dart index b63ff3bf..a6b1cdf5 100644 --- a/test/conformance/mcp_2026_07_28_rc_client.dart +++ b/test/conformance/mcp_2026_07_28_rc_client.dart @@ -251,7 +251,6 @@ Future _runCustomHeaders( 'debug': 'Debug', 'empty_val': 'EmptyVal', 'method_val': 'Method', - 'float_val': 'FloatVal', 'non_ascii_val': 'NonAscii', 'whitespace_val': 'Whitespace', 'leading_space_val': 'LeadingSpace', diff --git a/test/conformance/mcp_2026_07_28_rc_server.dart b/test/conformance/mcp_2026_07_28_rc_server.dart index 868c4fe5..3473c1e4 100644 --- a/test/conformance/mcp_2026_07_28_rc_server.dart +++ b/test/conformance/mcp_2026_07_28_rc_server.dart @@ -104,12 +104,52 @@ McpServer _createConformanceServer() { }, ); + _registerAlpha8StatelessDiagnostics(server); _registerStreamDiagnostics(server); _registerInputRequiredDiagnostics(server); return server; } +void _registerAlpha8StatelessDiagnostics(McpServer server) { + server.registerTool( + 'test_missing_capability', + description: + 'Requires sampling so missing client capability handling can be validated', + meta: const { + 'io.modelcontextprotocol/requiredClientCapabilities': ['sampling'], + }, + callback: (args, extra) async { + return _textResult('sampling capability present'); + }, + ); + + server.registerTool( + 'test_streaming_elicitation', + description: + 'Returns a normal result so response-stream frame shape can be validated', + callback: (args, extra) async { + return _textResult('stream-shape-ok'); + }, + ); + + server.registerTool( + 'test_logging_tool', + description: + 'Attempts request-scoped logging for the stateless log-level diagnostic', + callback: (args, extra) async { + await server.sendLoggingMessage( + const LoggingMessageNotification( + level: LoggingLevel.info, + data: 'log-level diagnostic', + ), + requestMeta: extra.meta, + ); + return _textResult('logging-ok'); + }, + ); +} + void _registerStreamDiagnostics(McpServer server) { server.registerTool( 'test_stream_cancellation', diff --git a/test/conformance/run_2025_server_conformance.dart b/test/conformance/run_2025_server_conformance.dart index e03fd9f4..cf3aa87f 100644 --- a/test/conformance/run_2025_server_conformance.dart +++ b/test/conformance/run_2025_server_conformance.dart @@ -3,10 +3,10 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.7'; + '@modelcontextprotocol/conformance@0.2.0-alpha.8'; const _defaultTimeout = Duration(seconds: 60); -// The alpha.7 conformance CLI occasionally leaks or stalls server-initiated +// The alpha conformance CLI can occasionally leak or stall server-initiated // elicitation state when the complete 2025 server suite is run in one process // on GitHub's Linux runners. Running each pinned scenario in a fresh // conformance process preserves coverage while isolating CLI-side state. diff --git a/test/conformance/run_2026_07_28_rc_client_conformance.dart b/test/conformance/run_2026_07_28_rc_client_conformance.dart index 155254e9..34feddba 100644 --- a/test/conformance/run_2026_07_28_rc_client_conformance.dart +++ b/test/conformance/run_2026_07_28_rc_client_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.7'; + '@modelcontextprotocol/conformance@0.2.0-alpha.8'; const _defaultTimeout = Duration(seconds: 30); const _draftClientScenarios = [ diff --git a/test/conformance/run_2026_07_28_rc_server_conformance.dart b/test/conformance/run_2026_07_28_rc_server_conformance.dart index 321b0cb8..68ccb129 100644 --- a/test/conformance/run_2026_07_28_rc_server_conformance.dart +++ b/test/conformance/run_2026_07_28_rc_server_conformance.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; const _defaultConformancePackage = - '@modelcontextprotocol/conformance@0.2.0-alpha.7'; + '@modelcontextprotocol/conformance@0.2.0-alpha.8'; const _defaultTimeout = Duration(seconds: 25); const _serverScenarios = [ diff --git a/test/interop/ts_2026_07_28_rc/README.md b/test/interop/ts_2026_07_28_rc/README.md index 8ed45e4c..8a294f24 100644 --- a/test/interop/ts_2026_07_28_rc/README.md +++ b/test/interop/ts_2026_07_28_rc/README.md @@ -5,12 +5,12 @@ This fixture is an experimental smoke test for the unreleased MCP progress. It is intentionally separate from `test/interop/ts`, which tracks the published -stable TypeScript SDK and MCP `2025-11-25` behavior. The fixture pins -`pkg.pr.new` client and server previews from the TypeScript SDK -`v2-2026-07-28` branch after PR #2327 landed. The TypeScript client path is a -draft-aligned smoke check against the Dart 2026-07-28 RC server. The reverse Dart -client path is a draft-aligned smoke check against the TypeScript preview -server. +stable TypeScript SDK and MCP `2025-11-25` behavior. The fixture pins published +`@modelcontextprotocol/client@2.0.0-beta.1` and +`@modelcontextprotocol/server@2.0.0-beta.1` packages. The TypeScript client path +is a draft-aligned smoke check against the Dart 2026-07-28 RC server. The +reverse Dart client path is a draft-aligned smoke check against the TypeScript +beta server. ## Run @@ -53,10 +53,10 @@ bound local URL, and then runs `src/client.mjs` against it. The fixture asserts: - Closing a 2026 HTTP SSE response stream cancels the in-flight Dart server request without sending `notifications/cancelled`. -The runner also starts `src/server.mjs` with the TypeScript preview +The runner also starts `src/server.mjs` with the TypeScript beta `createMcpHandler` entry and runs a Dart preview client against it. That reverse path asserts `server/discover` negotiation, `tools/list`, and `tools/call` -against the TypeScript preview server; failures are treated as interop failures. +against the TypeScript beta server; failures are treated as interop failures. Keep this fixture anchored to the official draft/RC behavior rather than the preview TypeScript implementation alone. In particular, `x-mcp-header` tests use @@ -67,11 +67,6 @@ assertion source and document the preview gap near the test. CI runs this fixture in the dedicated `Run MCP 2026-07-28 TypeScript Interop` workflow for relevant PRs, `dev/2026-07-28-rc` pushes, daily scheduled drift checks, and manual dispatch. -Keep the fixture pinned to a published TypeScript SDK alpha once upstream no -longer requires `pkg.pr.new` preview artifacts. Do not treat publication alone -as enough to re-pin: `@modelcontextprotocol/client@2.0.0-alpha.3` and -`@modelcontextprotocol/server@2.0.0-alpha.3` are published, but the published -client does not expose the preview `versionNegotiation` / `getProtocolEra` APIs -used here, and a direct `supportedProtocolVersions: ["2026-07-28"]` repin fails -the handshake. Keep this fixture on the `pkg.pr.new` preview until a published -package exposes the 2026 draft path and this runner passes against it. +Keep the fixture pinned to a published TypeScript SDK beta that exposes the +2026 draft path and passes this runner; do not treat package publication alone +as enough to re-pin without rerunning the interop check. diff --git a/test/interop/ts_2026_07_28_rc/package-lock.json b/test/interop/ts_2026_07_28_rc/package-lock.json index d24447b0..bdbe33f3 100644 --- a/test/interop/ts_2026_07_28_rc/package-lock.json +++ b/test/interop/ts_2026_07_28_rc/package-lock.json @@ -9,8 +9,8 @@ "version": "0.0.0", "dependencies": { "@cfworker/json-schema": "4.1.1", - "@modelcontextprotocol/client": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@9fdb62e", - "@modelcontextprotocol/server": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@9fdb62e" + "@modelcontextprotocol/client": "2.0.0-beta.1", + "@modelcontextprotocol/server": "2.0.0-beta.1" }, "engines": { "node": ">=20" @@ -23,9 +23,9 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0-alpha.2", - "resolved": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@9fdb62e", - "integrity": "sha512-A02KwaeB0p7WJ8TCQB83WLtPpGUK+1h3y4k6IMxfun3VmIiH7jrAw2y72TmSnofwO1GKYr53R142t78sCE5ycg==", + "version": "2.0.0-beta.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.1.tgz", + "integrity": "sha512-1YbnguKN7OFGA/lmCtgIthyE5d+j7dvu7hJld+f0mKrM0YAV4Q8TG80RBto+qyupxhHjTMEWItHu7cQlAmVtyA==", "license": "MIT", "dependencies": { "cross-spawn": "^7.0.5", @@ -40,9 +40,9 @@ } }, "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0-alpha.2", - "resolved": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@9fdb62e", - "integrity": "sha512-4yLquMq4S/d69ISm7XyDsyVDxtih7jGVKCXK1QDqBuxVgFGDN2jY4iZb13kFVD28Wv1IPQ93pHtCEEn7j+0iqw==", + "version": "2.0.0-beta.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.1.tgz", + "integrity": "sha512-83scKauajX9fWbSIC4NBYdnvXajH+cqvX8QqsrPH0XNgqtMmym1kWucMO7YyNkrBD9C7/R+FRCmkjC9kgEKXlQ==", "license": "MIT", "dependencies": { "zod": "^4.2.0" diff --git a/test/interop/ts_2026_07_28_rc/package.json b/test/interop/ts_2026_07_28_rc/package.json index 93f537a3..7c870d8d 100644 --- a/test/interop/ts_2026_07_28_rc/package.json +++ b/test/interop/ts_2026_07_28_rc/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@cfworker/json-schema": "4.1.1", - "@modelcontextprotocol/client": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@9fdb62e", - "@modelcontextprotocol/server": "https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@9fdb62e" + "@modelcontextprotocol/client": "2.0.0-beta.1", + "@modelcontextprotocol/server": "2.0.0-beta.1" }, "engines": { "node": ">=20" diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 91081152..bd64c977 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -45,6 +45,24 @@ class RecordingTransport extends Transport { } } +class ValidationRecordingTransport extends RecordingTransport + implements IncomingRequestValidationAwareTransport { + McpError? Function(JsonRpcRequest request)? incomingRequestValidator; + bool Function(String method)? requestMethodSupported; + + @override + void setIncomingRequestValidator( + McpError? Function(JsonRpcRequest request) validator, + ) { + incomingRequestValidator = validator; + } + + @override + void setRequestMethodSupported(bool Function(String method) isSupported) { + requestMethodSupported = isSupported; + } +} + class SessionRecordingTaskStore extends InMemoryTaskStore { final List createTaskSessionIds = []; final List updateTaskStatusSessionIds = []; @@ -2991,6 +3009,59 @@ void main() { ); }); + test('stateless tools/call rejects missing tool-required client capability', + () async { + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.preview2026, + ), + ); + server.registerTool( + 'needs_sampling', + meta: const { + 'io.modelcontextprotocol/requiredClientCapabilities': ['sampling'], + }, + callback: (args, extra) => const CallToolResult( + content: [TextContent(text: 'ok')], + ), + ); + final transport = ValidationRecordingTransport(); + await server.connect(transport); + final validator = transport.incomingRequestValidator; + expect(validator, isNotNull); + + final missingError = validator!( + JsonRpcCallToolRequest( + id: 'call-1', + params: const CallToolRequest(name: 'needs_sampling').toJson(), + meta: _clientMeta(), + ), + ); + + expect(missingError, isNotNull); + expect( + missingError!.code, + ErrorCode.missingRequiredClientCapability.value, + ); + expect(missingError.data, { + 'requiredCapabilities': ['sampling'], + }); + + final allowedError = validator( + JsonRpcCallToolRequest( + id: 'call-2', + params: const CallToolRequest(name: 'needs_sampling').toJson(), + meta: _clientMeta( + clientCapabilities: const ClientCapabilities( + sampling: ClientCapabilitiesSampling(tools: true), + ), + ), + ), + ); + expect(allowedError, isNull); + }); + test('stateless tools/call ignores legacy task parameter', () async { final server = McpServer( const Implementation(name: 'server', version: '1.0.0'),