Skip to content

Commit b868668

Browse files
authored
refactor(mcp): rewrite server, bridge, and protocol layer (#964)
* refactor(mcp): phase 1 wire layer + phase 3 session/auth/ratelimit Wire layer (TablePro/Core/MCP/Wire/) — pure value types with no transport awareness: tagged JsonRpcMessage enum (4 cases), JsonRpcId supporting null explicitly, JsonRpcCodec, strict-CRLF HttpRequestParser, SseEncoder/Decoder, typed JsonRpcDecodingError. Replaces ad-hoc message types and tolerant HTTP parser. Session layer (Session/, Auth/, RateLimit/) — actor-based with eviction broadcast: MCPSessionStore actor with multicast events stream, idle eviction under MCPClock abstraction, MCPBearerTokenAuthenticator with SHA-256 token fingerprint, rate limiter rekeyed on (clientAddress, principalFingerprint) to fix the localhost auth-DoS issue. Idle timeout raised from 5 to 15 min. Old types renamed with Legacy prefix to coexist during the rewrite — they will be deleted in phase 6 once all callers are migrated. * refactor(mcp): phase 2 transport layer Three new transports under Core/MCP/Transport/, all built on the new JsonRpcMessage type. Bridge transports (StdioMessageTransport, Streamable HttpClientTransport) conform to MCPMessageTransport — symmetric inbound stream + send. The stdio transport reads via FileHandle.bytes (no more availableData polling) and writes only valid JSON-RPC to stdout. The streaming HTTP client uses URLSession.bytes for incremental SSE parsing and synthesizes JSON-RPC error envelopes from non-2xx HTTP responses so no malformed line ever reaches the host's stdin — fixing the bridge bug at the architecture level. The in-app HTTP server transport (MCPHttpServerTransport) is exchange- based: each inbound POST yields an MCPInboundExchange whose responder handles wire-level details. Every error path now produces a JSON-RPC envelope; the previous bare {"error":...} body shape is impossible to emit by construction. CORS now includes Last-Event-ID. Remote access without TLS is rejected at configuration time, not at runtime. MCPProtocolError carries both JSON-RPC code and HTTP status; static factories (sessionNotFound, unauthenticated, etc.) encode the spec- correct mappings in one place. * refactor(mcp): phase 4 protocol dispatcher + per-method handlers ProtocolDispatcher actor routes inbound JSON-RPC by method, validates session state and required scopes, registers cancellation tokens, builds request contexts, invokes handlers, and translates typed errors back into JSON-RPC envelopes. Catches MCPProtocolError specifically and falls back to internalError for everything else. Per-method handlers split out as small structs: Initialize, Ping, ToolsList, ToolsCall, ResourcesList/Read/TemplatesList, plus stubs for prompts/logging/ completion. Tools live in their own registry — 19 implementations under Protocol/Tools/ each conforming to MCPToolImplementation. JsonValueLegacy Bridge converts at the MCPConnectionBridge boundary so the legacy JSONValue type can stay on the existing data layer until phase 6. Streaming progress works via MCPProgressEmitter — handlers emit progress events that the transport routes as notifications/progress to the session SSE stream. ExecuteQueryTool uses this at four checkpoints. Cancellation flows through MCPCancellationToken, looked up by (requestId, sessionId) in MCPInflightRegistry; notifications/cancelled triggers token cancel. * refactor(mcp): phase 5 bridge rewrite The tablepro-mcp binary is now a thin composition root over the new transport types. BridgeMain wires up MCPStdioMessageTransport (host side) and MCPStreamableHttpClientTransport (upstream side); BridgeProxy forwards JsonRpcMessage objects between them with a TaskGroup so both directions run concurrently. Errors land in os_log via MCPCompositeBridgeLogger and never leak to stdout — the host-facing transport guarantees only valid JSON-RPC bytes reach Claude Desktop's stdin. Handshake module now uses Duration/ContinuousClock instead of TimeInterval math, and properly distinguishes 'file missing' from 'process not running' when deciding whether to relaunch. mcp-server target's pbxproj exception list expanded to include the Wire/ and Transport/ files the bridge depends on; main.swift renamed to BridgeMain.swift since @main on a struct is incompatible with main.swift filename. Old MCPBridgeProxy.swift deleted. * refactor(mcp): phase 6 wire new server, delete legacy code MCPServerManager is now a thin composition root: it builds an MCPHttpServerTransport, an MCPProtocolDispatcher with all method handlers registered, an MCPSessionStore, MCPBearerTokenAuthenticator, and MCPRateLimiter, then wires the transport's exchanges stream into the dispatcher. Public API (start/stop/restart/lazyStart/tokenStore/state) preserved so AppDelegate, AppSettingsManager, MCPSection, and the launch intent router don't need changes beyond a SessionSnapshot type rename. Legacy code deleted: MCPServer, MCPRouter, MCPRouteHandler, MCPHTTPParser, MCPMessageTypes (JSONRPCRequest/Response/etc), MCPSession, MCPSessionPhase, MCPRateLimiter, MCPToolHandler (+Integrations), MCPResourceHandler, Routes/MCPProtocolHandler, Routes/IntegrationsExchange Handler, JsonValueLegacyBridge. About 7000 lines removed. MCPConnectionBridge migrated from legacy JSONValue to the new JsonValue type. All tools, resource handlers, and helpers updated accordingly. MCPError extracted into its own file (still used by the connection bridge, auth policy, and pairing service). Pre-existing test target compile errors fixed in passing: typos in TableRowsMutationTests, missing 'try' in EvictionTests/RowOperationsDispatch Tests/SortCacheInvalidationTests, obsolete sidebarLoadingState references removed, DeeplinkParser migration in ConnectionSharingTests, MCPTokenStore Tests updated for ConnectionAccess, plus deletion of obsolete legacy MCP test files (MCPRouterTests, MCPAuthGuardTests, etc.) that tested code we just deleted. CHANGELOG entry under Unreleased explains the user-facing impact. xcodebuild build clean for both TablePro and mcp-server schemes; swiftlint strict zero violations on Core/MCP/. * fix(mcp): make MCPHttpServerError messages human-readable Conform to LocalizedError so error.localizedDescription returns the actual reason (e.g., 'Remote access requires TLS to be enabled') instead of the opaque NSError-bridged 'MCPHttpServerError error 0'. Also log the configuration shape on start so failures show bindAddress, port, and tls status. * fix(mcp): remove conflicting port argument to NWListener NWListener(using:on:) with parameters.requiredLocalEndpoint already set produces EINVAL because the port and the endpoint conflict. The endpoint in NWParameters carries both host and port; passing 'on: port' tries to override the port at the listener constructor and the framework rejects it with errno 22. Use NWListener(using:) and let the configured endpoint in parameters drive the bind. * chore: gitignore profraw + .claude worktrees, register new MCP tool strings Auto-extracted localization keys from the new Phase 4 tool descriptions land in Localizable.xcstrings. .gitignore extended to cover Xcode coverage output (.profraw) and the Claude Code per-session worktree directory so they don't show as untracked clutter on other branches. * fix(mcp): in-app setup snippets use stdio command form The 'Setup for Claude Desktop / Cursor' helpers were emitting JSON with the 'url' transport key, which Claude Desktop rejects entirely (it only speaks stdio) and which makes Cursor reach the HTTP server directly with no bridge. Both should point at the bundled tablepro-mcp binary so the stdio bridge handles the handshake and lazy-launch. The Claude Code command line is updated to the same form: 'claude mcp add tablepro -- <bridge-path>' instead of '--transport http <url>'. Path is computed from Bundle.main so it works for non-/Applications installs (Setapp, DerivedData, etc.). * fix(mcp): wait for connection.send to flush before cancelling HttpConnectionContext.send was fire-and-forget — it queued the response bytes via NWConnection.send but returned immediately. handleReceive called context.cancel() right after, racing the flush, so URLSession got 'connection lost' instead of the response body. Make send await the .contentProcessed callback (via withCheckedContinuation) and propagate the await up through writeJsonResponse, writeOptions204, writeNoContent, writeAccepted, writeSseStreamHeaders, writeSseFrame, writeRaw. Cancel now safely runs only after the network framework confirms the data has been handed off. * fix(mcp): wire audit log entries for tool calls, queries, resources, auth ToolsCallHandler now records every tool invocation (success/denied/error) with the principal's token label and the tool's connection_id argument (when present). ResourcesReadHandler does the same for resources/read. ToolQueryExecutor adds an MCPAuditLogger.logQueryExecuted entry alongside the existing QueryHistoryStorage write so SQL execution shows up in the Activity Log. MCPBearerTokenAuthenticator records auth success/failure/ rate-limit events with the client IP. Drops the 'legacy' label from the domain error mapper in ResourcesReadHandler — MCPError is the data layer's domain error type, not legacy code. * refactor(mcp): split MCPError into MCPDataLayerError, drop JSON-RPC overlap The legacy MCPError enum had two kinds of cases mixed together: domain errors thrown by the data layer (notConnected, forbidden, timeout, etc.) and JSON-RPC-shaped errors (parseError, invalidRequest, methodNotFound, internalError, invalidParams) that overlapped with the protocol layer's MCPProtocolError. The unused JSON-RPC cases are deleted; the still-used ones are renamed to domain-flavored names (.invalidParams -> .invalid Argument, .internalError -> .dataSourceError) so the data layer no longer borrows protocol-layer terminology. mapDomainError in ResourcesReadHandler now translates the full set of domain errors into appropriate MCPProtocolError values (notFound -> resourceNotFound + 404, timeout -> requestTimeout, etc.) without a default case, so future additions to MCPDataLayerError will fail the compile and force an explicit decision about the protocol mapping. * refactor(mcp): propagate MCPDataLayerError rename to non-MCP callers LaunchIntentRouter, PairingApprovalSheet, and MCPPairingServiceTests also catch the data-layer error type; updated to the new name. * fix(mcp): writeback legacy token format on first load + restructure CHANGELOG MCPTokenStore now detects the old allowedConnectionIds field via a byte scan and re-saves immediately after decode, so stale on-disk format disappears after one launch instead of lingering forever. The decoder side already handled both shapes — this just forces the encode pass. CHANGELOG split the rewrite into Added (streaming progress as a real new capability), Fixed (5 user-facing bugs that are gone), and Changed (idle timeout + the 'this is a rewrite, but transparent to you' note). * refactor(mcp): drop legacy allowedConnectionIds compat path Old MCP wasn't shipped to users yet, so there's no on-disk format to migrate. Removes the dual-decode branch and the byte-scan migration writeback. Tokens persisted before this change with the old field name won't decode — that's fine, they were never released. * refactor(mcp): convert transports to actors, drop @unchecked Sendable MCPStdioMessageTransport and MCPStreamableHttpClientTransport are now actors. The previous outer-class-with-NSLock-around-internal-actor pattern carried @unchecked Sendable; converting to actors gets compiler- verified Sendable for free. Internal mutable state (readerTask, isClosed, sessionId, pendingRequests, etc.) is actor-isolated. The 'inbound' stream stays nonisolated public let so existing callers like BridgeProxy keep working without await on the property access. The 'var capturedContinuation: ...!' implicitly-unwrapped continuation capture is replaced by a small StreamContinuationBox value type that holds the continuation behind a lock. Sendable without @unchecked. MCPBridgeLogger.StderrWriter is also now an actor; the sync log() API is preserved by spawning a fire-and-forget Task — log ordering is best- effort, which is fine for stderr. Adds 15 new tool test files (DescribeTable, Disconnect, ExportData, FocusQueryTab, GetConnectionStatus, GetTableDdl, ListConnections, ListDatabases, ListRecentTabs, ListSchemas, ListTables, OpenConnection Window, OpenTableTab, SearchQueryHistory, SwitchSchema). Each follows the ConnectToolTests pattern: metadata + missing-arg + malformed-arg. Fixes a switch-vs-struct typo in two tests. * test(mcp): end-to-end bridge integration tests Spins up real MCPHttpServerTransport + MCPStreamableHttpClientTransport + MCPStdioMessageTransport in-process and exercises the full request/response path through a TestBridgeProxy that replicates production BridgeProxy logic (production class lives in the mcp-server target only, not visible to TableProTests via pbxproj membership exceptions). Four scenarios: happy-path init+tools/list, idle session eviction returns -32001 envelope, server emitting non-spec body is wrapped into a JSON-RPC error envelope by the client transport, malformed request returns a JSON-RPC error envelope (not the legacy plain {error:...} shape). Catches the class of wiring bugs that produced the 7 fix commits after the initial PR open. * fix(mcp): restore /v1/integrations/exchange HTTP route for Raycast Phase 6 deleted Routes/IntegrationsExchangeHandler.swift on the assumption that the bridge runs in-process with the app, so an HTTP route for pairing-code exchange was redundant. That assumption is wrong: the Raycast extension is a separate Node.js process that POSTs to /v1/integrations/exchange to redeem a pairing code for a bearer token, and any other future external integration would do the same. Restore the route inline in MCPHttpServerTransport.dispatch — POST to /v1/integrations/exchange parses {code, code_verifier}, calls MCPPairing Service.shared.exchange on MainActor, returns {token} on 200 or a plain {error: ...} JSON body on 4xx/5xx. Adds writePlainJsonResponse + writePlainJsonError helpers on HttpConnectionContext for the non-MCP shape this endpoint uses. * docs(mcp): align external-api docs with the rewrite mcp-tools.mdx — fix scope mismatches across tools (disconnect, switch_*, confirm_destructive_operation, navigation tools), document the streaming progress flow via _meta.progressToken, replace the HTTP-status error table with the JsonRpcErrorCode + HTTP status pairs that match JsonRpcErrorCode.swift / MCPProtocolError.swift, fix execute_query and export_data shapes to match what the actual handlers return, fix describe_table's database/schema defaulting note, add the spec-compliant 404/-32001 'Session not found' paragraph. mcp-clients.mdx — drop the spurious '--transport stdio' flag from the Claude Code command (stdio is the default). Add 404 and 429 entries to the verification troubleshooting list. Document the WWW-Authenticate header on 401. mcp-resources.mdx — new Discovery section for resources/list and resources/templates/list (latter was undocumented). Add the {contents: [{uri,mimeType,text}]} envelope shape that resources/read returns. tokens.mdx — rewrite TokenPermissions -> Set<MCPScope> mapping to match MCPBearerTokenAuthenticator.mcpScopes. Replace the per-IP escalating lockout description with the new flat policy from MCPRateLimitPolicy (5 fails / 60s window / 5min lockout / (client_address, principal_ fingerprint) keyed). url-scheme.mdx — remove the 'token=' query parameter described against DeeplinkParser.parseQuery, which doesn't read it. Add the 51,200-char SQL cap and a pointer to MCP execute_query for headless work. versioning.mdx — note protocolVersion: 2025-03-26 from InitializeHandler. features/mcp.mdx — replace 5-minute escalating lockout with the new policy, matching tokens.mdx. * chore(mcp): log connection accept + integrations-exchange lifecycle Adds info/warning logs at handleNewConnection and at every branch of handleIntegrationsExchange so we can see whether requests are arriving, whether bodies parse, and whether the handler hits the success or failure path. No behavior change. * fix(mcp): use dedicated -32009 unauthenticated JSON-RPC error code * fix(mcp): drop redundant top-level body-size guard and reject Last-Event-ID with 501 * fix(mcp): reuse transport-allocated session on initialize and surface session errors * fix(mcp): serialize HTTP client writes through chained Task pipeline * fix(mcp): write stderr bridge logs synchronously to avoid dropped lines on exit * fix(mcp): encode handshake file via Codable struct shared with bridge decoder * chore(mcp): drop unused Network import from MCPInboundExchange * fix(mcp): localize tool inputSchema descriptions * perf(mcp): replace linear tool lookup with precomputed dictionary * chore(mcp): add pairing exchange audit logger + thousands separators * fix(mcp): wire GET /mcp directly to SSE notification stream registration * fix(mcp): dispatch each exchange in a child task to avoid serializing tool calls * fix(mcp): negotiate initialize protocolVersion and validate header on follow-ups * test(mcp): assert Mcp-Session-Id header lookup is case-insensitive * fix(mcp): reflect Origin header against allowlist instead of hardcoding localhost * fix(mcp): cancel in-flight requests and terminate sessions on token revoke * fix(mcp): emit Retry-After header on 429 with actual lockout duration * chore(mcp): skip app delegate startup under XCTest to avoid orphan host processes * fix(mcp): clear stale handshake file with dead PID before writing new one * fix(mcp): reject duplicate initialize on the same session * feat(mcp): broadcast audit log inserts so the activity panel auto-refreshes * feat(mcp): emit error redirect when user denies the pairing approval sheet * feat(mcp): revoke prior tokens with the same name when re-pairing * docs: note B1-M4 MCP fixes in CHANGELOG * chore: untrack docs/refactor scratchpad The hig-audit notes were committed by accident during the doc audit pass. Untrack from index — keep on disk locally — so they don't ride along on future clones. * test(mcp): pass tokenId to MCPValidatedToken in bearer auth tests * chore: strip trailing blank line in MCPBridgeLogger * fix(mcp): downgrade unknown initialize protocolVersion instead of rejecting The earlier 'strict negotiation' rejected any protocolVersion not in our supported set with -32600 invalid request. Per the MCP transport spec, the server SHOULD respond with the highest version it supports when the client requests a newer one. Claude Code now uses 2025-11-25 (latest); our supportedProtocolVersions is just 2025-03-26, so its initialize was rejected and the bridge probe surfaced as 'Failed to connect'. Make negotiate() always return a valid version, downgrading unknown values to supportedProtocolVersion. Drop the unused 'unsupported version' guard. * feat(mcp): full support for protocol versions 2025-06-18 and 2025-11-25 Server now advertises and negotiates 2025-11-25 (the latest spec) as the preferred protocol version, with 2025-06-18 and 2025-03-26 as backward-compatible fallbacks. Capabilities advertise only what we genuinely implement — completions endpoint added (was already a stub handler), elicitation deliberately omitted because the server doesn't initiate sampling. 2025-11-25 features wired: - Structured tool output (structuredContent alongside content[]): list_*, describe_table, get_table_ddl, get_connection_status, list_recent_tabs, search_query_history, execute_query, and confirm_destructive_operation now return their structured data twice, once as text for older clients and once as structuredContent for clients that prefer the typed shape. - Tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint, title) emitted by tools/list. Read tools are marked readOnly+idempotent; execute_query and export_data are openWorld; confirm_destructive_operation is destructive; switch_*, connect, disconnect, and the open_/focus_ UI tools opt out of safety hints. - ServerInfo now includes a 'title' field per the new spec. Tests updated: InitializeHandler accepts all three versions and downgrades unknown ones to 2025-11-25; ToolsListHandler emits the annotation map; ToolsCallHandler passes structuredContent through. * docs(versioning): document 2025-06-18 + 2025-11-25 support, capabilities, annotations * docs(versioning): rewrite the new section in plain prose, drop em dashes * refactor(mcp): native AsyncStream, drop HttpWriter, typed SSE terminate, widen token fingerprint * docs(mcp): document 2025-11-25 features and fix broken external-api links * Update .gitignore
1 parent d5554b9 commit b868668

