diff --git a/Sources/PalmierPro/Agent/MCP/MCPHTTPServer.swift b/Sources/PalmierPro/Agent/MCP/MCPHTTPServer.swift index e3b8a4950..9da2cdf51 100644 --- a/Sources/PalmierPro/Agent/MCP/MCPHTTPServer.swift +++ b/Sources/PalmierPro/Agent/MCP/MCPHTTPServer.swift @@ -5,25 +5,35 @@ import Network /// HTTP server for MCP. Each TCP connection gets its own `Server` + `Transport` pair. actor MCPHTTPServer { - private let port: UInt16 + nonisolated static let loopbackHost = "127.0.0.1" + + private nonisolated let port: UInt16 + private nonisolated let bindHost: String + private nonisolated let bearerToken: String private let makeServer: @Sendable () async -> Server private nonisolated(unsafe) var listener: NWListener? - init(port: UInt16, makeServer: @escaping @Sendable () async -> Server) { + init( + port: UInt16, + bindHost: String = MCPHTTPServer.loopbackHost, + bearerToken: String = "", + makeServer: @escaping @Sendable () async -> Server + ) { self.port = port + self.bindHost = Self.normalizedBindHost(bindHost) + self.bearerToken = bearerToken self.makeServer = makeServer } func start() throws { - Log.mcp.info("listener start port=\(self.port)") + Log.mcp.info("listener start host=\(self.bindHost) port=\(self.port)") guard let endpointPort = NWEndpoint.Port(rawValue: port) else { Log.mcp.fault("invalid port \(self.port)") throw NSError(domain: "MCPHTTPServer", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid port \(port)"]) } let params = NWParameters.tcp params.allowLocalEndpointReuse = true - // Bind to IPv4 loopback only so the server is never reachable from the LAN. - params.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: endpointPort) + params.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host(bindHost), port: endpointPort) listener = try NWListener(using: params) listener?.newConnectionHandler = { [weak self] connection in @@ -43,11 +53,7 @@ actor MCPHTTPServer { // MARK: - Connection private func handleConnection(_ connection: NWConnection) async { - let pipeline = StandardValidationPipeline(validators: [ - OriginValidator.localhost(port: Int(port)), - ContentTypeValidator(), - ProtocolVersionValidator(), - ]) + let pipeline = Self.validationPipeline(port: port, bindHost: bindHost, bearerToken: bearerToken) let transport = StatelessHTTPServerTransport(validationPipeline: pipeline) let server = await makeServer() try? await server.start(transport: transport) @@ -70,7 +76,12 @@ actor MCPHTTPServer { } if request.path == "/.well-known/oauth-protected-resource" { - let body = "{\"resource\":\"http://127.0.0.1:\(port)\"}" + if let rejection = accessRejection(for: request) { + writeResponse(rejection, on: connection, transport: transport, keepAlive: false) + return + } + + let body = "{\"resource\":\"http://\(Self.advertisedHost(forBindHost: bindHost)):\(port)\"}" sendRaw("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)", on: connection, keepAlive: true) receive(on: connection, transport: transport) return @@ -81,6 +92,11 @@ actor MCPHTTPServer { return } + if let rejection = accessRejection(for: request) { + writeResponse(rejection, on: connection, transport: transport, keepAlive: false) + return + } + if request.method.uppercased() == "GET" { sendRaw("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\n\r\n: connected\n\n", on: connection, keepAlive: true) return @@ -90,16 +106,24 @@ actor MCPHTTPServer { writeResponse(mcpResponse, on: connection, transport: transport) } - private func writeResponse(_ response: HTTPResponse, on connection: NWConnection, transport: StatelessHTTPServerTransport) { + private func writeResponse(_ response: HTTPResponse, on connection: NWConnection, transport: StatelessHTTPServerTransport, keepAlive: Bool = true) { var head = "HTTP/1.1 \(response.statusCode) \(statusText(response.statusCode))\r\n" for (k, v) in response.headers { head += "\(k): \(v)\r\n" } - head += "Content-Length: \(response.bodyData?.count ?? 0)\r\nConnection: keep-alive\r\n\r\n" + head += "Content-Length: \(response.bodyData?.count ?? 0)\r\nConnection: \(keepAlive ? "keep-alive" : "close")\r\n\r\n" var responseData = head.data(using: .utf8)! if let bodyData = response.bodyData { responseData.append(bodyData) } - connection.send(content: responseData, completion: .contentProcessed { _ in }) - receive(on: connection, transport: transport) + connection.send(content: responseData, completion: .contentProcessed { _ in + if !keepAlive { connection.cancel() } + }) + if keepAlive { receive(on: connection, transport: transport) } + } + + private nonisolated func accessRejection(for request: HTTPRequest) -> HTTPResponse? { + guard Self.requiresBearerToken(bindHost: bindHost) else { return nil } + return MCPBearerTokenValidator(expectedToken: bearerToken) + .validate(request, context: .init(httpMethod: request.method.uppercased())) } // MARK: - HTTP Parsing @@ -139,8 +163,103 @@ actor MCPHTTPServer { private nonisolated func statusText(_ code: Int) -> String { switch code { case 200: "OK"; case 202: "Accepted"; case 400: "Bad Request" - case 404: "Not Found"; case 405: "Method Not Allowed"; case 500: "Internal Server Error" + case 401: "Unauthorized"; case 403: "Forbidden"; case 404: "Not Found" + case 405: "Method Not Allowed"; case 406: "Not Acceptable" + case 415: "Unsupported Media Type"; case 421: "Misdirected Request" + case 500: "Internal Server Error" default: "Unknown" } } + + // MARK: - Binding and validation + + nonisolated static func normalizedBindHost(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return loopbackHost } + guard trimmed == "localhost" || isValidIPv4Host(trimmed) else { return loopbackHost } + return trimmed + } + + nonisolated static func requiresBearerToken(bindHost: String) -> Bool { + !isLoopbackHost(normalizedBindHost(bindHost)) + } + + nonisolated static func advertisedHost(forBindHost bindHost: String) -> String { + let host = normalizedBindHost(bindHost) + guard host == "0.0.0.0" else { return host } + let hostName = ProcessInfo.processInfo.hostName + return hostName.isEmpty ? host : hostName + } + + nonisolated static func validationPipeline(port: UInt16, bindHost: String, bearerToken: String) -> StandardValidationPipeline { + let host = normalizedBindHost(bindHost) + var validators: [any HTTPRequestValidator] = [] + if requiresBearerToken(bindHost: host) { + validators.append(MCPBearerTokenValidator(expectedToken: bearerToken)) + validators.append(OriginValidator.disabled) + } else { + validators.append(OriginValidator.localhost(port: Int(port))) + } + validators.append(ContentTypeValidator()) + validators.append(ProtocolVersionValidator()) + return StandardValidationPipeline(validators: validators) + } + + private nonisolated static func isLoopbackHost(_ host: String) -> Bool { + host == loopbackHost || host == "localhost" + } + + private nonisolated static func isValidIPv4Host(_ host: String) -> Bool { + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return false } + return parts.allSatisfy { part in + guard !part.isEmpty, part.allSatisfy(\.isNumber), let octet = Int(part) else { return false } + return (0...255).contains(octet) + } + } +} + +struct MCPBearerTokenValidator: HTTPRequestValidator { + let expectedToken: String + + func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? { + guard let authorization = request.header("Authorization"), + let token = bearerToken(from: authorization), + tokenMatches(token) else { + return unauthorizedResponse(sessionID: context.sessionID) + } + return nil + } + + private func bearerToken(from header: String) -> String? { + let parts = header.trimmingCharacters(in: .whitespacesAndNewlines) + .split(maxSplits: 1, whereSeparator: { $0.isWhitespace }) + guard parts.count == 2, String(parts[0]).caseInsensitiveCompare("Bearer") == .orderedSame else { + return nil + } + let token = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty, !token.contains(where: \.isWhitespace) else { return nil } + return token + } + + private func tokenMatches(_ token: String) -> Bool { + let expected = Array(expectedToken.utf8) + let actual = Array(token.utf8) + var difference = expected.count ^ actual.count + for index in 0.. HTTPResponse { + .error( + statusCode: 401, + .invalidRequest("Unauthorized"), + sessionID: sessionID, + extraHeaders: ["WWW-Authenticate": "Bearer"] + ) + } } diff --git a/Sources/PalmierPro/Agent/MCP/MCPService.swift b/Sources/PalmierPro/Agent/MCP/MCPService.swift index c51ac60a6..b1ec21506 100644 --- a/Sources/PalmierPro/Agent/MCP/MCPService.swift +++ b/Sources/PalmierPro/Agent/MCP/MCPService.swift @@ -1,5 +1,6 @@ import Foundation import MCP +import Security /// HTTP adapter. Tool handling lives in `ToolExecutor`. @Observable @@ -7,8 +8,12 @@ import MCP final class MCPService { static let port: UInt16 = 19789 + static let loopbackBindHost = MCPHTTPServer.loopbackHost + static let lanBindHost = "0.0.0.0" private static let enabledKey = "io.palmier.pro.mcp.enabled" + private static let bindHostKey = "io.palmier.pro.mcp.bindHost" + private static let bearerTokenKey = "io.palmier.pro.mcp.bearerToken" static var isEnabledPreference: Bool { get { @@ -21,6 +26,37 @@ final class MCPService { } } + static var bindHostPreference: String { + get { + MCPHTTPServer.normalizedBindHost(UserDefaults.standard.string(forKey: bindHostKey) ?? loopbackBindHost) + } + set { + UserDefaults.standard.set(MCPHTTPServer.normalizedBindHost(newValue), forKey: bindHostKey) + } + } + + static var bearerTokenPreference: String { + let defaults = UserDefaults.standard + if let token = defaults.string(forKey: bearerTokenKey), !token.isEmpty { + return token + } + return regenerateBearerTokenPreference() + } + + static func regenerateBearerTokenPreference() -> String { + let token = makeBearerToken() + UserDefaults.standard.set(token, forKey: bearerTokenKey) + return token + } + + static var connectionHost: String { + MCPHTTPServer.advertisedHost(forBindHost: bindHostPreference) + } + + static var connectionURL: String { + "http://\(connectionHost):\(port)/mcp" + } + private(set) var isRunning: Bool = false @ObservationIgnored @@ -33,7 +69,9 @@ final class MCPService { } func start() { - let httpServer = MCPHTTPServer(port: Self.port) { [weak self] in + let bindHost = Self.bindHostPreference + let bearerToken = MCPHTTPServer.requiresBearerToken(bindHost: bindHost) ? Self.bearerTokenPreference : "" + let httpServer = MCPHTTPServer(port: Self.port, bindHost: bindHost, bearerToken: bearerToken) { [weak self] in let server = Server( name: "palmier-pro", version: "1.0.0", @@ -51,21 +89,24 @@ final class MCPService { Task { @MainActor [weak self] in do { try await httpServer.start() - Log.mcp.notice("http server started port=\(Self.port)") - self?.isRunning = true + guard let self, self.httpServer === httpServer else { return } + Log.mcp.notice("http server started host=\(bindHost) port=\(Self.port)") + self.isRunning = true } catch { + guard let self, self.httpServer === httpServer else { return } Log.mcp.error("http server failed to start: \(error.localizedDescription)") - self?.isRunning = false + self.isRunning = false } } } - func stop() { - if let server = httpServer { - Task { await server.stop() } - } + func stop() async { + let server = httpServer httpServer = nil isRunning = false + if let server { + await server.stop() + } Log.mcp.notice("http server stopped") } @@ -132,4 +173,20 @@ final class MCPService { } } + private static func makeBearerToken() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + let count = bytes.count + let status = bytes.withUnsafeMutableBytes { buffer in + SecRandomCopyBytes(kSecRandomDefault, count, buffer.baseAddress!) + } + guard status == errSecSuccess else { + return (0..<4).map { _ in UUID().uuidString.replacingOccurrences(of: "-", with: "") }.joined() + } + return Data(bytes) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + } diff --git a/Sources/PalmierPro/App/AppState.swift b/Sources/PalmierPro/App/AppState.swift index f73c76e2e..91e609fe0 100644 --- a/Sources/PalmierPro/App/AppState.swift +++ b/Sources/PalmierPro/App/AppState.swift @@ -28,8 +28,11 @@ final class AppState { } func stopMCPService() { - mcpService?.stop() + guard let service = mcpService else { return } mcpService = nil + Task { @MainActor in + await service.stop() + } } func setMCPEnabled(_ enabled: Bool) { @@ -41,6 +44,29 @@ final class AppState { } } + func setMCPHost(_ host: String) { + let normalizedHost = MCPHTTPServer.normalizedBindHost(host) + guard MCPService.bindHostPreference != normalizedHost else { return } + MCPService.bindHostPreference = normalizedHost + restartMCPServiceIfNeeded() + } + + @discardableResult + func regenerateMCPToken() -> String { + let token = MCPService.regenerateBearerTokenPreference() + restartMCPServiceIfNeeded() + return token + } + + private func restartMCPServiceIfNeeded() { + guard MCPService.isEnabledPreference, let service = mcpService else { return } + mcpService = nil + Task { @MainActor [weak self] in + await service.stop() + self?.startMCPService() + } + } + func showHome() { guard let project = activeProject else { HomeWindowController.shared.showWindow(nil) diff --git a/Sources/PalmierPro/Settings/AgentPane.swift b/Sources/PalmierPro/Settings/AgentPane.swift index 727ccd9c9..3d77372a5 100644 --- a/Sources/PalmierPro/Settings/AgentPane.swift +++ b/Sources/PalmierPro/Settings/AgentPane.swift @@ -6,6 +6,8 @@ struct AgentPane: View { @State private var hasKey: Bool = false @State private var maskedKey: String = "" @State private var draft: String = "" + @State private var mcpBindHost: String = MCPService.bindHostPreference + @State private var mcpBearerToken: String = "" @FocusState private var isFocused: Bool private let consoleURL = URL(string: "https://console.anthropic.com/settings/keys")! @@ -108,6 +110,7 @@ struct AgentPane: View { } private func refresh() { + refreshMCPSettings() Task { @MainActor in let key = await Self.loadKey() applyKey(key) @@ -159,6 +162,8 @@ struct AgentPane: View { VStack(alignment: .leading, spacing: AppTheme.Spacing.smMd) { mcpHeader mcpStatusRow + mcpNetworkAccessRow + mcpConnectionBox } } @@ -200,7 +205,7 @@ struct AgentPane: View { HStack(alignment: .firstTextBaseline, spacing: 0) { Text("Running on ") .foregroundStyle(AppTheme.Text.secondaryColor) - Text("127.0.0.1:\(String(MCPService.port))") + Text(mcpServerAddress) .font(.system(size: AppTheme.FontSize.sm, design: .monospaced)) .foregroundStyle(AppTheme.Text.primaryColor) } @@ -236,6 +241,137 @@ struct AgentPane: View { ) } + private var mcpNetworkAccessRow: some View { + HStack(spacing: AppTheme.Spacing.md) { + VStack(alignment: .leading, spacing: AppTheme.Spacing.xs) { + Text("Allow access from local network") + .font(.system(size: AppTheme.FontSize.sm, weight: .medium)) + .foregroundStyle(AppTheme.Text.primaryColor) + Text("Requires the bearer token for every LAN request.") + .font(.system(size: AppTheme.FontSize.sm)) + .foregroundStyle(AppTheme.Text.tertiaryColor) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + Toggle( + "", + isOn: Binding( + get: { mcpLANAccessEnabled }, + set: setMCPLANAccess + ) + ) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + } + .padding(.horizontal, AppTheme.Spacing.md) + .padding(.vertical, AppTheme.Spacing.smMd) + .background( + RoundedRectangle(cornerRadius: AppTheme.Radius.sm) + .fill(Color.black.opacity(AppTheme.Opacity.muted)) + ) + .overlay( + RoundedRectangle(cornerRadius: AppTheme.Radius.sm) + .strokeBorder(AppTheme.Border.subtleColor, lineWidth: AppTheme.BorderWidth.thin) + ) + } + + private var mcpConnectionBox: some View { + VStack(alignment: .leading, spacing: AppTheme.Spacing.smMd) { + mcpValueRow(label: "Connection URL", value: mcpConnectionURL) + + if mcpLANAccessEnabled { + Divider().overlay(AppTheme.Border.subtleColor) + mcpValueRow(label: "Bearer token", value: mcpBearerToken) + Button("Regenerate Token", action: regenerateMCPToken) + .buttonStyle(.capsule(.secondary, size: .regular)) + .controlSize(.large) + } + } + .padding(.horizontal, AppTheme.Spacing.md) + .padding(.vertical, AppTheme.Spacing.smMd) + .background( + RoundedRectangle(cornerRadius: AppTheme.Radius.sm) + .fill(Color.black.opacity(AppTheme.Opacity.muted)) + ) + .overlay( + RoundedRectangle(cornerRadius: AppTheme.Radius.sm) + .strokeBorder(AppTheme.Border.subtleColor, lineWidth: AppTheme.BorderWidth.thin) + ) + } + + private func mcpValueRow(label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: AppTheme.Spacing.xs) { + HStack(spacing: AppTheme.Spacing.sm) { + Text(label) + .font(.system(size: AppTheme.FontSize.xs, weight: .semibold)) + .foregroundStyle(AppTheme.Text.tertiaryColor) + .textCase(.uppercase) + Spacer() + mcpCopyButton(value: value) + } + + Text(value) + .font(.system(size: AppTheme.FontSize.sm, design: .monospaced)) + .foregroundStyle(AppTheme.Text.primaryColor) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + + private func mcpCopyButton(value: String) -> some View { + Button(action: { copyToPasteboard(value) }) { + Image(systemName: "doc.on.doc") + .font(.system(size: AppTheme.FontSize.sm, weight: .medium)) + .foregroundStyle(AppTheme.Text.secondaryColor) + .frame(width: AppTheme.IconSize.lg, height: AppTheme.IconSize.lg) + .hoverHighlight() + } + .buttonStyle(.plain) + .help("Copy") + } + + private var mcpLANAccessEnabled: Bool { + MCPHTTPServer.requiresBearerToken(bindHost: mcpBindHost) + } + + private var mcpServerAddress: String { + "\(mcpConnectionHost):\(MCPService.port)" + } + + private var mcpConnectionURL: String { + "http://\(mcpServerAddress)/mcp" + } + + private var mcpConnectionHost: String { + mcpBindHost == MCPService.lanBindHost ? MCPService.connectionHost : mcpBindHost + } + + private func refreshMCPSettings() { + mcpBindHost = MCPService.bindHostPreference + mcpBearerToken = mcpLANAccessEnabled ? MCPService.bearerTokenPreference : "" + } + + private func setMCPLANAccess(_ enabled: Bool) { + let host = enabled ? MCPService.lanBindHost : MCPService.loopbackBindHost + mcpBindHost = host + appState.setMCPHost(host) + mcpBearerToken = enabled ? MCPService.bearerTokenPreference : "" + } + + private func regenerateMCPToken() { + mcpBearerToken = appState.regenerateMCPToken() + } + + private func copyToPasteboard(_ value: String) { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(value, forType: .string) + } + private func openInstructions() { HelpWindowController.shared.show(tab: .mcp) } diff --git a/Tests/PalmierProTests/Agent/MCPHTTPServerTests.swift b/Tests/PalmierProTests/Agent/MCPHTTPServerTests.swift new file mode 100644 index 000000000..ea2d1ff71 --- /dev/null +++ b/Tests/PalmierProTests/Agent/MCPHTTPServerTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +@testable import PalmierPro + +@Suite("MCP host binding and auth gating") +struct MCPHTTPServerTests { + + @Test func defaultsToLoopbackWhenHostIsBlank() { + #expect(MCPHTTPServer.normalizedBindHost("127.0.0.1") == "127.0.0.1") + #expect(MCPHTTPServer.normalizedBindHost("") == "127.0.0.1") + #expect(MCPHTTPServer.normalizedBindHost(" ") == "127.0.0.1") + } + + @Test func acceptsLocalhostAndValidIPv4() { + #expect(MCPHTTPServer.normalizedBindHost("localhost") == "localhost") + #expect(MCPHTTPServer.normalizedBindHost("0.0.0.0") == "0.0.0.0") + #expect(MCPHTTPServer.normalizedBindHost("192.168.1.10") == "192.168.1.10") + } + + @Test func trimsAndLowercasesHost() { + #expect(MCPHTTPServer.normalizedBindHost(" 192.168.1.10 ") == "192.168.1.10") + #expect(MCPHTTPServer.normalizedBindHost("LOCALHOST") == "localhost") + } + + @Test func rejectsMalformedHostsBackToLoopback() { + #expect(MCPHTTPServer.normalizedBindHost("999.1.1.1") == "127.0.0.1") // octet out of range + #expect(MCPHTTPServer.normalizedBindHost("10.0.0") == "127.0.0.1") // too few octets + #expect(MCPHTTPServer.normalizedBindHost("10.0.0.1.5") == "127.0.0.1") // too many octets + #expect(MCPHTTPServer.normalizedBindHost("10.0.0.") == "127.0.0.1") // empty trailing octet + #expect(MCPHTTPServer.normalizedBindHost("example.com") == "127.0.0.1") // non-IPv4 hostname + } + + @Test func loopbackHostsDoNotRequireBearerToken() { + #expect(MCPHTTPServer.requiresBearerToken(bindHost: "127.0.0.1") == false) + #expect(MCPHTTPServer.requiresBearerToken(bindHost: "localhost") == false) + #expect(MCPHTTPServer.requiresBearerToken(bindHost: "") == false) // blank normalizes to loopback + } + + @Test func nonLoopbackHostsRequireBearerToken() { + #expect(MCPHTTPServer.requiresBearerToken(bindHost: "0.0.0.0") == true) + #expect(MCPHTTPServer.requiresBearerToken(bindHost: "192.168.1.10") == true) + } +}