@@ -24,28 +24,33 @@ var errBug = errors.New("there's a bug")
2424// ResponseFilteringWriter wraps an http.ResponseWriter to intercept and filter responses
2525type ResponseFilteringWriter struct {
2626 http.ResponseWriter
27- authorizer authorizers.Authorizer
28- request * http.Request
29- method string
30- buffer * bytes.Buffer
31- statusCode int
32- annotationCache * AnnotationCache
27+ authorizer authorizers.Authorizer
28+ request * http.Request
29+ method string
30+ buffer * bytes.Buffer
31+ statusCode int
32+ annotationCache * AnnotationCache
33+ passThroughTools map [string ]struct {}
3334}
3435
3536// NewResponseFilteringWriter creates a new response filtering writer.
3637// The annotationCache parameter is optional; pass nil to disable annotation caching.
38+ // The passThroughTools parameter is optional; tools whose names appear in this set
39+ // bypass policy filtering because authorization is enforced elsewhere (e.g., inside
40+ // the optimizer decorator for find_tool/call_tool).
3741func NewResponseFilteringWriter (
3842 w http.ResponseWriter , authorizer authorizers.Authorizer , r * http.Request , method string ,
39- annotationCache * AnnotationCache ,
43+ annotationCache * AnnotationCache , passThroughTools map [ string ] struct {},
4044) * ResponseFilteringWriter {
4145 return & ResponseFilteringWriter {
42- ResponseWriter : w ,
43- authorizer : authorizer ,
44- request : r ,
45- method : method ,
46- buffer : & bytes.Buffer {},
47- statusCode : http .StatusOK ,
48- annotationCache : annotationCache ,
46+ ResponseWriter : w ,
47+ authorizer : authorizer ,
48+ request : r ,
49+ method : method ,
50+ buffer : & bytes.Buffer {},
51+ statusCode : http .StatusOK ,
52+ annotationCache : annotationCache ,
53+ passThroughTools : passThroughTools ,
4954 }
5055}
5156
@@ -283,39 +288,32 @@ func (rfw *ResponseFilteringWriter) filterToolsResponse(response *jsonrpc2.Respo
283288 // subsequent tools/call requests can look up annotations.
284289 rfw .annotationCache .SetFromToolsList (listResult .Tools )
285290
286- // Note: instantiating the list ensures that no null value is sent over the wire.
287- // This is basically defensive programming, but for clients.
288- filteredTools := []mcp.Tool {}
289- for i , tool := range listResult .Tools {
290- // Inject this tool's annotations into the context so Cedar policies
291- // that use when clauses on resource attributes (e.g. resource.readOnlyHint)
292- // can evaluate correctly. Without this, the authorization check runs
293- // against a context with no annotations and all when clauses fail.
294- ctx := rfw .request .Context ()
295- ann := & listResult .Tools [i ].Annotations
296- if hasAnyHint (ann ) {
297- ctx = authorizers .WithToolAnnotations (ctx , convertMCPAnnotation (ann ))
298- }
299-
300- // Check if the user is authorized to call this tool
301- authorized , err := rfw .authorizer .AuthorizeWithJWTClaims (
302- ctx ,
303- authorizers .MCPFeatureTool ,
304- authorizers .MCPOperationCall ,
305- tool .Name ,
306- nil , // No arguments for the authorization check
307- )
308- if err != nil {
309- slog .Warn ("Authorization check failed for tool, skipping" ,
310- "tool" , tool .Name , "error" , err )
311- continue
312- }
313-
314- if authorized {
315- filteredTools = append (filteredTools , tool )
291+ // When the optimizer is enabled, its meta-tools (find_tool, call_tool) appear
292+ // in tools/list instead of real backend tools. These meta-tools won't match
293+ // any operator-written Cedar policy (which references real tool names), so
294+ // default-deny would filter them out — leaving the client with zero tools.
295+ // Authorization for the underlying backend tools is enforced inside the
296+ // optimizer decorator itself (find_tool filters results, call_tool gates
297+ // invocations), so the meta-tools can safely pass through the response filter.
298+ // See: https://github.com/stacklok/toolhive/issues/4373
299+ passThrough := []mcp.Tool {}
300+ regular := []mcp.Tool {}
301+ for _ , t := range listResult .Tools {
302+ if _ , ok := rfw .passThroughTools [t .Name ]; ok {
303+ passThrough = append (passThrough , t )
304+ } else {
305+ regular = append (regular , t )
316306 }
317307 }
318308
309+ // FilterToolsByPolicy checks each tool against the caller's Cedar policies
310+ // (injecting annotations into context for when-clause evaluation) and returns
311+ // only tools the caller is authorized to call.
312+ policyFiltered := FilterToolsByPolicy (rfw .request .Context (), rfw .authorizer , regular )
313+ filteredTools := make ([]mcp.Tool , 0 , len (passThrough )+ len (policyFiltered ))
314+ filteredTools = append (filteredTools , passThrough ... )
315+ filteredTools = append (filteredTools , policyFiltered ... )
316+
319317 // Create a new result with filtered tools
320318 filteredResult := mcp.ListToolsResult {
321319 PaginatedResult : listResult .PaginatedResult ,
0 commit comments