Skip to content

Gateway: enforce allowed-tools filtering server-side on tools/list and tools/call#3333

Merged
lpcox merged 10 commits intomainfrom
copilot/fix-allowed-tools-filtering
Apr 7, 2026
Merged

Gateway: enforce allowed-tools filtering server-side on tools/list and tools/call#3333
lpcox merged 10 commits intomainfrom
copilot/fix-allowed-tools-filtering

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 7, 2026

Agents with raw HTTP access to the gateway could bypass client-side --allowed-tools filters by directly sending tools/call JSON-RPC requests for tools they shouldn't be able to call. The existing tools field in StdinServerConfig/ServerConfig was parsed but never enforced at runtime.

Changes

Pre-computed allowed-tools sets (unified.go)

  • Added allowedToolSets map[string]map[string]bool to UnifiedServer, built once at init via buildAllowedToolSets(cfg) for O(1) per-call lookup
  • Added isToolAllowed(serverID, toolName) — returns true when no list is configured (unrestricted)

Enforcement in callBackendTool (unified.go)

Before any DIFC/guard work, rejects calls for tools not in the allowed set:

  • Returns IsError: true CallToolResult with a descriptive message ("tool %q is not in the allowed-tools list for this server")
  • Sets OTEL span HTTP status to 403
  • Logs at WARN with logger.LogWarn("client", ...) including the server ID

tools/list defense-in-depth (tool_registry.go)

During backend tool registration, non-allowed tools are filtered out using the pre-computed allowedToolSets — they never appear in tools/list responses and are never registered with the SDK server.

Config usage

The existing tools field on each MCP server entry is the allow-list:

{
  "mcpServers": {
    "github": {
      "type": "stdio",
      "container": "ghcr.io/github/github-mcp-server:latest",
      "tools": ["search_code", "get_file_contents", "list_issues"]
    }
  }
}

When tools is empty or absent, all tools remain accessible (no behavior change for existing configs).

Testing

A dedicated internal/server/allowed_tools_integration_test.go provides 11 integration tests covering the full enforcement path end-to-end:

  • tools/list filtering: unified server with a single backend, multiple independent backends, and no-restriction passthrough
  • tools/call enforcement: allowed tool executes successfully; blocked tool returns IsError: true with the backend verified to not receive the forwarded request; unrestricted server passes all tools
  • Routed mode: filtered tools/list and blocked call handler not registered
  • Helpers: buildAllowedToolSets with multiple servers and empty/nil config; isToolAllowed with real config

Additional unit tests in call_backend_tool_test.go and tool_registry_test.go cover the isToolAllowed helper, callBackendTool rejection, and registration filtering.

Copilot AI and others added 3 commits April 7, 2026 15:20
- Add isToolAllowed() helper to UnifiedServer that checks a tool against the server's configured Tools list
- Filter tools during registerToolsFromBackend so non-allowed tools never appear in tools/list responses
- Reject tools/call in callBackendTool for tools not in the allowed list, returning an MCP error with IsError:true
- Log denied tool calls at WARN level via logger.LogWarn with truncated session ID using auth.TruncateSessionID
- Add comprehensive tests: isToolAllowed unit tests, registration filtering tests, callBackendTool enforcement tests"

Agent-Logs-Url: https://github.com/github/gh-aw-mcpg/sessions/00582754-0d76-4bb6-a950-0cd6ff534f1b

Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
…, use client category for denied tool call warning

Agent-Logs-Url: https://github.com/github/gh-aw-mcpg/sessions/00582754-0d76-4bb6-a950-0cd6ff534f1b

Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
…oolAllowed and registerToolsFromBackend

Agent-Logs-Url: https://github.com/github/gh-aw-mcpg/sessions/00582754-0d76-4bb6-a950-0cd6ff534f1b

Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix gateway to enforce allowed-tools filtering on tools/call requests Gateway: enforce allowed-tools filtering server-side on tools/list and tools/call Apr 7, 2026
Copilot AI requested a review from lpcox April 7, 2026 15:33
Copilot finished work on behalf of lpcox April 7, 2026 15:33
@lpcox lpcox marked this pull request as ready for review April 7, 2026 16:04
Copilot AI review requested due to automatic review settings April 7, 2026 16:04
Copy link
Copy Markdown
Contributor

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

