Skip to content

Commit 1dcdf96

Browse files
committed
feat(macos): display + edit headers and env on server detail
Adds full round-trip support for headers and env on the macOS tray's server detail screen, matching the new Web UI experience. API model: - ServerStatus gains `headers` and `env` (both [String: String]?). CodingKeys updated to map the JSON `headers` and `env` fields the Go backend now emits (companion to the runtime + management wiring in the parent commit). View: - Headers section under Connection for HTTP / streamable-http servers, visible in both view mode (sorted key list with masked values) and edit mode (KEY=VALUE textarea, parallel to the existing env textarea). - editEnvVars now pre-populates from `server.env` instead of starting empty — fixes the long-standing stub at L939 that explicitly noted the missing config API. - editHeaders works the same way for headers. - saveEdits() sends both maps unconditionally so deletes round-trip; refuses to save if any header value is still `***REDACTED***` (the backend sentinel emitted when `reveal_secret_headers: false`) so we don't silently overwrite a real secret with the placeholder. - New helpers: parseKVTextarea (shared between env and headers) and maskedHeaderValue (recognises `${keyring:...}` and `${env:...}` references and renders them as-is, masks literal values, surfaces the redaction sentinel verbatim so users know to flip reveal_secret_headers in their config). Convert-to-secret in Swift: deferred. The Web UI surfaces this per row through KVValueCell; the equivalent SwiftUI experience would need a new modal + state machine that doesn't fit the existing textarea-based edit form. Tracked as a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8973e52 commit 1dcdf96

2 files changed

Lines changed: 113 additions & 12 deletions

File tree

