Add support for JSON-RPC batching (#67)

* Update StdioTransport documentation comments to indicate support for JSON-RPC batches

* Add server support for JSON-RPC batching

* Add client support for JSON-RPC batching

* Fix bug that caused error messages to be wrapped unnecessarily
This commit is contained in:
Mattt 2025-04-10 12:13:41 -07:00 • committed by GitHub
commit 82f4fd29b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 630 additions and 37 deletions

View file

@ -170,25 +170,38 @@ extension MCPError: Codable {
let message = try container.decode(String.self, forKey: .message)
let data = try container.decodeIfPresent([String: Value].self, forKey: .data)
// Helper to extract detail from data, falling back to message if needed
let unwrapDetail: (String?) -> String? = { fallback in
guard let detailValue = data?["detail"] else { return fallback }
if case .string(let str) = detailValue { return str }
return fallback
}
switch code {
case -32700:
self = .parseError(data?["detail"] as? String ?? message)
self = .parseError(unwrapDetail(message))
case -32600:
self = .invalidRequest(data?["detail"] as? String ?? message)
self = .invalidRequest(unwrapDetail(message))
case -32601:
self = .methodNotFound(data?["detail"] as? String ?? message)
self = .methodNotFound(unwrapDetail(message))
case -32602:
self = .invalidParams(data?["detail"] as? String ?? message)
self = .invalidParams(unwrapDetail(message))
case -32603:
self = .internalError(data?["detail"] as? String ?? message)
self = .internalError(unwrapDetail(nil))
case -32000:
self = .connectionClosed
case -32001:
// Extract underlying error string if present
let underlyingErrorString =
data?["error"].flatMap { val -> String? in
if case .string(let str) = val { return str }
return nil
} ?? message
self = .transportError(
NSError(
domain: "org.jsonrpc.error",
code: code,
userInfo: [NSLocalizedDescriptionKey: message]
userInfo: [NSLocalizedDescriptionKey: underlyingErrorString]
)
)
default:

View file

@ -30,7 +30,7 @@ public protocol Method {
}
/// Type-erased method for request/response handling
struct AnyMethod: Method {
struct AnyMethod: Method, Sendable {
static var name: String { "" }
typealias Parameters = Value
typealias Result = Value
@ -139,9 +139,19 @@ extension Request {
/// A type-erased request for request/response handling
typealias AnyRequest = Request<AnyMethod>
extension AnyRequest {
init<T: Method>(_ request: Request<T>) throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let data = try encoder.encode(request)
self = try decoder.decode(AnyRequest.self, from: data)
}
}
/// A box for request handlers that can be type-erased
class RequestHandlerBox: @unchecked Sendable {
func callAsFunction(_ request: Request<AnyMethod>) async throws -> Response<AnyMethod> {
func callAsFunction(_ request: AnyRequest) async throws -> AnyResponse {
fatalError("Must override")
}
}
@ -155,8 +165,7 @@ final class TypedRequestHandler<M: Method>: RequestHandlerBox, @unchecked Sendab
super.init()
}
override func callAsFunction(_ request: Request<AnyMethod>) async throws -> Response<AnyMethod>
{
override func callAsFunction(_ request: AnyRequest) async throws -> AnyResponse {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
@ -238,10 +247,28 @@ public struct Response<M: Method>: Hashable, Identifiable, Codable, Sendable {
/// A type-erased response for request/response handling
typealias AnyResponse = Response<AnyMethod>
extension AnyResponse {
init<T: Method>(_ response: Response<T>) throws {
// Instead of re-encoding/decoding which might double-wrap the error,
// directly transfer the properties
self.id = response.id
switch response.result {
case .success(let result):
// For success, we still need to convert the result to a Value
let data = try JSONEncoder().encode(result)
let resultValue = try JSONDecoder().decode(Value.self, from: data)
self.result = .success(resultValue)
case .failure(let error):
// Keep the original error without re-encoding/decoding
self.result = .failure(error)
}
}
}
// MARK: -
/// A notification message.
public protocol Notification {
public protocol Notification: Hashable, Codable, Sendable {
/// The parameters of the notification.
associatedtype Parameters: Hashable, Codable, Sendable = Empty
/// The name of the notification.
@ -249,11 +276,21 @@ public protocol Notification {
}
/// A type-erased notification for message handling
struct AnyNotification: Notification {
struct AnyNotification: Notification, Sendable {
static var name: String { "" }
typealias Parameters = Empty
}
extension AnyNotification {
init(_ notification: some Notification) throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let data = try encoder.encode(notification)
self = try decoder.decode(AnyNotification.self, from: data)
}
}
/// A message that can be used to send notifications.
public struct Message<N: Notification>: Hashable, Codable, Sendable {
/// The method name.

View file

@ -17,6 +17,12 @@ import struct Foundation.Data
#if canImport(Darwin) || canImport(Glibc)
/// Standard input/output transport implementation
///
/// This transport supports JSON-RPC 2.0 messages, including individual requests,
/// notifications, responses, and batches containing multiple requests/notifications.
///
/// Messages are delimited by newlines and must not contain embedded newlines.
/// Each message must be a complete, valid JSON object or array (for batches).
public actor StdioTransport: Transport {
private let input: FileDescriptor
private let output: FileDescriptor
@ -131,6 +137,13 @@ import struct Foundation.Data
logger.info("Transport disconnected")
}
/// Sends a message over the transport.
///
/// This method supports sending both individual JSON-RPC messages and JSON-RPC batches.
/// Batches should be encoded as a JSON array containing multiple request/notification objects
/// according to the JSON-RPC 2.0 specification.
///
/// - Parameter message: The message data to send (without a trailing newline)
public func send(_ message: Data) async throws {
guard isConnected else {
throw MCPError.transportError(Errno(rawValue: ENOTCONN))
@ -158,6 +171,11 @@ import struct Foundation.Data
}
}
/// Receives messages from the transport.
///
/// Messages may be individual JSON-RPC requests, notifications, responses,
/// or batches containing multiple requests/notifications encoded as JSON arrays.
/// Each message is guaranteed to be a complete JSON object or array.
public func receive() -> AsyncThrowingStream<Data, Swift.Error> {
return AsyncThrowingStream { continuation in
Task {