feat: Aggregate upstream MCP prompts through prompts/list - #973
Conversation
3055af7 to
a4b01b2
Compare
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Dumbris
left a comment
There was a problem hiding this comment.
Review: aggregate upstream MCP prompts through prompts/list
Thanks @nlaurance — this is a well-structured contribution. We checked it out locally (head 821428d), built both editions, ran the new tests under -race, ran the strict CI lint config, and QA'd the running binary against a live prompts-serving upstream (@modelcontextprotocol/server-everything) over both transports. The aggregation core is genuinely solid: correct server__prompt naming with no colon leak, argument passthrough works, quarantined/disabled servers are excluded with no leak on either list or get, graceful all-upstreams-down behavior, clean enable_prompts:false gating, and notifications/prompts/list_changed fires on refresh. Test discipline is unusually good for an external PR.
That said, live QA found one blocking issue the unit tests can't see, plus a few smaller ones.
Blocking
-
P1 — Prompts are unreachable over the Streamable HTTP
/mcpendpoint in every routing mode.RefreshPrompts/AddPromptregister only onp.server(internal/server/mcp_routing.go:720,mcp.go:1080), but/mcpservesGetMCPServerForMode(cfg.RoutingMode)(internal/server/server.go:774), which returnscallToolServer/directServer/codeExecServer— none created withWithPromptCapabilities(mcp_routing.go:563-602). Sinceconfig.Validate()normalizesrouting_modetoretrieve_tools(internal/config/config.go:2430), thep.serverfallback is never hit over HTTP. Verified live:initializeon/mcpadvertises nopromptscapability andprompts/listreturns-32601 "prompts not supported", while the same binary over stdio serves all aggregated prompts correctly. As it stands the headline feature only works for stdio deployments. Fix: register prompts (and the prompts capability) on the routing-mode server instances actually served at/mcp. -
P2 —
expose_promptscannot be toggled at runtime by any path.- REST:
PATCH /api/v1/servers/{id}with{"expose_prompts": false}is rejected with{"success":false,"error":"No fields to update"}—MergeServerConfig(internal/config/merge.go:159+) has noExposePromptsbranch (onlyCopyServerConfigwas updated). Please add the non-nil-pointer merge branch likeInitTimeout/MaxConcurrentRequests, plus amerge_test.gocase. - File hot-reload: flipping
expose_promptsalone bumps the config version but emits noservers.changed,RefreshPromptsnever runs, and the core client's cached ServerConfig stays stale — the toggle only takes effect when coupled with a reconnect-forcing change (enabled/quarantine flip) or restart. It needs to be wired into config change detection too.
- REST:
-
P2 — Missing SSE request serialization in
core.Client.ListPrompts/GetPrompt.ListTools(internal/upstream/core/client.go:300-305) andCallTool(:400-401) takec.sseRequestMufor SSE transports because concurrent requests cause response-delivery failures; the newcore/prompts.gomethods skip this, so a prompts refresh racing a tool call against an SSE upstream reintroduces exactly that failure mode. Please mirror theListToolspattern. -
P3 — gofmt:
gofmt -lflags all four new files (internal/upstream/core/prompts.go,core/prompts_test.go,managed/prompts.go,managed/prompts_test.go— missing trailing newline). Mechanical.
Recommended (non-blocking)
Manager.GetPrompt(internal/upstream/manager_prompts.go) lacks theEnabled/QuarantinedguardsManager.CallToolenforces (manager.go:1220). QA showed deregistration currently masks this (prompts/geton a quarantined server returns "prompt not found"), but there's a race window between quarantining and theservers.changed-driven refresh during which the proxy would still forward. Defense-in-depth: mirror CallTool's checks.- Pagination:
core/prompts.gofetches only the first page (NextCursorignored). Consistent withListToolstoday, so fine as a follow-up — a// TODOwould help. - Upstream
notifications/prompts/list_changedis not consumed (re-aggregation only onservers.changed) — worth a line in the design doc's non-goals. - Docs:
docs/configuration.mdhas noexpose_promptsentry. Test nit:disableOAuthForTestincore/prompts_test.gohand-rolls env save/restore;t.Setenvdoes it in one line.
Verified locally
go build ./... + -tags server both pass · prompt unit tests green under -race · golangci-lint v2 CI config: 0 issues on touched packages · no merge conflicts with main · live QA evidence: stdio prompts/list returns everything__args-prompt, everything__completable-prompt, everything__resource-prompt, everything__simple-prompt + 2 built-ins; prompts/get everything__args-prompt {city, state} echoes arguments; after quarantine: list drops to built-ins and get returns -32602 (no leak).
Happy to merge once items 1-4 are addressed — the core design won't need to change.
…mcp-proxy#972) MCP-770 replaced managed.Client's public Config field with an atomic-pointer-backed GetConfig() accessor while this branch was rebasing onto upstream/main. Update the two prompt-aggregation call sites (added by this PR) to use the accessor instead of the removed field.
…-proxy#972) make swagger picks up the new per-server expose_prompts override on ServerConfig.
…-mcp-proxy#972) Codecov flagged low patch coverage across the prompts-aggregation change. Add tests for the previously-uncovered paths: - managed.Client.ListPrompts/GetPrompt (0% -> 100%): not-connected, success, and upstream-error branches via a real in-process MCP server. - core.Client.ListPrompts/GetPrompt (66% -> 100%): not-connected, nil server info, and upstream-error branches. - Manager.ListPrompts (manager_prompts.go): quarantined-server skip, disabled-server skip, and per-client error skip-and-continue. - MCPProxyServer.RefreshPrompts: disabled no-op, aggregation of built-in + upstream prompts, and the no-upstream-clients case. - CopyServerConfig: ExposePrompts is copied by value, not aliased. Remaining uncovered lines are defensive nil-checks and an error branch unreachable through the public API (Manager.ListPrompts never returns a non-nil error today).
/mcp is served via GetMCPServerForMode(cfg.RoutingMode), which after config.Validate() normalizes routing_mode to retrieve_tools almost never resolves back to p.server. RefreshPrompts and the built-in prompt registration only ever touched p.server, so the aggregated prompts feature was unreachable over Streamable HTTP in every non-default routing mode (live QA: initialize on /mcp advertised no prompts capability, prompts/list returned -32601). Advertise WithPromptCapabilities on the direct/code_execution/call_tool servers too, and set the aggregated prompt set on all of them in RefreshPrompts. Addresses PR smart-mcp-proxy#973 review comment (P1, blocking).
ListPrompts already excludes disabled/quarantined servers from aggregation, but GetPrompt itself had no such check, unlike Manager.CallTool (manager.go:1220). A client that already knows a qualified "server:prompt" name from before a quarantine flip could still forward prompts/get during the race window before the next servers.changed-driven refresh. Addresses PR smart-mcp-proxy#973 review comment (recommended, item 5).
MergeServerConfig had no branch for ExposePrompts (only CopyServerConfig was updated for smart-mcp-proxy#972), so PATCH /api/v1/servers/{id} with {"expose_prompts": false} was rejected with "No fields to update". Add the tri-state pointer-merge branch, mirroring InitTimeout/ MaxConcurrentRequests. Separately, LoadConfiguredServers's hasChanged comparison (the file hot-reload path) didn't count ExposePrompts, so flipping it alone in the config file bumped the config version but never emitted servers.changed — meaning RefreshPrompts never re-ran. Add the missing comparison. Addresses PR smart-mcp-proxy#973 review comment (P2, blocking, part 1).
core.Client.config is set once in NewClient and never reassigned, so even after MergeServerConfig/hot-reload correctly refreshed the ServerConfig, ListPrompts kept enforcing whatever ExposePrompts value was in effect when the connection was created — the toggle only took effect after a reconnect-forcing change or a restart. Add an atomic override on core.Client (SetExposePrompts) and wire managed.Client.SetConfig to push the new value down whenever the upstream Manager refreshes a client's config, without touching the rest of core.Client's config which stays connection-scoped. Also mirror the SSE request-serialization pattern ListTools/CallTool use: ListPrompts/GetPrompt now take c.sseRequestMu for SSE transports, closing the same response-delivery race a concurrent prompts refresh against an SSE upstream would otherwise reintroduce. Addresses PR smart-mcp-proxy#973 review comments (P2, blocking, part 2; P2, blocking, item 3).
…gination TODO) - gofmt managed/prompts.go (missing trailing newline, P3). - Document expose_prompts in the Server Fields table. - Update the design doc's non-goals: the routing-mode-server limitation is fixed (see the mcp_routing.go commit); add the previously-implicit non-goal that notifications/prompts/list_changed isn't consumed. - Note the first-page-only pagination limitation in core/prompts.go (matches ListTools today). Addresses PR smart-mcp-proxy#973 review comments (P3; recommended items 6-8).
821428d to
58c3f66
Compare
Resolve oas/docs.go conflict by regenerating OpenAPI artifacts with 'make swagger' (keeps expose_prompts field alongside main's new endpoints).
The test asserted the FIRST event after the flip is servers.changed, but async supervisor work from the initial load (e.g. activity.quarantine_change) can land after subscribing. Drain events until servers.changed or timeout.
…out gaps Step 1 of the PR smart-mcp-proxy#973 review follow-up. Makes prompt aggregation safe by default and closes the two P1 security seams so opting in is safe. - Add `aggregate_upstream_prompts` config flag, default FALSE. Gates ONLY the upstream aggregation in RefreshPrompts; `enable_prompts` still governs the built-in prompts + the prompts capability, so users keep the safe built-ins out of the box and opt into upstream aggregation deliberately. Wired through config default, CLI flag, viper, hot-reload change detection, telemetry, swagger, and docs. - Make the flag a LIVE read: RefreshPrompts now reads currentConfig() (the hot-reloaded snapshot) instead of the boot-time p.config, and config.reloaded drives a refresh — so toggling it takes effect without a restart (F4). - F1: register mcpserver.WithPromptFilter on every routing-mode server. It enforces agent-token + profile scope on aggregated prompts (mcp-go applies it to both prompts/list AND prompts/get), closing the get-time auth bypass where a scoped token could fetch any server's prompt. Built-ins stay visible to all. - F3: enforce expose_prompts on core.Client.GetPrompt, not just ListPrompts — an opted-out server's prompt content can no longer be fetched by name. Tests: default-off + live-read (F4) regression, aggregation-disabled builtins- only, WithPromptFilter scope matrix (F1), core.GetPrompt opt-out (F3), hot- reload change detection, config round-trip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7J8Yv5zr4tMQZaY3ot3Za
|
Pushed a follow-up commit ( What changed
Tests added: default-off + live-read regressions, aggregation-disabled (built-ins only), the Deferred to a follow-up PR (tracked; feature stays off by default until then): scanning/sanitisation of upstream prompt content (parity with the tool TPA + output-sanitisation path), activity logging for |
…/F10/F12) (#1005) Step 2a of the PR #973 follow-up: bring aggregated upstream prompts to parity with the tool path's security controls. The feature remains OFF by default (aggregate_upstream_prompts=false); this hardens it for when a user opts in. - F2 (P1): scan + sanitise upstream prompt content. - Sanitise prompts/get results with the same detector + policy tool results get: redact secrets, strip control sequences, spotlight untrusted text, and block on a critical secret. Reaches TextContent and embedded text resources; binary/image/audio untouched. Extracts a shared saniseTextValue helper reused by the tool and prompt paths. - TPA-scan each aggregated prompt's name/description/argument descriptions at refresh time and drop any "dangerous" verdict, the analogue of tool- description poisoning detection. - F10 (P2): activity logging for prompts/get. New prompt_get activity type + event + emitter + handler (with sensitive-data detection over args/content), a minted request-id for `activity list --request-id` correlation, and the CLI --type allowlist entry. - F12 (P2): size + count caps. Per-message text truncated at 1 MiB (UTF-8-safe, clear marker), per-server prompt count capped at 200 and total aggregated at 1000 — all logged, never silent. The three layers compose in one getPromptAggregated getter (single upstream round-trip): fetch -> F12 size-cap -> F2 sanitise -> F10 log, sharing one request-id so the sanitisation policy_decision and prompt_get rows join. Tests: prompt-result sanitisation (redact/block/embedded), poisoned-description drop, UTF-8-safe truncation + size cap + nil-safety, per-server count cap, and prompt_get activity persistence. Deferred to step 2b (polish): per-prompt approval-hash/rug-pull baseline, list- metadata redaction, prompt name-collision/colon hardening, REST/UI expose_prompts wiring, pagination, and consuming upstream prompts/list_changed. Claude-Session: https://claude.ai/code/session_01H7J8Yv5zr4tMQZaY3ot3Za Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…, F15) (#1006) Step 2b of the PR #973 follow-up: correctness/robustness polish for prompt aggregation. Feature remains OFF by default (aggregate_upstream_prompts). - F6 (P2): reject ':' in server names at config validation. ':' is the "server:prompt"/"server:tool" routing separator, so a name containing it misroutes; no working config uses it. ('__', the direct-mode separator, is kept for back-compat and handled below.) - F7 (P2): collision detection in buildAggregatedServerPrompts. Two pairs that flatten to the same "server__prompt" display name are now resolved deterministically (first-writer-wins) with a Warn, instead of mcp-go's silent last-writer-wins overwrite. - F9 (P2): wire per-server expose_prompts through REST + contracts. Adds the field to AddServerRequest (create + PATCH), the contracts Server type + both converters, the TS generator (contracts.ts regenerated), the GET read-path projections, and swagger — so PATCH {"expose_prompts":false} persists and is read back instead of returning "No fields to update". (The upstream_servers MCP-tool arg and the Web UI toggle are a small follow-up; not in this PR.) - F14 (P3): bound the prompts/list cursor-follow. mcp-go already follows NextCursor but only under ctx cancellation; drive it via ListPromptsByPage with a page cap (50) and item cap (200, aligned with the per-server cap) so a hostile endless-cursor upstream can't spin forever. - F15 (P3): give Manager.GetPrompt the reconnect_on_use recovery CallTool has (extracted into a shared tryReconnectOnUse helper) — a disconnected reconnect_on_use server now recovers for prompts/get as it does for tool calls. Tests: ':' name rejection, display-name collision (kept-first + logged), bounded pagination (multi-page / item-cap / endless-cursor), GetPrompt reconnect-on-use (recovers / no-reconnect-when-off), and REST PATCH expose_prompts (persist + omit-preserves). Claude-Session: https://claude.ai/code/session_01H7J8Yv5zr4tMQZaY3ot3Za Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the PR #973 prompt-aggregation work. When aggregation is enabled, an upstream that adds/removes a prompt at runtime now refreshes the aggregated prompt set reactively instead of waiting for an unrelated servers.changed event. Mirrors the existing tools/list_changed plumbing: core client dispatches the notification by method to a new onPromptsChanged callback (connection_lifecycle refactored to a method switch), managed + manager forward it, and the runtime debounces a burst (1s trailing-edge) into a single EventTypeUpstreamPromptsChanged that listenForRoutingModeRefresh turns into one RefreshPrompts — on the same goroutine servers.changed/config.reloaded already use, so no new reentrancy. The runtime callback short-circuits (live config read) when aggregation is off, so an opted-out proxy pays nothing. Tests: debouncer coalesces a burst to one fire; core prompts/list_changed handler fires the callback (+ nil-safe). End-to-end server->client push is covered by manual QA (the test transport does not reliably push notifications). Claude-Session: https://claude.ai/code/session_01H7J8Yv5zr4tMQZaY3ot3Za Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… for prompts) (#1009) The deferred "big one" from the PR #973 review (finding F2, rug-pull half). Aggregated upstream prompts have scope, sanitisation, TPA description scanning, logging, caps, naming hardening, and reactive refresh — but no CHANGE detection. A trusted server can pass admission with a benign prompt and later mutate its metadata with no review and no record. This specs the tool-quarantine (Spec 032) analogue for prompts. Key design decisions captured: - Baselines advertised LIST metadata only (name+description+args); get-time message content is inherently not cheaply baselineable — stated loudly as the headline Non-Goal so it is never mistaken for content protection. - Enforcement is by WITHHOLDING (not registering a changed prompt = list-hide + native get-fail), so there is NO runtime get-time gate to build — the biggest simplification over tools. - Parallel PromptApprovalRecord/bucket (not a ToolApprovalRecord overload). - Reuses the quarantine_security approve path; a ~300-400 LOC MVP delivers the whole security guarantee, with REST/Vue/scan-mode explicitly deferred. Spec-only; no code. Implementation (MVP first) is a follow-up task under this spec. Claude-Session: https://claude.ai/code/session_01H7J8Yv5zr4tMQZaY3ot3Za Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Implements the core of spec 100 (merged in #1009): the tool-quarantine (Spec 032) analogue for aggregated upstream prompts. A trusted server that passes admission with a benign prompt and later mutates its advertised metadata is now caught and the changed prompt is WITHHELD from prompts/list until approved — closing the last F2 gap from the PR #973 review. Scope (metadata only): the baseline hashes advertised LIST metadata (name + description + arguments); get-time prompts/get message content is inherently not baselineable here (spec Non-Goals) and stays defended by the existing F2 sanitisation + F12 caps. - storage: parallel PromptApprovalRecord + prompt_approvals bucket + CRUD (+ Manager wrappers), mirroring the tool ops 1:1 — NOT a ToolApprovalRecord overload (the server:tool / server:prompt key spaces would collide). - engine (internal/server/prompt_quarantine.go): calculatePromptApprovalHash (sha256(name|desc|normalizeJSON(args)), excludes Meta/Title), checkPromptApprovals with the pending/changed/approved state machine + baseline-pass + QuarantineEnabled kill-switch + TrustMode/AutoApproveToolChanges reuse, a fail-closed enforcePromptInvariant transition spine, filterBlockedPrompts, and the ApprovePrompt/ApproveAllPrompts mutators (which re-baseline + RefreshPrompts()). - hook: checkPromptApprovals + filterBlockedPrompts run in RefreshPrompts after the TPA scan-and-drop, before registration. Withholding IS the block — a prompt never passed to SetPrompts is absent from prompts/list and fails prompts/get natively, so there is no runtime get-time gate. Tests: hash stability/change, fail-closed invariant, first-seen→pending, approve→registered, rug-pull change→withheld→revert→re-approved, trust=auto auto-approve, ApproveAllPrompts, and a full-RefreshPrompts withholding integration test. Existing aggregation tests set quarantine off (they verify aggregation, not the baseline). Deferred to a follow-up: the quarantine_security MCP ops (inspect_prompts/approve_prompt/approve_all_prompts), REST twins, and the Vue review banner — the approve API (ApprovePrompt/ApproveAllPrompts) is ready to wire. Adding the MCP tool-schema op requires regenerating the 3 frozen tool-surface goldens, done deliberately in that PR. Claude-Session: https://claude.ai/code/session_01H7J8Yv5zr4tMQZaY3ot3Za Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Capabilities.Promptsinto mcpproxy's ownprompts/list/prompts/get, alongside the existing built-in prompts.expose_promptsoverride so a server can be excluded from aggregation even if it advertises the capability.retrieve_tools-mode) server only, per the design spec's non-goals.ExposePromptswasn't persisted through the BBolt storage layer (would have been lost across a restart), andRefreshPrompts's upstream fan-out had no timeout (could block the shared routing-refresh goroutine on a hung upstream).Closes #972
Test plan
go build ./... && go vet ./... && go test ./internal/... -race(4541 passed; one pre-existing, unrelated failure inlauncher.TestWaitForURL_InfersDefaultPort, confirmed pre-existing on the base branch)prompts/list/prompts/getsurface it correctly