Skip to content

Authz list filters drop resultType, cacheScope and ttlMs from 2026-07-28 results #6708

Description

@alex-feel

Bug description

When authorization is configured, filterToolsResponse, filterPromptsResponse and filterResourcesResponse in pkg/authz/response_filter.go decode the list result into toolhive-core's mcp.ListToolsResult / ListPromptsResult / ListResourcesResult and marshal a new value. Those types hold only _meta, nextCursor and the list, so the filters drop resultType, cacheScope, ttlMs and any other result member. filterResourceTemplatesResponse works on the raw members, keeps them, and sets cacheScope/ttlMs to "private"/0.

MCP 2026-07-28 says a result MUST include resultType, and gives the rule that a missing resultType means "complete" for backward compatibility with servers on earlier revisions (spec). So a client that enforces this rejects tools/list from a 2026-07-28 backend behind a proxy with an authz config. vMCP's own Modern client is one of them: it maps a result without resultType to errLegacyResponseBody, the failure in #6684.

Steps to reproduce

main.go (below) sends a 2026-07-28 request for each list method through mcp.ParsingMiddleware and authz.Middleware with a Cedar policy that permits every item, once against a JSON and once against an SSE backend response, and prints the result members that come back.

mkdir repro && cd repro && go mod init repro
# save main.go from below
go get github.com/stacklok/toolhive@v0.51.1 && go mod tidy
go run .
main.go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"maps"
	"net/http"
	"net/http/httptest"
	"slices"
	"strings"

	"github.com/stacklok/toolhive/pkg/auth"
	"github.com/stacklok/toolhive/pkg/authz"
	"github.com/stacklok/toolhive/pkg/authz/authorizers/cedar"
	"github.com/stacklok/toolhive/pkg/mcp"
)

// List members of a 2026-07-28 result; every item is permitted by the policies below.
var lists = map[string]string{
	"tools/list":               `"tools":[{"name":"echo","inputSchema":{"type":"object"}}]`,
	"prompts/list":             `"prompts":[{"name":"greet"}]`,
	"resources/list":           `"resources":[{"uri":"file:///a.txt","name":"a"}]`,
	"resources/templates/list": `"resourceTemplates":[{"uriTemplate":"file:///{p}","name":"t"}]`,
}

func main() {
	authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{Policies: []string{
		`permit(principal, action == Action::"call_tool", resource);`,
		`permit(principal, action == Action::"get_prompt", resource);`,
		`permit(principal, action == Action::"read_resource", resource);`,
	}, EntitiesJSON: `[]`}, "demo")
	if err != nil {
		panic(err)
	}
	for _, method := range []string{"tools/list", "prompts/list", "resources/list", "resources/templates/list"} {
		result := `{"resultType":"complete","cacheScope":"private","ttlMs":0,"_meta":{},` + lists[method] + `}`
		for _, sse := range []bool{false, true} {
			backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
				body := `{"jsonrpc":"2.0","id":1,"result":` + result + `}`
				if sse {
					w.Header().Set("Content-Type", "text/event-stream")
					body = "event: message\ndata: " + body + "\n\n"
				} else {
					w.Header().Set("Content-Type", "application/json")
				}
				_, _ = w.Write([]byte(body))
			})
			handler := mcp.ParsingMiddleware(authz.Middleware(authorizer, backend, nil))

			req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewBufferString(
				`{"jsonrpc":"2.0","id":1,"method":"`+method+`",`+
					`"params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}`))
			req.Header.Set("Content-Type", "application/json")
			req.Header.Set("MCP-Protocol-Version", "2026-07-28")
			req.Header.Set("Mcp-Method", method)
			req = req.WithContext(auth.WithIdentity(req.Context(), &auth.Identity{
				PrincipalInfo: auth.PrincipalInfo{Subject: "user", Claims: map[string]any{"sub": "user"}},
			}))
			rec := httptest.NewRecorder()
			handler.ServeHTTP(rec, req)

			body := rec.Body.String()
			if i := strings.Index(body, "data: "); sse && i >= 0 {
				body = strings.TrimSpace(body[i+len("data: "):])
			}
			var resp struct {
				Result map[string]json.RawMessage `json:"result"`
			}
			if err := json.Unmarshal([]byte(body), &resp); err != nil {
				panic(err)
			}
			transport := map[bool]string{false: "json", true: "sse"}[sse]
			fmt.Printf("%-25s %-4s result keys: %v\n", method, transport, slices.Sorted(maps.Keys(resp.Result)))
		}
	}
}

Expected behavior

Each list result keeps resultType and carries cacheScope and ttlMs, as the resources/templates/list result does.

Actual behavior

tools/list                json result keys: [_meta tools]
tools/list                sse  result keys: [_meta tools]
prompts/list              json result keys: [_meta prompts]
prompts/list              sse  result keys: [_meta prompts]
resources/list            json result keys: [_meta resources]
resources/list            sse  result keys: [_meta resources]
resources/templates/list  json result keys: [_meta cacheScope resourceTemplates resultType ttlMs]
resources/templates/list  sse  result keys: [_meta cacheScope resourceTemplates resultType ttlMs]

Environment (if relevant)

  • OS/version: Windows 11, Go 1.27.0
  • ToolHive version: v0.51.1 (main is the same commit, 2b299c1)

Additional context

Building these results the way filterResourceTemplatesResponse does would fix it: keep the raw result members and replace only the list, with the same "private"/0 caching hints, since the filtered list depends on the caller. #6684 is the same loss in the tool filter (pkg/mcp/tool_filter.go). I plan to send a PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageIssue needs initial triage by a maintainer

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions