Skip to content

feat: Aggregate upstream MCP prompts through prompts/list - #973

Merged
Dumbris merged 25 commits into
smart-mcp-proxy:mainfrom
nlaurance:feat/aggregate-upstream-prompts
Aug 19, 2026
Merged

feat: Aggregate upstream MCP prompts through prompts/list#973
Dumbris merged 25 commits into
smart-mcp-proxy:mainfrom
nlaurance:feat/aggregate-upstream-prompts

Conversation

@nlaurance

Copy link
Copy Markdown
Contributor

Summary

  • Aggregates prompts from connected upstream servers that advertise Capabilities.Prompts into mcpproxy's own prompts/list/prompts/get, alongside the existing built-in prompts.
  • Adds a per-server expose_prompts override so a server can be excluded from aggregation even if it advertises the capability.
  • Scoped to the default (retrieve_tools-mode) server only, per the design spec's non-goals.
  • Also fixes two gaps caught during verification: ExposePrompts wasn't persisted through the BBolt storage layer (would have been lost across a restart), and RefreshPrompts'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 in launcher.TestWaitForURL_InfersDefaultPort, confirmed pre-existing on the base branch)
  • Manual: connect a real upstream advertising prompts and confirm prompts/list/prompts/get surface it correctly

@nlaurance
nlaurance force-pushed the feat/aggregate-upstream-prompts branch from 3055af7 to a4b01b2 Compare August 12, 2026 06:19
@nlaurance nlaurance changed the title Aggregate upstream MCP prompts through prompts/list feat: Aggregate upstream MCP prompts through prompts/list Aug 12, 2026
@codecov-commenter

codecov-commenter commented Aug 12, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 89.13858% with 29 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/upstream/core/prompts.go 70.00% 11 Missing and 4 partials ⚠️
cmd/mcpproxy/main.go 0.00% 4 Missing ⚠️
internal/upstream/manager_prompts.go 94.20% 2 Missing and 2 partials ⚠️
internal/server/mcp_routing.go 93.61% 2 Missing and 1 partial ⚠️
internal/server/mcp_direct_scope.go 91.30% 1 Missing and 1 partial ⚠️
internal/config/loader.go 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@Dumbris Dumbris left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

  1. P1 — Prompts are unreachable over the Streamable HTTP /mcp endpoint in every routing mode. RefreshPrompts/AddPrompt register only on p.server (internal/server/mcp_routing.go:720, mcp.go:1080), but /mcp serves GetMCPServerForMode(cfg.RoutingMode) (internal/server/server.go:774), which returns callToolServer/directServer/codeExecServer — none created with WithPromptCapabilities (mcp_routing.go:563-602). Since config.Validate() normalizes routing_mode to retrieve_tools (internal/config/config.go:2430), the p.server fallback is never hit over HTTP. Verified live: initialize on /mcp advertises no prompts capability and prompts/list returns -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.

  2. P2 — expose_prompts cannot 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 no ExposePrompts branch (only CopyServerConfig was updated). Please add the non-nil-pointer merge branch like InitTimeout/MaxConcurrentRequests, plus a merge_test.go case.
    • File hot-reload: flipping expose_prompts alone bumps the config version but emits no servers.changed, RefreshPrompts never 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.
  3. P2 — Missing SSE request serialization in core.Client.ListPrompts/GetPrompt. ListTools (internal/upstream/core/client.go:300-305) and CallTool (:400-401) take c.sseRequestMu for SSE transports because concurrent requests cause response-delivery failures; the new core/prompts.go methods skip this, so a prompts refresh racing a tool call against an SSE upstream reintroduces exactly that failure mode. Please mirror the ListTools pattern.

  4. P3 — gofmt: gofmt -l flags 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)

  1. Manager.GetPrompt (internal/upstream/manager_prompts.go) lacks the Enabled/Quarantined guards Manager.CallTool enforces (manager.go:1220). QA showed deregistration currently masks this (prompts/get on a quarantined server returns "prompt not found"), but there's a race window between quarantining and the servers.changed-driven refresh during which the proxy would still forward. Defense-in-depth: mirror CallTool's checks.
  2. Pagination: core/prompts.go fetches only the first page (NextCursor ignored). Consistent with ListTools today, so fine as a follow-up — a // TODO would help.
  3. Upstream notifications/prompts/list_changed is not consumed (re-aggregation only on servers.changed) — worth a line in the design doc's non-goals.
  4. Docs: docs/configuration.md has no expose_prompts entry. Test nit: disableOAuthForTest in core/prompts_test.go hand-rolls env save/restore; t.Setenv does 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).
@nlaurance
nlaurance force-pushed the feat/aggregate-upstream-prompts branch from 821428d to 58c3f66 Compare August 17, 2026 12:40
Dumbris and others added 3 commits August 17, 2026 21:28
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
@Dumbris

Dumbris commented Aug 19, 2026

Copy link
Copy Markdown
Member

Pushed a follow-up commit (326c5bdb8) that makes prompt aggregation safe by default and closes the two highest-severity gaps from review. CI is green.

What changed

  • Aggregation is now opt-in. New aggregate_upstream_prompts config flag, default false. It gates only the upstream aggregation in RefreshPrompts; enable_prompts still governs the built-in prompts + the prompts capability, so the safe built-ins keep working out of the box and users opt into upstream aggregation deliberately. Wired through config default, CLI flag, viper, hot-reload change detection, telemetry, OpenAPI, and docs.
  • The switch is a live read. RefreshPrompts reads the hot-reloaded config snapshot (not the boot-time copy) and re-runs on config.reloaded, so toggling the flag takes effect without a restart.
  • Scope enforcement on aggregated prompts. Registered mcpserver.WithPromptFilter on every routing-mode server. mcp-go applies it to both prompts/list and prompts/get, so an agent token or profile session scoped to one server can no longer list or fetch another server's prompts. Built-in prompts stay visible to everyone.
  • expose_prompts enforced on get. core.Client.GetPrompt now checks expose_prompts (and the prompts capability), matching ListPrompts — an opted-out server's prompt content can no longer be fetched by name.

Tests added: default-off + live-read regressions, aggregation-disabled (built-ins only), the WithPromptFilter scope matrix, core.GetPrompt opt-out, and hot-reload change detection.

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 prompts/get, a size/count cap + pagination, consuming upstream prompts/list_changed, per-server REST/UI wiring for expose_prompts, and name-collision/:-in-name hardening.

@Dumbris
Dumbris merged commit f9c6655 into smart-mcp-proxy:main Aug 19, 2026
40 checks passed
Dumbris added a commit that referenced this pull request Aug 19, 2026
…/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>
Dumbris added a commit that referenced this pull request Aug 19, 2026
…, 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>
Dumbris added a commit that referenced this pull request Aug 19, 2026
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>
Dumbris added a commit that referenced this pull request Aug 19, 2026
… 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>
Dumbris added a commit that referenced this pull request Aug 19, 2026
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>
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.

[Feature]: Aggregate/proxy upstream servers' MCP prompts through prompts/list

3 participants