206 files changed

Lines changed: 15701 additions & 7074 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,17 @@ playground.xcworkspace
4444
#
4545
# Xcode automatically generates this directory with a hierarchical structure of links to
4646
# temporary directories for debugging Swift packages.
47-
.swiftpm/xcode
47+
.swiftpm/
4848

4949
.build/
5050
LocalPackages/*/Package.resolved
5151

52+
# Coverage profile output
53+
*.profraw
54+
55+
# Claude Code worktrees and per-session state
56+
.claude/worktrees/
57+
5258
# CocoaPods
5359
#
5460
# We recommend against adding the Pods directory to your .gitignore. However
@@ -144,6 +150,3 @@ Libs/*.a
144150
Libs/.downloaded
145151
Libs/dylibs/
146152
Libs/ios/
147-
148-
# Local refactor scratchpad (per chore: untrack docs/refactor scratchpad)
149-
docs/refactor/

CHANGELOG.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- MCP: support for protocol versions `2025-06-18` and `2025-11-25` in addition to `2025-03-26`. Clients on the latest spec no longer downgrade. The server advertises the latest version it supports (`2025-11-25`) and falls back when a client requests an unknown version.
13+
- MCP: structured tool output (`structuredContent`) on every tool. The serialized JSON still appears in `content[].text` for backward compatibility, while 2025-11-25 clients can read the parsed object directly.
14+
- MCP: tool annotations (`title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) on every tool, plus `serverInfo.title` in `initialize` responses. Read tools advertise `readOnlyHint=true`; `confirm_destructive_operation` advertises `destructiveHint=true`.
15+
- MCP: `completions` capability advertised in `initialize` (the `completion/complete` handler was already wired).
16+
- MCP: streaming progress notifications. Long-running tool calls (e.g. `execute_query`) now emit `notifications/progress` events to clients that pass a `_meta.progressToken` in their request.
17+
- MCP: pairing redirect carries an explicit `error=denied` parameter when the user clicks Deny so extensions can show a clear error instead of hanging.
18+
- MCP: re-pairing the same client name automatically revokes the previous token instead of leaving it active.
1219
- Oracle 10G password verifier authentication. Accounts whose `password_versions` includes a 10G hash now connect successfully, matching DBeaver/JDBC/sqlplus behavior. The 10G hash is documented as legacy; rotating to a modern verifier is still recommended (#483)
1320
- Oracle Test Connection now opens a focused diagnostic sheet for auth failures with copy-able diagnostic info, suggested actions, and a link to file an issue
1421
- Oracle connection negotiation now matches python-oracledb's 23ai compile-capability advertisement, including TTC4 explicit boundary, TTC5 token/pipelining/sessionless flags, OCI3 sync, dequeue selectors, and sparse vector features
1522

1623
### Removed
1724

18-
- Keychain: the legacy-keychain migration (`migrateFromLegacyKeychainIfNeeded`) and the password-sync-state migration (`migratePasswordSyncState`). The first violated Apple's Data Protection keychain contract on sandboxed macOS apps and corrupted user credentials; the second toggled `kSecAttrSynchronizable` at runtime, which Apple does not document as safe. The Sync Passwords settings toggle now applies to new saves only existing keychain items keep their original sync state, matching Apple's documented behavior. Users with stale items in the legacy keychain can clean them via Keychain Access; the running app no longer touches them.
25+
- Keychain: the legacy-keychain migration (`migrateFromLegacyKeychainIfNeeded`) and the password-sync-state migration (`migratePasswordSyncState`). The first violated Apple's Data Protection keychain contract on sandboxed macOS apps and corrupted user credentials; the second toggled `kSecAttrSynchronizable` at runtime, which Apple does not document as safe. The Sync Passwords settings toggle now applies to new saves only, existing keychain items keep their original sync state, matching Apple's documented behavior. Users with stale items in the legacy keychain can clean them via Keychain Access; the running app no longer touches them.
1926

2027
### Changed
2128

29+
- MCP: idle session timeout raised from 5 to 15 minutes.
30+
- MCP: complete internal rewrite of the server, stdio bridge, and protocol dispatcher for spec compliance. Public API of `MCPServerManager` and the on-disk handshake format are unchanged; clients do not need to re-pair.
2231
- Internal: introduce `TabSession` as the foundation type for the editor tab/window subsystem rewrite. Currently a parallel structure mirroring `QueryTab`; subsequent PRs migrate state ownership and lifecycle hooks per `docs/architecture/tab-subsystem-rewrite.md`. No user-visible behavior change in this PR.
2332
- Internal: row data and load epoch now live on `TabSession`. `TabSessionRegistry` exposes the row-access methods directly (`tableRows(for:)`, `setTableRows(_:for:)`, `evict(for:)`, etc.); the intermediate `TableRowsStore` facade is gone. All consumers (coordinator, extensions, views, command actions) now read row data from the registry. No user-visible behavior change.
24-
- Internal: hidden-column state moves from the per-window `ColumnVisibilityManager` into each tab's `columnLayout.hiddenColumns`. The shared manager is removed; `MainContentCoordinator` exposes `hideColumn`, `showColumn`, `toggleColumnVisibility`, `showAllColumns`, `hideAllColumns`, and `pruneHiddenColumns` that mutate the active tab directly. Per-table UserDefaults persistence moves into a small `ColumnVisibilityPersistence` service. Tab-switch save/restore swap is gone each tab is its own source of truth. No user-visible behavior change.
33+
- Internal: hidden-column state moves from the per-window `ColumnVisibilityManager` into each tab's `columnLayout.hiddenColumns`. The shared manager is removed; `MainContentCoordinator` exposes `hideColumn`, `showColumn`, `toggleColumnVisibility`, `showAllColumns`, `hideAllColumns`, and `pruneHiddenColumns` that mutate the active tab directly. Per-table UserDefaults persistence moves into a small `ColumnVisibilityPersistence` service. Tab-switch save/restore swap is gone, each tab is its own source of truth. No user-visible behavior change.
2534
- Internal: filter state collapses from three places (the per-window `FilterStateManager`, the `TabFilterState` snapshot on `QueryTab`, and the per-table file-based restore) to a single source: `tab.filterState`. The shared manager is removed; `MainContentCoordinator` now exposes the full filter API (`addFilter`, `applyAllFilters`, `clearFilterState`, `toggleFilterPanel`, `setFKFilter`, `saveLastFilters(for:)`, `restoreLastFilters(for:)`, `saveFilterPreset`, `loadFilterPreset`, `generateFilterPreviewSQL`, etc.) that mutates the active tab. The file-based "restore last filters" persistence in `FilterSettingsStorage` is unchanged. `FilterPanelView`, `MainStatusBarView`, `MainContentCommandActions`, `MainContentView`, and `MainEditorContentView` read filter state directly off the active tab. No user-visible behavior change.
2635
- Internal: extract `QueryExecutor` service from `MainContentCoordinator`. Query data fetch, parallel schema fetch, schema parsing, parameter detection, row-cap policy, and DDL detection now live in `TablePro/Core/Services/Query/QueryExecutor.swift`. SQL parsing helpers (`extractTableName`, `stripTrailingOrderBy`, `parseSQLiteCheckConstraintValues`) move into `QuerySqlParser`. Coordinator methods become thin wrappers; behavior unchanged. No user-visible behavior change.
2736
- Security: non-syncing keychain items now use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. This keeps local-only secrets out of unencrypted device backups (the pairing Apple recommends for local secrets). Syncing items still use `kSecAttrAccessibleAfterFirstUnlock` because iCloud Keychain requires it. Existing items keep their accessibility class until you save them again.
@@ -30,6 +39,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3039

3140
### Fixed
3241

42+
- MCP: GET `/mcp` now opens a real SSE notification stream. Previously the GET path was routed through the request dispatcher, which had no handler for it, so the connection was closed immediately and `notifications/progress` events were dropped.
43+
- MCP: concurrent tool calls no longer serialize at the dispatcher loop. Each exchange is dispatched in its own child task while session-state guards still serialize per-session work.
44+
- MCP: server validates the `protocolVersion` requested in `initialize` against a supported set and rejects unknown versions with `-32600 invalid_request` instead of silently echoing back whatever the client sent.
45+
- MCP: server validates `MCP-Protocol-Version` on follow-up requests against the negotiated version on the session.
46+
- MCP: 429 responses now include a real `Retry-After` header derived from the rate-limiter lockout time. The audit log records the same value.
47+
- MCP: token revocation cancels every in-flight request issued by that token and terminates its sessions.
48+
- MCP: CORS reflects the request `Origin` against an allowlist (`localhost`, `127.0.0.1`, `claude.ai`, `app.cursor.com`) instead of unconditionally returning `Access-Control-Allow-Origin: http://localhost`. Requests without an `Origin` header (native clients) get no CORS headers.
49+
- MCP: duplicate `initialize` on the same session now returns `invalid_request` instead of silently overwriting `clientInfo`.
50+
- MCP: `xcodebuild test` no longer leaves an orphan `TablePro.app` running. The app delegate skips its normal startup when launched under XCTest.
51+
- MCP: server start removes a stale handshake file written by a crashed previous PID before writing a fresh one.
52+
- MCP: settings activity log refreshes automatically when new audit entries are written.
53+
- MCP: stale `Mcp-Session-Id` after idle timeout now produces a JSON-RPC `-32001 "Session not found"` envelope with HTTP 404, matching the spec and letting clients re-initialize cleanly. Previously the bridge forwarded a plain `{"error":"Session not found"}` body that Claude Desktop's parser rejected, hanging the request until a 4-minute client-side timeout fired.
54+
- MCP: stdio bridge no longer exits silently when stdin is briefly empty (was reading via `availableData`, which can't tell EOF from "no bytes right now"). Now uses `FileHandle.bytes` AsyncBytes.
55+
- MCP: SSE responses now stream incrementally instead of buffering the entire body before delivering events.
56+
- MCP: localhost auth-DoS surface closed. The rate limiter now keys on `(client_address, principal_fingerprint)` so failed attempts from one bridge can't lock out another.
57+
- MCP: in-app "Setup for Claude Desktop / Cursor" snippets now use the stdio command form pointing at the bundled `tablepro-mcp` binary. The previous `"url"` form was rejected by Claude Desktop entirely.
3358
- Saved connection passwords no longer disappear after quitting and relaunching the app. The legacy-keychain migration that ran on every launch was destructive on sandboxed macOS configurations: queries without `kSecUseDataProtectionKeychain` returned items that had been written *with* the flag, and the migration's "delete legacy entry" step then removed the only copy. Removed the legacy keychain migration entirely; `KeychainHelper` now exclusively reads and writes through the Data Protection keychain on every launch.
3459
- Tab switching: rapid Cmd+Number presses no longer leave a tail of tab transitions playing after the user releases the keys. The tab-selection setter (`NSWindowTabGroup.selectedWindow`) is now wrapped in `NSAnimationContext.runAnimationGroup` with `duration = 0`, so AppKit applies each switch synchronously without queuing a CAAnimation. Lazy-load also moved out of `windowDidBecomeKey` into `.task(id:)` view-appearance lifecycle per Apple's documentation. Note: extreme Cmd+Number bursts (e.g. holding the key for key-repeat) still incur per-switch AppKit window-focus overhead; this is platform-inherent to native NSWindow tabs and documented in `docs/architecture/tab-subsystem-rewrite.md` D2
3560
- Oracle TIMESTAMP, TIMESTAMP WITH TIME ZONE, TIMESTAMP WITH LOCAL TIME ZONE, INTERVAL DAY TO SECOND, INTERVAL YEAR TO MONTH, DATE, RAW, and BLOB columns now render through typed decoders instead of garbled text. Tables containing INTERVAL YEAR TO MONTH or BFILE columns no longer crash the app on row fetch. Unknown column types display `<unsupported: type>` instead of crashing (#965)

Plugins/TableProPluginKit/PluginDatabaseDriver.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,27 @@ public extension PluginDatabaseDriver {
279279
return "\"\(escaped)\""
280280
}
281281

282+
func streamRows(query: String) -> AsyncThrowingStream<PluginStreamElement, Error> {
283+
AsyncThrowingStream { continuation in
284+
Task {
285+
do {
286+
let result = try await self.execute(query: query)
287+
let header = PluginStreamHeader(
288+
columns: result.columns,
289+
columnTypeNames: result.columnTypeNames
290+
)
291+
continuation.yield(.header(header))
292+
if !result.rows.isEmpty {
293+
continuation.yield(.rows(result.rows))
294+
}
295+
continuation.finish()
296+
} catch {
297+
continuation.finish(throwing: error)
298+
}
299+
}
300+
}
301+
}
302+
282303
func escapeStringLiteral(_ value: String) -> String {
283304
var result = value
284305
result = result.replacingOccurrences(of: "'", with: "''")

TablePro.xcodeproj/project.pbxproj

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -319,8 +319,26 @@
319319
5A32BC082F9D5FC900BAEB5F /* Exceptions for "TablePro" folder in "mcp-server" target */ = {
320320
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
321321
membershipExceptions = (
322-
CLI/main.swift,
323-
CLI/MCPBridgeProxy.swift,
322+
CLI/BridgeMain.swift,
323+
CLI/BridgeProxy.swift,
324+
CLI/Handshake.swift,
325+
Core/MCP/Transport/MCPBridgeLogger.swift,
326+
Core/MCP/Transport/MCPMessageTransport.swift,
327+
Core/MCP/Transport/MCPProtocolError.swift,
328+
Core/MCP/Transport/MCPStdioMessageTransport.swift,
329+
Core/MCP/Transport/MCPStreamableHttpClientTransport.swift,
330+
Core/MCP/Wire/HttpRequestHead.swift,
331+
Core/MCP/Wire/HttpResponseHead.swift,
332+
Core/MCP/Wire/JsonRpcCodec.swift,
333+
Core/MCP/Wire/JsonRpcError.swift,
334+
Core/MCP/Wire/JsonRpcErrorCode.swift,
335+
Core/MCP/Wire/JsonRpcId.swift,
336+
Core/MCP/Wire/JsonRpcMessage.swift,
337+
Core/MCP/Wire/JsonRpcVersion.swift,
338+
Core/MCP/Wire/JsonValue.swift,
339+
Core/MCP/Wire/SseDecoder.swift,
340+
Core/MCP/Wire/SseEncoder.swift,
341+
Core/MCP/Wire/SseFrame.swift,
324342
);
325343
target = 5A32BBFF2F9D5F1300BAEB5F /* mcp-server */;
326344
};
@@ -460,8 +478,9 @@
460478
5AF312BE2F36FF7500E86682 /* Exceptions for "TablePro" folder in "TablePro" target */ = {
461479
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
462480
membershipExceptions = (
463-
CLI/main.swift,
464-
CLI/MCPBridgeProxy.swift,
481+
CLI/BridgeMain.swift,
482+
CLI/BridgeProxy.swift,
483+
CLI/Handshake.swift,
465484
Info.plist,
466485
);
467486
target = 5A1091C62EF17EDC0055EA7C /* TablePro */;

TablePro/AppDelegate.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
3131
// MARK: - Lifecycle
3232

3333
func applicationDidFinishLaunching(_ notification: Notification) {
34+
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil {
35+
Self.logger.info("Running under XCTest, skipping normal app startup")
36+
return
37+
}
38+
3439
let appearanceSettings = AppSettingsManager.shared.appearance
3540
ThemeEngine.shared.updateAppearanceAndTheme(
3641
mode: appearanceSettings.appearanceMode,

TablePro/CLI/BridgeMain.swift

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import Foundation
2+
3+
@main
4+
struct TableProMcpBridge {
5+
static func main() async {
6+
let logger: any MCPBridgeLogger = MCPCompositeBridgeLogger([
7+
MCPOSBridgeLogger(category: "MCP.Bridge"),
8+
MCPStderrBridgeLogger()
9+
])
10+
11+
let acquirer = MCPHandshakeAcquirer(logger: logger)
12+
let handshake: MCPBridgeHandshake
13+
do {
14+
handshake = try await acquirer.acquire()
15+
} catch {
16+
logger.log(.error, "Handshake failed: \(error.localizedDescription)")
17+
emitFatalJsonRpcError(message: "TablePro is not running. Launch the app and enable the MCP server.")
18+
exit(1)
19+
}
20+
21+
guard let endpoint = handshake.endpoint() else {
22+
logger.log(.error, "Handshake produced invalid endpoint")
23+
emitFatalJsonRpcError(message: "Invalid MCP server endpoint")
24+
exit(1)
25+
}
26+
27+
let upstream = MCPStreamableHttpClientTransport(
28+
configuration: MCPStreamableHttpClientConfiguration(
29+
endpoint: endpoint,
30+
bearerToken: handshake.token,
31+
tlsCertFingerprint: handshake.tlsCertFingerprint,
32+
requestTimeout: .seconds(60),
33+
serverInitiatedStream: false
34+
),
35+
errorLogger: logger
36+
)
37+
38+
let host = MCPStdioMessageTransport(errorLogger: logger)
39+
40+
let proxy = BridgeProxy(host: host, upstream: upstream, logger: logger)
41+
await proxy.run()
42+
}
43+
44+
private static func emitFatalJsonRpcError(message: String) {
45+
let envelope = JsonRpcMessage.errorResponse(
46+
JsonRpcErrorResponse(
47+
id: nil,
48+
error: JsonRpcError(
49+
code: JsonRpcErrorCode.serverError,
50+
message: message,
51+
data: nil
52+
)
53+
)
54+
)
55+
guard let data = try? JsonRpcCodec.encodeLine(envelope) else { return }
56+
FileHandle.standardOutput.write(data)
57+
}
58+
}

TablePro/CLI/BridgeProxy.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import Foundation
2+
3+
actor BridgeProxy {
4+
private let host: any MCPMessageTransport
5+
private let upstream: any MCPMessageTransport
6+
private let logger: any MCPBridgeLogger
7+
8+
init(host: any MCPMessageTransport, upstream: any MCPMessageTransport, logger: any MCPBridgeLogger) {
9+
self.host = host
10+
self.upstream = upstream
11+
self.logger = logger
12+
}
13+
14+
func run() async {
15+
await withTaskGroup(of: Void.self) { [host, upstream, logger] group in
16+
group.addTask { await Self.forward(from: host, to: upstream, direction: "host→upstream", logger: logger) }
17+
group.addTask { await Self.forward(from: upstream, to: host, direction: "upstream→host", logger: logger) }
18+
await group.waitForAll()
19+
}
20+
}
21+
22+
private static func forward(
23+
from source: any MCPMessageTransport,
24+
to destination: any MCPMessageTransport,
25+
direction: String,
26+
logger: any MCPBridgeLogger
27+
) async {
28+
do {
29+
for try await message in source.inbound {
30+
do {
31+
try await destination.send(message)
32+
} catch {
33+
logger.log(.warning, "[\(direction)] send failed: \(error.localizedDescription)")
34+
}
35+
}
36+
logger.log(.info, "[\(direction)] inbound stream closed")
37+
} catch {
38+
logger.log(.error, "[\(direction)] inbound failed: \(error.localizedDescription)")
39+
}
40+
41+
await destination.close()
42+
}
43+
}

0 commit comments

Comments
 (0)