diff --git a/doc/transports.md b/doc/transports.md index f73a878..35caec5 100644 --- a/doc/transports.md +++ b/doc/transports.md @@ -237,6 +237,56 @@ This helper handles: Use this for remote/browser-exposed deployments. +#### Deployment recipes + +**Safe local development** + +Bind only to loopback and allow the exact browser development origin that needs +to call the MCP endpoint: + +```dart +final server = StreamableMcpServer( + serverFactory: (sessionId) => McpServer( + const Implementation(name: 'local-dev-server', version: '1.0.0'), + ), + host: '127.0.0.1', + port: 3000, + path: '/mcp', + enableDnsRebindingProtection: true, + allowedHosts: {'localhost', '127.0.0.1'}, + allowedOrigins: {'http://localhost:5173'}, +); +``` + +Keep DNS rebinding protection enabled even on localhost. If your browser app runs +on a different dev-server port, add that exact origin instead of using a wildcard. + +**Production browser or remote deployment** + +Terminate TLS at your reverse proxy or load balancer, expose only the public MCP +hostname, and allow only the trusted web origins that should reach it. If your +deployment needs the Dart process itself to accept HTTPS, provide a custom secure +`HttpServer` setup; `StreamableMcpServer` binds its listener with plain HTTP: + +```dart +final server = StreamableMcpServer( + serverFactory: (sessionId) => McpServer( + const Implementation(name: 'production-server', version: '1.0.0'), + ), + host: '0.0.0.0', + port: 3000, + path: '/mcp', + enableDnsRebindingProtection: true, + allowedHosts: {'mcp.example.com'}, + allowedOrigins: {'https://app.example.com'}, +); +``` + +For authenticated deployments, pair these transport checks with your OAuth or +bearer-token layer. The examples in `example/authentication/` show the MCP OAuth +flow and PKCE shape; production clients should use PKCE S256 with cryptographic +randomness and keep redirect URIs/origins explicit. + ### Streamable HTTP Strict Defaults By default, Streamable HTTP server transports also enforce: @@ -244,31 +294,44 @@ By default, Streamable HTTP server transports also enforce: - Strict `MCP-Protocol-Version` request header validation - Rejection of JSON-RPC batch POST payloads -If you need a temporary compatibility mode during migration, disable strict checks explicitly: +These defaults make compatibility failures visible instead of accepting requests +that the Streamable HTTP spec no longer allows. If you need a temporary migration +mode, disable only the specific check that blocks a known legacy client: ```dart final server = StreamableMcpServer( serverFactory: (sessionId) => McpServer( const Implementation(name: 'server', version: '1.0.0'), ), + // Only if a legacy client still sends older or experimental versions. strictProtocolVersionHeaderValidation: false, - rejectBatchJsonRpcPayloads: false, - enableDnsRebindingProtection: false, + // Keep DNS rebinding protection enabled and explicit. + enableDnsRebindingProtection: true, + allowedHosts: {'mcp.example.com'}, + allowedOrigins: {'https://app.example.com'}, ); ``` -Or with low-level transport options: +Or with low-level transport options for a legacy client that temporarily still +sends JSON-RPC batch payloads: ```dart final transport = StreamableHTTPServerTransport( options: StreamableHTTPServerTransportOptions( - strictProtocolVersionHeaderValidation: false, + // Prefer migrating clients to non-batch Streamable HTTP requests. rejectBatchJsonRpcPayloads: false, - enableDnsRebindingProtection: false, + enableDnsRebindingProtection: true, + allowedHosts: {'mcp.example.com'}, + allowedOrigins: {'https://app.example.com'}, ), ); ``` +Avoid disabling `enableDnsRebindingProtection` on browser-exposed or remote +servers. If you must disable it for an internal compatibility test, bind to +loopback/private networking and put the exception behind a short-lived migration +plan. + ### Server Setup (Streamable HTTP) ```dart diff --git a/test/server/streamable_https_test.dart b/test/server/streamable_https_test.dart index 544e2cb..4c53c11 100644 --- a/test/server/streamable_https_test.dart +++ b/test/server/streamable_https_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:mcp_dart/src/server/streamable_https.dart'; @@ -303,6 +304,105 @@ void main() { expect(true, isTrue); }); + group('DNS rebinding protection', () { + Future postWithHeaders({ + required String host, + String? origin, + String body = '{}', + }) async { + final client = HttpClient(); + addTearDown(client.close); + + final request = await client.postUrl(Uri.parse('$serverUrlBase/mcp')); + request.headers + ..set(HttpHeaders.hostHeader, host) + ..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream') + ..contentType = ContentType.json; + if (origin != null) { + request.headers.set('Origin', origin); + } + request.write(body); + return request.close(); + } + + test('allows allowlisted headers to reach session validation', () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => 'test-session-id', + enableDnsRebindingProtection: true, + allowedHosts: {'localhost'}, + allowedOrigins: {'http://localhost:$serverPort'}, + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + + final response = await postWithHeaders( + host: 'localhost:$serverPort', + origin: 'http://localhost:$serverPort', + body: jsonEncode({ + 'jsonrpc': '2.0', + 'method': 'notifications/initialized', + }), + ); + final body = await utf8.decodeStream(response); + final decodedBody = jsonDecode(body) as Map; + final error = decodedBody['error'] as Map; + + expect(response.statusCode, equals(HttpStatus.badRequest)); + expect(error['code'], equals(ErrorCode.connectionClosed.value)); + expect(error['message'], equals('Bad Request: Server not initialized')); + expect(body, isNot(contains('DNS rebinding protection'))); + }); + + test('rejects requests with hosts outside the allowlist', () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => 'test-session-id', + enableDnsRebindingProtection: true, + allowedHosts: {'localhost'}, + allowedOrigins: {'http://localhost:$serverPort'}, + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + + final response = await postWithHeaders( + host: 'evil.example', + origin: 'http://localhost:$serverPort', + ); + final body = await utf8.decodeStream(response); + + expect(response.statusCode, equals(HttpStatus.forbidden)); + expect(body, contains('DNS rebinding protection')); + }); + + test('rejects requests with origins outside the allowlist', () async { + final transport = StreamableHTTPServerTransport( + options: StreamableHTTPServerTransportOptions( + sessionIdGenerator: () => 'test-session-id', + enableDnsRebindingProtection: true, + allowedHosts: {'localhost'}, + allowedOrigins: {'http://localhost:$serverPort'}, + ), + ); + addTearDown(transport.close); + await transport.start(); + transports['/mcp'] = transport; + + final response = await postWithHeaders( + host: 'localhost:$serverPort', + origin: 'http://evil.example', + ); + final body = await utf8.decodeStream(response); + + expect(response.statusCode, equals(HttpStatus.forbidden)); + expect(body, contains('DNS rebinding protection')); + }); + }); + test('session validation works correctly', () async { // Create a transport with session management final transport = StreamableHTTPServerTransport(