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.
Bug description
When authorization is configured,
filterToolsResponse,filterPromptsResponseandfilterResourcesResponseinpkg/authz/response_filter.godecode the list result into toolhive-core'smcp.ListToolsResult/ListPromptsResult/ListResourcesResultand marshal a new value. Those types hold only_meta,nextCursorand the list, so the filters dropresultType,cacheScope,ttlMsand any other result member.filterResourceTemplatesResponseworks on the raw members, keeps them, and setscacheScope/ttlMsto"private"/0.MCP 2026-07-28 says a result MUST include
resultType, and gives the rule that a missingresultTypemeans"complete"for backward compatibility with servers on earlier revisions (spec). So a client that enforces this rejectstools/listfrom 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 withoutresultTypetoerrLegacyResponseBody, the failure in #6684.Steps to reproduce
main.go(below) sends a 2026-07-28 request for each list method throughmcp.ParsingMiddlewareandauthz.Middlewarewith 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.main.go
Expected behavior
Each list result keeps
resultTypeand carriescacheScopeandttlMs, as theresources/templates/listresult does.Actual behavior
Environment (if relevant)
Additional context
Building these results the way
filterResourceTemplatesResponsedoes would fix it: keep the raw result members and replace only the list, with the same"private"/0caching 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.