Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
151 changes: 135 additions & 16 deletions Sources/PalmierPro/Agent/MCP/MCPHTTPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -81,6 +92,11 @@ actor MCPHTTPServer {
return
}

if let rejection = accessRejection(for: request) {
writeResponse(rejection, on: connection, transport: transport, keepAlive: false)
return
}
Comment thread
cursor[bot] marked this conversation as resolved.

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
Expand All @@ -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
Expand Down Expand Up @@ -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..<max(expected.count, actual.count) {
let lhs = index < expected.count ? expected[index] : 0
let rhs = index < actual.count ? actual[index] : 0
difference |= Int(lhs ^ rhs)
}
return difference == 0
}

private func unauthorizedResponse(sessionID: String?) -> HTTPResponse {
.error(
statusCode: 401,
.invalidRequest("Unauthorized"),
sessionID: sessionID,
extraHeaders: ["WWW-Authenticate": "Bearer"]
)
}
}
73 changes: 65 additions & 8 deletions Sources/PalmierPro/Agent/MCP/MCPService.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import Foundation
import MCP
import Security

/// HTTP adapter. Tool handling lives in `ToolExecutor`.
@Observable
@MainActor
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 {
Expand All @@ -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
Expand All @@ -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",
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Start task revives stopped listener

High Severity

MCPService.start() schedules a MainActor task that calls httpServer.start() before checking whether that server instance is still the active one. If stop() or a restart clears httpServer and stops the listener first, the pending task can still bind a listener on the discarded instance, leaving an orphaned MCP HTTP server (including LAN exposure) while the app starts or tracks a different instance.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 204caaa. Configure here.

}
}
}

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")
}

Expand Down Expand Up @@ -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: "")
}

}
28 changes: 27 additions & 1 deletion Sources/PalmierPro/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
Loading