Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ GITHUB_PERSONAL_ACCESS_TOKEN = "" # Pass through from host
# tools = ["*"] # Allow all tools (default)
# tools = ["read_file", "list_files"] # Allow specific tools only

# Optional: Prompt filtering
# prompts = ["*"] # Allow all prompts (default)
# prompts = ["summarize", "translate"] # Allow specific prompts only

# Optional: Per-tool response filters (jq expressions)
# Transforms tool response payloads before they are returned and before large-payload
# preview/schema processing runs.
Expand Down
6 changes: 6 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,12 @@ Non-absolute entries and the filesystem root (`/`) are ignored. When the resulti
- Use `["*"]` (wildcard) to allow all tools (default behavior when field is omitted)
- Example: `["get_file_contents", "search_code"]` (only these tools are accessible)

- **`prompts`** (optional): List of prompt names to allow for this server
- Enforced at runtime: prompts not in this list are hidden from `prompts/list` responses and rejected if invoked via `prompts/get`
- Use `["*"]` (wildcard) to allow all prompts (default behavior when field is omitted)
- Prompt retrieval runs through session initialization, per-agent server authorization, and the configured DIFC guard pipeline
- Example: `["assign_coding_agent", "issue_to_fix_workflow"]`

- **`tool_response_filters`** (optional): Per-tool jq expressions that transform tool response data before it is returned to the agent and before large-payload preview/schema processing runs
- Map key: tool name; map value: jq expression string
- Expressions are validated at startup (compile check); invalid jq causes startup/config validation failure
Expand Down
2 changes: 1 addition & 1 deletion docs/GUARD_RESPONSE_LABELING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Guard Response Labeling

This document describes how guards label responses for DIFC (Decentralized Information Flow Control) enforcement in the MCP Gateway.
This document describes how guards label responses for DIFC (Decentralized Information Flow Control) enforcement in the MCP Gateway. The same reference-monitor phases mediate both `tools/call` and `prompts/get`; prompt names are passed to `LabelResource()` and `LabelResponse()` as the guarded operation name. When a prompt guard returns collection labels, the collection is interpreted as labels for the prompt's `messages` array and inaccessible messages are removed or cause the response to be blocked according to the active enforcement mode.

## DIFC Label Rules

Expand Down
1 change: 1 addition & 0 deletions docs/RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### 🔒 Security

- **MCP prompt retrieval is now guarded**: `prompts/get` now requires session initialization and passes through per-agent server authorization, per-server prompt allowlisting, guard initialization, coarse DIFC checks, response labeling/filtering, and propagate-mode label accumulation. Prompt responses can no longer bypass the reference monitor through direct backend forwarding.
- **DIFC nil-label responses fail closed**: Unified MCP calls now fall back to the coarse access decision when a guard returns no fine-grained response labels. A coarse-denied read is withheld instead of returning the original backend payload; coarse-allowed responses and propagate-mode reads retain their existing behavior.
- **Custom guard compatibility**: Custom and third-party guards that intentionally return no response labels can no longer rely on the unified server passing through data after a coarse denial. Guards that need partial results for coarse-denied reads must provide fine-grained response labels; otherwise callers receive the standard DIFC access error.

