diff --git a/docs/features/security-quarantine.md b/docs/features/security-quarantine.md index ff68b1ad..30902133 100644 --- a/docs/features/security-quarantine.md +++ b/docs/features/security-quarantine.md @@ -152,6 +152,24 @@ built-in `tpa-descriptions` scanner. Its findings appear in the scan report `threat_level`, `confidence`, and the contributing check `signals`. See [Tool Scanner](/features/tool-scanner) for the full rule reference. +## Prompt rug-pull baseline (spec 100) + +When upstream **prompt aggregation** is enabled (`aggregate_upstream_prompts: true`, off by default), mcpproxy keeps a per-prompt approval baseline that mirrors the tool rug-pull machinery. A trusted server that ships a benign prompt and later mutates its **advertised metadata** (name, description, or argument descriptions) has that changed prompt **withheld from `prompts/list`** until it is approved — closing the gap where a subtler injection that passes the poison scanner could still slip in via a later edit. + +- **Scope — metadata only.** The baseline hashes the prompt's advertised metadata, not its `prompts/get` message content (which is materialised fresh per call and has no list-time artifact to baseline). Content is defended separately by output sanitisation and size caps. This is an inherent limit, not a shortcut. +- **Enforcement is by withholding.** A held prompt is simply not registered, so it is absent from `prompts/list` and `prompts/get` on it fails natively. There is no separate runtime gate. +- **Trust parity.** A server with `trust_mode: auto` (or `auto_approve_tool_changes: true`) auto-approves its prompt changes; `manual` holds them. Disabling quarantine globally (`quarantine_enabled: false`) auto-approves. + +Manage held prompts with the `quarantine_security` MCP tool: + +```jsonc +// see what is held (all servers, or one via "name") +{ "operation": "inspect_prompts", "name": "github" } +// approve one held prompt, or all for a server +{ "operation": "approve_prompt", "name": "github", "prompt_name": "summarize_pr" } +{ "operation": "approve_all_prompts", "name": "github" } +``` + ## Managing Quarantine ### View Quarantined Servers diff --git a/internal/server/mcp.go b/internal/server/mcp.go index c4dd7d40..5d6b38d7 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -1152,6 +1152,9 @@ func (p *MCPProxyServer) buildManagementTools() []mcpserver.ServerTool { mcp.Description("Per-server trust tier governing new-server admission AND tool-change approval (spec 086): 'auto' = approve without scanning; 'scan' = auto-approve only when the fast offline TPA scan is green, else hold for review; 'manual' = human reviews every change. Empty → manual (secure default). Used with add/update/patch."), mcp.Enum("auto", "scan", "manual"), ), + mcp.WithBoolean("expose_prompts", + mcp.Description("Per-server prompt-aggregation override (F9): true = include this server's MCP prompts in mcpproxy's aggregated prompts/list; false = exclude them regardless of capability. Omit to leave unchanged (patch) / inherit the default (aggregate if advertised). Only meaningful when aggregate_upstream_prompts is enabled globally. Used with add/update/patch."), + ), ) tools = append(tools, mcpserver.ServerTool{Tool: upstreamServersTool, Handler: p.handleUpstreamServers}) } @@ -1166,15 +1169,18 @@ func (p *MCPProxyServer) buildManagementTools() []mcpserver.ServerTool { mcp.WithOpenWorldHintAnnotation(false), mcp.WithString("operation", mcp.Required(), - mcp.Description("Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled."), - mcp.Enum("list_quarantined", "inspect_quarantined", "quarantine_server", "inspect_tools", "approve_tool", "approve_all_tools", "block_tool", "block_all_tools", "enable_tool", "disable_tool"), + mcp.Description("Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool, inspect_prompts, approve_prompt, approve_all_prompts. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled. The prompt operations (spec 100) manage aggregated upstream prompts held by the metadata rug-pull baseline: a prompt whose advertised metadata changed since approval is withheld from prompts/list until approved."), + mcp.Enum("list_quarantined", "inspect_quarantined", "quarantine_server", "inspect_tools", "approve_tool", "approve_all_tools", "block_tool", "block_all_tools", "enable_tool", "disable_tool", "inspect_prompts", "approve_prompt", "approve_all_prompts"), ), mcp.WithString("name", - mcp.Description("Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools)"), + mcp.Description("Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, approve_prompt, approve_all_prompts)"), ), mcp.WithString("tool_name", mcp.Description("Tool name (required for approve_tool and block_tool operations)"), ), + mcp.WithString("prompt_name", + mcp.Description("Prompt name (required for approve_prompt; spec 100)"), + ), ) tools = append(tools, mcpserver.ServerTool{Tool: quarantineSecurityTool, Handler: p.handleQuarantineSecurity}) } @@ -3410,6 +3416,12 @@ func (p *MCPProxyServer) handleQuarantineSecurity(ctx context.Context, request m result, opErr = p.handleSetToolEnabledByName(request, true) case "disable_tool": result, opErr = p.handleSetToolEnabledByName(request, false) + case "inspect_prompts": + result, opErr = p.handleInspectPromptApprovals(request) + case "approve_prompt": + result, opErr = p.handleApprovePromptByName(request) + case "approve_all_prompts": + result, opErr = p.handleApproveAllPromptsByServer(request) default: p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", fmt.Sprintf("Unknown quarantine operation: %s", operation), time.Since(startTime).Milliseconds(), args, nil, nil, "") return mcp.NewToolResultError(fmt.Sprintf("Unknown quarantine operation: %s", operation)), nil @@ -4603,6 +4615,16 @@ func (p *MCPProxyServer) handleAddUpstream(ctx context.Context, request mcp.Call TrustMode: trustMode, // spec 086: carry the per-server trust_mode through on create } + // F9: optional per-server expose_prompts override on add. GetBool can't tell + // absent from false, so probe the raw args for presence. + if rawArgs := request.GetArguments(); rawArgs != nil { + if raw, ok := rawArgs["expose_prompts"]; ok { + if b, ok := raw.(bool); ok { + serverConfig.ExposePrompts = &b + } + } + } + // Save to storage if err := p.storage.SaveUpstreamServer(serverConfig); err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to add upstream: %v", err)), nil @@ -5119,6 +5141,19 @@ func (p *MCPProxyServer) buildPatchConfigFromRequest(request mcp.CallToolRequest patch.InitTimeout = &v } + // F9: per-server expose_prompts override. GetBool collapses absent→false, so + // detect key presence in the raw args and only set the pointer when the caller + // actually provided it (nil = leave unchanged for MergeServerConfig). + if args := request.GetArguments(); args != nil { + if raw, ok := args["expose_prompts"]; ok { + b, ok := raw.(bool) + if !ok { + return nil, opts, fmt.Errorf("invalid expose_prompts: must be a boolean, got %T", raw) + } + patch.ExposePrompts = &b + } + } + // Handle oauth JSON string - deep merge for nested config if oauthJSON := request.GetString("oauth_json", ""); oauthJSON != "" { // Check for explicit null removal diff --git a/internal/server/prompt_quarantine.go b/internal/server/prompt_quarantine.go index eae1d93f..dfe81474 100644 --- a/internal/server/prompt_quarantine.go +++ b/internal/server/prompt_quarantine.go @@ -338,3 +338,80 @@ func (p *MCPProxyServer) ApproveAllPrompts(serverName, approvedBy string) (int, } return approved, nil } + +// --- MCP quarantine_security prompt operations (spec 100 FR-7) --- + +// handleInspectPromptApprovals returns the prompt approval records for a server +// (all servers when 'name' is omitted), with pending/changed counts so an agent +// can see which prompts the rug-pull baseline is withholding. +func (p *MCPProxyServer) handleInspectPromptApprovals(request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if p.storage == nil { + return mcp.NewToolResultError("storage unavailable"), nil + } + serverName := request.GetString("name", "") + recs, err := p.storage.ListPromptApprovals(serverName) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to list prompt approvals: %v", err)), nil + } + type promptView struct { + Server string `json:"server"` + Prompt string `json:"prompt"` + Status string `json:"status"` + ChangedFrom string `json:"changed_from,omitempty"` + } + var pending, changed int + views := make([]promptView, 0, len(recs)) + for _, r := range recs { + switch r.Status { + case promptStatusPending: + pending++ + case promptStatusChanged: + changed++ + } + v := promptView{Server: r.ServerName, Prompt: r.PromptName, Status: r.Status} + if r.Status == promptStatusChanged && r.PreviousDescription != "" { + v.ChangedFrom = r.PreviousDescription + } + views = append(views, v) + } + payload := map[string]interface{}{ + "prompts": views, + "pending_count": pending, + "changed_count": changed, + "action_required": pending + changed, + } + b, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to encode: %v", err)), nil + } + return mcp.NewToolResultText(string(b)), nil +} + +// handleApprovePromptByName approves one held prompt (re-baselines it). +func (p *MCPProxyServer) handleApprovePromptByName(request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + serverName := request.GetString("name", "") + if serverName == "" { + return mcp.NewToolResultError("Missing required parameter 'name' (server name)"), nil + } + promptName := request.GetString("prompt_name", "") + if promptName == "" { + return mcp.NewToolResultError("Missing required parameter 'prompt_name'"), nil + } + if err := p.ApprovePrompt(serverName, promptName, "mcp"); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to approve prompt '%s': %v", promptName, err)), nil + } + return mcp.NewToolResultText(fmt.Sprintf("Prompt '%s' on server '%s' has been approved.", promptName, serverName)), nil +} + +// handleApproveAllPromptsByServer approves every held prompt for a server. +func (p *MCPProxyServer) handleApproveAllPromptsByServer(request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + serverName := request.GetString("name", "") + if serverName == "" { + return mcp.NewToolResultError("Missing required parameter 'name' (server name)"), nil + } + n, err := p.ApproveAllPrompts(serverName, "mcp") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to approve prompts for '%s': %v", serverName, err)), nil + } + return mcp.NewToolResultText(fmt.Sprintf("Approved %d prompt(s) on server '%s'.", n, serverName)), nil +} diff --git a/internal/server/prompt_quarantine_test.go b/internal/server/prompt_quarantine_test.go index c25b3898..096b40b1 100644 --- a/internal/server/prompt_quarantine_test.go +++ b/internal/server/prompt_quarantine_test.go @@ -162,3 +162,47 @@ func TestApproveAllPrompts(t *testing.T) { // Guard: the storage record type is what we expect (compile-time contract). var _ = storage.PromptApprovalRecord{} + +// --- MCP quarantine_security prompt op handlers --- + +func TestHandlePromptApprovalOps(t *testing.T) { + proxy := manualTrustProxy(t) + proxy.checkPromptApprovals([]mcp.Prompt{{Name: "srv:a", Description: "1"}, {Name: "srv:b", Description: "2"}}) + + // inspect_prompts reports the two held prompts. + insp, err := proxy.handleInspectPromptApprovals(mcp.CallToolRequest{Params: mcp.CallToolParams{ + Arguments: map[string]interface{}{"name": "srv"}, + }}) + require.NoError(t, err) + require.False(t, insp.IsError) + body := insp.Content[0].(mcp.TextContent).Text + assert.Contains(t, body, "\"pending_count\": 2") + assert.Contains(t, body, "\"action_required\": 2") + + // approve_prompt approves one. + ap, err := proxy.handleApprovePromptByName(mcp.CallToolRequest{Params: mcp.CallToolParams{ + Arguments: map[string]interface{}{"name": "srv", "prompt_name": "a"}, + }}) + require.NoError(t, err) + assert.False(t, ap.IsError) + rec, _ := proxy.storage.GetPromptApproval("srv", "a") + assert.Equal(t, promptStatusApproved, rec.Status) + + // approve_all_prompts approves the rest. + aa, err := proxy.handleApproveAllPromptsByServer(mcp.CallToolRequest{Params: mcp.CallToolParams{ + Arguments: map[string]interface{}{"name": "srv"}, + }}) + require.NoError(t, err) + assert.Contains(t, aa.Content[0].(mcp.TextContent).Text, "Approved 1 prompt") + recB, _ := proxy.storage.GetPromptApproval("srv", "b") + assert.Equal(t, promptStatusApproved, recB.Status) +} + +func TestHandleApprovePromptByName_MissingArgs(t *testing.T) { + proxy := manualTrustProxy(t) + res, err := proxy.handleApprovePromptByName(mcp.CallToolRequest{Params: mcp.CallToolParams{ + Arguments: map[string]interface{}{"name": "srv"}, + }}) + require.NoError(t, err) + assert.True(t, res.IsError, "missing prompt_name is an error result") +} diff --git a/internal/server/testdata/tools_list_prefeature.golden.json b/internal/server/testdata/tools_list_prefeature.golden.json index c081365b..816b2b2b 100644 --- a/internal/server/testdata/tools_list_prefeature.golden.json +++ b/internal/server/testdata/tools_list_prefeature.golden.json @@ -171,11 +171,11 @@ "inputSchema": { "properties": { "name": { - "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools)", + "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, approve_prompt, approve_all_prompts)", "type": "string" }, "operation": { - "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled.", + "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool, inspect_prompts, approve_prompt, approve_all_prompts. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled. The prompt operations (spec 100) manage aggregated upstream prompts held by the metadata rug-pull baseline: a prompt whose advertised metadata changed since approval is withheld from prompts/list until approved.", "enum": [ "list_quarantined", "inspect_quarantined", @@ -186,10 +186,17 @@ "block_tool", "block_all_tools", "enable_tool", - "disable_tool" + "disable_tool", + "inspect_prompts", + "approve_prompt", + "approve_all_prompts" ], "type": "string" }, + "prompt_name": { + "description": "Prompt name (required for approve_prompt; spec 100)", + "type": "string" + }, "tool_name": { "description": "Tool name (required for approve_tool and block_tool operations)", "type": "string" @@ -371,6 +378,10 @@ "description": "Environment variables for stdio servers as JSON object (e.g., '{\"API_KEY\": \"value\"}'). For update/patch: MERGES with existing vars (new keys added, existing keys updated).", "type": "string" }, + "expose_prompts": { + "description": "Per-server prompt-aggregation override (F9): true = include this server's MCP prompts in mcpproxy's aggregated prompts/list; false = exclude them regardless of capability. Omit to leave unchanged (patch) / inherit the default (aggregate if advertised). Only meaningful when aggregate_upstream_prompts is enabled globally. Used with add/update/patch.", + "type": "boolean" + }, "headers_json": { "description": "HTTP headers for authentication as JSON object (e.g., '{\"Authorization\": \"Bearer token\"}'). For update/patch: MERGES with existing headers (new keys added, existing keys updated).", "type": "string" @@ -431,10 +442,6 @@ "description": "Registry id to add from (e.g. 'pulse') - required for add_from_registry. Use the 'list_registries'/'search_servers' tools to discover registries and server ids.", "type": "string" }, - "url": { - "description": "Server URL for HTTP/SSE servers (e.g., 'http://localhost:3001')", - "type": "string" - }, "trust_mode": { "description": "Per-server trust tier governing new-server admission AND tool-change approval (spec 086): 'auto' = approve without scanning; 'scan' = auto-approve only when the fast offline TPA scan is green, else hold for review; 'manual' = human reviews every change. Empty → manual (secure default). Used with add/update/patch.", "enum": [ @@ -443,6 +450,10 @@ "manual" ], "type": "string" + }, + "url": { + "description": "Server URL for HTTP/SSE servers (e.g., 'http://localhost:3001')", + "type": "string" } }, "required": [ @@ -505,11 +516,11 @@ "inputSchema": { "properties": { "name": { - "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools)", + "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, approve_prompt, approve_all_prompts)", "type": "string" }, "operation": { - "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled.", + "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool, inspect_prompts, approve_prompt, approve_all_prompts. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled. The prompt operations (spec 100) manage aggregated upstream prompts held by the metadata rug-pull baseline: a prompt whose advertised metadata changed since approval is withheld from prompts/list until approved.", "enum": [ "list_quarantined", "inspect_quarantined", @@ -520,10 +531,17 @@ "block_tool", "block_all_tools", "enable_tool", - "disable_tool" + "disable_tool", + "inspect_prompts", + "approve_prompt", + "approve_all_prompts" ], "type": "string" }, + "prompt_name": { + "description": "Prompt name (required for approve_prompt; spec 100)", + "type": "string" + }, "tool_name": { "description": "Tool name (required for approve_tool and block_tool operations)", "type": "string" @@ -662,6 +680,10 @@ "description": "Environment variables for stdio servers as JSON object (e.g., '{\"API_KEY\": \"value\"}'). For update/patch: MERGES with existing vars (new keys added, existing keys updated).", "type": "string" }, + "expose_prompts": { + "description": "Per-server prompt-aggregation override (F9): true = include this server's MCP prompts in mcpproxy's aggregated prompts/list; false = exclude them regardless of capability. Omit to leave unchanged (patch) / inherit the default (aggregate if advertised). Only meaningful when aggregate_upstream_prompts is enabled globally. Used with add/update/patch.", + "type": "boolean" + }, "headers_json": { "description": "HTTP headers for authentication as JSON object (e.g., '{\"Authorization\": \"Bearer token\"}'). For update/patch: MERGES with existing headers (new keys added, existing keys updated).", "type": "string" @@ -722,10 +744,6 @@ "description": "Registry id to add from (e.g. 'pulse') - required for add_from_registry. Use the 'list_registries'/'search_servers' tools to discover registries and server ids.", "type": "string" }, - "url": { - "description": "Server URL for HTTP/SSE servers (e.g., 'http://localhost:3001')", - "type": "string" - }, "trust_mode": { "description": "Per-server trust tier governing new-server admission AND tool-change approval (spec 086): 'auto' = approve without scanning; 'scan' = auto-approve only when the fast offline TPA scan is green, else hold for review; 'manual' = human reviews every change. Empty → manual (secure default). Used with add/update/patch.", "enum": [ @@ -734,6 +752,10 @@ "manual" ], "type": "string" + }, + "url": { + "description": "Server URL for HTTP/SSE servers (e.g., 'http://localhost:3001')", + "type": "string" } }, "required": [ @@ -893,11 +915,11 @@ "inputSchema": { "properties": { "name": { - "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools)", + "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, approve_prompt, approve_all_prompts)", "type": "string" }, "operation": { - "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled.", + "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool, inspect_prompts, approve_prompt, approve_all_prompts. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled. The prompt operations (spec 100) manage aggregated upstream prompts held by the metadata rug-pull baseline: a prompt whose advertised metadata changed since approval is withheld from prompts/list until approved.", "enum": [ "list_quarantined", "inspect_quarantined", @@ -908,10 +930,17 @@ "block_tool", "block_all_tools", "enable_tool", - "disable_tool" + "disable_tool", + "inspect_prompts", + "approve_prompt", + "approve_all_prompts" ], "type": "string" }, + "prompt_name": { + "description": "Prompt name (required for approve_prompt; spec 100)", + "type": "string" + }, "tool_name": { "description": "Tool name (required for approve_tool and block_tool operations)", "type": "string" @@ -1085,6 +1114,10 @@ "description": "Environment variables for stdio servers as JSON object (e.g., '{\"API_KEY\": \"value\"}'). For update/patch: MERGES with existing vars (new keys added, existing keys updated).", "type": "string" }, + "expose_prompts": { + "description": "Per-server prompt-aggregation override (F9): true = include this server's MCP prompts in mcpproxy's aggregated prompts/list; false = exclude them regardless of capability. Omit to leave unchanged (patch) / inherit the default (aggregate if advertised). Only meaningful when aggregate_upstream_prompts is enabled globally. Used with add/update/patch.", + "type": "boolean" + }, "headers_json": { "description": "HTTP headers for authentication as JSON object (e.g., '{\"Authorization\": \"Bearer token\"}'). For update/patch: MERGES with existing headers (new keys added, existing keys updated).", "type": "string" @@ -1145,10 +1178,6 @@ "description": "Registry id to add from (e.g. 'pulse') - required for add_from_registry. Use the 'list_registries'/'search_servers' tools to discover registries and server ids.", "type": "string" }, - "url": { - "description": "Server URL for HTTP/SSE servers (e.g., 'http://localhost:3001')", - "type": "string" - }, "trust_mode": { "description": "Per-server trust tier governing new-server admission AND tool-change approval (spec 086): 'auto' = approve without scanning; 'scan' = auto-approve only when the fast offline TPA scan is green, else hold for review; 'manual' = human reviews every change. Empty → manual (secure default). Used with add/update/patch.", "enum": [ @@ -1157,6 +1186,10 @@ "manual" ], "type": "string" + }, + "url": { + "description": "Server URL for HTTP/SSE servers (e.g., 'http://localhost:3001')", + "type": "string" } }, "required": [ diff --git a/internal/server/testdata/toolslist_goldens/code_execution_mode.json b/internal/server/testdata/toolslist_goldens/code_execution_mode.json index bab63007..b9f6af6e 100644 --- a/internal/server/testdata/toolslist_goldens/code_execution_mode.json +++ b/internal/server/testdata/toolslist_goldens/code_execution_mode.json @@ -52,11 +52,11 @@ "inputSchema": { "properties": { "name": { - "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools)", + "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, approve_prompt, approve_all_prompts)", "type": "string" }, "operation": { - "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled.", + "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool, inspect_prompts, approve_prompt, approve_all_prompts. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled. The prompt operations (spec 100) manage aggregated upstream prompts held by the metadata rug-pull baseline: a prompt whose advertised metadata changed since approval is withheld from prompts/list until approved.", "enum": [ "list_quarantined", "inspect_quarantined", @@ -67,10 +67,17 @@ "block_tool", "block_all_tools", "enable_tool", - "disable_tool" + "disable_tool", + "inspect_prompts", + "approve_prompt", + "approve_all_prompts" ], "type": "string" }, + "prompt_name": { + "description": "Prompt name (required for approve_prompt; spec 100)", + "type": "string" + }, "tool_name": { "description": "Tool name (required for approve_tool and block_tool operations)", "type": "string" @@ -209,6 +216,10 @@ "description": "Environment variables for stdio servers as JSON object (e.g., '{\"API_KEY\": \"value\"}'). For update/patch: MERGES with existing vars (new keys added, existing keys updated).", "type": "string" }, + "expose_prompts": { + "description": "Per-server prompt-aggregation override (F9): true = include this server's MCP prompts in mcpproxy's aggregated prompts/list; false = exclude them regardless of capability. Omit to leave unchanged (patch) / inherit the default (aggregate if advertised). Only meaningful when aggregate_upstream_prompts is enabled globally. Used with add/update/patch.", + "type": "boolean" + }, "headers_json": { "description": "HTTP headers for authentication as JSON object (e.g., '{\"Authorization\": \"Bearer token\"}'). For update/patch: MERGES with existing headers (new keys added, existing keys updated).", "type": "string" diff --git a/internal/server/testdata/toolslist_goldens/default_server.json b/internal/server/testdata/toolslist_goldens/default_server.json index b7e8267d..a4f4680c 100644 --- a/internal/server/testdata/toolslist_goldens/default_server.json +++ b/internal/server/testdata/toolslist_goldens/default_server.json @@ -192,11 +192,11 @@ "inputSchema": { "properties": { "name": { - "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools)", + "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, approve_prompt, approve_all_prompts)", "type": "string" }, "operation": { - "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled.", + "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool, inspect_prompts, approve_prompt, approve_all_prompts. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled. The prompt operations (spec 100) manage aggregated upstream prompts held by the metadata rug-pull baseline: a prompt whose advertised metadata changed since approval is withheld from prompts/list until approved.", "enum": [ "list_quarantined", "inspect_quarantined", @@ -207,10 +207,17 @@ "block_tool", "block_all_tools", "enable_tool", - "disable_tool" + "disable_tool", + "inspect_prompts", + "approve_prompt", + "approve_all_prompts" ], "type": "string" }, + "prompt_name": { + "description": "Prompt name (required for approve_prompt; spec 100)", + "type": "string" + }, "tool_name": { "description": "Tool name (required for approve_tool and block_tool operations)", "type": "string" @@ -404,6 +411,10 @@ "description": "Environment variables for stdio servers as JSON object (e.g., '{\"API_KEY\": \"value\"}'). For update/patch: MERGES with existing vars (new keys added, existing keys updated).", "type": "string" }, + "expose_prompts": { + "description": "Per-server prompt-aggregation override (F9): true = include this server's MCP prompts in mcpproxy's aggregated prompts/list; false = exclude them regardless of capability. Omit to leave unchanged (patch) / inherit the default (aggregate if advertised). Only meaningful when aggregate_upstream_prompts is enabled globally. Used with add/update/patch.", + "type": "boolean" + }, "headers_json": { "description": "HTTP headers for authentication as JSON object (e.g., '{\"Authorization\": \"Bearer token\"}'). For update/patch: MERGES with existing headers (new keys added, existing keys updated).", "type": "string" diff --git a/internal/server/testdata/toolslist_goldens/pre099/default_server.json b/internal/server/testdata/toolslist_goldens/pre099/default_server.json index 777607c1..f5fe0794 100644 --- a/internal/server/testdata/toolslist_goldens/pre099/default_server.json +++ b/internal/server/testdata/toolslist_goldens/pre099/default_server.json @@ -173,11 +173,11 @@ "inputSchema": { "properties": { "name": { - "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools)", + "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, approve_prompt, approve_all_prompts)", "type": "string" }, "operation": { - "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled.", + "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool, inspect_prompts, approve_prompt, approve_all_prompts. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled. The prompt operations (spec 100) manage aggregated upstream prompts held by the metadata rug-pull baseline: a prompt whose advertised metadata changed since approval is withheld from prompts/list until approved.", "enum": [ "list_quarantined", "inspect_quarantined", @@ -188,10 +188,17 @@ "block_tool", "block_all_tools", "enable_tool", - "disable_tool" + "disable_tool", + "inspect_prompts", + "approve_prompt", + "approve_all_prompts" ], "type": "string" }, + "prompt_name": { + "description": "Prompt name (required for approve_prompt; spec 100)", + "type": "string" + }, "tool_name": { "description": "Tool name (required for approve_tool and block_tool operations)", "type": "string" @@ -385,6 +392,10 @@ "description": "Environment variables for stdio servers as JSON object (e.g., '{\"API_KEY\": \"value\"}'). For update/patch: MERGES with existing vars (new keys added, existing keys updated).", "type": "string" }, + "expose_prompts": { + "description": "Per-server prompt-aggregation override (F9): true = include this server's MCP prompts in mcpproxy's aggregated prompts/list; false = exclude them regardless of capability. Omit to leave unchanged (patch) / inherit the default (aggregate if advertised). Only meaningful when aggregate_upstream_prompts is enabled globally. Used with add/update/patch.", + "type": "boolean" + }, "headers_json": { "description": "HTTP headers for authentication as JSON object (e.g., '{\"Authorization\": \"Bearer token\"}'). For update/patch: MERGES with existing headers (new keys added, existing keys updated).", "type": "string" diff --git a/internal/server/testdata/toolslist_goldens/pre099/retrieve_tools_mode.json b/internal/server/testdata/toolslist_goldens/pre099/retrieve_tools_mode.json index c68cdd28..10faccda 100644 --- a/internal/server/testdata/toolslist_goldens/pre099/retrieve_tools_mode.json +++ b/internal/server/testdata/toolslist_goldens/pre099/retrieve_tools_mode.json @@ -198,11 +198,11 @@ "inputSchema": { "properties": { "name": { - "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools)", + "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, approve_prompt, approve_all_prompts)", "type": "string" }, "operation": { - "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled.", + "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool, inspect_prompts, approve_prompt, approve_all_prompts. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled. The prompt operations (spec 100) manage aggregated upstream prompts held by the metadata rug-pull baseline: a prompt whose advertised metadata changed since approval is withheld from prompts/list until approved.", "enum": [ "list_quarantined", "inspect_quarantined", @@ -213,10 +213,17 @@ "block_tool", "block_all_tools", "enable_tool", - "disable_tool" + "disable_tool", + "inspect_prompts", + "approve_prompt", + "approve_all_prompts" ], "type": "string" }, + "prompt_name": { + "description": "Prompt name (required for approve_prompt; spec 100)", + "type": "string" + }, "tool_name": { "description": "Tool name (required for approve_tool and block_tool operations)", "type": "string" @@ -406,6 +413,10 @@ "description": "Environment variables for stdio servers as JSON object (e.g., '{\"API_KEY\": \"value\"}'). For update/patch: MERGES with existing vars (new keys added, existing keys updated).", "type": "string" }, + "expose_prompts": { + "description": "Per-server prompt-aggregation override (F9): true = include this server's MCP prompts in mcpproxy's aggregated prompts/list; false = exclude them regardless of capability. Omit to leave unchanged (patch) / inherit the default (aggregate if advertised). Only meaningful when aggregate_upstream_prompts is enabled globally. Used with add/update/patch.", + "type": "boolean" + }, "headers_json": { "description": "HTTP headers for authentication as JSON object (e.g., '{\"Authorization\": \"Bearer token\"}'). For update/patch: MERGES with existing headers (new keys added, existing keys updated).", "type": "string" diff --git a/internal/server/testdata/toolslist_goldens/retrieve_tools_mode.json b/internal/server/testdata/toolslist_goldens/retrieve_tools_mode.json index ddd5dbdc..d0b2dbd4 100644 --- a/internal/server/testdata/toolslist_goldens/retrieve_tools_mode.json +++ b/internal/server/testdata/toolslist_goldens/retrieve_tools_mode.json @@ -217,11 +217,11 @@ "inputSchema": { "properties": { "name": { - "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools)", + "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, approve_prompt, approve_all_prompts)", "type": "string" }, "operation": { - "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled.", + "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool, inspect_prompts, approve_prompt, approve_all_prompts. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled. The prompt operations (spec 100) manage aggregated upstream prompts held by the metadata rug-pull baseline: a prompt whose advertised metadata changed since approval is withheld from prompts/list until approved.", "enum": [ "list_quarantined", "inspect_quarantined", @@ -232,10 +232,17 @@ "block_tool", "block_all_tools", "enable_tool", - "disable_tool" + "disable_tool", + "inspect_prompts", + "approve_prompt", + "approve_all_prompts" ], "type": "string" }, + "prompt_name": { + "description": "Prompt name (required for approve_prompt; spec 100)", + "type": "string" + }, "tool_name": { "description": "Tool name (required for approve_tool and block_tool operations)", "type": "string" @@ -425,6 +432,10 @@ "description": "Environment variables for stdio servers as JSON object (e.g., '{\"API_KEY\": \"value\"}'). For update/patch: MERGES with existing vars (new keys added, existing keys updated).", "type": "string" }, + "expose_prompts": { + "description": "Per-server prompt-aggregation override (F9): true = include this server's MCP prompts in mcpproxy's aggregated prompts/list; false = exclude them regardless of capability. Omit to leave unchanged (patch) / inherit the default (aggregate if advertised). Only meaningful when aggregate_upstream_prompts is enabled globally. Used with add/update/patch.", + "type": "boolean" + }, "headers_json": { "description": "HTTP headers for authentication as JSON object (e.g., '{\"Authorization\": \"Bearer token\"}'). For update/patch: MERGES with existing headers (new keys added, existing keys updated).", "type": "string" diff --git a/internal/server/upstream_expose_prompts_test.go b/internal/server/upstream_expose_prompts_test.go new file mode 100644 index 00000000..71e9a68a --- /dev/null +++ b/internal/server/upstream_expose_prompts_test.go @@ -0,0 +1,36 @@ +package server + +import ( + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// TestBuildPatchConfig_ExposePrompts verifies F9: the upstream_servers +// patch/update tool maps expose_prompts into the ServerConfig patch as a +// tri-state *bool — present false sets it, an omitted key leaves it nil. +func TestBuildPatchConfig_ExposePrompts(t *testing.T) { + proxy, _ := createTestProxyWithRuntime(t, nil) + existing := &config.ServerConfig{Name: "srv", Protocol: "stdio", Enabled: true} + + t.Run("explicit false sets the pointer", func(t *testing.T) { + req := mcp.CallToolRequest{Params: mcp.CallToolParams{ + Arguments: map[string]interface{}{"operation": "patch", "name": "srv", "expose_prompts": false}, + }} + patch, _, err := proxy.buildPatchConfigFromRequest(req, existing) + require.NoError(t, err) + require.NotNil(t, patch.ExposePrompts) + require.False(t, *patch.ExposePrompts) + }) + t.Run("omitted key leaves nil", func(t *testing.T) { + req := mcp.CallToolRequest{Params: mcp.CallToolParams{ + Arguments: map[string]interface{}{"operation": "patch", "name": "srv", "enabled": true}, + }} + patch, _, err := proxy.buildPatchConfigFromRequest(req, existing) + require.NoError(t, err) + require.Nil(t, patch.ExposePrompts) + }) +}