From 3ac16368a093a6c726a5358fd4a12b5c35860812 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Sun, 31 May 2026 14:12:44 -0400 Subject: [PATCH] Handle MCP 2026 removed core RPCs --- CHANGELOG.md | 7 + lib/src/server/mcp_server.dart | 11 +- lib/src/server/server.dart | 119 ++++++++++++++++- lib/src/types/initialization.dart | 4 +- lib/src/types/roots.dart | 4 +- test/mcp_2026_07_28_test.dart | 204 ++++++++++++++++++++++++++++++ 6 files changed, 343 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2582b435..ec50557f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ `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. +- Added request-scoped stateless logging gating via + `io.modelcontextprotocol/logLevel` metadata so 2026 log notifications are + emitted only when the current request opts in. ## 2.2.0 diff --git a/lib/src/server/mcp_server.dart b/lib/src/server/mcp_server.dart index 1acb5f76..696be6d8 100644 --- a/lib/src/server/mcp_server.dart +++ b/lib/src/server/mcp_server.dart @@ -984,11 +984,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. diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index dfce9f60..896c2946 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; @@ -336,6 +433,12 @@ class Server extends Protocol { if (metadataError != null) { return metadataError; } + final removedMethodError = _validateStatelessRemovedRequestMethod( + request, + ); + if (removedMethodError != null) { + return removedMethodError; + } return _validateRequestTaskSemantics(request); } @@ -372,6 +475,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: @@ -1027,12 +1136,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/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/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/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 1264fff7..bce67d67 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -189,11 +189,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, ); } @@ -1255,6 +1257,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',