Skip to content

fix: enforce direct-mode tool callability#526

Merged
Dumbris merged 4 commits into
smart-mcp-proxy:mainfrom
muxammadreza:fix/direct-mode-callability
May 26, 2026
Merged

fix: enforce direct-mode tool callability#526
Dumbris merged 4 commits into
smart-mcp-proxy:mainfrom
muxammadreza:fix/direct-mode-callability

Conversation

@muxammadreza
Copy link
Copy Markdown
Contributor

Summary

Enforces the existing callable-tool policy boundary in direct mode.

Direct mode previously checked agent-token server/permission scope, then invoked the upstream tool directly. That allowed direct-mode clients to bypass disabled-tool, server-quarantine, and tool-approval controls that are enforced by the call_tool_* paths.

This change blocks direct-mode calls before upstream invocation when the target tool is not callable, and filters direct-mode tools/list for agent-token contexts so agents do not discover tools they cannot invoke.

Changes

  • Blocks direct-mode calls to:
    • disabled servers
    • quarantined servers
    • tools denied by enabled_tools / disabled_tools
    • user-disabled tools
    • pending-approval tools
    • changed-since-approval tools
  • Adds a direct-mode WithToolFilter for agent-token tools/list.
  • Hides non-callable direct tools from agent discovery.
  • Preserves existing admin/non-agent discovery behavior.
  • Keeps existing direct-mode agent server/permission checks intact.

Validation

Targeted tests only:

go test ./internal/server -run 'TestDirectToolCallabilityBlock|TestFilterDirectToolsForAgentCallability|TestDirectModeHandler|TestGetMCPServerForMode' -count=1

Copilot AI review requested due to automatic review settings May 25, 2026 21:07
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR tightens direct-mode tool execution so it can’t bypass existing policy controls (disabled/quarantine/approval), and also hides non-callable direct-mode tools from agent discovery.

Changes:

  • Add a direct-mode callability gate before emitting “tool started” events / calling upstream.
  • Add an agent-only tool discovery filter for direct-mode tools.
  • Add focused unit tests for direct-mode callability and filtering behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 9 comments.

File Description
internal/server/mcp_routing.go Enforces direct-mode policy gating and applies a tool filter to the direct-mode server.
internal/server/mcp_direct_callability.go Implements direct-mode callability evaluation + quarantine/pending/changed responses and agent-only discovery filtering.
internal/server/mcp_direct_callability_test.go Adds unit tests for the new direct-mode callability and filtering logic.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/server/mcp_routing.go Outdated
Comment on lines 156 to 166

// Enforce direct-mode callability before emitting a tool-started event or
// invoking upstream. Direct mode must not bypass disabled, quarantine, or
// approval controls enforced by call_tool_* variants.
if blocked := p.directToolCallabilityBlock(ctx, serverName, toolName, injectAuthMetadata(ctx, args)); blocked != nil {
p.emitActivityPolicyDecision(serverName, toolName, sessionID, "blocked", "direct tool is not callable")
return blocked, nil
}

// Emit activity event
enrichedArgs := injectAuthMetadata(ctx, args)
Comment on lines +28 to +39
filtered := make([]mcp.Tool, 0, len(tools))
for _, tool := range tools {
serverName, toolName, ok := ParseDirectToolName(tool.Name)
if !ok {
filtered = append(filtered, tool)
continue
}

if p.isDirectToolCallable(serverName, toolName) {
filtered = append(filtered, tool)
}
}
Comment on lines +116 to +124
response := map[string]interface{}{
"status": "TOOL_QUARANTINED",
"server_name": serverName,
"tool_name": toolName,
"reason": "new_unapproved_tool",
"message": fmt.Sprintf("Tool '%s:%s' has not been approved yet. New tools must be inspected and approved before use.", serverName, toolName),
"current_description": approval.CurrentDescription,
"action": fmt.Sprintf("Approve via: POST /api/v1/servers/%s/tools/approve or mcpproxy upstream inspect %s", serverName, serverName),
}
Comment on lines +125 to +126
jsonResult, _ := json.Marshal(response)
return mcp.NewToolResultText(string(jsonResult))
return nil
}

serverConfig, err := p.storage.GetUpstreamServer(serverName)
return mcp.NewToolResultError(p.blockedToolMessage(serverName, toolName))
}

if serverConfig.Quarantined {
return p.handleQuarantinedToolCall(ctx, serverName, toolName, args)
}

if !serverConfig.IsToolAllowedByConfig(toolName) {
Comment on lines +68 to +69
if p.config != nil && p.config.IsQuarantineEnabled() && !serverConfig.IsQuarantineSkipped() {
approval, approvalErr := p.storage.GetToolApproval(serverName, toolName)
Comment on lines +91 to +95
var response map[string]interface{}
text := result.Content[0].(mcp.TextContent).Text
require.NoError(t, json.Unmarshal([]byte(text), &response))
assert.Equal(t, "TOOL_QUARANTINED", response["status"])
assert.Equal(t, "new_unapproved_tool", response["reason"])
@Dumbris Dumbris merged commit 424f9a7 into smart-mcp-proxy:main May 26, 2026
Dumbris added a commit that referenced this pull request May 26, 2026
…ints (#529)

Follow-up to #526 and #527, addressing review concerns.

call_tool_* variants and direct mode (#526) duplicated the pending/changed
TOOL_QUARANTINED payload and independently derived config-denial — a second
source of truth that could drift from isToolCallable/blockedToolMessage.

- Extract toolPendingApprovalResult/toolChangedApprovalResult/toolPolicyJSONResult
  as the single source of truth; call_tool_* and direct mode both use them.
- Add MCPProxyServer.isToolConfigDenied as the one config-denial authority
  (prefers live runtime config, falls back to stored server config); both
  blockedToolMessage and the direct evaluator route through it.
- captureOutputSchemaJSON (#527) returns "" on marshal failure instead of
  baking an error payload into the contract hash (which would spuriously flip
  the tool to "changed").
- Consolidate the three JSON normalizers (runtime.normalizeJSON,
  core.normalizeRawJSON) onto a single exported hash.NormalizeJSON.

Net -33 lines. Behavior-preserving: the approval-hash stability canary and all
direct/call_tool/quarantine tests pass unchanged; adds drift-guard tests for the
shared builders and NormalizeJSON.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants