Skip to content

mcp: tools/call omits required resultType when a receiving middleware short-circuits #1225

Description

@roncodingenthusiast

Describe the bug
The SDK sets resultType on a CallToolResult only inside Server.callTool. A receiving middleware can reject a call by returning a result instead of calling next. That result never reaches Server.callTool, so the tools/call response carries no resultType. The 2026-07-28 schema requires the field, and strict clients reject the response as malformed.

This gap is the tools/call half of #1032. PR #1060 fixed the other half. It embedded completeResultWithType into CompleteResult, DiscoverResult, and the four list results, then called setCompleteResultType from ServerSession.handle.

PR #1060 left out the three multi-round-trip results: CallToolResult, GetPromptResult, and ReadResourceResult. Each one also carries input_required, so each holds its own unexported resultType field. They implement setResultType but not isCompleteResult(), so setCompleteResultType skips them.

A middleware that rejects a call is a normal use of a public extension point. Servers gate authentication, rate limits, and entitlements this way. But the SDK offers no way to build a spec-compliant CallToolResult outside the package:

  • resultType is unexported.
  • The SDK exposes no setter for it.
  • isResult() seals Result, so no external type can implement it.

A server that uses AddReceivingMiddleware this way cannot return a compliant response.

Environment:

To Reproduce

The program below starts two servers. They differ only in whether a middleware short-circuits tools/call. Each one prints whether the response carries resultType.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

const meta = `"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",` +
	`"io.modelcontextprotocol/clientInfo":{"name":"repro","version":"1.0"},` +
	`"io.modelcontextprotocol/clientCapabilities":{}}`

// gate rejects tools/call the way an auth, rate-limit, or entitlement
// middleware does: by returning a result instead of calling next.
func gate() mcp.Middleware {
	return func(next mcp.MethodHandler) mcp.MethodHandler {
		return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
			if method != "tools/call" {
				return next(ctx, method, req)
			}
			return &mcp.CallToolResult{
				Content: []mcp.Content{&mcp.TextContent{Text: "rate limit exceeded"}},
				IsError: true,
			}, nil
		}
	}
}

func newServer(withGate bool) *mcp.Server {
	s := mcp.NewServer(&mcp.Implementation{Name: "repro", Version: "1.0.0"}, nil)
	mcp.AddTool(s, &mcp.Tool{Name: "greet"},
		func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, any, error) {
			return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "hi"}}}, nil, nil
		})
	if withGate {
		s.AddReceivingMiddleware(gate())
	}
	return s
}

func callTool(s *mcp.Server) map[string]json.RawMessage {
	ts := httptest.NewServer(mcp.NewStreamableHTTPHandler(
		func(*http.Request) *mcp.Server { return s },
		&mcp.StreamableHTTPOptions{Stateless: true}))
	defer ts.Close()

	body := fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"tools/call",`+
		`"params":{"name":"greet","arguments":{},%s}}`, meta)
	req, _ := http.NewRequest("POST", ts.URL, strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json, text/event-stream")
	req.Header.Set("MCP-Protocol-Version", "2026-07-28")
	req.Header.Set("Mcp-Method", "tools/call")
	req.Header.Set("Mcp-Name", "greet")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	raw, _ := io.ReadAll(resp.Body)

	payload := raw
	if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") {
		for _, line := range strings.Split(string(raw), "\n") {
			if d, ok := strings.CutPrefix(strings.TrimRight(line, "\r"), "data: "); ok {
				payload = []byte(d)
				break
			}
		}
	}
	var msg struct {
		Result map[string]json.RawMessage `json:"result"`
	}
	if err := json.Unmarshal(payload, &msg); err != nil {
		panic(fmt.Sprintf("decode %v: %s", err, raw))
	}
	return msg.Result
}

func main() {
	for _, tc := range []struct {
		label    string
		withGate bool
	}{
		{"handler reaches dispatcher", false},
		{"middleware short-circuits tools/call", true},
	} {
		rt, ok := callTool(newServer(tc.withGate))["resultType"]
		fmt.Printf("%-38s resultType present=%-5v value=%s\n", tc.label, ok, rt)
	}
}

Expected behavior

  • Both responses carry resultType, because both are tools/call results on a 2026-07-28 request.

Observed behavior

handler reaches dispatcher             resultType present=true  value="complete"
middleware short-circuits tools/call   resultType present=false value=

Activity

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

    P3Nice to haves, rare edge cases

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions