blob: 99feff0d8870673a8c53bf0cc8765f4e2634e15d [file]
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:convert';
import 'dart:io';
import 'package:json_rpc_2/error_code.dart' as error_code;
import 'package:stream_channel/stream_channel.dart';
import 'package:stream_transform/stream_transform.dart';
import '../api/api.dart';
import '../utils/constants.dart';
import '../utils/json_rpc_2_object.dart';
import '../utils/sse.dart' show sseMessageStream;
import '../utils/streamable_http.dart';
/// Creates a [StreamChannel] that POSTs each JSON-RPC message to [uri].
///
/// [protocolVersion] must have [ProtocolVersion.supportsStreamableHttp] and is
/// written to `_meta` and `MCP-Protocol-Version`. [clientCapabilities] and
/// [clientInfo] merge into `_meta`. JSON replies use `jsonDecode` and SSE
/// replies use [sseMessageStream]. A failed POST is a JSON-RPC error for that
/// request ID, or an error on the channel itself when it carried a
/// notification. Notifications have no ID to carry an error. A response
/// stream that ends without answering the request fails it the same way. A
/// `202` on a notification is not an inbound message. Valid `x-mcp-header`
/// annotations from `tools/list` are mirrored on later `tools/call` requests.
/// Invalid tool definitions are dropped. The [headers] map is written to each
/// POST, and the protocol's own headers override it. Does not send
/// `initialize`.
StreamChannel<Map<String, Object?>> streamableHttpClientChannel(
Uri uri, {
required ProtocolVersion protocolVersion,
required ClientCapabilities clientCapabilities,
Implementation? clientInfo,
Map<String, String>? headers,
}) {
if (!protocolVersion.supportsStreamableHttp) {
final supportedVersions = ProtocolVersion.values
.where((version) => version.supportsStreamableHttp)
.map((version) => version.versionString)
.join(', ');
throw ArgumentError.value(
protocolVersion.versionString,
Keys.protocolVersion,
'The Streamable HTTP client channel only allows one of: '
'$supportedVersions',
);
}
// Reject a bad identifier when the channel opens instead of on the first
// request. This checks and returns, so the caller keeps the object each
// message forwards and a capability set later still reaches the server.
ClientCapabilities.fromMap(clientCapabilities as Map<String, Object?>);
final controller = StreamChannelController<Map<String, Object?>>();
final httpClient = HttpClient();
final state = _StreamableHttpClientState();
controller.local.stream
.tap(null, onDone: () => httpClient.close(force: true))
.concurrentAsyncExpand(
(message) => _sendStreamableHttpMessage(
httpClient,
uri,
message,
protocolVersion,
clientCapabilities,
clientInfo,
headers,
state,
),
)
.listen(
controller.local.sink.add,
onError: controller.local.sink.addError,
onDone: controller.local.sink.close,
);
return controller.foreign;
}
/// Sends one JSON-RPC [message] and emits each message in its response.
Stream<Map<String, Object?>> _sendStreamableHttpMessage(
HttpClient httpClient,
Uri uri,
Map<String, Object?> message,
ProtocolVersion protocolVersion,
ClientCapabilities clientCapabilities,
Implementation? clientInfo,
Map<String, String>? headers,
_StreamableHttpClientState state,
) async* {
// Shape errors happen before this turns true, transport errors after.
var posting = false;
try {
final object = JsonRpc2Object.fromMap(message);
final method = object.method;
if (object.kind == JsonRpc2Kind.response || method == null) {
throw ArgumentError.value(
message,
Keys.message,
'Streamable HTTP only allows JSON-RPC requests or notifications. '
'Got ${object.kind.name} with method $method.',
);
}
final params = (message[Keys.params] as Map?)?.cast<String, Object?>();
final meta = (params?[Keys.meta] as Map?)?.cast<String, Object?>();
final body = <String, Object?>{
...message,
Keys.params: <String, Object?>{
...?params,
Keys.meta: <String, Object?>{
...?meta,
Keys.protocolVersionMeta: protocolVersion.versionString,
Keys.clientCapabilitiesMeta: clientCapabilities,
if (clientInfo != null) Keys.clientInfoMeta: clientInfo,
},
},
};
final nameParam =
object.kind == JsonRpc2Kind.request ? mcpNameParams[method] : null;
final rawName = nameParam == null ? null : params?[nameParam];
if (nameParam != null && rawName is! String) {
throw ArgumentError.value(
rawName,
nameParam,
'$method requires a String $nameParam parameter. '
'Got ${rawName.runtimeType}.',
);
}
state.recordOutgoing(message, method);
// Resolve mirrored headers before opening the POST so a shape error does
// not leave an unsent request hanging.
final extraHeaders =
object.kind == JsonRpc2Kind.request
? state.headersFor(message)
: const <String, String>{};
posting = true;
final request = await httpClient.postUrl(uri);
headers?.forEach(request.headers.set);
request.headers
..contentType = ContentType.json
..set(
HttpHeaders.acceptHeader,
'${ContentType.json.mimeType}, $eventStreamMimeType',
)
..set(protocolVersionHeader, protocolVersion.versionString);
if (object.kind == JsonRpc2Kind.request) {
request.headers.set(mcpMethodHeader, method);
if (rawName is String) {
request.headers.set(mcpNameHeader, _encodeSentinel(rawName));
}
for (final MapEntry(:key, :value) in extraHeaders.entries) {
request.headers.set(key, value);
}
}
request.write(jsonEncode(body));
final response = await request.close();
if (response.statusCode == HttpStatus.accepted) {
await response.drain<void>();
if (object.kind == JsonRpc2Kind.notification) return;
throw StateError(
'A Streamable HTTP request must return application/json or '
'text/event-stream. Got HTTP ${HttpStatus.accepted}.',
);
}
final responseType = response.headers.contentType?.mimeType;
final wantsResponse = message.containsKey(Keys.id);
if (responseType == ContentType.json.mimeType) {
final decoded =
(jsonDecode(await utf8.decodeStream(response)) as Map)
.cast<String, Object?>();
if (!wantsResponse) {
throw StateError(
'A notification must be answered with ${HttpStatus.accepted}. Got '
'HTTP ${response.statusCode} with a ${ContentType.json.mimeType} '
'body instead.',
);
}
if (decoded[Keys.id] != message[Keys.id]) {
throw StateError(
'The JSON response for request ${message[Keys.id]} carried id '
'${decoded[Keys.id]} instead.',
);
}
yield state.recordIncoming(decoded);
return;
}
if (responseType == eventStreamMimeType) {
var answered = false;
await for (final event in sseMessageStream(response)) {
if (event[Keys.id] == message[Keys.id] &&
(event.containsKey(Keys.result) || event.containsKey(Keys.error))) {
answered = true;
}
yield state.recordIncoming(event);
}
if (wantsResponse && !answered) {
throw StateError(
'The response stream for request ${message[Keys.id]} ended without '
'a response.',
);
}
return;
}
final responseBody = await utf8.decodeStream(response);
throw UnsupportedError(
'A Streamable HTTP request must return ${ContentType.json.mimeType} or '
'$eventStreamMimeType. Got HTTP ${response.statusCode} '
'"$responseType" with body "$responseBody".',
);
} catch (error, stackTrace) {
if (!message.containsKey(Keys.id)) {
// A notification has no ID to attach a JSON-RPC error to. A message this
// client never sent is the caller's to fix, but a POST that failed on
// the wire has nothing else to report it.
if (posting) Error.throwWithStackTrace(error, stackTrace);
return;
}
yield state.recordIncoming({
Keys.jsonrpc: '2.0',
Keys.id: message[Keys.id],
Keys.error: {
Keys.code: error_code.SERVER_ERROR,
Keys.message: error.toString(),
},
});
}
}
/// Tracks request state shared by the POSTs on one client channel.
///
/// `x-mcp-header` annotations in `tools/list` results declare headers for
/// later `tools/call` requests. Request ids retain each page's role so a
/// first page replaces the cache and continuation pages extend it.
final class _StreamableHttpClientState {
final _listToolsRequestIds = <Object?, bool>{};
final _toolHeaders = <String, List<_McpHeaderDeclaration>>{};
void recordOutgoing(Map<String, Object?> message, String method) {
if (method == ListToolsRequest.methodName && message.containsKey(Keys.id)) {
final params = message[Keys.params];
final cursor =
params is Map<String, Object?> ? params[Keys.cursor] : null;
_listToolsRequestIds[message[Keys.id]] = cursor == null;
}
}
/// Builds the `Mcp-Param-*` headers declared for a `tools/call`.
///
/// Declarations come from valid `tools/list` results. An absent value emits
/// no header, while a present value must match the declared primitive type.
Map<String, String> headersFor(Map<String, Object?> message) {
if (message[Keys.method] != CallToolRequest.methodName) return const {};
// Validate the call shape before reading its cached declarations.
final params = message[Keys.params];
if (params is! Map<String, Object?>) {
throw ArgumentError.value(
params,
Keys.params,
'${CallToolRequest.methodName} requires a params object. '
'Got ${params.runtimeType}.',
);
}
final name = params[Keys.name];
final arguments = params[Keys.arguments];
if (arguments is! Map<String, Object?>?) {
throw ArgumentError.value(
arguments,
Keys.arguments,
'${CallToolRequest.methodName} requires ${Keys.arguments} to be an '
'object. Got ${arguments.runtimeType}.',
);
}
if (name is! String || arguments == null) return const {};
final declarations = _toolHeaders[name];
if (declarations == null) return const {};
final headers = <String, String>{};
// Read each annotated argument path and encode its declared primitive type.
for (final declaration in declarations) {
final value = _valueAtPath(arguments, declaration.path);
final encoded = switch ((declaration.type, value)) {
(final String type, final String value)
when type == JsonType.string.typeName =>
_encodeSentinel(value),
(final String type, final num value)
when type == JsonType.int.typeName &&
value.isFinite &&
value == value.roundToDouble() &&
value >= _minimumSafeInteger &&
value <= _maximumSafeInteger =>
'${value.toInt()}',
(final String type, final bool value)
when type == JsonType.bool.typeName =>
'$value',
_ => null,
};
if (encoded == null) {
if (value == null) continue;
throw ArgumentError.value(
value,
declaration.path.join('.'),
'cannot be mirrored onto ${declaration.header}. The tool declares '
'it as ${declaration.type}.',
);
}
headers['$mcpParamHeaderPrefix${declaration.header}'] = encoded;
}
return headers;
}
/// Applies an incoming message to the cached tool-header snapshot.
///
/// A list-change notification clears it. Tool-list pages update its
/// declarations and remove invalid tool definitions from the returned result.
Map<String, Object?> recordIncoming(Map<String, Object?> message) {
if (message[Keys.method] == ToolListChangedNotification.methodName) {
_toolHeaders.clear();
return message;
}
final firstPage = _listToolsRequestIds.remove(message[Keys.id]);
if (firstPage == null) return message;
final result = message[Keys.result];
if (result is! Map<String, Object?>) return message;
final tools = result[Keys.tools];
if (tools is! List) return message;
// A page carrying no cursor is a fresh snapshot, so anything it leaves out
// is gone, not waiting on a later page.
if (firstPage) _toolHeaders.clear();
final validTools = <Object?>[];
for (final tool in tools) {
if (tool is! Map<String, Object?>) continue;
final name = tool[Keys.name];
final inputSchema = tool[Keys.inputSchema];
if (name is! String || inputSchema is! Map<String, Object?>) continue;
final declarations = _mcpHeaderDeclarations(inputSchema);
if (declarations == null) continue;
_toolHeaders[name] = declarations;
validTools.add(tool);
}
return {
...message,
Keys.result: {...result, Keys.tools: validTools},
};
}
}
typedef _McpHeaderDeclaration =
({List<String> path, String header, String type});
/// Reads valid custom header annotations from a tool input [schema].
List<_McpHeaderDeclaration>? _mcpHeaderDeclarations(
Map<String, Object?> schema,
) {
final declarations = <_McpHeaderDeclaration>[];
final seen = <String>{};
bool visit(Object? node, List<String> path, {required bool reachable}) {
if (node is! Map<String, Object?>) return true;
if (node.containsKey(Keys.xMcpHeader)) {
final header = node[Keys.xMcpHeader];
final type = node[Keys.type];
if (!reachable ||
path.isEmpty ||
header is! String ||
!_httpToken.hasMatch(header) ||
(type != JsonType.string.typeName &&
type != JsonType.int.typeName &&
type != JsonType.bool.typeName) ||
!seen.add(header.toLowerCase())) {
return false;
}
declarations.add((path: path, header: header, type: type as String));
}
final properties = node[Keys.properties];
if (properties is Map<String, Object?>) {
for (final MapEntry(:key, :value) in properties.entries) {
if (!visit(value, [...path, key], reachable: reachable)) return false;
}
}
for (final keyword in _subschemaKeywords) {
final subschema = node[keyword];
if (subschema == null) continue;
final branches =
subschema is List
? subschema
: subschema is Map<String, Object?> &&
_mapValuedSubschemaKeywords.contains(keyword)
? subschema.values
: [subschema];
for (final branch in branches) {
if (!visit(branch, [...path, keyword], reachable: false)) return false;
}
}
return true;
}
return visit(schema, const [], reachable: true) ? declarations : null;
}
Object? _valueAtPath(Map<String, Object?> arguments, List<String> path) {
Object? node = arguments;
for (final key in path) {
if (node is! Map<String, Object?>) return null;
node = node[key];
}
return node;
}
/// Keywords that introduce subschemas outside a `properties` path.
///
/// MCP limits annotated properties to `properties` paths:
/// https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#schema-extension
/// JSON Schema defines current subschema applicators:
/// https://json-schema.org/draft/2020-12/json-schema-core#section-10
/// `contentSchema` is defined separately:
/// https://json-schema.org/draft/2020-12/json-schema-validation#section-8.5
/// Legacy names remain for schemas using older drafts. Sources checked as of
/// 2026-08-31.
const _subschemaKeywords = {
Keys.items,
Keys.prefixItems,
Keys.additionalProperties,
Keys.unevaluatedProperties,
Keys.unevaluatedItems,
Keys.propertyNames,
Keys.patternProperties,
Keys.oneOf,
Keys.anyOf,
Keys.allOf,
Keys.not,
'contains',
'contentSchema',
'dependencies',
'additionalItems',
'dependentSchemas',
'if',
'then',
'else',
r'$defs',
'definitions',
};
const _mapValuedSubschemaKeywords = {
Keys.patternProperties,
'dependencies',
'dependentSchemas',
r'$defs',
'definitions',
};
final _httpToken = RegExp(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$");
const _minimumSafeInteger = -9007199254740991;
const _maximumSafeInteger = 9007199254740991;
/// Encodes [value] when it is not a safe plain ASCII header value.
///
/// See the Streamable HTTP value-encoding conditions:
/// https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#value-encoding
String _encodeSentinel(String value) {
final matchesSentinel = value.startsWith('=?base64?') && value.endsWith('?=');
final needsEncoding =
value.isEmpty ||
value != value.trim() ||
matchesSentinel ||
value.codeUnits.any(
(unit) => unit != 0x09 && (unit < 0x20 || unit > 0x7e),
);
return needsEncoding
? '=?base64?${base64.encode(utf8.encode(value))}?='
: value;
}