From 1fc69a1c1902d935dc073c96df729dbfcad4f0fd Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:04:32 +0800 Subject: [PATCH 1/2] feat: expose memory through local MCP server --- .../RxCodeCoreTests/MCPToolCallTests.swift | 8 + RxCode/App/AppCore.swift | 3 + RxCode/App/AppState+Agents.swift | 16 ++ RxCode/App/AppState.swift | 2 + RxCode/App/WorkspaceManager.swift | 21 ++ RxCode/Resources/Localizable.xcstrings | 36 +++ RxCode/Services/IDEServer/IDEMCPServer.swift | 244 ++++++++++++++++-- RxCode/Views/SettingsMemoryViews.swift | 222 ++++++++++++++++ RxCodeTests/MemoryIntentTests.swift | 40 +++ 9 files changed, 571 insertions(+), 21 deletions(-) diff --git a/Packages/Tests/RxCodeCoreTests/MCPToolCallTests.swift b/Packages/Tests/RxCodeCoreTests/MCPToolCallTests.swift index 813c54ae..bbc26bd5 100644 --- a/Packages/Tests/RxCodeCoreTests/MCPToolCallTests.swift +++ b/Packages/Tests/RxCodeCoreTests/MCPToolCallTests.swift @@ -25,4 +25,12 @@ struct MCPToolCallTests { #expect(message.toolCalls.first?.isError == false) #expect(message.toolCalls.first?.result == "") } + + @Test("Memory tools use search instead of a get-by-id tool") + func memoryToolsUseSearch() { + let names = Set(IDEToolRegistry.allTools.map(\.name)) + + #expect(names.contains("ide__memory_search")) + #expect(!names.contains("ide__memory_get")) + } } diff --git a/RxCode/App/AppCore.swift b/RxCode/App/AppCore.swift index 1eb9bbd8..52522eda 100644 --- a/RxCode/App/AppCore.swift +++ b/RxCode/App/AppCore.swift @@ -26,6 +26,9 @@ final class AppCore { let cliStore: CLISessionStore let claude: ClaudeService let workspaceRegistry: WorkspaceRegistry + /// Process-wide listener for the optional, user-configured memory MCP API. + /// Its handler follows the frontmost workspace through `WorkspaceManager`. + let memoryMCPServer = IDEMCPServer() init() { let metaStore = SessionMetaStore() diff --git a/RxCode/App/AppState+Agents.swift b/RxCode/App/AppState+Agents.swift index dac824d0..87a669db 100644 --- a/RxCode/App/AppState+Agents.swift +++ b/RxCode/App/AppState+Agents.swift @@ -8,6 +8,22 @@ import SwiftUI extension AppState { // MARK: - Memory + func configureMemoryMCPServer(enabled: Bool, port: Int, exposedTools: Set) async { + await core.memoryMCPServer.setHandler(self) + do { + try await core.memoryMCPServer.configureMemoryAPI( + enabled: enabled && memoryEnabled, + port: port, + exposedTools: exposedTools + ) + memoryMCPServerRunning = enabled && memoryEnabled + memoryMCPServerError = nil + } catch { + memoryMCPServerRunning = false + memoryMCPServerError = error.localizedDescription + } + } + func allMemoryItems() async -> [MemoryItem] { await memoryService.allMemories() } diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 331b005b..9b13df6d 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -454,6 +454,8 @@ final class AppState { } var memoryRevision = 0 + var memoryMCPServerRunning = false + var memoryMCPServerError: String? // MARK: - Notifications diff --git a/RxCode/App/WorkspaceManager.swift b/RxCode/App/WorkspaceManager.swift index eb31837f..4d882b6a 100644 --- a/RxCode/App/WorkspaceManager.swift +++ b/RxCode/App/WorkspaceManager.swift @@ -48,6 +48,9 @@ final class WorkspaceManager { let workspace = snapshot.all.first { $0.id == workspaceID } ?? snapshot.active let appState = AppState(core: core, workspace: workspace) appStatesByWorkspaceID[workspace.id] = appState + if workspace.id == frontmostWorkspaceID { + bindMemoryMCPServer(to: appState) + } startPerformanceMonitorIfNeeded() return appState } @@ -65,6 +68,24 @@ final class WorkspaceManager { func markFrontmost(_ workspaceID: String) { frontmostWorkspaceID = workspaceID transferMobileSyncOwnership(to: workspaceID) + bindMemoryMCPServer(to: appState(for: workspaceID)) + } + + private func bindMemoryMCPServer(to appState: AppState) { + let defaults = UserDefaults.standard + let enabled = defaults.bool(forKey: IDEMCPServer.memoryAPIEnabledDefaultsKey) + let savedPort = defaults.integer(forKey: IDEMCPServer.memoryAPIPortDefaultsKey) + let port = savedPort == 0 ? IDEMCPServer.memoryAPIDefaultPort : savedPort + let exposedTools = IDEMCPServer.exposedTools( + from: defaults.string(forKey: IDEMCPServer.exposedToolsDefaultsKey) + ) + Task { + await appState.configureMemoryMCPServer( + enabled: enabled, + port: port, + exposedTools: exposedTools + ) + } } /// Hand mobile-sync ownership to `workspaceID`, tearing it down on the diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index ec865b11..2316b726 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -3296,6 +3296,9 @@ } } } + }, + "Choose which RxCode tools are available to other local MCP clients." : { + }, "Choose…" : { "localizations" : { @@ -6394,6 +6397,9 @@ } } } + }, + "Enable MCP Server" : { + }, "Enable Memory" : { "localizations" : { @@ -6410,6 +6416,9 @@ } } } + }, + "Enable Memory to start the MCP server." : { + }, "Enabled" : { "localizations" : { @@ -6727,6 +6736,9 @@ } } } + }, + "Expose selected RxCode tools to other local MCP clients." : { + }, "Extra environment" : { "localizations" : { @@ -8327,6 +8339,9 @@ } } } + }, + "Keep RxCode running while another MCP client uses this server. Ports 19847–19946 are reserved for chat sessions." : { + }, "Keep summaries on the thread model, or route titles and briefings through an OpenAI-compatible endpoint." : { "localizations" : { @@ -9165,6 +9180,9 @@ } } } + }, + "MCP Server" : { + }, "MCP server disconnected" : { "comment" : "Notification title when an MCP server transitions from connected to failed.", @@ -10900,6 +10918,9 @@ } } } + }, + "Off" : { + }, "Offline" : { "localizations" : { @@ -11810,6 +11831,9 @@ } } } + }, + "Port" : { + }, "PR #%lld" : { "localizations" : { @@ -13362,6 +13386,12 @@ } } } + }, + "Running" : { + + }, + "Running on 127.0.0.1:%@" : { + }, "Running swift build" : { "localizations" : { @@ -14560,6 +14590,9 @@ } } } + }, + "Server stopped" : { + }, "Servers" : { "localizations" : { @@ -16439,6 +16472,9 @@ } } } + }, + "Tools to Expose" : { + }, "Transport" : { "localizations" : { diff --git a/RxCode/Services/IDEServer/IDEMCPServer.swift b/RxCode/Services/IDEServer/IDEMCPServer.swift index 4d44f5e7..4f0d4d93 100644 --- a/RxCode/Services/IDEServer/IDEMCPServer.swift +++ b/RxCode/Services/IDEServer/IDEMCPServer.swift @@ -3,6 +3,42 @@ import Network import RxCodeCore import os +nonisolated enum MCPServerTool: String, CaseIterable, Identifiable, Sendable { + case memorySearch = "memory_search" + case memoryCreate = "memory_create" + case memoryUpdate = "memory_update" + case memoryDelete = "memory_delete" + + var id: String { rawValue } + + var internalName: String { + switch self { + case .memorySearch: "ide__memory_search" + case .memoryCreate: "ide__memory_add" + case .memoryUpdate: "ide__memory_update" + case .memoryDelete: "ide__memory_delete" + } + } + + var title: String { + switch self { + case .memorySearch: "Memory Search" + case .memoryCreate: "Memory Create" + case .memoryUpdate: "Memory Update" + case .memoryDelete: "Memory Delete" + } + } + + var summary: String { + switch self { + case .memorySearch: "Search saved memories and retrieve matching preferences, facts, and decisions." + case .memoryCreate: "Create new durable memories." + case .memoryUpdate: "Update existing durable memories." + case .memoryDelete: "Delete durable memories." + } + } +} + /// Hosts a local MCP server that polyfills missing editor capabilities for /// agents whose native toolset lacks them (currently: ACP's `ask_user` / /// `todos`), and exposes always-IDE-only introspection (running jobs, @@ -18,8 +54,19 @@ actor IDEMCPServer { private static let basePort: UInt16 = 19847 private static let maxPort: UInt16 = 19946 + static let memoryAPIDefaultPort = 19846 + static let memoryAPIEnabledDefaultsKey = "memoryMCPServerEnabled" + static let memoryAPIPortDefaultsKey = "memoryMCPServerPort" + static let exposedToolsDefaultsKey = "memoryMCPServerExposedTools" + static let defaultExposedToolsValue = MCPServerTool.allCases.map(\.rawValue).joined(separator: ",") private static let messageMaxLength = 1 << 20 // 1 MiB private static let logger = Logger(subsystem: "com.claudework", category: "IDEMCPServer") + private static let memoryAPISessionKey = "rxcode-mcp-server" + + private struct ToolExposure: Equatable, Sendable { + let publicName: String + let internalName: String + } // MARK: - Session State @@ -30,7 +77,8 @@ actor IDEMCPServer { let listener: NWListener let sessionKey: String let capabilities: CapabilitySet - var connections: Set = [] + var toolExposures: [ToolExposure]? + var connections: [UUID: NWConnection] = [:] } private var allocations: [String: Allocation] = [:] /// Port → sessionKey, so connection accept can resolve back. @@ -81,18 +129,12 @@ actor IDEMCPServer { if portIndex[candidate] != nil { continue } do { let listener = try makeListener(port: candidate) - var alloc = Allocation( - port: candidate, - listener: listener, - sessionKey: sessionKey, - capabilities: capabilities - ) - _ = alloc allocations[sessionKey] = Allocation( port: candidate, listener: listener, sessionKey: sessionKey, - capabilities: capabilities + capabilities: capabilities, + toolExposures: nil ) portIndex[candidate] = sessionKey listener.newConnectionHandler = { [weak self] connection in @@ -115,7 +157,105 @@ actor IDEMCPServer { guard let alloc = allocations.removeValue(forKey: sessionKey) else { return } portIndex.removeValue(forKey: alloc.port) alloc.listener.cancel() - Self.logger.info("[IDE] released port \(alloc.port) for session=\(sessionKey, privacy: .public)") + for connection in alloc.connections.values { + connection.cancel() + } + Self.logger.info("[IDE] released port \(alloc.port) for session=\(sessionKey, privacy: .public) connections=\(alloc.connections.count)") + } + + // MARK: - Settings-Managed MCP Server + + /// Starts or stops the process-wide MCP listener configured in Settings. + /// It is bound to loopback and intentionally sits outside the ephemeral + /// per-chat port range used by agent sessions. + func configureMemoryAPI(enabled: Bool, port: Int, exposedTools: Set) async throws { + guard enabled else { + await release(sessionKey: Self.memoryAPISessionKey) + return + } + guard Self.isValidMemoryAPIPort(port) else { + throw MemoryAPIError.invalidPort(port) + } + let requestedPort = UInt16(port) + let requestedToolExposures = Self.toolExposures(for: exposedTools) + if let allocation = allocations[Self.memoryAPISessionKey], allocation.port == requestedPort { + guard allocation.toolExposures != requestedToolExposures else { return } + let connections = Array(allocation.connections.values) + allocations[Self.memoryAPISessionKey]?.toolExposures = requestedToolExposures + allocations[Self.memoryAPISessionKey]?.connections.removeAll() + for connection in connections { + connection.cancel() + } + Self.logger.info("[RxCode MCP] updated tools=\(requestedToolExposures.count) revokedConnections=\(connections.count)") + return + } + await release(sessionKey: Self.memoryAPISessionKey) + guard portIndex[requestedPort] == nil else { + throw MemoryAPIError.portUnavailable(port) + } + + do { + let listener = try makeListener(port: requestedPort) + allocations[Self.memoryAPISessionKey] = Allocation( + port: requestedPort, + listener: listener, + sessionKey: Self.memoryAPISessionKey, + capabilities: [], + toolExposures: requestedToolExposures + ) + portIndex[requestedPort] = Self.memoryAPISessionKey + listener.newConnectionHandler = { [weak self] connection in + guard let self else { return } + Task { await self.accept(connection: connection, port: requestedPort) } + } + listener.start(queue: .global(qos: .userInitiated)) + Self.logger.info("[RxCode MCP] listening on 127.0.0.1:\(requestedPort) tools=\(requestedToolExposures.count)") + } catch { + allocations.removeValue(forKey: Self.memoryAPISessionKey) + portIndex.removeValue(forKey: requestedPort) + throw MemoryAPIError.listenerFailed(port, error.localizedDescription) + } + } + + static func isValidMemoryAPIPort(_ port: Int) -> Bool { + (1024...65_535).contains(port) + && !(Int(basePort)...Int(maxPort)).contains(port) + } + + static func exposedTools(from storedValue: String?) -> Set { + guard let storedValue else { return Set(MCPServerTool.allCases) } + return Set(storedValue.split(separator: ",").compactMap { MCPServerTool(rawValue: String($0)) }) + } + + static func storedValue(for exposedTools: Set) -> String { + MCPServerTool.allCases + .filter { exposedTools.contains($0) } + .map(\.rawValue) + .joined(separator: ",") + } + + private static func toolExposures(for tools: Set) -> [ToolExposure] { + MCPServerTool.allCases.compactMap { tool in + guard tools.contains(tool) else { return nil } + return ToolExposure(publicName: tool.rawValue, internalName: tool.internalName) + } + } + + static func memoryAPIConfiguration(port: Int) -> String? { + guard isValidMemoryAPIPort(port), let port = UInt16(exactly: port) else { return nil } + let bridge = bridgeCommand(forPort: port) + let value: [String: Any] = [ + "mcpServers": [ + "rxcode": [ + "command": bridge.command, + "args": bridge.args, + ], + ], + ] + guard let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys]) else { + return nil + } + return String(data: data, encoding: .utf8) } // MARK: - Listener @@ -136,8 +276,9 @@ actor IDEMCPServer { connection.cancel() return } + let toolExposures = allocations[sessionKey]?.toolExposures let id = UUID() - allocations[sessionKey]?.connections.insert(id) + allocations[sessionKey]?.connections[id] = connection connection.stateUpdateHandler = { state in Self.logger.info("[IDE.conn] conn=\(id.uuidString.prefix(8), privacy: .public) session=\(sessionKey, privacy: .public) state=\(String(describing: state), privacy: .public)") } @@ -148,11 +289,12 @@ actor IDEMCPServer { connection: connection, connectionId: id, sessionKey: sessionKey, - capabilities: capabilities + capabilities: capabilities, + toolExposures: toolExposures ) connection.cancel() - allocations[sessionKey]?.connections.remove(id) + allocations[sessionKey]?.connections.removeValue(forKey: id) Self.logger.info("[IDE] closed connection id=\(id.uuidString, privacy: .public)") } @@ -162,7 +304,8 @@ actor IDEMCPServer { connection: NWConnection, connectionId: UUID, sessionKey: String, - capabilities: CapabilitySet + capabilities: CapabilitySet, + toolExposures: [ToolExposure]? ) async { var pendingBuffer = Data() var chunkCount = 0 @@ -172,11 +315,16 @@ actor IDEMCPServer { let lineData = pendingBuffer[pendingBuffer.startIndex.. [IDETool] { + private func currentTools( + sessionKey: String, + capabilities: CapabilitySet, + toolExposures: [ToolExposure]? + ) async -> [IDETool] { + let available: [IDETool] if let handler { - return await handler.ideAvailableTools(forSession: sessionKey) + available = await handler.ideAvailableTools(forSession: sessionKey) + } else { + available = IDEToolRegistry.tools(for: capabilities) + } + guard let toolExposures else { return available } + let byName = Dictionary(uniqueKeysWithValues: available.map { ($0.name, $0) }) + return toolExposures.compactMap { exposure in + guard let tool = byName[exposure.internalName] else { return nil } + return IDETool( + name: exposure.publicName, + description: tool.description, + visibility: tool.visibility, + inputSchema: tool.inputSchema + ) } - return IDEToolRegistry.tools(for: capabilities) } // MARK: - Reply @@ -382,6 +566,7 @@ actor IDEMCPServer { "ide__get_thread_messages", "ide__get_thread_detail", "ide__memory_search", + "memory_search", "ide__get_usage", ] @@ -444,6 +629,23 @@ actor IDEMCPServer { } } +private enum MemoryAPIError: LocalizedError { + case invalidPort(Int) + case portUnavailable(Int) + case listenerFailed(Int, String) + + var errorDescription: String? { + switch self { + case .invalidPort(let port): + return "Port \(port) is invalid. Use 1024–65535, excluding 19847–19946." + case .portUnavailable(let port): + return "Port \(port) is already in use by RxCode." + case .listenerFailed(let port, let message): + return "Could not start the memory MCP server on port \(port): \(message)" + } + } +} + // MARK: - JSONValue <-> Any helpers private extension JSONValue { diff --git a/RxCode/Views/SettingsMemoryViews.swift b/RxCode/Views/SettingsMemoryViews.swift index 25a7c78e..3696f073 100644 --- a/RxCode/Views/SettingsMemoryViews.swift +++ b/RxCode/Views/SettingsMemoryViews.swift @@ -1,3 +1,4 @@ +import AppKit import RxCodeChatKit import RxCodeCore import SwiftUI @@ -10,6 +11,10 @@ struct MemorySettingsSection: View { @State private var editingDraft: MemoryDraft? @State private var showMemoryBrowser = false + @State private var showMCPServerSettings = false + @AppStorage(IDEMCPServer.memoryAPIEnabledDefaultsKey) private var memoryMCPServerEnabled = false + @AppStorage(IDEMCPServer.memoryAPIPortDefaultsKey) private var memoryMCPServerPort = IDEMCPServer.memoryAPIDefaultPort + @AppStorage(IDEMCPServer.exposedToolsDefaultsKey) private var exposedMCPTools = IDEMCPServer.defaultExposedToolsValue var body: some View { VStack(alignment: .leading, spacing: 18) { @@ -23,6 +28,29 @@ struct MemorySettingsSection: View { MemoryBrowserSheet() .environment(appState) } + .sheet(isPresented: $showMCPServerSettings) { + MCPServerSettingsSheet( + isEnabled: $memoryMCPServerEnabled, + port: $memoryMCPServerPort, + storedExposedTools: $exposedMCPTools + ) + .environment(appState) + } + .task { + await configureMCPServer() + } + .onChange(of: memoryMCPServerEnabled) { _, _ in + Task { await configureMCPServer() } + } + .onChange(of: memoryMCPServerPort) { _, _ in + Task { await configureMCPServer() } + } + .onChange(of: exposedMCPTools) { _, _ in + Task { await configureMCPServer() } + } + .onChange(of: appState.memoryEnabled) { _, _ in + Task { await configureMCPServer() } + } } private var controlsSection: some View { @@ -91,8 +119,202 @@ struct MemorySettingsSection: View { Text("Saved memory history is available from Manage.") .font(.system(size: ClaudeTheme.size(11))) .foregroundStyle(.secondary) + + Divider() + + memoryMCPServerSection + } + } + + private var memoryMCPServerSection: some View { + Button { + showMCPServerSettings = true + } label: { + HStack(spacing: 12) { + Image(systemName: "server.rack") + .font(.system(size: ClaudeTheme.size(17))) + .foregroundStyle(ClaudeTheme.accent) + + VStack(alignment: .leading, spacing: 2) { + Text("MCP Server") + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + Text("Choose which RxCode tools are available to other local MCP clients.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 8) + + if appState.memoryMCPServerRunning { + Text("Running") + .foregroundStyle(.green) + } else { + Text("Off") + .foregroundStyle(.secondary) + } + Image(systemName: "chevron.right") + .foregroundStyle(.tertiary) + } + .font(.system(size: ClaudeTheme.size(11))) + .padding(12) + .contentShape(Rectangle()) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + } + + private func configureMCPServer() async { + await appState.configureMemoryMCPServer( + enabled: memoryMCPServerEnabled, + port: memoryMCPServerPort, + exposedTools: IDEMCPServer.exposedTools(from: exposedMCPTools) + ) + } +} + +struct MCPServerSettingsSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + @Binding var isEnabled: Bool + @Binding var port: Int + @Binding var storedExposedTools: String + + @State private var portDraft = "" + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("MCP Server") + .font(.system(size: ClaudeTheme.size(16), weight: .semibold)) + Text("Expose selected RxCode tools to other local MCP clients.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + Spacer() + Button("Done") { dismiss() } + .keyboardShortcut(.defaultAction) + } + + HStack { + Text("Enable MCP Server") + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + Spacer(minLength: 12) + Toggle("", isOn: $isEnabled) + .labelsHidden() + .toggleStyle(.switch) + } + .disabled(!appState.memoryEnabled) + + if !appState.memoryEnabled { + Label("Enable Memory to start the MCP server.", systemImage: "pause.circle") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + + Divider() + + VStack(alignment: .leading, spacing: 10) { + Text("Tools to Expose") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + + ForEach(MCPServerTool.allCases) { tool in + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(tool.title) + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + Text(tool.summary) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 12) + + Toggle("", isOn: binding(for: tool)) + .labelsHidden() + .toggleStyle(.switch) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("Port") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(.secondary) + TextField("Port", text: $portDraft) + .textFieldStyle(.roundedBorder) + .frame(width: 90) + .onSubmit { applyPort() } + Button("Apply") { applyPort() } + .disabled(parsedPort == nil || parsedPort == port) + Spacer() + } + + serverStatus + + Text("Keep RxCode running while another MCP client uses this server. Ports 19847–19946 are reserved for chat sessions.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(20) + .frame(width: 560) + .task { portDraft = String(port) } + .onChange(of: port) { _, newValue in + portDraft = String(newValue) + } + } + + @ViewBuilder + private var serverStatus: some View { + if let error = appState.memoryMCPServerError { + Label(error, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } else if appState.memoryMCPServerRunning { + Label( + "Running on 127.0.0.1:\(port, format: .number.grouping(.never))", + systemImage: "checkmark.circle.fill" + ) + .foregroundStyle(.green) + } else { + Label("Server stopped", systemImage: "circle") + .foregroundStyle(.secondary) } } + + private var parsedPort: Int? { + guard let value = Int(portDraft), IDEMCPServer.isValidMemoryAPIPort(value) else { return nil } + return value + } + + private func binding(for tool: MCPServerTool) -> Binding { + Binding( + get: { IDEMCPServer.exposedTools(from: storedExposedTools).contains(tool) }, + set: { isSelected in + var tools = IDEMCPServer.exposedTools(from: storedExposedTools) + if isSelected { + tools.insert(tool) + } else { + tools.remove(tool) + } + storedExposedTools = IDEMCPServer.storedValue(for: tools) + } + ) + } + + private func applyPort() { + guard let parsedPort else { return } + port = parsedPort + } } struct MemoryBrowserSheet: View { diff --git a/RxCodeTests/MemoryIntentTests.swift b/RxCodeTests/MemoryIntentTests.swift index daac2c6d..36f610ad 100644 --- a/RxCodeTests/MemoryIntentTests.swift +++ b/RxCodeTests/MemoryIntentTests.swift @@ -70,4 +70,44 @@ final class MemoryIntentTests: XCTestCase { XCTAssertTrue(prompt.contains("- id: memory-1\n content: Always run make lint.")) XCTAssertTrue(prompt.contains("If an existing memory should be refined, return an update operation with its id.")) } + + func testMemoryMCPConfigurationUsesLoopbackBridge() throws { + let raw = try XCTUnwrap( + IDEMCPServer.memoryAPIConfiguration(port: IDEMCPServer.memoryAPIDefaultPort) + ) + let data = try XCTUnwrap(raw.data(using: .utf8)) + let root = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + let servers = try XCTUnwrap(root["mcpServers"] as? [String: Any]) + let server = try XCTUnwrap(servers["rxcode"] as? [String: Any]) + + XCTAssertEqual(server["command"] as? String, "/usr/bin/perl") + let args = try XCTUnwrap(server["args"] as? [String]) + XCTAssertEqual(Array(args.suffix(2)), ["127.0.0.1", String(IDEMCPServer.memoryAPIDefaultPort)]) + } + + func testMemoryMCPPortValidationReservesChatListenerRange() { + XCTAssertTrue(IDEMCPServer.isValidMemoryAPIPort(IDEMCPServer.memoryAPIDefaultPort)) + XCTAssertFalse(IDEMCPServer.isValidMemoryAPIPort(19847)) + XCTAssertFalse(IDEMCPServer.isValidMemoryAPIPort(19946)) + XCTAssertFalse(IDEMCPServer.isValidMemoryAPIPort(1023)) + } + + func testMCPServerToolSelectionRoundTripsInStableOrder() { + let selected: Set = [.memoryUpdate, .memorySearch] + + let storedValue = IDEMCPServer.storedValue(for: selected) + + XCTAssertEqual(storedValue, "memory_search,memory_update") + XCTAssertEqual(IDEMCPServer.exposedTools(from: storedValue), selected) + } + + func testMCPServerDefaultsExposeMemorySearchWithoutMemoryGet() { + let exposedTools = IDEMCPServer.exposedTools(from: nil) + + XCTAssertEqual(exposedTools, Set(MCPServerTool.allCases)) + XCTAssertTrue(exposedTools.contains(.memorySearch)) + XCTAssertFalse(IDEMCPServer.defaultExposedToolsValue.contains("memory_get")) + } } From 83b9bcce5d886f10dbaa890d5da1a6b5515db3b5 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:50:38 +0800 Subject: [PATCH 2/2] feat: expose memory mcp --- Packages/Sources/RxCodeSync/SyncClient.swift | 10 +- .../RxCodeSync/Transport/PortMapper.swift | 4 + RxCode.xcodeproj/project.pbxproj | 17 + .../xcshareddata/swiftpm/Package.resolved | 47 ++- RxCode/Resources/Localizable.xcstrings | 6 +- RxCode/Services/IDEServer/IDEMCPServer.swift | 164 +++++++-- RxCode/Services/IDEServer/MCPHTTPServer.swift | 315 ++++++++++++++++++ RxCode/Views/SettingsMemoryViews.swift | 32 +- RxCodeTests/MemoryIntentTests.swift | 19 +- 9 files changed, 564 insertions(+), 50 deletions(-) create mode 100644 RxCode/Services/IDEServer/MCPHTTPServer.swift diff --git a/Packages/Sources/RxCodeSync/SyncClient.swift b/Packages/Sources/RxCodeSync/SyncClient.swift index e4fddf95..042d6d06 100644 --- a/Packages/Sources/RxCodeSync/SyncClient.swift +++ b/Packages/Sources/RxCodeSync/SyncClient.swift @@ -258,15 +258,23 @@ public actor SyncClient: PeerManagerHost { /// schedule a renewal before it expires. No mapping ⇒ WAN-direct simply isn't /// offered and the relay carries traffic. private func refreshWANMapping() async { + guard !Task.isCancelled else { return } guard let port = await advertiser?.listeningPort() else { return } + guard !Task.isCancelled else { return } let mapping = await portMapper.requestMapping(internalPort: port) + guard !Task.isCancelled else { return } currentWANMapping = mapping wanRenewTask?.cancel() guard let mapping, mapping.lifetimeSeconds > 0 else { return } // Renew at half the granted lifetime. let renewAfter = max(60, Int(mapping.lifetimeSeconds) / 2) wanRenewTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(renewAfter) * 1_000_000_000) + do { + try await Task.sleep(nanoseconds: UInt64(renewAfter) * 1_000_000_000) + } catch { + return + } + guard !Task.isCancelled else { return } await self?.refreshWANMapping() } } diff --git a/Packages/Sources/RxCodeSync/Transport/PortMapper.swift b/Packages/Sources/RxCodeSync/Transport/PortMapper.swift index 226ffe9d..2aef12b8 100644 --- a/Packages/Sources/RxCodeSync/Transport/PortMapper.swift +++ b/Packages/Sources/RxCodeSync/Transport/PortMapper.swift @@ -28,17 +28,21 @@ public actor PortMapper { /// Request a public address + TCP mapping for `internalPort`. Returns nil on /// any failure (no gateway, no NAT-PMP support, timeout). public func requestMapping(internalPort: UInt16, lifetime: UInt32 = 3600) async -> PortMapping? { + guard !Task.isCancelled else { return nil } guard let gateway = Self.defaultGatewayIPv4() else { logger.info("[PortMapper] no default gateway found — WAN-direct unavailable") return nil } guard let publicIP = await sendExternalAddressRequest(gateway: gateway) else { + guard !Task.isCancelled else { return nil } logger.info("[PortMapper] gateway \(gateway, privacy: .public) did not answer NAT-PMP — WAN-direct unavailable") return nil } + guard !Task.isCancelled else { return nil } guard let external = await sendMapRequest(gateway: gateway, internalPort: internalPort, lifetime: lifetime) else { return nil } + guard !Task.isCancelled else { return nil } logger.info("[PortMapper] mapped \(internalPort, privacy: .public) → \(publicIP, privacy: .public):\(external.port, privacy: .public) lifetime=\(external.lifetime, privacy: .public)s") return PortMapping(publicIP: publicIP, externalPort: external.port, lifetimeSeconds: external.lifetime) } diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 707143e8..ad6a9950 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -46,6 +46,7 @@ DF46526A2FCD34B3002D9562 /* JSONSchemaValidator in Frameworks */ = {isa = PBXBuildFile; productRef = DF4652692FCD34B3002D9562 /* JSONSchemaValidator */; }; DF46683A2FCDB56B002D9562 /* JSONSchemaForm in Frameworks */ = {isa = PBXBuildFile; productRef = DF4668392FCDB56B002D9562 /* JSONSchemaForm */; }; DF46683C2FCDB56B002D9562 /* JSONSchemaValidator in Frameworks */ = {isa = PBXBuildFile; productRef = DF46683B2FCDB56B002D9562 /* JSONSchemaValidator */; }; + DF98F2373004F38B00BBB638 /* MCP in Frameworks */ = {isa = PBXBuildFile; productRef = DF98F2363004F38B00BBB638 /* MCP */; }; DFA0CCD12FB4CC01005991E1 /* PlanDecisionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */; }; DFA0CCD22FB4CC01005991E1 /* PlanCardViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */; }; DFA0CCD42FB4CC01005991E1 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */; }; @@ -324,6 +325,7 @@ DF462DCB2FC73FA8002D9562 /* RxAuthSwiftUI in Frameworks */, E6D001072FA0000100000001 /* RxCodeSync in Frameworks */, DF462A602FC6EDCE002D9562 /* RxAuthSwift in Frameworks */, + DF98F2373004F38B00BBB638 /* MCP in Frameworks */, DF23FF1D2FBB42F7008929A6 /* WaterfallGrid in Frameworks */, FB0000030000000000000001 /* FirebaseAnalytics in Frameworks */, DF462DC92FC73FA8002D9562 /* RxAuthSwift in Frameworks */, @@ -637,6 +639,7 @@ DF4652672FCD34B3002D9562 /* JSONSchemaForm */, DF4652692FCD34B3002D9562 /* JSONSchemaValidator */, DFAA00022FCD34B3002D9562 /* JSONSchema */, + DF98F2363004F38B00BBB638 /* MCP */, ); productName = RxCode; productReference = E67335382F7356F600FD26C7 /* RxCode.app */; @@ -701,6 +704,7 @@ DF4652662FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-jsonschema-form" */, DFAA00032FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-json-schema" */, DFA8E3B22FD1A27F00F33E7F /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */, + DF98F2353004F38B00BBB638 /* XCRemoteSwiftPackageReference "swift-sdk" */, ); preferredProjectObjectVersion = 77; productRefGroup = E67335392F7356F600FD26C7 /* Products */; @@ -1565,6 +1569,14 @@ kind = branch; }; }; + DF98F2353004F38B00BBB638 /* XCRemoteSwiftPackageReference "swift-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/modelcontextprotocol/swift-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.12.1; + }; + }; DFA8E3B22FD1A27F00F33E7F /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SDWebImage/SDWebImageSVGCoder"; @@ -1703,6 +1715,11 @@ package = DF4652662FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-jsonschema-form" */; productName = JSONSchemaValidator; }; + DF98F2363004F38B00BBB638 /* MCP */ = { + isa = XCSwiftPackageProductDependency; + package = DF98F2353004F38B00BBB638 /* XCRemoteSwiftPackageReference "swift-sdk" */; + productName = MCP; + }; DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */ = { isa = XCSwiftPackageProductDependency; productName = RxCodeChatKit; diff --git a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 485ec6d8..55543e8f 100644 --- a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash": "4e57c3e72b3783f86f3921d55aadf023d63038ca5bc9265e34900dc7724ab193", + "originHash": "41271d02c93dbc18e9be262d16b5eb4573ee0d396c9b773ccf1525c1f6931890", "pins": [ { "identity": "abseil-cpp-binary", @@ -19,6 +19,15 @@ "version": "11.2.0" } }, + { + "identity": "eventsource", + "kind": "remoteSourceControl", + "location": "https://github.com/mattt/eventsource.git", + "state": { + "revision": "a3a85a85214caf642abaa96ae664e4c772a59f6e", + "version": "1.4.1" + } + }, { "identity": "firebase-ios-sdk", "kind": "remoteSourceControl", @@ -163,6 +172,15 @@ "version": "1.7.1" } }, + { + "identity": "swift-atomics", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-atomics.git", + "state": { + "revision": "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version": "1.3.1" + } + }, { "identity": "swift-collections", "kind": "remoteSourceControl", @@ -199,6 +217,15 @@ "version": "1.13.0" } }, + { + "identity": "swift-nio", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-nio.git", + "state": { + "revision": "cd3e1152083706d77b223fb29110e590efcc70c0", + "version": "2.101.2" + } + }, { "identity": "swift-protobuf", "kind": "remoteSourceControl", @@ -208,6 +235,24 @@ "version": "1.38.0" } }, + { + "identity": "swift-sdk", + "kind": "remoteSourceControl", + "location": "https://github.com/modelcontextprotocol/swift-sdk", + "state": { + "revision": "a0ae212ebf6eab5f754c3129608bc5557637e605", + "version": "0.12.1" + } + }, + { + "identity": "swift-system", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-system.git", + "state": { + "revision": "b5544ba79a70a0cb3563e75bf26dc198d6b40ed3", + "version": "1.7.4" + } + }, { "identity": "swiftterm", "kind": "remoteSourceControl", diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index 2316b726..6beca3c4 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -13389,9 +13389,6 @@ }, "Running" : { - }, - "Running on 127.0.0.1:%@" : { - }, "Running swift build" : { "localizations" : { @@ -15707,6 +15704,9 @@ } } } + }, + "Streamable HTTP" : { + }, "streaming stops" : { "extractionState" : "stale", diff --git a/RxCode/Services/IDEServer/IDEMCPServer.swift b/RxCode/Services/IDEServer/IDEMCPServer.swift index 4f0d4d93..485e68f1 100644 --- a/RxCode/Services/IDEServer/IDEMCPServer.swift +++ b/RxCode/Services/IDEServer/IDEMCPServer.swift @@ -1,4 +1,5 @@ import Foundation +import MCP import Network import RxCodeCore import os @@ -68,6 +69,12 @@ actor IDEMCPServer { let internalName: String } + private struct SettingsHTTPServer { + let port: UInt16 + let toolExposures: [ToolExposure] + let server: MCPHTTPServer + } + // MARK: - Session State /// One allocated listener per RxCode session. Created on @@ -81,6 +88,7 @@ actor IDEMCPServer { var connections: [UUID: NWConnection] = [:] } private var allocations: [String: Allocation] = [:] + private var settingsHTTPServer: SettingsHTTPServer? /// Port → sessionKey, so connection accept can resolve back. private var portIndex: [UInt16: String] = [:] private var nextPort: UInt16 = IDEMCPServer.basePort @@ -170,7 +178,7 @@ actor IDEMCPServer { /// per-chat port range used by agent sessions. func configureMemoryAPI(enabled: Bool, port: Int, exposedTools: Set) async throws { guard enabled else { - await release(sessionKey: Self.memoryAPISessionKey) + await stopSettingsHTTPServer() return } guard Self.isValidMemoryAPIPort(port) else { @@ -178,45 +186,60 @@ actor IDEMCPServer { } let requestedPort = UInt16(port) let requestedToolExposures = Self.toolExposures(for: exposedTools) - if let allocation = allocations[Self.memoryAPISessionKey], allocation.port == requestedPort { - guard allocation.toolExposures != requestedToolExposures else { return } - let connections = Array(allocation.connections.values) - allocations[Self.memoryAPISessionKey]?.toolExposures = requestedToolExposures - allocations[Self.memoryAPISessionKey]?.connections.removeAll() - for connection in connections { - connection.cancel() - } - Self.logger.info("[RxCode MCP] updated tools=\(requestedToolExposures.count) revokedConnections=\(connections.count)") + if let settingsHTTPServer, + settingsHTTPServer.port == requestedPort, + settingsHTTPServer.toolExposures == requestedToolExposures + { return } - await release(sessionKey: Self.memoryAPISessionKey) + await stopSettingsHTTPServer() guard portIndex[requestedPort] == nil else { throw MemoryAPIError.portUnavailable(port) } do { - let listener = try makeListener(port: requestedPort) - allocations[Self.memoryAPISessionKey] = Allocation( + let httpServer = try MCPHTTPServer( port: requestedPort, - listener: listener, - sessionKey: Self.memoryAPISessionKey, - capabilities: [], - toolExposures: requestedToolExposures + toolProvider: { [weak self] in + guard let self else { return [] } + return try await self.mcpTools(exposedBy: requestedToolExposures) + }, + toolCaller: { [weak self] name, arguments in + guard let self else { + throw MCPError.internalError("IDE handler is unavailable") + } + return try await self.callMCPTool( + name: name, + arguments: arguments, + exposedBy: requestedToolExposures + ) + } ) - portIndex[requestedPort] = Self.memoryAPISessionKey - listener.newConnectionHandler = { [weak self] connection in - guard let self else { return } - Task { await self.accept(connection: connection, port: requestedPort) } + do { + try await httpServer.start() + } catch { + await httpServer.stop() + throw error } - listener.start(queue: .global(qos: .userInitiated)) - Self.logger.info("[RxCode MCP] listening on 127.0.0.1:\(requestedPort) tools=\(requestedToolExposures.count)") + settingsHTTPServer = SettingsHTTPServer( + port: requestedPort, + toolExposures: requestedToolExposures, + server: httpServer + ) + Self.logger.info("[RxCode MCP] Streamable HTTP listening on http://127.0.0.1:\(requestedPort)/mcp tools=\(requestedToolExposures.count)") } catch { - allocations.removeValue(forKey: Self.memoryAPISessionKey) - portIndex.removeValue(forKey: requestedPort) + await stopSettingsHTTPServer() throw MemoryAPIError.listenerFailed(port, error.localizedDescription) } } + private func stopSettingsHTTPServer() async { + guard let settingsHTTPServer else { return } + self.settingsHTTPServer = nil + await settingsHTTPServer.server.stop() + Self.logger.info("[RxCode MCP] stopped Streamable HTTP server on port \(settingsHTTPServer.port)") + } + static func isValidMemoryAPIPort(_ port: Int) -> Bool { (1024...65_535).contains(port) && !(Int(basePort)...Int(maxPort)).contains(port) @@ -242,13 +265,11 @@ actor IDEMCPServer { } static func memoryAPIConfiguration(port: Int) -> String? { - guard isValidMemoryAPIPort(port), let port = UInt16(exactly: port) else { return nil } - let bridge = bridgeCommand(forPort: port) + guard let endpoint = memoryAPIEndpoint(port: port) else { return nil } let value: [String: Any] = [ "mcpServers": [ "rxcode": [ - "command": bridge.command, - "args": bridge.args, + "url": endpoint.absoluteString, ], ], ] @@ -258,6 +279,11 @@ actor IDEMCPServer { return String(data: data, encoding: .utf8) } + static func memoryAPIEndpoint(port: Int) -> URL? { + guard isValidMemoryAPIPort(port) else { return nil } + return URL(string: "http://127.0.0.1:\(port)/mcp") + } + // MARK: - Listener private func makeListener(port: UInt16) throws -> NWListener { @@ -501,15 +527,53 @@ actor IDEMCPServer { } } + private func mcpTools(exposedBy toolExposures: [ToolExposure]) async throws -> [MCP.Tool] { + let tools = await currentTools( + sessionKey: Self.memoryAPISessionKey, + capabilities: [], + toolExposures: toolExposures + ) + return try tools.map(Self.mcpTool) + } + + private func callMCPTool( + name: String, + arguments: [String: MCP.Value], + exposedBy toolExposures: [ToolExposure] + ) async throws -> MCP.CallTool.Result { + guard let exposure = toolExposures.first(where: { $0.publicName == name }) else { + throw MCPError.methodNotFound("Unknown tool: \(name)") + } + guard let handler else { + throw MCPError.internalError("IDE handler is unavailable") + } + + let encodedArguments = try JSONEncoder().encode(arguments) + let decodedArguments = try JSONDecoder().decode([String: JSONValue].self, from: encodedArguments) + do { + let result = try await handler.ideHandleToolCall( + name: exposure.internalName, + arguments: .object(decodedArguments), + sessionKey: Self.memoryAPISessionKey + ) + return try Self.mcpCallResult(result) + } catch let error as IDEToolError { + switch error { + case .unknownTool: + throw MCPError.methodNotFound(Self.errorMessage(for: error)) + case .invalidArguments: + throw MCPError.invalidParams(Self.errorMessage(for: error)) + case .notSupported: + throw MCPError.serverError(code: -32004, message: Self.errorMessage(for: error)) + case .handlerFailed: + throw MCPError.internalError(Self.errorMessage(for: error)) + } + } + } + // MARK: - Reply private func reply(id: Any, result: Any, on connection: NWConnection) async { - var payload: [String: Any] = [ - "jsonrpc": "2.0", - "id": id, - "result": result - ] - _ = payload let body: [String: Any] = [ "jsonrpc": "2.0", "id": id, @@ -557,6 +621,26 @@ actor IDEMCPServer { return descriptor } + private static func mcpTool(_ tool: IDETool) throws -> MCP.Tool { + let annotations: MCP.Tool.Annotations + if toolAnnotations(for: tool) != nil { + annotations = .init( + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) + } else { + annotations = nil + } + return try MCP.Tool( + name: tool.name, + description: tool.description, + inputSchema: MCP.Value(tool.inputSchema), + annotations: annotations + ) + } + private static func toolAnnotations(for tool: IDETool) -> [String: Any]? { let readOnlyTools: Set = [ "ide__get_running_jobs", @@ -610,6 +694,12 @@ actor IDEMCPServer { ] } + private static func mcpCallResult(_ value: JSONValue) throws -> MCP.CallTool.Result { + let wrapped = wrapToolResult(value) + let data = try JSONSerialization.data(withJSONObject: wrapped) + return try JSONDecoder().decode(MCP.CallTool.Result.self, from: data) + } + private static func errorCode(for error: IDEToolError) -> Int { switch error { case .unknownTool: return -32601 @@ -649,7 +739,7 @@ private enum MemoryAPIError: LocalizedError { // MARK: - JSONValue <-> Any helpers private extension JSONValue { - static func fromAny(_ any: Any) -> JSONValue { + nonisolated static func fromAny(_ any: Any) -> JSONValue { if let s = any as? String { return .string(s) } if let b = any as? Bool { return .bool(b) } if let n = any as? NSNumber { @@ -666,7 +756,7 @@ private extension JSONValue { return .null } - func toAny() -> Any? { + nonisolated func toAny() -> Any? { switch self { case .null: return NSNull() case .bool(let b): return b diff --git a/RxCode/Services/IDEServer/MCPHTTPServer.swift b/RxCode/Services/IDEServer/MCPHTTPServer.swift new file mode 100644 index 00000000..289c7917 --- /dev/null +++ b/RxCode/Services/IDEServer/MCPHTTPServer.swift @@ -0,0 +1,315 @@ +import Foundation +import MCP +import Network +import os + +/// Small loopback HTTP/1.1 adapter for the MCP Swift SDK's framework-agnostic +/// stateless Streamable HTTP transport. +actor MCPHTTPServer { + typealias ToolProvider = @Sendable () async throws -> [MCP.Tool] + typealias ToolCaller = @Sendable (String, [String: MCP.Value]) async throws -> MCP.CallTool.Result + + private static let headerLimit = 64 * 1024 + private static let bodyLimit = 1 << 20 + private static let headerTerminator = Data("\r\n\r\n".utf8) + private static let logger = Logger(subsystem: "com.claudework", category: "MCPHTTPServer") + + let port: UInt16 + + private let listener: NWListener + private let transport: StatelessHTTPServerTransport + private let server: Server + private let toolProvider: ToolProvider + private let toolCaller: ToolCaller + private var connections: [UUID: NWConnection] = [:] + private var startContinuation: CheckedContinuation? + + init(port: UInt16, toolProvider: @escaping ToolProvider, toolCaller: @escaping ToolCaller) throws { + let parameters = NWParameters.tcp + parameters.allowLocalEndpointReuse = true + parameters.requiredInterfaceType = .loopback + + self.port = port + self.listener = try NWListener(using: parameters, on: NWEndpoint.Port(rawValue: port)!) + self.transport = StatelessHTTPServerTransport() + self.server = Server( + name: "rxcode", + version: "1", + capabilities: .init(tools: .init(listChanged: false)) + ) + self.toolProvider = toolProvider + self.toolCaller = toolCaller + } + + func start() async throws { + let toolProvider = toolProvider + let toolCaller = toolCaller + + await server.withMethodHandler(ListTools.self) { _ in + let tools = try await toolProvider() + return ListTools.Result(tools: tools) + } + await server.withMethodHandler(CallTool.self) { parameters in + try await toolCaller(parameters.name, parameters.arguments ?? [:]) + } + try await server.start(transport: transport) + + listener.stateUpdateHandler = { [weak self] state in + guard let self else { return } + Task { await self.handleListenerState(state) } + } + listener.newConnectionHandler = { [weak self] connection in + guard let self else { return } + Task { await self.accept(connection) } + } + try await withCheckedThrowingContinuation { continuation in + startContinuation = continuation + listener.start(queue: .global(qos: .userInitiated)) + } + } + + func stop() async { + if let startContinuation { + self.startContinuation = nil + startContinuation.resume(throwing: CancellationError()) + } + listener.cancel() + let activeConnections = Array(connections.values) + connections.removeAll() + for connection in activeConnections { + connection.cancel() + } + await server.stop() + } + + private func handleListenerState(_ state: NWListener.State) { + switch state { + case .ready: + Self.logger.info("[RxCode MCP HTTP] ready on http://127.0.0.1:\(self.port)/mcp") + startContinuation?.resume() + startContinuation = nil + case .failed(let error): + Self.logger.error("[RxCode MCP HTTP] listener failed: \(error.localizedDescription, privacy: .public)") + startContinuation?.resume(throwing: error) + startContinuation = nil + case .cancelled: + startContinuation?.resume(throwing: CancellationError()) + startContinuation = nil + default: + break + } + } + + private func accept(_ connection: NWConnection) async { + let id = UUID() + connections[id] = connection + connection.start(queue: .global(qos: .userInitiated)) + + await serve(connection) + + connection.cancel() + connections.removeValue(forKey: id) + } + + private func serve(_ connection: NWConnection) async { + var buffer = Data() + + while true { + do { + while let parsed = try Self.parseRequest(from: &buffer) { + let response: MCP.HTTPResponse + if parsed.request.path != "/mcp" { + response = .error(statusCode: 404, .invalidRequest("Not Found")) + } else { + response = await transport.handleRequest(parsed.request) + } + + try await send(response, closeConnection: parsed.closeConnection, on: connection) + if parsed.closeConnection { return } + } + + let chunk = try await receiveChunk(from: connection) + if chunk.isEmpty { return } + buffer.append(chunk) + if buffer.count > Self.headerLimit + Self.bodyLimit { + try await sendError(statusCode: 413, message: "Payload Too Large", on: connection) + return + } + } catch let error as HTTPParsingError { + try? await sendError(statusCode: error.statusCode, message: error.message, on: connection) + return + } catch { + Self.logger.debug("[RxCode MCP HTTP] connection ended: \(error.localizedDescription, privacy: .public)") + return + } + } + } + + private static func parseRequest(from buffer: inout Data) throws -> ParsedRequest? { + guard let headerRange = buffer.range(of: headerTerminator) else { + if buffer.count > headerLimit { + throw HTTPParsingError(statusCode: 431, message: "Request Header Fields Too Large") + } + return nil + } + + let headerData = buffer[..= 0 else { + throw HTTPParsingError(statusCode: 400, message: "Invalid Content-Length") + } + contentLength = parsedLength + } else { + contentLength = 0 + } + guard contentLength <= bodyLimit else { + throw HTTPParsingError(statusCode: 413, message: "Payload Too Large") + } + + let bodyStart = headerRange.upperBound + let requestEnd = bodyStart + contentLength + guard buffer.count >= requestEnd else { return nil } + + let body = contentLength == 0 ? nil : Data(buffer[bodyStart.. Data { + try await withCheckedThrowingContinuation { continuation in + connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { data, _, isComplete, error in + if let error { + continuation.resume(throwing: error) + } else if let data, !data.isEmpty { + continuation.resume(returning: data) + } else if isComplete { + continuation.resume(returning: Data()) + } else { + continuation.resume(returning: Data()) + } + } + } + } + + private func send( + _ response: MCP.HTTPResponse, + closeConnection: Bool, + on connection: NWConnection + ) async throws { + guard case .stream = response else { + let body = response.bodyData ?? Data() + var headers = response.headers + headers["Content-Length"] = String(body.count) + headers["Connection"] = closeConnection ? "close" : "keep-alive" + + var responseText = "HTTP/1.1 \(response.statusCode) \(Self.reasonPhrase(for: response.statusCode))\r\n" + for (name, value) in headers.sorted(by: { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending }) { + responseText += "\(name): \(value)\r\n" + } + responseText += "\r\n" + + var bytes = Data(responseText.utf8) + bytes.append(body) + try await send(bytes, on: connection) + return + } + + try await sendError(statusCode: 501, message: "Streaming responses are not enabled", on: connection) + } + + private func sendError(statusCode: Int, message: String, on connection: NWConnection) async throws { + let body = try JSONSerialization.data(withJSONObject: ["error": message]) + let responseText = """ + HTTP/1.1 \(statusCode) \(Self.reasonPhrase(for: statusCode))\r + Content-Type: application/json\r + Content-Length: \(body.count)\r + Connection: close\r + \r + """ + var bytes = Data(responseText.utf8) + bytes.append(body) + try await send(bytes, on: connection) + } + + private func send(_ bytes: Data, on connection: NWConnection) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + connection.send(content: bytes, completion: .contentProcessed { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + }) + } + } + + private static func reasonPhrase(for statusCode: Int) -> String { + switch statusCode { + case 200: "OK" + case 202: "Accepted" + case 400: "Bad Request" + case 404: "Not Found" + case 405: "Method Not Allowed" + case 406: "Not Acceptable" + case 413: "Payload Too Large" + case 415: "Unsupported Media Type" + case 431: "Request Header Fields Too Large" + case 500: "Internal Server Error" + case 501: "Not Implemented" + default: "Error" + } + } + + private struct ParsedRequest { + let request: MCP.HTTPRequest + let closeConnection: Bool + } + + private struct HTTPParsingError: Error { + let statusCode: Int + let message: String + } +} diff --git a/RxCode/Views/SettingsMemoryViews.swift b/RxCode/Views/SettingsMemoryViews.swift index 3696f073..303b432c 100644 --- a/RxCode/Views/SettingsMemoryViews.swift +++ b/RxCode/Views/SettingsMemoryViews.swift @@ -258,6 +258,28 @@ struct MCPServerSettingsSheet: View { Spacer() } + if let endpoint = IDEMCPServer.memoryAPIEndpoint(port: port) { + LabeledContent("Transport") { + Text("Streamable HTTP") + } + .font(.system(size: ClaudeTheme.size(11))) + + LabeledContent("Endpoint") { + HStack(spacing: 8) { + Text(verbatim: endpoint.absoluteString) + .textSelection(.enabled) + Button { + copyEndpoint(endpoint) + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + .help("Copy") + } + } + .font(.system(size: ClaudeTheme.size(11))) + } + serverStatus Text("Keep RxCode running while another MCP client uses this server. Ports 19847–19946 are reserved for chat sessions.") @@ -280,10 +302,7 @@ struct MCPServerSettingsSheet: View { Label(error, systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.red) } else if appState.memoryMCPServerRunning { - Label( - "Running on 127.0.0.1:\(port, format: .number.grouping(.never))", - systemImage: "checkmark.circle.fill" - ) + Label("Running", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) } else { Label("Server stopped", systemImage: "circle") @@ -315,6 +334,11 @@ struct MCPServerSettingsSheet: View { guard let parsedPort else { return } port = parsedPort } + + private func copyEndpoint(_ endpoint: URL) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(endpoint.absoluteString, forType: .string) + } } struct MemoryBrowserSheet: View { diff --git a/RxCodeTests/MemoryIntentTests.swift b/RxCodeTests/MemoryIntentTests.swift index 36f610ad..1e10cfe2 100644 --- a/RxCodeTests/MemoryIntentTests.swift +++ b/RxCodeTests/MemoryIntentTests.swift @@ -71,7 +71,7 @@ final class MemoryIntentTests: XCTestCase { XCTAssertTrue(prompt.contains("If an existing memory should be refined, return an update operation with its id.")) } - func testMemoryMCPConfigurationUsesLoopbackBridge() throws { + func testMemoryMCPConfigurationUsesLoopbackHTTPURL() throws { let raw = try XCTUnwrap( IDEMCPServer.memoryAPIConfiguration(port: IDEMCPServer.memoryAPIDefaultPort) ) @@ -82,9 +82,20 @@ final class MemoryIntentTests: XCTestCase { let servers = try XCTUnwrap(root["mcpServers"] as? [String: Any]) let server = try XCTUnwrap(servers["rxcode"] as? [String: Any]) - XCTAssertEqual(server["command"] as? String, "/usr/bin/perl") - let args = try XCTUnwrap(server["args"] as? [String]) - XCTAssertEqual(Array(args.suffix(2)), ["127.0.0.1", String(IDEMCPServer.memoryAPIDefaultPort)]) + XCTAssertEqual( + server["url"] as? String, + "http://127.0.0.1:\(IDEMCPServer.memoryAPIDefaultPort)/mcp" + ) + XCTAssertNil(server["command"]) + XCTAssertNil(server["args"]) + } + + func testMemoryMCPEndpointRejectsReservedPort() { + XCTAssertEqual( + IDEMCPServer.memoryAPIEndpoint(port: IDEMCPServer.memoryAPIDefaultPort)?.absoluteString, + "http://127.0.0.1:19846/mcp" + ) + XCTAssertNil(IDEMCPServer.memoryAPIEndpoint(port: 19847)) } func testMemoryMCPPortValidationReservesChatListenerRange() {