From 2d11970e918a5bf729b546c1a6d9412ee9ebeb60 Mon Sep 17 00:00:00 2001 From: Ehsan Azish Date: Thu, 24 Sep 2026 19:12:19 +0300 Subject: [PATCH] Preserve structured JSON-RPC server error data --- Sources/MCP/Base/Error.swift | 30 +++++++++++++++++----- Tests/MCPTests/ResponseTests.swift | 41 ++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/Sources/MCP/Base/Error.swift b/Sources/MCP/Base/Error.swift index b7196f07..ef6b9473 100644 --- a/Sources/MCP/Base/Error.swift +++ b/Sources/MCP/Base/Error.swift @@ -36,6 +36,8 @@ public enum MCPError: Swift.Error, Sendable { // Server errors (-32000 to -32099) case serverError(code: Int, message: String) + /// A server error with structured JSON-RPC error data. + case serverErrorWithData(code: Int, message: String, data: Value) // MCP specific errors case urlElicitationRequired(message: String, elicitations: [URLElicitationInfo]) // -32042 @@ -53,6 +55,7 @@ public enum MCPError: Swift.Error, Sendable { case .invalidParams: return -32602 case .internalError: return -32603 case .serverError(let code, _): return code + case .serverErrorWithData(let code, _, _): return code case .urlElicitationRequired: return -32042 case .connectionClosed: return -32000 case .transportError: return -32001 @@ -91,6 +94,8 @@ extension MCPError: LocalizedError { return "Internal error" + (detail.map { ": \($0)" } ?? "") case .serverError(_, let message): return "Server error: \(message)" + case .serverErrorWithData(_, let message, _): + return message case .urlElicitationRequired(let message, _): return "URL elicitation required: \(message)" case .connectionClosed: @@ -112,7 +117,7 @@ extension MCPError: LocalizedError { return "Invalid method parameter(s)" case .internalError: return "Internal JSON-RPC error" - case .serverError: + case .serverError, .serverErrorWithData: return "Server-defined error occurred" case .urlElicitationRequired: return "The server requires user authentication or input via external URL" @@ -187,6 +192,9 @@ extension MCPError: Codable { // No additional data for server errors try container.encode(errorDescription ?? "Unknown error", forKey: .message) break + case .serverErrorWithData(_, let message, let data): + try container.encode(message, forKey: .message) + try container.encode(data, forKey: .data) case .urlElicitationRequired(let message, let elicitations): // Encode the raw message so decode can round-trip without prefix doubling try container.encode(message, forKey: .message) @@ -218,11 +226,12 @@ extension MCPError: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) let code = try container.decode(Int.self, forKey: .code) let message = try container.decode(String.self, forKey: .message) - let data = try container.decodeIfPresent([String: Value].self, forKey: .data) + let data = try container.decodeIfPresent(Value.self, forKey: .data) + let dataObject = data?.objectValue // 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 } + guard let detailValue = dataObject?["detail"] else { return fallback } if case .string(let str) = detailValue { return str } return fallback } @@ -241,7 +250,7 @@ extension MCPError: Codable { case -32042: // Extract elicitations array from data var elicitations: [URLElicitationInfo] = [] - if case .array(let items) = data?["elicitations"] { + if case .array(let items) = dataObject?["elicitations"] { for item in items { if case .object(let dict) = item, case .string(let mode) = dict["mode"], @@ -262,7 +271,7 @@ extension MCPError: Codable { case -32001: // Extract underlying error string if present let underlyingErrorString = - data?["error"].flatMap { val -> String? in + dataObject?["error"].flatMap { val -> String? in if case .string(let str) = val { return str } return nil } ?? message @@ -274,7 +283,11 @@ extension MCPError: Codable { ) ) default: - self = .serverError(code: code, message: message) + if let data { + self = .serverErrorWithData(code: code, message: message, data: data) + } else { + self = .serverError(code: code, message: message) + } } } } @@ -291,6 +304,8 @@ extension MCPError: Equatable { case (.internalError(let a), .internalError(let b)): return a == b case (.serverError(let c1, let m1), .serverError(let c2, let m2)): return c1 == c2 && m1 == m2 + case (.serverErrorWithData(let c1, let m1, let d1), .serverErrorWithData(let c2, let m2, let d2)): + return c1 == c2 && m1 == m2 && d1 == d2 case (.urlElicitationRequired(let m1, let e1), .urlElicitationRequired(let m2, let e2)): return m1 == m2 && e1 == e2 case (.connectionClosed, .connectionClosed): return true @@ -319,6 +334,9 @@ extension MCPError: Hashable { hasher.combine(detail) case .serverError(_, let message): hasher.combine(message) + case .serverErrorWithData(_, let message, let data): + hasher.combine(message) + hasher.combine(data) case .urlElicitationRequired(let message, let elicitations): hasher.combine(message) hasher.combine(elicitations) diff --git a/Tests/MCPTests/ResponseTests.swift b/Tests/MCPTests/ResponseTests.swift index cb918521..4d409193 100644 --- a/Tests/MCPTests/ResponseTests.swift +++ b/Tests/MCPTests/ResponseTests.swift @@ -2,6 +2,9 @@ import Testing import class Foundation.JSONDecoder import class Foundation.JSONEncoder +import class Foundation.JSONSerialization +import class Foundation.NSDictionary +import struct Foundation.Data @testable import MCP @@ -116,4 +119,42 @@ struct ResponseTests { #expect(Bool(false), "Expected success result") } } + + @Test("Unsupported protocol version error keeps its structured data") + func testUnsupportedVersionErrorData() throws { + let json = #"{"jsonrpc":"2.0","id":1,"error":{"code":-32022,"message":"Unsupported protocol version","data":{"supported":["2026-07-28","2025-11-25"],"requested":"1900-01-01"}}}"# + let response = try JSONDecoder().decode(Response.self, from: Data(json.utf8)) + + guard case .failure(.serverErrorWithData(let code, let message, let data)) = response.result else { + Issue.record("Expected a structured server error") + return + } + #expect(code == -32022) + #expect(message == "Unsupported protocol version") + #expect(data.objectValue?["requested"] == .string("1900-01-01")) + #expect(data.objectValue?["supported"] == .array([.string("2026-07-28"), .string("2025-11-25")])) + + let encoded = try JSONEncoder().encode(response) + let actual = try JSONSerialization.jsonObject(with: encoded) as? NSDictionary + let expected = try JSONSerialization.jsonObject(with: Data(json.utf8)) as? NSDictionary + #expect(actual == expected) + } + + @Test("Missing capability error retains nested capability object") + func testMissingCapabilityErrorData() throws { + let json = #"{"jsonrpc":"2.0","id":1,"error":{"code":-32021,"message":"Server requires elicitation","data":{"requiredCapabilities":{"elicitation":{}}}}}"# + let response = try JSONDecoder().decode(Response.self, from: Data(json.utf8)) + + guard case .failure(.serverErrorWithData(let code, _, let data)) = response.result else { + Issue.record("Expected a structured server error") + return + } + #expect(code == -32021) + #expect(data.objectValue?["requiredCapabilities"] == .object(["elicitation": .object([:])])) + + let encoded = try JSONEncoder().encode(response) + let actual = try JSONSerialization.jsonObject(with: encoded) as? NSDictionary + let expected = try JSONSerialization.jsonObject(with: Data(json.utf8)) as? NSDictionary + #expect(actual == expected) + } }