native/macos/MCPProxy/MCPProxy/API/Models.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,18 @@ struct ServerStatus: Codable, Identifiable, Equatable {
281281
let command: String?
282282
let args: [String]?
283283
let workingDir: String?
284+
/// HTTP headers attached to every request to this server (HTTP /
285+
/// streamable-http only). Sensitive values are redacted to
286+
/// `***REDACTED***` by the backend unless `reveal_secret_headers:
287+
/// true` is set in the loaded config (see
288+
/// internal/httpapi/server.go:redactServerHeaders). Edit-mode preserves
289+
/// the user-supplied values via the PATCH endpoint regardless.
290+
let headers: [String: String]?
291+
/// Environment variables attached to stdio servers. The Web UI's
292+
/// Edit Config screen has full round-trip support; this field lets
293+
/// the Swift tray display and pre-populate them on its own edit form
294+
/// rather than starting from an empty textarea.
295+
let env: [String: String]?
284296
let `protocol`: String
285297
let enabled: Bool
286298
let connected: Bool
@@ -311,6 +323,8 @@ struct ServerStatus: Codable, Identifiable, Equatable {
311323
enum CodingKeys: String, CodingKey {
312324
case id, name, url, command, args
313325
case workingDir = "working_dir"
326+
case headers
327+
case env
314328
case `protocol` = "protocol"
315329
case enabled, connected, connecting, quarantined
316330
case status

native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift

Lines changed: 99 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ struct ServerDetailView: View {
5353
@State private var editArgs = ""
5454
@State private var editWorkingDir = ""
5555
@State private var editEnvVars = ""
56+
/// HTTP servers only — KEY=VALUE per line. Pre-populated from
57+
/// `server.headers` on enter-edit. Sensitive values may show as
58+
/// `***REDACTED***` if the backend redacted them; saving redacted
59+
/// content unchanged is a no-op (the PATCH endpoint preserves whatever
60+
/// arrives but a `***REDACTED***` literal is meaningless to upstream),
61+
/// so users editing redacted headers should either edit the value or
62+
/// enable `reveal_secret_headers: true` in their config first.
63+
@State private var editHeaders = ""
5664
@State private var editEnabled = true
5765
@State private var editQuarantined = false
5866
@State private var editDockerIsolation = false
@@ -547,6 +555,25 @@ struct ServerDetailView: View {
547555
configRow(label: "URL", value: server.url ?? "N/A")
548556
}
549557
}
558+
// Headers section: visible whenever we have any headers
559+
// to show OR we're in edit mode (so users can add new
560+
// headers on a server that started without any).
561+
if isEditing || (server.headers?.isEmpty == false) {
562+
configSection(title: "Headers") {
563+
if isEditing {
564+
configEditRow(
565+
label: "KEY=VALUE per line",
566+
text: $editHeaders,
567+
placeholder: "Authorization=Bearer abc123\nX-API-Key=${keyring:my-key}",
568+
multiline: true
569+
)
570+
} else if let headers = server.headers {
571+
ForEach(headers.keys.sorted(), id: \.self) { key in
572+
configRow(label: key, value: maskedHeaderValue(headers[key] ?? ""))
573+
}
574+
}
575+
}
576+
}
550577
}
551578

552579
if server.protocol == "stdio" {
@@ -936,7 +963,18 @@ struct ServerDetailView: View {
936963
editCommand = server.command ?? ""
937964
editArgs = (server.args ?? []).joined(separator: "\n")
938965
editWorkingDir = server.workingDir ?? ""
939-
editEnvVars = "" // env vars not in ServerStatus model, would need config API
966+
// Pre-populate env and headers textareas from the server payload
967+
// so users entering edit mode start from the existing config
968+
// rather than from blank. Both maps emit one KEY=VALUE per line,
969+
// sorted by key for stable rendering.
970+
editEnvVars = (server.env ?? [:])
971+
.sorted(by: { $0.key < $1.key })
972+
.map { "\($0.key)=\($0.value)" }
973+
.joined(separator: "\n")
974+
editHeaders = (server.headers ?? [:])
975+
.sorted(by: { $0.key < $1.key })
976+
.map { "\($0.key)=\($0.value)" }
977+
.joined(separator: "\n")
940978
editEnabled = server.enabled
941979
editQuarantined = server.quarantined
942980
editDockerIsolation = server.isolation?.enabled ?? false
@@ -981,18 +1019,30 @@ struct ServerDetailView: View {
9811019
let wd = editWorkingDir.trimmingCharacters(in: .whitespaces)
9821020
if !wd.isEmpty { updates["working_dir"] = wd }
9831021

984-
// Parse env vars
985-
if !editEnvVars.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
986-
var env: [String: String] = [:]
987-
for line in editEnvVars.components(separatedBy: "\n") {
988-
let trimmed = line.trimmingCharacters(in: .whitespaces)
989-
if trimmed.isEmpty { continue }
990-
let parts = trimmed.components(separatedBy: "=")
991-
if parts.count >= 2 {
992-
env[parts[0].trimmingCharacters(in: .whitespaces)] = parts.dropFirst().joined(separator: "=")
993-
}
1022+
// Parse env vars. The textarea is the user's authoritative copy on
1023+
// save — sending an empty map clears all env vars on the backend
1024+
// (matches the existing add-server flow). We pass it through
1025+
// unconditionally so deletes round-trip.
1026+
updates["env"] = parseKVTextarea(editEnvVars)
1027+
1028+
// Parse headers (HTTP / streamable-http only). Same all-or-nothing
1029+
// semantics as env: we always send the parsed map so deletes
1030+
// propagate. The backend handler treats `headers: {}` as "clear",
1031+
// not "ignore".
1032+
if server.protocol == "http" || server.protocol == "sse" || server.protocol == "streamable-http" {
1033+
let headers = parseKVTextarea(editHeaders)
1034+
// Refuse to save a literal `***REDACTED***` value — that
1035+
// sentinel means the backend never sent us the real value
1036+
// (reveal_secret_headers is off), so persisting it would
1037+
// silently overwrite a real header with the redaction string.
1038+
// Users editing redacted headers must either change the value
1039+
// or enable reveal_secret_headers in their config first.
1040+
if headers.values.contains("***REDACTED***") {
1041+
editError = "Some header values are redacted (`***REDACTED***`). Set `reveal_secret_headers: true` in your config to view + edit them, or replace those values with new ones before saving."
1042+
isSavingEdit = false
1043+
return
9941044
}
995-
if !env.isEmpty { updates["env"] = env }
1045+
updates["headers"] = headers
9961046
}
9971047

9981048
// Boolean toggles
@@ -1081,6 +1131,43 @@ struct ServerDetailView: View {
10811131
logRefreshTimer?.invalidate()
10821132
logRefreshTimer = nil
10831133
}
1134+
1135+
// MARK: - Headers + Env helpers
1136+
1137+
/// Parse a "KEY=VALUE per line" textarea (used for both env and
1138+
/// headers) into a map. Empty lines are dropped; lines without `=`
1139+
/// are dropped silently — matching the existing env-parsing behaviour
1140+
/// on this view. Returns an empty map when the textarea is empty so
1141+
/// callers can treat the result as the authoritative new state.
1142+
private func parseKVTextarea(_ text: String) -> [String: String] {
1143+
var out: [String: String] = [:]
1144+
for line in text.components(separatedBy: "\n") {
1145+
let trimmed = line.trimmingCharacters(in: .whitespaces)
1146+
if trimmed.isEmpty { continue }
1147+
let parts = trimmed.components(separatedBy: "=")
1148+
guard parts.count >= 2 else { continue }
1149+
let key = parts[0].trimmingCharacters(in: .whitespaces)
1150+
if key.isEmpty { continue }
1151+
out[key] = parts.dropFirst().joined(separator: "=")
1152+
}
1153+
return out
1154+
}
1155+
1156+
/// Render a header value safely in view mode. Keyring/env references
1157+
/// pass through as-is (they're already labels, not secrets). Literal
1158+
/// values are masked to "•••• (NN chars)" so a casual onlooker can
1159+
/// see the header IS set without exposing the secret. The backend
1160+
/// may have already redacted the value to `***REDACTED***`; in that
1161+
/// case we surface that string verbatim so users know to flip
1162+
/// `reveal_secret_headers` in config to inspect / edit it.
1163+
private func maskedHeaderValue(_ value: String) -> String {
1164+
if value == "***REDACTED***" { return value }
1165+
if value.hasPrefix("${keyring:") || value.hasPrefix("${env:") { return value }
1166+
if value.isEmpty { return "(empty)" }
1167+
if value.count <= 4 { return "••••" }
1168+
let tail = value.suffix(2)
1169+
return "••••\(tail) (\(value.count) chars)"
1170+
}
10841171
}
10851172

10861173
// MARK: - Tool Row (Expandable Disclosure)

0 commit comments

Comments
 (0)