feat: 2025-11-25 review fixes (#201)
* fix: review fixes of the latest changes * fix: updated unit tests * chore: more conformance compliance improvements * chore: reverted protocol changes * chore: more compliance improvements * chore: more conformance compliance improvements * chore: improvements to the backcompat and updated to Tool unit tests * chore: ci update
This commit is contained in:
parent
6112a3995a
commit
973b46b8f7
9 changed files with 692 additions and 507 deletions
|
|
@ -171,7 +171,6 @@ extension MCPError: Codable {
|
|||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(code, forKey: .code)
|
||||
try container.encode(errorDescription ?? "Unknown error", forKey: .message)
|
||||
|
||||
// Encode additional data if available
|
||||
switch self {
|
||||
|
|
@ -180,13 +179,17 @@ extension MCPError: Codable {
|
|||
.methodNotFound(let detail),
|
||||
.invalidParams(let detail),
|
||||
.internalError(let detail):
|
||||
try container.encode(errorDescription ?? "Unknown error", forKey: .message)
|
||||
if let detail = detail {
|
||||
try container.encode(["detail": detail], forKey: .data)
|
||||
}
|
||||
case .serverError(_, _):
|
||||
// No additional data for server errors
|
||||
try container.encode(errorDescription ?? "Unknown error", forKey: .message)
|
||||
break
|
||||
case .urlElicitationRequired(_, let elicitations):
|
||||
case .urlElicitationRequired(let message, let elicitations):
|
||||
// Encode the raw message so decode can round-trip without prefix doubling
|
||||
try container.encode(message, forKey: .message)
|
||||
// Encode elicitations array as structured data
|
||||
let elicitationsData = elicitations.map { info -> [String: Value] in
|
||||
return [
|
||||
|
|
@ -201,8 +204,9 @@ extension MCPError: Codable {
|
|||
forKey: .data
|
||||
)
|
||||
case .connectionClosed:
|
||||
break
|
||||
try container.encode(errorDescription ?? "Unknown error", forKey: .message)
|
||||
case .transportError(let error):
|
||||
try container.encode(errorDescription ?? "Unknown error", forKey: .message)
|
||||
try container.encode(
|
||||
["error": error.localizedDescription],
|
||||
forKey: .data
|
||||
|
|
@ -279,7 +283,21 @@ extension MCPError: Codable {
|
|||
|
||||
extension MCPError: Equatable {
|
||||
public static func == (lhs: MCPError, rhs: MCPError) -> Bool {
|
||||
lhs.code == rhs.code
|
||||
switch (lhs, rhs) {
|
||||
case (.parseError(let a), .parseError(let b)): return a == b
|
||||
case (.invalidRequest(let a), .invalidRequest(let b)): return a == b
|
||||
case (.methodNotFound(let a), .methodNotFound(let b)): return a == b
|
||||
case (.invalidParams(let a), .invalidParams(let b)): return a == b
|
||||
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 (.urlElicitationRequired(let m1, let e1), .urlElicitationRequired(let m2, let e2)):
|
||||
return m1 == m2 && e1 == e2
|
||||
case (.connectionClosed, .connectionClosed): return true
|
||||
case (.transportError(let a), .transportError(let b)):
|
||||
return a.localizedDescription == b.localizedDescription
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,10 +38,9 @@ struct AnyMethod: Method, Sendable {
|
|||
|
||||
extension Method where Parameters == Empty {
|
||||
public static func request(
|
||||
id: ID = .random,
|
||||
_meta: Metadata? = nil
|
||||
id: ID = .random
|
||||
) -> Request<Self> {
|
||||
Request(id: id, method: name, params: Empty(), _meta: _meta)
|
||||
Request(id: id, method: name, params: Empty())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,28 +54,25 @@ extension Method {
|
|||
/// Create a request with the given parameters.
|
||||
public static func request(
|
||||
id: ID = .random,
|
||||
_ parameters: Self.Parameters,
|
||||
_meta: Metadata? = nil
|
||||
_ parameters: Self.Parameters
|
||||
) -> Request<Self> {
|
||||
Request(id: id, method: name, params: parameters, _meta: _meta)
|
||||
Request(id: id, method: name, params: parameters)
|
||||
}
|
||||
|
||||
/// Create a response with the given result.
|
||||
public static func response(
|
||||
id: ID,
|
||||
result: Self.Result,
|
||||
_meta: Metadata? = nil
|
||||
result: Self.Result
|
||||
) -> Response<Self> {
|
||||
Response(id: id, result: result, _meta: _meta)
|
||||
Response(id: id, result: result)
|
||||
}
|
||||
|
||||
/// Create a response with the given error.
|
||||
public static func response(
|
||||
id: ID,
|
||||
error: MCPError,
|
||||
_meta: Metadata? = nil
|
||||
error: MCPError
|
||||
) -> Response<Self> {
|
||||
Response(id: id, error: error, _meta: _meta)
|
||||
Response(id: id, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,23 +86,19 @@ public struct Request<M: Method>: Hashable, Identifiable, Codable, Sendable {
|
|||
public let method: String
|
||||
/// The request parameters.
|
||||
public let params: M.Parameters
|
||||
/// Metadata for this request (see spec for _meta usage, includes progressToken)
|
||||
public let _meta: Metadata?
|
||||
|
||||
init(
|
||||
id: ID = .random,
|
||||
method: String,
|
||||
params: M.Parameters,
|
||||
_meta: Metadata? = nil
|
||||
params: M.Parameters
|
||||
) {
|
||||
self.id = id
|
||||
self.method = method
|
||||
self.params = params
|
||||
self._meta = _meta
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey, CaseIterable {
|
||||
case jsonrpc, id, method, params, _meta
|
||||
case jsonrpc, id, method, params
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
|
|
@ -115,7 +107,6 @@ public struct Request<M: Method>: Hashable, Identifiable, Codable, Sendable {
|
|||
try container.encode(id, forKey: .id)
|
||||
try container.encode(method, forKey: .method)
|
||||
try container.encode(params, forKey: .params)
|
||||
try container.encodeIfPresent(_meta, forKey: ._meta)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -129,7 +120,6 @@ extension Request {
|
|||
}
|
||||
id = try container.decode(ID.self, forKey: .id)
|
||||
method = try container.decode(String.self, forKey: .method)
|
||||
_meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
|
||||
|
||||
if M.Parameters.self is NotRequired.Type {
|
||||
// For NotRequired parameters, use decodeIfPresent or init()
|
||||
|
|
@ -221,44 +211,37 @@ public struct Response<M: Method>: Hashable, Identifiable, Codable, Sendable {
|
|||
public let id: ID
|
||||
/// The response result.
|
||||
public let result: Swift.Result<M.Result, MCPError>
|
||||
/// Metadata for this response (see spec for _meta usage)
|
||||
public let _meta: Metadata?
|
||||
|
||||
public init(
|
||||
id: ID,
|
||||
result: Swift.Result<M.Result, MCPError>,
|
||||
_meta: Metadata? = nil
|
||||
result: Swift.Result<M.Result, MCPError>
|
||||
) {
|
||||
self.id = id
|
||||
self.result = result
|
||||
self._meta = _meta
|
||||
}
|
||||
|
||||
public init(
|
||||
id: ID,
|
||||
result: M.Result,
|
||||
_meta: Metadata? = nil
|
||||
result: M.Result
|
||||
) {
|
||||
self.init(id: id, result: .success(result), _meta: _meta)
|
||||
self.init(id: id, result: .success(result))
|
||||
}
|
||||
|
||||
public init(
|
||||
id: ID,
|
||||
error: MCPError,
|
||||
_meta: Metadata? = nil
|
||||
error: MCPError
|
||||
) {
|
||||
self.init(id: id, result: .failure(error), _meta: _meta)
|
||||
self.init(id: id, result: .failure(error))
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey, CaseIterable {
|
||||
case jsonrpc, id, result, error, _meta
|
||||
case jsonrpc, id, result, error
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(jsonrpc, forKey: .jsonrpc)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encodeIfPresent(_meta, forKey: ._meta)
|
||||
|
||||
switch result {
|
||||
case .success(let result):
|
||||
|
|
@ -286,7 +269,6 @@ public struct Response<M: Method>: Hashable, Identifiable, Codable, Sendable {
|
|||
codingPath: container.codingPath,
|
||||
debugDescription: "Invalid response"))
|
||||
}
|
||||
_meta = try container.decodeIfPresent(Metadata.self, forKey: ._meta)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -301,14 +283,12 @@ extension AnyResponse {
|
|||
let resultValue = try JSONDecoder().decode(Value.self, from: data)
|
||||
self = Response<AnyMethod>(
|
||||
id: response.id,
|
||||
result: .success(resultValue),
|
||||
_meta: response._meta
|
||||
result: .success(resultValue)
|
||||
)
|
||||
case .failure(let error):
|
||||
self = Response<AnyMethod>(
|
||||
id: response.id,
|
||||
result: .failure(error),
|
||||
_meta: response._meta
|
||||
result: .failure(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
Sources/MCP/Base/PendingRequest.swift
Normal file
43
Sources/MCP/Base/PendingRequest.swift
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import class Foundation.JSONDecoder
|
||||
import class Foundation.JSONEncoder
|
||||
|
||||
/// An error indicating a type mismatch when decoding a pending request response.
|
||||
struct TypeMismatchError: Swift.Error {}
|
||||
|
||||
/// A pending request with a continuation for the result.
|
||||
struct PendingRequest<T> {
|
||||
let continuation: CheckedContinuation<T, Swift.Error>
|
||||
}
|
||||
|
||||
/// A type-erased pending request.
|
||||
struct AnyPendingRequest: Sendable {
|
||||
private let _resume: @Sendable (Result<Any, Swift.Error>) -> Void
|
||||
|
||||
init<T: Sendable & Decodable>(_ request: PendingRequest<T>) {
|
||||
_resume = { result in
|
||||
switch result {
|
||||
case .success(let value):
|
||||
if let typedValue = value as? T {
|
||||
request.continuation.resume(returning: typedValue)
|
||||
} else if let value = value as? Value,
|
||||
let data = try? JSONEncoder().encode(value),
|
||||
let decoded = try? JSONDecoder().decode(T.self, from: data)
|
||||
{
|
||||
request.continuation.resume(returning: decoded)
|
||||
} else {
|
||||
request.continuation.resume(throwing: TypeMismatchError())
|
||||
}
|
||||
case .failure(let error):
|
||||
request.continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resume(returning value: Any) {
|
||||
_resume(.success(value))
|
||||
}
|
||||
|
||||
func resume(throwing error: Swift.Error) {
|
||||
_resume(.failure(error))
|
||||
}
|
||||
}
|
||||
|
|
@ -221,6 +221,14 @@ public actor HTTPClientTransport: Transport {
|
|||
logger.debug("HTTP clienttransport disconnected")
|
||||
}
|
||||
|
||||
/// Updates the protocol version used for `MCP-Protocol-Version` headers on subsequent requests.
|
||||
///
|
||||
/// Call this after lifecycle negotiation if the server responds with a different version.
|
||||
/// - Parameter version: The negotiated protocol version.
|
||||
public func updateNegotiatedProtocolVersion(_ version: String) {
|
||||
self.protocolVersion = version
|
||||
}
|
||||
|
||||
/// Sends data through an HTTP POST request
|
||||
///
|
||||
/// This sends a JSON-RPC message to the server via HTTP POST and processes
|
||||
|
|
|
|||
|
|
@ -205,8 +205,14 @@ public struct SessionValidator: HTTPRequestValidator {
|
|||
// Skip validation for initialization requests
|
||||
guard !context.isInitializationRequest else { return nil }
|
||||
|
||||
// If no session exists yet, skip (server hasn't been initialized)
|
||||
guard let expectedSessionID = context.sessionID else { return nil }
|
||||
// Non-initialization requests require an established session.
|
||||
guard let expectedSessionID = context.sessionID else {
|
||||
return .error(
|
||||
statusCode: 400,
|
||||
.invalidRequest("Bad Request: Session not initialized"),
|
||||
sessionID: nil
|
||||
)
|
||||
}
|
||||
|
||||
let requestSessionID = request.header(HTTPHeaderName.sessionID)
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ public enum HTTPResponse: Sendable {
|
|||
|
||||
/// Error response with a JSON-RPC error body.
|
||||
/// The status code, headers, and body are derived automatically.
|
||||
case error(statusCode: Int, MCPError, sessionID: String? = nil)
|
||||
case error(statusCode: Int, MCPError, sessionID: String? = nil, extraHeaders: [String: String] = [:])
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
|
|
@ -96,7 +96,7 @@ public enum HTTPResponse: Sendable {
|
|||
switch self {
|
||||
case .accepted: 202
|
||||
case .ok, .data, .stream: 200
|
||||
case .error(let code, _, _): code
|
||||
case .error(let code, _, _, _): code
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -104,9 +104,10 @@ public enum HTTPResponse: Sendable {
|
|||
switch self {
|
||||
case .accepted(let headers), .ok(let headers), .data(_, let headers), .stream(_, let headers):
|
||||
return headers
|
||||
case .error(_, _, let sessionID):
|
||||
case .error(_, _, let sessionID, let extraHeaders):
|
||||
var headers: [String: String] = [HTTPHeaderName.contentType: ContentType.json]
|
||||
if let sessionID { headers[HTTPHeaderName.sessionID] = sessionID }
|
||||
headers.merge(extraHeaders) { _, new in new }
|
||||
return headers
|
||||
}
|
||||
}
|
||||
|
|
@ -118,7 +119,7 @@ public enum HTTPResponse: Sendable {
|
|||
return nil
|
||||
case .data(let data, _):
|
||||
return data
|
||||
case .error(_, let error, _):
|
||||
case .error(_, let error, _, _):
|
||||
let errorBody: [String: Any] = [
|
||||
"jsonrpc": "2.0",
|
||||
"error": [
|
||||
|
|
@ -136,8 +137,8 @@ public enum HTTPResponse: Sendable {
|
|||
|
||||
/// Standard header names used by the MCP Streamable HTTP transport.
|
||||
public enum HTTPHeaderName {
|
||||
public static let sessionID = "Mcp-Session-Id"
|
||||
public static let protocolVersion = "Mcp-Protocol-Version"
|
||||
public static let sessionID = "MCP-Session-Id"
|
||||
public static let protocolVersion = "MCP-Protocol-Version"
|
||||
public static let lastEventID = "Last-Event-Id"
|
||||
public static let accept = "Accept"
|
||||
public static let contentType = "Content-Type"
|
||||
|
|
|
|||
|
|
@ -182,7 +182,8 @@ public actor StatefulHTTPServerTransport: Transport {
|
|||
return .error(
|
||||
statusCode: 405,
|
||||
.invalidRequest("Method Not Allowed"),
|
||||
sessionID: sessionID
|
||||
sessionID: sessionID,
|
||||
extraHeaders: [HTTPHeaderName.allow: "GET, POST, DELETE"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -238,6 +239,15 @@ public actor StatefulHTTPServerTransport: Transport {
|
|||
}
|
||||
|
||||
private func handleInitializationRequest(_ body: Data, request: HTTPRequest) -> HTTPResponse {
|
||||
// Reject re-initialization at the transport level
|
||||
if sessionID != nil {
|
||||
return .error(
|
||||
statusCode: 400,
|
||||
.invalidRequest("Bad Request: Session already initialized"),
|
||||
sessionID: sessionID
|
||||
)
|
||||
}
|
||||
|
||||
// Generate session ID
|
||||
let newSessionID = sessionIDGenerator.generateSessionID()
|
||||
|
||||
|
|
@ -357,9 +367,7 @@ public actor StatefulHTTPServerTransport: Transport {
|
|||
supportedProtocolVersions: Version.supported
|
||||
)
|
||||
|
||||
// Only run session validation for DELETE (not all validators)
|
||||
let sessionValidator = SessionValidator()
|
||||
if let errorResponse = sessionValidator.validate(request, context: context) {
|
||||
if let errorResponse = validationPipeline.validate(request, context: context) {
|
||||
return errorResponse
|
||||
}
|
||||
|
||||
|
|
@ -481,14 +489,27 @@ public actor StatefulHTTPServerTransport: Transport {
|
|||
continuation: AsyncThrowingStream<Data, Swift.Error>.Continuation,
|
||||
protocolVersion: String
|
||||
) {
|
||||
// Priming events with empty data are only safe for clients >= 2025-11-25
|
||||
guard protocolVersion >= "2025-11-25" else { return }
|
||||
// Priming events with empty data are only safe for clients >= 2025-03-26
|
||||
guard protocolVersion >= "2025-03-26" else { return }
|
||||
|
||||
let primingEventID = storeEvent(streamID: streamID, message: nil)
|
||||
let primingEvent = SSEEvent.priming(id: primingEventID, retry: retryInterval)
|
||||
continuation.yield(primingEvent.formatted())
|
||||
}
|
||||
|
||||
private func extractProtocolVersion(from body: Data, request: HTTPRequest) -> String {
|
||||
// For initialize requests, extract from the request body params
|
||||
if let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any],
|
||||
let method = json["method"] as? String, method == "initialize",
|
||||
let params = json["params"] as? [String: Any],
|
||||
let version = params["protocolVersion"] as? String
|
||||
{
|
||||
return version
|
||||
}
|
||||
// For other requests, use the header
|
||||
return request.header(HTTPHeaderName.protocolVersion) ?? Version.latest
|
||||
}
|
||||
|
||||
// MARK: - Session Helpers
|
||||
|
||||
private func sessionHeaders() -> [String: String] {
|
||||
|
|
@ -504,17 +525,16 @@ public actor StatefulHTTPServerTransport: Transport {
|
|||
return id.utf8.allSatisfy { $0 >= 0x21 && $0 <= 0x7E }
|
||||
}
|
||||
|
||||
private func extractProtocolVersion(from body: Data, request: HTTPRequest) -> String {
|
||||
// For initialize requests, extract from the request body params
|
||||
if let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any],
|
||||
let method = json["method"] as? String, method == "initialize",
|
||||
let params = json["params"] as? [String: Any],
|
||||
let version = params["protocolVersion"] as? String
|
||||
{
|
||||
return version
|
||||
}
|
||||
// For other requests, use the header
|
||||
return request.header(HTTPHeaderName.protocolVersion) ?? Version.latest
|
||||
// MARK: - Stream Control
|
||||
|
||||
/// Closes the SSE stream for a specific request without sending a response.
|
||||
///
|
||||
/// Used to trigger client reconnection mid-call (SEP-1699). The response,
|
||||
/// when eventually sent, will be stored and replayed to the reconnected stream.
|
||||
package func closeSSEStream(forRequestID requestID: String) {
|
||||
guard let continuation = requestSSEContinuations[requestID] else { return }
|
||||
continuation.finish()
|
||||
requestSSEContinuations.removeValue(forKey: requestID)
|
||||
}
|
||||
|
||||
// MARK: - Termination
|
||||
|
|
|
|||
|
|
@ -162,7 +162,8 @@ public actor StatelessHTTPServerTransport: Transport {
|
|||
default:
|
||||
return .error(
|
||||
statusCode: 405,
|
||||
.invalidRequest("Method Not Allowed")
|
||||
.invalidRequest("Method Not Allowed"),
|
||||
extraHeaders: [HTTPHeaderName.allow: "POST"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,13 +15,13 @@ public struct CancelledNotification: Notification {
|
|||
/// The ID of the request to cancel.
|
||||
///
|
||||
/// This MUST correspond to the ID of a request previously issued
|
||||
/// in the same direction.
|
||||
public let requestId: ID
|
||||
/// in the same direction. Optional per the spec (`requestId?`).
|
||||
public let requestId: ID?
|
||||
|
||||
/// An optional human-readable reason for the cancellation.
|
||||
public let reason: String?
|
||||
|
||||
public init(requestId: ID, reason: String? = nil) {
|
||||
public init(requestId: ID? = nil, reason: String? = nil) {
|
||||
self.requestId = requestId
|
||||
self.reason = reason
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue