Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
fc1f259
refactor(mcp): phase 1 wire layer + phase 3 session/auth/ratelimit
datlechin May 1, 2026
ae75a8d
refactor(mcp): phase 2 transport layer
datlechin May 1, 2026
8b61d70
refactor(mcp): phase 4 protocol dispatcher + per-method handlers
datlechin May 2, 2026
a53a82e
refactor(mcp): phase 5 bridge rewrite
datlechin May 2, 2026
2108fb4
refactor(mcp): phase 6 wire new server, delete legacy code
datlechin May 2, 2026
0d93d4e
fix(mcp): make MCPHttpServerError messages human-readable
datlechin May 2, 2026
8d7ea80
fix(mcp): remove conflicting port argument to NWListener
datlechin May 2, 2026
0caee43
chore: gitignore profraw + .claude worktrees, register new MCP tool s…
datlechin May 2, 2026
1f8d7f2
fix(mcp): in-app setup snippets use stdio command form
datlechin May 2, 2026
ded173f
fix(mcp): wait for connection.send to flush before cancelling
datlechin May 2, 2026
fff5cdd
fix(mcp): wire audit log entries for tool calls, queries, resources, …
datlechin May 2, 2026
8bea876
refactor(mcp): split MCPError into MCPDataLayerError, drop JSON-RPC o…
datlechin May 2, 2026
454ecd2
refactor(mcp): propagate MCPDataLayerError rename to non-MCP callers
datlechin May 2, 2026
0b669ba
fix(mcp): writeback legacy token format on first load + restructure C…
datlechin May 2, 2026
ff63a90
refactor(mcp): drop legacy allowedConnectionIds compat path
datlechin May 2, 2026
8701df8
refactor(mcp): convert transports to actors, drop @unchecked Sendable
datlechin May 2, 2026
be82b3d
test(mcp): end-to-end bridge integration tests
datlechin May 2, 2026
60c7ba8
fix(mcp): restore /v1/integrations/exchange HTTP route for Raycast
datlechin May 2, 2026
2babe10
docs(mcp): align external-api docs with the rewrite
datlechin May 2, 2026
c788e71
chore(mcp): log connection accept + integrations-exchange lifecycle
datlechin May 2, 2026
5d37d76
fix(mcp): use dedicated -32009 unauthenticated JSON-RPC error code
datlechin May 2, 2026
8dc6876
fix(mcp): drop redundant top-level body-size guard and reject Last-Ev…
datlechin May 2, 2026
130e1cc
fix(mcp): reuse transport-allocated session on initialize and surface…
datlechin May 2, 2026
5e81926
fix(mcp): serialize HTTP client writes through chained Task pipeline
datlechin May 2, 2026
bceb73a
fix(mcp): write stderr bridge logs synchronously to avoid dropped lin…
datlechin May 2, 2026
6a0be56
fix(mcp): encode handshake file via Codable struct shared with bridge…
datlechin May 2, 2026
c59c12a
chore(mcp): drop unused Network import from MCPInboundExchange
datlechin May 2, 2026
fdd44ea
fix(mcp): localize tool inputSchema descriptions
datlechin May 2, 2026
ae8f7ed
perf(mcp): replace linear tool lookup with precomputed dictionary
datlechin May 2, 2026
523c171
chore(mcp): add pairing exchange audit logger + thousands separators
datlechin May 2, 2026
5ee99f7
fix(mcp): wire GET /mcp directly to SSE notification stream registration
datlechin May 2, 2026
f4d6205
fix(mcp): dispatch each exchange in a child task to avoid serializing…
datlechin May 2, 2026
1d64e9e
fix(mcp): negotiate initialize protocolVersion and validate header on…
datlechin May 2, 2026
7e48168
test(mcp): assert Mcp-Session-Id header lookup is case-insensitive
datlechin May 2, 2026
7dbe363
fix(mcp): reflect Origin header against allowlist instead of hardcodi…
datlechin May 2, 2026
d0360dd
fix(mcp): cancel in-flight requests and terminate sessions on token r…
datlechin May 2, 2026
2ea9222
fix(mcp): emit Retry-After header on 429 with actual lockout duration
datlechin May 2, 2026
31c122a
chore(mcp): skip app delegate startup under XCTest to avoid orphan ho…
datlechin May 2, 2026
6496186
fix(mcp): clear stale handshake file with dead PID before writing new…
datlechin May 2, 2026
ca4a7e6
fix(mcp): reject duplicate initialize on the same session
datlechin May 2, 2026
0427bb4
feat(mcp): broadcast audit log inserts so the activity panel auto-ref…
datlechin May 2, 2026
564dbac
feat(mcp): emit error redirect when user denies the pairing approval …
datlechin May 2, 2026
a614dcb
feat(mcp): revoke prior tokens with the same name when re-pairing
datlechin May 2, 2026
7c3f92f
docs: note B1-M4 MCP fixes in CHANGELOG
datlechin May 2, 2026
1c4a3ab
chore: untrack docs/refactor scratchpad
datlechin May 2, 2026
cbf04fd
test(mcp): pass tokenId to MCPValidatedToken in bearer auth tests
datlechin May 2, 2026
e97a4a5
chore: strip trailing blank line in MCPBridgeLogger
datlechin May 2, 2026
6d65757
fix(mcp): downgrade unknown initialize protocolVersion instead of rej…
datlechin May 2, 2026
2863f12
feat(mcp): full support for protocol versions 2025-06-18 and 2025-11-25
datlechin May 2, 2026
d2ce0be
docs(versioning): document 2025-06-18 + 2025-11-25 support, capabilit…
datlechin May 2, 2026
ac57d4e
docs(versioning): rewrite the new section in plain prose, drop em dashes
datlechin May 2, 2026
bc6f6c4
Merge branch 'main' into refactor/mcp-rewrite
datlechin May 3, 2026
5e3e575
refactor(mcp): native AsyncStream, drop HttpWriter, typed SSE termina…
datlechin May 3, 2026
ac7ee46
docs(mcp): document 2025-11-25 features and fix broken external-api l…
datlechin May 3, 2026
14169d6
Update .gitignore
datlechin May 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,17 @@ playground.xcworkspace
#
# Xcode automatically generates this directory with a hierarchical structure of links to
# temporary directories for debugging Swift packages.
.swiftpm/xcode
.swiftpm/

.build/
LocalPackages/*/Package.resolved

# Coverage profile output
*.profraw

# Claude Code worktrees and per-session state
.claude/worktrees/

# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
Expand Down Expand Up @@ -144,6 +150,3 @@ Libs/*.a
Libs/.downloaded
Libs/dylibs/
Libs/ios/

# Local refactor scratchpad (per chore: untrack docs/refactor scratchpad)
docs/refactor/
29 changes: 27 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- 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.
- 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.
- 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`.
- MCP: `completions` capability advertised in `initialize` (the `completion/complete` handler was already wired).
- 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.
- MCP: pairing redirect carries an explicit `error=denied` parameter when the user clicks Deny so extensions can show a clear error instead of hanging.
- MCP: re-pairing the same client name automatically revokes the previous token instead of leaving it active.
- 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)
- 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
- 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

### Removed

- 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.
- 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.

### Changed

- MCP: idle session timeout raised from 5 to 15 minutes.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
Expand All @@ -30,6 +39,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- 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.
- 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.
- 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.
- MCP: server validates `MCP-Protocol-Version` on follow-up requests against the negotiated version on the session.
- MCP: 429 responses now include a real `Retry-After` header derived from the rate-limiter lockout time. The audit log records the same value.
- MCP: token revocation cancels every in-flight request issued by that token and terminates its sessions.
- 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.
- MCP: duplicate `initialize` on the same session now returns `invalid_request` instead of silently overwriting `clientInfo`.
- MCP: `xcodebuild test` no longer leaves an orphan `TablePro.app` running. The app delegate skips its normal startup when launched under XCTest.
- MCP: server start removes a stale handshake file written by a crashed previous PID before writing a fresh one.
- MCP: settings activity log refreshes automatically when new audit entries are written.
- 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.
- 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.
- MCP: SSE responses now stream incrementally instead of buffering the entire body before delivering events.
- 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.
- 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.
- 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.
- 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
- 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)
Expand Down
21 changes: 21 additions & 0 deletions Plugins/TableProPluginKit/PluginDatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,27 @@ public extension PluginDatabaseDriver {
return "\"\(escaped)\""
}

func streamRows(query: String) -> AsyncThrowingStream<PluginStreamElement, Error> {
AsyncThrowingStream { continuation in
Task {
do {
let result = try await self.execute(query: query)
let header = PluginStreamHeader(
columns: result.columns,
columnTypeNames: result.columnTypeNames
)
continuation.yield(.header(header))
if !result.rows.isEmpty {
continuation.yield(.rows(result.rows))
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}

func escapeStringLiteral(_ value: String) -> String {
var result = value
result = result.replacingOccurrences(of: "'", with: "''")
Expand Down
27 changes: 23 additions & 4 deletions TablePro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,26 @@
5A32BC082F9D5FC900BAEB5F /* Exceptions for "TablePro" folder in "mcp-server" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
CLI/main.swift,
CLI/MCPBridgeProxy.swift,
CLI/BridgeMain.swift,
CLI/BridgeProxy.swift,
CLI/Handshake.swift,
Core/MCP/Transport/MCPBridgeLogger.swift,
Core/MCP/Transport/MCPMessageTransport.swift,
Core/MCP/Transport/MCPProtocolError.swift,
Core/MCP/Transport/MCPStdioMessageTransport.swift,
Core/MCP/Transport/MCPStreamableHttpClientTransport.swift,
Core/MCP/Wire/HttpRequestHead.swift,
Core/MCP/Wire/HttpResponseHead.swift,
Core/MCP/Wire/JsonRpcCodec.swift,
Core/MCP/Wire/JsonRpcError.swift,
Core/MCP/Wire/JsonRpcErrorCode.swift,
Core/MCP/Wire/JsonRpcId.swift,
Core/MCP/Wire/JsonRpcMessage.swift,
Core/MCP/Wire/JsonRpcVersion.swift,
Core/MCP/Wire/JsonValue.swift,
Core/MCP/Wire/SseDecoder.swift,
Core/MCP/Wire/SseEncoder.swift,
Core/MCP/Wire/SseFrame.swift,
);
target = 5A32BBFF2F9D5F1300BAEB5F /* mcp-server */;
};
Expand Down Expand Up @@ -460,8 +478,9 @@
5AF312BE2F36FF7500E86682 /* Exceptions for "TablePro" folder in "TablePro" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
CLI/main.swift,
CLI/MCPBridgeProxy.swift,
CLI/BridgeMain.swift,
CLI/BridgeProxy.swift,
CLI/Handshake.swift,
Info.plist,
);
target = 5A1091C62EF17EDC0055EA7C /* TablePro */;
Expand Down
5 changes: 5 additions & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: - Lifecycle

func applicationDidFinishLaunching(_ notification: Notification) {
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil {
Self.logger.info("Running under XCTest, skipping normal app startup")
return
}

let appearanceSettings = AppSettingsManager.shared.appearance
ThemeEngine.shared.updateAppearanceAndTheme(
mode: appearanceSettings.appearanceMode,
Expand Down
58 changes: 58 additions & 0 deletions TablePro/CLI/BridgeMain.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Foundation

@main
struct TableProMcpBridge {
static func main() async {
let logger: any MCPBridgeLogger = MCPCompositeBridgeLogger([
MCPOSBridgeLogger(category: "MCP.Bridge"),
MCPStderrBridgeLogger()
])

let acquirer = MCPHandshakeAcquirer(logger: logger)
let handshake: MCPBridgeHandshake
do {
handshake = try await acquirer.acquire()
} catch {
logger.log(.error, "Handshake failed: \(error.localizedDescription)")
emitFatalJsonRpcError(message: "TablePro is not running. Launch the app and enable the MCP server.")
exit(1)
}

guard let endpoint = handshake.endpoint() else {
logger.log(.error, "Handshake produced invalid endpoint")
emitFatalJsonRpcError(message: "Invalid MCP server endpoint")
exit(1)
}

let upstream = MCPStreamableHttpClientTransport(
configuration: MCPStreamableHttpClientConfiguration(
endpoint: endpoint,
bearerToken: handshake.token,
tlsCertFingerprint: handshake.tlsCertFingerprint,
requestTimeout: .seconds(60),
serverInitiatedStream: false
),
errorLogger: logger
)

let host = MCPStdioMessageTransport(errorLogger: logger)

let proxy = BridgeProxy(host: host, upstream: upstream, logger: logger)
await proxy.run()
}

private static func emitFatalJsonRpcError(message: String) {
let envelope = JsonRpcMessage.errorResponse(
JsonRpcErrorResponse(
id: nil,
error: JsonRpcError(
code: JsonRpcErrorCode.serverError,
message: message,
data: nil
)
)
)
guard let data = try? JsonRpcCodec.encodeLine(envelope) else { return }
FileHandle.standardOutput.write(data)
}
}
43 changes: 43 additions & 0 deletions TablePro/CLI/BridgeProxy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Foundation

actor BridgeProxy {
private let host: any MCPMessageTransport
private let upstream: any MCPMessageTransport
private let logger: any MCPBridgeLogger

init(host: any MCPMessageTransport, upstream: any MCPMessageTransport, logger: any MCPBridgeLogger) {
self.host = host
self.upstream = upstream
self.logger = logger
}

func run() async {
await withTaskGroup(of: Void.self) { [host, upstream, logger] group in
group.addTask { await Self.forward(from: host, to: upstream, direction: "host→upstream", logger: logger) }
group.addTask { await Self.forward(from: upstream, to: host, direction: "upstream→host", logger: logger) }
await group.waitForAll()
}
}

private static func forward(
from source: any MCPMessageTransport,
to destination: any MCPMessageTransport,
direction: String,
logger: any MCPBridgeLogger
) async {
do {
for try await message in source.inbound {
do {
try await destination.send(message)
} catch {
logger.log(.warning, "[\(direction)] send failed: \(error.localizedDescription)")
}
}
logger.log(.info, "[\(direction)] inbound stream closed")
} catch {
logger.log(.error, "[\(direction)] inbound failed: \(error.localizedDescription)")
}

await destination.close()
}
}
Loading
Loading