Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 69 additions & 6 deletions doc/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,38 +237,101 @@ 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:

- 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
Expand Down
100 changes: 100 additions & 0 deletions test/server/streamable_https_test.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:mcp_dart/src/server/streamable_https.dart';
Expand Down Expand Up @@ -303,6 +304,105 @@ void main() {
expect(true, isTrue);
});

group('DNS rebinding protection', () {
Future<HttpClientResponse> 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'},
),
);
Comment thread
leehack marked this conversation as resolved.
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<String, dynamic>;
final error = decodedBody['error'] as Map<String, dynamic>;

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')));
Comment thread
leehack marked this conversation as resolved.
});

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(
Expand Down
Loading