Skip to content

Commit d27fa38

Browse files
Dumbrisclaude
andauthored
feat(server-detail): display + edit headers/env, with reveal & convert-to-secret (#466)
* feat(server-detail): expose and edit headers + env in Web UI Headers and env were dropped at the runtime -> management -> contract boundary, so neither the Web UI nor the macOS tray could render or round-trip them. A server configured with a static Authorization header (e.g. synapbus with a Bearer token) appeared with no headers section at all on the Web UI Config tab. Backend wiring (this commit, all surfaces): - internal/runtime/runtime.go: GetAllServers emits headers and env from serverStatus.Config in serverMap. - internal/management/service.go: ListServers extracts headers and env in both typed (map[string]string) and generic (map[string]interface{}) shapes. Existing redaction at the HTTP layer continues to apply. Web UI (Config tab): - New Headers card with redact-by-default + click-to-reveal, per-row inline edit/delete, an Add row, and a "Convert to secret" button that stores the literal value in the OS keyring and rewrites the field as `${keyring:<name>}`. - Same affordances applied to the Environment Variables card. - New reusable KVValueCell component encapsulates the per-row UX so Headers and Env share the same display/edit/reveal/convert logic. - api.ts: new patchServer() and storeSecret() helpers wrapping PATCH /api/v1/servers/{id} and POST /api/v1/secrets respectively. - types/api.ts: add `headers` to Server interface (the contracts.ts twin already had it; api.ts had drifted). Note on reveal: backend redaction replaces sensitive header values with `***REDACTED***` unless `reveal_secret_headers: true` is set in config. The KVValueCell detects that sentinel and disables both reveal and the "Convert to secret" button (since neither has the real value in hand), surfacing a tooltip that points the user at the config flag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(server-detail): render \${keyring:...} preview literally in convert modal Vue templates do NOT interpret \${...} as JS template-literal syntax — the dollar-and-braces inside the modal body were rendering verbatim as '${'{'}'... text. Replace the awkward \${'{'} escape with a plain string interpolation through {{ '...' }} mustache so the user sees the actual reference syntax they're about to substitute into config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 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> * feat(macos): Convert-to-secret + sidebar cap + redacted-save guard hardening User feedback on the previous macOS commit triggered three follow-up fixes that all land naturally together: Sidebar cap (MainWindow.swift): - NavigationSplitView only set `min: 180, ideal: 220` for the sidebar column. SwiftUI was free to expand it unbounded after some layout transitions, squeezing the detail pane to a sliver on the right. - Add `max: 280` so the sidebar stays at a sensible width while the detail pane gets the rest. Verified visually on the server detail Config tab. Redacted-save guard (ServerDetailView.swift::saveEdits): - Previous version only refused to save when the textarea still contained `***REDACTED***` literally. A user could still delete the redacted line, add a new header, and silently wipe out the real Authorization behind the redaction. - Now we diff the new headers map against the original `server.headers` and refuse the save when either (a) a `***REDACTED***` literal is still present OR (b) any key whose original value was redacted is missing entirely from the new map. The error message lists the offending keys and points the user at `reveal_secret_headers: true`. Convert-to-secret on macOS (the previously-deferred work): - New SwiftUI sheet binding through `@State convertSheet: ConvertToSecretContext?`. The sheet body mirrors the Web UI flow — pre-suggests a secret name (`<server-name>-<key>`, lowercased and hyphen-sanitised), shows a live `${keyring:NAME}` preview, has Cancel + Convert with proper keyboard shortcuts. - Headers view-mode rendering switched from the static `configRow(label:value:)` to a new `kvRow(scope:key:value:)` that renders `${keyring:…}` / `${env:…}` references as a capsule chip with no actions, surfaces the `***REDACTED***` sentinel verbatim (still no convert button — we don't have the real value), and shows a 🔒 button for genuine literal values that opens the convert sheet. - Environment Variables now also renders in view mode (previously it was edit-only) with the same kvRow affordances. Visible whenever the server has any env vars, regardless of protocol — stdio servers can finally inspect their pre-populated env without entering edit mode. - APIClient gains `storeSecret(name:value:)` wrapping POST /api/v1/secrets which returns the `${keyring:<name>}` reference string to substitute back into the server config via the existing `updateServer` PATCH. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(headers): stop redacting REST + SSE responses; scope reveal_secret_headers to MCP tool only User feedback on the previous round: the Server Detail edit form refused to save when any header value still contained the `***REDACTED***` sentinel, and pushed users at the `reveal_secret_headers: true` config flag to unblock themselves. That trade-off was wrong — it punished the human UI to protect an agent code path. The actual threat model: - REST API (`/api/v1/servers`, SSE `/events`): gated by the local per-install API key. Same trust boundary as access to `~/.mcpproxy/mcp_config.json` on disk where these values are already stored in plaintext. Redacting in the response bought no real security, only broke the Web UI + macOS tray edit-and-save round-trip. - MCP `upstream_servers` tool: invoked by AI agents, output gets read back to the LLM context. THIS is the agent-context exposure `reveal_secret_headers` was created to protect — and that redaction was already implemented separately in `internal/server/mcp.go:~2545`, unaffected by this change. Backend changes: - internal/httpapi/server.go: drop `redactServerHeaders` calls in `handleGetServers` (both code paths) and remove the now-unused method. - internal/runtime/event_bus.go: drop the `redactServerHeaders` call on SSE `servers.changed` payloads and remove the now-unused method. - internal/runtime/event_bus_payload_test.go: rewrite the redaction test to assert the new policy — SSE must now carry plaintext, the test name changes from `…_RedactsSensitiveHeaders` to `…_SendsPlaintextHeaders`. - internal/config/config.go: update the `RevealSecretHeaders` doc to explicitly scope the flag to the MCP tool. REST + SSE always send plaintext from now on. UI cleanup (the redaction sentinel is no longer expected on the wire): - macOS Swift `saveEdits()`: drop the elaborate redacted-save guard (refused save when ***REDACTED*** literal still in textarea OR a redacted key was deleted). Both cases became impossible once the REST API serves plaintext. - macOS Swift `kvRow()`: drop the `value != "***REDACTED***"` check that disabled the Convert-to-secret button. - macOS Swift `performConvertToSecret()`: drop the matching guard. - Vue `KVValueCell.vue`: drop the `isBackendRedacted` computed flag and the "Backend redacted this value, set reveal_secret_headers" tooltip. Reveal and Convert are always available on literal values. - Vue `ServerDetail.vue::commitConvert()`: drop the same guard. Verified end-to-end on macOS and Web UI: - REST: `curl /api/v1/servers` → `Authorization: Bearer 1d386f...` (the real 71-char token). - MCP tool: `upstream_servers list` → `"Authorization":"***REDACTED***"` (still hidden from agents). - macOS Server Detail → synapbus → Edit → Headers textarea pre-populated with the real Bearer token; Save no longer blocked. - Web UI synapbus Config → Headers row shows `••••59 (71 chars)` with reveal eye, Convert-to-secret 🔒, edit, delete — all functional. OAS spec regenerated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(headers): restore REST/SSE redaction and add deep-merge PATCH User feedback on the previous commit: dropping REST/SSE redaction unblocked the UI but narrowed the threat model from PR #425 (a local process that holds the API key but not filesystem access could read other upstreams' Bearer tokens off the wire). The better trade-off is to keep both halves of PR #425 intact AND let the UI edit without round-tripping the redacted sentinel. Solution: deep-merge PATCH semantics so the client sends only the keys that changed. Redacted-but-untouched values stay out of the patch entirely, the backend keeps the real string on disk. Backend: - internal/httpapi/server.go::handlePatchServer — when the request body contains `headers` or `env`, MERGE into the existing stored map instead of replacing. New `headers_remove` / `env_remove` fields carry explicit deletes. Sending both is allowed (deletes apply after upserts). - internal/httpapi/server.go::AddServerRequest — adds the two `*_remove` fields with a comment documenting the new semantics. The add path ignores them. - internal/httpapi/server.go::handleGetServers — re-enable `redactServerHeaders` on both code paths. - internal/runtime/event_bus.go::emitServersChanged — re-enable redaction on SSE `servers.changed` payloads. SSE rides the same trust boundary as the REST GET. - internal/config/config.go::RevealSecretHeaders — restore the original PR #425 doc (REST + MCP both gated) with a new paragraph pointing at the deep-merge mechanism that makes the UI work anyway. Tests: - internal/httpapi/patch_server_test.go — 4 new tests pinning the merge semantics: - TestHandlePatchServer_HeadersDeepMerge — `headers: {X-New: v}` against existing `{Authorization, X-Trace}` preserves both original keys and adds X-New. - TestHandlePatchServer_HeadersRemove — `headers_remove: [...]` deletes the listed keys. - TestHandlePatchServer_HeadersSetAndRemove — both fields in one PATCH; deletes apply after upserts. - TestHandlePatchServer_EnvDeepMerge — same pattern for env. - internal/runtime/event_bus_payload_test.go — restored the original PR #425 assertion (`...RedactsSensitiveHeaders`). Frontend (Web UI): - ServerDetail.vue — `patchServerDiff(patch, action)` replaces the old `patchKVMap`. Each per-row UI action now sends a minimal targeted patch: - Edit one row: `{headers: {key: newValue}}` - Delete one row: `{headers_remove: [key]}` - Add one row: `{headers: {newKey: newValue}}` - Convert to secret: `{headers: {key: "${keyring:NAME}"}}` The redacted sentinel for unchanged keys never round-trips, by construction. - KVValueCell.vue — restore the `isBackendRedacted` branch. When the cell renders `***REDACTED***`, the reveal / Convert-to-secret buttons disappear (we don't hold the real value) and the cell shows the sentinel verbatim with a tooltip explaining that editing still works through the inline edit button. macOS Swift: - ServerDetailView.swift::saveEdits — switch from "send the full parsed map" to "diff against `server.headers` / `server.env` and send the diff". New private helper `diffKVMap(original:next:)` returns a `(set, remove)` tuple suitable for the deep-merge PATCH body. The same invariant holds: leaving a redacted line untouched in the textarea produces `next[k] == "***REDACTED***" == original[k]`, so the key stays out of both sides of the diff and the backend preserves the real value. - ServerDetailView.swift::kvRow — restore the `value != "***REDACTED***"` gate on the Convert-to-secret button (the sentinel isn't useful as a keyring payload). - ServerDetailView.swift::editHeaders doc — explain the new flow. End-to-end verification against the live local instance: $ curl -s -H "X-API-Key: ..." /api/v1/servers | jq '...synapbus.headers' → {"Authorization": "***REDACTED***"} # redacted ✓ $ jq '...synapbus.headers' ~/.mcpproxy/mcp_config.json → {"Authorization": "Bearer 1d386..."} # real on disk $ curl -X PATCH .../servers/synapbus -d '{"headers":{"X-Trace-Test":"merge-works"}}' $ jq '...synapbus.headers' ~/.mcpproxy/mcp_config.json → {"Authorization": "Bearer 1d386...", "X-Trace-Test": "merge-works"} # real token preserved ✓ $ curl -X PATCH .../servers/synapbus -d '{"headers_remove":["X-Trace-Test"]}' $ jq '...synapbus.headers' ~/.mcpproxy/mcp_config.json → {"Authorization": "Bearer 1d386..."} # X-Trace-Test deleted ✓ PR #425's E2E tests still pass (TestE2E_PatchDeepMergesEnvAndHeaders, TestE2E_MultipleEnableDisablePreservesConfig — both exercise the MCP tool which still redacts). PR #425's intent is fully preserved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(headers): unify delete syntax to JSON Merge Patch + add CLI patch User feedback on the previous round: REST PATCH had two ways to delete a key (`headers_remove: ["X"]` array vs MCP tool's `{"X": null}`). The MCP tool's syntax is cleaner and is a documented standard (RFC 7396). Unify on it and add a CLI command so all three interfaces — MCP, REST, CLI — behave the same way. Backend (internal/httpapi/server.go): - AddServerRequest.Headers and .Env switch from `map[string]string` to `map[string]*string`. Go's encoding/json then maps a missing key to "no entry", a present non-null value to a non-nil `*string`, and a present `null` to a nil pointer. The merge loop reads each entry: nil pointer = delete, non-nil = upsert. - Drop the `headers_remove` / `env_remove` array fields. A single `null` in the same map carries the same intent and aligns with the MCP tool. - POST (add) ignores nil entries via the new flattenNullableMap helper; `null` on create has no meaning. - redactServerHeaders / SSE redaction unchanged. Tests (internal/httpapi/patch_server_test.go): - Rewrite the previous `*_remove`-style tests to use literal JSON null payloads via `json.RawMessage`. The raw-byte approach is independent of any Go marshaling quirks that could collapse `null` values. - New TestHandlePatchServer_HeadersEmptyStringSetsNotDeletes pins the distinction between `""` (set to empty) and `null` (delete) — JSON Merge Patch is explicit about it and a future refactor that "helpfully" collapses one to the other would silently break. - Total: 7 tests, all green. Web UI (frontend/src/views/ServerDetail.vue): - deleteKv now sends `{headers: {key: null}}` instead of the array form. JSON.stringify emits `null` literally, no special handling. - Drop the `scopeRemoveKey` helper (no longer needed). macOS Swift (native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift): - diffKVMap returns a single `[String: Any]` patch dict where deleted keys map to `NSNull()` instead of returning the previous `(set, remove)` tuple. - saveEdits writes the patch as `updates["headers"] = patch` directly; no `headers_remove` companion field anymore. - performConvertToSecret sends a single-key patch `{field: {key: ref}}` instead of building the full map — minimal wire payload, never round-trips the redacted Authorization. - The trap was real and surprising: Swift's default `JSONEncoder` on `[String: String?]` SILENTLY DROPS nil entries from the JSON output. Using `[String: Any]` with `NSNull()` + `JSONSerialization` (the encoder our APIClient already uses) renders `null` correctly. Swift unit test (native/macos/MCPProxy/MCPProxyTests/MergePatchEncodingTests.swift): - 4 tests pinning the encoding contract: 1. NSNull encodes as literal `null` via JSONSerialization. 2. A delete-only patch round-trips through JSON and the value parses back as NSNull (not "", not absent). 3. The wrong path — `[String: String?]` + default `JSONEncoder` — does silently drop nils. Documented as a poison-pill test so a future refactor that "simplifies" to it has to explicitly delete this test and read the comment first. 4. Empty string still encodes as `""` and explicit null as `null` — the JSON Merge Patch set-vs-delete distinction is preserved. CLI (cmd/mcpproxy/upstream_cmd.go + internal/cliclient/client.go): - New `mcpproxy upstream patch <name>` subcommand with flags: --header K=V upsert (repeatable) --header-remove K delete (repeatable) --env K=V upsert (repeatable) --env-remove K delete (repeatable) - New cliclient.PatchServer(name, body) sends raw JSON to PATCH /api/v1/servers/{name}. Body shape is the same JSON Merge Patch the Web UI and macOS tray send. - Closes the CLI gap I called out in the boundaries-matrix summary — REST, MCP, and CLI now all support both write and delete on headers / env with the same semantics. Live end-to-end verification: $ mcpproxy upstream patch synapbus --header "X-Cli-Test: hello-from-cli" ✅ Patched synapbus: 1 header(s) set → on disk: { Authorization (real Bearer), X-Cli-Test } (Auth preserved) $ mcpproxy upstream patch synapbus --header-remove X-Cli-Test ✅ Patched synapbus: 1 header(s) removed → on disk: { Authorization (real Bearer) } (Auth preserved) $ mcpproxy upstream patch synapbus --header "X-Foo: v" --header-remove X-Foo Error: --header and --header-remove for "X-Foo" conflict; pick one Web UI: "+ Add header" → X-WebUI-Test=from-browser → Save → on disk: { Authorization (real Bearer), X-WebUI-Test } macOS tray: Edit → textarea pre-populated with "Authorization=***REDACTED***" → user appends "X-Mac-Test=hello-from-mac" → Save → on disk: { Authorization (REAL Bearer, preserved!), X-Mac-Test } PR #463 subagent review confirmed the unrelated "disable tool" pattern is a different domain (reversible state in BBolt vs destructive mutation in mcp_config.json) and should not be unified with this work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(headers): unify display + server-side convert-to-secret for redacted values User feedback: the Headers card looked split-brained. A non-sensitive header showed `••••XX (NN chars)` with a Convert-to-secret button. The Authorization header showed `***REDACTED***` with no button — exactly the most-likely candidate for "move me to keyring" was the one the UI refused to convert, because the client side couldn't hand the real value to the existing two-step (POST /secrets, then PATCH server) flow. This change unifies both display and conversion: 1. **Single mask format on the wire** — internal/oauth/logging.go replaces the `***REDACTED***` sentinel in RedactStringHeaders with MaskValue(v), producing `••••<last2> (<N> chars)` for literal secrets. The same format the Web UI / macOS tray have been computing client-side. Bare ${keyring:NAME} / ${env:VAR} references pass through unchanged (they're labels, not secrets; the UI needs to recognise them to render the keyring chip). 2. **Server-side atomic convert** — new endpoint: POST /api/v1/servers/{name}/config-to-secret body: {"scope": "header"|"env", "key": "<k>", "secret_name": "<n>"} The backend reads the real value from the loaded config, stores it in the OS keyring under secret_name, and rewrites the config field with ${keyring:<n>}. The client never has to possess the plaintext, so Convert-to-secret now works on the redacted-on-read path too. 3. **Reveal button removed from KVValueCell.vue** — with all literals displayed identically and Convert-to-secret available everywhere, the reveal toggle was a security-shaped speed bump with no real use case. The two paths to peek at a value (open the config file, or edit-cancel) remain. revealedKeys reactive state and the reveal/hide events disappear from the parent too. 4. **Symmetric macOS Swift cleanup** — ServerDetailView.swift::kvRow drops the `value != "***REDACTED***"` gate on the Convert-to-secret button. maskedHeaderValue() drops the sentinel special case. performConvertToSecret() calls the new atomic endpoint via the new APIClient.convertConfigToSecret helper instead of two-stepping through storeSecret + PATCH. Backend tests updated for the new format: - internal/oauth/logging_test.go::TestRedactStringHeaders — asserts the ••••et (40 chars) mask shape with length + last-2 suffix. New sub-tests pin the keyring/env-ref pass-through, short-value (<=4 chars -> bare ••••), and empty-value ((empty)) edge cases. - internal/runtime/event_bus_payload_test.go — SSE redaction test asserts the new format on the wire. - internal/server/e2e_test.go — both PR #425 round-trip tests now assert mask shape instead of literal sentinel. New backend test coverage: - internal/httpapi/patch_server_test.go — 9 new sub-tests for the /config-to-secret endpoint validation paths: missing scope / key / secret_name, invalid scope, key not on server, value already a reference (keyring + env separately), empty value, server not found. Happy-path lives in the live verification because secret.Resolver is a concrete struct without a mock. End-to-end verification on live local mcpproxy: GET /api/v1/servers -> synapbus.headers.Authorization = ••••59 (71 chars) # new format -> kaggle.headers.Authorization = ••••N} (30 chars) # Bearer\${k...} also masked POST /api/v1/servers/synapbus/config-to-secret {"scope":"header","key":"Authorization","secret_name":"synapbus-authorization"} -> 200 {"reference":"\${keyring:synapbus-authorization}"} -> on disk: {"Authorization": "\${keyring:synapbus-authorization}"} -> GET response: passes the bare reference through unchanged Web UI: Headers row transformed from `Authorization ••••59 (71 chars) [lock] Convert to secret` to `Authorization [key-chip] stored in keyring: synapbus-authorization` via the Convert-to-secret modal — no intermediate steps for the user, no plaintext on the client. CLI: `upstream patch synapbus --header X-Cli-Verify=hello` / `upstream patch synapbus --header-remove X-Cli-Verify` still work; the keyring reference on Authorization survives both. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: cover PR #466 headers/env editing + Convert-to-secret + new endpoints REST API (docs/api/rest-api.md): - New section on Header redaction and the ••••<last2> (<N> chars) mask format, when reveal_secret_headers applies, and why operators rarely need to flip it. - New PATCH /api/v1/servers/{name} endpoint documented with full JSON Merge Patch (RFC 7396) semantics: non-null upserts, JSON null deletes, absent keys preserved. Includes worked curl examples and the empty-string-is-not-delete gotcha. - New POST /api/v1/servers/{name}/config-to-secret endpoint — atomically moves a header/env value out of mcp_config.json into the OS keyring without the client ever holding the plaintext. CLI (docs/cli/management-commands.md): - New `upstream patch` subcommand with --header / --header-remove / --env / --env-remove flags and the deep-merge guarantee that no un-named key gets disturbed. Configuration guide (docs/configuration/upstream-servers.md): - New "Headers, Environment Variables, and Secrets" section explaining the wire mask, the three editing surfaces (Web UI / macOS tray / CLI / REST), and the ${keyring:NAME} / ${env:VAR} reference shapes. - Cross-links to the REST PATCH reference, the CLI subcommand, and the keyring integration page. Web UI (docs/web-ui/server-detail.md, new): - Dedicated page for the Server Detail Configuration tab focused on the Headers and Environment Variables cards. Documents the value formats (masked literal, keyring chip, env chip, plain), the per-row actions (add / edit / delete / convert), and the convert-to-secret flow including the atomic backend swap. - Two embedded screenshots showing the Headers card and the Convert-to-secret modal. Screenshots (docs/screenshots/server-detail/): - web-headers-card.png — the Headers card with masked Authorization + Convert to secret button. Captured against the live local instance with synapbus configured with a real Bearer token. - web-convert-modal.png — the modal preview with auto-suggested secret name and the ${keyring:NAME} live preview. Cross-references between the four pages so a reader can land anywhere and find the relevant detail in two clicks. * lint: drop trailing period from upstream patch daemon-required error staticcheck ST1005 — error strings must not end with punctuation. Flatten the multi-line message into a single sentence so the no-trailing-period rule is easy to keep over time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent c086770 commit d27fa38

30 files changed

Lines changed: 2547 additions & 86 deletions

File tree

cmd/mcpproxy/upstream_cmd.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,39 @@ Examples:
121121
RunE: runUpstreamAddJSON,
122122
}
123123

