diff --git a/CHANGELOG.md b/CHANGELOG.md index 7df7eef69..1a663d4d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iCloud sync of app settings (general, appearance, editor, data grid, history, tabs, keyboard, AI) no longer silently does nothing when a category's payload fails to decode. Each of the eight category branches previously wrapped the decode in `try?`, so a record written by a newer schema version would fall through with no log, no error, and no UI signal: the user would think their settings synced when they hadn't. Decode failures now skip the category and log which one failed and why. - Keychain reads no longer collapse a cancelled Touch ID prompt, a failed biometric auth, or any unknown OSStatus into "not found". The `KeychainResult` enum now distinguishes `.userCancelled`, `.authFailed`, and `.error(OSStatus)` from `.notFound`, and the read paths in connection passwords, SSH profile secrets, AI provider keys, and the license key log each case with its own message. Previously a cancelled prompt looked identical to a missing entry, so the caller would treat the password as gone and silently re-save with an empty string on the next write, producing duplicate keychain entries or a connection saved with a blank password. - Terminal PTY writes retry on `EINTR` instead of treating any non-positive return as "we're done". A signal mid-write previously truncated the input the user typed; the loop would exit silently and the keystrokes were partially sent. The new path retries on `EINTR`, logs the byte position and errno on any other non-recoverable failure, and reports a return value of zero distinctly so the cause is visible in Console. +- MCP HTTP transport no longer writes an empty body when JSON encoding of the response envelope fails. Five sites in `MCPInboundExchange` and `MCPHttpRequestRouter` previously fell back to `Data()`, sending zero bytes to the client which then saw a protocol violation and either disconnected or hung. The encode-failure paths now log and substitute a static `{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"internal_error"}}` envelope; the pairing-exchange success path falls back to `internalServerError` with a small JSON error body. - Result-grid cells on rows marked for deletion keep their dropdown / date / JSON / blob chevron visible at reduced opacity instead of hiding it, so the cell type is still legible while clearly inactive. Click on the dimmed chevron is a no-op; FK arrow navigation is unchanged. Matches the macOS HIG "disabled appearance" guideline. - Foreign key navigation from a table with unsaved edits opens the referenced table in a new window tab to preserve the edit buffer. Closing that new tab no longer wipes the original tab's data grid. Previously the new tab's teardown broadcast a connection-scoped event that other coordinators on the same connection received, causing them to release their cell data. - Tables sidebar refreshes automatically after a successful SQL import; the refresh notification now fires after the success sheet's dismissal animation, so the main window is key when the observer runs (#1114) diff --git a/TablePro/Core/MCP/Transport/MCPHttpRequestRouter.swift b/TablePro/Core/MCP/Transport/MCPHttpRequestRouter.swift index a2196f47b..df2aa5abf 100644 --- a/TablePro/Core/MCP/Transport/MCPHttpRequestRouter.swift +++ b/TablePro/Core/MCP/Transport/MCPHttpRequestRouter.swift @@ -4,6 +4,10 @@ import os struct MCPHttpRequestRouter: Sendable { private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.HttpRouter") + private static let staticInternalErrorEnvelope = Data( + #"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"internal_error"}}"#.utf8 + ) + typealias InboundEmitter = @Sendable (MCPInboundExchange) -> AsyncStream.Continuation.YieldResult typealias SseStarter = @Sendable (UUID, MCPSessionId, HttpConnectionContext) async -> Void typealias ResponderSinkFactory = @Sendable (HttpConnectionContext) -> any MCPResponderSink @@ -115,8 +119,13 @@ struct MCPHttpRequestRouter: Sendable { Self.logger.info("Integrations exchange succeeded (token len=\(token.count, privacy: .public))") let label = await Self.resolveTokenLabel(for: token) MCPAuditLogger.logPairingExchange(outcome: .success, tokenName: label, ip: ip) - let payload = (try? JSONEncoder().encode(ExchangeResponse(token: token))) ?? Data() - await context.writePlainJsonResponse(status: .ok, body: payload) + do { + let payload = try JSONEncoder().encode(ExchangeResponse(token: token)) + await context.writePlainJsonResponse(status: .ok, body: payload) + } catch { + Self.logger.error("Encode ExchangeResponse failed: \(error.localizedDescription, privacy: .public)") + await context.writePlainJsonError(status: .internalServerError, message: "internal_error") + } await context.cancel() case .failure(let error): let mapped = Self.mapExchangeError(error) @@ -422,7 +431,13 @@ struct MCPHttpRequestRouter: Sendable { requestId: JsonRpcId? ) async { let envelope = error.toJsonRpcErrorResponse(id: requestId) - let data = (try? JSONEncoder().encode(envelope)) ?? Data() + let data: Data + do { + data = try JSONEncoder().encode(envelope) + } catch { + Self.logger.error("Encode top-level error envelope failed: \(error.localizedDescription, privacy: .public); using static fallback") + data = Self.staticInternalErrorEnvelope + } await context.writeJsonResponse( data: data, status: error.httpStatus, diff --git a/TablePro/Core/MCP/Transport/MCPInboundExchange.swift b/TablePro/Core/MCP/Transport/MCPInboundExchange.swift index 8cb3a1dd5..e6f1abaf2 100644 --- a/TablePro/Core/MCP/Transport/MCPInboundExchange.swift +++ b/TablePro/Core/MCP/Transport/MCPInboundExchange.swift @@ -51,6 +51,10 @@ public protocol MCPResponderSink: Sendable { public actor MCPExchangeResponder { private static let logger = Logger(subsystem: "com.TablePro", category: "MCP.HttpServer") + private static let staticInternalErrorEnvelope = Data( + #"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"internal_error"}}"#.utf8 + ) + private let sink: MCPResponderSink private var completed: Bool = false private let requestId: JsonRpcId? @@ -71,8 +75,14 @@ public actor MCPExchangeResponder { do { body = try JsonRpcCodec.encode(message) } catch { + Self.logger.error("Encode response failed: \(error.localizedDescription, privacy: .public)") let fallback = MCPProtocolError.internalError(detail: "encode failed").toJsonRpcErrorResponse(id: requestId) - body = (try? JSONEncoder().encode(fallback)) ?? Data() + do { + body = try JSONEncoder().encode(fallback) + } catch { + Self.logger.error("Encode internal_error envelope failed: \(error.localizedDescription, privacy: .public); using static fallback") + body = Self.staticInternalErrorEnvelope + } } await sink.writeJson(body, status: .ok, sessionId: sessionId, extraHeaders: []) @@ -87,7 +97,13 @@ public actor MCPExchangeResponder { completed = true let envelope = error.toJsonRpcErrorResponse(id: responseId ?? requestId) - let data = (try? JSONEncoder().encode(envelope)) ?? Data() + let data: Data + do { + data = try JSONEncoder().encode(envelope) + } catch { + Self.logger.error("Encode error envelope failed: \(error.localizedDescription, privacy: .public); using static fallback") + data = Self.staticInternalErrorEnvelope + } await sink.writeJson(data, status: error.httpStatus, sessionId: nil, extraHeaders: error.extraHeaders) await sink.closeConnection() } @@ -138,7 +154,13 @@ public actor MCPExchangeResponder { completed = true let envelope = error.toJsonRpcErrorResponse(id: requestId) - let data = (try? JSONEncoder().encode(envelope)) ?? Data() + let data: Data + do { + data = try JSONEncoder().encode(envelope) + } catch { + Self.logger.error("Encode reject envelope failed: \(error.localizedDescription, privacy: .public); using static fallback") + data = Self.staticInternalErrorEnvelope + } await sink.writeJson(data, status: error.httpStatus, sessionId: nil, extraHeaders: error.extraHeaders) await sink.closeConnection() }