diff --git a/lib/src/server/streamable_mcp_server.dart b/lib/src/server/streamable_mcp_server.dart index ee676206..9cdb615e 100644 --- a/lib/src/server/streamable_mcp_server.dart +++ b/lib/src/server/streamable_mcp_server.dart @@ -707,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']; @@ -726,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; } diff --git a/test/server/streamable_mcp_server_test.dart b/test/server/streamable_mcp_server_test.dart index ac227e6b..3bba902e 100644 --- a/test/server/streamable_mcp_server_test.dart +++ b/test/server/streamable_mcp_server_test.dart @@ -362,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), @@ -383,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();