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:
yehorsobko 2026-03-03 16:33:28 +01:00 • committed by GitHub
commit 973b46b8f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 692 additions and 507 deletions

View file

@ -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)

View file

@ -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"

View file

@ -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

View file

@ -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"]
)
}
}