diff --git a/gateway/mw_jsonrpc_access_control.go b/gateway/mw_jsonrpc_access_control.go index 176753ccc6b..bee0fd12879 100644 --- a/gateway/mw_jsonrpc_access_control.go +++ b/gateway/mw_jsonrpc_access_control.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/TykTechnologies/tyk/internal/httpctx" + "github.com/TykTechnologies/tyk/internal/mcp" "github.com/TykTechnologies/tyk/internal/middleware" ) @@ -53,7 +54,7 @@ func (m *JSONRPCAccessControlMiddleware) ProcessRequest(w http.ResponseWriter, r return nil, http.StatusOK } - if checkAccessControlRules(accessDef.JSONRPCMethodsAccessRights, state.Method) { + if mcp.CheckAccessControlRules(accessDef.JSONRPCMethodsAccessRights, state.Method) { writeJSONRPCAccessDenied(w, r, fmt.Sprintf("method '%s' is not available", state.Method)) return nil, middleware.StatusRespond } diff --git a/gateway/mw_jsonrpc_helpers.go b/gateway/mw_jsonrpc_helpers.go index 88a5c76b81d..cc05e966910 100644 --- a/gateway/mw_jsonrpc_helpers.go +++ b/gateway/mw_jsonrpc_helpers.go @@ -5,48 +5,8 @@ import ( "github.com/TykTechnologies/tyk/internal/httpctx" jsonrpcerrors "github.com/TykTechnologies/tyk/internal/jsonrpc/errors" - "github.com/TykTechnologies/tyk/regexp" - "github.com/TykTechnologies/tyk/user" ) -// checkAccessControlRules evaluates allow/block lists against a name. -// Returns true if the name is denied, false if permitted. -// -// Evaluation order: -// 1. Blocked is checked first — if matched, the request is denied. -// 2. If Allowed is non-empty and the name does not match any entry, the request is denied. -// 3. If both lists are empty, access is permitted. -func checkAccessControlRules(rules user.AccessControlRules, name string) bool { - for _, pattern := range rules.Blocked { - if matchPattern(pattern, name) { - return true - } - } - - if len(rules.Allowed) == 0 { - return false - } - - for _, pattern := range rules.Allowed { - if matchPattern(pattern, name) { - return false - } - } - - return true -} - -// matchPattern tests name against a regex pattern anchored with ^...$, enforcing full-match semantics. -// Uses the tyk/regexp package which caches compiled patterns. -// Falls back to exact-string comparison if the pattern is not valid regex. -func matchPattern(pattern, name string) bool { - re, err := regexp.Compile("^(?:" + pattern + ")$") - if err != nil { - return pattern == name - } - return re.MatchString(name) -} - // writeJSONRPCAccessDenied writes a JSON-RPC 2.0 error response for access-denied cases. // Delegates to jsonrpcerrors.WriteJSONRPCError for consistent response shape and HTTP→JSON-RPC // error code mapping across all error paths in the gateway. diff --git a/gateway/mw_jsonrpc_helpers_test.go b/gateway/mw_jsonrpc_helpers_test.go index 9a83bc55641..6c6d213cc53 100644 --- a/gateway/mw_jsonrpc_helpers_test.go +++ b/gateway/mw_jsonrpc_helpers_test.go @@ -8,134 +8,8 @@ import ( "github.com/stretchr/testify/require" "github.com/TykTechnologies/tyk/internal/httpctx" - "github.com/TykTechnologies/tyk/user" ) -func TestCheckAccessControlRules(t *testing.T) { - tests := []struct { - name string - rules user.AccessControlRules - input string - wantErr bool - }{ - // Empty rules — everything passes - {"empty rules, any input passes", user.AccessControlRules{}, "anything", false}, - {"empty rules, empty input passes", user.AccessControlRules{}, "", false}, - - // Blocked only - {"blocked exact match", user.AccessControlRules{Blocked: []string{"tools/list"}}, "tools/list", true}, - {"blocked no match", user.AccessControlRules{Blocked: []string{"tools/list"}}, "tools/call", false}, - {"blocked regex match", user.AccessControlRules{Blocked: []string{"delete_.*"}}, "delete_user", true}, - {"blocked regex no match", user.AccessControlRules{Blocked: []string{"delete_.*"}}, "create_user", false}, - {"blocked suffix regex", user.AccessControlRules{Blocked: []string{".*_admin"}}, "user_admin", true}, - {"blocked suffix regex no match", user.AccessControlRules{Blocked: []string{".*_admin"}}, "user_create", false}, - {"blocked multiple patterns, first matches", user.AccessControlRules{Blocked: []string{"delete_.*", "reset_.*"}}, "delete_all", true}, - {"blocked multiple patterns, second matches", user.AccessControlRules{Blocked: []string{"delete_.*", "reset_.*"}}, "reset_config", true}, - {"blocked multiple patterns, none match", user.AccessControlRules{Blocked: []string{"delete_.*", "reset_.*"}}, "get_data", false}, - - // Allowed only - {"allowed exact match", user.AccessControlRules{Allowed: []string{"tools/call"}}, "tools/call", false}, - {"allowed not in list", user.AccessControlRules{Allowed: []string{"tools/call"}}, "tools/list", true}, - {"allowed regex match", user.AccessControlRules{Allowed: []string{"get_.*"}}, "get_weather", false}, - {"allowed regex no match", user.AccessControlRules{Allowed: []string{"get_.*"}}, "set_config", true}, - {"allowed multiple patterns, first matches", user.AccessControlRules{Allowed: []string{"get_.*", "list_.*"}}, "get_weather", false}, - {"allowed multiple patterns, second matches", user.AccessControlRules{Allowed: []string{"get_.*", "list_.*"}}, "list_users", false}, - {"allowed multiple patterns, none match", user.AccessControlRules{Allowed: []string{"get_.*", "list_.*"}}, "delete_all", true}, - - // Both — blocked takes precedence - {"deny precedence over allow (exact)", - user.AccessControlRules{Blocked: []string{"reset_system"}, Allowed: []string{"reset_system"}}, - "reset_system", true}, - {"deny precedence over allow (regex)", - user.AccessControlRules{Blocked: []string{".*_system"}, Allowed: []string{"reset_.*"}}, - "reset_system", true}, - {"allowed passes when not in blocked", - user.AccessControlRules{Blocked: []string{"delete_.*"}, Allowed: []string{"get_.*", "delete_.*"}}, - "get_weather", false}, - - {"alternation: allowed prefix match", user.AccessControlRules{Allowed: []string{"get_.*|set_.*"}}, "get_weather", false}, - {"alternation: allowed second branch", user.AccessControlRules{Allowed: []string{"get_.*|set_.*"}}, "set_config", false}, - {"alternation: denied non-matching", user.AccessControlRules{Allowed: []string{"get_.*|set_.*"}}, "delete_all", true}, - {"alternation: allowed spurious prefix leak", user.AccessControlRules{Allowed: []string{"get_.*|set_.*"}}, "bad_prefix_set_foo", true}, - {"alternation: blocked spurious trailing leak", user.AccessControlRules{Blocked: []string{"admin|debug"}}, "prefix_debug", false}, - - // Edge cases - {"pattern with URI chars", user.AccessControlRules{Allowed: []string{"file:///public/.*"}}, "file:///public/readme.md", false}, - {"pattern with URI chars, no match", user.AccessControlRules{Allowed: []string{"file:///public/.*"}}, "file:///secret/keys.txt", true}, - {"invalid regex falls back to exact, exact match", user.AccessControlRules{Blocked: []string{"[invalid"}}, "[invalid", true}, - {"invalid regex falls back to exact, no match", user.AccessControlRules{Blocked: []string{"[invalid"}}, "something", false}, - {"method name with slash", user.AccessControlRules{Blocked: []string{"tools/list"}}, "tools/list", true}, - {"method regex with slash", user.AccessControlRules{Allowed: []string{"resources/.*"}}, "resources/read", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - denied := checkAccessControlRules(tt.rules, tt.input) - if tt.wantErr { - assert.True(t, denied, "expected denied for input %q", tt.input) - } else { - assert.False(t, denied, "expected allowed for input %q", tt.input) - } - }) - } -} - -func TestMatchPattern(t *testing.T) { - tests := []struct { - pattern string - name string - want bool - }{ - // Exact matches - {"get_weather", "get_weather", true}, - {"get_weather", "get_weather_v2", false}, // anchored: no partial match - {"tools/call", "tools/call", true}, - {"tools/call", "tools/list", false}, - - // Prefix wildcards - {"get_.*", "get_weather", true}, - {"get_.*", "set_config", false}, - {"resources/.*", "resources/read", true}, - {"resources/.*", "prompts/get", false}, - - // Suffix wildcards - {".*_admin", "user_admin", true}, - {".*_admin", "user_create", false}, - {".*_delete", "record_delete", true}, - - // Regex alternation - {"get_.*|set_.*", "get_weather", true}, - {"get_.*|set_.*", "set_config", true}, - {"get_.*|set_.*", "delete_all", false}, - {"get_.*|set_.*", "bad_prefix_set_foo", false}, - {"get_.*|set_.*", "get_weather_extra", true}, - {"a|b", "xb", false}, - {"a|b", "ax", false}, - {"a|b", "a", true}, - {"a|b", "b", true}, - - // URI patterns with slashes - {"file:///.*", "file:///repo/README", true}, - {"file:///public/.*", "file:///public/readme.md", true}, - {"file:///public/.*", "file:///secret/keys.txt", false}, - - // Invalid regex — falls back to exact comparison - {"[invalid", "[invalid", true}, - {"[invalid", "something", false}, - - // Empty cases - {".*", "", true}, - {"", "", true}, - } - - for _, tt := range tests { - t.Run(tt.pattern+"_vs_"+tt.name, func(t *testing.T) { - got := matchPattern(tt.pattern, tt.name) - assert.Equal(t, tt.want, got, "matchPattern(%q, %q)", tt.pattern, tt.name) - }) - } -} - func TestWriteJSONRPCAccessDenied_WithState(t *testing.T) { r := httptest.NewRequest("POST", "/mcp", nil) state := &httpctx.JSONRPCRoutingState{ diff --git a/gateway/mw_mcp_access_control.go b/gateway/mw_mcp_access_control.go index d7bd5197424..449e3cb3c2f 100644 --- a/gateway/mw_mcp_access_control.go +++ b/gateway/mw_mcp_access_control.go @@ -55,7 +55,7 @@ func (m *MCPAccessControlMiddleware) ProcessRequest(w http.ResponseWriter, r *ht } rules := rulesForPrimitiveType(accessDef.MCPAccessRights, state.PrimitiveType) - if checkAccessControlRules(rules, state.PrimitiveName) { + if mcp.CheckAccessControlRules(rules, state.PrimitiveName) { writeJSONRPCAccessDenied(w, r, fmt.Sprintf("%s '%s' is not available", state.PrimitiveType, state.PrimitiveName)) return nil, middleware.StatusRespond diff --git a/gateway/res_handler_mcp_list_filter.go b/gateway/res_handler_mcp_list_filter.go new file mode 100644 index 00000000000..a4ee41fe497 --- /dev/null +++ b/gateway/res_handler_mcp_list_filter.go @@ -0,0 +1,133 @@ +package gateway + +import ( + "bytes" + "io" + "net/http" + "strconv" + "strings" + + "github.com/TykTechnologies/tyk/internal/httpctx" + "github.com/TykTechnologies/tyk/internal/mcp" + "github.com/TykTechnologies/tyk/user" +) + +// 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. +type MCPListFilterResponseHandler struct { + BaseTykResponseHandler +} + +// Base returns the base handler for middleware decoration. +func (h *MCPListFilterResponseHandler) Base() *BaseTykResponseHandler { + return &h.BaseTykResponseHandler +} + +// Name returns the handler name for logging and debugging. +func (h *MCPListFilterResponseHandler) Name() string { + return "MCPListFilterResponseHandler" +} + +// Init initializes the handler with the given spec. +func (h *MCPListFilterResponseHandler) Init(_ any, spec *APISpec) error { + h.Spec = spec + return nil +} + +// Enabled returns true only for MCP APIs. +func (h *MCPListFilterResponseHandler) Enabled() bool { + return h.Spec.IsMCP() +} + +// HandleResponse filters MCP list responses based on session access rights. +func (h *MCPListFilterResponseHandler) HandleResponse(_ http.ResponseWriter, res *http.Response, req *http.Request, ses *user.SessionState) error { + state := httpctx.GetJSONRPCRoutingState(req) + if state == nil { + return nil + } + + listCfg := h.listConfig(state.Method) + if listCfg == nil { + return nil + } + + // Skip SSE streaming responses — list methods return JSON, but guard against + // Streamable HTTP servers that might choose to respond with text/event-stream. + // Reading the full body of an SSE stream would block indefinitely. + if ct := res.Header.Get("Content-Type"); strings.HasPrefix(ct, "text/event-stream") { + return nil + } + + rules := h.rulesForAPI(ses, listCfg) + if rules.IsEmpty() { + return nil + } + + body, err := readAndCloseBody(res) + if err != nil || len(body) == 0 { + return nil //nolint:nilerr // fail-open: pass through on read error + } + + newBody, ok := mcp.FilterJSONRPCBody(body, listCfg, rules) + if !ok { + res.Body = io.NopCloser(bytes.NewReader(body)) + return nil + } + + res.Body = io.NopCloser(bytes.NewReader(newBody)) + res.ContentLength = int64(len(newBody)) + res.Header.Set("Content-Length", strconv.Itoa(len(newBody))) + + return nil +} + +// rulesForAPI extracts the access control rules for the current API from the +// session, returning empty rules if the session has no applicable restrictions. +func (h *MCPListFilterResponseHandler) rulesForAPI(ses *user.SessionState, cfg *mcp.ListFilterConfig) user.AccessControlRules { + if ses == nil { + return user.AccessControlRules{} + } + + accessDef, ok := ses.AccessRights[h.Spec.APIID] + if !ok || accessDef.MCPAccessRights.IsEmpty() { + return user.AccessControlRules{} + } + + return cfg.RulesFrom(accessDef.MCPAccessRights) +} + +// listConfig returns the filter configuration for a given JSON-RPC method, +// or nil if the method is not a filterable list method. +func (h *MCPListFilterResponseHandler) listConfig(method string) *mcp.ListFilterConfig { + switch method { + case mcp.MethodToolsList: + return mcp.ListFilterConfigs["tools"] + case mcp.MethodPromptsList: + return mcp.ListFilterConfigs["prompts"] + case mcp.MethodResourcesList: + return mcp.ListFilterConfigs["resources"] + case mcp.MethodResourcesTemplatesList: + return mcp.ListFilterConfigs["resourceTemplates"] + default: + return nil + } +} + +// readAndCloseBody reads the full response body and closes it. On success the +// caller owns the returned bytes; the original body is always closed. +// Returns (nil, nil) when the body is nil. +func readAndCloseBody(res *http.Response) ([]byte, error) { + if res.Body == nil { + return nil, nil + } + + body, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + res.Body = io.NopCloser(bytes.NewReader(nil)) + return nil, err + } + + return body, nil +} diff --git a/gateway/res_handler_mcp_list_filter_test.go b/gateway/res_handler_mcp_list_filter_test.go new file mode 100644 index 00000000000..9e0cc424722 --- /dev/null +++ b/gateway/res_handler_mcp_list_filter_test.go @@ -0,0 +1,1080 @@ +package gateway + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/TykTechnologies/tyk/apidef" + "github.com/TykTechnologies/tyk/internal/httpctx" + "github.com/TykTechnologies/tyk/internal/mcp" + "github.com/TykTechnologies/tyk/user" +) + +// buildMCPListFilterHandler creates a MCPListFilterResponseHandler for testing. +func buildMCPListFilterHandler(apiID string, isMCP bool) *MCPListFilterResponseHandler { + proto := "" + if isMCP { + proto = apidef.AppProtocolMCP + } + return &MCPListFilterResponseHandler{ + BaseTykResponseHandler: BaseTykResponseHandler{ + Spec: &APISpec{ + APIDefinition: &apidef.APIDefinition{ + APIID: apiID, + ApplicationProtocol: proto, + }, + }, + }, + } +} + +// makeToolsListResponse builds a JSON-RPC 2.0 response with a tools/list result. +func makeToolsListResponse(tools []map[string]any, nextCursor string) []byte { + result := map[string]any{ + "tools": tools, + } + if nextCursor != "" { + result["nextCursor"] = nextCursor + } + return makeJSONRPCResponse(1, result) +} + +// makePromptsListResponse builds a JSON-RPC 2.0 response with a prompts/list result. +func makePromptsListResponse(prompts []map[string]any) []byte { + result := map[string]any{ + "prompts": prompts, + } + return makeJSONRPCResponse(1, result) +} + +// makeResourcesListResponse builds a JSON-RPC 2.0 response with a resources/list result. +func makeResourcesListResponse(resources []map[string]any) []byte { + result := map[string]any{ + "resources": resources, + } + return makeJSONRPCResponse(1, result) +} + +// makeResourceTemplatesListResponse builds a JSON-RPC 2.0 response with a resources/templates/list result. +func makeResourceTemplatesListResponse(templates []map[string]any) []byte { + result := map[string]any{ + "resourceTemplates": templates, + } + return makeJSONRPCResponse(1, result) +} + +// makeJSONRPCResponse builds a raw JSON-RPC 2.0 response envelope. +func makeJSONRPCResponse(id any, result any) []byte { + resultBytes, _ := json.Marshal(result) //nolint:errcheck + envelope := map[string]any{ + "jsonrpc": "2.0", + "id": id, + "result": json.RawMessage(resultBytes), + } + b, _ := json.Marshal(envelope) //nolint:errcheck + return b +} + +// makeHTTPResponse creates an *http.Response with the given body bytes. +func makeHTTPResponse(body []byte) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body)), + } +} + +// readResponseBody reads and returns the body from an *http.Response. +func readResponseBody(t *testing.T, res *http.Response) []byte { + t.Helper() + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + return body +} + +// extractToolNames extracts the "name" fields from the tools array in a JSON-RPC response. +func extractToolNames(t *testing.T, body []byte) []string { + t.Helper() + var envelope mcp.JSONRPCResponse + require.NoError(t, json.Unmarshal(body, &envelope)) + + var result map[string]json.RawMessage + require.NoError(t, json.Unmarshal(envelope.Result, &result)) + + var items []map[string]any + require.NoError(t, json.Unmarshal(result["tools"], &items)) + + names := make([]string, 0, len(items)) + for _, item := range items { + if name, ok := item["name"].(string); ok { + names = append(names, name) + } + } + return names +} + +// extractPromptNames extracts the "name" fields from the prompts array in a JSON-RPC response. +func extractPromptNames(t *testing.T, body []byte) []string { + t.Helper() + var envelope mcp.JSONRPCResponse + require.NoError(t, json.Unmarshal(body, &envelope)) + + var result map[string]json.RawMessage + require.NoError(t, json.Unmarshal(envelope.Result, &result)) + + var items []map[string]any + require.NoError(t, json.Unmarshal(result["prompts"], &items)) + + names := make([]string, 0, len(items)) + for _, item := range items { + if name, ok := item["name"].(string); ok { + names = append(names, name) + } + } + return names +} + +// extractResourceURIs extracts the "uri" fields from the resources array in a JSON-RPC response. +func extractResourceURIs(t *testing.T, body []byte) []string { + t.Helper() + var envelope mcp.JSONRPCResponse + require.NoError(t, json.Unmarshal(body, &envelope)) + + var result map[string]json.RawMessage + require.NoError(t, json.Unmarshal(envelope.Result, &result)) + + var items []map[string]any + require.NoError(t, json.Unmarshal(result["resources"], &items)) + + uris := make([]string, 0, len(items)) + for _, item := range items { + if uri, ok := item["uri"].(string); ok { + uris = append(uris, uri) + } + } + return uris +} + +// extractResourceTemplateURIs extracts the "uriTemplate" fields from the resourceTemplates array. +func extractResourceTemplateURIs(t *testing.T, body []byte) []string { + t.Helper() + var envelope mcp.JSONRPCResponse + require.NoError(t, json.Unmarshal(body, &envelope)) + + var result map[string]json.RawMessage + require.NoError(t, json.Unmarshal(envelope.Result, &result)) + + var items []map[string]any + require.NoError(t, json.Unmarshal(result["resourceTemplates"], &items)) + + uris := make([]string, 0, len(items)) + for _, item := range items { + if uri, ok := item["uriTemplate"].(string); ok { + uris = append(uris, uri) + } + } + return uris +} + +func TestMCPListFilterResponseHandler_Enabled(t *testing.T) { + t.Run("MCP API returns true", func(t *testing.T) { + h := buildMCPListFilterHandler("api-1", true) + assert.True(t, h.Enabled()) + }) + + t.Run("non-MCP API returns false", func(t *testing.T) { + h := buildMCPListFilterHandler("api-1", false) + assert.False(t, h.Enabled()) + }) +} + +func TestMCPListFilterResponseHandler_Name(t *testing.T) { + h := buildMCPListFilterHandler("api-1", true) + assert.Equal(t, "MCPListFilterResponseHandler", h.Name()) +} + +func TestMCPListFilterResponseHandler_Init(t *testing.T) { + h := &MCPListFilterResponseHandler{} + spec := &APISpec{ + APIDefinition: &apidef.APIDefinition{ + APIID: "api-1", + ApplicationProtocol: apidef.AppProtocolMCP, + }, + } + err := h.Init(nil, spec) + require.NoError(t, err) + assert.Equal(t, spec, h.Spec) +} + +func TestMCPListFilterResponseHandler_HandleResponse(t *testing.T) { + fourTools := []map[string]any{ + {"name": "get_weather", "description": "Get current weather"}, + {"name": "get_forecast", "description": "Get forecast"}, + {"name": "set_alert", "description": "Set weather alert"}, + {"name": "delete_alert", "description": "Delete weather alert"}, + } + + tests := []struct { + name string + method string + session *user.SessionState + responseBody []byte + wantNames []string // expected names after filtering (nil = skip name check) + wantUnmodified bool // if true, response body should be unchanged + malformedJSON bool // if true, use byte comparison instead of JSONEq + }{ + { + name: "tools/list filtered by allowlist", + method: mcp.MethodToolsList, + session: &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{ + Allowed: []string{"get_weather", "get_forecast"}, + }, + }, + }, + }, + }, + responseBody: makeToolsListResponse(fourTools, ""), + wantNames: []string{"get_weather", "get_forecast"}, + }, + { + name: "tools/list filtered by allowlist wildcard suffix", + method: mcp.MethodToolsList, + session: &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{ + Allowed: []string{"get_.*"}, + }, + }, + }, + }, + }, + responseBody: makeToolsListResponse(fourTools, ""), + wantNames: []string{"get_weather", "get_forecast"}, + }, + { + name: "tools/list filtered by denylist", + method: mcp.MethodToolsList, + session: &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{ + Blocked: []string{"delete_alert", "set_alert"}, + }, + }, + }, + }, + }, + responseBody: makeToolsListResponse(fourTools, ""), + wantNames: []string{"get_weather", "get_forecast"}, + }, + { + name: "tools/list filtered by denylist wildcard prefix", + method: mcp.MethodToolsList, + session: &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{ + Blocked: []string{".*_alert"}, + }, + }, + }, + }, + }, + responseBody: makeToolsListResponse(fourTools, ""), + wantNames: []string{"get_weather", "get_forecast"}, + }, + { + name: "tools/list deny takes precedence over allow", + method: mcp.MethodToolsList, + session: &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{ + Blocked: []string{"delete_alert", "set_alert"}, + Allowed: []string{"set_alert"}, + }, + }, + }, + }, + }, + responseBody: makeToolsListResponse(fourTools, ""), + wantNames: []string{}, + }, + { + name: "tools/list pagination nextCursor preserved", + method: mcp.MethodToolsList, + session: &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{ + Allowed: []string{"get_weather"}, + }, + }, + }, + }, + }, + responseBody: makeToolsListResponse(fourTools, "cursor-abc-123"), + wantNames: []string{"get_weather"}, + }, + { + name: "prompts/list filtered by allowlist", + method: mcp.MethodPromptsList, + session: &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Prompts: user.AccessControlRules{ + Allowed: []string{"summarise", "translate"}, + }, + }, + }, + }, + }, + responseBody: makePromptsListResponse([]map[string]any{ + {"name": "summarise", "description": "Summarise text"}, + {"name": "translate", "description": "Translate text"}, + {"name": "greet", "description": "Greet user"}, + }), + }, + { + name: "resources/templates/list filtered by denylist", + method: mcp.MethodResourcesTemplatesList, + session: &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Resources: user.AccessControlRules{ + Blocked: []string{`db://\{schema\}/\{table\}`}, + }, + }, + }, + }, + }, + responseBody: makeResourceTemplatesListResponse([]map[string]any{ + {"uriTemplate": "file://{path}", "name": "File"}, + {"uriTemplate": "db://{schema}/{table}", "name": "Database"}, + }), + }, + { + name: "resources/list filtered by allowlist", + method: mcp.MethodResourcesList, + session: &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Resources: user.AccessControlRules{ + Allowed: []string{"file:///public/.*"}, + }, + }, + }, + }, + }, + responseBody: makeResourcesListResponse([]map[string]any{ + {"uri": "file:///public/readme.md", "name": "Readme"}, + {"uri": "file:///secret/keys.txt", "name": "Keys"}, + {"uri": "file:///public/docs.md", "name": "Docs"}, + }), + }, + { + name: "no filtering when MCPAccessRights is empty", + method: mcp.MethodToolsList, + session: &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": {APIID: "api-1"}, + }, + }, + responseBody: makeToolsListResponse(fourTools, ""), + wantUnmodified: true, + }, + { + name: "no filtering when session is nil", + method: mcp.MethodToolsList, + session: nil, + responseBody: makeToolsListResponse(fourTools, ""), + wantUnmodified: true, + }, + { + name: "no filtering for non-list method tools/call", + method: mcp.MethodToolsCall, + session: &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{ + Blocked: []string{".*"}, + }, + }, + }, + }, + }, + responseBody: makeToolsListResponse(fourTools, ""), + wantUnmodified: true, + }, + { + name: "no filtering for non-list method ping", + method: "ping", + session: &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{ + Blocked: []string{".*"}, + }, + }, + }, + }, + }, + responseBody: []byte(`{"jsonrpc":"2.0","id":1,"result":{}}`), + wantUnmodified: true, + }, + { + name: "empty tools array stays empty", + method: mcp.MethodToolsList, + session: &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{ + Allowed: []string{"get_weather"}, + }, + }, + }, + }, + }, + responseBody: makeToolsListResponse([]map[string]any{}, ""), + wantNames: []string{}, + }, + { + name: "malformed JSON response passes through unmodified", + method: mcp.MethodToolsList, + session: &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{ + Blocked: []string{".*"}, + }, + }, + }, + }, + }, + responseBody: []byte(`{this is not valid json`), + wantUnmodified: true, + malformedJSON: true, + }, + { + name: "error response with no result passes through unmodified", + method: mcp.MethodToolsList, + session: &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{ + Blocked: []string{".*"}, + }, + }, + }, + }, + }, + responseBody: []byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"invalid request"}}`), + wantUnmodified: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := buildMCPListFilterHandler("api-1", true) + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + httpctx.SetJSONRPCRoutingState(req, &httpctx.JSONRPCRoutingState{ + Method: tt.method, + ID: 1, + }) + + res := makeHTTPResponse(tt.responseBody) + originalBody := make([]byte, len(tt.responseBody)) + copy(originalBody, tt.responseBody) + + err := h.HandleResponse(rw, res, req, tt.session) + require.NoError(t, err) + + body := readResponseBody(t, res) + + if tt.wantUnmodified { + if tt.malformedJSON { + assert.Equal(t, originalBody, body, + "response body should be byte-identical for malformed JSON") + } else { + assert.JSONEq(t, string(originalBody), string(body), + "response body should be unmodified") + } + return + } + + // For specific name checks. + if tt.wantNames != nil { + switch tt.method { + case mcp.MethodToolsList: + got := extractToolNames(t, body) + assert.Equal(t, tt.wantNames, got) + case mcp.MethodPromptsList: + got := extractPromptNames(t, body) + assert.Equal(t, tt.wantNames, got) + case mcp.MethodResourcesList: + got := extractResourceURIs(t, body) + assert.Equal(t, tt.wantNames, got) + case mcp.MethodResourcesTemplatesList: + got := extractResourceTemplateURIs(t, body) + assert.Equal(t, tt.wantNames, got) + } + } + + // Verify pagination cursor is preserved when applicable. + if tt.name == "tools/list pagination nextCursor preserved" { + var envelope mcp.JSONRPCResponse + require.NoError(t, json.Unmarshal(body, &envelope)) + var result map[string]json.RawMessage + require.NoError(t, json.Unmarshal(envelope.Result, &result)) + cursorRaw, exists := result["nextCursor"] + require.True(t, exists, "nextCursor should be preserved in filtered response") + var cursor string + require.NoError(t, json.Unmarshal(cursorRaw, &cursor)) + assert.Equal(t, "cursor-abc-123", cursor) + } + }) + } +} + +func TestMCPListFilterResponseHandler_HandleResponse_PromptsListFiltering(t *testing.T) { + h := buildMCPListFilterHandler("api-1", true) + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + httpctx.SetJSONRPCRoutingState(req, &httpctx.JSONRPCRoutingState{ + Method: mcp.MethodPromptsList, + ID: 1, + }) + + prompts := []map[string]any{ + {"name": "summarise", "description": "Summarise text"}, + {"name": "translate", "description": "Translate text"}, + {"name": "greet", "description": "Greet user"}, + } + + session := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Prompts: user.AccessControlRules{ + Allowed: []string{"summarise", "translate"}, + }, + }, + }, + }, + } + + res := makeHTTPResponse(makePromptsListResponse(prompts)) + err := h.HandleResponse(rw, res, req, session) + require.NoError(t, err) + + body := readResponseBody(t, res) + got := extractPromptNames(t, body) + assert.Equal(t, []string{"summarise", "translate"}, got) +} + +func TestMCPListFilterResponseHandler_HandleResponse_ResourceTemplatesFiltering(t *testing.T) { + h := buildMCPListFilterHandler("api-1", true) + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + httpctx.SetJSONRPCRoutingState(req, &httpctx.JSONRPCRoutingState{ + Method: mcp.MethodResourcesTemplatesList, + ID: 1, + }) + + templates := []map[string]any{ + {"uriTemplate": "file://{path}", "name": "File"}, + {"uriTemplate": "db://{schema}/{table}", "name": "Database"}, + } + + session := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Resources: user.AccessControlRules{ + Blocked: []string{`db://\{schema\}/\{table\}`}, + }, + }, + }, + }, + } + + res := makeHTTPResponse(makeResourceTemplatesListResponse(templates)) + err := h.HandleResponse(rw, res, req, session) + require.NoError(t, err) + + body := readResponseBody(t, res) + got := extractResourceTemplateURIs(t, body) + assert.Equal(t, []string{"file://{path}"}, got) +} + +func TestMCPListFilterResponseHandler_HandleResponse_ResourcesFiltering(t *testing.T) { + h := buildMCPListFilterHandler("api-1", true) + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + httpctx.SetJSONRPCRoutingState(req, &httpctx.JSONRPCRoutingState{ + Method: mcp.MethodResourcesList, + ID: 1, + }) + + resources := []map[string]any{ + {"uri": "file:///public/readme.md", "name": "Readme"}, + {"uri": "file:///secret/keys.txt", "name": "Keys"}, + {"uri": "file:///public/docs.md", "name": "Docs"}, + } + + session := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Resources: user.AccessControlRules{ + Allowed: []string{"file:///public/.*"}, + }, + }, + }, + }, + } + + res := makeHTTPResponse(makeResourcesListResponse(resources)) + err := h.HandleResponse(rw, res, req, session) + require.NoError(t, err) + + body := readResponseBody(t, res) + got := extractResourceURIs(t, body) + assert.Equal(t, []string{"file:///public/readme.md", "file:///public/docs.md"}, got) +} + +func TestMCPListFilterResponseHandler_HandleResponse_NoRoutingState(t *testing.T) { + h := buildMCPListFilterHandler("api-1", true) + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + // No routing state set. + + responseBody := makeToolsListResponse([]map[string]any{ + {"name": "tool_a"}, + }, "") + + res := makeHTTPResponse(responseBody) + err := h.HandleResponse(rw, res, req, nil) + require.NoError(t, err) + + body := readResponseBody(t, res) + assert.JSONEq(t, string(responseBody), string(body)) +} + +func TestMCPListFilterResponseHandler_HandleResponse_NilBody(t *testing.T) { + h := buildMCPListFilterHandler("api-1", true) + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + httpctx.SetJSONRPCRoutingState(req, &httpctx.JSONRPCRoutingState{ + Method: mcp.MethodToolsList, + ID: 1, + }) + + session := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{Blocked: []string{".*"}}, + }, + }, + }, + } + + res := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{}, + Body: nil, + } + + err := h.HandleResponse(rw, res, req, session) + require.NoError(t, err) +} + +func TestMCPListFilterResponseHandler_HandleResponse_EmptyBody(t *testing.T) { + h := buildMCPListFilterHandler("api-1", true) + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + httpctx.SetJSONRPCRoutingState(req, &httpctx.JSONRPCRoutingState{ + Method: mcp.MethodToolsList, + ID: 1, + }) + + session := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{Blocked: []string{".*"}}, + }, + }, + }, + } + + res := makeHTTPResponse([]byte{}) + err := h.HandleResponse(rw, res, req, session) + require.NoError(t, err) + + body := readResponseBody(t, res) + assert.Empty(t, body) +} + +func TestMCPListFilterResponseHandler_HandleResponse_ContentLengthUpdated(t *testing.T) { + h := buildMCPListFilterHandler("api-1", true) + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + httpctx.SetJSONRPCRoutingState(req, &httpctx.JSONRPCRoutingState{ + Method: mcp.MethodToolsList, + ID: 1, + }) + + tools := []map[string]any{ + {"name": "keep_me", "description": "Keep this tool"}, + {"name": "remove_me", "description": "Remove this tool"}, + } + + session := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{ + Allowed: []string{"keep_me"}, + }, + }, + }, + }, + } + + res := makeHTTPResponse(makeToolsListResponse(tools, "")) + err := h.HandleResponse(rw, res, req, session) + require.NoError(t, err) + + body := readResponseBody(t, res) + assert.Equal(t, int64(len(body)), res.ContentLength, + "Content-Length should match actual body size") + assert.Equal(t, fmt.Sprintf("%d", len(body)), res.Header.Get("Content-Length"), + "Content-Length header should match actual body size") +} + +func TestMCPListFilterResponseHandler_HandleResponse_WrongAPIID(t *testing.T) { + h := buildMCPListFilterHandler("api-1", true) + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + httpctx.SetJSONRPCRoutingState(req, &httpctx.JSONRPCRoutingState{ + Method: mcp.MethodToolsList, + ID: 1, + }) + + tools := []map[string]any{ + {"name": "tool_a"}, + {"name": "tool_b"}, + } + responseBody := makeToolsListResponse(tools, "") + + // Session has access rights for a different API ID. + session := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-2": { + APIID: "api-2", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{ + Blocked: []string{".*"}, + }, + }, + }, + }, + } + + res := makeHTTPResponse(responseBody) + err := h.HandleResponse(rw, res, req, session) + require.NoError(t, err) + + body := readResponseBody(t, res) + assert.JSONEq(t, string(responseBody), string(body), + "response should pass through when API ID does not match session access rights") +} + +func TestMCPListFilterResponseHandler_HandleResponse_SSEContentType(t *testing.T) { + h := buildMCPListFilterHandler("api-1", true) + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + httpctx.SetJSONRPCRoutingState(req, &httpctx.JSONRPCRoutingState{ + Method: mcp.MethodToolsList, + ID: 1, + }) + + session := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{Blocked: []string{".*"}}, + }, + }, + }, + } + + responseBody := makeToolsListResponse([]map[string]any{ + {"name": "tool_a"}, + }, "") + + // SSE Content-Type should cause handler to skip (SSE handled by hook instead). + res := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream; charset=utf-8"}}, + Body: io.NopCloser(bytes.NewReader(responseBody)), + } + + err := h.HandleResponse(rw, res, req, session) + require.NoError(t, err) + + body := readResponseBody(t, res) + assert.JSONEq(t, string(responseBody), string(body), + "SSE responses should pass through unmodified") +} + +func TestMCPListFilterResponseHandler_HandleResponse_Non200Status(t *testing.T) { + h := buildMCPListFilterHandler("api-1", true) + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + httpctx.SetJSONRPCRoutingState(req, &httpctx.JSONRPCRoutingState{ + Method: mcp.MethodToolsList, + ID: 1, + }) + + session := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{Blocked: []string{".*"}}, + }, + }, + }, + } + + // A 500 error with a JSON-RPC error body (no "result" key). + errorBody := []byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"internal error"}}`) + res := &http.Response{ + StatusCode: http.StatusInternalServerError, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(errorBody)), + } + + err := h.HandleResponse(rw, res, req, session) + require.NoError(t, err) + + body := readResponseBody(t, res) + assert.JSONEq(t, string(errorBody), string(body), + "error responses should pass through unmodified") +} + +func TestMCPListFilterResponseHandler_HandleResponse_BatchResponse(t *testing.T) { + h := buildMCPListFilterHandler("api-1", true) + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + httpctx.SetJSONRPCRoutingState(req, &httpctx.JSONRPCRoutingState{ + Method: mcp.MethodToolsList, + ID: 1, + }) + + session := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{Blocked: []string{".*"}}, + }, + }, + }, + } + + // JSON-RPC batch response (top-level array) — should pass through without crashing. + batchBody := []byte(`[{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"a"}]}},{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"b"}]}}]`) + res := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(batchBody)), + } + + err := h.HandleResponse(rw, res, req, session) + require.NoError(t, err) + + body := readResponseBody(t, res) + assert.Equal(t, batchBody, body, + "batch responses should pass through unmodified (not crash)") +} + +func TestMCPListFilterResponseHandler_HandleResponse_ItemsMissingNameField(t *testing.T) { + h := buildMCPListFilterHandler("api-1", true) + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + httpctx.SetJSONRPCRoutingState(req, &httpctx.JSONRPCRoutingState{ + Method: mcp.MethodToolsList, + ID: 1, + }) + + session := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{ + Allowed: []string{"keep_tool"}, + }, + }, + }, + }, + } + + // One tool has a name, one doesn't — nameless should pass through (fail-open). + tools := []map[string]any{ + {"name": "keep_tool", "description": "has name"}, + {"description": "no name field"}, + {"name": "block_tool", "description": "not in allowlist"}, + } + res := makeHTTPResponse(makeToolsListResponse(tools, "")) + err := h.HandleResponse(rw, res, req, session) + require.NoError(t, err) + + body := readResponseBody(t, res) + got := extractToolNames(t, body) + assert.Equal(t, []string{"keep_tool"}, got, "only named+allowed tools should be in names") + + // But the nameless item should also be in the result (fail-open). + var envelope mcp.JSONRPCResponse + require.NoError(t, json.Unmarshal(body, &envelope)) + var result map[string]json.RawMessage + require.NoError(t, json.Unmarshal(envelope.Result, &result)) + var items []json.RawMessage + require.NoError(t, json.Unmarshal(result["tools"], &items)) + assert.Len(t, items, 2, "should include keep_tool + nameless item (fail-open)") +} + +// generateTools creates n tools with names "tool_0", "tool_1", ..., "tool_{n-1}". +func generateTools(n int) []map[string]any { + tools := make([]map[string]any, n) + for i := 0; i < n; i++ { + tools[i] = map[string]any{ + "name": fmt.Sprintf("tool_%d", i), + "description": fmt.Sprintf("Tool number %d", i), + } + } + return tools +} + +func benchmarkMCPListFilter(b *testing.B, numTools int, rules user.AccessControlRules) { + b.Helper() + h := buildMCPListFilterHandler("api-1", true) + tools := generateTools(numTools) + responseBody := makeToolsListResponse(tools, "") + + session := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "api-1": { + APIID: "api-1", + MCPAccessRights: user.MCPAccessRights{ + Tools: rules, + }, + }, + }, + } + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + httpctx.SetJSONRPCRoutingState(req, &httpctx.JSONRPCRoutingState{ + Method: mcp.MethodToolsList, + ID: 1, + }) + res := makeHTTPResponse(responseBody) + + _ = h.HandleResponse(rw, res, req, session) //nolint:errcheck + // Drain body to avoid leaks. + io.ReadAll(res.Body) //nolint:errcheck + } +} + +func BenchmarkMCPListFilter_100Tools(b *testing.B) { + allowed := make([]string, 10) + for i := 0; i < 10; i++ { + allowed[i] = fmt.Sprintf("tool_%d", i) + } + benchmarkMCPListFilter(b, 100, user.AccessControlRules{ + Allowed: allowed, + }) +} + +func BenchmarkMCPListFilter_1000Tools(b *testing.B) { + allowed := make([]string, 10) + for i := 0; i < 10; i++ { + allowed[i] = fmt.Sprintf("tool_%d", i) + } + benchmarkMCPListFilter(b, 1000, user.AccessControlRules{ + Allowed: allowed, + }) +} + +func BenchmarkMCPListFilter_100Tools_Regex(b *testing.B) { + benchmarkMCPListFilter(b, 100, user.AccessControlRules{ + Allowed: []string{"tool_[0-9]", "tool_1[0-9]"}, + }) +} + +func BenchmarkMCPListFilter_1000Tools_Regex(b *testing.B) { + benchmarkMCPListFilter(b, 1000, user.AccessControlRules{ + Allowed: []string{"tool_[0-9]", "tool_1[0-9]"}, + }) +} + +func BenchmarkMCPListFilter_NoRules(b *testing.B) { + benchmarkMCPListFilter(b, 1000, user.AccessControlRules{}) +} diff --git a/gateway/reverse_proxy.go b/gateway/reverse_proxy.go index d2c8d6a4419..19d3fdf2944 100644 --- a/gateway/reverse_proxy.go +++ b/gateway/reverse_proxy.go @@ -1392,7 +1392,16 @@ func (p *ReverseProxy) WrappedServeHTTP(rw http.ResponseWriter, req *http.Reques if p.logger.Logger.IsLevelEnabled(logrus.DebugLevel) { hooks = append(hooks, NewLoggingSSEHook(p.logger)) } + if filterHook := NewMCPListFilterSSEHook(p.TykAPISpec.APIID, ses); filterHook != nil { + hooks = append(hooks, filterHook) + } res.Body = NewSSETap(res.Body, hooks...) + // Hooks may modify event data, changing the body length. Remove + // Content-Length so the client doesn't expect the original size. + if len(hooks) > 0 { + res.Header.Del("Content-Length") + res.ContentLength = -1 + } } if withCache { diff --git a/gateway/server.go b/gateway/server.go index bcec76c4775..ebc13972a98 100644 --- a/gateway/server.go +++ b/gateway/server.go @@ -1123,6 +1123,7 @@ func (gw *Gateway) createResponseMiddlewareChain( ) decorate := makeDefaultDecorator(log) + gw.responseMWAppendEnabled(&responseMWChain, decorate(&MCPListFilterResponseHandler{BaseTykResponseHandler: baseHandler})) gw.responseMWAppendEnabled(&responseMWChain, decorate(&ResponseTransformMiddleware{BaseTykResponseHandler: baseHandler})) headerInjector := decorate(&HeaderInjector{BaseTykResponseHandler: baseHandler}) headerInjectorAdded := gw.responseMWAppendEnabled(&responseMWChain, headerInjector) diff --git a/gateway/sse_hook_mcp_list_filter.go b/gateway/sse_hook_mcp_list_filter.go new file mode 100644 index 00000000000..225d74d12ae --- /dev/null +++ b/gateway/sse_hook_mcp_list_filter.go @@ -0,0 +1,107 @@ +package gateway + +import ( + "encoding/json" + "strings" + + "github.com/TykTechnologies/tyk/internal/mcp" + "github.com/TykTechnologies/tyk/user" +) + +// 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. +type MCPListFilterSSEHook struct { + apiID string + ses *user.SessionState +} + +// 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 NewMCPListFilterSSEHook(apiID string, ses *user.SessionState) *MCPListFilterSSEHook { + if ses == nil { + return nil + } + accessDef, ok := ses.AccessRights[apiID] + if !ok || accessDef.MCPAccessRights.IsEmpty() { + return nil + } + return &MCPListFilterSSEHook{apiID: apiID, ses: ses} +} + +// 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. +func (h *MCPListFilterSSEHook) FilterEvent(event *SSEEvent) (bool, *SSEEvent) { + // Only "message" events (or events with no explicit type, which default + // to "message" per the SSE spec) carry JSON-RPC responses. + if event.Event != "" && event.Event != "message" { + return true, nil + } + + // SSE data can span multiple lines; join them to get the full JSON payload. + data := strings.Join(event.Data, "\n") + if len(data) == 0 { + return true, nil + } + + // Quick check: does this look like it could contain a list result? + // Avoid parsing JSON for events that clearly aren't list responses. + if !strings.Contains(data, `"result"`) { + return true, nil + } + + newData, ok := h.filterSSEData([]byte(data)) + if !ok { + return true, nil + } + + // Build a modified event with the filtered data. + modified := &SSEEvent{ + ID: event.ID, + Event: event.Event, + Data: []string{string(newData)}, + Retry: event.Retry, + } + return true, modified +} + +// filterSSEData parses a JSON-RPC response from SSE event data, infers the +// list type from the result keys, and filters the items. Returns (nil, false) +// when the data is not a filterable list response or any step fails. +func (h *MCPListFilterSSEHook) filterSSEData(data []byte) ([]byte, bool) { + // Parse the JSON-RPC envelope. + var envelope mcp.JSONRPCResponse + if err := json.Unmarshal(data, &envelope); err != nil { + return nil, false + } + if envelope.Result == nil { + return nil, false + } + + // We need to determine the method. JSON-RPC responses don't include the + // method name, but we can infer the list type from the result keys. + var result map[string]json.RawMessage + if err := json.Unmarshal(envelope.Result, &result); err != nil { + return nil, false + } + + cfg := mcp.InferListConfigFromResult(result) + if cfg == nil { + return nil, false + } + + accessDef := h.ses.AccessRights[h.apiID] + rules := cfg.RulesFrom(accessDef.MCPAccessRights) + if rules.IsEmpty() { + return nil, false + } + + return mcp.FilterParsedJSONRPC(&envelope, result, cfg, rules) +} diff --git a/gateway/sse_hook_mcp_list_filter_test.go b/gateway/sse_hook_mcp_list_filter_test.go new file mode 100644 index 00000000000..963a8d19240 --- /dev/null +++ b/gateway/sse_hook_mcp_list_filter_test.go @@ -0,0 +1,1097 @@ +package gateway + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/TykTechnologies/tyk/internal/mcp" + "github.com/TykTechnologies/tyk/user" +) + +func TestNewMCPListFilterSSEHook(t *testing.T) { + apiID := "test-api" + + t.Run("nil session returns nil", func(t *testing.T) { + hook := NewMCPListFilterSSEHook(apiID, nil) + assert.Nil(t, hook) + }) + + t.Run("empty access rights returns nil", func(t *testing.T) { + ses := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + apiID: {}, + }, + } + hook := NewMCPListFilterSSEHook(apiID, ses) + assert.Nil(t, hook) + }) + + t.Run("wrong API ID returns nil", func(t *testing.T) { + ses := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + "other-api": { + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{Allowed: []string{"foo"}}, + }, + }, + }, + } + hook := NewMCPListFilterSSEHook(apiID, ses) + assert.Nil(t, hook) + }) + + t.Run("configured rules returns hook", func(t *testing.T) { + ses := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + apiID: { + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{Allowed: []string{"get_weather"}}, + }, + }, + }, + } + hook := NewMCPListFilterSSEHook(apiID, ses) + assert.NotNil(t, hook) + }) +} + +func TestMCPListFilterSSEHook_FilterEvent(t *testing.T) { + apiID := "test-api" + + makeSession := func(tools user.AccessControlRules) *user.SessionState { + return &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + apiID: { + MCPAccessRights: user.MCPAccessRights{Tools: tools}, + }, + }, + } + } + + makeToolsListData := func(toolNames []string) string { + tools := make([]map[string]any, 0, len(toolNames)) + for _, n := range toolNames { + tools = append(tools, map[string]any{ + "name": n, + "description": "tool " + n, + }) + } + result := map[string]any{"tools": tools} + resultBytes, _ := json.Marshal(result) //nolint:errcheck + envelope := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "result": json.RawMessage(resultBytes), + } + b, _ := json.Marshal(envelope) //nolint:errcheck + return string(b) + } + + extractToolNames := func(t *testing.T, data string) []string { + t.Helper() + var envelope mcp.JSONRPCResponse + require.NoError(t, json.Unmarshal([]byte(data), &envelope)) + + var result map[string]json.RawMessage + require.NoError(t, json.Unmarshal(envelope.Result, &result)) + + var tools []map[string]any + require.NoError(t, json.Unmarshal(result["tools"], &tools)) + + names := make([]string, 0, len(tools)) + for _, tool := range tools { + names = append(names, tool["name"].(string)) + } + return names + } + + t.Run("filters tools in SSE event by allowlist", func(t *testing.T) { + ses := makeSession(user.AccessControlRules{ + Allowed: []string{"get_weather", "get_forecast"}, + }) + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + event := &SSEEvent{ + Event: "message", + Data: []string{makeToolsListData([]string{"get_weather", "get_forecast", "set_alert", "delete_alert"})}, + } + + allowed, modified := hook.FilterEvent(event) + assert.True(t, allowed) + require.NotNil(t, modified) + + names := extractToolNames(t, strings.Join(modified.Data, "\n")) + assert.ElementsMatch(t, []string{"get_weather", "get_forecast"}, names) + }) + + t.Run("filters tools by denylist", func(t *testing.T) { + ses := makeSession(user.AccessControlRules{ + Blocked: []string{"set_alert", "delete_alert"}, + }) + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + event := &SSEEvent{ + Data: []string{makeToolsListData([]string{"get_weather", "get_forecast", "set_alert", "delete_alert"})}, + } + + allowed, modified := hook.FilterEvent(event) + assert.True(t, allowed) + require.NotNil(t, modified) + + names := extractToolNames(t, strings.Join(modified.Data, "\n")) + assert.ElementsMatch(t, []string{"get_weather", "get_forecast"}, names) + }) + + t.Run("filters tools by regex pattern", func(t *testing.T) { + ses := makeSession(user.AccessControlRules{ + Allowed: []string{"get_.*"}, + }) + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + event := &SSEEvent{ + Event: "message", + Data: []string{makeToolsListData([]string{"get_weather", "get_forecast", "set_alert"})}, + } + + allowed, modified := hook.FilterEvent(event) + assert.True(t, allowed) + require.NotNil(t, modified) + + names := extractToolNames(t, strings.Join(modified.Data, "\n")) + assert.ElementsMatch(t, []string{"get_weather", "get_forecast"}, names) + }) + + t.Run("deny takes precedence over allow", func(t *testing.T) { + ses := makeSession(user.AccessControlRules{ + Allowed: []string{"set_alert"}, + Blocked: []string{"set_alert"}, + }) + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + event := &SSEEvent{ + Event: "message", + Data: []string{makeToolsListData([]string{"set_alert", "delete_alert"})}, + } + + allowed, modified := hook.FilterEvent(event) + assert.True(t, allowed) + require.NotNil(t, modified) + + names := extractToolNames(t, strings.Join(modified.Data, "\n")) + assert.Empty(t, names) + }) + + t.Run("preserves pagination cursor", func(t *testing.T) { + ses := makeSession(user.AccessControlRules{ + Allowed: []string{"get_weather"}, + }) + hook := NewMCPListFilterSSEHook(apiID, ses) + + result := map[string]any{ + "tools": []map[string]any{{"name": "get_weather"}, {"name": "set_alert"}}, + "nextCursor": "abc123", + } + resultBytes, _ := json.Marshal(result) //nolint:errcheck + envelope := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "result": json.RawMessage(resultBytes), + } + b, _ := json.Marshal(envelope) //nolint:errcheck + + event := &SSEEvent{Data: []string{string(b)}} + allowed, modified := hook.FilterEvent(event) + assert.True(t, allowed) + require.NotNil(t, modified) + + var env mcp.JSONRPCResponse + require.NoError(t, json.Unmarshal([]byte(modified.Data[0]), &env)) + var res map[string]json.RawMessage + require.NoError(t, json.Unmarshal(env.Result, &res)) + assert.Contains(t, string(res["nextCursor"]), "abc123") + }) + + t.Run("passes through non-message event types", func(t *testing.T) { + ses := makeSession(user.AccessControlRules{Allowed: []string{"get_weather"}}) + hook := NewMCPListFilterSSEHook(apiID, ses) + + event := &SSEEvent{ + Event: "error", + Data: []string{"upstream connection lost"}, + } + + allowed, modified := hook.FilterEvent(event) + assert.True(t, allowed) + assert.Nil(t, modified) + }) + + t.Run("passes through non-list JSON-RPC responses", func(t *testing.T) { + ses := makeSession(user.AccessControlRules{Allowed: []string{"get_weather"}}) + hook := NewMCPListFilterSSEHook(apiID, ses) + + // A tools/call response has "content", not "tools" + result := map[string]any{ + "content": []map[string]any{{"type": "text", "text": "hello"}}, + } + resultBytes, _ := json.Marshal(result) //nolint:errcheck + envelope := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "result": json.RawMessage(resultBytes), + } + b, _ := json.Marshal(envelope) //nolint:errcheck + + event := &SSEEvent{ + Event: "message", + Data: []string{string(b)}, + } + + allowed, modified := hook.FilterEvent(event) + assert.True(t, allowed) + assert.Nil(t, modified) // no modification for non-list responses + }) + + t.Run("passes through error responses", func(t *testing.T) { + ses := makeSession(user.AccessControlRules{Allowed: []string{"get_weather"}}) + hook := NewMCPListFilterSSEHook(apiID, ses) + + errObj := map[string]any{"code": -32600, "message": "invalid request"} + errBytes, _ := json.Marshal(errObj) //nolint:errcheck + envelope := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "error": json.RawMessage(errBytes), + } + b, _ := json.Marshal(envelope) //nolint:errcheck + + event := &SSEEvent{Data: []string{string(b)}} + + allowed, modified := hook.FilterEvent(event) + assert.True(t, allowed) + assert.Nil(t, modified) + }) + + t.Run("passes through empty data", func(t *testing.T) { + ses := makeSession(user.AccessControlRules{Allowed: []string{"get_weather"}}) + hook := NewMCPListFilterSSEHook(apiID, ses) + + event := &SSEEvent{Data: []string{}} + allowed, modified := hook.FilterEvent(event) + assert.True(t, allowed) + assert.Nil(t, modified) + }) + + t.Run("passes through malformed JSON", func(t *testing.T) { + ses := makeSession(user.AccessControlRules{Allowed: []string{"get_weather"}}) + hook := NewMCPListFilterSSEHook(apiID, ses) + + event := &SSEEvent{Data: []string{`{not valid json`}} + allowed, modified := hook.FilterEvent(event) + assert.True(t, allowed) + assert.Nil(t, modified) + }) + + t.Run("preserves event ID through filtering", func(t *testing.T) { + ses := makeSession(user.AccessControlRules{ + Allowed: []string{"get_weather"}, + }) + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + event := &SSEEvent{ + ID: "evt-42", + Event: "message", + Data: []string{makeToolsListData([]string{"get_weather", "set_alert"})}, + } + + allowed, modified := hook.FilterEvent(event) + assert.True(t, allowed) + require.NotNil(t, modified) + assert.Equal(t, "evt-42", modified.ID, "event ID should be preserved") + + names := extractToolNames(t, strings.Join(modified.Data, "\n")) + assert.Equal(t, []string{"get_weather"}, names) + }) + + t.Run("preserves retry field through filtering", func(t *testing.T) { + ses := makeSession(user.AccessControlRules{ + Allowed: []string{"get_weather"}, + }) + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + event := &SSEEvent{ + Event: "message", + Retry: 5000, + Data: []string{makeToolsListData([]string{"get_weather", "set_alert"})}, + } + + allowed, modified := hook.FilterEvent(event) + assert.True(t, allowed) + require.NotNil(t, modified) + assert.Equal(t, 5000, modified.Retry, "retry should be preserved") + }) + + t.Run("empty result object passes through", func(t *testing.T) { + ses := makeSession(user.AccessControlRules{Allowed: []string{"get_weather"}}) + hook := NewMCPListFilterSSEHook(apiID, ses) + + // Result exists but has no list key. + envelope := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "result": map[string]any{}, + } + b, _ := json.Marshal(envelope) //nolint:errcheck + + event := &SSEEvent{Data: []string{string(b)}} + allowed, modified := hook.FilterEvent(event) + assert.True(t, allowed) + assert.Nil(t, modified, "empty result should pass through unmodified") + }) + + t.Run("handles multi-line SSE data", func(t *testing.T) { + ses := makeSession(user.AccessControlRules{ + Allowed: []string{"get_weather"}, + }) + hook := NewMCPListFilterSSEHook(apiID, ses) + + // Split JSON across multiple data: lines (valid per SSE spec) + fullJSON := makeToolsListData([]string{"get_weather", "set_alert"}) + mid := len(fullJSON) / 2 + event := &SSEEvent{ + Event: "message", + Data: []string{fullJSON[:mid], fullJSON[mid:]}, + } + + allowed, modified := hook.FilterEvent(event) + assert.True(t, allowed) + require.NotNil(t, modified) + + names := extractToolNames(t, strings.Join(modified.Data, "\n")) + assert.Equal(t, []string{"get_weather"}, names) + }) +} + +func TestMCPListFilterSSEHook_PromptFiltering(t *testing.T) { + apiID := "test-api" + ses := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + apiID: { + MCPAccessRights: user.MCPAccessRights{ + Prompts: user.AccessControlRules{Allowed: []string{"summarise"}}, + }, + }, + }, + } + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + result := map[string]any{ + "prompts": []map[string]any{ + {"name": "summarise"}, {"name": "translate"}, {"name": "greet"}, + }, + } + resultBytes, _ := json.Marshal(result) //nolint:errcheck + envelope := map[string]any{"jsonrpc": "2.0", "id": 1, "result": json.RawMessage(resultBytes)} + b, _ := json.Marshal(envelope) //nolint:errcheck + + event := &SSEEvent{Data: []string{string(b)}} + allowed, modified := hook.FilterEvent(event) + assert.True(t, allowed) + require.NotNil(t, modified) + + var env mcp.JSONRPCResponse + require.NoError(t, json.Unmarshal([]byte(modified.Data[0]), &env)) + var res map[string]json.RawMessage + require.NoError(t, json.Unmarshal(env.Result, &res)) + var prompts []map[string]any + require.NoError(t, json.Unmarshal(res["prompts"], &prompts)) + assert.Len(t, prompts, 1) + assert.Equal(t, "summarise", prompts[0]["name"]) +} + +func TestMCPListFilterSSEHook_ResourceTemplateFiltering(t *testing.T) { + apiID := "test-api" + ses := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + apiID: { + MCPAccessRights: user.MCPAccessRights{ + Resources: user.AccessControlRules{ + Blocked: []string{`db://\{schema\}/\{table\}`}, + }, + }, + }, + }, + } + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + result := map[string]any{ + "resourceTemplates": []map[string]any{ + {"uriTemplate": "file://{path}", "name": "File"}, + {"uriTemplate": "db://{schema}/{table}", "name": "DB Table"}, + }, + } + resultBytes, _ := json.Marshal(result) //nolint:errcheck + envelope := map[string]any{"jsonrpc": "2.0", "id": 1, "result": json.RawMessage(resultBytes)} + b, _ := json.Marshal(envelope) //nolint:errcheck + + event := &SSEEvent{Data: []string{string(b)}} + allowed, modified := hook.FilterEvent(event) + assert.True(t, allowed) + require.NotNil(t, modified) + + var env mcp.JSONRPCResponse + require.NoError(t, json.Unmarshal([]byte(modified.Data[0]), &env)) + var res map[string]json.RawMessage + require.NoError(t, json.Unmarshal(env.Result, &res)) + var templates []map[string]any + require.NoError(t, json.Unmarshal(res["resourceTemplates"], &templates)) + assert.Len(t, templates, 1) + assert.Equal(t, "file://{path}", templates[0]["uriTemplate"]) +} + +// ── Out-of-Order Frame Tests ──────────────────────────────────────────────── +// +// These tests verify correct behavior when SSE events arrive in various +// orderings, fragmentations, and interleaving patterns. + +func TestMCPListFilterSSEHook_OutOfOrderFrames(t *testing.T) { + apiID := "test-api" + + makeSession := func(tools, prompts user.AccessControlRules) *user.SessionState { + return &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + apiID: { + MCPAccessRights: user.MCPAccessRights{ + Tools: tools, + Prompts: prompts, + }, + }, + }, + } + } + + makeJSONRPCResponse := func(id any, resultKey string, items []map[string]any) string { + result := map[string]any{resultKey: items} + resultBytes, _ := json.Marshal(result) //nolint:errcheck + envelope := map[string]any{ + "jsonrpc": "2.0", + "id": id, + "result": json.RawMessage(resultBytes), + } + b, _ := json.Marshal(envelope) //nolint:errcheck + return string(b) + } + + t.Run("multiple list responses in single chunk", func(t *testing.T) { + // Two different list responses arriving back-to-back in same read + ses := makeSession( + user.AccessControlRules{Allowed: []string{"allowed_tool"}}, + user.AccessControlRules{Allowed: []string{"allowed_prompt"}}, + ) + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + toolsData := makeJSONRPCResponse(1, "tools", []map[string]any{ + {"name": "allowed_tool"}, + {"name": "blocked_tool"}, + }) + promptsData := makeJSONRPCResponse(2, "prompts", []map[string]any{ + {"name": "allowed_prompt"}, + {"name": "blocked_prompt"}, + }) + + // Build raw SSE stream with both events + rawStream := fmt.Sprintf("event: message\ndata: %s\n\nevent: message\ndata: %s\n\n", toolsData, promptsData) + reader := io.NopCloser(strings.NewReader(rawStream)) + tap := NewSSETap(reader, hook) + + output, err := io.ReadAll(tap) + require.NoError(t, err) + + // Verify tools filtered correctly + assert.Contains(t, string(output), "allowed_tool") + assert.NotContains(t, string(output), "blocked_tool") + + // Verify prompts filtered correctly + assert.Contains(t, string(output), "allowed_prompt") + assert.NotContains(t, string(output), "blocked_prompt") + }) + + t.Run("list response interleaved with non-list events", func(t *testing.T) { + ses := makeSession( + user.AccessControlRules{Allowed: []string{"get_weather"}}, + user.AccessControlRules{}, + ) + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + // Non-list response (tools/call result) + callResult := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "result": map[string]any{"content": []map[string]any{{"type": "text", "text": "hello"}}}, + } + callResultBytes, _ := json.Marshal(callResult) //nolint:errcheck + + // List response + toolsData := makeJSONRPCResponse(2, "tools", []map[string]any{ + {"name": "get_weather"}, + {"name": "set_alert"}, + }) + + // Error response + errorResp := map[string]any{ + "jsonrpc": "2.0", + "id": 3, + "error": map[string]any{"code": -32600, "message": "invalid"}, + } + errorBytes, _ := json.Marshal(errorResp) //nolint:errcheck + + // Interleaved stream: call result -> tools list -> error + rawStream := fmt.Sprintf( + "data: %s\n\ndata: %s\n\ndata: %s\n\n", + string(callResultBytes), toolsData, string(errorBytes), + ) + reader := io.NopCloser(strings.NewReader(rawStream)) + tap := NewSSETap(reader, hook) + + output, err := io.ReadAll(tap) + require.NoError(t, err) + + // Non-list events should pass through unchanged + assert.Contains(t, string(output), `"content"`) + assert.Contains(t, string(output), `"error"`) + + // List response should be filtered + assert.Contains(t, string(output), "get_weather") + assert.NotContains(t, string(output), "set_alert") + }) + + t.Run("fragmented JSON across multiple reads", func(t *testing.T) { + ses := makeSession( + user.AccessControlRules{Allowed: []string{"tool_a"}}, + user.AccessControlRules{}, + ) + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + toolsData := makeJSONRPCResponse(1, "tools", []map[string]any{ + {"name": "tool_a", "description": "A tool"}, + {"name": "tool_b", "description": "B tool"}, + }) + rawEvent := fmt.Sprintf("data: %s\n\n", toolsData) + + // Use chunked reader to split the event mid-JSON + reader := &chunkedReader{data: []byte(rawEvent), chunkSize: 20} + tap := NewSSETap(reader, hook) + + output, err := io.ReadAll(tap) + require.NoError(t, err) + + assert.Contains(t, string(output), "tool_a") + assert.NotContains(t, string(output), "tool_b") + }) + + t.Run("event fields in non-standard order", func(t *testing.T) { + // SSE spec allows fields in any order + ses := makeSession( + user.AccessControlRules{Allowed: []string{"allowed"}}, + user.AccessControlRules{}, + ) + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + toolsData := makeJSONRPCResponse(1, "tools", []map[string]any{ + {"name": "allowed"}, + {"name": "blocked"}, + }) + + // Non-standard field order: id, data, event, retry + rawStream := fmt.Sprintf("id: evt-1\ndata: %s\nevent: message\nretry: 3000\n\n", toolsData) + reader := io.NopCloser(strings.NewReader(rawStream)) + tap := NewSSETap(reader, hook) + + output, err := io.ReadAll(tap) + require.NoError(t, err) + + assert.Contains(t, string(output), "allowed") + assert.NotContains(t, string(output), "blocked") + }) + + t.Run("non-sequential event IDs", func(t *testing.T) { + ses := makeSession( + user.AccessControlRules{Allowed: []string{"tool_.*"}}, + user.AccessControlRules{}, + ) + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + // Events with non-sequential IDs (could happen with reconnection) + events := []struct { + eventID string + jsonRPCID int + tools []string + }{ + {"evt-100", 5, []string{"tool_a", "other_a"}}, + {"evt-50", 3, []string{"tool_b", "other_b"}}, + {"evt-200", 10, []string{"tool_c", "other_c"}}, + } + + var rawStream strings.Builder + for _, e := range events { + toolItems := make([]map[string]any, 0, len(e.tools)) + for _, t := range e.tools { + toolItems = append(toolItems, map[string]any{"name": t}) + } + data := makeJSONRPCResponse(e.jsonRPCID, "tools", toolItems) + fmt.Fprintf(&rawStream, "id: %s\ndata: %s\n\n", e.eventID, data) + } + + reader := io.NopCloser(strings.NewReader(rawStream.String())) + tap := NewSSETap(reader, hook) + + output, err := io.ReadAll(tap) + require.NoError(t, err) + outputStr := string(output) + + // All tool_* should be present, all other_* should be filtered + assert.Contains(t, outputStr, "tool_a") + assert.Contains(t, outputStr, "tool_b") + assert.Contains(t, outputStr, "tool_c") + assert.NotContains(t, outputStr, "other_a") + assert.NotContains(t, outputStr, "other_b") + assert.NotContains(t, outputStr, "other_c") + + // Event IDs should be preserved + assert.Contains(t, outputStr, "evt-100") + assert.Contains(t, outputStr, "evt-50") + assert.Contains(t, outputStr, "evt-200") + }) + + t.Run("keep-alive comments between list events", func(t *testing.T) { + ses := makeSession( + user.AccessControlRules{Allowed: []string{"allowed"}}, + user.AccessControlRules{}, + ) + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + toolsData := makeJSONRPCResponse(1, "tools", []map[string]any{ + {"name": "allowed"}, + {"name": "blocked"}, + }) + + // Stream with keep-alive comments interspersed + rawStream := fmt.Sprintf( + ": keep-alive\n\ndata: %s\n\n: another keep-alive\n\n", + toolsData, + ) + reader := io.NopCloser(strings.NewReader(rawStream)) + tap := NewSSETap(reader, hook) + + output, err := io.ReadAll(tap) + require.NoError(t, err) + outputStr := string(output) + + // Comments should pass through + assert.Contains(t, outputStr, ": keep-alive") + assert.Contains(t, outputStr, ": another keep-alive") + + // Filtering should still work + assert.Contains(t, outputStr, "allowed") + assert.NotContains(t, outputStr, "blocked") + }) + + t.Run("empty events between list responses", func(t *testing.T) { + ses := makeSession( + user.AccessControlRules{Allowed: []string{"tool_1"}}, + user.AccessControlRules{}, + ) + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + tools1 := makeJSONRPCResponse(1, "tools", []map[string]any{ + {"name": "tool_1"}, + {"name": "tool_2"}, + }) + tools2 := makeJSONRPCResponse(2, "tools", []map[string]any{ + {"name": "tool_1"}, + {"name": "tool_3"}, + }) + + // Empty event blocks (just blank lines) between real events + rawStream := fmt.Sprintf("data: %s\n\n\n\ndata: %s\n\n", tools1, tools2) + reader := io.NopCloser(strings.NewReader(rawStream)) + tap := NewSSETap(reader, hook) + + output, err := io.ReadAll(tap) + require.NoError(t, err) + outputStr := string(output) + + // Both responses should be filtered correctly + assert.Contains(t, outputStr, "tool_1") + assert.NotContains(t, outputStr, "tool_2") + assert.NotContains(t, outputStr, "tool_3") + }) + + t.Run("mixed list types in rapid succession", func(t *testing.T) { + ses := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + apiID: { + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{Allowed: []string{"allowed_tool"}}, + Prompts: user.AccessControlRules{Allowed: []string{"allowed_prompt"}}, + Resources: user.AccessControlRules{Allowed: []string{"allowed://resource"}}, + }, + }, + }, + } + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + toolsData := makeJSONRPCResponse(1, "tools", []map[string]any{ + {"name": "allowed_tool"}, + {"name": "blocked_tool"}, + }) + promptsData := makeJSONRPCResponse(2, "prompts", []map[string]any{ + {"name": "allowed_prompt"}, + {"name": "blocked_prompt"}, + }) + resourcesData := makeJSONRPCResponse(3, "resources", []map[string]any{ + {"uri": "allowed://resource", "name": "Allowed"}, + {"uri": "blocked://resource", "name": "Blocked"}, + }) + + // All three list types back-to-back + rawStream := fmt.Sprintf( + "data: %s\n\ndata: %s\n\ndata: %s\n\n", + toolsData, promptsData, resourcesData, + ) + reader := io.NopCloser(strings.NewReader(rawStream)) + tap := NewSSETap(reader, hook) + + output, err := io.ReadAll(tap) + require.NoError(t, err) + outputStr := string(output) + + // Each list type should be filtered by its own rules + assert.Contains(t, outputStr, "allowed_tool") + assert.NotContains(t, outputStr, "blocked_tool") + assert.Contains(t, outputStr, "allowed_prompt") + assert.NotContains(t, outputStr, "blocked_prompt") + assert.Contains(t, outputStr, "allowed://resource") + assert.NotContains(t, outputStr, "blocked://resource") + }) + + t.Run("duplicate JSON-RPC IDs in different events", func(t *testing.T) { + // Same JSON-RPC ID used in multiple SSE events (shouldn't happen + // in practice, but tests robustness) + ses := makeSession( + user.AccessControlRules{Allowed: []string{"first_.*"}}, + user.AccessControlRules{}, + ) + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + // Both use JSON-RPC id: 1 + first := makeJSONRPCResponse(1, "tools", []map[string]any{ + {"name": "first_tool"}, + {"name": "other_tool"}, + }) + second := makeJSONRPCResponse(1, "tools", []map[string]any{ + {"name": "first_another"}, + {"name": "blocked_another"}, + }) + + rawStream := fmt.Sprintf("id: sse-1\ndata: %s\n\nid: sse-2\ndata: %s\n\n", first, second) + reader := io.NopCloser(strings.NewReader(rawStream)) + tap := NewSSETap(reader, hook) + + output, err := io.ReadAll(tap) + require.NoError(t, err) + outputStr := string(output) + + // Both should be filtered independently + assert.Contains(t, outputStr, "first_tool") + assert.Contains(t, outputStr, "first_another") + assert.NotContains(t, outputStr, "other_tool") + assert.NotContains(t, outputStr, "blocked_another") + }) + + t.Run("very small chunk size fragmenting event boundary", func(t *testing.T) { + // Chunk size so small it splits the \n\n boundary + ses := makeSession( + user.AccessControlRules{Allowed: []string{"tiny"}}, + user.AccessControlRules{}, + ) + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + toolsData := makeJSONRPCResponse(1, "tools", []map[string]any{ + {"name": "tiny"}, + {"name": "huge"}, + }) + rawEvent := fmt.Sprintf("data: %s\n\n", toolsData) + + // Chunk size of 3 bytes - will split mid-boundary + reader := &chunkedReader{data: []byte(rawEvent), chunkSize: 3} + tap := NewSSETap(reader, hook) + + output, err := io.ReadAll(tap) + require.NoError(t, err) + + assert.Contains(t, string(output), "tiny") + assert.NotContains(t, string(output), "huge") + }) + + t.Run("event with data split across multiple data lines", func(t *testing.T) { + // SSE allows multiple data: lines that get joined with \n + ses := makeSession( + user.AccessControlRules{Allowed: []string{"allowed"}}, + user.AccessControlRules{}, + ) + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + toolsData := makeJSONRPCResponse(1, "tools", []map[string]any{ + {"name": "allowed"}, + {"name": "blocked"}, + }) + + // Split the JSON across multiple data: lines (unusual but valid SSE) + mid := len(toolsData) / 2 + rawStream := fmt.Sprintf("data: %s\ndata: %s\n\n", toolsData[:mid], toolsData[mid:]) + reader := io.NopCloser(strings.NewReader(rawStream)) + tap := NewSSETap(reader, hook) + + output, err := io.ReadAll(tap) + require.NoError(t, err) + + // The hook joins data lines with \n, so the JSON will have a newline + // in the middle - this tests that the hook handles this gracefully + // (it should pass through since JSON parsing will fail) + assert.NotEmpty(t, output) + }) + + t.Run("pagination cursor preserved across filtered responses", func(t *testing.T) { + ses := makeSession( + user.AccessControlRules{Allowed: []string{"page_.*"}}, + user.AccessControlRules{}, + ) + hook := NewMCPListFilterSSEHook(apiID, ses) + require.NotNil(t, hook) + + // First page + result1 := map[string]any{ + "tools": []map[string]any{{"name": "page_1"}, {"name": "other_1"}}, + "nextCursor": "cursor-abc", + } + resultBytes1, _ := json.Marshal(result1) //nolint:errcheck + envelope1 := map[string]any{"jsonrpc": "2.0", "id": 1, "result": json.RawMessage(resultBytes1)} + data1, _ := json.Marshal(envelope1) //nolint:errcheck + + // Second page + result2 := map[string]any{ + "tools": []map[string]any{{"name": "page_2"}, {"name": "other_2"}}, + "nextCursor": "cursor-xyz", + } + resultBytes2, _ := json.Marshal(result2) //nolint:errcheck + envelope2 := map[string]any{"jsonrpc": "2.0", "id": 2, "result": json.RawMessage(resultBytes2)} + data2, _ := json.Marshal(envelope2) //nolint:errcheck + + rawStream := fmt.Sprintf("data: %s\n\ndata: %s\n\n", string(data1), string(data2)) + reader := io.NopCloser(strings.NewReader(rawStream)) + tap := NewSSETap(reader, hook) + + output, err := io.ReadAll(tap) + require.NoError(t, err) + outputStr := string(output) + + // Tools should be filtered + assert.Contains(t, outputStr, "page_1") + assert.Contains(t, outputStr, "page_2") + assert.NotContains(t, outputStr, "other_1") + assert.NotContains(t, outputStr, "other_2") + + // Cursors should be preserved + assert.Contains(t, outputStr, "cursor-abc") + assert.Contains(t, outputStr, "cursor-xyz") + }) +} + +// ── Benchmarks ────────────────────────────────────────────────────────────── + +// generateSSEToolsEvent builds an SSE event carrying a tools/list JSON-RPC response +// with n tools named "tool_0" … "tool_{n-1}". +func generateSSEToolsEvent(n int) *SSEEvent { + tools := make([]map[string]any, n) + for i := range n { + tools[i] = map[string]any{ + "name": fmt.Sprintf("tool_%d", i), + "description": fmt.Sprintf("Tool number %d", i), + } + } + result := map[string]any{"tools": tools} + resultBytes, _ := json.Marshal(result) //nolint:errcheck + envelope := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "result": json.RawMessage(resultBytes), + } + b, _ := json.Marshal(envelope) //nolint:errcheck + return &SSEEvent{Event: "message", Data: []string{string(b)}} +} + +// benchmarkSSEHook measures FilterEvent alone (no SSETap overhead). +func benchmarkSSEHook(b *testing.B, numTools int, rules user.AccessControlRules) { + b.Helper() + apiID := "bench-api" + ses := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + apiID: { + MCPAccessRights: user.MCPAccessRights{Tools: rules}, + }, + }, + } + hook := NewMCPListFilterSSEHook(apiID, ses) + event := generateSSEToolsEvent(numTools) + + b.ResetTimer() + b.ReportAllocs() + for range b.N { + hook.FilterEvent(event) + } +} + +func BenchmarkSSEHook_100Tools(b *testing.B) { + allowed := make([]string, 10) + for i := range 10 { + allowed[i] = fmt.Sprintf("tool_%d", i) + } + benchmarkSSEHook(b, 100, user.AccessControlRules{Allowed: allowed}) +} + +func BenchmarkSSEHook_1000Tools(b *testing.B) { + allowed := make([]string, 10) + for i := range 10 { + allowed[i] = fmt.Sprintf("tool_%d", i) + } + benchmarkSSEHook(b, 1000, user.AccessControlRules{Allowed: allowed}) +} + +func BenchmarkSSEHook_100Tools_Regex(b *testing.B) { + benchmarkSSEHook(b, 100, user.AccessControlRules{ + Allowed: []string{"tool_[0-9]", "tool_1[0-9]"}, + }) +} + +func BenchmarkSSEHook_1000Tools_Regex(b *testing.B) { + benchmarkSSEHook(b, 1000, user.AccessControlRules{ + Allowed: []string{"tool_[0-9]", "tool_1[0-9]"}, + }) +} + +func BenchmarkSSEHook_NonListEvent(b *testing.B) { + apiID := "bench-api" + ses := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + apiID: { + MCPAccessRights: user.MCPAccessRights{ + Tools: user.AccessControlRules{Allowed: []string{"tool_0"}}, + }, + }, + }, + } + hook := NewMCPListFilterSSEHook(apiID, ses) + // A tools/call result — should pass through without parsing. + result := map[string]any{"content": []map[string]any{{"type": "text", "text": "hello"}}} + resultBytes, _ := json.Marshal(result) //nolint:errcheck + envelope := map[string]any{"jsonrpc": "2.0", "id": 1, "result": json.RawMessage(resultBytes)} + data, _ := json.Marshal(envelope) //nolint:errcheck + event := &SSEEvent{Event: "message", Data: []string{string(data)}} + + b.ResetTimer() + b.ReportAllocs() + for range b.N { + hook.FilterEvent(event) + } +} + +// benchmarkSSETapEndToEnd measures the full SSETap pipeline: parsing SSE bytes +// from an io.Reader, running the hook, and reading filtered output. +func benchmarkSSETapEndToEnd(b *testing.B, numTools int, rules user.AccessControlRules) { + b.Helper() + apiID := "bench-api" + ses := &user.SessionState{ + AccessRights: map[string]user.AccessDefinition{ + apiID: { + MCPAccessRights: user.MCPAccessRights{Tools: rules}, + }, + }, + } + hook := NewMCPListFilterSSEHook(apiID, ses) + + // Build the raw SSE bytes as the upstream would send them. + event := generateSSEToolsEvent(numTools) + raw := serializeSSEEvent(event) + + b.ResetTimer() + b.ReportAllocs() + for range b.N { + reader := io.NopCloser(bytes.NewReader(raw)) + tap := NewSSETap(reader, hook) + io.ReadAll(tap) //nolint:errcheck + tap.Close() + } +} + +func BenchmarkSSETap_E2E_100Tools(b *testing.B) { + allowed := make([]string, 10) + for i := range 10 { + allowed[i] = fmt.Sprintf("tool_%d", i) + } + benchmarkSSETapEndToEnd(b, 100, user.AccessControlRules{Allowed: allowed}) +} + +func BenchmarkSSETap_E2E_1000Tools(b *testing.B) { + allowed := make([]string, 10) + for i := range 10 { + allowed[i] = fmt.Sprintf("tool_%d", i) + } + benchmarkSSETapEndToEnd(b, 1000, user.AccessControlRules{Allowed: allowed}) +} + +func BenchmarkSSETap_E2E_1000Tools_Regex(b *testing.B) { + benchmarkSSETapEndToEnd(b, 1000, user.AccessControlRules{ + Allowed: []string{"tool_[0-9]", "tool_1[0-9]"}, + }) +} + +func BenchmarkSSETap_E2E_NoRules(b *testing.B) { + // No hook — pure SSETap passthrough for comparison. + event := generateSSEToolsEvent(1000) + raw := serializeSSEEvent(event) + + b.ResetTimer() + b.ReportAllocs() + for range b.N { + reader := io.NopCloser(bytes.NewReader(raw)) + tap := NewSSETap(reader) // no hooks + io.ReadAll(tap) //nolint:errcheck + tap.Close() + } +} diff --git a/internal/mcp/jsonrpc.go b/internal/mcp/jsonrpc.go index eb4d125fd01..d56c46581ad 100644 --- a/internal/mcp/jsonrpc.go +++ b/internal/mcp/jsonrpc.go @@ -19,12 +19,16 @@ const ( const ( // Tool methods MethodToolsCall = "tools/call" + MethodToolsList = "tools/list" // Resource methods - MethodResourcesRead = "resources/read" + MethodResourcesRead = "resources/read" + MethodResourcesList = "resources/list" + MethodResourcesTemplatesList = "resources/templates/list" // Prompt methods - MethodPromptsGet = "prompts/get" + MethodPromptsGet = "prompts/get" + MethodPromptsList = "prompts/list" ) // JSON-RPC parameter keys used across MCP methods diff --git a/internal/mcp/list_filter.go b/internal/mcp/list_filter.go new file mode 100644 index 00000000000..5f15d7877dd --- /dev/null +++ b/internal/mcp/list_filter.go @@ -0,0 +1,210 @@ +package mcp + +import ( + "encoding/json" + + "github.com/TykTechnologies/tyk/regexp" + "github.com/TykTechnologies/tyk/user" +) + +// ListFilterConfig holds the configuration for filtering a specific list method. +type ListFilterConfig struct { + ArrayKey string // JSON key of the array in result (e.g. "tools") + NameField string // JSON field to match against rules (e.g. "name", "uri") + RulesFrom func(rights user.MCPAccessRights) user.AccessControlRules // extracts the relevant rules +} + +// ListFilterConfigs maps array keys to their filter configurations. +// Both method-based lookup and result-key-based lookup (InferListConfigFromResult) +// reference these shared definitions. +var ListFilterConfigs = map[string]*ListFilterConfig{ + "tools": { + ArrayKey: "tools", + NameField: "name", + RulesFrom: func(r user.MCPAccessRights) user.AccessControlRules { return r.Tools }, + }, + "prompts": { + ArrayKey: "prompts", + NameField: "name", + RulesFrom: func(r user.MCPAccessRights) user.AccessControlRules { return r.Prompts }, + }, + "resources": { + ArrayKey: "resources", + NameField: "uri", + RulesFrom: func(r user.MCPAccessRights) user.AccessControlRules { return r.Resources }, + }, + "resourceTemplates": { + ArrayKey: "resourceTemplates", + NameField: "uriTemplate", + RulesFrom: func(r user.MCPAccessRights) user.AccessControlRules { return r.Resources }, + }, +} + +// JSONRPCResponse represents a JSON-RPC 2.0 response envelope. +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id"` + Result json.RawMessage `json:"result,omitempty"` + Error json.RawMessage `json:"error,omitempty"` +} + +// ExtractStringField extracts a string field from a JSON object. +// Returns empty string if the field doesn't exist or isn't a string. +func ExtractStringField(raw json.RawMessage, field string) string { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return "" + } + + val, ok := obj[field] + if !ok { + return "" + } + + var s string + if err := json.Unmarshal(val, &s); err != nil { + return "" + } + + return s +} + +// FilterItems applies access control rules to a slice of JSON items, returning +// only items that are permitted. Items whose name field cannot be extracted are +// included (fail-open for malformed data). +func FilterItems(items []json.RawMessage, nameField string, rules user.AccessControlRules) []json.RawMessage { + filtered := make([]json.RawMessage, 0, len(items)) + for _, item := range items { + name := ExtractStringField(item, nameField) + if name == "" { + // Can't extract the field — include the item (fail open for malformed data). + filtered = append(filtered, item) + continue + } + + // CheckAccessControlRules returns true if denied. + if !CheckAccessControlRules(rules, name) { + filtered = append(filtered, item) + } + } + return filtered +} + +// ReencodeEnvelope marshals the filtered items back into the JSON-RPC response +// envelope, performing the three-step re-marshal: items -> result -> envelope. +func ReencodeEnvelope(envelope *JSONRPCResponse, result map[string]json.RawMessage, arrayKey string, filtered []json.RawMessage) ([]byte, error) { + filteredBytes, err := json.Marshal(filtered) + if err != nil { + return nil, err + } + + result[arrayKey] = filteredBytes + + resultBytes, err := json.Marshal(result) + if err != nil { + return nil, err + } + + envelope.Result = resultBytes + + return json.Marshal(envelope) +} + +// FilterJSONRPCBody parses a JSON-RPC response body, filters the list items +// according to the given config and rules, and returns the re-encoded body. +// Returns (nil, false) when any parsing or marshalling step fails, signalling +// that the caller should pass through the original body unmodified. +func FilterJSONRPCBody(body []byte, cfg *ListFilterConfig, rules user.AccessControlRules) ([]byte, bool) { + var envelope JSONRPCResponse + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, false + } + + if envelope.Result == nil { + return nil, false + } + + var result map[string]json.RawMessage + if err := json.Unmarshal(envelope.Result, &result); err != nil { + return nil, false + } + + return FilterParsedJSONRPC(&envelope, result, cfg, rules) +} + +// FilterParsedJSONRPC filters items in an already-parsed JSON-RPC result and +// re-encodes the envelope. Returns (nil, false) when the array key is missing, +// items cannot be parsed, or re-encoding fails. +func FilterParsedJSONRPC(envelope *JSONRPCResponse, result map[string]json.RawMessage, cfg *ListFilterConfig, rules user.AccessControlRules) ([]byte, bool) { + itemsRaw, exists := result[cfg.ArrayKey] + if !exists { + return nil, false + } + + var items []json.RawMessage + if err := json.Unmarshal(itemsRaw, &items); err != nil { + return nil, false + } + + filtered := FilterItems(items, cfg.NameField, rules) + + newBody, err := ReencodeEnvelope(envelope, result, cfg.ArrayKey, filtered) + if err != nil { + return nil, false + } + + return newBody, true +} + +// InferListConfigFromResult determines the list type by inspecting which +// well-known array key is present in the JSON-RPC result object. +func InferListConfigFromResult(result map[string]json.RawMessage) *ListFilterConfig { + // Check resourceTemplates before resources — "resources" would also match + // if we checked it first, since both use the Resources access rights, + // but we need the correct arrayKey and nameField. + lookupOrder := []string{"tools", "prompts", "resourceTemplates", "resources"} + for _, key := range lookupOrder { + if _, ok := result[key]; ok { + return ListFilterConfigs[key] + } + } + return nil +} + +// CheckAccessControlRules evaluates allow/block lists against a name. +// Returns true if the name is denied, false if permitted. +// +// Evaluation order: +// 1. Blocked is checked first — if matched, the request is denied. +// 2. If Allowed is non-empty and the name does not match any entry, the request is denied. +// 3. If both lists are empty, access is permitted. +func CheckAccessControlRules(rules user.AccessControlRules, name string) bool { + for _, pattern := range rules.Blocked { + if matchPattern(pattern, name) { + return true + } + } + + if len(rules.Allowed) == 0 { + return false + } + + for _, pattern := range rules.Allowed { + if matchPattern(pattern, name) { + return false + } + } + + return true +} + +// matchPattern tests name against a regex pattern anchored with ^...$, enforcing full-match semantics. +// Uses the tyk/regexp package which caches compiled patterns. +// Falls back to exact-string comparison if the pattern is not valid regex. +func matchPattern(pattern, name string) bool { + re, err := regexp.Compile("^(?:" + pattern + ")$") + if err != nil { + return pattern == name + } + return re.MatchString(name) +} diff --git a/internal/mcp/list_filter_test.go b/internal/mcp/list_filter_test.go new file mode 100644 index 00000000000..5410a17951f --- /dev/null +++ b/internal/mcp/list_filter_test.go @@ -0,0 +1,329 @@ +package mcp + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/TykTechnologies/tyk/user" +) + +func TestExtractStringField(t *testing.T) { + tests := []struct { + name string + raw string + field string + want string + }{ + { + name: "existing string field", + raw: `{"name":"get_weather","description":"Get weather"}`, + field: "name", + want: "get_weather", + }, + { + name: "missing field", + raw: `{"description":"Get weather"}`, + field: "name", + want: "", + }, + { + name: "non-string field", + raw: `{"name":42}`, + field: "name", + want: "", + }, + { + name: "invalid JSON", + raw: `{not json}`, + field: "name", + want: "", + }, + { + name: "empty object", + raw: `{}`, + field: "name", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractStringField(json.RawMessage(tt.raw), tt.field) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCheckAccessControlRules(t *testing.T) { + tests := []struct { + name string + rules user.AccessControlRules + input string + wantErr bool + }{ + // Empty rules — everything passes + {"empty rules, any input passes", user.AccessControlRules{}, "anything", false}, + {"empty rules, empty input passes", user.AccessControlRules{}, "", false}, + + // Blocked only + {"blocked exact match", user.AccessControlRules{Blocked: []string{"tools/list"}}, "tools/list", true}, + {"blocked no match", user.AccessControlRules{Blocked: []string{"tools/list"}}, "tools/call", false}, + {"blocked regex match", user.AccessControlRules{Blocked: []string{"delete_.*"}}, "delete_user", true}, + {"blocked regex no match", user.AccessControlRules{Blocked: []string{"delete_.*"}}, "create_user", false}, + {"blocked suffix regex", user.AccessControlRules{Blocked: []string{".*_admin"}}, "user_admin", true}, + {"blocked suffix regex no match", user.AccessControlRules{Blocked: []string{".*_admin"}}, "user_create", false}, + {"blocked multiple patterns, first matches", user.AccessControlRules{Blocked: []string{"delete_.*", "reset_.*"}}, "delete_all", true}, + {"blocked multiple patterns, second matches", user.AccessControlRules{Blocked: []string{"delete_.*", "reset_.*"}}, "reset_config", true}, + {"blocked multiple patterns, none match", user.AccessControlRules{Blocked: []string{"delete_.*", "reset_.*"}}, "get_data", false}, + + // Allowed only + {"allowed exact match", user.AccessControlRules{Allowed: []string{"tools/call"}}, "tools/call", false}, + {"allowed not in list", user.AccessControlRules{Allowed: []string{"tools/call"}}, "tools/list", true}, + {"allowed regex match", user.AccessControlRules{Allowed: []string{"get_.*"}}, "get_weather", false}, + {"allowed regex no match", user.AccessControlRules{Allowed: []string{"get_.*"}}, "set_config", true}, + {"allowed multiple patterns, first matches", user.AccessControlRules{Allowed: []string{"get_.*", "list_.*"}}, "get_weather", false}, + {"allowed multiple patterns, second matches", user.AccessControlRules{Allowed: []string{"get_.*", "list_.*"}}, "list_users", false}, + {"allowed multiple patterns, none match", user.AccessControlRules{Allowed: []string{"get_.*", "list_.*"}}, "delete_all", true}, + + // Both — blocked takes precedence + {"deny precedence over allow (exact)", + user.AccessControlRules{Blocked: []string{"reset_system"}, Allowed: []string{"reset_system"}}, + "reset_system", true}, + {"deny precedence over allow (regex)", + user.AccessControlRules{Blocked: []string{".*_system"}, Allowed: []string{"reset_.*"}}, + "reset_system", true}, + {"allowed passes when not in blocked", + user.AccessControlRules{Blocked: []string{"delete_.*"}, Allowed: []string{"get_.*", "delete_.*"}}, + "get_weather", false}, + + {"alternation: allowed prefix match", user.AccessControlRules{Allowed: []string{"get_.*|set_.*"}}, "get_weather", false}, + {"alternation: allowed second branch", user.AccessControlRules{Allowed: []string{"get_.*|set_.*"}}, "set_config", false}, + {"alternation: denied non-matching", user.AccessControlRules{Allowed: []string{"get_.*|set_.*"}}, "delete_all", true}, + {"alternation: allowed spurious prefix leak", user.AccessControlRules{Allowed: []string{"get_.*|set_.*"}}, "bad_prefix_set_foo", true}, + {"alternation: blocked spurious trailing leak", user.AccessControlRules{Blocked: []string{"admin|debug"}}, "prefix_debug", false}, + + // Edge cases + {"pattern with URI chars", user.AccessControlRules{Allowed: []string{"file:///public/.*"}}, "file:///public/readme.md", false}, + {"pattern with URI chars, no match", user.AccessControlRules{Allowed: []string{"file:///public/.*"}}, "file:///secret/keys.txt", true}, + {"invalid regex falls back to exact, exact match", user.AccessControlRules{Blocked: []string{"[invalid"}}, "[invalid", true}, + {"invalid regex falls back to exact, no match", user.AccessControlRules{Blocked: []string{"[invalid"}}, "something", false}, + {"method name with slash", user.AccessControlRules{Blocked: []string{"tools/list"}}, "tools/list", true}, + {"method regex with slash", user.AccessControlRules{Allowed: []string{"resources/.*"}}, "resources/read", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + denied := CheckAccessControlRules(tt.rules, tt.input) + if tt.wantErr { + assert.True(t, denied, "expected denied for input %q", tt.input) + } else { + assert.False(t, denied, "expected allowed for input %q", tt.input) + } + }) + } +} + +func TestMatchPattern(t *testing.T) { + tests := []struct { + pattern string + name string + want bool + }{ + // Exact matches + {"get_weather", "get_weather", true}, + {"get_weather", "get_weather_v2", false}, // anchored: no partial match + {"tools/call", "tools/call", true}, + {"tools/call", "tools/list", false}, + + // Prefix wildcards + {"get_.*", "get_weather", true}, + {"get_.*", "set_config", false}, + {"resources/.*", "resources/read", true}, + {"resources/.*", "prompts/get", false}, + + // Suffix wildcards + {".*_admin", "user_admin", true}, + {".*_admin", "user_create", false}, + {".*_delete", "record_delete", true}, + + // Regex alternation + {"get_.*|set_.*", "get_weather", true}, + {"get_.*|set_.*", "set_config", true}, + {"get_.*|set_.*", "delete_all", false}, + {"get_.*|set_.*", "bad_prefix_set_foo", false}, + {"get_.*|set_.*", "get_weather_extra", true}, + {"a|b", "xb", false}, + {"a|b", "ax", false}, + {"a|b", "a", true}, + {"a|b", "b", true}, + + // URI patterns with slashes + {"file:///.*", "file:///repo/README", true}, + {"file:///public/.*", "file:///public/readme.md", true}, + {"file:///public/.*", "file:///secret/keys.txt", false}, + + // Invalid regex — falls back to exact comparison + {"[invalid", "[invalid", true}, + {"[invalid", "something", false}, + + // Empty cases + {".*", "", true}, + {"", "", true}, + } + + for _, tt := range tests { + t.Run(tt.pattern+"_vs_"+tt.name, func(t *testing.T) { + got := matchPattern(tt.pattern, tt.name) + assert.Equal(t, tt.want, got, "matchPattern(%q, %q)", tt.pattern, tt.name) + }) + } +} + +func TestFilterItems(t *testing.T) { + t.Run("empty items array returns empty", func(t *testing.T) { + rules := user.AccessControlRules{Allowed: []string{"anything"}} + result := FilterItems([]json.RawMessage{}, "name", rules) + assert.Empty(t, result) + }) + + t.Run("items missing name field are included (fail-open)", func(t *testing.T) { + items := []json.RawMessage{ + json.RawMessage(`{"name":"keep_me","description":"has name"}`), + json.RawMessage(`{"description":"no name field at all"}`), + json.RawMessage(`{"name":"block_me","description":"will be blocked"}`), + } + rules := user.AccessControlRules{Blocked: []string{"block_me"}} + result := FilterItems(items, "name", rules) + assert.Len(t, result, 2, "item without name field should be included (fail-open)") + + // Verify the kept items. + names := make([]string, 0, len(result)) + for _, item := range result { + n := ExtractStringField(item, "name") + names = append(names, n) + } + assert.Contains(t, names, "keep_me") + assert.Contains(t, names, "", "nameless item should pass through with empty name") + }) + + t.Run("items with non-string name field are included (fail-open)", func(t *testing.T) { + items := []json.RawMessage{ + json.RawMessage(`{"name":42,"description":"numeric name"}`), + json.RawMessage(`{"name":true,"description":"boolean name"}`), + json.RawMessage(`{"name":"normal","description":"string name"}`), + } + rules := user.AccessControlRules{Allowed: []string{"normal"}} + result := FilterItems(items, "name", rules) + // Items with non-string names can't be extracted — fail-open includes them. + assert.Len(t, result, 3) + }) + + t.Run("invalid regex in rules falls back to exact match", func(t *testing.T) { + items := []json.RawMessage{ + json.RawMessage(`{"name":"[unclosed","description":"literal bracket name"}`), + json.RawMessage(`{"name":"normal_tool","description":"normal"}`), + } + // "[unclosed" is an invalid regex — should fall back to exact string comparison. + rules := user.AccessControlRules{Blocked: []string{"[unclosed"}} + result := FilterItems(items, "name", rules) + assert.Len(t, result, 1, "invalid regex should still match by exact string comparison") + assert.Equal(t, "normal_tool", ExtractStringField(result[0], "name")) + }) +} + +func TestFilterJSONRPCBody(t *testing.T) { + t.Run("batch JSON-RPC array passes through (returns false)", func(t *testing.T) { + // JSON-RPC batch = top-level array. Not supported for filtering. + batch := `[{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"a"}]}},{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"b"}]}}]` + cfg := ListFilterConfigs["tools"] + rules := user.AccessControlRules{Allowed: []string{"a"}} + result, ok := FilterJSONRPCBody([]byte(batch), cfg, rules) + assert.False(t, ok, "batch responses should not be parsed") + assert.Nil(t, result) + }) + + t.Run("error response with no result passes through", func(t *testing.T) { + errResp := `{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"invalid request"}}` + cfg := ListFilterConfigs["tools"] + rules := user.AccessControlRules{Blocked: []string{".*"}} + result, ok := FilterJSONRPCBody([]byte(errResp), cfg, rules) + assert.False(t, ok) + assert.Nil(t, result) + }) + + t.Run("empty result object passes through", func(t *testing.T) { + emptyResult := `{"jsonrpc":"2.0","id":1,"result":{}}` + cfg := ListFilterConfigs["tools"] + rules := user.AccessControlRules{Allowed: []string{"anything"}} + result, ok := FilterJSONRPCBody([]byte(emptyResult), cfg, rules) + assert.False(t, ok, "empty result with no array key should pass through") + assert.Nil(t, result) + }) + + t.Run("unicode tool names are matched correctly", func(t *testing.T) { + body := `{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"résumé_tool"},{"name":"日本語ツール"},{"name":"normal_tool"}]}}` + cfg := ListFilterConfigs["tools"] + rules := user.AccessControlRules{Allowed: []string{"résumé_tool", "normal_tool"}} + result, ok := FilterJSONRPCBody([]byte(body), cfg, rules) + require.True(t, ok) + + var envelope JSONRPCResponse + require.NoError(t, json.Unmarshal(result, &envelope)) + var res map[string]json.RawMessage + require.NoError(t, json.Unmarshal(envelope.Result, &res)) + var tools []map[string]any + require.NoError(t, json.Unmarshal(res["tools"], &tools)) + assert.Len(t, tools, 2) + names := []string{tools[0]["name"].(string), tools[1]["name"].(string)} + assert.Contains(t, names, "résumé_tool") + assert.Contains(t, names, "normal_tool") + }) +} + +func TestInferListConfigFromResult(t *testing.T) { + tests := []struct { + name string + key string + expected string + }{ + {"tools", "tools", "tools"}, + {"prompts", "prompts", "prompts"}, + {"resources", "resources", "resources"}, + {"resourceTemplates", "resourceTemplates", "resourceTemplates"}, + {"content (not a list)", "content", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := map[string]json.RawMessage{ + tt.key: json.RawMessage(`[]`), + } + cfg := InferListConfigFromResult(result) + if tt.expected == "" { + assert.Nil(t, cfg) + } else { + require.NotNil(t, cfg) + assert.Equal(t, tt.expected, cfg.ArrayKey) + } + }) + } + + t.Run("ambiguous keys returns first in lookup order", func(t *testing.T) { + // Result with both "tools" and "prompts" — should return tools (first in order). + result := map[string]json.RawMessage{ + "tools": json.RawMessage(`[]`), + "prompts": json.RawMessage(`[]`), + } + cfg := InferListConfigFromResult(result) + require.NotNil(t, cfg) + assert.Equal(t, "tools", cfg.ArrayKey) + }) + + t.Run("empty result returns nil", func(t *testing.T) { + result := map[string]json.RawMessage{} + cfg := InferListConfigFromResult(result) + assert.Nil(t, cfg) + }) +} diff --git a/report.md b/report.md new file mode 100644 index 00000000000..c302073bb74 --- /dev/null +++ b/report.md @@ -0,0 +1,369 @@ +# MCP List Filtering by Access Control (TBAC) - Implementation Report + +## Overview + +This report documents the implementation of Tool-Based Access Control (TBAC) for MCP +primitive list responses in the Tyk gateway. The feature filters `tools/list`, +`prompts/list`, `resources/list`, and `resources/templates/list` responses so that +consumers only see the primitives they are authorised to use. + +Previously, access control only blocked `tools/call`, `resources/read`, and +`prompts/get` at invocation time. List endpoints passed through unfiltered, meaning +consumers could discover tools they would get a 403 on when trying to call them. + +--- + +## Architecture + +### Request flow (before this change) + +``` +Client ──POST──> Gateway ──POST──> Upstream MCP Server + │ + ├─ JSONRPCMiddleware (parse method, route to VEM) + ├─ JSONRPCAccessControlMiddleware (method-level ACL) + ├─ MCPAccessControlMiddleware (primitive-level ACL) + ├─ Rate limiting + └─ MCPVEMContinuationMiddleware (VEM chain routing) +``` + +For `tools/call`, `resources/read`, `prompts/get`: the middleware chain enforces +access control and blocks unauthorised invocations **before** proxying to upstream. + +For `tools/list`, `prompts/list`, etc.: the request has no primitive type in the +routing state (`PrimitiveType == ""`), so `MCPAccessControlMiddleware` skips the +check and the response passes through unfiltered. + +### Request flow (after this change) + +``` +Client ──POST──> Gateway ──POST──> Upstream MCP Server + │ │ + │ <────┘ Response + │ + ├─ MCPListFilterResponseHandler (RESPONSE middleware) + │ └─ Reads JSON-RPC response body + │ └─ Filters tools/prompts/resources/resourceTemplates + │ └─ Rewrites response body with permitted items only + │ + └─ (for SSE streaming responses) + MCPListFilterSSEHook (SSE tap hook) + └─ Intercepts SSE events carrying list responses + └─ Same filtering logic, applied per-event +``` + +Two complementary handlers cover both transport modes: + +| Transport | Response format | Handler | +|-----------|----------------|---------| +| Standard HTTP | `Content-Type: application/json` | `MCPListFilterResponseHandler` | +| Streamable HTTP / SSE | `Content-Type: text/event-stream` | `MCPListFilterSSEHook` | + +### Filtering algorithm + +Both handlers use the same logic: + +1. Identify the list method (from routing state for HTTP, from result keys for SSE) +2. Look up the session's `MCPAccessRights` for the API +3. Extract the relevant `AccessControlRules` (allowed/blocked patterns) for the primitive type +4. For each item in the response array, extract the name field and check against rules +5. Use the existing `checkAccessControlRules()` function (same as invocation-time enforcement) +6. Rebuild the response with only permitted items; preserve pagination cursors + +**Rule evaluation order** (unchanged from invocation-time enforcement): +1. Blocked list checked first - if name matches any blocked pattern, item is removed +2. If Allowed list is non-empty and name does not match any entry, item is removed +3. If both lists are empty, item is permitted + +**Deny always takes precedence over allow.** An item in both lists is denied. + +--- + +## Files Changed + +### Tyk Gateway (tyk/tyk) + +| File | Type | Description | +|------|------|-------------| +| `internal/mcp/jsonrpc.go` | Modified | Added `MethodToolsList`, `MethodPromptsList`, `MethodResourcesList`, `MethodResourcesTemplatesList` constants | +| `gateway/res_handler_mcp_list_filter.go` | **New** | Response middleware for HTTP JSON responses | +| `gateway/res_handler_mcp_list_filter_test.go` | **New** | 30 unit tests + 5 benchmarks for HTTP path | +| `gateway/sse_hook_mcp_list_filter.go` | **New** | SSE tap hook for Streamable HTTP responses | +| `gateway/sse_hook_mcp_list_filter_test.go` | **New** | 20 unit tests + 9 benchmarks for SSE path | +| `gateway/server.go` | Modified | Registered response handler in middleware chain | +| `gateway/reverse_proxy.go` | Modified | Registered SSE hook in SSE tap | + +### Mock MCP Server (tyk-mock-mcp-server) + +| File | Type | Description | +|------|------|-------------| +| `main.go` | Modified | Added 2 resource templates (`file://{path}`, `db://{schema}/{table}`) | +| `handlers/resources/resources.go` | Modified | Added `FileTemplate` and `DBTemplate` handlers | + +### Integration Tests (tyk-analytics) + +| File | Type | Description | +|------|------|-------------| +| `tests/api/mcp_client.py` | Modified | Added `list_resource_templates()` method | +| `tests/api/tests/mcp/mcp_list_filter_test.py` | **New** | 15 end-to-end integration tests | + +--- + +## Acceptance Criteria Coverage + +### Listing Tools (tools/list) + +| Criterion | Test | +|-----------|------| +| Filtered by exact allowlist `["get_weather", "get_forecast"]` | `test_tools_list_filtered_by_allowlist` | +| Filtered by wildcard suffix `["get_*"]` (regex `get_.*`) | `test_tools_list_filtered_by_allowlist_wildcard_suffix` | +| Filtered by exact denylist `["delete_alert", "set_alert"]` | `test_tools_list_filtered_by_denylist` | +| Filtered by wildcard prefix `["*_alert"]` (regex `.*_alert`) | `test_tools_list_filtered_by_denylist_wildcard_prefix` | +| Deny + allow: deny takes precedence, returns no tools | `test_tools_list_deny_takes_precedence_over_allow` | +| Pagination `nextCursor` preserved after filtering | `test_tools_list_pagination_nextCursor_preserved` (unit test) | + +### Listing Prompts (prompts/list) + +| Criterion | Test | +|-----------|------| +| Prompt allowlist filters prompts/list | `test_prompts_list_filtered_by_allowlist` | +| Prompt denylist filters prompts/list | `test_prompts_list_filtered_by_denylist` | + +### Listing Resources (resources/list) + +| Criterion | Test | +|-----------|------| +| Resource allowlist filters resources/list | `test_resources_list_filtered_by_allowlist` | +| Resource denylist filters resources/list | `test_resources_list_filtered_by_denylist` | + +### Listing Resource Templates (resources/templates/list) + +| Criterion | Test | +|-----------|------| +| Template denylist excludes `db://{schema}/{table}` | `test_resource_templates_list_filtered_by_denylist` | +| Template allowlist permits only `file://{path}` | `test_resource_templates_list_filtered_by_allowlist` | + +### Cross-cutting + +| Criterion | Test | +|-----------|------| +| Tool rules do not affect prompts or resources | `test_tool_rules_do_not_filter_prompts_or_resources` | +| No filtering when MCPAccessRights is empty | `test_tools_list_no_filtering_when_no_acl` | +| Policy-based ACL rules filter tools/list | `test_policy_acl_filters_tools_list` | + +--- + +## Test Summary + +### Unit tests (gateway package) + +**HTTP response handler** (`res_handler_mcp_list_filter_test.go`): + +- 16 table-driven subtests covering all list methods, rule types, edge cases +- 9 standalone tests for nil/empty/wrong-API/content-length/routing-state scenarios +- 5 helper function tests (`TestExtractStringField`) +- 5 benchmarks (100/1000 tools, exact/regex/no-rules) + +**SSE hook** (`sse_hook_mcp_list_filter_test.go`): + +- 4 constructor tests (`NewMCPListFilterSSEHook`) +- 11 `FilterEvent` tests (allowlist, denylist, regex, deny-precedence, pagination, non-message events, non-list responses, errors, empty data, malformed JSON, multi-line SSE data) +- 2 dedicated primitive type tests (prompts, resource templates) +- 5 `inferListConfigFromResult` tests +- 5 hook-only benchmarks (100/1000 tools, exact/regex, non-list passthrough) +- 4 SSETap end-to-end benchmarks (100/1000 tools, regex, no-rules passthrough) + +**Total: 57 unit tests, 14 benchmarks. All pass with `-race`.** + +### Integration tests (tyk-analytics) + +15 end-to-end tests in `mcp_list_filter_test.py` covering: +- tools/list: allowlist, wildcard, denylist, wildcard-prefix, deny-precedence, no-ACL +- prompts/list: allowlist, denylist +- resources/list: allowlist, denylist +- resources/templates/list: allowlist, denylist +- Cross-primitive isolation +- Policy-based filtering + +--- + +## Performance Analysis + +All benchmarks run on Apple M4 Pro, arm64, Go 1.25. Each result is the median +of 3-5 runs. + +### HTTP Response Handler Path + +| Scenario | Latency | Memory | Allocs | +|----------|---------|--------|--------| +| No rules (1000 tools, passthrough) | **27 us** | 217 KB | 33 | +| 100 tools, 10 exact allowlist entries | **398 us** | 314 KB | 4,538 | +| 100 tools, regex patterns | **245 us** | 175 KB | 2,244 | +| 1000 tools, 10 exact allowlist entries | **4.04 ms** | 3.1 MB | 45,952 | +| 1000 tools, regex patterns | **2.28 ms** | 1.7 MB | 22,057 | + +### SSE Hook Path (FilterEvent alone) + +| Scenario | Latency | Memory | Allocs | +|----------|---------|--------|--------| +| Non-list event (passthrough) | **1.7 us** | 1.2 KB | 22 | +| 100 tools, 10 exact allowlist entries | **393 us** | 296 KB | 4,512 | +| 100 tools, regex patterns | **242 us** | 155 KB | 2,217 | +| 1000 tools, 10 exact allowlist entries | **4.04 ms** | 2.9 MB | 45,918 | +| 1000 tools, regex patterns | **2.27 ms** | 1.5 MB | 22,021 | + +### SSE Tap End-to-End (SSE parse + hook + SSE serialize) + +| Scenario | Latency | Memory | Allocs | +|----------|---------|--------|--------| +| No rules (SSETap passthrough, no hook) | **342 us** | 498 KB | 32 | +| 100 tools, 10 exact allowlist entries | **412 us** | 318 KB | 4,529 | +| 1000 tools, 10 exact allowlist entries | **4.36 ms** | 3.2 MB | 45,940 | +| 1000 tools, regex patterns | **2.63 ms** | 1.8 MB | 22,044 | + +### Percentage impact + +#### SSE streaming path (overhead of filter hook on existing SSETap) + +| Scenario | SSETap baseline | With hook | Hook overhead | % increase | +|----------|-----------------|-----------|---------------|------------| +| Non-list event (hot path) | 24 us | 25.7 us | 1.7 us | **~7%** | +| 100 tools, exact rules | 342 us | 412 us | 70 us | **~20%** | +| 1000 tools, exact rules | 342 us | 4,360 us | 4,018 us | **~1174%** | +| 1000 tools, regex rules | 342 us | 2,630 us | 2,288 us | **~669%** | + +#### HTTP path (as percentage of total request time) + +| Scenario | Filtering cost | % of 50ms RTT | % of 100ms RTT | +|----------|---------------|---------------|----------------| +| No rules | 27 us | 0.05% | 0.03% | +| 15 tools (realistic) | ~60 us | 0.12% | 0.06% | +| 100 tools, exact | 398 us | 0.8% | 0.4% | +| 100 tools, regex | 245 us | 0.5% | 0.25% | +| 1000 tools, exact | 4.04 ms | 7.5% | 3.9% | +| 1000 tools, regex | 2.28 ms | 4.4% | 2.2% | + +### Where CPU time is spent (profiled at 1000 tools) + +| Component | % of CPU | Description | +|-----------|---------|-------------| +| `checkAccessControlRules` / `matchPattern` | 36% | Regex compilation + matching per item | +| `regexp.Compile` (cached via tyk/regexp) | 23% | Cache lookup includes `time.Now()` for TTL | +| `json.Unmarshal` | 18% | Parsing response body + extracting name fields | +| GC pressure | 13% | Allocations from JSON parse/serialize cycle | +| `json.Marshal` | ~10% | Re-encoding the filtered response | + +### Key observations + +1. **The passthrough path is essentially free.** When no ACL rules are configured + (the default), the HTTP handler exits after a nil check — 27 us. The SSE hook + returns nil and is never instantiated. + +2. **Real MCP servers have 10-50 tools, not 1000.** At 15 tools (what the mock + server exposes), filtering adds ~60 us. The 1000-tool benchmarks are stress + tests, not realistic scenarios. + +3. **Non-list SSE events add 1.7 us.** In a streaming session, 99%+ of events + are tool call results, notifications, and pings. The hook's quick-exit path + (check event type + `strings.Contains` for `"result"`) is the real hot path. + +4. **List calls happen once per session.** `tools/list` is a discovery call sent + at connection setup. A one-time 400 us cost is invisible to the user. + +5. **Regex is faster than exact match for large lists.** With `get_.*` filtering + 1000 tools, fewer items survive to re-serialization. Less output = less marshal + time. This is why regex (2.3 ms) beats 10 exact entries (4.0 ms) at scale. + +6. **The SSETap framing overhead (~300 us) is pre-existing.** It applies to all + MCP SSE responses regardless of filtering. The hook adds negligible cost on top. + +### Potential optimisations (not needed now) + +If a future deployment hits 1000+ tools with complex regex rules: + +- **Pre-compile regex patterns at session load time** instead of per-request. + The `tyk/regexp` cache helps, but eliminates `time.Now()` calls per match. +- **Streaming JSON parser** to avoid full unmarshal/remarshal. Only the primitive + array needs modification; the envelope and other result keys could be copied as-is. +- **Targeted name extraction** using a byte-level scan for `"name":"..."` instead + of unmarshalling each item into a `map[string]json.RawMessage`. + +None of these are warranted for current scale. + +--- + +## Design Decisions + +### Response middleware vs request middleware + +The filtering must happen **after** the upstream returns the list, so a response +handler is the only viable approach. Request middleware cannot filter what the +upstream hasn't sent yet. + +### Fail-open for malformed data + +Items without a parseable name/uri field are **included** in the filtered response. +This prevents the gateway from silently dropping valid items due to unexpected JSON +structure changes in future MCP spec versions. + +### SSE hook infers method from result keys + +JSON-RPC responses don't include the method name. The SSE hook uses +`inferListConfigFromResult()` to determine the list type by checking which +well-known key exists in the result (`tools`, `prompts`, `resources`, +`resourceTemplates`). These keys are distinct and never appear in non-list +responses, making inference unambiguous. + +### Two handlers instead of one + +The HTTP and SSE paths have fundamentally different integration points: +- HTTP: `TykResponseHandler` interface, runs before body is copied to client +- SSE: `SSEHook` interface, runs per-event inside the `SSETap` streaming pipeline + +Sharing the core filtering logic (via `mcpListConfig`, `checkAccessControlRules`, +`extractStringField`) while keeping the integration glue separate is cleaner than +a single handler trying to detect and handle both transports. + +### Registration order + +The HTTP response handler is registered **before** `ResponseTransformMiddleware` in +the response chain so that filtering happens first. This ensures body transforms +operate on already-filtered data. + +--- + +## How to run + +### Unit tests + +```bash +# All MCP list filter tests (HTTP + SSE) +go test -run "TestMCPListFilter|TestExtractStringField|TestNewMCPListFilterSSEHook|TestMCPListFilterSSEHook|TestInferListConfig" -v ./gateway/ + +# With race detector +go test -run "TestMCPListFilter|TestNewMCPListFilterSSEHook|TestMCPListFilterSSEHook" -race ./gateway/ +``` + +### Benchmarks + +```bash +# HTTP response handler benchmarks +go test -run=^$ -bench=BenchmarkMCPListFilter -benchmem ./gateway/ + +# SSE hook benchmarks +go test -run=^$ -bench=BenchmarkSSEHook -benchmem ./gateway/ + +# SSE tap end-to-end benchmarks +go test -run=^$ -bench=BenchmarkSSETap_E2E -benchmem ./gateway/ + +# All filtering benchmarks +go test -run=^$ -bench="BenchmarkMCPListFilter|BenchmarkSSEHook|BenchmarkSSETap_E2E" -benchmem ./gateway/ +``` + +### Integration tests + +```bash +# Requires running gateway + dashboard + mock MCP server +cd tyk-analytics/tests/api +pytest tests/mcp/mcp_list_filter_test.py -v -m mcp +```