This PR closes a security gap where agents could bypass client-side --allowed-tools by sending raw tools/call requests, by enforcing the configured per-server tool allow-list on the gateway side in both tools/list exposure and tools/call execution.

Changes:

  • Precomputes per-server allowed-tool sets during UnifiedServer initialization for O(1) checks.
  • Enforces allow-list in callBackendTool (returns an error result + 403 span status when blocked).
  • Filters backend tool registration so disallowed tools never appear in tools/list, plus adds targeted tests.
Show a summary per file
File Description
internal/server/unified.go Builds allowedToolSets and blocks disallowed tools early in callBackendTool.
internal/server/tool_registry.go Filters backend tools/list results to only register/advertise allowed tools.
internal/server/tool_registry_test.go Adds coverage ensuring registration/listing is filtered when allow-lists are configured.
internal/server/call_backend_tool_test.go Adds coverage for allowed-tools enforcement behavior in callBackendTool.

Copilot's findings

Tip

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

Comments suppressed due to low confidence (2)

internal/server/call_backend_tool_test.go:380

  • This test creates a UnifiedServer but doesn’t call Close(). Since NewUnified starts background goroutines (health monitor) and holds resources, please defer us.Close() after NewUnified to avoid leaks between tests.
	us, err := NewUnified(context.Background(), cfg)
	require.NoError(err)

	ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")

internal/server/call_backend_tool_test.go:472

  • This test initializes a UnifiedServer via NewUnified but never closes it. Add defer us.Close() after successful construction to avoid goroutine/resource leaks during the test run.
	us, err := NewUnified(context.Background(), cfg)
	require.NoError(err)

	ctx := context.WithValue(context.Background(), SessionIDContextKey, "sess-123")
	result, _, callErr := us.callBackendTool(ctx, "s", "blocked", nil)
  • Files reviewed: 4/4 changed files
  • Comments generated: 2

lpcox and others added 2 commits April 7, 2026 09:33
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@lpcox
Copy link
Copy Markdown
Collaborator

lpcox commented Apr 7, 2026

@copilot make sure that allow-tools filtering has thorough unit and integration tests

Copilot AI and others added 2 commits April 7, 2026 16:44
…utine leaks; fix error message wording and remove unused auth import

- Add allowed_tools_integration_test.go with 11 tests covering tools/list filtering, tools/call blocking, routed mode, multiple servers, unrestricted mode, buildAllowedToolSets, and isToolAllowed
- Add defer us.Close() in TestCallBackendTool_NoAllowedListPermitsAllTools and TestCallBackendTool_AllowedToolsError_MessageFormat (fixes goroutine leaks)
- TestAllowedTools_CallBlocked_ReturnsError verifies the backend is NOT called when a tool is blocked
- Remove unused 'auth' import from unified.go (no longer needed after log message wording change)

Agent-Logs-Url: https://github.com/github/gh-aw-mcpg/sessions/994b596a-0e4d-411b-a68a-76d4fd59fdb1

Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
…g to avoid double conversion

Agent-Logs-Url: https://github.com/github/gh-aw-mcpg/sessions/994b596a-0e4d-411b-a68a-76d4fd59fdb1

Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 7, 2026

@copilot make sure that allow-tools filtering has thorough unit and integration tests

Added a dedicated internal/server/allowed_tools_integration_test.go (commit 1bc9de3) with 11 tests covering the full enforcement path:

  • tools/list filtering: unified server, multiple independent backends, no-restriction passthrough
  • tools/call enforcement: allowed tool succeeds, blocked tool returns IsError=true with the backend verified to not be called, unrestricted server passes all tools
  • Routed mode: tools/list filtered, blocked call handler not registered
  • Helpers: buildAllowedToolSets with multiple servers / empty config, isToolAllowed integration with real config

