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
135 changes: 134 additions & 1 deletion Sources/XcodeMCPProxy/HTTPHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ final class HTTPHandler: ChannelInboundHandler, Sendable {
let method: String
let path: String
let remoteAddress: String?
let clientName: String?

func withClientName(_ name: String?) -> RequestLogContext {
guard let name = name?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty else {
return self
}
return RequestLogContext(
id: id,
method: method,
path: path,
remoteAddress: remoteAddress,
clientName: name
)
}
}

private struct State: Sendable {
Expand All @@ -28,6 +43,7 @@ final class HTTPHandler: ChannelInboundHandler, Sendable {
private let config: ProxyConfig
private let sessionManager: any SessionManaging
private let logger: Logger = ProxyLogging.make("http")
private let maxLoggedBodyBytes = 4096

init(config: ProxyConfig, sessionManager: any SessionManaging) {
self.config = config
Expand Down Expand Up @@ -107,7 +123,8 @@ final class HTTPHandler: ChannelInboundHandler, Sendable {
id: UUID().uuidString,
method: head.method.rawValue,
path: path,
remoteAddress: remoteAddressString(for: context.channel)
remoteAddress: remoteAddressString(for: context.channel),
clientName: nil
Comment on lines 123 to +127
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

The initial "HTTP request" log is emitted before clientName is extracted, so it will never include the client name (even when present in the Mcp-Client-Name header). Consider populating clientName from headers in handleRequest before calling logRequest, and only falling back to body parsing for POST requests if needed.

Copilot uses AI. Check for mistakes.
)
logRequest(requestLog)

Expand Down Expand Up @@ -314,6 +331,10 @@ final class HTTPHandler: ChannelInboundHandler, Sendable {
return
}

let clientName = clientName(from: head.headers, bodyData: bodyData)
let requestLog = requestLog.withClientName(clientName)
logRequestBody(bodyData, requestLog: requestLog)
Comment on lines +334 to +336
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

clientName(from:head.headers, bodyData:) will JSON-parse the body to extract the client name, and then handlePost JSON-parses the same bodyData again immediately after. To avoid double parsing (and extra work on every request), parse the JSON body once and reuse the parsed object both for method routing and client-name extraction.

Copilot uses AI. Check for mistakes.

let headerSessionId = HTTPRequestValidator.sessionId(from: head.headers)
let headerSessionExists = headerSessionId.map { sessionManager.hasSession(id: $0) } ?? false

Expand Down Expand Up @@ -743,6 +764,7 @@ final class HTTPHandler: ChannelInboundHandler, Sendable {
)
return
}
logResponseBody(data, requestLog: requestLog, status: .ok, sessionId: sessionId)
logResponse(requestLog, status: .ok, sessionId: sessionId)
}

Expand Down Expand Up @@ -839,6 +861,9 @@ final class HTTPHandler: ChannelInboundHandler, Sendable {
}

private func sendJSON(on channel: Channel, buffer: ByteBuffer, keepAlive: Bool, sessionId: String, requestLog: RequestLogContext) {
if let data = buffer.getData(at: buffer.readerIndex, length: buffer.readableBytes) {
logResponseBody(data, requestLog: requestLog, status: .ok, sessionId: sessionId)
}
logResponse(requestLog, status: .ok, sessionId: sessionId)
MCPResponseEmitter.sendJSON(
on: channel,
Expand All @@ -855,6 +880,7 @@ final class HTTPHandler: ChannelInboundHandler, Sendable {
sessionId: String?,
requestLog: RequestLogContext
) {
logResponseBody(data, requestLog: requestLog, status: .ok, sessionId: sessionId)
logResponse(requestLog, status: .ok, sessionId: sessionId)
var buffer = channel.allocator.buffer(capacity: data.count)
buffer.writeBytes(data)
Expand All @@ -874,6 +900,7 @@ final class HTTPHandler: ChannelInboundHandler, Sendable {
sessionId: String?,
requestLog: RequestLogContext
) {
logResponseBody(body, requestLog: requestLog, status: status, sessionId: sessionId)
logResponse(requestLog, status: status, sessionId: sessionId)
MCPResponseEmitter.sendPlain(
on: channel,
Expand Down Expand Up @@ -1024,6 +1051,9 @@ final class HTTPHandler: ChannelInboundHandler, Sendable {
if let remote = request.remoteAddress {
metadata["remote"] = .string(remote)
}
if let clientName = request.clientName {
metadata["client"] = .string(clientName)
}
logger.info("HTTP request", metadata: metadata)
}

Expand All @@ -1037,12 +1067,115 @@ final class HTTPHandler: ChannelInboundHandler, Sendable {
if let remote = request.remoteAddress {
metadata["remote"] = .string(remote)
}
if let clientName = request.clientName {
metadata["client"] = .string(clientName)
}
if let sessionId {
metadata["session"] = .string(sessionId)
}
logger.info("HTTP response", metadata: metadata)
}

private func logRequestBody(_ data: Data, requestLog: RequestLogContext) {
logBody(
label: "HTTP request body",
data: data,
requestLog: requestLog,
status: nil,
sessionId: nil
)
}

private func logResponseBody(_ data: Data, requestLog: RequestLogContext, status: HTTPResponseStatus, sessionId: String?) {
logBody(
label: "HTTP response body",
data: data,
requestLog: requestLog,
status: status,
sessionId: sessionId
)
}

private func logResponseBody(_ body: String, requestLog: RequestLogContext, status: HTTPResponseStatus, sessionId: String?) {
let data = Data(body.utf8)
logBody(
label: "HTTP response body",
data: data,
requestLog: requestLog,
status: status,
sessionId: sessionId
)
}

private func logBody(
label: String,
data: Data,
requestLog: RequestLogContext,
status: HTTPResponseStatus?,
sessionId: String?
) {
var metadata: Logger.Metadata = [
"id": .string(requestLog.id),
"method": .string(requestLog.method),
"path": .string(requestLog.path),
"bytes": .string("\(data.count)"),
]
if let remote = requestLog.remoteAddress {
metadata["remote"] = .string(remote)
}
if let clientName = requestLog.clientName {
metadata["client"] = .string(clientName)
}
if let status {
metadata["status"] = .string("\(status.code)")
}
if let sessionId {
metadata["session"] = .string(sessionId)
}
let previewData = data.prefix(maxLoggedBodyBytes)
let previewText = String(decoding: previewData, as: UTF8.self)
metadata["preview"] = .string(previewText)
if data.count > maxLoggedBodyBytes {
metadata["truncated"] = .string("true")
}
logger.info(Logger.Message(stringLiteral: label), metadata: metadata)
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

Request/response bodies are logged at .info with a plaintext preview. This can leak sensitive data (tokens, prompts, tool arguments) into logs and can significantly increase log volume. Consider gating body logging behind a debug log level and/or an explicit config/env flag, and/or redacting known sensitive fields before logging.

Suggested change
logger.info(Logger.Message(stringLiteral: label), metadata: metadata)
logger.debug(Logger.Message(stringLiteral: label), metadata: metadata)

Copilot uses AI. Check for mistakes.
Comment on lines +1135 to +1141
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

logBody unconditionally logs a 4KB preview of every HTTP request and response body (metadata["preview"]) at info level, along with byte length, path, and optional session/client identifiers. These bodies can easily contain sensitive material (e.g., API keys, access tokens, secrets in payloads, or proprietary code and prompts), so anyone with access to logs or a multi-tenant logging backend could recover that data. Consider restricting full body logging to an opt-in debug mode, redacting or hashing sensitive fields, and/or ensuring logs never contain authentication secrets or other high-sensitivity content.

Suggested change
let previewData = data.prefix(maxLoggedBodyBytes)
let previewText = String(decoding: previewData, as: UTF8.self)
metadata["preview"] = .string(previewText)
if data.count > maxLoggedBodyBytes {
metadata["truncated"] = .string("true")
}
logger.info(Logger.Message(stringLiteral: label), metadata: metadata)
// Always log high-level request/response information at info level,
// but do not include body content to avoid leaking sensitive data.
logger.info(Logger.Message(stringLiteral: label), metadata: metadata)
// Only log body previews in debug/loopback scenarios to reduce
// the risk of exposing sensitive information in logs.
if isLoopbackDebugEndpointEnabled || logger.logLevel <= .debug {
var debugMetadata = metadata
let previewData = data.prefix(maxLoggedBodyBytes)
let previewText = String(decoding: previewData, as: UTF8.self)
debugMetadata["preview"] = .string(previewText)
if data.count > maxLoggedBodyBytes {
debugMetadata["truncated"] = .string("true")
}
logger.debug(Logger.Message(stringLiteral: label), metadata: debugMetadata)
}

Copilot uses AI. Check for mistakes.
}

private func clientName(from headers: HTTPHeaders, bodyData: Data) -> String? {
if let header = headers.first(name: "Mcp-Client-Name")?.trimmingCharacters(in: .whitespacesAndNewlines),
!header.isEmpty {
return header
}
return clientName(from: bodyData)
}

private func clientName(from bodyData: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: bodyData, options: []) else {
return nil
}
if let object = json as? [String: Any] {
return clientName(from: object)
}
if let array = json as? [Any] {
for item in array {
if let object = item as? [String: Any], let name = clientName(from: object) {
return name
}
}
}
return nil
}

private func clientName(from object: [String: Any]) -> String? {
guard let params = object["params"] as? [String: Any],
let clientInfo = params["clientInfo"] as? [String: Any],
let name = clientInfo["name"] as? String else {
return nil
}
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}

private func remoteAddressString(for channel: Channel) -> String? {
guard let address = channel.remoteAddress else {
return nil
Expand Down
2 changes: 1 addition & 1 deletion Sources/XcodeMCPProxy/ProxyServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public final class ProxyServer {
let (host, port) = resolvedListenAddress(for: channel)
let displayHost = config.listenHost == "localhost" ? "localhost" : host
writeDiscovery(resolvedHost: host, port: port)
logger.info("Xcode MCP proxy listening on http://\(displayHost):\(port)")
logger.info("Xcode MCP proxy \(Version.current) listening on http://\(displayHost):\(port)")
return (host, port)
}

Expand Down
3 changes: 3 additions & 0 deletions Sources/XcodeMCPProxy/Version.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
public enum Version {
public static let current = "0.4.1"
}
10 changes: 10 additions & 0 deletions Sources/XcodeMCPProxyCommands/XcodeMCPProxyCLICommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package struct CLICommandLogSink {

package struct CLICommandInvocation {
package var showHelp = false
package var showVersion = false
package var usesRemovedURLHelper = false
package var hasExplicitURL = false
package var hasStdioFlag = false
Expand Down Expand Up @@ -93,6 +94,11 @@ package struct XcodeMCPProxyCLICommand {
return 0
}

if invocation.showVersion {
dependencies.stdout("xcode-mcp-proxy \(Version.current)")
return 0
}
Comment on lines +97 to +100
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

New --version behavior isn't covered by tests. There are existing CLI command tests in Tests/XcodeMCPProxyTests/CLICommandTests.swift; please add a test that --version prints xcode-mcp-proxy <version> and exits 0 without creating an adapter.

Copilot uses AI. Check for mistakes.

if invocation.usesRemovedURLHelper {
logSink.error(
"url helper mode was removed; configure your HTTP client with a concrete URL (default: http://localhost:8765/mcp)."
Expand Down Expand Up @@ -158,6 +164,9 @@ package struct XcodeMCPProxyCLICommand {
case "-h", "--help":
invocation.showHelp = true
index += 1
case "--version":
invocation.showVersion = true
index += 1
case "url" where index == 1:
invocation.usesRemovedURLHelper = true
index += 1
Expand Down Expand Up @@ -259,6 +268,7 @@ package struct XcodeMCPProxyCLICommand {
Options:
--request-timeout seconds Request timeout (default: 300, 0 disables)
--url url Explicit upstream URL (default: env/discovery/http://localhost:8765/mcp)
--version Show version
-h, --help Show help

Environment:
Expand Down
15 changes: 15 additions & 0 deletions Sources/XcodeMCPProxyCommands/XcodeMCPProxyServerCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ extension ProxyServer: ProxyServerCommandServer {}
package struct ProxyServerOptions {
package var forwardedArgs: [String]
package var showHelp: Bool
package var showVersion: Bool
package var hasListenFlag: Bool
package var hasHostFlag: Bool
package var hasPortFlag: Bool
Expand All @@ -18,6 +19,7 @@ package struct ProxyServerOptions {
package init(
forwardedArgs: [String],
showHelp: Bool,
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

Adding showVersion to ProxyServerOptions changes its initializer signature; any existing initializations (notably in ServerCommandTests) need to be updated to pass showVersion to keep tests compiling.

Suggested change
showHelp: Bool,
showHelp: Bool,
hasListenFlag: Bool,
hasHostFlag: Bool,
hasPortFlag: Bool,
hasXcodePidFlag: Bool,
hasLazyInitFlag: Bool,
forceRestart: Bool,
dryRun: Bool
) {
self.init(
forwardedArgs: forwardedArgs,
showHelp: showHelp,
showVersion: false,
hasListenFlag: hasListenFlag,
hasHostFlag: hasHostFlag,
hasPortFlag: hasPortFlag,
hasXcodePidFlag: hasXcodePidFlag,
hasLazyInitFlag: hasLazyInitFlag,
forceRestart: forceRestart,
dryRun: dryRun
)
}
package init(
forwardedArgs: [String],
showHelp: Bool,

Copilot uses AI. Check for mistakes.
showVersion: Bool,
hasListenFlag: Bool,
hasHostFlag: Bool,
hasPortFlag: Bool,
Expand All @@ -28,6 +30,7 @@ package struct ProxyServerOptions {
) {
self.forwardedArgs = forwardedArgs
self.showHelp = showHelp
self.showVersion = showVersion
self.hasListenFlag = hasListenFlag
self.hasHostFlag = hasHostFlag
self.hasPortFlag = hasPortFlag
Expand Down Expand Up @@ -109,6 +112,10 @@ package struct XcodeMCPProxyServerCommand {
dependencies.stdout(Self.serverUsage())
return 0
}
if options.showVersion {
dependencies.stdout("xcode-mcp-proxy-server \(Version.current)")
return 0
}
Comment on lines +115 to +118
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

New --version behavior isn't covered by tests. There are already comprehensive command tests in Tests/XcodeMCPProxyTests/ServerCommandTests.swift; please add coverage asserting --version prints the version string and exits 0 (and that it doesn't attempt to start the server).

Copilot uses AI. Check for mistakes.
Self.applyDefaults(
from: environment,
to: &options,
Expand Down Expand Up @@ -167,6 +174,7 @@ package struct XcodeMCPProxyServerCommand {
package static func parseOptions(args: [String]) throws -> ProxyServerOptions {
var forwarded: [String] = []
var showHelp = false
var showVersion = false
var hasListen = false
var hasHost = false
var hasPort = false
Expand Down Expand Up @@ -198,6 +206,7 @@ package struct XcodeMCPProxyServerCommand {
return ProxyServerOptions(
forwardedArgs: forwarded,
showHelp: showHelp,
showVersion: showVersion,
hasListenFlag: hasListen,
hasHostFlag: hasHost,
hasPortFlag: hasPort,
Expand All @@ -206,6 +215,10 @@ package struct XcodeMCPProxyServerCommand {
forceRestart: forceRestart,
dryRun: dryRun
)
case "--version":
showVersion = true
index += 1
continue
case "--dry-run":
dryRun = true
index += 1
Expand Down Expand Up @@ -254,6 +267,7 @@ package struct XcodeMCPProxyServerCommand {
return ProxyServerOptions(
forwardedArgs: forwarded,
showHelp: showHelp,
showVersion: showVersion,
hasListenFlag: hasListen,
hasHostFlag: hasHost,
hasPortFlag: hasPort,
Expand Down Expand Up @@ -319,6 +333,7 @@ package struct XcodeMCPProxyServerCommand {
--lazy-init
--force-restart
--dry-run
--version
-h, --help

Notes:
Expand Down