Expand Down
3 changes: 3 additions & 0 deletions internal/config/config_core.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,9 @@ type ServerConfig struct {
// Tools is an optional list of tools to filter/expose
Tools []string `toml:"tools" json:"tools,omitempty"`

// Prompts is an optional list of prompts to filter/expose.
Prompts []string `toml:"prompts" json:"prompts,omitempty"`

// ToolResponseFilters configures per-tool jq expressions that transform tool
// response data before it is returned to the agent and before large-payload
// preview/schema processing runs.
Expand Down
5 changes: 5 additions & 0 deletions internal/config/config_stdin.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ type StdinServerConfig struct {
// Tools is an optional list of tools to filter/expose
Tools []string `json:"tools,omitempty"`

// Prompts is an optional list of prompts to filter/expose
Prompts []string `json:"prompts,omitempty"`

// ToolResponseFilters configures per-tool jq expressions that transform tool
// response data before it is returned to the agent.
ToolResponseFilters map[string]string `json:"tool_response_filters,omitempty"`
Expand Down Expand Up @@ -245,6 +248,7 @@ func (s *StdinServerConfig) UnmarshalJSON(data []byte) error {
"url": true,
"headers": true,
"tools": true,
"prompts": true,
"tool_response_filters": true,
"registry": true,
"guard-policies": true,
Expand Down Expand Up @@ -690,6 +694,7 @@ func buildStdioServerConfigWithRuntime(name string, server *StdinServerConfig, r
// between HTTP and stdio server configurations.
func applyCommonServerConfigFields(cfg *ServerConfig, src *StdinServerConfig) {
cfg.Tools = src.Tools
cfg.Prompts = src.Prompts
cfg.ToolResponseFilters = src.ToolResponseFilters
cfg.Registry = src.Registry
cfg.GuardPolicies = src.GuardPolicies
Expand Down
8 changes: 6 additions & 2 deletions internal/config/config_stdin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ func TestConvertStdinServerConfig_StdioServer(t *testing.T) {
"PASSTHROUGH": "",
"ANOTHER_VAR": "abc",
},
Tools: []string{"tool1", "tool2"},
Tools: []string{"tool1", "tool2"},
Prompts: []string{"prompt1", "prompt2"},
}

result, err := convertStdinServerConfig("test-server", server, nil)
Expand Down Expand Up @@ -69,6 +70,7 @@ func TestConvertStdinServerConfig_StdioServer(t *testing.T) {

// Check tools
assert.Equal([]string{"tool1", "tool2"}, result.Tools)
assert.Equal([]string{"prompt1", "prompt2"}, result.Prompts)
}

// TestConvertStdinServerConfig_HTTPServer tests HTTP server conversion.
Expand All @@ -83,7 +85,8 @@ func TestConvertStdinServerConfig_HTTPServer(t *testing.T) {
"Authorization": "******",
"X-Custom": "value",
},
Tools: []string{"search", "fetch"},
Tools: []string{"search", "fetch"},
Prompts: []string{"summarize"},
}

result, err := convertStdinServerConfig("http-server", server, nil)
Expand All @@ -95,6 +98,7 @@ func TestConvertStdinServerConfig_HTTPServer(t *testing.T) {
assert.Equal("******", result.Headers["Authorization"])
assert.Equal("value", result.Headers["X-Custom"])
assert.Equal([]string{"search", "fetch"}, result.Tools)
assert.Equal([]string{"summarize"}, result.Prompts)

// HTTP servers should not have Command or Args
assert.Empty(result.Command)
Expand Down
173 changes: 173 additions & 0 deletions internal/server/prompt_call.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package server

import (
"context"
"encoding/json"
"fmt"
"time"

"github.com/github/gh-aw-mcpg/internal/difc"
"github.com/github/gh-aw-mcpg/internal/guard"
"github.com/github/gh-aw-mcpg/internal/logger"
"github.com/github/gh-aw-mcpg/internal/mcp"
"github.com/github/gh-aw-mcpg/internal/sanitize"
"github.com/github/gh-aw-mcpg/internal/util"
sdk "github.com/modelcontextprotocol/go-sdk/mcp"
)

func (us *UnifiedServer) isPromptAllowed(serverID, promptName string) bool {
set, ok := us.allowedPromptSets[serverID]
if !ok || set == nil {
return true
}
allowed := set[promptName]
if !allowed {
logUnified.Printf("isPromptAllowed: prompt blocked by allowlist: serverID=%s, promptName=%s", serverID, promptName)
}
return allowed
}

// callBackendPrompt retrieves a backend prompt through the same DIFC reference-monitor
// phases used for tool responses.
func (us *UnifiedServer) callBackendPrompt(ctx context.Context, serverID, promptName string, args map[string]string) (*sdk.GetPromptResult, error) {
logUnified.Printf("callBackendPrompt: serverID=%s, promptName=%s", serverID, promptName)

toolTimeout := 0
if us.cfg != nil {
if serverCfg, ok := us.cfg.Servers[serverID]; ok && serverCfg != nil && serverCfg.ToolTimeout > 0 {
toolTimeout = serverCfg.ToolTimeout
} else if us.cfg.Gateway != nil && us.cfg.Gateway.ToolTimeout > 0 {
toolTimeout = us.cfg.Gateway.ToolTimeout
}
}
if toolTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(toolTimeout)*time.Second)
defer cancel()
}

sessionID := us.getSessionID(ctx)
agentID := guard.GetAgentIDFromContext(ctx)
if us.agentPoliciesEnforced() && !us.agentCanAccessServer(agentID, serverID) {
return nil, fmt.Errorf("prompt %q on server %q is not permitted for this agent", promptName, serverID)
}
if !us.isPromptAllowed(serverID, promptName) {
return nil, fmt.Errorf("prompt %q is not in the allowed-prompts list for this server", promptName)
}

g, err := us.guardForSession(ctx, sessionID, serverID)
if err != nil {
return nil, fmt.Errorf("failed to create isolated guard session: %w", err)
}
backendCaller := &guardBackendCaller{
server: us,
serverID: serverID,
ctx: ctx,
}
enforcementMode, err := us.ensureGuardInitialized(ctx, sessionID, serverID, g, backendCaller)
if err != nil {
return nil, fmt.Errorf("guard session initialization failed: %w", err)
}

requestEvaluator := difc.NewEvaluatorWithMode(enforcementMode)
pipelineIn := guard.PipelineInput{
AgentID: agentID,
ToolName: promptName,
Args: args,
Guard: g,
Evaluator: requestEvaluator,
AgentRegistry: us.AgentRegistry,
Capabilities: us.Capabilities,
EnforcementMode: enforcementMode,
BackendCaller: backendCaller,
SensitiveLogging: sanitize.ShouldRedactPayload(
mcp.IsEnclaveSession(ctx),
),
}
ctx, pre, err := guard.RunPipelinePrePhases(ctx, pipelineIn)
if err != nil {
if denied, detailedErr := guard.HandlePrePhaseError(err); denied != nil {
logCoarseDIFCDenial(ctx, serverID, promptName, denied)
return nil, detailedErr
}
return nil, fmt.Errorf("guard labeling failed: %w", err)
}

backendResult, err := executeBackendRequest[interface{}](ctx, us.launcher, serverID, sessionID, "prompts/get", map[string]interface{}{
"name": promptName,
"arguments": args,
})
if err != nil {
return nil, err
}

labeledData, err := guard.RunPipelinePhase4(ctx, pipelineIn, pre, backendResult)
if err != nil {
return nil, fmt.Errorf("response labeling failed: %w", err)
}
filterResult, err := difc.FilterAndConvertLabeledData(
requestEvaluator,
pre.AgentLabels.Secrecy,
pre.AgentLabels.Integrity,
pre.Operation,
labeledData,
enforcementMode,
)
if err != nil {
return nil, fmt.Errorf("failed to convert labeled data: %w", err)
}
if filterResult.Blocked {
return nil, fmt.Errorf("DIFC policy violation: prompt response contains data that is not accessible to this agent")
}
Comment on lines +119 to +121

finalResult := backendResult
if labeledData != nil {
finalResult = filterResult.FinalResult
if _, ok := labeledData.(*difc.CollectionLabeledData); ok {
resultMap, mapOK := backendResult.(map[string]interface{})
if !mapOK {
return nil, fmt.Errorf("prompt response labeling returned a collection for non-object response data")
}
messages, messagesOK := resultMap["messages"]
if !messagesOK {
return nil, fmt.Errorf("prompt response labeling returned a collection but the response has no messages")
}
if _, ok := messages.([]interface{}); !ok {
return nil, fmt.Errorf("prompt response messages have unexpected type %T", messages)
}
filteredMap := make(map[string]interface{}, len(resultMap))
for key, value := range resultMap {
filteredMap[key] = value
}
Comment on lines +138 to +141
filteredMap["messages"] = filterResult.FinalResult
finalResult = filteredMap
}
} else if !pre.EvalResult.IsAllowed() {
denied := &guard.PipelineAccessDenied{
EvalResult: pre.EvalResult,
Resource: pre.Resource,
AgentLabels: pre.AgentLabels,
}
logCoarseDIFCDenial(ctx, serverID, promptName, denied)
return nil, difc.FormatViolationError(
pre.EvalResult,
pre.AgentLabels.Secrecy,
pre.AgentLabels.Integrity,
pre.Resource,
)
}

guard.RunPipelinePhase6(pre, labeledData, enforcementMode)

resultJSON, err := json.Marshal(finalResult)
if err != nil {
return nil, fmt.Errorf("failed to marshal filtered prompt result: %w", err)
}
var result sdk.GetPromptResult
if err := json.Unmarshal(resultJSON, &result); err != nil {
return nil, fmt.Errorf("failed to parse filtered prompt result: %w", err)
}
logger.LogInfo("client", "MCP prompt response mediated, session=%s, prompt=%s",
util.FormatSessionIDForLog(sessionID), promptName)
return &result, nil
}
Loading
Loading