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:
parent
2a978d175e
commit
82f4fd29b3
3 changed files with 630 additions and 37 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue