Skip to content

feat(server-detail): display + edit headers/env, with reveal & convert-to-secret#466

Merged
Dumbris merged 10 commits into
mainfrom
feat/server-detail-headers-env
May 14, 2026
Merged

feat(server-detail): display + edit headers/env, with reveal & convert-to-secret#466
Dumbris merged 10 commits into
mainfrom
feat/server-detail-headers-env

Conversation

@Dumbris
Copy link
Copy Markdown
Member

@Dumbris Dumbris commented May 14, 2026

Summary

Server detail screens (Web UI and macOS tray) now display, edit, and round-trip HTTP headers and environment variables. Includes a click-to-reveal affordance for redacted values and a one-step "Convert to secret" flow that stores the literal value in the OS keyring and rewrites the field as ${keyring:<name>}.

Triggered by a real user report: synapbus configured with a static Authorization: Bearer ... header showed no headers section at all on the Config tab. Diagnosis (trace) revealed a 4-layer drop:

Layer File Bug
Runtime → map internal/runtime/runtime.go GetAllServers emitted args/working_dir but not headers/env
Map → contract internal/management/service.go ListServers had no extractor for headers/env
Web UI frontend/src/views/ServerDetail.vue No Headers card; Env card was read-only and masked
macOS Swift native/macos/MCPProxy/MCPProxy/API/Models.swift + ServerDetailView.swift ServerStatus model had neither field; edit form's env textarea was a known stub (L939: "env vars not in ServerStatus model, would need config API")

What's in the change

Backend wiring (1st commit)

  • runtime.go::GetAllServers emits serverStatus.Config.Headers and .Env when non-empty.
  • service.go::ListServers accepts both typed map[string]string (in-process StateView) and generic map[string]interface{} (JSON round-trip) shapes for both fields, with three new unit tests.
  • Existing redaction (redactServerHeaders in httpapi/server.go) still applies; the reveal_secret_headers: true config flag continues to govern whether sensitive header values are sent in plaintext or as ***REDACTED***.

Web UI (1st + 3rd commit)

  • New Headers card on the Config tab with redact-by-default, per-row eye toggle, inline edit, delete, "Add header", and a Convert to secret button on literal values that opens a modal, POST /api/v1/secrets with a suggested name, then PATCH /api/v1/servers/{id} replacing the value with ${keyring:<name>}.
  • Same affordances applied to the Environment Variables card, which previously showed only static masked values.
  • New reusable KVValueCell.vue encapsulates per-row UX so headers and env share the same display/edit/reveal/convert logic.
  • ${keyring:NAME} and ${env:VAR} references render as info chips with no reveal/convert (already pointers, not secrets).
  • api.ts: new patchServer() and storeSecret() helpers wrapping the existing backend endpoints.
  • types/api.ts: add headers field to Server interface (the contracts.ts twin already had it; api.ts had drifted).

macOS Swift (2nd commit)

  • ServerStatus gains headers and env Codable fields with proper CodingKeys.
  • Server detail view gains a Headers section under Connection for HTTP/streamable-http servers — view mode shows sorted key list with masked values; edit mode is a KEY=VALUE per line textarea parallel to the existing env textarea.
  • editEnvVars now pre-populates from server.env instead of starting empty — closes the long-standing stub at L939.
  • saveEdits() sends both maps unconditionally so deletes round-trip; refuses to save if any header value is still ***REDACTED*** so the redaction sentinel can't silently overwrite a real secret.
  • New helpers: parseKVTextarea (shared between env + headers) and maskedHeaderValue (recognises ${keyring:…} / ${env:…} references, masks literal values, surfaces the redaction sentinel verbatim).
  • Deferred to follow-up: per-row reveal + Convert-to-secret in the Swift UI. The existing Swift edit form is textarea-based, not row-based; bringing it to feature parity with the Web UI's KVValueCell is a separate UX refactor.

Reveal semantics

The backend continues to redact via redactServerHeaders unless reveal_secret_headers: true is set. The KVValueCell detects the ***REDACTED*** sentinel and disables both reveal and the "Convert to secret" button (the convert flow needs the real value to write to keyring), surfacing a tooltip pointing the user at the config flag. The Swift edit form refuses to save when the textarea still contains ***REDACTED*** for the same reason.

Verification

Web UI — verified end-to-end against a fresh mcpproxy on :18091 with a synapbus-style HTTP server (Bearer token + custom header) and a stdio server with one literal env value and one ${keyring:db-url} reference:

  • Headers card renders with both rows; Authorization value ••••59 (71 chars) redacted; X-Trace ••••.
  • Click the eye on Authorization → expands to the full Bearer 1d386f72... token, eye becomes hide (🙈).
  • Click "Convert to secret" → modal opens with title, body referencing ${keyring:NAME}, secret name auto-suggested synapbus-demo-authorization, live preview Will be referenced as ${keyring:synapbus-demo-authorization}.
  • Env card on demo-stdio: API_KEY and LOG_LEVEL show as redacted with all four actions; DATABASE_URL correctly detected as a ${keyring:db-url} reference and rendered as an info badge with no reveal / no convert (only edit + delete).