Also fixed two missing defer us.Close() calls in the existing call_backend_tool_test.go tests to prevent goroutine leaks.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • example.com
    • Triggering command: /tmp/go-build1596506164/b514/launcher.test /tmp/go-build1596506164/b514/launcher.test -test.testlogfile=/tmp/go-build1596506164/b514/testlog.txt -test.paniconexit0 -test.timeout=10m0s cfg 6302503/b387/_pkg_.a pkg/mod/go.opentelemetry.io/otel/trace@v1.43.0/config.go x_amd64/vet -p g/grpc/resolver -lang=go1.25 x_amd64/vet cfg 5z2z/VhqIL5F_jm2-errorsas -trimpath x_amd64/vet -p go.opentelemetry--version -lang=go1.25 x_amd64/vet (dns block)
  • invalid-host-that-does-not-exist-12345.com
    • Triggering command: /tmp/go-build1596506164/b496/config.test /tmp/go-build1596506164/b496/config.test -test.testlogfile=/tmp/go-build1596506164/b496/testlog.txt -test.paniconexit0 -test.timeout=10m0s -W IaeZEj70g elemetry.io/otel-ifaceassert x_amd64/vet . .io/otel/propaga-atomic --64 x_amd64/vet cfg olang.org/protob-errorsas olang.org/protob-ifaceassert x_amd64/vet --gdwarf-5 --64 -o x_amd64/vet (dns block)
  • nonexistent.local
    • Triggering command: /tmp/go-build1596506164/b514/launcher.test /tmp/go-build1596506164/b514/launcher.test -test.testlogfile=/tmp/go-build1596506164/b514/testlog.txt -test.paniconexit0 -test.timeout=10m0s cfg 6302503/b387/_pkg_.a pkg/mod/go.opentelemetry.io/otel/trace@v1.43.0/config.go x_amd64/vet -p g/grpc/resolver -lang=go1.25 x_amd64/vet cfg 5z2z/VhqIL5F_jm2-errorsas -trimpath x_amd64/vet -p go.opentelemetry--version -lang=go1.25 x_amd64/vet (dns block)
  • slow.example.com
    • Triggering command: /tmp/go-build1596506164/b514/launcher.test /tmp/go-build1596506164/b514/launcher.test -test.testlogfile=/tmp/go-build1596506164/b514/testlog.txt -test.paniconexit0 -test.timeout=10m0s cfg 6302503/b387/_pkg_.a pkg/mod/go.opentelemetry.io/otel/trace@v1.43.0/config.go x_amd64/vet -p g/grpc/resolver -lang=go1.25 x_amd64/vet cfg 5z2z/VhqIL5F_jm2-errorsas -trimpath x_amd64/vet -p go.opentelemetry--version -lang=go1.25 x_amd64/vet (dns block)
  • this-host-does-not-exist-12345.com
    • Triggering command: /tmp/go-build1596506164/b523/mcp.test /tmp/go-build1596506164/b523/mcp.test -test.testlogfile=/tmp/go-build1596506164/b523/testlog.txt -test.paniconexit0 -test.timeout=10m0s cfg 316419/b026/vet.cfg /tmp/go-build1306302503/b317/ x_amd64/vet . --gdwarf2 --64 x_amd64/vet (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Copilot finished work on behalf of lpcox April 7, 2026 16:47
Copilot AI and others added 2 commits April 7, 2026 17:17
…d tools/call (#3334)

Agents with raw HTTP access to the gateway could bypass client-side
`--allowed-tools` filters by directly sending `tools/call` JSON-RPC
requests for tools they shouldn't be able to call. The existing `tools`
field in `StdinServerConfig`/`ServerConfig` was parsed but never
enforced at runtime.

## Changes

### Pre-computed allowed-tools sets (`unified.go`)
- Added `allowedToolSets map[string]map[string]bool` to `UnifiedServer`,
built once at init via `buildAllowedToolSets(cfg)` for O(1) per-call
lookup
- Added `isToolAllowed(serverID, toolName)` — returns `true` when no
list is configured (unrestricted)

### Enforcement in `callBackendTool` (`unified.go`)
Before any DIFC/guard work, rejects calls for tools not in the allowed
set:
- Returns `IsError: true` `CallToolResult` with a descriptive message
- Sets OTEL span HTTP status to 403
- Logs at WARN with `logger.LogWarn("client", ...)` including the server
ID

### tools/list defense-in-depth (`tool_registry.go`)
During backend tool registration, non-allowed tools are filtered out —
they never appear in `tools/list` responses and are never registered
with the SDK server.

### Lint fix
- Removed unused `sendUnifiedMCPRequest` and `parseSSEBody` helper
functions from `allowed_tools_integration_test.go` (golangci-lint
`unused` violations)
@lpcox lpcox merged commit 4f382af into main Apr 7, 2026
13 checks passed
@lpcox lpcox deleted the copilot/fix-allowed-tools-filtering branch April 7, 2026 17:26
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.

Gateway should enforce allowed-tools filtering on tools/call requests

3 participants