Skip to content

[TT-16566] mcp policy filter tools resource prompt lists#7868

Merged
lghiur merged 12 commits intomasterfrom
TT-16566-mcp-policy-filter-tools-resource-prompt-lists
Apr 16, 2026
Merged

[TT-16566] mcp policy filter tools resource prompt lists#7868
lghiur merged 12 commits intomasterfrom
TT-16566-mcp-policy-filter-tools-resource-prompt-lists

Conversation

@andrei-tyk
Copy link
Copy Markdown
Contributor

@andrei-tyk andrei-tyk commented Mar 10, 2026

Description

This PR implements MCP list response filtering based on access control rules (TBAC - Token-Based Access Control). When a consumer has mcp_access_rights configured (either directly on a key or via a policy), the gateway now filters the responses from tools/list, prompts/list, resources/list, and resources/templates/list to show only primitives the consumer is authorized to see.

Key Changes

New Response Handler (res_handler_mcp_list_filter.go):

  • Filters standard HTTP JSON-RPC responses for MCP list methods
  • Applies allow/block list rules from the session's MCPAccessRights
  • Updates Content-Length header after filtering

New SSE Hook (sse_hook_mcp_list_filter.go):

  • Filters SSE (Server-Sent Events) streaming responses for Streamable HTTP transport
  • Infers list type from JSON-RPC result keys (since SSE events don't include method name)
  • Handles fragmented/out-of-order SSE frames correctly

Shared Filtering Logic (internal/mcp/list_filter.go):

  • Extracted common filtering logic to internal/mcp package
  • CheckAccessControlRules() - evaluates allow/block lists with regex support
  • FilterJSONRPCBody() / FilterParsedJSONRPC() - parse and filter JSON-RPC responses
  • ListFilterConfigs - configuration for each list type (tools, prompts, resources, resourceTemplates)

Refactoring:

  • Moved checkAccessControlRules() and matchPattern() from gateway/mw_jsonrpc_helpers.go to internal/mcp/list_filter.go
  • Both request-time access control (middleware) and response-time filtering (handler/hook) now use the same shared logic

Filtering Behavior

  • Blocked takes precedence: If a primitive matches both allowed and blocked patterns, it is denied
  • Empty rules = no filtering: If both allowed and blocked lists are empty, all primitives pass through
  • Fail-open for malformed data: Items missing the name/uri field are included (not filtered)
  • Pagination preserved: nextCursor and other result fields are preserved after filtering
  • Cross-primitive isolation: Tool rules only affect tools, prompt rules only affect prompts, etc.

Related Issue

TT-16566

Motivation and Context

MCP APIs expose tools, prompts, and resources to AI agents. Organizations need to control which primitives different consumers can discover and use. While invocation-time access control (blocking tools/call, resources/read, etc.) was already implemented, consumers could still see all available primitives in list responses.

This creates a security concern: consumers could see tools/resources they're not authorized to use, potentially leaking sensitive capability information. This PR completes the access control story by filtering discovery responses to match invocation permissions.

How This Has Been Tested

Unit Tests (this repo)

Response Handler Tests (res_handler_mcp_list_filter_test.go - 1080 lines):

  • Filtering by allowlist (exact names and regex patterns)
  • Filtering by denylist (exact names and regex patterns)
  • Deny precedence over allow
  • Pagination cursor preservation
  • All four list methods (tools, prompts, resources, resourceTemplates)
  • Edge cases: nil session, empty body, malformed JSON, SSE content-type skip, wrong API ID, batch responses
  • Benchmarks for 100/1000 tools with exact and regex patterns

SSE Hook Tests (sse_hook_mcp_list_filter_test.go - 1097 lines):

  • Filtering tools/prompts/resources in SSE events
  • Regex pattern matching
  • Deny precedence
  • Pagination cursor preservation
  • Non-message event passthrough
  • Non-list JSON-RPC response passthrough
  • Error response passthrough
  • Multi-line SSE data handling
  • Out-of-order frame tests (fragmented JSON, interleaved events, keep-alive comments)
  • Benchmarks for SSE hook and full SSETap pipeline

Shared Logic Tests (internal/mcp/list_filter_test.go - 329 lines):

  • ExtractStringField for various JSON shapes
  • FilterItems with allow/block rules
  • CheckAccessControlRules comprehensive test matrix
  • InferListConfigFromResult for all list types

Integration Tests (tyk-analytics repo)

tests/api/tests/mcp/mcp_list_filter_test.py (491 lines):

  • test_tools_list_filtered_by_allowlist - exact name filtering
  • test_tools_list_filtered_by_allowlist_wildcard_suffix - regex get_.*
  • test_tools_list_filtered_by_denylist - exact name blocking
  • test_tools_list_filtered_by_denylist_wildcard_prefix - regex .*_user
  • test_tools_list_deny_takes_precedence_over_allow
  • test_tools_list_no_filtering_when_no_acl
  • test_prompts_list_filtered_by_allowlist
  • test_prompts_list_filtered_by_denylist
  • test_resources_list_filtered_by_allowlist
  • test_resources_list_filtered_by_denylist
  • test_resource_templates_list_filtered_by_denylist
  • test_resource_templates_list_filtered_by_allowlist
  • test_tool_rules_do_not_filter_prompts_or_resources - cross-primitive isolation
  • test_tools_list_wildcard_star_blocks_everything
  • test_tools_list_wildcard_star_allows_everything
  • test_tools_list_alternation_pattern - regex get_.*|validate_.*
  • test_resources_list_wildcard_uri_pattern
  • test_empty_allowed_list_blocks_everything
  • test_prompts_list_wildcard_suffix_pattern
  • test_policy_acl_filters_tools_list - policy-based filtering

Ticket Details

TT-16566
Status In Code Review
Summary MCP Policy - Filter Tools/Resource/Prompt Lists

Generated at: 2026-03-10 15:38:14

Add response-time filtering for tools/list, prompts/list, resources/list,
and resources/templates/list so consumers only see primitives they are
authorised to use based on their MCPAccessRights allow/block lists.

Two complementary handlers cover both transport modes:
- MCPListFilterResponseHandler: filters standard HTTP JSON responses
- MCPListFilterSSEHook: filters SSE events in Streamable HTTP transport

Both use the same checkAccessControlRules() logic as invocation-time
enforcement, supporting exact match and regex/wildcard patterns with
deny-takes-precedence semantics.
…uplication

- Extract filterItems, reencodeEnvelope, filterParsedJSONRPC as shared
  functions used by both the HTTP response handler and SSE hook
- Deduplicate mcpListConfig definitions into a shared mcpListConfigs map
- Extract rulesForAPI, readAndCloseBody, filterJSONRPCBody to shrink
  HandleResponse from ~130 lines to ~40
- Extract filterSSEData to keep FilterEvent focused on SSE concerns
- Simplify inferListConfigFromResult to use shared config map
Move pure filtering logic (no gateway dependencies) from gateway/ to
internal/mcp/list_filter.go:

- ListFilterConfig, ListFilterConfigs, JSONRPCResponse (types)
- FilterItems, FilterJSONRPCBody, FilterParsedJSONRPC (filtering)
- ReencodeEnvelope, ExtractStringField (helpers)
- InferListConfigFromResult (SSE config inference)
- CheckAccessControlRules, matchPattern (ACL evaluation)

Gateway files become thin wrappers: the response handler and SSE hook
handle transport concerns (HTTP body, SSE events) and delegate to
internal/mcp for access control filtering.
@andrei-tyk andrei-tyk changed the title Tt 16566 mcp policy filter tools resource prompt lists [TT-16566] mcp policy filter tools resource prompt lists Mar 10, 2026
@probelabs
Copy link
Copy Markdown
Contributor

probelabs Bot commented Mar 10, 2026

This PR introduces response filtering for MCP (Multi-Cloud Proxy) list endpoints to enforce Token-Based Access Control (TBAC). When a consumer has mcp_access_rights configured, the gateway now filters the responses from tools/list, prompts/list, resources/list, and resources/templates/list to show only the primitives the consumer is authorized to see. This closes a security gap where consumers could discover all available primitives, even those they were not permitted to invoke.

The implementation adds a new response handler for standard HTTP/JSON responses and a new SSE hook for streaming responses. The core access control logic has been refactored into a shared internal package (internal/mcp) to ensure consistent rule evaluation for both request-time invocation checks and the new response-time discovery filtering.

Files Changed Analysis

The changes involve 14 files, with a net addition of 3,174 lines. The bulk of the new code consists of comprehensive unit tests and benchmarks, reflecting a high standard of quality assurance.

  • New Feature Implementation: The core logic is introduced in gateway/res_handler_mcp_list_filter.go (HTTP handler), gateway/sse_hook_mcp_list_filter.go (SSE hook), and internal/mcp/list_filter.go (shared filtering logic).
  • Extensive Testing: Over 2,500 lines of new tests and benchmarks have been added across ..._test.go files, covering a wide range of scenarios, edge cases, and performance measurements.
  • Refactoring for Consistency: The checkAccessControlRules function was moved from gateway/mw_jsonrpc_helpers.go to the new internal/mcp package. Existing access control middlewares (mw_jsonrpc_access_control.go, mw_mcp_access_control.go) were updated to use this centralized function.
  • System Integration: The new components are registered in the gateway's processing pipeline in gateway/server.go (response middleware) and gateway/reverse_proxy.go (SSE hook).
  • Documentation: A detailed report.md file provides an excellent overview of the architecture, design decisions, and performance analysis.

Architecture & Impact Assessment

  • What this PR accomplishes: It enhances MCP security by aligning resource discovery with invocation permissions. This prevents unauthorized consumers from learning about the existence of sensitive tools or resources, closing an information disclosure vulnerability.

  • Key technical changes introduced:

    1. Response-Side Filtering: A new response middleware (MCPListFilterResponseHandler) intercepts and modifies upstream list responses before they are sent to the client.
    2. Dual-Transport Support: The solution robustly handles both standard unary JSON-RPC and streaming SSE transports via a dedicated response handler and an SSE hook (MCPListFilterSSEHook).
    3. Centralized Logic: Access control rule evaluation is refactored into the internal/mcp package, ensuring that invocation and discovery are governed by the exact same logic.
  • Affected system components: The changes are confined to the Tyk Gateway and only affect APIs configured with the mcp application protocol. The filtering is only active for consumers that have MCPAccessRights defined in their session (via an API key or a policy). Systems without MCP or without these rules are unaffected.

Component Interaction Diagram

sequenceDiagram
    participant Client
    participant Gateway
    participant Upstream

    Client->>+Gateway: POST /mcp (e.g., tools/list)
    Gateway->>+Upstream: Forward request
    Upstream-->>-Gateway: Full list response (JSON or SSE)

    alt Standard HTTP Response
        Gateway->>Gateway: MCPListFilterResponseHandler
        Note right of Gateway: 1. Parse response body<br/>2. Get session MCPAccessRights<br/>3. Filter items in list<br/>4. Rewrite response body & Content-Length
    else SSE Streaming Response
        Gateway->>Gateway: SSETap with MCPListFilterSSEHook
        Note right of Gateway: For each SSE 'message' event:<br/>1. Parse event data (JSON-RPC)<br/>2. Infer list type from result keys<br/>3. Filter items<br/>4. Rewrite event data
    end

    Gateway-->>-Client: Filtered list response
Loading

Scope Discovery & Context Expansion

The scope of this change is well-contained within the gateway's MCP processing pipeline. By building upon the existing user.SessionState and MCPAccessRights data structures, it avoids impacting external components like the Tyk Dashboard or database schemas.

The refactoring of checkAccessControlRules into internal/mcp/list_filter.go is a key aspect of this PR. It ensures that the logic for allowing or denying access is identical, whether it's checked at request time for an invocation (tools/call) or at response time for discovery (tools/list). This centralization is a significant improvement for maintainability and security consistency.

References

  • New Response Handler: gateway/res_handler_mcp_list_filter.go
  • New SSE Hook: gateway/sse_hook_mcp_list_filter.go
  • Centralized Filtering Logic: internal/mcp/list_filter.go
  • Response Handler Registration: gateway/server.go:1124
  • SSE Hook Registration: gateway/reverse_proxy.go:1395
  • Updated Middleware Usage: gateway/mw_jsonrpc_access_control.go, gateway/mw_mcp_access_control.go
Metadata
  • Review Effort: 4 / 5
  • Primary Label: feature

Powered by Visor from Probelabs

Last updated: 2026-04-16T13:41:38.628Z | Triggered by: pr_updated | Commit: 47582c7

💡 TIP: You can chat with Visor using /visor ask <your question>

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 10, 2026

API Changes

--- prev.txt	2026-04-16 13:40:27.684972002 +0000
+++ current.txt	2026-04-16 13:40:19.140901883 +0000
@@ -11395,6 +11395,51 @@
     state.PrimitiveType is empty (non-primitive methods such as initialize,
     ping, tools/list).
 
+type MCPListFilterResponseHandler struct {
+	BaseTykResponseHandler
+}
+    MCPListFilterResponseHandler filters MCP list responses (tools/list,
+    prompts/list, resources/list, resources/templates/list) to show only
+    primitives the consumer is authorized to see based on their MCPAccessRights
+    allow/block lists.
+
+func (h *MCPListFilterResponseHandler) Base() *BaseTykResponseHandler
+    Base returns the base handler for middleware decoration.
+
+func (h *MCPListFilterResponseHandler) Enabled() bool
+    Enabled returns true only for MCP APIs.
+
+func (h *MCPListFilterResponseHandler) HandleResponse(_ http.ResponseWriter, res *http.Response, req *http.Request, ses *user.SessionState) error
+    HandleResponse filters MCP list responses based on session access rights.
+
+func (h *MCPListFilterResponseHandler) Init(_ any, spec *APISpec) error
+    Init initializes the handler with the given spec.
+
+func (h *MCPListFilterResponseHandler) Name() string
+    Name returns the handler name for logging and debugging.
+
+type MCPListFilterSSEHook struct {
+	// Has unexported fields.
+}
+    MCPListFilterSSEHook filters MCP list responses (tools/list, prompts/list,
+    resources/list, resources/templates/list) inside SSE events when the
+    upstream uses Streamable HTTP transport.
+
+    In Streamable HTTP, the server may respond to any JSON-RPC method with an
+    SSE stream where each "message" event carries a complete JSON-RPC response.
+    This hook intercepts those events and applies the same access-control
+    filtering as MCPListFilterResponseHandler does for regular HTTP responses.
+
+func NewMCPListFilterSSEHook(apiID string, ses *user.SessionState) *MCPListFilterSSEHook
+    NewMCPListFilterSSEHook creates a hook that filters list response events
+    based on the session's MCPAccessRights for the given API. Returns nil if no
+    filtering is needed (nil session or no ACL rules).
+
+func (h *MCPListFilterSSEHook) FilterEvent(event *SSEEvent) (bool, *SSEEvent)
+    FilterEvent inspects an SSE event. If it contains a JSON-RPC list response,
+    the primitive array is filtered by access-control rules. Non-list events and
+    non-message events pass through unmodified.
+
 type MCPVEMContinuationMiddleware struct {
 	*BaseMiddleware
 }

@probelabs
Copy link
Copy Markdown
Contributor

probelabs Bot commented Mar 10, 2026

Architecture Issues (3)

Severity Location Issue
🟡 Warning gateway/reverse_proxy.go:1399-1403
The Content-Length header is unconditionally deleted and ContentLength is set to -1 whenever any SSE hooks are present. This is overly broad and could have unintended consequences for other hooks that do not modify the body length. The logic should be more targeted to only apply when a hook that is known to modify content, like the new MCPListFilterSSEHook, is active.
💡 SuggestionRefactor the logic to only delete the Content-Length header when a hook known to modify the response body is actually added to the chain. This could be achieved by having the `NewMCPListFilterSSEHook` function return an additional boolean indicating it may modify content, or by checking the type of hooks in the `WrappedServeHTTP` function. A more direct approach is to check if `filterHook` is not nil.
🟡 Warning internal/mcp/list_filter.go:181-186
The `InferListConfigFromResult` function relies on a hard-coded `lookupOrder` slice to resolve ambiguity when a JSON-RPC result contains multiple list keys (e.g., 'tools' and 'prompts'). This creates a brittle dependency on the order of keys, which is not guaranteed. If a future response contains both 'resources' and 'resourceTemplates', it would incorrectly match 'resourceTemplates' first, even if the primary data is in 'resources', because of the hardcoded check order. This design is not robust against future API changes.
💡 SuggestionInstead of relying on a fixed lookup order, the logic should be tied to the original request method if possible. Since the method is not available in the SSE hook context, an alternative is to log a warning when multiple known list keys are detected in a single response, as this likely indicates an unexpected upstream response that should be investigated. For the HTTP handler path, the method is known and should be used directly to select the config, avoiding inference altogether.
🟡 Warning gateway/res_handler_mcp_list_filter.go:105
The `listConfig` method in `MCPListFilterResponseHandler` duplicates the logic of mapping a method name to a filter configuration. A similar mapping is needed in the SSE hook, which infers the configuration from response keys. This suggests an opportunity for a more centralized and reusable mapping mechanism within the `internal/mcp` package.
💡 SuggestionCreate a new function in the `internal/mcp` package, such as `GetListConfigForMethod(method string) *ListFilterConfig`, which contains the switch statement. This would centralize the method-to-config mapping, making it reusable and the single source of truth. The `MCPListFilterResponseHandler` can then call this new function, reducing code duplication and improving maintainability.
\n\n

Architecture Issues (3)

Severity Location Issue
🟡 Warning gateway/reverse_proxy.go:1399-1403
The Content-Length header is unconditionally deleted and ContentLength is set to -1 whenever any SSE hooks are present. This is overly broad and could have unintended consequences for other hooks that do not modify the body length. The logic should be more targeted to only apply when a hook that is known to modify content, like the new MCPListFilterSSEHook, is active.
💡 SuggestionRefactor the logic to only delete the Content-Length header when a hook known to modify the response body is actually added to the chain. This could be achieved by having the `NewMCPListFilterSSEHook` function return an additional boolean indicating it may modify content, or by checking the type of hooks in the `WrappedServeHTTP` function. A more direct approach is to check if `filterHook` is not nil.
🟡 Warning internal/mcp/list_filter.go:181-186
The `InferListConfigFromResult` function relies on a hard-coded `lookupOrder` slice to resolve ambiguity when a JSON-RPC result contains multiple list keys (e.g., 'tools' and 'prompts'). This creates a brittle dependency on the order of keys, which is not guaranteed. If a future response contains both 'resources' and 'resourceTemplates', it would incorrectly match 'resourceTemplates' first, even if the primary data is in 'resources', because of the hardcoded check order. This design is not robust against future API changes.
💡 SuggestionInstead of relying on a fixed lookup order, the logic should be tied to the original request method if possible. Since the method is not available in the SSE hook context, an alternative is to log a warning when multiple known list keys are detected in a single response, as this likely indicates an unexpected upstream response that should be investigated. For the HTTP handler path, the method is known and should be used directly to select the config, avoiding inference altogether.
🟡 Warning gateway/res_handler_mcp_list_filter.go:105
The `listConfig` method in `MCPListFilterResponseHandler` duplicates the logic of mapping a method name to a filter configuration. A similar mapping is needed in the SSE hook, which infers the configuration from response keys. This suggests an opportunity for a more centralized and reusable mapping mechanism within the `internal/mcp` package.
💡 SuggestionCreate a new function in the `internal/mcp` package, such as `GetListConfigForMethod(method string) *ListFilterConfig`, which contains the switch statement. This would centralize the method-to-config mapping, making it reusable and the single source of truth. The `MCPListFilterResponseHandler` can then call this new function, reducing code duplication and improving maintainability.
\n\n ### Performance Issues (3)
Severity Location Issue
🟠 Error internal/mcp/list_filter.go:83
The `FilterItems` function exhibits an N+1 JSON parsing anti-pattern. It iterates over a slice of `json.RawMessage` and calls `ExtractStringField` for each item. `ExtractStringField` in turn calls `json.Unmarshal` on each item's raw JSON just to extract a single field. For a list of N items, this results in at least N separate unmarshaling operations within a loop, which is highly inefficient and leads to excessive allocations and CPU usage, especially for large lists.
💡 SuggestionRefactor the filtering logic to avoid unmarshaling each item individually inside the loop. Instead, unmarshal the entire list into a structured Go type (e.g., `[]map[string]any`) once. Then, iterate over this slice, perform the filtering by accessing the fields via map lookups, and finally, re-marshal the filtered slice back to JSON. This changes the complexity from O(N * M) where M is item parsing cost, to O(N) for filtering after a single initial parse.
🟡 Warning internal/mcp/list_filter.go:205
The `matchPattern` function calls `regexp.Compile` on every invocation. This function is called within a loop in `CheckAccessControlRules` for each access control pattern against each item in the list. While the `tyk/regexp` package provides caching, this still results in repeated cache lookup overhead (map access, mutex locks) in a hot path. For a list of 1000 items and 10 rules, this can be called 10,000 times per request.
💡 SuggestionPre-compile all regex patterns from the `user.AccessControlRules` once per request, before starting the item filtering loop. The resulting `*regexp.Regexp` objects can be stored in a temporary struct or slice and reused for all items in that request. This avoids the overhead of repeated compilation or cache lookups inside the main filtering loop.
🟡 Warning gateway/res_handler_mcp_list_filter.go:65
The `HandleResponse` function reads the entire response body into memory via `readAndCloseBody` (`io.ReadAll`) before filtering. For very large list responses, this can cause significant memory allocations and put pressure on the garbage collector. While the author's performance analysis notes this is acceptable for the current expected scale, it presents a potential scalability risk if list sizes grow significantly.
💡 SuggestionFor future optimization, consider replacing the full-body read with a streaming JSON parser. This would allow filtering the list without holding the entire response in memory, albeit at the cost of increased implementation complexity. No immediate action is required, but this should be monitored.

Quality Issues (1)

Severity Location Issue
🟡 Warning gateway/res_handler_mcp_list_filter.go:69
The handler silently ignores errors from reading the response body. When `readAndCloseBody` fails, it replaces the response body with an empty one, and this handler proceeds without signaling an error. The client receives a `200 OK` with an empty body, which can mask underlying network issues between the gateway and the upstream service. Additionally, the comment "pass through on read error" is misleading, as the body is replaced, not passed through.
💡 SuggestionLog the error from `readAndCloseBody` to improve observability. If silent failure is the desired behavior, update the comment to accurately reflect that the response body is replaced with an empty one on a read error. A more robust approach would be to propagate the error to the middleware chain, potentially resulting in a 5xx response to the client, making the failure visible.

Powered by Visor from Probelabs

Last updated: 2026-04-16T13:41:11.446Z | Triggered by: pr_updated | Commit: 47582c7

💡 TIP: You can chat with Visor using /visor ask <your question>

@github-actions
Copy link
Copy Markdown
Contributor

🚨 Jira Linter Failed

Commit: 47582c7
Failed at: 2026-04-16 13:39:28 UTC

The Jira linter failed to validate your PR. Please check the error details below:

🔍 Click to view error details
failed to get Jira issue: failed to fetch Jira issue TT-16566: Issue does not exist or you do not have permission to see it.: request failed. Please analyze the request body for more details. Status code: 404

Next Steps

  • Ensure your branch name contains a valid Jira ticket ID (e.g., ABC-123)
  • Verify your PR title matches the branch's Jira ticket ID
  • Check that the Jira ticket exists and is accessible

This comment will be automatically deleted once the linter passes.

@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Passed Quality Gate passed

Issues
2 New issues
0 Accepted issues

Measures
0 Security Hotspots
91.9% Coverage on New Code
0.0% Duplication on New Code

See analysis details on SonarQube Cloud

@lghiur lghiur merged commit ca6a119 into master Apr 16, 2026
43 of 57 checks passed
@lghiur lghiur deleted the TT-16566-mcp-policy-filter-tools-resource-prompt-lists branch April 16, 2026 14:46
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.

4 participants