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
18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ go test -race ./internal/... -v # Race detection
mcpproxy upstream list # List all servers
mcpproxy upstream logs <name> # View logs (--tail, --follow)
mcpproxy upstream restart <name> # Restart server (supports --all)
mcpproxy upstream inspect <name> # Inspect tool approval status (Spec 032)
mcpproxy upstream approve <name> # Approve pending/changed tools (Spec 032)
mcpproxy doctor # Run health checks
```

Expand Down Expand Up @@ -300,6 +302,7 @@ See [docs/configuration.md](docs/configuration.md) for complete reference.
- **`call_tool_destructive`** - Proxy destructive tool calls to upstream servers (Spec 018)
- **`code_execution`** - Execute JavaScript to orchestrate multiple tools (disabled by default)
- **`upstream_servers`** - CRUD operations for server management
- **`quarantine_security`** - Security quarantine management: list/inspect quarantined servers, inspect/approve/approve-all tools (Spec 032)

**Tool Format**: `<serverName>:<toolName>` (e.g., `github:create_issue`)

Expand Down Expand Up @@ -333,6 +336,9 @@ See [docs/configuration.md](docs/configuration.md) for complete reference.
| `GET /api/v1/tokens/{name}` | Get agent token details |
| `DELETE /api/v1/tokens/{name}` | Revoke agent token |
| `POST /api/v1/tokens/{name}/regenerate` | Regenerate agent token secret |
| `POST /api/v1/servers/{id}/tools/approve` | Approve pending/changed tools (Spec 032) |
| `GET /api/v1/servers/{id}/tools/{tool}/diff` | View tool description/schema changes (Spec 032) |
| `GET /api/v1/servers/{id}/tools/export` | Export tool approval records (Spec 032) |
| `GET /events` | SSE stream for live updates |

**Authentication**: Use `X-API-Key` header or `?apikey=` query parameter.
Expand Down Expand Up @@ -439,6 +445,7 @@ See `docs/code_execution/` for complete guides:
- **`require_mcp_auth`**: When enabled, `/mcp` endpoint rejects unauthenticated requests (default: false for backward compatibility)
- **Quarantine system**: New servers quarantined until manually approved
- **Tool Poisoning Attack (TPA) protection**: Automatic detection of malicious descriptions
- **Tool-level quarantine (Spec 032)**: SHA-256 hash-based change detection for individual tool descriptions/schemas. New tools start as "pending", changed tools marked as "changed" (rug pull detection). Configurable via `quarantine_enabled` (global) and `skip_quarantine` (per-server).

See [docs/features/agent-tokens.md](docs/features/agent-tokens.md) and [docs/features/security-quarantine.md](docs/features/security-quarantine.md) for details.

Expand Down Expand Up @@ -562,6 +569,17 @@ Exponential backoff, separate contexts for app vs server lifecycle, state machin
### Tool Indexing
Full rebuild on server changes, hash-based change detection, background indexing.

### Tool-Level Quarantine (Spec 032)
SHA-256 hash-based approval system for individual tools. Key files:
- `internal/storage/models.go` - `ToolApprovalRecord` model and `ToolApprovalBucket`
- `internal/storage/bbolt.go` - CRUD operations for tool approvals
- `internal/runtime/tool_quarantine.go` - Hash calculation, approval checking, blocking logic
- `internal/runtime/lifecycle.go` - Integration in `applyDifferentialToolUpdate()`
- `internal/server/mcp.go` - Tool-level blocking in `handleCallToolVariant()` and MCP tool operations
- `internal/httpapi/server.go` - REST API endpoints for inspection/approval
- `internal/config/config.go` - `QuarantineEnabled` (global) and `SkipQuarantine` (per-server)
- `frontend/src/views/ServerDetail.vue` - Web UI quarantine panel

### Signal Handling
Graceful shutdown, context cancellation, Docker cleanup, double shutdown protection.

Expand Down
242 changes: 235 additions & 7 deletions cmd/mcpproxy/upstream_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,33 @@ Examples:
RunE: runUpstreamAddJSON,
}

upstreamInspectCmd = &cobra.Command{
Use: "inspect <server-name>",
Short: "Inspect tool approval status for a server",
Long: `Show tool-level quarantine status for all tools on a server.
Displays approval status, hashes, and any detected description/schema changes.

