refactor(mcp): restore errors.Is for the oversize-response sentinel

Federation's tools/call oversize branch matched a string marker with
strings.Contains instead of wrapping a sentinel. The stated reason was
that no gate exercised this path under Yaegi, so errors.Is was unproven
here.

The caution was misplaced. errors.New returns a compiled
*errors.errorString and fmt.Errorf a compiled *fmt.wrapError, so
declaring the sentinel variable in interpreted code leaves the entire
chain errors.Is walks compiled. Neither documented trap applies: the
matchesSentinel trip-wire covers a plugin-declared error TYPE, and the
comma-ok trap needs a plugin-declared interface. retry.go has matched
errRequestBuildFailed this way through a multi-%w wrap since v0.2.0.
The string check was also strictly worse: err.Error() is itself an
interpreted-to-compiled dispatch, so it verified nothing extra, and
matching on text that partly comes from the backend admits false
positives a sentinel cannot have.

Rather than settle this by argument, tools/yaegi-check now drives POST
/mcp tools/call against a backend whose response exceeds the cap, and
asserts the interpreted plugin reports "response too large". Breaking
the match makes the gate fail with "upstream error", so the probe has
teeth. This closes the objection permanently instead of answering it in
a comment.

Also in doBackendJSONRPC, check the HTTP status before the size cap. A
backend failing with a large HTML error page now reports "upstream
returned HTTP 500", which is actionable, rather than sending the
operator after a payload-size problem that does not exist.

Two comment corrections carried forward from review: the fan-out
semaphore is not the same shape as limiter.spawnTokens (spawnTokens
drops work via a default case, this one blocks and must never skip a
server), and the shared fan-out deadline gives later batches the
remaining budget, not a fresh one, so uniformly slow backends yield a
partial list.
This commit is contained in:
2026-08-22 15:38:35 +01:00
parent 452be5151c
commit de42192426
3 changed files with 169 additions and 28 deletions
+6 -5
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -1072,9 +1073,9 @@ func TestMcpBackendCall_NetworkFailure_NeverAttemptsHandshake(t *testing.T) {
// fast, small-scale unit test of doBackendJSONRPC's own maxBytes
// mechanism, using an artificially tiny cap so the test needs no
// multi-megabyte payload: a backend response over the cap must be
// reported with mcpResponseTooLargeMarker in its message —
// distinguishable via strings.Contains from an ordinary network/parse
// failure — never silently truncated into a confusing parse error.
// reported by wrapping errMCPResponseTooLarge — distinguishable via
// errors.Is from an ordinary network/parse failure — never silently
// truncated into a confusing parse error.
func TestMcpBackendCall_ResponseExceedsCap_ReturnsDistinctTooLargeError(t *testing.T) {
const tinyCap = 64
big := strings.Repeat("x", tinyCap*4) // well past tinyCap once JSON-encoded
@@ -1101,8 +1102,8 @@ func TestMcpBackendCall_ResponseExceedsCap_ReturnsDistinctTooLargeError(t *testi
if err == nil {
t.Fatal("want an error for a response exceeding the cap")
}
if !strings.Contains(err.Error(), mcpResponseTooLargeMarker) {
t.Errorf("err = %v, want it to contain %q", err, mcpResponseTooLargeMarker)
if !errors.Is(err, errMCPResponseTooLarge) {
t.Errorf("err = %v, want it to wrap %v", err, errMCPResponseTooLarge)
}
}