Skip to content

Commit 82f4fd2

Browse files
authored
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
1 parent 2a978d1 commit 82f4fd2

File tree

9 files changed

+630
-37
lines changed

9 files changed

+630
-37
lines changed

Sources/MCP/Base/Error.swift

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,25 +170,38 @@ extension MCPError: Codable {
170170
let message = try container.decode(String.self, forKey: .message)
171171
let data = try container.decodeIfPresent([String: Value].self, forKey: .data)
172172

173+
// Helper to extract detail from data, falling back to message if needed
174+
let unwrapDetail: (String?) -> String? = { fallback in
175+
guard let detailValue = data?["detail"] else { return fallback }
176+
if case .string(let str) = detailValue { return str }
177+
return fallback
178+
}
179+
173180
switch code {
174181
case -32700:
175-
self = .parseError(data?["detail"] as? String ?? message)
182+
self = .parseError(unwrapDetail(message))
176183
case -32600:
177-
self = .invalidRequest(data?["detail"] as? String ?? message)
184+
self = .invalidRequest(unwrapDetail(message))
178185
case -32601:
179-
self = .methodNotFound(data?["detail"] as? String ?? message)
186+
self = .methodNotFound(unwrapDetail(message))
180187
case -32602:
181-
self = .invalidParams(data?["detail"] as? String ?? message)
188+
self = .invalidParams(unwrapDetail(message))
182189
case -32603:
183-
self = .internalError(data?["detail"] as? String ?? message)
190+
self = .internalError(unwrapDetail(nil))
184191
case -32000:
185192
self = .connectionClosed
186193
case -32001:
194+
// Extract underlying error string if present
195+
let underlyingErrorString =
196+
data?["error"].flatMap { val -> String? in
197+
if case .string(let str) = val { return str }
198+
return nil
199+
} ?? message
187200
self = .transportError(
188201
NSError(
189202
domain: "org.jsonrpc.error",
190203
code: code,
191-
userInfo: [NSLocalizedDescriptionKey: message]
204+
userInfo: [NSLocalizedDescriptionKey: underlyingErrorString]
192205
)
193206
)
194207
default:

Sources/MCP/Base/Messages.swift

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public protocol Method {
3030
}
3131

3232
/// Type-erased method for request/response handling
33-
struct AnyMethod: Method {
33+
struct AnyMethod: Method, Sendable {
3434
static var name: String { "" }
3535
typealias Parameters = Value
3636
typealias Result = Value
@@ -139,9 +139,19 @@ extension Request {
139139
/// A type-erased request for request/response handling
140140
typealias AnyRequest = Request<AnyMethod>
141141

142+
extension AnyRequest {
143+
init<T: Method>(_ request: Request<T>) throws {
144+
let encoder = JSONEncoder()
145+
let decoder = JSONDecoder()
146+
147+
let data = try encoder.encode(request)
148+
self = try decoder.decode(AnyRequest.self, from: data)
149+
}
150+
}
151+
142152
/// A box for request handlers that can be type-erased
143153
class RequestHandlerBox: @unchecked Sendable {
144-
func callAsFunction(_ request: Request<AnyMethod>) async throws -> Response<AnyMethod> {
154+
func callAsFunction(_ request: AnyRequest) async throws -> AnyResponse {
145155
fatalError("Must override")
146156
}
147157
}
@@ -155,8 +165,7 @@ final class TypedRequestHandler<M: Method>: RequestHandlerBox, @unchecked Sendab
155165
super.init()
156166
}
157167

158-
override func callAsFunction(_ request: Request<AnyMethod>) async throws -> Response<AnyMethod>
159-
{
168+
override func callAsFunction(_ request: AnyRequest) async throws -> AnyResponse {
160169
let encoder = JSONEncoder()
161170
let decoder = JSONDecoder()
162171

@@ -238,22 +247,50 @@ public struct Response<M: Method>: Hashable, Identifiable, Codable, Sendable {
238247
/// A type-erased response for request/response handling
239248
typealias AnyResponse = Response<AnyMethod>
240249

250+
extension AnyResponse {
251+
init<T: Method>(_ response: Response<T>) throws {
252+
// Instead of re-encoding/decoding which might double-wrap the error,
253+
// directly transfer the properties
254+
self.id = response.id
255+
switch response.result {
256+
case .success(let result):
257+
// For success, we still need to convert the result to a Value
258+
let data = try JSONEncoder().encode(result)
259+
let resultValue = try JSONDecoder().decode(Value.self, from: data)
260+
self.result = .success(resultValue)
261+
case .failure(let error):
262+
// Keep the original error without re-encoding/decoding
263+
self.result = .failure(error)
264+
}
265+
}
266+
}
267+
241268
// MARK: -
242269

243270
/// A notification message.
244-
public protocol Notification {
271+
public protocol Notification: Hashable, Codable, Sendable {
245272
/// The parameters of the notification.
246273
associatedtype Parameters: Hashable, Codable, Sendable = Empty
247274
/// The name of the notification.
248275
static var name: String { get }
249276
}
250277

251278
/// A type-erased notification for message handling
252-
struct AnyNotification: Notification {
279+
struct AnyNotification: Notification, Sendable {
253280
static var name: String { "" }
254281
typealias Parameters = Empty
255282
}
256283

284+
extension AnyNotification {
285+
init(_ notification: some Notification) throws {
286+
let encoder = JSONEncoder()
287+
let decoder = JSONDecoder()
288+
289+
let data = try encoder.encode(notification)
290+
self = try decoder.decode(AnyNotification.self, from: data)
291+
}
292+
}
293+
257294
/// A message that can be used to send notifications.
258295
public struct Message<N: Notification>: Hashable, Codable, Sendable {
259296
/// The method name.

Sources/MCP/Base/Transports/StdioTransport.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ import struct Foundation.Data
1717

1818
#if canImport(Darwin) || canImport(Glibc)
1919
/// Standard input/output transport implementation
20+
///
21+
/// This transport supports JSON-RPC 2.0 messages, including individual requests,
22+
/// notifications, responses, and batches containing multiple requests/notifications.
23+
///
24+
/// Messages are delimited by newlines and must not contain embedded newlines.
25+
/// Each message must be a complete, valid JSON object or array (for batches).
2026
public actor StdioTransport: Transport {
2127
private let input: FileDescriptor
2228
private let output: FileDescriptor
@@ -131,6 +137,13 @@ import struct Foundation.Data
131137
logger.info("Transport disconnected")
132138
}
133139

140+
/// Sends a message over the transport.
141+
///
142+
/// This method supports sending both individual JSON-RPC messages and JSON-RPC batches.
143+
/// Batches should be encoded as a JSON array containing multiple request/notification objects
144+
/// according to the JSON-RPC 2.0 specification.
145+
///
146+
/// - Parameter message: The message data to send (without a trailing newline)
134147
public func send(_ message: Data) async throws {
135148
guard isConnected else {
136149
throw MCPError.transportError(Errno(rawValue: ENOTCONN))
@@ -158,6 +171,11 @@ import struct Foundation.Data
158171
}
159172
}
160173

174+
/// Receives messages from the transport.
175+
///
176+
/// Messages may be individual JSON-RPC requests, notifications, responses,
177+
/// or batches containing multiple requests/notifications encoded as JSON arrays.
178+
/// Each message is guaranteed to be a complete JSON object or array.
161179
public func receive() -> AsyncThrowingStream<Data, Swift.Error> {
162180
return AsyncThrowingStream { continuation in
163181
Task {

0 commit comments

Comments
 (0)