Examples:
mcpproxy upstream inspect github
mcpproxy upstream inspect github --output=json
mcpproxy upstream inspect github --tool create_issue`,
Args: cobra.ExactArgs(1),
RunE: runUpstreamInspect,
}

upstreamApproveCmd = &cobra.Command{
Use: "approve <server-name> [tool-names...]",
Short: "Approve quarantined tools for a server",
Long: `Approve pending or changed tools so they can be used by AI agents.
Without specific tool names, approves all pending/changed tools.

Examples:
mcpproxy upstream approve github # Approve all tools
mcpproxy upstream approve github create_issue list_repos # Approve specific tools`,
Args: cobra.MinimumNArgs(1),
RunE: runUpstreamApprove,
}

upstreamImportCmd = &cobra.Command{
Use: "import <path>",
Short: "Import servers from external configuration file",
Expand Down Expand Up @@ -176,6 +203,9 @@ Examples:
upstreamRemoveYes bool
upstreamRemoveIfExists bool

// Inspect command flags
upstreamInspectTool string

// Import command flags
upstreamImportServer string
upstreamImportFormat string
Expand All @@ -200,6 +230,8 @@ func init() {
upstreamCmd.AddCommand(upstreamAddCmd)
upstreamCmd.AddCommand(upstreamRemoveCmd)
upstreamCmd.AddCommand(upstreamAddJSONCmd)
upstreamCmd.AddCommand(upstreamInspectCmd)
upstreamCmd.AddCommand(upstreamApproveCmd)
upstreamCmd.AddCommand(upstreamImportCmd)

// Define flags (note: output format handled by global --output/-o flag from root command)
Expand Down Expand Up @@ -237,6 +269,9 @@ func init() {
upstreamRemoveCmd.Flags().BoolVarP(&upstreamRemoveYes, "y", "y", false, "Skip confirmation prompt (short form)")
upstreamRemoveCmd.Flags().BoolVar(&upstreamRemoveIfExists, "if-exists", false, "Don't error if server doesn't exist")

// Inspect command flags
upstreamInspectCmd.Flags().StringVar(&upstreamInspectTool, "tool", "", "Show details for a specific tool")

// Import command flags
upstreamImportCmd.Flags().StringVarP(&upstreamImportServer, "server", "s", "", "Import only a specific server by name")
upstreamImportCmd.Flags().StringVar(&upstreamImportFormat, "format", "", "Force format (claude-desktop, claude-code, cursor, codex, gemini)")
Expand Down Expand Up @@ -1430,13 +1465,13 @@ func parseImportFormat(format string) configimport.ConfigFormat {
func outputImportResultStructured(result *configimport.ImportResult, format string) error {
// Build output structure
output := map[string]interface{}{
"format": result.Format,
"format_name": result.FormatDisplayName,
"summary": result.Summary,
"imported": buildImportedServersOutput(result.Imported),
"skipped": result.Skipped,
"failed": result.Failed,
"warnings": result.Warnings,
"format": result.Format,
"format_name": result.FormatDisplayName,
"summary": result.Summary,
"imported": buildImportedServersOutput(result.Imported),
"skipped": result.Skipped,
"failed": result.Failed,
"warnings": result.Warnings,
}

formatter, err := GetOutputFormatter()
Expand Down Expand Up @@ -1620,6 +1655,199 @@ func applyImportedServersDaemonMode(ctx context.Context, dataDir string, importe
return nil
}

// runUpstreamInspect handles the 'upstream inspect' command (Spec 032)
func runUpstreamInspect(_ *cobra.Command, args []string) error {
serverName := args[0]

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

globalConfig, err := loadUpstreamConfig()
if err != nil {
return outputError(output.NewStructuredError(output.ErrCodeConfigNotFound, err.Error()).
WithGuidance("Check that your config file exists and is valid").
WithRecoveryCommand("mcpproxy doctor"), output.ErrCodeConfigNotFound)
}

if !shouldUseUpstreamDaemon(globalConfig.DataDir) {
return fmt.Errorf("mcpproxy daemon is not running. Start it with: mcpproxy serve")
}

logger, err := createUpstreamLogger("warn")
if err != nil {
return outputError(err, output.ErrCodeOperationFailed)
}

socketPath := socket.DetectSocketPath(globalConfig.DataDir)
client := cliclient.NewClient(socketPath, logger.Sugar())

// If a specific tool is requested, show the diff
if upstreamInspectTool != "" {
record, err := client.GetToolDiff(ctx, serverName, upstreamInspectTool)
if err != nil {
return cliError("failed to get tool diff", err)
}

outputFormat := ResolveOutputFormat()
if outputFormat == "json" || outputFormat == "yaml" {
formatter, fmtErr := GetOutputFormatter()
if fmtErr != nil {
return fmtErr
}
result, fmtErr := formatter.Format(record)
if fmtErr != nil {
return fmtErr
}
fmt.Println(result)
return nil
}

// Table format: show detailed diff
fmt.Printf("Tool Diff: %s:%s\n", serverName, record.ToolName)
fmt.Printf("Status: %s\n\n", record.Status)
fmt.Printf("--- Previous Description ---\n%s\n\n", record.PreviousDescription)
fmt.Printf("+++ Current Description ---\n%s\n\n", record.CurrentDescription)
if record.PreviousSchema != "" || record.CurrentSchema != "" {
fmt.Printf("--- Previous Schema ---\n%s\n\n", record.PreviousSchema)
fmt.Printf("+++ Current Schema ---\n%s\n", record.CurrentSchema)
}
return nil
}

// List all tool approvals for this server
records, err := client.GetToolApprovals(ctx, serverName)
if err != nil {
return cliError("failed to get tool approvals", err)
}

if len(records) == 0 {
fmt.Printf("No tool approval records found for server '%s'\n", serverName)
return nil
}

outputFormat := ResolveOutputFormat()
if outputFormat == "json" || outputFormat == "yaml" {
formatter, fmtErr := GetOutputFormatter()
if fmtErr != nil {
return fmtErr
}
result, fmtErr := formatter.Format(records)
if fmtErr != nil {
return fmtErr
}
fmt.Println(result)
return nil
}

// Table format
headers := []string{"TOOL", "STATUS", "HASH", "DESCRIPTION"}
var rows [][]string
pendingCount, changedCount, approvedCount := 0, 0, 0
for _, r := range records {
status := r.Status
switch status {
case "pending":
pendingCount++
case "changed":
changedCount++
case "approved":
approvedCount++
}

desc := r.Description
if len(desc) > 60 {
desc = desc[:57] + "..."
}

hash := r.Hash
if len(hash) > 12 {
hash = hash[:12]
}

rows = append(rows, []string{r.ToolName, status, hash, desc})
}

formatter, fmtErr := GetOutputFormatter()
if fmtErr != nil {
return fmtErr
}
result, fmtErr := formatter.FormatTable(headers, rows)
if fmtErr != nil {
return fmtErr
}
fmt.Print(result)
fmt.Printf("\nSummary: %d approved, %d pending, %d changed (total: %d)\n", approvedCount, pendingCount, changedCount, len(records))

if pendingCount > 0 || changedCount > 0 {
fmt.Printf("\nTo approve all tools: mcpproxy upstream approve %s\n", serverName)
if changedCount > 0 {
fmt.Printf("To inspect changes: mcpproxy upstream inspect %s --tool <name>\n", serverName)
}
}

return nil
}

// runUpstreamApprove handles the 'upstream approve' command (Spec 032)
func runUpstreamApprove(_ *cobra.Command, args []string) error {
serverName := args[0]
toolNames := args[1:]

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

globalConfig, err := loadUpstreamConfig()
if err != nil {
return outputError(output.NewStructuredError(output.ErrCodeConfigNotFound, err.Error()).
WithGuidance("Check that your config file exists and is valid").
WithRecoveryCommand("mcpproxy doctor"), output.ErrCodeConfigNotFound)
}

if !shouldUseUpstreamDaemon(globalConfig.DataDir) {
return fmt.Errorf("mcpproxy daemon is not running. Start it with: mcpproxy serve")
}

logger, err := createUpstreamLogger("warn")
if err != nil {
return outputError(err, output.ErrCodeOperationFailed)
}

socketPath := socket.DetectSocketPath(globalConfig.DataDir)
client := cliclient.NewClient(socketPath, logger.Sugar())

approveAll := len(toolNames) == 0
count, err := client.ApproveTools(ctx, serverName, toolNames, approveAll)
if err != nil {
return cliError("failed to approve tools", err)
}

outputFormat := ResolveOutputFormat()
if outputFormat == "json" || outputFormat == "yaml" {
formatter, fmtErr := GetOutputFormatter()
if fmtErr != nil {
return fmtErr
}
result, fmtErr := formatter.Format(map[string]interface{}{
"server_name": serverName,
"approved": count,
"tools": toolNames,
})
if fmtErr != nil {
return fmtErr
}
fmt.Println(result)
return nil
}

if approveAll {
fmt.Printf("Approved %d tools for server '%s'\n", count, serverName)
} else {
fmt.Printf("Approved %d tool(s) for server '%s': %s\n", count, serverName, strings.Join(toolNames, ", "))
}

return nil
}

// applyImportedServersConfigMode adds servers directly to the config file
func applyImportedServersConfigMode(imported []*configimport.ImportedServer, globalConfig *config.Config) error {
// Add all imported servers to config
Expand Down
21 changes: 20 additions & 1 deletion frontend/src/services/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { APIResponse, Server, Tool, SearchResult, StatusUpdate, SecretRef, MigrationAnalysis, ConfigSecretsResponse, GetToolCallsResponse, GetToolCallDetailResponse, GetServerToolCallsResponse, GetConfigResponse, ValidateConfigResponse, ConfigApplyResult, ServerTokenMetrics, GetRegistriesResponse, SearchRegistryServersResponse, RepositoryServer, GetSessionsResponse, GetSessionDetailResponse, InfoResponse, ActivityListResponse, ActivityDetailResponse, ActivitySummaryResponse, ImportResponse, AgentTokenInfo, CreateAgentTokenRequest, CreateAgentTokenResponse } from '@/types'
import type { APIResponse, Server, Tool, ToolApproval, SearchResult, StatusUpdate, SecretRef, MigrationAnalysis, ConfigSecretsResponse, GetToolCallsResponse, GetToolCallDetailResponse, GetServerToolCallsResponse, GetConfigResponse, ValidateConfigResponse, ConfigApplyResult, ServerTokenMetrics, GetRegistriesResponse, SearchRegistryServersResponse, RepositoryServer, GetSessionsResponse, GetSessionDetailResponse, InfoResponse, ActivityListResponse, ActivityDetailResponse, ActivitySummaryResponse, ImportResponse, AgentTokenInfo, CreateAgentTokenRequest, CreateAgentTokenResponse } from '@/types'

// Event types for API service
export interface APIAuthEvent {
Expand Down Expand Up @@ -273,6 +273,25 @@ class APIService {
return this.request<{ tools: Tool[] }>(`/api/v1/servers/${encodeURIComponent(serverName)}/tools`)
}

// Tool-level quarantine (Spec 032)
async getToolApprovals(serverName: string): Promise<APIResponse<{ tools: ToolApproval[], count: number }>> {
return this.request<{ tools: ToolApproval[], count: number }>(`/api/v1/servers/${encodeURIComponent(serverName)}/tools/export`)
}

async getToolDiff(serverName: string, toolName: string): Promise<APIResponse<ToolApproval>> {
return this.request<ToolApproval>(`/api/v1/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/diff`)
}

async approveTools(serverName: string, tools?: string[]): Promise<APIResponse<{ approved: number }>> {
const body = tools && tools.length > 0
? { tools }
: { approve_all: true }
return this.request<{ approved: number }>(`/api/v1/servers/${encodeURIComponent(serverName)}/tools/approve`, {
method: 'POST',
body: JSON.stringify(body),
})
}

async getServerLogs(serverName: string, tail?: number): Promise<APIResponse<{ logs: string[] }>> {
const params = tail ? `?tail=${tail}` : ''
return this.request<{ logs: string[] }>(`/api/v1/servers/${encodeURIComponent(serverName)}/logs${params}`)
Expand Down
18 changes: 17 additions & 1 deletion frontend/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ export interface Tool {
annotations?: ToolAnnotation
}

// Tool approval types (Spec 032)
export interface ToolApproval {
server_name: string
tool_name: string
status: 'pending' | 'approved' | 'changed'
hash: string
description: string
schema?: string
approved_hash?: string
current_hash?: string
previous_description?: string
current_description?: string
previous_schema?: string
current_schema?: string
}

// Search result types
export interface SearchResult {
tool: {
Expand Down Expand Up @@ -453,4 +469,4 @@ export interface ImportResponse {
skipped: SkippedServer[]
failed: FailedServer[]
warnings: string[]
}
}
Loading
Loading