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/lib/src/client/client.dart b/lib/src/client/client.dart index 35defaf5..44e31593 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, }); } @@ -173,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) { @@ -471,10 +468,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( @@ -494,6 +488,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, 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..37a38629 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' && 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_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 5b82dbef..88203da7 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, @@ -2497,12 +2503,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); @@ -2529,6 +2534,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(); @@ -3470,7 +3531,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);