Report-only artifact review of git diff origin/main...HEAD for PR #194, at exact HEAD 40718d806f7c5f80ce661e44dd16120d3d2ca015. No source, tests, docs, or git history were modified. Follows a clean dependency review.
JSONRPCRequest; it now models a single-or-batch envelope, with envelope parsing and HTTP status selection kept in MCPServer and per-message dispatch left untouched in MCPToolHandler.BatchElement.invalid keeps malformed entries in sequence so each yields its own -32600, instead of one bad member failing the whole array. This is the detail a naive Codable single-or-array enum would have gotten wrong, and the report says so explicitly.make lint clean (304 files), make test-quick Test Succeeded with 0 failures and all 7 new batch cases running."id": null (request, not notification), integral-valued floats, fractional ids, and ids beyond Int64. No id-fidelity divergence.isNotification check after dispatch means a single notification-shaped tools/call POST now executes the tool where it was previously a silent no-op. Spec-correct and documented in agent-notes, but the CHANGELOG describes the change as batch-only.initialize, an encode-failure blast-radius note, test-helper duplication, and two coverage gaps.Ready to push
The fix is correct, well-scoped, and matches both JSON-RPC 2.0 §6 and the MCP 2025-03-26 Streamable HTTP transport. I independently re-ran make lint (0 violations across 304 files) and make test-quick (Test Succeeded, 0 failures, all 7 new MCPServerBatchRequestTests cases discovered and green).
I probed the one design detail that could plausibly have introduced a silent regression — batch members are decoded by re-serializing each parsed dictionary rather than decoding the original bytes, a different route than the single-request path — and confirmed the round-trip preserves the notification-vs-explicit-null-id distinction and rejects fractional and out-of-range ids identically to the single path. No correctness defect survived verification.
Six observations follow, all suggestions rather than blockers. The one I would act on before merge is a CHANGELOG line: the notification dispatch change in MCPToolHandler also alters the single-request path, which the entry currently frames as batch-only.
40718d8 T-1834: Support MCP JSON-RPC request batches c6cf62b T-1834: Add batch request regression tests working-tree No changes applied in this review Transit ships a small web server (the MCP server) that AI agents talk to. Agents send it messages in a format called JSON-RPC, and Transit answers.
The protocol Transit advertises allows an agent to send several messages at once, packed into a JSON list — a batch. Transit only ever knew how to read one message per request. So every batch, even a perfectly valid one, came back as a single error: Invalid Request.
This branch teaches the server to look at the incoming body and ask: is this one message, or a list of them? If it is a list, each entry is handled in turn and the answers are sent back as a list.
Any standards-compliant MCP client that batches its calls was silently broken against Transit. Not slow — broken: every request in the batch was thrown away and replaced with one error. Since Transit tells clients it speaks protocol version 2025-03-26, which permits batching, this was a promise the server was not keeping.
id expects an answer. A message with no id at all is a notification — the sender does not want a reply. The server still has to do the work; it just stays quiet about the result.initialize is special. It is the handshake that starts a session, so the MCP spec says it must be sent on its own. If it turns up inside a batch, that one member is rejected and its siblings still run.The change draws a clean line between envelope concerns and message concerns.
MCPServer.decodeIncomingRequest previously returned DecodeOutcome.success(JSONRPCRequest) or .failure(JSONRPCResponse). It gains a third case, .batch([BatchElement]), where BatchElement is either .request(JSONRPCRequest) or .invalid. That second case is the load-bearing design choice: an invalid member is retained in position rather than dropped or fatal, so the route can emit exactly one -32600 per bad entry while its siblings still dispatch.
Dispatch moves into a new batchResponse(for:handler:) that walks the elements, calls the existing MCPToolHandler.handle for valid ones, collects only non-nil responses, and picks the HTTP status from what is left: empty means 202 with no body, otherwise 200 with a JSON array. jsonResponse was generalised from JSONRPCResponse to some Encodable & Sendable, which is the whole of the encoder change — single-request response shape is byte-identical to before.
Two-stage decode, preserved. Stage 1 is JSONSerialization with .fragmentsAllowed to separate -32700 Parse error (not JSON at all) from -32600 Invalid Request (JSON with the wrong shape). Stage 2 is the structural JSONDecoder pass. The branch keeps this and extracts the previously inline jsonrpc-member check into a named isValidRequestObjectShape, which both the single and batch paths now share — a genuine deduplication rather than a parallel implementation.
Dispatch-then-suppress. The most consequential edit is four lines in MCPToolHandler.handle. The notification check moved from before the method switch to after it. Notifications are now method calls whose result is discarded, not calls that are skipped. Without this, a tools/call notification inside a batch would parse, be counted, and do nothing — the exact opposite of what a notification means.
Sequential dispatch. Batch members run in input order on the MainActor. JSON-RPC permits any processing order, so this is a deliberate trade of throughput for deterministic side-effect ordering against Transit's shared SwiftData context.
Batch members are decoded by re-serializing the already-parsed [String: Any] with JSONSerialization.data and running it back through JSONDecoder, because JSONDecoder cannot be pointed at a sub-range of the original bytes. It costs one extra encode/decode per member and, in principle, risks number-fidelity drift between the two paths. In practice it does not: I probed explicit null ids, integral floats, fractional ids, and ids beyond Int64, and both paths agree on all four.
The correctness of the batch path hinges on JSONRPCRequest's custom init(from:), which distinguishes an absent id member (isNotification = true) from a present-but-null one (isNotification = false, id = nil) via container.contains(.id). That distinction is a prior bug fix (T-1128 / MCPNullIdTests) and it is precisely what a re-serialization round-trip could quietly destroy: an NSNull value in the parsed dictionary must survive JSONSerialization.data(withJSONObject:) as a literal null, not be elided. It does — I confirmed [{"jsonrpc":"2.0","id":null,"method":"ping"}] yields isNotification == false, id == nil on both paths, so it correctly receives a response with "id": null. Nothing in the suite locks that behaviour on the batch path, which is the coverage gap I would close.
The same probe rules out id-type divergence: 1.0 decodes to .integer(1) on both paths (JSONDecoder accepts lossless float-to-Int), 1.5 and 12345678901234567890 are rejected on both. Whatever number-fidelity risk the re-serialization carries in theory does not materialise for the id domain JSON-RPC actually permits.
Lifecycle enforcement now lives in the transport rather than the dispatcher, which is the right place — "is this message in a batch?" is envelope knowledge that MCPToolHandler.handle structurally cannot have. The cost is one precedence inversion: batchResponse tests method == "initialize" before delegating, so it never reaches the guard request.jsonrpc == "2.0" check at the top of handle. A batched {"jsonrpc":"1.0","id":1,"method":"initialize"} therefore reports "initialize must not be batched" where the single-request path would report the T-1106 version error. Same -32600 code, different message; cosmetic, but it is a divergence between the two paths in a codebase that has otherwise been careful to keep them aligned.
Note also that handle's version guard returns an error even for notification-shaped envelopes — deliberate, per T-1106. Inside a batch this now surfaces as a response object for something that looked like a notification, so an otherwise all-notification batch containing one bad-version member returns 200 with a one-element array instead of 202. Defensible under JSON-RPC §5 (an invalid Request's notification status is not reliably determinable), but it is new emergent behaviour worth being aware of.
jsonResponse's catch substitutes a single -32603 object. For a batch, one unencodable AnyCodable result now discards every sibling response and flips the reply from array to object — a shape violation on top of the data loss. Unlikely, since all results are app-controlled Encodable types, but the blast radius grew from one response to N.collect(upTo: 1_048_576). A 1 MiB body holds several thousand tools/call members, each performing a SwiftData save synchronously on the MainActor. The endpoint binds 127.0.0.1 and the server is single-user, so this is a self-inflicted-stall concern rather than a security one — but the bound is implicit in a byte count, not stated.initialize in a batch is skipped entirely — neither dispatched nor answered. Correct: it is forbidden by lifecycle rules and no response may be emitted for a notification.[[{...}]]) fall through the element as? [String: Any] cast to .invalid, yielding -32600. Correct per §6.-32600 object, not an array — the one place JSON-RPC deliberately breaks the array symmetry, and it is handled and tested.Transit/Transit/MCP/MCPServer.swift
Why it matters. This is the fix. Modelling the POST body as a single-or-batch envelope is what unblocks every downstream behaviour: per-member errors, response arrays, 202 selection, and lifecycle validation. Getting the element type wrong here would make all four impossible.
What to look at. MCPServer.swift:166-228 (DecodeOutcome, BatchElement, decodeIncomingRequest)
Transit/Transit/MCP/MCPToolHandler.swift
Why it matters. Four lines with the widest behavioural reach in the branch, and the only change that also alters the pre-existing single-request path. Without it, a tools/call notification in a batch would be silently discarded before doing its work — the opposite of notification semantics.
What to look at. MCPToolHandler.swift:62-83 (handle)
Transit/Transit/MCP/MCPServer.swift
Why it matters. Concentrates every batch-only rule in one readable loop — per-member -32600, lifecycle rejection of initialize, notification omission, and the 202-vs-200 decision. Reviewers can verify the whole protocol contract in 35 lines.
What to look at. MCPServer.swift:116-154
Transit/Transit/MCP/MCPServer.swift
Why it matters. Prevents the single and batch paths from drifting on shape validation. The lenient-jsonrpc rule exists to preserve T-1106's detailed version error; had the batch path re-implemented it, the two paths would classify the same body differently.
What to look at. MCPServer.swift:234-248 (isValidRequestObjectShape, decodeRequestObject)
Transit/TransitTests/MCPServerBatchRequestTests.swift
Why it matters. Batch behaviour is only observable at the transport boundary — HTTP status, array-vs-object body shape, and empty 202 bodies cannot be asserted against MCPToolHandler alone. Testing below the router would have missed the two rules most likely to regress.
What to look at. MCPServerBatchRequestTests.swift:1-201
Batch rules span four layers — JSON parsing, MCP lifecycle validation, JSON-RPC response shape, and HTTP status selection. The report explicitly notes that fixing only the structural decoder would leave the endpoint non-compliant, so DecodeOutcome, batchResponse, and jsonResponse were all changed together.
handle receives one JSONRPCRequest and structurally cannot know whether it arrived inside a batch. Putting the initialize rejection in batchResponse preserves the existing dispatcher unchanged while giving the rule the context it needs. Consequence: the rejection precedes handle's jsonrpc version guard — see the Findings section.
Report's Alternatives Considered: rejecting the entire batch was considered and rejected because per-member handling preserves valid sibling requests while still returning a lifecycle error for the invalid member. initializeInsideBatchIsRejectedWithoutRejectingOtherRequests locks this in.
JSON-RPC permits any processing order. Sequential input-order dispatch was chosen so that state-changing tool calls against Transit's shared @MainActor SwiftData context have deterministic ordering. Documented both in the code comment and in docs/agent-notes/mcp-server.md.
Rather than adding a parallel jsonResponse(_: [JSONRPCResponse]) overload, the existing helper was made generic. Single-request output is byte-identical; only the accepted input type widened. Inferred from the diff — no stated rationale.
decodeRequestObject runs JSONSerialization.data(withJSONObject:) then JSONDecoder, because JSONDecoder cannot be aimed at a sub-range of the original body. Inferred — the diff shows the mechanism but no comment states why the original bytes are not reused. I verified the round-trip is behaviourally equivalent to the single path for explicit-null, integral-float, fractional, and out-of-Int64-range ids.
JSON-RPC §6 requires a single Invalid Request object for [], breaking the array symmetry that holds everywhere else. Encoding this at the decode stage (rather than as an empty-array special case in batchResponse) keeps batchResponse's contract to 'one non-empty batch' and makes its empty-responses guard unambiguously mean 'notifications only'.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | CHANGELOG.md — scope of the notification change | Moving the isNotification check after the method switch in MCPToolHandler.handle also changes the pre-existing single-request path: a lone notification-shaped tools/call POST now executes the tool where it was previously a silent no-op returning 202. This is spec-correct (JSON-RPC 2.0 §4.1) and is described in docs/agent-notes/mcp-server.md, but the CHANGELOG entry frames the entire change as batch-scoped ('now accepts non-empty JSON-RPC request/notification batches'), so a reader would not learn that single-request behaviour moved. Practical regression risk is low — a client omitting id on tools/call is malformed, and notifications/initialized still falls to the methodNotFound default with no side effect. | Suggest one added clause to the T-1834 CHANGELOG entry noting that notifications now execute their method/tool path before their response is suppressed, on both the single and batch paths. Not applied — this run is report-only. |
| minor | MCPServer.swift:134 — batched initialize bypasses the jsonrpc version guard | batchResponse tests `rpcRequest.method == "initialize"` before delegating to handler.handle, so a batched {"jsonrpc":"1.0","id":1,"method":"initialize"} never reaches the `guard request.jsonrpc == "2.0"` check at the top of handle. It reports 'Invalid Request: initialize must not be batched' where the single-request path reports the T-1106 version error. Both are -32600, so only the message differs — but the two paths otherwise agree message-for-message, and this codebase has previously treated error-message precision as load-bearing (T-1106, T-1128). | Suggest checking `rpcRequest.jsonrpc == "2.0"` before the initialize branch, or moving the initialize rejection to run after the version guard. Not applied — report-only. |
| minor | MCPServer.swift:273-291 — encode-failure fallback collapses a batch | jsonResponse catches an encoding error and substitutes a single -32603 object. For a batch payload, one unencodable AnyCodable result now discards every sibling response and flips the reply from a JSON array to a JSON object, which a batching client will not be expecting. Pre-fix this could affect at most one response; the blast radius is now N. Probability is low (all results are app-controlled Encodable types), which is why this is a note rather than a fix request. | Suggest encoding each JSONRPCResponse individually and substituting a per-element -32603 on failure, preserving both array shape and sibling responses. Not applied — report-only. |
| minor | MCPServerBatchRequestTests.swift:146-175 — duplicated route plumbing | The responder/Request/EmbeddedChannel/BasicRequestContext construction in this file's respond(handler:body:) duplicates MCPServerOriginValidationTests.dispatch(responder:head:body:) almost line for line. The only real difference is that the new helper collects the response body while the older one returns just the status. Two copies of NIO test plumbing will drift when Hummingbird's context-construction API changes. | Suggest hoisting a body-collecting dispatch helper into MCPTestHelpers and having the origin-validation suite read `.status` off its result. Test-only, and outside this branch's blast radius. Not applied — report-only, and the skill forbids test edits absent an actual bug. |
| minor | MCPServerBatchRequestTests.swift — coverage gap on explicit null id inside a batch | Batch members are decoded through decodeRequestObject's re-serialize-then-decode round-trip, a genuinely different route than the single path's decode-the-original-bytes. The property most at risk on that route is JSONRPCRequest's notification-vs-explicit-null-id distinction (T-1128, guarded by MCPNullIdTests only at the type level). I verified by probe that [{"jsonrpc":"2.0","id":null,"method":"ping"}] correctly yields isNotification == false and a response with "id": null, but no test in the suite locks it on the batch path. A batch member with "jsonrpc":"1.0" is likewise uncovered. | Suggest two added cases: an explicit-null-id batch member receiving a response with id null, and a bad-version batch member receiving the T-1106 version error. Not applied — report-only. |
| minor | MCPServer.swift:116-154 — no batch cardinality bound | The only limit on batch size is the 1 MiB body cap in collect(upTo:). That body can hold several thousand tools/call members, each dispatched sequentially on the MainActor with a SwiftData save. The endpoint binds 127.0.0.1 and Transit is single-user, so this is a UI-stall concern rather than a security one — but the bound is implicit in a byte count rather than stated, and the sequential-dispatch decision that makes it possible is documented without mentioning it. | No change recommended for this branch. Worth one sentence in docs/agent-notes/mcp-server.md if batch sizes ever grow in practice. Not applied — report-only. |
Click to expand.
diff --git a/Transit/Transit/MCP/MCPServer.swift b/Transit/Transit/MCP/MCPServer.swiftindex 210a7e6..6821491 100644--- a/Transit/Transit/MCP/MCPServer.swift+++ b/Transit/Transit/MCP/MCPServer.swift@@ -95,23 +95,62 @@ final class MCPServer { let body = try await request.body.collect(upTo: 1_048_576) let data = Data(buffer: body) - let rpcRequest: JSONRPCRequest switch decodeIncomingRequest(data) {- case .success(let req):- rpcRequest = req+ case .success(let rpcRequest):+ // A single notification has no JSON-RPC response body.+ guard let rpcResponse = await handler.handle(rpcRequest) else {+ return Response(status: .accepted)+ }+ return jsonResponse(rpcResponse)++ case .batch(let elements):+ return await batchResponse(for: elements, handler: handler)+ case .failure(let errorResponse): return jsonResponse(errorResponse) }+ }+ return router+ }++ /// Dispatches one non-empty JSON-RPC batch and builds its HTTP response.+ private static func batchResponse(+ for elements: [BatchElement],+ handler: MCPToolHandler+ ) async -> Response {+ var responses: [JSONRPCResponse] = []+ responses.reserveCapacity(elements.count) - // Notifications (id member omitted) must not receive a- // JSON-RPC response body. Explicit "id": null is NOT a- // notification and is handled by the tool dispatcher.- guard let rpcResponse = await handler.handle(rpcRequest) else {- return Response(status: .accepted)+ // Sequential dispatch is intentional. JSON-RPC permits any processing+ // order, and preserving input order keeps side effects deterministic+ // for Transit's shared model context.+ for element in elements {+ switch element {+ case .invalid:+ responses.append(invalidRequestResponse())+ case .request(let rpcRequest):+ // MCP 2025-03-26 lifecycle: initialize MUST NOT be in a batch.+ // Notifications still never get a response.+ if rpcRequest.method == "initialize" {+ if !rpcRequest.isNotification {+ responses.append(JSONRPCResponse.error(+ id: rpcRequest.id,+ code: JSONRPCErrorCode.invalidRequest,+ message: "Invalid Request: initialize must not be batched"+ ))+ }+ } else if let rpcResponse = await handler.handle(rpcRequest) {+ responses.append(rpcResponse)+ } }- return jsonResponse(rpcResponse) }- return router++ // JSON-RPC forbids an empty response array. Streamable HTTP requires+ // 202 with no body when the input is notifications only.+ guard !responses.isEmpty else {+ return Response(status: .accepted)+ }+ return jsonResponse(responses) } func stop() {@@ -123,32 +162,37 @@ final class MCPServer { // MARK: - Helpers - /// Result of attempting to decode an incoming request body as JSON-RPC.+ /// Result of decoding an incoming body as one JSON-RPC request or a batch. nonisolated enum DecodeOutcome: Sendable { case success(JSONRPCRequest)+ case batch([BatchElement]) case failure(JSONRPCResponse) } - /// Decode an incoming request body as a JSON-RPC request, distinguishing- /// transport-level parse failures from protocol-level shape failures.- ///- /// Per JSON-RPC 2.0 §5.1:- /// - Returns `.failure` with code `-32700 "Parse error"` when the body is- /// not well-formed JSON.- /// - Returns `.failure` with code `-32600 "Invalid Request"` when the body- /// is valid JSON but is not a well-formed Request object (e.g. missing- /// `method`, non-string `jsonrpc`, unsupported `id` type, or a non-object- /// root).- /// - Returns `.success` with the decoded request otherwise.+ /// One member of a non-empty JSON-RPC batch. Invalid members stay in the+ /// sequence so the route can emit one -32600 response for each of them.+ nonisolated enum BatchElement: Sendable {+ case request(JSONRPCRequest)+ case invalid+ }++ /// Decode an incoming request body as a single JSON-RPC request or a+ /// non-empty JSON-RPC batch, distinguishing transport-level parse failures+ /// from protocol-level shape failures. ///- /// The error response always uses `id: null` because the id member of a- /// malformed request cannot be reliably extracted.+ /// Per JSON-RPC 2.0 §5.1 and §6:+ /// - Malformed JSON produces one `-32700 Parse error` response.+ /// - An invalid single root or an empty batch produces one `-32600 Invalid+ /// Request` response object.+ /// - A non-empty batch preserves every member. Valid request objects are+ /// dispatched, while each invalid member produces its own `-32600`+ /// response in the eventual response array. nonisolated static func decodeIncomingRequest( _ data: Data ) -> DecodeOutcome { // Stage 1: confirm the bytes are well-formed JSON. `.fragmentsAllowed`- // lets scalar roots through so they reach the shape check (and become- // Invalid Request rather than Parse error).+ // lets scalar roots through so they become Invalid Request rather than+ // Parse error. let parsed: Any do { parsed = try JSONSerialization.jsonObject(@@ -162,33 +206,53 @@ final class MCPServer { )) } - // A present-but-non-string `jsonrpc` member is a structurally invalid- // request (-32600), not a parse error. `JSONRPCRequest`'s decoder is- // intentionally lenient — it coerces a non-string `jsonrpc` to "" so the- // handler's version check can reject it (T-1106) — so the shape failure- // is detected here to honor this method's documented contract (T-1128).- if let object = parsed as? [String: Any],- object["jsonrpc"] != nil,- !(object["jsonrpc"] is String) {- return .failure(JSONRPCResponse.error(- id: nil,- code: JSONRPCErrorCode.invalidRequest,- message: "Invalid Request"- ))+ if let batch = parsed as? [Any] {+ guard !batch.isEmpty else {+ return .failure(invalidRequestResponse())+ }+ return .batch(batch.map { element in+ guard let object = element as? [String: Any],+ let request = decodeRequestObject(object) else {+ return .invalid+ }+ return .request(request)+ }) } - // Stage 2: structural decode into JSONRPCRequest. Any failure here is- // valid JSON with the wrong shape — Invalid Request.- do {- let rpcRequest = try JSONDecoder().decode(JSONRPCRequest.self, from: data)- return .success(rpcRequest)- } catch {- return .failure(JSONRPCResponse.error(- id: nil,- code: JSONRPCErrorCode.invalidRequest,- message: "Invalid Request"- ))+ guard let object = parsed as? [String: Any],+ isValidRequestObjectShape(object),+ let request = try? JSONDecoder().decode(JSONRPCRequest.self, from: data) else {+ return .failure(invalidRequestResponse())+ }+ return .success(request)+ }++ /// Structural checks that `JSONRPCRequest` intentionally leaves lenient.+ /// In particular, a missing `jsonrpc` member decodes to `""` so the handler+ /// can preserve T-1106's detailed version error, but a present non-string+ /// member is not a valid Request object at all.+ nonisolated private static func isValidRequestObjectShape(+ _ object: [String: Any]+ ) -> Bool {+ object["jsonrpc"] == nil || object["jsonrpc"] is String+ }++ nonisolated private static func decodeRequestObject(+ _ object: [String: Any]+ ) -> JSONRPCRequest? {+ guard isValidRequestObjectShape(object),+ let data = try? JSONSerialization.data(withJSONObject: object) else {+ return nil }+ return try? JSONDecoder().decode(JSONRPCRequest.self, from: data)+ }++ nonisolated private static func invalidRequestResponse() -> JSONRPCResponse {+ JSONRPCResponse.error(+ id: nil,+ code: JSONRPCErrorCode.invalidRequest,+ message: "Invalid Request"+ ) } /// Transport-level rejection. Deliberately not a JSON-RPC error body: the@@ -207,11 +271,11 @@ final class MCPServer { } nonisolated private static func jsonResponse(- _ response: JSONRPCResponse+ _ payload: some Encodable & Sendable ) -> Response { let data: Data do {- data = try JSONEncoder().encode(response)+ data = try JSONEncoder().encode(payload) } catch { let fallback = """ {"jsonrpc":"2.0","id":null,\
diff --git a/Transit/Transit/MCP/MCPToolHandler.swift b/Transit/Transit/MCP/MCPToolHandler.swiftindex 159873a..053108e 100644--- a/Transit/Transit/MCP/MCPToolHandler.swift+++ b/Transit/Transit/MCP/MCPToolHandler.swift@@ -59,30 +59,28 @@ final class MCPToolHandler { ) } - // Notifications omit the `id` member entirely (per JSON-RPC 2.0 §4.1).- // An explicit `"id": null` is a regular request and must receive a- // response with `id: null`, so we rely on the parsed notification- // flag rather than `request.id == nil`.- if request.isNotification {- return nil- }-+ let response: JSONRPCResponse switch request.method { case "initialize":- return handleInitialize(id: request.id)+ response = handleInitialize(id: request.id) case "ping":- return JSONRPCResponse.success(id: request.id, result: EmptyResult())+ response = JSONRPCResponse.success(id: request.id, result: EmptyResult()) case "tools/list":- return handleToolsList(id: request.id)+ response = handleToolsList(id: request.id) case "tools/call":- return await handleToolCall(id: request.id, params: request.params)+ response = await handleToolCall(id: request.id, params: request.params) default:- return JSONRPCResponse.error(+ response = JSONRPCResponse.error( id: request.id, code: JSONRPCErrorCode.methodNotFound, message: "Unknown method: \(request.method)" ) }++ // Notifications are method calls whose results are intentionally+ // discarded, not calls that should be skipped. This matters for+ // state-changing tools/call notifications inside a JSON-RPC batch.+ return request.isNotification ? nil : response } // MARK: - Initialize
diff --git a/Transit/TransitTests/MCPServerBatchRequestTests.swift b/Transit/TransitTests/MCPServerBatchRequestTests.swiftnew file mode 100644index 0000000..57ebfb8--- /dev/null+++ b/Transit/TransitTests/MCPServerBatchRequestTests.swift@@ -0,0 +1,201 @@+#if os(macOS)+import Foundation+import HTTPTypes+import Hummingbird+import Logging+import NIOConcurrencyHelpers+import NIOCore+import NIOEmbedded+import NIOFoundationCompat+import SwiftData+import Testing+@testable import Transit++/// Regression tests for T-1834: the MCP Streamable HTTP endpoint advertises+/// protocol 2025-03-26, so POST bodies may be JSON-RPC request batches.+///+/// Before the fix, `MCPServer.decodeIncomingRequest` decoded only one+/// `JSONRPCRequest`. Every array therefore became a single -32600 response,+/// including valid batches and all-notification batches that require HTTP 202.+@MainActor @Suite(.serialized)+struct MCPServerBatchRequestTests {++ @Test func mixedRequestAndNotificationBatchReturnsOnlyRequestResponses() async throws {+ let response = try await respond(body: """+ [+ {"jsonrpc":"2.0","id":1,"method":"ping"},+ {"jsonrpc":"2.0","method":"ping"},+ {"jsonrpc":"2.0","id":"missing","method":"unknown/method"}+ ]+ """)++ #expect(response.status == .ok)+ #expect(response.contentType == "application/json")+ let objects = try #require(response.json as? [[String: Any]])+ #expect(objects.count == 2)+ #expect(objects.contains { $0["id"] as? Int == 1 && $0["result"] != nil })+ #expect(objects.contains { object in+ object["id"] as? String == "missing"+ && (object["error"] as? [String: Any])?["code"] as? Int+ == JSONRPCErrorCode.methodNotFound+ })+ }++ @Test func allNotificationBatchReturnsAcceptedWithNoBody() async throws {+ let response = try await respond(body: """+ [+ {"jsonrpc":"2.0","method":"ping"},+ {"jsonrpc":"2.0","method":"notifications/initialized"}+ ]+ """)++ #expect(response.status == .accepted)+ #expect(response.body.isEmpty)+ }++ @Test func toolCallNotificationIsDispatchedButGetsNoResponse() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let project = MCPTestHelpers.makeProject(in: env.context)+ let response = try await respond(handler: env.handler, body: """+ [{+ "jsonrpc":"2.0",+ "method":"tools/call",+ "params":{+ "name":"create_task",+ "arguments":{+ "name":"Notification task",+ "type":"bug",+ "projectId":"\(project.id.uuidString)"+ }+ }+ }]+ """)++ #expect(response.status == .accepted)+ #expect(response.body.isEmpty)+ let tasks = try env.context.fetch(FetchDescriptor<TransitTask>())+ #expect(tasks.map(\.name) == ["Notification task"])+ }++ @Test func emptyBatchReturnsSingleInvalidRequestObject() async throws {+ let response = try await respond(body: "[]")++ #expect(response.status == .ok)+ let object = try #require(response.json as? [String: Any])+ let error = try #require(object["error"] as? [String: Any])+ #expect(error["code"] as? Int == JSONRPCErrorCode.invalidRequest)+ #expect(object["id"] is NSNull)+ }++ @Test func invalidBatchMembersEachProduceAnErrorResponse() async throws {+ let response = try await respond(body: """+ [+ 17,+ {"jsonrpc":"2.0","id":2,"method":"ping"},+ {"jsonrpc":"2.0","id":false,"method":"ping"}+ ]+ """)++ #expect(response.status == .ok)+ let objects = try #require(response.json as? [[String: Any]])+ #expect(objects.count == 3)+ #expect(objects.filter { object in+ (object["error"] as? [String: Any])?["code"] as? Int+ == JSONRPCErrorCode.invalidRequest+ }.count == 2)+ #expect(objects.contains { $0["id"] as? Int == 2 && $0["result"] != nil })+ }++ @Test func initializeInsideBatchIsRejectedWithoutRejectingOtherRequests() async throws {+ let response = try await respond(body: """+ [+ {"jsonrpc":"2.0","id":1,"method":"initialize"},+ {"jsonrpc":"2.0","id":2,"method":"ping"}+ ]+ """)++ #expect(response.status == .ok)+ let objects = try #require(response.json as? [[String: Any]])+ #expect(objects.count == 2)+ let initializeResponse = try #require(objects.first { $0["id"] as? Int == 1 })+ let error = try #require(initializeResponse["error"] as? [String: Any])+ #expect(error["code"] as? Int == JSONRPCErrorCode.invalidRequest)+ #expect(objects.contains { $0["id"] as? Int == 2 && $0["result"] != nil })+ }++ @Test func singleRequestStillReturnsAResponseObject() async throws {+ let response = try await respond(+ body: #"{"jsonrpc":"2.0","id":1,"method":"ping"}"#+ )++ #expect(response.status == .ok)+ let object = try #require(response.json as? [String: Any])+ #expect(object["id"] as? Int == 1)+ #expect(object["result"] != nil)+ }++ // MARK: - Helpers++ private func respond(body: String) async throws -> CapturedResponse {+ let env = try MCPTestHelpers.makeEnv()+ return try await respond(handler: env.handler, body: body)+ }++ private func respond(+ handler: MCPToolHandler,+ body: String+ ) async throws -> CapturedResponse {+ let responder = MCPServer.makeRouter(handler: handler).buildResponder()+ let request = Request(+ head: HTTPRequest(+ method: .post,+ scheme: "http",+ authority: "127.0.0.1:3141",+ path: "/mcp",+ headerFields: [.contentType: "application/json"]+ ),+ body: RequestBody(buffer: ByteBuffer(string: body))+ )++ let channel = EmbeddedChannel()+ defer { _ = try? channel.finish() }+ let context = BasicRequestContext(+ source: ApplicationRequestContextSource(+ channel: channel, logger: Logger(label: "mcp-batch-tests")+ )+ )++ let response = try await responder.respond(to: request, context: context)+ let writer = CollatedResponseWriter()+ try await response.body.write(writer)+ let data = Data(buffer: writer.collated.withLockedValue { $0 })+ return CapturedResponse(+ status: response.status,+ contentType: response.headers[.contentType],+ body: data+ )+ }+}++private nonisolated struct CapturedResponse {+ let status: HTTPResponse.Status+ let contentType: String?+ let body: Data++ var json: Any? {+ guard !body.isEmpty else { return nil }+ return try? JSONSerialization.jsonObject(with: body)+ }+}++private nonisolated final class CollatedResponseWriter: ResponseBodyWriter {+ let collated = NIOLockedValueBox(ByteBuffer())++ func write(_ buffer: ByteBuffer) async throws {+ collated.withLockedValue { $0.writeImmutableBuffer(buffer) }+ }++ func finish(_: HTTPFields?) async throws {}+}++#endif
diff --git a/Transit/TransitTests/MCPRequestDecodeErrorClassificationTests.swift b/Transit/TransitTests/MCPRequestDecodeErrorClassificationTests.swiftindex 8f3b5e0..5c099cc 100644--- a/Transit/TransitTests/MCPRequestDecodeErrorClassificationTests.swift+++ b/Transit/TransitTests/MCPRequestDecodeErrorClassificationTests.swift@@ -87,6 +87,8 @@ struct MCPRequestDecodeErrorClassificationTests { switch response { case .success: Issue.record("Expected error response for invalid id type")+ case .batch:+ Issue.record("Expected single-request decode failure, not a batch") case .failure(let errorResponse): let encoded = try JSONEncoder().encode(errorResponse) let parsed = try JSONSerialization.jsonObject(with: encoded) as? [String: Any]@@ -106,6 +108,8 @@ struct MCPRequestDecodeErrorClassificationTests { #expect(req.method == "ping") #expect(req.jsonrpc == "2.0") #expect(!req.isNotification)+ case .batch:+ Issue.record("Single request must not decode as a batch") case .failure: Issue.record("Well-formed request must decode successfully") }@@ -119,6 +123,9 @@ struct MCPRequestDecodeErrorClassificationTests { case .success: Issue.record("Expected decode failure but request decoded successfully") throw DecodeUnwrapError.unexpectedSuccess+ case .batch:+ Issue.record("Expected single-request decode failure, not a batch")+ throw DecodeUnwrapError.unexpectedSuccess case .failure(let response): let error = try #require(response.error, "Error response must carry a JSONRPCError") return error
diff --git a/docs/agent-notes/mcp-server.md b/docs/agent-notes/mcp-server.mdindex f1a7021..5ef3923 100644--- a/docs/agent-notes/mcp-server.md+++ b/docs/agent-notes/mcp-server.md@@ -97,6 +97,12 @@ Gotchas: `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call` +## JSON-RPC Batch Envelopes (T-1834)++`POST /mcp` accepts either one `JSONRPCRequest` or a non-empty JSON-RPC batch. `MCPServer.decodeIncomingRequest` preserves invalid batch members so each produces its own `-32600` response, while an empty batch produces one standalone `-32600` object as required by JSON-RPC 2.0.++Batch dispatch is sequential on the MainActor to keep mutations against the shared SwiftData context deterministic. Responses to request members remain inside an array even when notifications are omitted. A batch containing only notifications returns HTTP 202 with no body; notifications still execute their method/tool path before the result is discarded. Batched `initialize` requests are rejected with `-32600` because MCP 2025-03-26 requires initialization to be a standalone request. Single requests continue to return one response object.+ ## Dependencies - **Hummingbird 2.x** — Transit's first (and only) external SPM dependency
diff --git a/specs/bugfixes/mcp-rejects-valid-json-rpc-batch-requests/report.md b/specs/bugfixes/mcp-rejects-valid-json-rpc-batch-requests/report.mdnew file mode 100644index 0000000..aacb7a7--- /dev/null+++ b/specs/bugfixes/mcp-rejects-valid-json-rpc-batch-requests/report.md@@ -0,0 +1,128 @@+# Bugfix Report: MCP Rejects Valid JSON-RPC Batch Requests++**Date:** 2026-08-01+**Status:** Fixed+**Transit ticket:** T-1834++## Description of the Issue++Transit advertises MCP protocol version `2025-03-26`, whose Streamable HTTP transport permits a POST body containing a non-empty JSON-RPC batch of requests and notifications. The embedded MCP server decodes every POST body directly as one `JSONRPCRequest`, so every array is rejected with a single `-32600 Invalid Request` response.++**Reproduction steps:**+1. Enable Transit's MCP server.+2. POST `[{"jsonrpc":"2.0","id":1,"method":"ping"}]` to `/mcp`.+3. Observe a single `-32600 Invalid Request` object instead of an array containing the ping response.++**Impact:** Standards-compliant MCP clients cannot batch calls against Transit. Mixed batches lose all valid requests, notification-only batches return HTTP 200 with an error body instead of HTTP 202 with no body, and the advertised protocol contract is not met.++## Investigation Summary++### Phase 1: Initial overview++- **Expected behaviour:** A non-empty request/notification batch is processed element by element; notifications produce no JSON-RPC response; request responses remain wrapped in an array; all-notification batches return HTTP 202 with no body; malformed batch elements produce per-element `-32600` responses; empty batches produce one `-32600` response object; `initialize` is rejected when batched.+- **Actual behaviour:** Any top-level array fails structural decoding as one `JSONRPCRequest` and returns one `-32600` response object.+- **Context:** The defect occurs for every batch POST, independent of the methods or IDs in the batch.++### Phase 2: Systematic inspection++- **Data flow defect — `Transit/Transit/MCP/MCPServer.swift`:** `decodeIncomingRequest(_:)` parses the JSON root but then always decodes the original bytes as one `JSONRPCRequest`; it has no single-versus-batch envelope.+- **Logic defect — `Transit/Transit/MCP/MCPServer.swift`:** `makeRouter(handler:)` dispatches exactly one request and can encode exactly one response.+- **Boundary defect — empty array:** JSON-RPC requires a single invalid-request object for an empty batch, distinct from non-empty invalid batches whose errors are returned in an array.+- **Lifecycle defect — batched initialize:** MCP 2025-03-26 states that `initialize` MUST NOT be part of a JSON-RPC batch, but the current transport has no batch context in which to enforce that rule.+- **Response-shape defect:** JSON-RPC requires a batch response array even when a valid batch contains only one request; notification responses must be omitted rather than represented as null or empty entries.++### Phase 3: Root cause analysis++1. Why are valid batches rejected? Because the decoder expects one request object.+2. Why does it expect one object? Because `DecodeOutcome.success` carries only `JSONRPCRequest`.+3. Why can the route not recover? Because dispatch and response encoding are also single-message only.+4. Why was this accepted? The original HTTP endpoint implemented the common one-request-per-POST path without mirroring the batching clause of the advertised Streamable HTTP protocol.+5. Why do lifecycle and HTTP status errors follow from the same omission? Batch context is required to reject `initialize`, omit notification responses, choose an array response, and detect an all-notification POST.++**Root cause:** The transport model represents a POST body as one JSON-RPC request instead of a JSON-RPC single-or-batch envelope.++**Assumptions validated:** The MCP 2025-03-26 transport specification explicitly allows non-empty request/notification arrays and requires HTTP 202 for notification-only input. Its lifecycle specification explicitly forbids `initialize` in a batch. JSON-RPC 2.0 defines empty and invalid batch response shapes.++### Phase 4: Solution and verification++Implement a top-level decode outcome that preserves single-request compatibility and adds non-empty batch elements. Decode each batch element independently so malformed members become per-element `-32600` responses. Dispatch valid elements through the existing `MCPToolHandler`, reject non-notification `initialize` elements before handler dispatch, collect only non-nil responses, and encode collected batch responses as an array. Return HTTP 202 with no body when no response remains.++Verification will cover mixed requests/notifications, all-notification HTTP 202, empty batches, invalid members, batched initialize, batch response arrays, and unchanged single-request object responses.++## Discovered Root Cause++The MCP HTTP transport has no representation of a JSON-RPC batch envelope. Its decoder, dispatcher, and encoder are each hard-coded to exactly one request and one response.++**Defect type:** Protocol integration and boundary-handling omission.++**Why it occurred:** The initial implementation covered single JSON-RPC request/notification POSTs but did not implement the batching requirements of MCP protocol `2025-03-26` that the server later advertised.++**Contributing factors:** Batch rules span JSON parsing, MCP lifecycle validation, JSON-RPC response shape, and HTTP status selection, so handling only the structural decoder would still leave the endpoint non-compliant.++## Resolution for the Issue++**Changes made:**+- `Transit/Transit/MCP/MCPServer.swift` now distinguishes single requests from non-empty batch envelopes, retains invalid members for per-element errors, handles empty batches as one invalid request, dispatches batch elements sequentially, rejects batched `initialize`, returns response arrays for batches, and returns HTTP 202 with no body when no responses remain.+- `Transit/Transit/MCP/MCPToolHandler.swift` now executes valid notification method/tool paths before suppressing their response, rather than dropping notifications before dispatch.+- JSON response encoding now accepts either a single `JSONRPCResponse` or `[JSONRPCResponse]` without changing single-request response shape.++**Approach rationale:** Keeping envelope parsing and HTTP response-shape decisions in `MCPServer` preserves the existing request dispatcher while giving it the batch context needed for MCP lifecycle validation. Sequential dispatch is legal under JSON-RPC and avoids nondeterministic mutations on Transit's shared MainActor SwiftData context.++**Alternatives considered:**+- Decode a Codable single-or-array enum directly. Rejected because independently invalid batch members must survive decoding and produce their own `-32600` responses instead of failing the whole array.+- Dispatch batch elements concurrently. Rejected because JSON-RPC does not require concurrency and deterministic ordering is safer for state-changing task tools sharing one model context.+- Reject an entire batch when it contains `initialize`. Rejected because per-member handling preserves valid sibling requests while returning a lifecycle error for the invalid initialize member.++## Regression Test++**Test file:** `Transit/TransitTests/MCPServerBatchRequestTests.swift`++**Tests:**+- `mixedRequestAndNotificationBatchReturnsOnlyRequestResponses`+- `allNotificationBatchReturnsAcceptedWithNoBody`+- `toolCallNotificationIsDispatchedButGetsNoResponse`+- `emptyBatchReturnsSingleInvalidRequestObject`+- `invalidBatchMembersEachProduceAnErrorResponse`+- `initializeInsideBatchIsRejectedWithoutRejectingOtherRequests`+- `singleRequestStillReturnsAResponseObject`++**What they verify:** Streamable HTTP status handling, notification side effects, lifecycle enforcement, and externally visible JSON-RPC object-versus-array response semantics at the real Hummingbird router boundary.++**Run command:** `make test-quick`++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/MCP/MCPServer.swift` | Batch envelope decoding, dispatch, lifecycle validation, and response encoding |+| `Transit/Transit/MCP/MCPToolHandler.swift` | Execute notifications while omitting their responses |+| `Transit/TransitTests/MCPServerBatchRequestTests.swift` | Focused route-level regression coverage |+| `Transit/TransitTests/MCPRequestDecodeErrorClassificationTests.swift` | Existing single-request decode assertions made exhaustive for batch outcomes |+| `docs/agent-notes/mcp-server.md` | Batch semantics and maintenance guidance |+| `specs/bugfixes/mcp-rejects-valid-json-rpc-batch-requests/report.md` | Investigation and resolution record |+| `CHANGELOG.md` | User-visible T-1834 fix summary |++## Verification++**Automated:**+- [x] Regression tests fail before the fix: `make test-quick PIPE_PRETTY=''` discovered the new suite and failed exactly the unsupported batch behaviours. Empty-batch and unchanged single-request controls passed.+- [x] Regression tests and the full macOS unit suite pass: `make test-quick`.+- [x] SwiftLint and the model-container ownership guard pass: `make lint`.+- [x] The affected macOS app builds: `make build-macos`.+- [~] `make test` and `make test-ui` were attempted but could not produce a clean iOS result. Xcode repeatedly failed to connect to an attached device's `com.apple.mobile.notification_proxy`, emitted `DebuggerVersionStore` errors, and the commands hit the harness timeout. The UI run also reproduced six unrelated existing UI failures (`testClearAll`, `testEditViewPreservesTaskMilestone`, three Settings navigation tests, and `testDataMaintenanceGoldenPath`). T-1834 changes are macOS-only and the macOS suite is green.++**Manual verification:**+- The route-level tests collect and inspect the real Hummingbird response body, confirming response arrays, null IDs for invalid members, content type, and empty HTTP 202 bodies without requiring a separately launched app process.++## Prevention++- Model protocol envelopes explicitly rather than assuming one message per transport operation.+- Add transport-level tests whenever the advertised MCP protocol version changes.+- Keep JSON-RPC batch rules and MCP lifecycle restrictions covered independently.++## Related++- Transit ticket T-1834+- MCP 2025-03-26 Streamable HTTP transport: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#sending-messages-to-the-server+- MCP 2025-03-26 lifecycle: https://modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle#initialization+- JSON-RPC 2.0 batching: https://www.jsonrpc.org/specification#batch
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 7870fe7..d831256 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- The MCP Streamable HTTP endpoint now accepts non-empty JSON-RPC request/notification batches for advertised protocol `2025-03-26` (T-1834). It decodes each member independently, dispatches requests and notifications sequentially, omits notification responses, preserves response-array semantics, returns HTTP 202 with no body for all-notification batches, emits per-member `-32600` errors for invalid entries, handles empty batches as one invalid-request object, and rejects lifecycle-invalid batched `initialize` calls. Route-level regression tests cover each behavior and unchanged single-request responses.+ - SwiftData test fixtures now retain their backing `ModelContainer` for the full test lifetime (T-2003). `TestModelContainer` is a container-owning fixture instead of a bare-context factory, 207 context-acquisition call sites across 92 test files were migrated, and bespoke context helpers in task-entity, task-creation-result, and report tests now delegate to the shared fixture. An escaped-context lifetime regression plus an executable ownership guard with positive/negative fixtures prevent raw container/context factories from recurring. - Cross-device milestone name conflicts no longer make name-based operations target an arbitrary record (T-1938). `MilestoneService.findByName` now throws on multiple project-scoped matches; all MCP and App Intent callers report the ambiguity (`AMBIGUOUS_MILESTONE` for intents). Launch/foreground/connectivity maintenance deterministically keeps the oldest milestone name and renames other UUID-distinct records with UUID-derived suffixes, preserving records and task assignments. Cross-context tests simulate CloudKit imports and verify ambiguity reporting, reconciliation, idempotence, and data preservation.
Because decodeRequestObject re-serializes the parsed dictionary rather than decoding the original bytes, I ran both paths side by side against four id shapes. Results were identical in every case: explicit null → request (not notification) with id == nil; 1.0 → .integer(1); 1.5 → rejected; 12345678901234567890 → rejected. Nested arrays ([[{...}]]) become .invalid. No divergence found — this closes the main correctness question the design raised.
make lint: 0 violations, 0 serious, across 304 files (SwiftLint plus the model-container ownership guard). make test-quick: Test Succeeded, exit 0, 0 failures, and all seven MCPServerBatchRequestTests cases discovered and green. The report's verification claims hold.
The report marks make test and make test-ui as attempted-but-inconclusive (device notification-proxy failures, harness timeout, plus six pre-existing unrelated UI failures). I did not re-attempt them. All production code in this branch is inside #if os(macOS), so the macOS suite is the load-bearing one — but the iOS gap is unclosed and the author's own note is the only record of it.
handle's guard request.jsonrpc == "2.0" deliberately returns an error even for notification-shaped envelopes (T-1106). Inside a batch this means an all-notification batch containing one bad-version member returns 200 with a one-element array rather than 202 with no body. Defensible under JSON-RPC §5 — an invalid Request's notification status is not reliably determinable — and arguably the better behaviour, but it is new emergent behaviour from combining two previously independent rules, and it is neither documented nor tested.
The if !rpcRequest.isNotification guard means a batched initialize notification is neither dispatched nor answered. That is the right call — the lifecycle rule forbids it, and no response may be emitted for a notification — but it is worth stating explicitly since 'silently drop' reads as an oversight on first pass.