Fresh independent review of PR #245 at dcccc3e, covering the complete origin/main...HEAD diff after the session-correlation, SSE disconnect-cleanup, and strict Accept-negotiation fixes.
Mcp-Session-Id and advertises tools.listChanged: true.q=0 while retaining the existing 405 contract.Ready to push
The change now advertises tool-list invalidation, correlates concurrent SSE streams by initialized client session, removes idle registrations from channel-close signals, and negotiates only an exact positive-quality text/event-stream media range. No blocking correctness, security, reuse, quality, efficiency, specification, or documentation issue was found. make test-quick, make lint, and git diff --check origin/main...HEAD all completed successfully in this review.
dcccc3e T-2169: Harden MCP notification streams 4d1e8e9 T-2169: Align notification regression report 462b28f T-2169: Strengthen MCP notification coverage d037e5f T-2169: Notify MCP clients when tools change 742dc23 T-2169: Add MCP tool-list refresh regression Transit can expose two optional maintenance tools. MCP clients often cache the tool list, so changing the setting previously did not update already-connected clients. Transit now tells those clients that the list changed. Each initialized client receives a session ID and can open a listening connection for notifications.
The setting takes effect without restarting Transit or reconnecting the agent. A client receives a small invalidation message and can request the current full tool list.
The reported stale-cache behavior, multi-stream duplicate risk, idle disconnect cleanup, and malformed Accept-header boundaries are implemented and covered. No required behavior is partially implemented or missing.
MCPSettings owns a MainActor-isolated broadcaster. A successful standalone initialize creates a UUID session, and GET /mcp registers an AsyncStream under that session after origin, Accept, and session validation. A real setting transition groups registrations by session and yields the typed notifications/tools/list_changed envelope to one stream in each group.
The stream uses .bufferingNewest(1) because invalidations are idempotent. A custom Hummingbird request context retains the NIO channel's closeFuture, allowing cleanup while the response task is idle. The parser handles comma/semicolon splitting outside quoted strings and validates RFC 9110 qvalues rather than relying on substring matching.
Production wiring, teardown, route compatibility, protocol types, documentation, and client-perspective regressions are all present. Session expiration and resumable SSE event IDs remain optional follow-up hardening, not gaps in the ticket.
The transport establishes state only after an error-free, non-batched initialize result. sessionIDs gates GET registration; registrations maps stream UUIDs to session IDs and continuations. MainActor serialization makes setting transitions and registry mutation race-free at the data-structure level, while channel.closeFuture bridges NIO lifecycle completion back to MainActor and finishes the associated continuation. Server teardown invalidates all session IDs before graceful listener shutdown, causing response-body loops to finish.
Negotiation parses list members without splitting quoted delimiters, compares the normalized media type exactly, permits unrelated well-formed parameters, rejects duplicate or malformed q parameters, and admits only qvalues greater than zero. SSE payloads are deterministic compact JSON-RPC notifications with no response ID.
The implementation correctly avoids broadcasting one JSON-RPC message across a session's simultaneous streams. UUID sessions persist until listener teardown, and there is a narrow scheduling interval where a just-closed registration may remain selectable before its MainActor cleanup task runs; both are documented below as low-impact follow-ups for a loopback-only personal server. Neither changes the conclusion that the branch meets the ticket and protocol behavior under review.
Fully implemented: capability advertisement, session correlation, one-message-per-session delivery, live toggle invalidation, idle channel cleanup, server teardown, strict GET negotiation, and route/security regression coverage. Partially implemented: none required. Missing: none required.
Transit/Transit/MCP/MCPServer.swift
Why it matters. Correlates multiple listening streams belonging to one MCP client and exposes the session ID only after successful standalone initialization.
What to look at. MCPServer.makeRouter initialize response path
Transit/Transit/MCP/MCPToolListChangeBroadcaster.swift
Why it matters. Prevents duplicate notifications for one client while preserving independent delivery to other initialized clients.
What to look at. MCPToolListChangeBroadcaster.stream and notifyToolsListChanged
Transit/Transit/MCP/MCPRequestContext.swift
Why it matters. Allows an idle SSE registration to terminate promptly when its peer disconnects instead of waiting for a future write.
What to look at. MCPRequestContext and channel.closeFuture registration
Transit/Transit/MCP/MCPServer+ToolListNotifications.swift
Why it matters. Avoids accepting lookalike media types or representations explicitly disabled with q=0.
What to look at. acceptsEventStream, splitHTTPValue, and qualityValue
Transit/TransitTests/MCPToolListChangeNotificationTests.swift
Why it matters. Tests the externally observable handshake-to-notification path and the lifecycle behavior that motivated the artifact fixes.
What to look at. MCPToolListChangeNotificationTests and MCPServerRouteTests
A UUID returned in Mcp-Session-Id identifies all listening streams for one initialized client, allowing exactly one notification per logical client.
The custom request context retains the channel and registers its close future so idle stream cleanup does not depend on another toggle or write attempt.
notifications/tools/list_changed carries no delta. Coalescing pending notifications is safe because clients re-fetch the complete current list.
GET without an acceptable event-stream media range retains the existing HTTP 405 plus Allow: POST compatibility contract.
Exact media types, quoted delimiters, duplicate/malformed q parameters, and RFC qvalue bounds are handled explicitly and fail closed.
Click to expand.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex d74cfde..eca2d7e 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +- T-2169: MCP initialization now advertises `tools.listChanged` and returns an `Mcp-Session-Id`; negotiated Streamable HTTP GET/SSE channels receive one `notifications/tools/list_changed` per client session whenever **Expose maintenance tools** changes, even if a session has multiple listening streams. Closed channels promptly unregister their streams, fresh `tools/list` behavior remains live, and GET requires an exact acceptable `text/event-stream` media range while preserving the prior 405 response when negotiation fails. Client-perspective regressions cover session isolation, duplicate-delivery prevention, disconnect cleanup, and `Accept` quality/media-type boundaries.+ - Restored the original width-adaptive dashboard layout: iPhone portrait keeps the multi-column Kanban board whenever at least two 200-point columns fit, and uses segmented single-column navigation only below that boundary. This supersedes T-1802 / PR #205 and corrects requirement 13.1. - Dashboard UI tests now dismiss the iOS search presentation, navigate toolbar overflow actions, match combined detail-row accessibility labels, and disambiguate nested alert buttons so the full iPhone 17 suite covers the restored multi-column portrait layout reliably.
diff --git a/Transit/Transit/MCP/MCPRequestContext.swift b/Transit/Transit/MCP/MCPRequestContext.swiftnew file mode 100644index 0000000..b9b183d--- /dev/null+++ b/Transit/Transit/MCP/MCPRequestContext.swift@@ -0,0 +1,19 @@+#if os(macOS)+import Hummingbird+import NIOCore++/// Hummingbird request context retaining the transport channel for the request.+/// BasicRequestContext intentionally discards it after initialization, while+/// long-lived SSE responses need `closeFuture` to end idle registrations when+/// the peer disconnects.+nonisolated struct MCPRequestContext: RequestContext {+ var coreContext: CoreRequestContextStorage+ let channel: any Channel++ init(source: ApplicationRequestContextSource) {+ self.coreContext = .init(source: source)+ self.channel = source.channel+ }+}++#endif
diff --git a/Transit/Transit/MCP/MCPServer+Responses.swift b/Transit/Transit/MCP/MCPServer+Responses.swiftnew file mode 100644index 0000000..1789754--- /dev/null+++ b/Transit/Transit/MCP/MCPServer+Responses.swift@@ -0,0 +1,33 @@+#if os(macOS)+import Foundation+import HTTPTypes+import Hummingbird+import NIOCore+import NIOFoundationCompat++extension MCPServer {+ nonisolated static func jsonResponse(+ _ payload: some Encodable & Sendable,+ additionalHeaders: HTTPFields = [:]+ ) -> Response {+ let data: Data+ do {+ data = try JSONEncoder().encode(payload)+ } catch {+ let fallback = """+ {"jsonrpc":"2.0","id":null,\+ "error":{"code":-32603,"message":"Encoding failed"}}+ """+ data = Data(fallback.utf8)+ }+ var headers = additionalHeaders+ headers[.contentType] = "application/json"+ return Response(+ status: .ok,+ headers: headers,+ body: .init(byteBuffer: ByteBuffer(data: data))+ )+ }+}++#endif
diff --git a/Transit/Transit/MCP/MCPServer+ToolListNotifications.swift b/Transit/Transit/MCP/MCPServer+ToolListNotifications.swiftnew file mode 100644index 0000000..be31c30--- /dev/null+++ b/Transit/Transit/MCP/MCPServer+ToolListNotifications.swift@@ -0,0 +1,165 @@+#if os(macOS)+import Foundation+import HTTPTypes+import Hummingbird+import NIOCore+import NIOFoundationCompat++extension MCPServer {+ nonisolated static func toolListChangeStreamResponse(+ request: Request,+ context: MCPRequestContext,+ handler: MCPToolHandler+ ) async -> Response {+ guard acceptsEventStream(request.head.headerFields[.accept]) else {+ return Response(status: .methodNotAllowed, headers: [.allow: "POST"])+ }+ guard let sessionID = request.head.headerFields[.mcpSessionID] else {+ return Response(status: .badRequest)+ }+ guard let notifications = await handler.toolListChangeNotifications(+ sessionID: sessionID,+ channelClose: context.channel.closeFuture+ ) else {+ return Response(status: .notFound)+ }++ return Response(+ status: .ok,+ headers: [+ .contentType: "text/event-stream",+ .cacheControl: "no-cache"+ ],+ body: ResponseBody { writer in+ for await notification in notifications {+ try await writer.write(try Self.serverSentEvent(notification))+ }+ try await writer.finish(nil)+ }+ )+ }++ /// GET listening streams require an explicit, acceptable+ /// `text/event-stream` media range. A zero quality value means the client+ /// does not accept that representation; substring lookalikes are unrelated+ /// media types and must not negotiate SSE.+ nonisolated static func acceptsEventStream(_ accept: String?) -> Bool {+ guard let accept,+ let mediaRanges = splitHTTPValue(accept[...], separator: ",") else {+ return false+ }++ for mediaRange in mediaRanges {+ guard let components = splitHTTPValue(mediaRange, separator: ";"),+ components.first?.trimmingCharacters(in: .whitespacesAndNewlines)+ .lowercased() == "text/event-stream" else {+ continue+ }++ var quality = 1.0+ var sawQuality = false+ var parametersAreValid = true+ for parameter in components.dropFirst() {+ let pair = parameter.split(+ separator: "=",+ maxSplits: 1,+ omittingEmptySubsequences: false+ )+ guard pair.count == 2 else {+ parametersAreValid = false+ break+ }+ guard pair[0].trimmingCharacters(in: .whitespacesAndNewlines)+ .lowercased() == "q" else {+ continue+ }+ guard !sawQuality,+ let parsed = qualityValue(+ pair[1].trimmingCharacters(in: .whitespacesAndNewlines)+ ) else {+ parametersAreValid = false+ break+ }+ sawQuality = true+ quality = parsed+ }+ if parametersAreValid && quality > 0 { return true }+ }+ return false+ }++ /// Splits an HTTP list while preserving separators inside quoted strings.+ /// A malformed quoted string invalidates the field instead of accidentally+ /// turning a parameter fragment into an acceptable media range.+ nonisolated private static func splitHTTPValue(+ _ value: Substring,+ separator: Character+ ) -> [Substring]? {+ var pieces: [Substring] = []+ var start = value.startIndex+ var index = start+ var isQuoted = false+ var isEscaped = false++ while index < value.endIndex {+ let character = value[index]+ if isEscaped {+ isEscaped = false+ } else if isQuoted && character == "\\" {+ isEscaped = true+ } else if character == "\"" {+ isQuoted.toggle()+ } else if character == separator && !isQuoted {+ pieces.append(value[start..<index])+ start = value.index(after: index)+ }+ index = value.index(after: index)+ }++ guard !isQuoted, !isEscaped else { return nil }+ pieces.append(value[start...])+ return pieces+ }++ /// RFC 9110 qvalue: 0 or 1 with at most three fractional digits; only zero+ /// digits may follow 1. Rejecting extensions such as `.5` and `1e-1` keeps+ /// malformed quality values from silently enabling a listening stream.+ nonisolated private static func qualityValue(_ rawValue: String) -> Double? {+ if rawValue == "0" { return 0 }+ if rawValue == "1" { return 1 }+ guard rawValue.count >= 2,+ rawValue.count <= 5,+ rawValue[rawValue.index(after: rawValue.startIndex)] == "." else {+ return nil+ }++ let integer = rawValue.first+ let fraction = rawValue.dropFirst(2)+ guard fraction.allSatisfy(\.isNumber) else { return nil }+ if integer == "1", fraction.allSatisfy({ $0 == "0" }) {+ return 1+ }+ if integer == "0" {+ return Double(rawValue)+ }+ return nil+ }++ nonisolated private static func serverSentEvent(+ _ notification: MCPServerNotification+ ) throws -> ByteBuffer {+ let encoder = JSONEncoder()+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]+ let payload = try encoder.encode(notification)+ var event = Data("data: ".utf8)+ event.append(payload)+ event.append(contentsOf: [0x0A, 0x0A])+ return ByteBuffer(data: event)+ }+}++extension HTTPField.Name {+ nonisolated static let mcpSessionID = HTTPField.Name("Mcp-Session-Id")!+}++#endif
diff --git a/Transit/Transit/MCP/MCPServer.swift b/Transit/Transit/MCP/MCPServer.swiftindex aa5a890..623dbfd 100644--- a/Transit/Transit/MCP/MCPServer.swift+++ b/Transit/Transit/MCP/MCPServer.swift@@ -141,6 +141,7 @@ extension MCPServer { // as a start failure for a server the caller just asked to stop. serverGeneration += 1 activeServer = nil+ toolHandler.finishToolListChangeSessions() await currentServer.serviceGroup.triggerGracefulShutdown() await currentServer.task.value }@@ -194,14 +195,19 @@ extension MCPServer { extension MCPServer { /// Builds the Hummingbird router for the MCP endpoint.- /// POST carries JSON-RPC; GET is explicitly 405 because no SSE stream is offered.+ /// POST carries JSON-RPC; GET offers the optional Streamable HTTP SSE+ /// channel used for server-initiated notifications. /// `nonisolated` keeps transport construction independent of MainActor; /// route callbacks execute on Hummingbird/NIO and hop to MainActor when dispatching.- nonisolated static func makeRouter(handler: MCPToolHandler) -> Router<BasicRequestContext> {- let router = Router()- router.get("mcp") { request, _ -> Response in+ nonisolated static func makeRouter(handler: MCPToolHandler) -> Router<MCPRequestContext> {+ let router = Router(context: MCPRequestContext.self)+ router.get("mcp") { request, context -> Response in guard Self.isAllowedMCPRequest(request) else { return forbiddenResponse() }- return Response(status: .methodNotAllowed, headers: [.allow: "POST"])+ return await Self.toolListChangeStreamResponse(+ request: request,+ context: context,+ handler: handler+ ) } router.post("mcp") { request, _ -> Response in // Validate origin before reading the body.@@ -216,6 +222,13 @@ extension MCPServer { guard let rpcResponse = await handler.handle(rpcRequest) else { return Response(status: .accepted) }+ if rpcRequest.method == "initialize", rpcResponse.error == nil {+ let sessionID = await handler.createToolListChangeSession()+ return jsonResponse(+ rpcResponse,+ additionalHeaders: [.mcpSessionID: sessionID]+ )+ } return jsonResponse(rpcResponse) case .batch(let elements):@@ -376,25 +389,5 @@ extension MCPServer { ) } - nonisolated private static func jsonResponse(- _ payload: some Encodable & Sendable- ) -> Response {- let data: Data- do {- data = try JSONEncoder().encode(payload)- } catch {- let fallback = """- {"jsonrpc":"2.0","id":null,\- "error":{"code":-32603,"message":"Encoding failed"}}- """- data = Data(fallback.utf8)- }- return Response(- status: .ok,- headers: [.contentType: "application/json"],- body: .init(byteBuffer: ByteBuffer(data: data))- )- } }- #endif
diff --git a/Transit/Transit/MCP/MCPSettings.swift b/Transit/Transit/MCP/MCPSettings.swiftindex 703eb43..0e4a6c2 100644--- a/Transit/Transit/MCP/MCPSettings.swift+++ b/Transit/Transit/MCP/MCPSettings.swift@@ -1,5 +1,6 @@ #if os(macOS) import Foundation+import NIOCore @Observable final class MCPSettings {@@ -7,6 +8,7 @@ final class MCPSettings { private static let enabledKey = "mcpServerEnabled" private static let portKey = "mcpServerPort" private static let maintenanceToolsKey = "mcpMaintenanceToolsEnabled"+ private let toolListChangeBroadcaster = MCPToolListChangeBroadcaster() static let defaultPort = 3141 /// Valid TCP port range. Port 0 means "any available port" to the OS and is@@ -31,7 +33,33 @@ final class MCPSettings { } var maintenanceToolsEnabled: Bool {- didSet { UserDefaults.standard.set(maintenanceToolsEnabled, forKey: Self.maintenanceToolsKey) }+ didSet {+ UserDefaults.standard.set(maintenanceToolsEnabled, forKey: Self.maintenanceToolsKey)+ guard maintenanceToolsEnabled != oldValue else { return }+ toolListChangeBroadcaster.notifyToolsListChanged()+ }+ }++ func createToolListChangeSession() -> String {+ toolListChangeBroadcaster.createSession()+ }++ func toolListChangeNotifications(+ sessionID: String,+ channelClose: EventLoopFuture<Void>+ ) -> AsyncStream<MCPServerNotification>? {+ toolListChangeBroadcaster.stream(+ sessionID: sessionID,+ channelClose: channelClose+ )+ }++ func finishToolListChangeSessions() {+ toolListChangeBroadcaster.finishAllSessions()+ }++ var activeToolListChangeStreamCount: Int {+ toolListChangeBroadcaster.activeStreamCount } init() {
diff --git a/Transit/Transit/MCP/MCPToolHandler.swift b/Transit/Transit/MCP/MCPToolHandler.swiftindex c183bfd..3980f6a 100644--- a/Transit/Transit/MCP/MCPToolHandler.swift+++ b/Transit/Transit/MCP/MCPToolHandler.swift@@ -1,5 +1,6 @@ #if os(macOS) import Foundation+import NIOCore import SwiftData // swiftlint:disable file_length@@ -67,6 +68,28 @@ final class MCPToolHandler { // MARK: - JSON-RPC Dispatch + func createToolListChangeSession() -> String {+ settings.createToolListChangeSession()+ }++ func toolListChangeNotifications(+ sessionID: String,+ channelClose: EventLoopFuture<Void>+ ) -> AsyncStream<MCPServerNotification>? {+ settings.toolListChangeNotifications(+ sessionID: sessionID,+ channelClose: channelClose+ )+ }++ func finishToolListChangeSessions() {+ settings.finishToolListChangeSessions()+ }++ var activeToolListChangeStreamCount: Int {+ settings.activeToolListChangeStreamCount+ }+ /// Returns `nil` for JSON-RPC notifications (no response required). func handle(_ request: JSONRPCRequest) async -> JSONRPCResponse? { // MCP 2025-03-26 requires a present request id to be a string or@@ -145,7 +168,7 @@ final class MCPToolHandler { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" let result = MCPInitializeResult( protocolVersion: protocolVersion,- capabilities: MCPServerCapabilities(tools: MCPToolsCapability()),+ capabilities: MCPServerCapabilities(tools: MCPToolsCapability(listChanged: true)), serverInfo: MCPServerInfo(name: "transit", version: version) ) return JSONRPCResponse.success(id: id, result: result)
diff --git a/Transit/Transit/MCP/MCPToolListChangeBroadcaster.swift b/Transit/Transit/MCP/MCPToolListChangeBroadcaster.swiftnew file mode 100644index 0000000..5f40c6b--- /dev/null+++ b/Transit/Transit/MCP/MCPToolListChangeBroadcaster.swift@@ -0,0 +1,82 @@+#if os(macOS)+import Foundation+import NIOCore++/// Publishes server-wide MCP tool-list invalidations once per active session.+/// A session can own multiple Streamable HTTP listening streams, but MCP+/// requires each JSON-RPC message to be sent on only one of them.+@MainActor+final class MCPToolListChangeBroadcaster {+ private struct StreamRegistration {+ let sessionID: String+ let continuation: AsyncStream<MCPServerNotification>.Continuation+ }++ private var sessionIDs: Set<String> = []+ private var registrations: [UUID: StreamRegistration] = [:]++ var activeStreamCount: Int { registrations.count }++ func createSession() -> String {+ let sessionID = UUID().uuidString+ sessionIDs.insert(sessionID)+ return sessionID+ }++ func stream(+ sessionID: String,+ channelClose: EventLoopFuture<Void>+ ) -> AsyncStream<MCPServerNotification>? {+ guard sessionIDs.contains(sessionID) else { return nil }++ let streamID = UUID()+ let (stream, continuation) = AsyncStream.makeStream(+ of: MCPServerNotification.self,+ bufferingPolicy: .bufferingNewest(1)+ )+ registrations[streamID] = StreamRegistration(+ sessionID: sessionID,+ continuation: continuation+ )+ continuation.onTermination = { @Sendable [weak self] _ in+ Task { @MainActor in+ self?.removeStream(streamID)+ }+ }+ channelClose.whenComplete { @Sendable [weak self] _ in+ Task { @MainActor in+ self?.finishStream(streamID)+ }+ }+ return stream+ }++ func notifyToolsListChanged() {+ let streamsBySession = Dictionary(grouping: registrations) { $0.value.sessionID }+ for streams in streamsBySession.values {+ // Dictionary order is intentionally not part of the contract: MCP+ // permits any one of a session's concurrently connected streams.+ streams.first?.value.continuation.yield(.toolsListChanged)+ }+ }++ func finishAllSessions() {+ sessionIDs.removeAll()+ let continuations = registrations.values.map(\.continuation)+ registrations.removeAll()+ for continuation in continuations {+ continuation.finish()+ }+ }++ private func finishStream(_ streamID: UUID) {+ guard let registration = registrations.removeValue(forKey: streamID) else { return }+ registration.continuation.finish()+ }++ private func removeStream(_ streamID: UUID) {+ registrations.removeValue(forKey: streamID)+ }+}++#endif
diff --git a/Transit/Transit/MCP/MCPTypes.swift b/Transit/Transit/MCP/MCPTypes.swiftindex 5e89769..962b268 100644--- a/Transit/Transit/MCP/MCPTypes.swift+++ b/Transit/Transit/MCP/MCPTypes.swift@@ -157,7 +157,16 @@ nonisolated struct MCPServerCapabilities: Encodable, Sendable { } nonisolated struct MCPToolsCapability: Encodable, Sendable {- // Empty object signals tool support+ let listChanged: Bool+}++nonisolated struct MCPServerNotification: Encodable, Sendable {+ let jsonrpc: String = "2.0"+ let method: String++ static let toolsListChanged = MCPServerNotification(+ method: "notifications/tools/list_changed"+ ) } nonisolated struct MCPServerInfo: Encodable, Sendable {
diff --git a/Transit/TransitTests/MCPHTTPTestHelpers.swift b/Transit/TransitTests/MCPHTTPTestHelpers.swiftindex 320230a..0d71292 100644--- a/Transit/TransitTests/MCPHTTPTestHelpers.swift+++ b/Transit/TransitTests/MCPHTTPTestHelpers.swift@@ -19,6 +19,8 @@ extension MCPTestHelpers { origin: String? = nil, authority: String = "127.0.0.1:3141", contentType: String? = nil,+ accept: String? = nil,+ sessionID: String? = nil, body: String = "", loggerLabel: String ) async throws -> MCPHTTPTestResponse {@@ -30,6 +32,12 @@ extension MCPTestHelpers { if let contentType { headers[.contentType] = contentType }+ if let accept {+ headers[.accept] = accept+ }+ if let sessionID {+ headers[.mcpSessionID] = sessionID+ } let request = Request( head: HTTPRequest( method: method,@@ -43,7 +51,7 @@ extension MCPTestHelpers { let channel = EmbeddedChannel() defer { _ = try? channel.finish() }- let context = BasicRequestContext(+ let context = MCPRequestContext( source: ApplicationRequestContextSource( channel: channel, logger: Logger(label: loggerLabel)@@ -56,8 +64,7 @@ extension MCPTestHelpers { let data = Data(buffer: writer.collated.withLockedValue { $0 }) return MCPHTTPTestResponse( status: response.status,- contentType: response.headers[.contentType],- allow: response.headers[.allow],+ headers: response.headers, body: data ) }@@ -65,10 +72,13 @@ extension MCPTestHelpers { nonisolated struct MCPHTTPTestResponse { let status: HTTPResponse.Status- let contentType: String?- let allow: String?+ let headers: HTTPFields let body: Data + var contentType: String? { headers[.contentType] }+ var allow: String? { headers[.allow] }+ var sessionID: String? { headers[.mcpSessionID] }+ var json: Any? { guard !body.isEmpty else { return nil } return try? JSONSerialization.jsonObject(with: body)@@ -79,7 +89,7 @@ private nonisolated final class MCPHTTPTestResponseWriter: ResponseBodyWriter { let collated = NIOLockedValueBox(ByteBuffer()) func write(_ buffer: ByteBuffer) async throws {- collated.withLockedValue { $0.writeImmutableBuffer(buffer) }+ _ = collated.withLockedValue { $0.writeImmutableBuffer(buffer) } } func finish(_: HTTPFields?) async throws {}
diff --git a/Transit/TransitTests/MCPServerOriginValidationTests.swift b/Transit/TransitTests/MCPServerOriginValidationTests.swiftindex 595f6e3..07d8ba8 100644--- a/Transit/TransitTests/MCPServerOriginValidationTests.swift+++ b/Transit/TransitTests/MCPServerOriginValidationTests.swift@@ -265,7 +265,7 @@ struct MCPServerOriginValidationTests { } private func dispatch(- responder: some HTTPResponder<BasicRequestContext>,+ responder: some HTTPResponder<MCPRequestContext>, head: HTTPRequest, body: String ) async throws -> HTTPResponse.Status {@@ -273,7 +273,7 @@ struct MCPServerOriginValidationTests { let channel = EmbeddedChannel() defer { _ = try? channel.finish() }- let context = BasicRequestContext(+ let context = MCPRequestContext( source: ApplicationRequestContextSource( channel: channel, logger: Logger(label: "mcp-origin-tests") )
diff --git a/Transit/TransitTests/MCPServerRouteTests.swift b/Transit/TransitTests/MCPServerRouteTests.swiftindex 81ac738..959948d 100644--- a/Transit/TransitTests/MCPServerRouteTests.swift+++ b/Transit/TransitTests/MCPServerRouteTests.swift@@ -4,16 +4,16 @@ import HTTPTypes import Testing @testable import Transit -/// Regression tests for T-1835: the advertised MCP Streamable HTTP endpoint-/// must distinguish an unsupported method on `/mcp` from an unknown route.+/// Regression tests for the MCP Streamable HTTP endpoint routes. ///-/// MCP 2025-03-26 requires GET on the MCP endpoint to return 405 when the-/// server does not offer an SSE listening stream. Hummingbird's unmatched-/// route fallback previously returned 404 because only POST was registered.+/// MCP 2025-03-26 permits GET to open an SSE listening stream when the client+/// accepts `text/event-stream`. A GET without that required negotiation remains+/// unsupported, while POST dispatch, origin validation, and unrelated routes+/// keep their existing behavior. @MainActor @Suite(.serialized) struct MCPServerRouteTests { - @Test func getMcpReturnsMethodNotAllowedWithPostAllowHeader() async throws {+ @Test func getMcpWithoutEventStreamAcceptReturnsMethodNotAllowed() async throws { let env = try MCPTestHelpers.makeEnv() let response = try await respond( handler: env.handler,@@ -25,6 +25,75 @@ struct MCPServerRouteTests { #expect(response.allow == "POST") } + @Test func eventStreamAcceptParsingRequiresExactPositiveQualityMediaRange() {+ #expect(MCPServer.acceptsEventStream("text/event-stream"))+ #expect(MCPServer.acceptsEventStream(+ "application/json, TEXT/EVENT-STREAM; charset=utf-8; q=0.5"+ ))+ #expect(MCPServer.acceptsEventStream(+ "text/event-stream;q=0, text/event-stream;q=0.001"+ ))++ #expect(!MCPServer.acceptsEventStream(nil))+ #expect(!MCPServer.acceptsEventStream("application/x-text/event-stream"))+ #expect(!MCPServer.acceptsEventStream("text/event-streaming"))+ #expect(!MCPServer.acceptsEventStream("text/event-stream;q=0"))+ #expect(!MCPServer.acceptsEventStream("text/event-stream;q=1.1"))+ #expect(!MCPServer.acceptsEventStream("text/event-stream;q=.5"))+ #expect(!MCPServer.acceptsEventStream("text/event-stream;note=\"a,b\";q=0"))+ }++ @Test func getMcpRejectsEventStreamWithZeroQuality() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let response = try await respond(+ handler: env.handler,+ method: .get,+ path: "/mcp",+ accept: "application/json, text/event-stream;q=0"+ )++ #expect(response.status == .methodNotAllowed)+ #expect(response.allow == "POST")+ }++ @Test func getMcpRejectsEventStreamMediaTypeSuperstring() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let response = try await respond(+ handler: env.handler,+ method: .get,+ path: "/mcp",+ accept: "application/x-text/event-stream"+ )++ #expect(response.status == .methodNotAllowed)+ #expect(response.allow == "POST")+ }++ @Test func getMcpWithEventStreamAcceptRequiresSession() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let response = try await respond(+ handler: env.handler,+ method: .get,+ path: "/mcp",+ accept: "text/event-stream; charset=utf-8; q=0.5"+ )++ #expect(response.status == .badRequest)+ }++ @Test func getMcpRejectsUnknownSession() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let response = try await respond(+ handler: env.handler,+ method: .get,+ path: "/mcp",+ accept: "text/event-stream",+ sessionID: "unknown-session"+ )++ #expect(response.status == .notFound)+ }+ @Test func postMcpStillDispatchesNormally() async throws { let env = try MCPTestHelpers.makeEnv() let response = try await respond(@@ -103,6 +172,8 @@ struct MCPServerRouteTests { path: String, origin: String? = nil, host: String = "127.0.0.1:3141",+ accept: String? = nil,+ sessionID: String? = nil, body: String = "" ) async throws -> MCPHTTPTestResponse { try await MCPTestHelpers.respond(@@ -111,6 +182,8 @@ struct MCPServerRouteTests { path: path, origin: origin, authority: host,+ accept: accept,+ sessionID: sessionID, body: body, loggerLabel: "mcp-route-tests" )
diff --git a/Transit/TransitTests/MCPToolListChangeNotificationTests.swift b/Transit/TransitTests/MCPToolListChangeNotificationTests.swiftnew file mode 100644index 0000000..9e6e222--- /dev/null+++ b/Transit/TransitTests/MCPToolListChangeNotificationTests.swift@@ -0,0 +1,222 @@+#if os(macOS)+import Foundation+import HTTPTypes+import Hummingbird+import Logging+import NIOConcurrencyHelpers+import NIOCore+import NIOEmbedded+import Testing+@testable import Transit++/// Regression coverage for T-2169: clients initialized before the maintenance+/// setting changes must be told that their cached tool list is stale without+/// broadcasting one JSON-RPC message across a session's concurrent streams.+@MainActor @Suite(.serialized)+struct MCPToolListChangeNotificationTests {++ @Test func maintenanceToggleNotifiesEachSessionOnOnlyOneConnectedStream() async throws {+ let env = try MCPTestHelpers.makeEnv()+ env.mcpSettings.maintenanceToolsEnabled = false+ defer { env.mcpSettings.maintenanceToolsEnabled = false }++ let firstSessionID = try await initializeSession(handler: env.handler)+ let secondSessionID = try await initializeSession(handler: env.handler)+ #expect(firstSessionID != secondSessionID)+ let firstStream = try await notificationResponse(+ handler: env.handler,+ sessionID: firstSessionID+ )+ let firstConcurrentStream = try await notificationResponse(+ handler: env.handler,+ sessionID: firstSessionID+ )+ let secondStream = try await notificationResponse(+ handler: env.handler,+ sessionID: secondSessionID+ )++ let firstWriter = RecordingEventWriter()+ let firstConcurrentWriter = RecordingEventWriter()+ let secondWriter = RecordingEventWriter()+ let bodyTasks = [+ bodyTask(response: firstStream.response, writer: firstWriter),+ bodyTask(response: firstConcurrentStream.response, writer: firstConcurrentWriter),+ bodyTask(response: secondStream.response, writer: secondWriter)+ ]++ env.mcpSettings.maintenanceToolsEnabled = true+ try #require(await waitForEventCount(2, writers: [+ firstWriter, firstConcurrentWriter, secondWriter+ ]))++ env.handler.finishToolListChangeSessions()+ for task in bodyTasks {+ try await task.value+ }+ try await firstStream.channel.close()+ try await firstConcurrentStream.channel.close()+ try await secondStream.channel.close()++ let expected = "data: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}\n\n"+ let firstSessionEvents = firstWriter.events.withLockedValue(\.count)+ + firstConcurrentWriter.events.withLockedValue(\.count)+ #expect(firstSessionEvents == 1)+ #expect(secondWriter.events.withLockedValue { $0 } == [expected])+ #expect(+ firstWriter.events.withLockedValue { $0 } == [expected]+ || firstConcurrentWriter.events.withLockedValue { $0 } == [expected]+ )+ #expect(env.handler.activeToolListChangeStreamCount == 0)+ }++ @Test func assigningExistingMaintenanceValueDoesNotNotify() async throws {+ let env = try MCPTestHelpers.makeEnv()+ env.mcpSettings.maintenanceToolsEnabled = false+ let sessionID = env.handler.createToolListChangeSession()+ let channel = EmbeddedChannel()+ let notifications = try #require(env.handler.toolListChangeNotifications(+ sessionID: sessionID,+ channelClose: channel.closeFuture+ ))++ env.mcpSettings.maintenanceToolsEnabled = false++ let notification = await firstNotification(in: notifications, timeout: .milliseconds(50))+ #expect(notification?.method == nil)+ try await channel.close()+ }++ @Test func closingIdleChannelImmediatelyUnregistersStream() async throws {+ let env = try MCPTestHelpers.makeEnv()+ let sessionID = env.handler.createToolListChangeSession()+ let channel = EmbeddedChannel()+ _ = try #require(env.handler.toolListChangeNotifications(+ sessionID: sessionID,+ channelClose: channel.closeFuture+ ))+ #expect(env.handler.activeToolListChangeStreamCount == 1)++ try await channel.close()++ #expect(await waitForStreamCount(0, handler: env.handler))+ }++ private func initializeSession(handler: MCPToolHandler) async throws -> String {+ let body = """+ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{+ "protocolVersion":"2025-03-26","capabilities":{},+ "clientInfo":{"name":"Transit Tests","version":"1.0"}}}+ """+ let response = try await MCPTestHelpers.respond(+ handler: handler,+ contentType: "application/json",+ accept: "application/json, text/event-stream",+ body: body,+ loggerLabel: "mcp-tool-list-change-initialize"+ )+ #expect(response.status == .ok)+ let result = try #require(response.json as? [String: Any])+ let resultObject = try #require(result["result"] as? [String: Any])+ let capabilities = try #require(resultObject["capabilities"] as? [String: Any])+ let tools = try #require(capabilities["tools"] as? [String: Any])+ #expect(tools["listChanged"] as? Bool == true)+ return try #require(response.sessionID)+ }++ private func bodyTask(+ response: Response,+ writer: RecordingEventWriter+ ) -> Task<Void, any Error> {+ Task {+ try await response.body.write(writer)+ }+ }++ private func firstNotification(+ in notifications: AsyncStream<MCPServerNotification>,+ timeout: Duration+ ) async -> MCPServerNotification? {+ await withTaskGroup(of: MCPServerNotification?.self) { group in+ group.addTask {+ await notifications.first { _ in true }+ }+ group.addTask {+ try? await Task.sleep(for: timeout)+ return nil+ }+ let first = await group.next() ?? nil+ group.cancelAll()+ return first+ }+ }++ private func notificationResponse(+ handler: MCPToolHandler,+ sessionID: String+ ) async throws -> (response: Response, channel: EmbeddedChannel) {+ let responder = MCPServer.makeRouter(handler: handler).buildResponder()+ var headers = HTTPFields()+ headers[.accept] = "text/event-stream"+ headers[.mcpSessionID] = sessionID+ let request = Request(+ head: HTTPRequest(+ method: .get,+ scheme: "http",+ authority: "127.0.0.1:3141",+ path: "/mcp",+ headerFields: headers+ ),+ body: RequestBody(buffer: ByteBuffer())+ )+ let channel = EmbeddedChannel()+ let context = MCPRequestContext(+ source: ApplicationRequestContextSource(+ channel: channel,+ logger: Logger(label: "mcp-tool-list-change-tests")+ )+ )+ return (try await responder.respond(to: request, context: context), channel)+ }++ private func waitForEventCount(+ _ expected: Int,+ writers: [RecordingEventWriter],+ timeout: Duration = .seconds(1)+ ) async -> Bool {+ let deadline = ContinuousClock.now + timeout+ while ContinuousClock.now < deadline {+ let count = writers.reduce(0) { result, writer in+ result + writer.events.withLockedValue(\.count)+ }+ if count == expected { return true }+ try? await Task.sleep(for: .milliseconds(10))+ }+ return false+ }++ private func waitForStreamCount(+ _ expected: Int,+ handler: MCPToolHandler,+ timeout: Duration = .seconds(1)+ ) async -> Bool {+ let deadline = ContinuousClock.now + timeout+ while ContinuousClock.now < deadline {+ if handler.activeToolListChangeStreamCount == expected { return true }+ try? await Task.sleep(for: .milliseconds(10))+ }+ return false+ }+}++private nonisolated final class RecordingEventWriter: ResponseBodyWriter, @unchecked Sendable {+ let events = NIOLockedValueBox<[String]>([])++ func write(_ buffer: ByteBuffer) async throws {+ events.withLockedValue { $0.append(String(buffer: buffer)) }+ }++ func finish(_: HTTPFields?) async throws {}+}++#endif
diff --git a/docs/agent-notes/mcp-server.md b/docs/agent-notes/mcp-server.mdindex 24e320f..94f315b 100644--- a/docs/agent-notes/mcp-server.md+++ b/docs/agent-notes/mcp-server.md@@ -7,20 +7,24 @@ Embedded MCP server in the Transit macOS app using Hummingbird HTTP server. Expo ## Architecture ```-Claude Code ←→ HTTP POST /mcp (localhost:3141) ←→ MCPServer ←→ MCPToolHandler ←→ TaskService/ProjectService/CommentService ←→ SwiftData+Claude Code ←→ HTTP POST + session GET/SSE /mcp (localhost:3141) ←→ MCPServer ←→ MCPToolHandler ←→ TaskService/ProjectService/CommentService ←→ SwiftData ``` -- **Transport**: Streamable HTTP, JSON-RPC 2.0 over `POST /mcp`; `GET /mcp` is explicitly handled with HTTP 405 and `Allow: POST` because Transit does not offer an SSE listening stream+- **Transport**: Streamable HTTP at `/mcp`. `POST /mcp` carries JSON-RPC requests and client notifications; a successful standalone `initialize` returns an `Mcp-Session-Id`. A GET with that session ID and an exact acceptable `text/event-stream` media range opens the optional SSE channel for server-initiated notifications. Missing session IDs return 400, unknown IDs return 404, and GET without SSE negotiation remains HTTP 405 with `Allow: POST`. - **HTTP server**: Hummingbird 2.x (SwiftNIO-based), binds to `127.0.0.1` only - **Lifecycle**: Opt-in via Settings toggle. Default port 3141. ## Files - `Transit/Transit/MCP/MCPTypes.swift` — All Codable protocol types (JSON-RPC, MCP)-- `Transit/Transit/MCP/MCPSettings.swift` — UserDefaults-backed settings (`isEnabled`, `port`)+- `Transit/Transit/MCP/MCPSettings.swift` — UserDefaults-backed settings (`isEnabled`, `port`) and settings-scoped tool-list invalidation source+- `Transit/Transit/MCP/MCPToolListChangeBroadcaster.swift` — active SSE stream registration and list-change broadcasting - `Transit/Transit/MCP/MCPToolHandler.swift` — Tool dispatch, handler methods, helpers - `Transit/Transit/MCP/MCPToolDefinitions.swift` — Tool schema definitions (extracted for file length)-- `Transit/Transit/MCP/MCPServer.swift` — Hummingbird server lifecycle and HTTP routing+- `Transit/Transit/MCP/MCPServer.swift` — Hummingbird server lifecycle, session creation, and HTTP routing+- `Transit/Transit/MCP/MCPServer+Responses.swift` — shared JSON response encoding and header construction+- `Transit/Transit/MCP/MCPRequestContext.swift` — request context retaining the NIO channel for disconnect observation+- `Transit/Transit/MCP/MCPServer+ToolListNotifications.swift` — session validation, Streamable HTTP GET negotiation, and SSE framing - `Transit/Transit/MCP/MCPServerLifecycleConfiguration.swift` — bounded shutdown and no-signal `ServiceGroup` policy - `Transit/Transit/MCP/MCPServerDecodeTypes.swift` — request decode outcome types shared by the route and decoder - `Transit/Transit/MCP/MCPOriginValidator.swift` — transport-level `Origin`/`Host` validation@@ -51,7 +55,7 @@ Key challenge: Hummingbird runs on SwiftNIO event loops (nonisolated), but servi - `MCPToolDefinitions` is split into `coreTools` and `maintenanceTools`; `tools(includingMaintenance:)` returns the right subset. The legacy `all` alias still resolves to `coreTools` only — anything in production should use the helper. - When the toggle is off, `tools/list` excludes both maintenance tools and `tools/call` for either name returns JSON-RPC `invalidParams` (-32602) with the literal message `Tool '<name>' is disabled. Enable maintenance tools in Transit Settings.` — distinct from the "Unknown tool" message used for genuinely unknown names.-- The toggle takes effect on the next `tools/list` without restart (settings is read live).+- The toggle takes effect on the next `tools/list` without restart. Initialization advertises `tools.listChanged: true` and returns a unique session ID; each initialized session with one or more negotiated GET/SSE streams receives exactly one `notifications/tools/list_changed` on each real toggle change. `MCPToolListChangeBroadcaster` is scoped to the shared `MCPSettings` instance, deduplicates across concurrent streams in the same session, and retains only the newest pending invalidation because the notification is idempotent. The request context observes each NIO channel's close future so even an idle disconnected stream is promptly finished and unregistered. - Dispatch handlers (`handleScanDuplicateDisplayIds`, `handleReassignDuplicateDisplayIds`) encode `DisplayIDMaintenanceTypes` (Codable structs) via a shared `encodedTextResult(_:)` helper that JSON-encodes any `Encodable` and wraps it in the `MCPToolResult.content[text]` envelope. ## Origin / Host Validation (T-1833)
diff --git a/specs/bugfixes/mcp-maintenance-toggle-client-refresh/report.md b/specs/bugfixes/mcp-maintenance-toggle-client-refresh/report.mdnew file mode 100644index 0000000..9d0036e--- /dev/null+++ b/specs/bugfixes/mcp-maintenance-toggle-client-refresh/report.md@@ -0,0 +1,121 @@+# Bugfix Report: MCP Maintenance Toggle Client Refresh++**Date:** 2026-08-30+**Status:** Fixed++## Description of the Issue++Changing **Expose maintenance tools** updates `MCPSettings.maintenanceToolsEnabled`, and a new `tools/list` request immediately reflects the new value, but clients initialized before the change are not told that their cached tool list is stale.++**Reproduction steps:**+1. Initialize an MCP client while maintenance tools are disabled and cache the ten tools returned by `tools/list`.+2. Enable **Expose maintenance tools** in Transit Settings without reconnecting the client.+3. Observe that the client receives no `notifications/tools/list_changed` notification and continues using its cached list.++**Impact:** Active MCP clients cannot discover newly enabled maintenance tools and can retain disabled tools until they manually refresh or reconnect. The setting therefore appears ineffective during an active session.++## Investigation Summary++The investigation followed a structured inspection of setting persistence, handler dispatch, initialization capabilities, HTTP transport routes, tests, and the accepted maintenance-tool design decision.++- **Symptoms examined:** Fresh `tools/list` calls see the toggle, but initialized clients do not refresh when it changes.+- **Code inspected:** `MCPSettings.swift`, `MCPToolHandler.swift`, `MCPTypes.swift`, `MCPServer.swift`, `SettingsView.swift`, `TransitApp.swift`, MCP handler/route tests, and duplicate-displayid-cleanup Decision 9.+- **Hypotheses tested:** Stale `UserDefaults` state and cached handler definitions were ruled out because the handler reads the live setting on every `tools/list`. Restarting the listener was rejected because the current HTTP transport is stateless between POSTs and a listener restart does not invalidate a client-side tool cache.++### Systematic inspection findings++1. **Data flow:** `MCPSettings.maintenanceToolsEnabled.didSet` only persists to `UserDefaults`; it has no change-observation output.+2. **Protocol capability:** `MCPToolsCapability` encodes an empty object, so clients have no reason to expect list-change notifications.+3. **Transport integration:** GET `/mcp` deliberately returns HTTP 405, leaving no independent server-to-client SSE stream for notifications.+4. **UI integration:** the Settings toggle only mutates the setting and does not notify or reconnect MCP clients.+5. **Test gap:** the existing toggle test issues a second `tools/list` manually, proving live reads but not active-client refresh.++## Discovered Root Cause++Transit implemented dynamic tool-list computation but not the MCP list-change contract or a server-to-client delivery channel.++**Defect type:** Missing protocol/transport integration and state-change notification.++**Why it occurred:**+1. Why does an active client keep the old tools? Because it caches `tools/list` and receives no invalidation notification.+2. Why is no invalidation sent? Because the maintenance setting only persists its new value and has no notification publisher.+3. Why would a client not expect an invalidation? Because initialization advertises an empty tools capability rather than `listChanged: true`.+4. Why can the server not push the notification anyway? Because Transit implements only POST responses and explicitly rejects the Streamable HTTP GET/SSE listening channel.+5. Why did existing coverage miss this? Because it tested a manually repeated list request rather than an initialized client with a cached list.++**Contributing factors:** Decision 9 specified no app restart, but the initial implementation interpreted that as “the next list call is live” rather than “connected clients are invalidated.” The original embedded-server pattern intentionally chose a minimal POST-only transport, which was sufficient until server-initiated notifications were needed.++## Resolution for the Issue++**Changes made:**+- `Transit/Transit/MCP/MCPTypes.swift` - added `listChanged` to the tools capability and a typed server-notification envelope.+- `Transit/Transit/MCP/MCPSettings.swift` - publishes a tool-list invalidation only when the maintenance setting actually changes and owns notification sessions.+- `Transit/Transit/MCP/MCPToolListChangeBroadcaster.swift` - groups active streams by session, emits each JSON-RPC notification on only one stream per session, and finishes registrations when their channels close.+- `Transit/Transit/MCP/MCPToolHandler.swift` - advertises `tools.listChanged: true` and exposes session/stream lifecycle operations to the transport.+- `Transit/Transit/MCP/MCPRequestContext.swift` - retains each Hummingbird request channel so an idle SSE registration can observe its close future directly.+- `Transit/Transit/MCP/MCPServer.swift` - creates a unique `Mcp-Session-Id` after successful standalone initialization, validates and routes GET `/mcp`, and finishes notification sessions during listener teardown.+- `Transit/Transit/MCP/MCPServer+ToolListNotifications.swift` - validates session headers, parses exact `text/event-stream` media ranges and RFC quality values (including `q=0`), and frames deterministic SSE data events.++**Approach rationale:** This implements MCP 2025-03-26's native cache-invalidation mechanism while preserving the transport rule that one JSON-RPC message must not be duplicated across a client's concurrent listening streams. Session IDs identify which streams belong to one initialized client. Channel-close futures remove idle disconnected streams promptly, rather than waiting for a later notification write. Buffering one pending event remains sufficient because list-change notifications are idempotent: a client always refreshes the current full list.++**Alternatives considered:**+- Restart the Hummingbird listener on toggle — rejected because POST requests are stateless and restarting the server does not make a client discard its cached tools.+- Add UI text requiring manual reconnection — rejected because Decision 9 explicitly targets a no-restart maintenance session and MCP provides a standard list-change notification.+- Broadcast once per SSE connection without sessions — rejected because one client may open concurrent listening streams and MCP forbids sending the same JSON-RPC message more than once.+- Depend only on `AsyncStream.onTermination` for disconnect cleanup — rejected because an idle socket close should remove its registration immediately and independently of response-task cancellation/write timing.++## Regression Test++**Test files:** `Transit/TransitTests/MCPToolListChangeNotificationTests.swift` and `Transit/TransitTests/MCPServerRouteTests.swift`++**What they verify:**+- Successful initialization advertises `tools.listChanged` and creates distinct client session IDs.+- Two streams in one session receive exactly one canonical notification between them, while a second session independently receives one notification.+- Closing an idle transport channel promptly unregisters its stream; same-value setting assignments remain silent.+- GET rejects missing/unknown sessions, zero or malformed quality values, and media-type superstrings; a valid positive-quality exact media range negotiates SSE.++**Run command:** `make test-quick`++## Affected Files++| File | Change |+|------|--------|+| `CHANGELOG.md` | Records the completed session-aware behavior. |+| `Transit/Transit/MCP/MCPSettings.swift` | Publishes real changes and owns notification sessions. |+| `Transit/Transit/MCP/MCPToolHandler.swift` | Advertises list changes and bridges session lifecycle. |+| `Transit/Transit/MCP/MCPToolListChangeBroadcaster.swift` | Deduplicates delivery per session and unregisters closed streams. |+| `Transit/Transit/MCP/MCPTypes.swift` | Defines capability and notification payloads. |+| `Transit/Transit/MCP/MCPRequestContext.swift` | Retains the request transport channel. |+| `Transit/Transit/MCP/MCPServer.swift` | Creates session IDs and routes/tears down GET/SSE sessions. |+| `Transit/Transit/MCP/MCPServer+Responses.swift` | Builds JSON responses with optional session headers. |+| `Transit/Transit/MCP/MCPServer+ToolListNotifications.swift` | Validates sessions, negotiates SSE, and frames notifications. |+| `Transit/TransitTests/MCPHTTPTestHelpers.swift` | Supports the custom context and response/session headers. |+| `Transit/TransitTests/MCPServerOriginValidationTests.swift` | Uses the production request context in origin regressions. |+| `Transit/TransitTests/MCPServerRouteTests.swift` | Covers negotiation and session rejection boundaries. |+| `Transit/TransitTests/MCPToolListChangeNotificationTests.swift` | Covers per-session delivery and prompt disconnect cleanup. |+| `docs/agent-notes/mcp-server.md` | Documents the session-aware SSE transport. |+| `specs/bugfixes/mcp-maintenance-toggle-client-refresh/report.md` | Records investigation, artifact fixes, and verification. |++## Verification++The original T-2169 implementation passed focused tests, `make test-quick`, `make test`, `make test-ui`, `make build-macos`, and `make lint` before the artifact review. For this session/deduplication, channel-close cleanup, and strict negotiation follow-up:+- Bounded focused `MCPToolListChangeNotificationTests`: passed (3 tests).+- `make test-quick`: passed after the final source-file refactor.+- `make lint`: passed, including the SwiftData ownership guard and 358 Swift files with zero violations.+- `git diff --check`: passed.++**Manual verification represented by regressions:**+- The in-process HTTP test initializes two client sessions, opens two streams for one session and one for the other, toggles maintenance tools once, and verifies exactly one canonical event per session.+- Direct lifecycle regressions verify same-value suppression and prompt removal when an idle channel closes.++## Prevention++**Recommendations to avoid similar bugs:**+- Treat dynamic MCP capability changes as both data updates and cache-invalidation events.+- Test state changes from the perspective of a client initialized before the change, not only with fresh list calls.++## Related++- Transit T-2169+- `specs/duplicate-displayid-cleanup/decision_log.md` Decision 9+- MCP 2025-03-26 tools list-change notification and Streamable HTTP transport specifications
Each successful initialize inserts one UUID into sessionIDs; individual IDs are not expired when their last stream closes. The set is cleared during listener teardown. This is a small, non-blocking lifetime cost for the single-user loopback server, but expiration or DELETE handling could be considered if reconnect volume becomes material.
Channel-close cleanup hops to MainActor through a task. A toggle in the narrow interval after a channel closes but before that task removes its registration could select the closing stream instead of a healthy sibling in the same session. Prompt close cleanup and the local architecture make this non-blocking; any future hardening must preserve MCP's prohibition on duplicate delivery across concurrent streams.