Screenshots are embedded in the PR conversation thread.

macOS Swiftswiftc build is clean (only pre-existing SecretsView.swift / MCPProxyApp.swift warnings unrelated to this change). I did not replace /Applications/mcpproxy.app/Contents/MacOS/MCPProxy because the user's tray is actively connected to live servers; visual verification of the macOS surface should happen by pulling this branch, running make build, and rebuilding the Swift bundle per the CLAUDE.md instructions.

Test plan

  • go test ./internal/management/ -run TestListServers -v — 3 new subtests pass.
  • go test ./internal/upstream/core/ ./internal/management/ ./internal/httpapi/ — green.
  • vue-tsc --noEmit — clean.
  • vite build — bundles clean.
  • make build + Swift swiftc rebuild — both green.
  • Manual Web UI verification (above).
  • Manual macOS tray verification by pulling the branch (out of scope for this Claude session per the note above).

Out of scope / follow-ups

  • macOS per-row reveal + Convert-to-secret (textarea form is sufficient for round-trip but the UX gap with Web UI should close in a focused Swift refactor).
  • Convert-to-secret currently posts {type: "keyring"} only. A future iteration could add ${env:VAR} reference creation too.

🤖 Generated with Claude Code

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 14, 2026

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7151911
Status: ✅  Deploy successful!
Preview URL: https://ba67cde1.mcpproxy-docs.pages.dev
Branch Preview URL: https://feat-server-detail-headers-e.mcpproxy-docs.pages.dev

View logs

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 14, 2026

📦 Build Artifacts

Workflow Run: View Run
Branch: feat/server-detail-headers-env

Available Artifacts

  • archive-darwin-amd64 (26 MB)
  • archive-darwin-arm64 (23 MB)
  • archive-linux-amd64 (15 MB)
  • archive-linux-arm64 (13 MB)
  • archive-windows-amd64 (26 MB)
  • archive-windows-arm64 (23 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (20 MB)
  • installer-dmg-darwin-arm64 (18 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 25857898404 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

claude added 8 commits May 14, 2026 14:08
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>
…rt 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>
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>
…rdening

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>
…et_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>
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>
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>
…cted 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>
@Dumbris Dumbris force-pushed the feat/server-detail-headers-env branch from ffccc8d to fc4051f Compare May 14, 2026 11:13
claude added 2 commits May 14, 2026 14:29
…points

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.
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>
@Dumbris Dumbris merged commit d27fa38 into main May 14, 2026
31 of 34 checks passed
@Dumbris Dumbris deleted the feat/server-detail-headers-env branch May 14, 2026 11:53
electrolobzik added a commit to HaloCollar/mcpproxy-go that referenced this pull request May 16, 2026
Brings in upstream's 9 commits since 2b9b5f9:
- 0597762 fix(upstream): stop misclassifying transport errors as auth failures (smart-mcp-proxy#464)
- aaec117 fix(diagnostics): correct bug-report URL (smart-mcp-proxy#465)
- d27fa38 feat(server-detail): display + edit headers/env, with reveal & convert-to-secret (smart-mcp-proxy#466)
- c086770 feat(webui): per-tool enable/disable + bulk Enable All/Disable All (smart-mcp-proxy#463)
- 0ef75a8 chore(docs): remove stale root-level docs
- 4b4b62a fix(ui): respect engaged flag in sidebar Setup pulse (smart-mcp-proxy#462)
- 24aab3d docs(installation): add migrating-from-manual-install section (smart-mcp-proxy#459)
- 9b79254 feat(doctor): surface snap-docker override hint when host needs it (smart-mcp-proxy#460)
- be927b6 packaging(deb): ship unattended-upgrades whitelist so installs auto-update (smart-mcp-proxy#458)

Conflicts resolved:

1. internal/management/service_test.go — purely additive on upstream's
   side (3 new headers/env t.Run blocks). Halo-main had no overlapping
   test work. Took upstream's additions verbatim.

2. oas/docs.go + oas/swagger.yaml — auto-generated by swaggo from Go
   annotations in source. Took --theirs on the conflict then regenerated
   with `make swagger` against the merged source so both halo-main- and
   upstream-introduced REST endpoints are reflected.

Note on upstream's c086770 (per-tool enable/disable, upstream's version
of smart-mcp-proxy#463): halo-main already has its own per-tool enable/disable work
(via feat/per-tool-enable-disable + the security-hardening stack on
top — admin gating, isToolCallable fail-closed, sentinel-error
quarantine synthesis, etc.). The upstream version produced no merge
conflicts because the file-level diffs aligned cleanly — the fork's
hardening sits atop the same surface upstream landed.

Sanity-checked:
- `go build ./...` succeeds.
- `go test -short ./internal/management/ ./internal/runtime/
  ./internal/httpapi/ ./internal/storage/ ./cmd/generate-types/` passes.
- internal/server pre-existing sandbox-environment flake
  (TestBinaryAPIEndpoints/GET_/servers) was verified to fail on
  pre-merge halo-main (ad31fde) too — not a regression.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants