Review of origin/main...257f914 for PR #198. No attributable regression found.
initialize now requires the documented MCP parameter object and returns the correct JSON-RPC error class for malformed requests.testClearAll, testEditViewPreservesTaskMilestone, the three dashboard.settingsButton selectors, and testDataMaintenanceGoldenPath on both head and origin/main.Ready to push
No correctness, quality, reuse, or efficiency defect was found in the reviewed range. The head's make test and make test-ui result bundles each contain only the established iOS 26.5 six-selector UI baseline; an independent clean origin/main make test-ui run produced the same set. The branch contains no UI implementation or UI-test changes, so the suite failure is documented as a baseline rather than attributed to this branch.
7c75859 T-1778: Add MCP initialize regression coverage ff75d3a T-1778: Validate MCP initialize handshakes 0f72958 T-1778: Address PR review feedback 257f914 T-1778: Record final iOS test results The MCP connection now checks that a client introduces itself correctly before accepting the connection. Missing or malformed handshake details receive a clear error instead of a successful response. Tests cover the bad inputs so this does not quietly regress.
MCPToolHandler now receives initialize parameters, validates required fields and types, and chooses the requested protocol version only when supported. MCPServer rejects scalar or null JSON-RPC params before dispatch. Focused tests cover parameter classification, field validation, and fallback negotiation.
The change preserves the existing permissive AnyCodable envelope while drawing the protocol boundary at the correct layer: invalid JSON-RPC request shapes receive -32600, whereas method-level initialize envelope and field defects receive -32602. Validation stays isolated from the NIO-facing decode path, retains the MainActor handler boundary, and models version negotiation through an explicit supported-version set and latest-version constant.
Transit/Transit/MCP/MCPToolHandler.swift
Why it matters. Prevents malformed clients from establishing a successful MCP session and returns protocol-correct errors.
What to look at. MCPToolHandler.handleInitialize and validation helpers
Transit/Transit/MCP/MCPServer.swift
Why it matters. Maintains the JSON-RPC distinction between invalid request structure and invalid method parameters.
What to look at. MCPServer.isValidRequestObjectShape
Transit/TransitTests/MCPInitializeHandshakeTests.swift
Why it matters. Covers the previously untested input-validation and unsupported-version paths.
What to look at. MCPInitializeHandshakeTests
The handler receives AnyCodable by design. Valid JSON-RPC requests with malformed initialize arguments must return -32602, so field validation belongs in MCPToolHandler.
For an unsupported requested version, MCP lifecycle negotiation expects a server-supported version, preferably the latest, rather than a handshake failure. Transit currently supports only 2025-03-26.
Click to expand.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex a6d0a14..542d3fa 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- MCP `initialize` now requires object params with string `protocolVersion`, object `capabilities`, and typed `clientInfo` identity fields (T-1778). Missing or malformed method parameters return JSON-RPC `-32602 Invalid Params`. JSON-RPC request decoding now rejects scalar/null top-level `params` server-wide with `-32600 Invalid Request`. Supported `2025-03-26` requests are echoed and unsupported versions receive the server's latest advertised supported version. Focused handshake tests cover absent params/fields, wrong types, malformed client information, request-shape errors, and both negotiation paths. - 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. - MCP `create_task`, `CreateTaskIntent`, and `AddTaskSheet.persist` now create a task plus optional milestone atomically in one `TaskService` save (T-1768). The relationship is validated and attached before insertion; failed saves delete the pending aggregate instead of relying on a second, fallible compensating deletion. Surface-level failure regressions verify no task remains or is resurrected by a later save. `CreateTaskIntent` maps the merged save's failures per error kind, so a storage failure that was previously reported as `INTERNAL_ERROR` by the separate milestone-assignment step still reports `INTERNAL_ERROR` rather than collapsing into `INVALID_INPUT`; `milestoneProjectMismatch` from the service boundary surfaces as `MILESTONE_PROJECT_MISMATCH`. - Duplicate cleanup now re-probes a task or milestone loser's committed display ID after asynchronous allocation and immediately before mutation (T-2019). Peer changes that land during allocation are preserved and reported as `stale-id`; task cleanup no longer emits a false audit comment for an overwritten peer repair. Deterministic gated regressions cover both record types and the task audit path.
diff --git a/Transit/Transit/MCP/MCPServer.swift b/Transit/Transit/MCP/MCPServer.swiftindex 6821491..5110a30 100644--- a/Transit/Transit/MCP/MCPServer.swift+++ b/Transit/Transit/MCP/MCPServer.swift@@ -228,13 +228,20 @@ final class MCPServer { } /// 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.+ /// 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. Likewise, present `params` must be the structured+ /// object or array required by JSON-RPC 2.0 §4.2. nonisolated private static func isValidRequestObjectShape( _ object: [String: Any] ) -> Bool {- object["jsonrpc"] == nil || object["jsonrpc"] is String+ guard object["jsonrpc"] == nil || object["jsonrpc"] is String else {+ return false+ }+ guard let params = object["params"] else {+ return true+ }+ return params is [String: Any] || params is [Any] } nonisolated private static func decodeRequestObject(
diff --git a/Transit/Transit/MCP/MCPToolHandler.swift b/Transit/Transit/MCP/MCPToolHandler.swiftindex dea1ad5..636914b 100644--- a/Transit/Transit/MCP/MCPToolHandler.swift+++ b/Transit/Transit/MCP/MCPToolHandler.swift@@ -62,7 +62,7 @@ final class MCPToolHandler { let response: JSONRPCResponse switch request.method { case "initialize":- response = handleInitialize(id: request.id)+ response = handleInitialize(id: request.id, params: request.params) case "ping": response = JSONRPCResponse.success(id: request.id, result: EmptyResult()) case "tools/list":@@ -85,16 +85,95 @@ final class MCPToolHandler { // MARK: - Initialize - private func handleInitialize(id: JSONRPCId?) -> JSONRPCResponse {+ private static let latestSupportedProtocolVersion = "2025-03-26"+ private static let supportedProtocolVersions: Set<String> = [latestSupportedProtocolVersion]++ private struct InvalidInitializeParams: Error {+ let message: String+ }++ private func handleInitialize(id: JSONRPCId?, params: AnyCodable?) -> JSONRPCResponse {+ guard let params else {+ return invalidInitializeParams(id: id, message: "Missing initialize params")+ }+ guard let dict = params.value as? [String: Any] else {+ return invalidInitializeParams(id: id, message: "Initialize params must be an object")+ }++ let requestedProtocolVersion: String+ do {+ requestedProtocolVersion = try initializeProtocolVersion(in: dict)+ try validateInitializeCapabilities(in: dict)+ try validateInitializeClientInfo(in: dict)+ } catch {+ return invalidInitializeParams(id: id, message: error.message)+ }++ let protocolVersion = Self.supportedProtocolVersions.contains(requestedProtocolVersion)+ ? requestedProtocolVersion+ : Self.latestSupportedProtocolVersion let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" let result = MCPInitializeResult(- protocolVersion: "2025-03-26",+ protocolVersion: protocolVersion, capabilities: MCPServerCapabilities(tools: MCPToolsCapability()), serverInfo: MCPServerInfo(name: "transit", version: version) ) return JSONRPCResponse.success(id: id, result: result) } + private func initializeProtocolVersion(+ in params: [String: Any]+ ) throws(InvalidInitializeParams) -> String {+ guard params["protocolVersion"] != nil else {+ throw InvalidInitializeParams(message: "Missing protocolVersion")+ }+ guard let protocolVersion = params["protocolVersion"] as? String else {+ throw InvalidInitializeParams(message: "protocolVersion must be a string")+ }+ return protocolVersion+ }++ private func validateInitializeCapabilities(+ in params: [String: Any]+ ) throws(InvalidInitializeParams) {+ guard params["capabilities"] != nil else {+ throw InvalidInitializeParams(message: "Missing capabilities")+ }+ guard params["capabilities"] is [String: Any] else {+ throw InvalidInitializeParams(message: "capabilities must be an object")+ }+ }++ private func validateInitializeClientInfo(+ in params: [String: Any]+ ) throws(InvalidInitializeParams) {+ guard params["clientInfo"] != nil else {+ throw InvalidInitializeParams(message: "Missing clientInfo")+ }+ guard let clientInfo = params["clientInfo"] as? [String: Any] else {+ throw InvalidInitializeParams(message: "clientInfo must be an object")+ }+ guard clientInfo["name"] != nil else {+ throw InvalidInitializeParams(message: "Missing clientInfo.name")+ }+ guard clientInfo["name"] is String else {+ throw InvalidInitializeParams(message: "clientInfo.name must be a string")+ }+ guard clientInfo["version"] != nil else {+ throw InvalidInitializeParams(message: "Missing clientInfo.version")+ }+ guard clientInfo["version"] is String else {+ throw InvalidInitializeParams(message: "clientInfo.version must be a string")+ }+ if clientInfo["title"] != nil, !(clientInfo["title"] is String) {+ throw InvalidInitializeParams(message: "clientInfo.title must be a string")+ }+ }++ private func invalidInitializeParams(id: JSONRPCId?, message: String) -> JSONRPCResponse {+ JSONRPCResponse.error(id: id, code: JSONRPCErrorCode.invalidParams, message: message)+ }+ // MARK: - Tools List private func handleToolsList(id: JSONRPCId?) -> JSONRPCResponse {
diff --git a/Transit/TransitTests/MCPInitializeHandshakeTests.swift b/Transit/TransitTests/MCPInitializeHandshakeTests.swiftnew file mode 100644index 0000000..81e9e5b--- /dev/null+++ b/Transit/TransitTests/MCPInitializeHandshakeTests.swift@@ -0,0 +1,124 @@+#if os(macOS)++import Foundation+import Testing+@testable import Transit++@MainActor @Suite(.serialized)+struct MCPInitializeHandshakeTests {++ @Test func initializeWithoutParamsReturnsInvalidParams() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let response = await env.handler.handle(MCPTestHelpers.request(method: "initialize"))++ let error = try MCPTestHelpers.jsonRPCError(response)+ #expect(error["code"] as? Int == JSONRPCErrorCode.invalidParams)+ }++ @Test func initializeWithNonObjectParamsReturnsInvalidParams() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let request = JSONRPCRequest(+ jsonrpc: "2.0",+ id: .integer(1),+ method: "initialize",+ params: AnyCodable(["2025-03-26"])+ )++ let error = try MCPTestHelpers.jsonRPCError(await env.handler.handle(request))+ #expect(error["code"] as? Int == JSONRPCErrorCode.invalidParams)+ }++ @Test func initializeWithScalarParamsReturnsInvalidRequest() throws {+ for params in ["42", "true", "null", "\"invalid\""] {+ let data = Data("""+ {"jsonrpc":"2.0","id":1,"method":"initialize","params":\(params)}+ """.utf8)++ let response: JSONRPCResponse+ switch MCPServer.decodeIncomingRequest(data) {+ case .failure(let errorResponse):+ response = errorResponse+ case .success, .batch:+ Issue.record("Scalar initialize params must be an invalid request")+ continue+ }+ let error = try MCPTestHelpers.jsonRPCError(response)+ #expect(error["code"] as? Int == JSONRPCErrorCode.invalidRequest)+ }+ }++ @Test func initializeRequiresEveryHandshakeField() async throws {+ let env = try MCPTestHelpers.makeEnv()++ for field in ["protocolVersion", "capabilities", "clientInfo"] {+ var params = validInitializeParams()+ params.removeValue(forKey: field)++ let request = MCPTestHelpers.request(method: "initialize", params: params)+ let error = try MCPTestHelpers.jsonRPCError(await env.handler.handle(request))+ #expect(error["code"] as? Int == JSONRPCErrorCode.invalidParams)+ }+ }++ @Test func initializeRejectsWrongRequiredFieldTypes() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let malformedFields: [(String, Any)] = [+ ("protocolVersion", 20250326),+ ("capabilities", []),+ ("clientInfo", "Transit Tests")+ ]++ for (field, value) in malformedFields {+ var params = validInitializeParams()+ params[field] = value++ let request = MCPTestHelpers.request(method: "initialize", params: params)+ let error = try MCPTestHelpers.jsonRPCError(await env.handler.handle(request))+ #expect(error["code"] as? Int == JSONRPCErrorCode.invalidParams)+ }+ }++ @Test func initializeRejectsMalformedClientInfo() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let malformedClientInfo: [[String: Any]] = [+ ["version": "1.0"],+ ["name": "Transit Tests"],+ ["name": 42, "version": "1.0"],+ ["name": "Transit Tests", "version": 1],+ ["name": "Transit Tests", "version": "1.0", "title": false]+ ]++ for clientInfo in malformedClientInfo {+ var params = validInitializeParams()+ params["clientInfo"] = clientInfo++ let request = MCPTestHelpers.request(method: "initialize", params: params)+ let error = try MCPTestHelpers.jsonRPCError(await env.handler.handle(request))+ #expect(error["code"] as? Int == JSONRPCErrorCode.invalidParams)+ }+ }++ @Test func initializeFallsBackToSupportedProtocolVersion() async throws {+ let env = try MCPTestHelpers.makeEnv()+ var params = validInitializeParams()+ params["protocolVersion"] = "2099-01-01"++ let request = MCPTestHelpers.request(method: "initialize", params: params)+ let response = try #require(await env.handler.handle(request))+ let data = try JSONEncoder().encode(response)+ let json = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])+ let result = try #require(json["result"] as? [String: Any])++ #expect(result["protocolVersion"] as? String == "2025-03-26")+ }++ private func validInitializeParams() -> [String: Any] {+ [+ "protocolVersion": "2025-03-26",+ "capabilities": [:] as [String: Any],+ "clientInfo": ["name": "Transit Tests", "version": "1.0"]+ ]+ }+}++#endif
diff --git a/Transit/TransitTests/MCPToolHandlerTests.swift b/Transit/TransitTests/MCPToolHandlerTests.swiftindex 8cc01a6..d319910 100644--- a/Transit/TransitTests/MCPToolHandlerTests.swift+++ b/Transit/TransitTests/MCPToolHandlerTests.swift@@ -10,9 +10,16 @@ struct MCPToolHandlerTests { // MARK: - Initialize - @Test func initializeReturnsServerInfo() async throws {+ @Test func initializeReturnsServerInfoForSupportedProtocolVersion() async throws { let env = try MCPTestHelpers.makeEnv()- let response = try #require(await env.handler.handle(MCPTestHelpers.request(method: "initialize")))+ let response = try #require(await env.handler.handle(MCPTestHelpers.request(+ method: "initialize",+ params: [+ "protocolVersion": "2025-03-26",+ "capabilities": [:] as [String: Any],+ "clientInfo": ["name": "Transit Tests", "version": "1.0"]+ ]+ ))) let data = try JSONEncoder().encode(response) let json = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
diff --git a/docs/agent-notes/mcp-server.md b/docs/agent-notes/mcp-server.mdindex 5ef3923..1ef45ae 100644--- a/docs/agent-notes/mcp-server.md+++ b/docs/agent-notes/mcp-server.md@@ -97,6 +97,10 @@ Gotchas: `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call` +### Initialize handshake validation (T-1778)++`initialize` requires an object `params` containing string `protocolVersion`, object `capabilities`, and object `clientInfo`. `clientInfo.name` and `clientInfo.version` are required strings; optional `clientInfo.title` must also be a string. Missing fields and object/array method-parameter mismatches return JSON-RPC `invalidParams` (`-32602`); scalar or null top-level `params` violate the JSON-RPC request shape and return `invalidRequest` (`-32600`). Transit currently supports only `2025-03-26`: that version is echoed when requested, while any unsupported requested version receives the latest advertised supported version (`2025-03-26`) per the MCP lifecycle negotiation rule.+ ## 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.
diff --git a/specs/bugfixes/mcp-initialize-accepts-malformed-handshake-requests/report.md b/specs/bugfixes/mcp-initialize-accepts-malformed-handshake-requests/report.mdnew file mode 100644index 0000000..494d81d--- /dev/null+++ b/specs/bugfixes/mcp-initialize-accepts-malformed-handshake-requests/report.md@@ -0,0 +1,89 @@+# Bugfix Report: MCP Initialize Accepts Malformed Handshake Requests++**Date:** 2026-08-02+**Status:** Fixed++## Description of the Issue++Transit returned a successful MCP initialize result without reading the request parameters. Requests that omitted or malformed the protocol version, client capabilities, or client implementation information therefore completed as if they were valid handshakes.++**Reproduction steps:**+1. Send an `initialize` JSON-RPC request with no `params`, array `params`, scalar/null `params`, or malformed required object fields.+2. Observe Transit's MCP response.+3. Before the fix, the server returns a successful initialize result advertising protocol version `2025-03-26`; correct behavior is `-32602 Invalid Params` for method-parameter errors and `-32600 Invalid Request` for scalar/null top-level `params`.++**Impact:** MCP clients could establish a session without negotiating version compatibility, capabilities, or implementation identity, violating the advertised MCP 2025-03-26 lifecycle contract.++## Investigation Summary++The handler dispatch, protocol wire types, existing tests, implementation history, MCP server notes, and MCP 2025-03-26 lifecycle specification were inspected.++- **Symptoms examined:** Parameterless and malformed initialize requests succeed; the response version is hard-coded.+- **Code inspected:** `MCPToolHandler.handle`, `MCPToolHandler.handleInitialize`, `JSONRPCRequest`, MCP initialize result types, test helpers, and existing MCP handler tests.+- **Hypotheses tested:** JSON decoding already enforced handshake parameters (ruled out because `params` is generic `AnyCodable`); another layer negotiated protocol versions (ruled out by repository search); the hard-coded response was sufficient (ruled out because supported requested versions must be echoed).++## Discovered Root Cause++`MCPToolHandler.handle` called `handleInitialize` with only the JSON-RPC id. `handleInitialize` therefore had no access to `request.params`, performed no shape or field validation, and always returned the same hard-coded protocol version.++**Defect type:** Missing input validation and protocol negotiation.++**Why it occurred:** The original initialize implementation was added as a static success response. Its regression test also omitted parameters and encoded the invalid behavior as expected success, so later validation work did not cover the handshake boundary.++**Contributing factors:** Initialize request parameters have no dedicated decoded type and arrive through the permissive `AnyCodable` envelope.++## Resolution for the Issue++**Changes made:**+- `Transit/Transit/MCP/MCPToolHandler.swift` - Pass initialize params into the method handler; require an object envelope; validate `protocolVersion`, `capabilities`, and `clientInfo` plus `clientInfo.name`, `clientInfo.version`, and optional `clientInfo.title`; return JSON-RPC `-32602` for malformed method params; and negotiate against the server's supported-version set.+- `Transit/Transit/MCP/MCPServer.swift` - Enforce JSON-RPC's object/array requirement for top-level `params`, returning `-32600 Invalid Request` for scalar or null values before method dispatch.+- `Transit/TransitTests/MCPToolHandlerTests.swift` - Replace the incorrect parameterless success request with a valid supported-version handshake.+- `Transit/TransitTests/MCPInitializeHandshakeTests.swift` - Add focused malformed-handshake and unsupported-version regression coverage.++**Approach rationale:** Method-specific validation stays in `MCPToolHandler`, where all other MCP parameter validation occurs. Small typed-throwing validators keep field-specific errors readable while preserving a single JSON-RPC error mapping. An explicit supported-version set and latest-version constant encode the MCP negotiation rule without changing Transit's advertised version.++**Alternatives considered:**+- Add a dedicated `Decodable` initialize request type at the HTTP decoding layer - rejected because malformed method params are JSON-RPC `Invalid Params`, not malformed request envelopes, and existing method dispatch intentionally carries `params` through `AnyCodable`.+- Return an unsupported-version error - rejected because MCP 2025-03-26 requires the server to respond with another supported version, preferably its latest, when the requested version is unsupported.++## Regression Test++**Test files:** `Transit/TransitTests/MCPInitializeHandshakeTests.swift`, `Transit/TransitTests/MCPToolHandlerTests.swift`+**Test names:** `initializeWithoutParamsReturnsInvalidParams`, `initializeWithNonObjectParamsReturnsInvalidParams`, `initializeWithScalarParamsReturnsInvalidRequest`, `initializeRequiresEveryHandshakeField`, `initializeRejectsWrongRequiredFieldTypes`, `initializeRejectsMalformedClientInfo`, `initializeReturnsServerInfoForSupportedProtocolVersion`, `initializeFallsBackToSupportedProtocolVersion`++**What it verifies:** Required handshake fields and their wire types are enforced; object/array method-parameter mismatches return `-32602`; scalar/null JSON-RPC `params` return `-32600`; valid supported versions are echoed; and unsupported versions fall back to the server's advertised supported version.++**Run command:** `make test-quick`++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/MCP/MCPToolHandler.swift` | Validate initialize parameters and negotiate protocol versions |+| `Transit/Transit/MCP/MCPServer.swift` | Reject scalar/null top-level params as invalid JSON-RPC requests |+| `Transit/TransitTests/MCPToolHandlerTests.swift` | Send a valid handshake from the success test |+| `Transit/TransitTests/MCPInitializeHandshakeTests.swift` | Focused initialize validation and negotiation coverage |+| `docs/agent-notes/mcp-server.md` | Document the enforced handshake contract |+| `CHANGELOG.md` | Record the user-visible protocol fix |+| `specs/bugfixes/mcp-initialize-accepts-malformed-handshake-requests/report.md` | Investigation and resolution record |++## Verification++**Automated:**+- [x] `make test-quick` passes: final `.xcresult` reports 1,595 tests passed, 0 failed; all eight initialize handshake regression tests passed.+- [x] `make lint` passes.+- [ ] `make test` completed, but direct `.xcresult` inspection reports 1,140 tests passed and 6 unrelated UI tests failed: `testClearAll`, `testEditViewPreservesTaskMilestone`, `testSettingsHasBackChevron`, `testSettingsWithNoProjectsShowsCreatePrompt`, `testTappingGearPushesSettingsView`, and `testDataMaintenanceGoldenPath`. The Makefile pipeline returned exit 0 despite the failed result bundle, so the bundle—not the shell status—is authoritative. Another worktree began using the same simulator during this retry; all six failures match the earlier contended run.++**Manual verification:**+- [x] Compared response behavior with the MCP 2025-03-26 lifecycle initialization and version-negotiation requirements.++## Prevention++**Recommendations to avoid similar bugs:**+- Pass method parameters into every method-specific handler and validate required shapes at the boundary.+- Keep at least one negative protocol test beside each successful handshake test.++## Related++- Transit T-1778+- MCP 2025-03-26 lifecycle: https://modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle/
make test and make test-ui on the head both produced exactly six UI failures. An independent clean origin/main make test-ui run produced the identical six selectors. A base make test run timed out under run_silent after its pre-existing UI execution, but introduced no selector outside that set. No UI source or UI-test file is in the review diff.