Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4936739
feat(server): design spec for aggregating upstream MCP prompts (#972)
nlaurance Aug 11, 2026
f9bb407
feat(server): add per-server expose_prompts override to prompts desig…
nlaurance Aug 11, 2026
a5ab0cb
feat(server): correct prompts design to reuse colon/__ naming split, …
nlaurance Aug 11, 2026
41d6ba5
feat(config): add per-server ExposePrompts override (#972)
nlaurance Aug 11, 2026
21ddafb
feat(upstream): add core.Client.ListPrompts/GetPrompt (#972)
nlaurance Aug 11, 2026
3ed8ff3
feat(upstream): add managed.Client.ListPrompts/GetPrompt (#972)
nlaurance Aug 11, 2026
0942271
feat(upstream): add Manager.ListPrompts/GetPrompt aggregation (#972)
nlaurance Aug 11, 2026
4702455
fix(upstream): guard nil client/config in Manager.GetPrompt lookup (#…
nlaurance Aug 11, 2026
34d6a51
feat(server): add FormatDirectPromptName helper (#972)
nlaurance Aug 11, 2026
7fe38b8
feat(server): aggregate upstream prompts via RefreshPrompts (#972)
nlaurance Aug 11, 2026
45ea0aa
feat(server): refresh aggregated prompts on servers.changed (#972)
nlaurance Aug 11, 2026
d7a9b29
fix(storage): persist ExposePrompts field across BBolt round-trip (#972)
nlaurance Aug 11, 2026
6d95173
fix(server): bound RefreshPrompts upstream fan-out with a timeout (#972)
nlaurance Aug 11, 2026
a71dcf0
fix(upstream): use GetConfig() accessor in prompt aggregation (#972)
nlaurance Aug 12, 2026
43264e5
docs(oas): regenerate OpenAPI spec for ExposePrompts field (#972)
nlaurance Aug 12, 2026
3028d8d
test(prompts): close patch-coverage gaps in prompt aggregation (#972)
nlaurance Aug 12, 2026
9ebbb9e
docs(oas): regenerate spec after rebase onto main
nlaurance Aug 14, 2026
c04f597
fix(server): serve aggregated prompts on every routing-mode MCP server
nlaurance Aug 17, 2026
0f6a74d
fix(upstream): enforce Enabled/Quarantined guards in Manager.GetPrompt
nlaurance Aug 17, 2026
e055118
fix(config): merge and detect expose_prompts changes on hot-reload
nlaurance Aug 17, 2026
72b6d49
fix(upstream): let ExposePrompts take effect without a reconnect
nlaurance Aug 17, 2026
58c3f66
docs+style: PR #973 review follow-ups (gofmt, docs, pagination TODO)
nlaurance Aug 17, 2026
9d261a2
Merge branch 'main' into feat/aggregate-upstream-prompts
Dumbris Aug 17, 2026
3ef6cc8
test(runtime): deflake expose_prompts servers.changed test
Dumbris Aug 17, 2026
326c5bd
feat(prompts): make upstream aggregation opt-in + close P1 scope/opt-…
Dumbris Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions cmd/mcpproxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,13 @@ var (
logDir string

// Security flags
requireMCPAuth bool
readOnlyMode bool
disableManagement bool
allowServerAdd bool
allowServerRemove bool
enablePrompts bool
requireMCPAuth bool
readOnlyMode bool
disableManagement bool
allowServerAdd bool
allowServerRemove bool
enablePrompts bool
aggregateUpstreamPrompts bool

// Output formatting flags (global)
globalOutputFormat string
Expand Down Expand Up @@ -136,6 +137,7 @@ func main() {
serverCmd.Flags().BoolVar(&allowServerAdd, "allow-server-add", true, "Allow adding new servers")
serverCmd.Flags().BoolVar(&allowServerRemove, "allow-server-remove", true, "Allow removing existing servers")
serverCmd.Flags().BoolVar(&enablePrompts, "enable-prompts", true, "Enable prompts for user input")
serverCmd.Flags().BoolVar(&aggregateUpstreamPrompts, "aggregate-upstream-prompts", false, "Aggregate upstream servers' MCP prompts into mcpproxy's prompt list (opt-in)")

// Add search-servers command
searchCmd := createSearchServersCommand()
Expand Down Expand Up @@ -428,6 +430,7 @@ func runServer(cmd *cobra.Command, _ []string) error {
cmdAllowServerAdd, _ := cmd.Flags().GetBool("allow-server-add")
cmdAllowServerRemove, _ := cmd.Flags().GetBool("allow-server-remove")
cmdEnablePrompts, _ := cmd.Flags().GetBool("enable-prompts")
cmdAggregateUpstreamPrompts, _ := cmd.Flags().GetBool("aggregate-upstream-prompts")

// Load configuration first to get logging settings
cfg, err := loadConfig(cmd)
Expand Down Expand Up @@ -552,6 +555,9 @@ func runServer(cmd *cobra.Command, _ []string) error {
if cmd.Flags().Changed("enable-prompts") {
cfg.EnablePrompts = cmdEnablePrompts
}
if cmd.Flags().Changed("aggregate-upstream-prompts") {
cfg.AggregateUpstreamPrompts = cmdAggregateUpstreamPrompts
}

logger.Info("Configuration loaded",
zap.String("data_dir", cfg.DataDir),
Expand Down
5 changes: 4 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,14 +355,16 @@ it and none can double-report it.
{
"debug_search": false,
"enable_prompts": true,
"aggregate_upstream_prompts": false,
"check_server_repo": true
}
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `debug_search` | boolean | `false` | Enable debug logging for search operations |
| `enable_prompts` | boolean | `true` | Enable MCP prompts feature for workflow guidance and interactive assistance with common tasks (finding tools, debugging search, setting up servers, troubleshooting connections) |
| `enable_prompts` | boolean | `true` | Enable mcpproxy's built-in prompts (setup / troubleshoot workflows) and advertise the MCP `prompts` capability. Governs only the built-ins; upstream aggregation is controlled separately by `aggregate_upstream_prompts`. |
| `aggregate_upstream_prompts` | boolean | `false` | **Opt-in.** When `true`, aggregate every connected upstream server's MCP prompts into mcpproxy's own `prompts/list` (exposed as `<server>__<prompt>`). Off by default so users are safe until they deliberately enable it. Requires `enable_prompts: true` (the default) to have any effect. Hot-reloadable. The per-server [`expose_prompts`](#per-server-settings) override further filters which servers contribute once this is on. |
| `check_server_repo` | boolean | `true` | Enable repository detection for MCP servers (shows install commands) |

---
Expand Down Expand Up @@ -414,6 +416,7 @@ it and none can double-report it.
| `enabled` | boolean | No | Enable/disable server (default: `true`) |
| `quarantined` | boolean | No | Security quarantine status (default: `false` for manually added servers, `true` for LLM-added servers) |
| `reconnect_on_use` | boolean | No | When `true`, tool calls to a disconnected server trigger an immediate reconnect attempt (15s timeout) before failing (default: `false`) |
| `expose_prompts` | boolean | No | Per-server override for whether this server's MCP prompts are aggregated into mcpproxy's `prompts/list`. Only takes effect when the global `aggregate_upstream_prompts` master switch is on. Omit to expose prompts whenever the server advertises `Capabilities.Prompts`; `false` opts this server out even if it does. |
| `toon_output` | string | No | Per-server override for the global [`toon_output`](#toon-output-adaptive-result-encoding): `off`, `adaptive`, or `always`. Non-empty value wins over the global for this server's tools; omit to inherit. See [TOON Output](features/toon-output.md). |
| `created` | string | No | ISO 8601 timestamp (auto-generated) |
| `updated` | string | No | ISO 8601 timestamp (auto-updated) |
Expand Down
218 changes: 218 additions & 0 deletions docs/superpowers/specs/2026-08-11-aggregate-upstream-prompts-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
# Aggregate upstream MCP prompts through prompts/list

GitHub issue: [#972](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/972)

## Problem

mcpproxy exposes only two hardcoded prompts (`setup-new-mcp-server`,
`troubleshoot-mcp-server`) via `registerPrompts()` in `internal/server/mcp.go`.
Upstream servers that advertise their own `Capabilities.Prompts` are detected
(`internal/upstream/cli/client.go:203-210`, debug logging only) but never
forwarded. This is inconsistent with how `tools/list` already aggregates
upstream tools (direct routing mode).

## Goals

- `prompts/list` returns the two built-in prompts plus every prompt advertised
by a connected upstream server whose `Capabilities.Prompts != nil`.
- `prompts/get` resolves a (possibly prefixed) prompt name to its owning
upstream server and forwards the request, returning the upstream's
`GetPromptResult` unchanged.
- Behavior is gated by the existing global `enable_prompts` config flag, plus
a new per-server `expose_prompts` override (tri-state; nil inherits the
default-aggregate behavior) so a server can be excluded individually even
though it advertises the capability.

## Non-goals

- A way to force-expose prompts for a server that doesn't advertise
`Capabilities.Prompts` — the per-server flag can only opt a server *out*,
not fabricate a capability it doesn't have.
- Consuming an upstream's `notifications/prompts/list_changed` notification.
Re-aggregation only happens on the proxy's own `servers.changed` event
(connect/disconnect/config change); if a still-connected upstream adds or
removes a prompt without a server-level config change, mcpproxy won't
notice until the next `servers.changed`.

> **Update (PR #973 review):** the routing-mode servers (`direct`,
> `code_execution`, `call_tool`) now also get `WithPromptCapabilities` and
> the aggregated prompt set — the original non-goal above ("only the default
> `retrieve_tools` server supports prompts") turned out to make the feature
> unreachable over Streamable HTTP `/mcp` in every routing mode besides the
> default, since `config.Validate()` normalizes `routing_mode` away from
> `retrieve_tools`. See `RefreshPrompts` and `initRoutingModeServers` in
> `internal/server/mcp_routing.go`.

## Architecture

Mirrors the existing tools-aggregation pattern (specifically direct-mode
tools, `internal/server/mcp_routing.go`), not the `retrieve_tools` BM25
search path — prompts are few and cheap enough to aggregate in full.

### Per-server config

`internal/config/config.go`: add `ExposePrompts *bool` to `ServerConfig`
(`type ServerConfig struct` at line 209), following the same tri-state
pointer convention already used by `IsolationConfig.Enabled *bool` (line
~266, with its `IsEnabled()` nil-safe accessor) elsewhere in this file:

```go
ExposePrompts *bool `json:"expose_prompts,omitempty" mapstructure:"expose-prompts"`
```

- `nil` (unset, the default) → inherit the default-aggregate behavior: this
server's prompts are included if it advertises `Capabilities.Prompts`.
- `false` → excluded from aggregation regardless of advertised capability.
- `true` → explicit no-op today (same effect as `nil` when the capability is
present); reserved so a future global default flip doesn't require a config
migration.

### Upstream client layer

Note: `internal/upstream/cli/client.go` is only used by the `mcpproxy auth`
CLI command and the tray app, not by the runtime aggregation path — it is
out of scope here. The runtime path is `Manager` → `managed.Client` →
`core.Client`.

- `internal/upstream/core/client.go`: add
`ListPrompts(ctx context.Context) ([]mcp.Prompt, error)` (mirrors
`ListTools`: checks connection, returns `nil, nil` if
`serverInfo.Capabilities.Prompts == nil` **or** `c.config.ExposePrompts != nil && !*c.config.ExposePrompts`
— both checks live here so `Manager.ListPrompts` needs no special-casing
beyond the per-client error skip it already mirrors from `DiscoverTools` —
otherwise calls the mark3labs client's `ListPrompts(ctx, mcp.ListPromptsRequest{})`,
returns `result.Prompts`) and
`GetPrompt(ctx context.Context, name string, args map[string]string) (*mcp.GetPromptResult, error)`
(mirrors `CallTool`: checks connection, builds
`mcp.GetPromptRequest{Params: mcp.GetPromptParams{Name: name, Arguments: args}}`,
calls the mark3labs client's `GetPrompt`, returns the result).
- `internal/upstream/managed/client.go`: add `ListPrompts`/`GetPrompt` on
`managed.Client`, mirroring the simple connectivity-check-then-delegate
shape of `managed.Client.CallTool` (not the leader-election/coalescing
shape of `managed.Client.ListTools` — prompts are only refreshed on
`servers.changed`, not per-request, so no coalescing is needed).

### Manager aggregation layer

`internal/upstream/manager.go`:

- `Manager.ListPrompts(ctx context.Context) ([]mcp.Prompt, error)` iterates
`m.clients` (same enabled/quarantined/connected snapshot pattern as
`DiscoverTools`) and calls each client's `ListPrompts` (which already
returns `nil, nil` for capability-less or opted-out servers, per above). On
a per-client error, log a warning and skip that client — the rest of the
aggregation proceeds (mirrors `DiscoverTools` resilience). Each returned
prompt's `Name` is rewritten in place to
`serverName:promptName` (colon) — the same internal-registry convention
`Manager.CallTool` already uses for `"server:tool"`, entirely self-contained
within the `upstream` package (no dependency on the `server` package's `__`
convention).
- `Manager.GetPrompt(ctx context.Context, name string, args map[string]string) (*mcp.GetPromptResult, error)`
takes a colon-qualified `"serverName:promptName"`, parses it via
`strings.SplitN(name, ":", 2)` (identical to `Manager.CallTool`), resolves
the owning client by name (same lookup pattern as `Manager.CallTool`), and
forwards to `targetClient.GetPrompt(ctx, promptName, args)` with the
unqualified prompt name. Errors (bad format, no matching client, upstream
error) propagate unchanged to the caller — no swallowing.

### Server wiring

`internal/server/mcp.go` / `mcp_routing.go`:

- `registerPrompts()` is unchanged — still registers the 2 built-ins.
- New `RefreshPrompts()` on `MCPProxyServer`: early-returns if
`!p.config.EnablePrompts`. Otherwise calls `p.upstreamManager.ListPrompts(ctx)`;
for each colon-qualified `mcp.Prompt` returned, splits it back into
`serverName`/`promptName` (the same inline `strings.SplitN(name, ":", 2)`
pattern already used elsewhere in this package, e.g.
`internal/server/mcp.go:1856`), builds a **display** copy of the prompt
with `Name` rewritten to the client-facing `serverName__promptName` via a
new one-line `FormatDirectPromptName` helper (a thin, readably-named
wrapper around the existing `FormatDirectToolName` — same `__` separator,
no duplicated logic), and pairs it with a `ServerPrompt.Handler` closure
that captures the *original* colon-qualified name and calls
`p.upstreamManager.GetPrompt(ctx, "serverName:promptName", request.Params.Arguments)`.
The mcp-go library dispatches incoming `prompts/get` requests to this
handler by matching the registered (client-facing, `__`) name, so no
reverse-parsing of the `__` name is ever needed — the closure already
knows which server it belongs to. Built-ins are added to the same slice
unchanged, then `p.server.SetPrompts(all...)` atomically replaces the full
prompt set — same pattern as `RefreshDirectModeTools`'s use of `SetTools`.
- Hook `RefreshPrompts()` into the existing `EventTypeServersChanged` branch
in `listenForRoutingModeRefresh` (`internal/server/server.go:379-386`),
alongside `RefreshDirectModeTools()` / `RefreshCodeExecModeTools()`. No
separate manual call at startup is needed — upstream servers connect
asynchronously and `servers.changed` fires once they do, matching the
existing comment/pattern for direct-mode tools.

## Data flow

1. Upstream server connects → `EventTypeServersChanged` fires →
`RefreshPrompts()` calls `Manager.ListPrompts` (colon-qualified names) →
builds display copies (`__`-qualified) with bound handler closures →
`SetPrompts` on `p.server`.
2. Client sends `prompts/list` → mcp-go's built-in handler returns the
current `s.prompts` map (built-ins + aggregated, kept in sync by step 1).
3. Client sends `prompts/get` with a `__`-qualified name → mcp-go dispatches
to that prompt's bound handler closure → calls `Manager.GetPrompt` with
the closure's captured colon-qualified name → resolves server → forwards
→ returns the upstream's `GetPromptResult` unchanged. Built-in prompt
names are unprefixed and never collide with an aggregated name (which is
always prefixed by a unique server name).

## Error handling

- `Manager.ListPrompts`: per-server failure → log warning, skip that server,
continue with the rest.
- `Manager.GetPrompt`: malformed name / unknown server / upstream error → all
propagate as a normal MCP error to the client, same as `CallTool` today.
- No collision handling required: the qualifying prefix (colon internally,
`__` for display) is always the connecting server's (unique) name.

## Testing

`Manager`, `managed.Client`, and `core.Client` are concrete types with no
mock-friendly interface boundary in this codebase today (confirmed: neither
`ListTools`/`CallTool` nor `DiscoverTools` have interface-based unit tests —
coverage there comes from real-but-disconnected `httptest` servers, e.g.
`internal/upstream/client_test.go`, or from the e2e suite). This feature
follows the same real-server approach rather than introducing mocks:

- `internal/upstream/core/client_test.go` (new): spin up a real
`mcpserver.NewTestStreamableHTTPServer` backed by an `mcpserver.MCPServer`
with `mcpserver.WithPromptCapabilities(true)` and 1-2 `AddPrompt`-registered
prompts; connect a `core.Client` to it (`MCPPROXY_DISABLE_OAUTH=true`,
mirroring `TestClient_Connect_WorkingTransports`); assert `ListPrompts`
returns them and `GetPrompt` returns the expected `GetPromptResult`. A
second case points at a test server with no prompt capability and asserts
`ListPrompts` returns `nil, nil`. A third case sets
`ExposePrompts: BoolPtr(false)` on the same capable server and asserts
`ListPrompts` returns `nil, nil` without the test server ever being asked
(verified via a call counter in the test server's list-prompts handler).
- `internal/upstream/manager_test.go` (new): using `Manager.AddServerConfig`
+ `Manager.ConnectAll` against two real test servers (one with prompts, one
without) plus one server config pointed at a closed port (never connects),
assert `Manager.ListPrompts` returns the capable server's prompts with
colon-qualified names and silently omits the disconnected one. Separately,
test `Manager.GetPrompt` resolution errors (malformed name with no colon,
unknown server name) without needing any connected client, plus one
success case against the real connected server.
- `internal/server/mcp_routing_test.go`: table tests for the new
`FormatDirectPromptName` helper (pure function), alongside existing tests
for `FormatDirectToolName`.
- `internal/server`: extract the aggregation-to-`ServerPrompt` step of
`RefreshPrompts` into a pure helper,
`buildAggregatedServerPrompts(builtins []mcpserver.ServerPrompt, upstreamPrompts []mcp.Prompt, getPrompt func(ctx context.Context, name string, args map[string]string) (*mcp.GetPromptResult, error)) []mcpserver.ServerPrompt`,
so it can be unit-tested with a fake `getPrompt` func — no real server or
manager needed. Table tests assert: built-ins pass through unchanged;
colon-qualified upstream prompts get `__`-renamed; each aggregated
prompt's handler calls `getPrompt` with the original colon-qualified name
and forwards its result/error unchanged. `RefreshPrompts()` itself (the
thin glue that calls `Manager.ListPrompts` and `SetPrompts`) is not
separately unit-tested, consistent with its siblings
`RefreshDirectModeTools`/`RefreshCodeExecModeTools`, neither of which have
dedicated tests today.
- `internal/config/config_test.go`: round-trip `ExposePrompts` (nil / true /
false) through JSON marshal-unmarshal on a `ServerConfig` value, asserting
the pointer value survives the round trip in all three states.
- `go test ./internal/... -race` per repo convention.
19 changes: 18 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,14 @@ type Config struct {
// Prompts settings
EnablePrompts bool `json:"enable_prompts" mapstructure:"enable-prompts"`

// AggregateUpstreamPrompts, when true, aggregates every connected upstream
// server's advertised MCP prompts into mcpproxy's own prompts/list
// (exposed as "<server>__<prompt>"). OFF by default: users are safe by
// default and opt in deliberately. EnablePrompts still governs the built-in
// prompts + the prompts capability; this flag gates ONLY the upstream
// aggregation performed by RefreshPrompts. Hot-reloadable.
AggregateUpstreamPrompts bool `json:"aggregate_upstream_prompts" mapstructure:"aggregate-upstream-prompts"`

// Repository detection settings
CheckServerRepo bool `json:"check_server_repo" mapstructure:"check-server-repo"`

Expand Down Expand Up @@ -621,6 +629,12 @@ type ServerConfig struct {
Isolation *IsolationConfig `json:"isolation,omitempty" mapstructure:"isolation"` // Per-server isolation settings
ReconnectOnUse bool `json:"reconnect_on_use,omitempty" mapstructure:"reconnect-on-use"` // Attempt reconnection when a tool call targets a disconnected server

// ExposePrompts overrides whether this server's advertised MCP prompts are
// aggregated into mcpproxy's prompts/list. nil (default) inherits the
// default-aggregate behavior (included if the server advertises
// Capabilities.Prompts); false excludes it regardless of capability.
ExposePrompts *bool `json:"expose_prompts,omitempty" mapstructure:"expose-prompts"`

// LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched
// HTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted
// when the server is configured with both Command and an HTTP/SSE URL — i.e.,
Expand Down Expand Up @@ -1653,9 +1667,12 @@ func DefaultConfig() *Config {
AllowServerAdd: true,
AllowServerRemove: true,

// Prompts enabled by default
// Prompts enabled by default (built-in prompts + capability)
EnablePrompts: true,

// Upstream prompt aggregation OFF by default (opt-in) — see field doc.
AggregateUpstreamPrompts: false,

// Repository detection enabled by default
CheckServerRepo: true,

Expand Down
Loading
Loading