124+
upstreamPatchCmd = &cobra.Command{
125+
Use: "patch <name>",
126+
Short: "Update headers / env on an existing upstream server",
127+
Long: `Update HTTP headers and stdio environment variables on an existing upstream server.
128+
129+
The PATCH endpoint uses JSON Merge Patch semantics: keys you specify are
130+
upserted, keys you delete with -remove flags are explicitly removed, and
131+
every other key on the stored config is preserved. So you can safely
132+
rotate a single header without seeing or touching the rest — including
133+
sensitive values the backend redacts from list / inspect responses.
134+
135+
Examples:
136+
# rotate the Authorization header on the synapbus server
137+
mcpproxy upstream patch synapbus --header "Authorization: Bearer new-token"
138+
139+
# add a custom header without disturbing existing ones
140+
mcpproxy upstream patch synapbus --header "X-Trace: on"
141+
142+
# remove a header
143+
mcpproxy upstream patch synapbus --header-remove "X-Stale"
144+
145+
# set + remove in a single round-trip
146+
mcpproxy upstream patch synapbus --header "X-New: v" --header-remove "X-Old"
147+
148+
# update env vars on a stdio server
149+
mcpproxy upstream patch obsidian-pilot --env "LOG_LEVEL=debug" --env-remove "OLD_VAR"
150+
151+
Flags are repeatable. The corresponding null in the JSON Merge Patch body
152+
is constructed automatically — you never have to think about wire format.`,
153+
Args: cobra.ExactArgs(1),
154+
RunE: runUpstreamPatch,
155+
}
156+
124157
upstreamInspectCmd = &cobra.Command{
125158
Use: "inspect <server-name>",
126159
Short: "Inspect tool approval status for a server",
@@ -248,6 +281,12 @@ Examples:
248281
// Inspect command flags
249282
upstreamInspectTool string
250283

284+
// Patch command flags
285+
upstreamPatchHeaders []string
286+
upstreamPatchHeaderRemove []string
287+
upstreamPatchEnvs []string
288+
upstreamPatchEnvRemove []string
289+
251290
// Import command flags
252291
upstreamImportServer string
253292
upstreamImportFormat string
@@ -272,6 +311,7 @@ func init() {
272311
upstreamCmd.AddCommand(upstreamAddCmd)
273312
upstreamCmd.AddCommand(upstreamRemoveCmd)
274313
upstreamCmd.AddCommand(upstreamAddJSONCmd)
314+
upstreamCmd.AddCommand(upstreamPatchCmd)
275315
upstreamCmd.AddCommand(upstreamInspectCmd)
276316
upstreamCmd.AddCommand(upstreamApproveCmd)
277317
upstreamCmd.AddCommand(upstreamImportCmd)
@@ -316,6 +356,12 @@ func init() {
316356
upstreamRemoveCmd.Flags().BoolVarP(&upstreamRemoveYes, "y", "y", false, "Skip confirmation prompt (short form)")
317357
upstreamRemoveCmd.Flags().BoolVar(&upstreamRemoveIfExists, "if-exists", false, "Don't error if server doesn't exist")
318358

359+
// Patch command flags
360+
upstreamPatchCmd.Flags().StringArrayVar(&upstreamPatchHeaders, "header", nil, "HTTP header to upsert in 'Name: value' format (repeatable)")
361+
upstreamPatchCmd.Flags().StringArrayVar(&upstreamPatchHeaderRemove, "header-remove", nil, "HTTP header name to delete (repeatable)")
362+
upstreamPatchCmd.Flags().StringArrayVar(&upstreamPatchEnvs, "env", nil, "Environment variable to upsert in KEY=value format (repeatable)")
363+
upstreamPatchCmd.Flags().StringArrayVar(&upstreamPatchEnvRemove, "env-remove", nil, "Environment variable name to delete (repeatable)")
364+
319365
// Inspect command flags
320366
upstreamInspectCmd.Flags().StringVar(&upstreamInspectTool, "tool", "", "Show details for a specific tool")
321367

@@ -2006,3 +2052,125 @@ func applyImportedServersConfigMode(imported []*configimport.ImportedServer, glo
20062052

20072053
return nil
20082054
}
2055+
2056+
// runUpstreamPatch handles the 'upstream patch' command. Translates the
2057+
// repeatable --header / --env / --header-remove / --env-remove flags into
2058+
// a JSON Merge Patch (RFC 7396) body and POSTs it to PATCH /api/v1/servers/{name}.
2059+
//
2060+
// Upserts encode as {"headers": {"X-Foo": "bar"}}; deletes encode as
2061+
// {"headers": {"X-Stale": null}}. Both shapes go through encoding/json
2062+
// (`map[string]*string`) where a nil pointer renders as the literal
2063+
// `null` token — verified by the backend's PATCH tests.
2064+
func runUpstreamPatch(_ *cobra.Command, args []string) error {
2065+
serverName := strings.TrimSpace(args[0])
2066+
if serverName == "" {
2067+
return fmt.Errorf("server name is required")
2068+
}
2069+
2070+
if len(upstreamPatchHeaders) == 0 && len(upstreamPatchHeaderRemove) == 0 &&
2071+
len(upstreamPatchEnvs) == 0 && len(upstreamPatchEnvRemove) == 0 {
2072+
return fmt.Errorf("at least one of --header / --header-remove / --env / --env-remove must be specified")
2073+
}
2074+
2075+
headers := map[string]*string{}
2076+
for _, h := range upstreamPatchHeaders {
2077+
parts := strings.SplitN(h, ":", 2)
2078+
if len(parts) != 2 {
2079+
return fmt.Errorf("invalid --header format: %q (expected 'Name: value')", h)
2080+
}
2081+
key := strings.TrimSpace(parts[0])
2082+
val := strings.TrimSpace(parts[1])
2083+
if key == "" {
2084+
return fmt.Errorf("invalid --header: empty header name in %q", h)
2085+
}
2086+
headers[key] = &val
2087+
}
2088+
for _, k := range upstreamPatchHeaderRemove {
2089+
key := strings.TrimSpace(k)
2090+
if key == "" {
2091+
return fmt.Errorf("invalid --header-remove: empty name")
2092+
}
2093+
if _, dupe := headers[key]; dupe {
2094+
return fmt.Errorf("--header and --header-remove for %q conflict; pick one", key)
2095+
}
2096+
headers[key] = nil // JSON Merge Patch: null = delete
2097+
}
2098+
2099+
envs := map[string]*string{}
2100+
for _, e := range upstreamPatchEnvs {
2101+
parts := strings.SplitN(e, "=", 2)
2102+
if len(parts) != 2 {
2103+
return fmt.Errorf("invalid --env format: %q (expected 'KEY=value')", e)
2104+
}
2105+
key := strings.TrimSpace(parts[0])
2106+
val := parts[1]
2107+
if key == "" {
2108+
return fmt.Errorf("invalid --env: empty key in %q", e)
2109+
}
2110+
envs[key] = &val
2111+
}
2112+
for _, k := range upstreamPatchEnvRemove {
2113+
key := strings.TrimSpace(k)
2114+
if key == "" {
2115+
return fmt.Errorf("invalid --env-remove: empty name")
2116+
}
2117+
if _, dupe := envs[key]; dupe {
2118+
return fmt.Errorf("--env and --env-remove for %q conflict; pick one", key)
2119+
}
2120+
envs[key] = nil
2121+
}
2122+
2123+
body := map[string]interface{}{}
2124+
if len(headers) > 0 {
2125+
body["headers"] = headers
2126+
}
2127+
if len(envs) > 0 {
2128+
body["env"] = envs
2129+
}
2130+
2131+
bodyBytes, err := json.Marshal(body)
2132+
if err != nil {
2133+
return fmt.Errorf("failed to marshal patch body: %w", err)
2134+
}
2135+
2136+
ctx, cancel := context.WithTimeout(reqcontext.WithMetadata(context.Background(), reqcontext.SourceCLI), 15*time.Second)
2137+
defer cancel()
2138+
2139+
globalConfig, err := loadUpstreamConfig()
2140+
if err != nil {
2141+
return fmt.Errorf("failed to load config: %w", err)
2142+
}
2143+
if !shouldUseUpstreamDaemon(globalConfig.DataDir) {
2144+
return fmt.Errorf("mcpproxy daemon is not running — start it with `mcpproxy serve` first; the `patch` subcommand requires a live backend so configuration changes are applied with full deep-merge semantics and propagated to running upstream connections immediately. Editing the config file by hand only works while the daemon is offline")
2145+
}
2146+
logger, err := createUpstreamLogger("warn")
2147+
if err != nil {
2148+
return fmt.Errorf("failed to create logger: %w", err)
2149+
}
2150+
socketPath := socket.DetectSocketPath(globalConfig.DataDir)
2151+
client := cliclient.NewClient(socketPath, logger.Sugar())
2152+
2153+
if err := client.PatchServer(ctx, serverName, bodyBytes); err != nil {
2154+
return err
2155+
}
2156+
2157+
fmt.Printf("✅ Patched %s", serverName)
2158+
parts := []string{}
2159+
if n := len(upstreamPatchHeaders); n > 0 {
2160+
parts = append(parts, fmt.Sprintf("%d header(s) set", n))
2161+
}
2162+
if n := len(upstreamPatchHeaderRemove); n > 0 {
2163+
parts = append(parts, fmt.Sprintf("%d header(s) removed", n))
2164+
}
2165+
if n := len(upstreamPatchEnvs); n > 0 {
2166+
parts = append(parts, fmt.Sprintf("%d env var(s) set", n))
2167+
}
2168+
if n := len(upstreamPatchEnvRemove); n > 0 {
2169+
parts = append(parts, fmt.Sprintf("%d env var(s) removed", n))
2170+
}
2171+
if len(parts) > 0 {
2172+
fmt.Printf(": %s", strings.Join(parts, ", "))
2173+
}
2174+
fmt.Println()
2175+
return nil
2176+
}

docs/api/rest-api.md

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,36 @@ Get server status and statistics.
117117

118118
List all upstream servers with unified health status.
119119

120+
##### Header redaction and the mask format
121+
122+
By default, sensitive header values (`Authorization`, `X-API-Key`, `Cookie`,
123+
`Set-Cookie`, etc.) are replaced with a length-preserving mask of the form
124+
`••••<last2> (<N> chars)` before serialization. This applies to:
125+
126+
- `GET /api/v1/servers` and its single-server children
127+
- The `/events` SSE `servers.changed` payloads
128+
- The `upstream_servers list` MCP tool
129+
130+
The mask preserves enough information to identify which token is in use
131+
(the last two characters + total length) while keeping the secret out of
132+
the response. Values that are already secret references — `${keyring:NAME}`
133+
or `${env:VAR}` — pass through unchanged because they're labels, not
134+
secrets.
135+
136+
Setting `reveal_secret_headers: true` in
137+
[`mcp_config.json`](../configuration/config-file.md) disables redaction on
138+
all three channels. This is **not normally needed**: the Web UI / macOS
139+
tray / CLI can edit, delete, and convert-to-secret without ever seeing
140+
the plaintext, because the PATCH endpoint deep-merges (omitted keys are
141+
preserved) and the [`config-to-secret`](#post-apiv1serversnameconfig-to-secret)
142+
endpoint reads the real value server-side. Flip the flag only if you
143+
need to inspect a raw value through the API for debugging.
144+
145+
The MCP `upstream_servers` tool was the original motivator for redaction
146+
(see [PR #425](https://github.com/smart-mcp-proxy/mcpproxy-go/pull/425)) —
147+
a prompt-injected agent could otherwise read another upstream's PAT via
148+
`upstream_servers list`.
149+
120150
**Response:**
121151
```json
122152
{
@@ -167,6 +197,118 @@ List all upstream servers with unified health status.
167197
| `detail` | string | Optional additional context about the status |
168198
| `action` | string | Suggested remediation: `login`, `restart`, `enable`, `approve`, `view_logs`, or empty |
169199

200+
#### PATCH /api/v1/servers/{name}
201+
202+
Partial update of an existing upstream server. All request fields are optional;
203+
omitted fields are preserved as-is.
204+
205+
The map-typed fields `headers` and `env` follow **JSON Merge Patch
206+
([RFC 7396](https://www.rfc-editor.org/rfc/rfc7396))** semantics:
207+
208+
| Value in patch body | Effect on stored map |
209+
|---|---|
210+
| key present with a non-null string value | upsert (add or replace that key) |
211+
| key present with JSON `null` | delete that key |
212+
| key absent from the patch body | preserve as-is |
213+
214+
This is the same convention the MCP `upstream_servers patch` tool uses. It
215+
lets the Web UI / macOS tray / CLI send a minimal diff — keys that match
216+
the server's current masked view (`••••<last2> (<N> chars)` — see
217+
[Header redaction](#header-redaction-and-the-mask-format) below) simply stay
218+
out of the patch body, so the real stored value is never overwritten by the
219+
mask string.
220+
221+
**Request body** ([`AddServerRequest`](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/internal/httpapi/server.go) — all fields optional):
222+
223+
```json
224+
{
225+
"url": "https://api.example.com/mcp",
226+
"command": "uvx",
227+
"args": ["mcp-server-foo"],
228+
"env": {"API_KEY": "new-value", "OLD_VAR": null},
229+
"headers": {"X-Trace": "on", "X-Stale": null},
230+
"working_dir": "/path/to/dir",
231+
"protocol": "http",
232+
"enabled": true,
233+
"quarantined": false,
234+
"isolation": {"enabled": true, "image": "node:20"}
235+
}
236+
```
237+
238+
**Examples:**
239+
240+
```bash
241+
# Rotate a Bearer token without touching anything else on the server
242+
curl -X PATCH -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
243+
-d '{"headers":{"Authorization":"Bearer new-token"}}' \
244+
http://127.0.0.1:8080/api/v1/servers/synapbus
245+
246+
# Remove a stale header (the JSON null is the delete signal)
247+
curl -X PATCH -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
248+
-d '{"headers":{"X-Stale":null}}' \
249+
http://127.0.0.1:8080/api/v1/servers/synapbus
250+
251+
# Upsert one env var and delete another in a single round-trip
252+
curl -X PATCH -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
253+
-d '{"env":{"LOG_LEVEL":"debug","OBSOLETE":null}}' \
254+
http://127.0.0.1:8080/api/v1/servers/obsidian-pilot
255+
```
256+
257+
**Notes:**
258+
259+
- Empty string `""` is **set-to-empty**, NOT delete. JSON Merge Patch is
260+
explicit about this — only the JSON `null` token deletes.
261+
- Boolean fields (`enabled`, `quarantined`, `reconnect_on_use`) use
262+
pointer-style semantics: absent = preserve, present = explicit value.
263+
264+
#### POST /api/v1/servers/{name}/config-to-secret
265+
266+
Atomically move a header or env value out of `mcp_config.json` and into the
267+
OS keyring. The backend reads the real value from the loaded config, stores
268+
it in the keyring under `secret_name`, and rewrites the config field with
269+
`${keyring:<secret_name>}`. The client never needs to possess the plaintext
270+
— useful when the API redacts sensitive header values on the read path.
271+
272+
**Request body:**
273+
274+
```json
275+
{
276+
"scope": "header",
277+
"key": "Authorization",
278+
"secret_name": "synapbus-auth"
279+
}
280+
```
281+
282+
| Field | Type | Description |
283+
|---|---|---|
284+
| `scope` | string | `header` or `env` |
285+
| `key` | string | The key on the server's headers / env map |
286+
| `secret_name` | string | Name to store the value under in the OS keyring |
287+
288+
**Response (200 OK):**
289+
290+
```json
291+
{
292+
"success": true,
293+
"data": {
294+
"message": "header \"Authorization\" on \"synapbus\" now references keyring secret \"synapbus-auth\"",
295+
"reference": "${keyring:synapbus-auth}"
296+
}
297+
}
298+
```
299+
300+
**Failure cases:**
301+
302+
| Status | Cause |
303+
|---|---|
304+
| 400 | Missing `scope` / `key` / `secret_name`, invalid scope, value is already a `${keyring:…}` or `${env:…}` reference, or value is empty |
305+
| 404 | Server or key not found |
306+
| 500 | Secret resolver unavailable, keyring store failed, or config update failed |
307+
308+
This endpoint is what the Web UI and macOS tray "Convert to secret" button
309+
calls. It works even for headers the API redacts (the backend has the real
310+
value on disk).
311+
170312
#### POST /api/v1/servers/{name}/enable
171313

172314
Enable a server.

0 commit comments

Comments
 (0)