Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 57 additions & 7 deletions pkg/vmcp/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,20 @@ type httpBackendClient struct {
// TestListCapabilities_MisCachedLegacy_SelfHealsViaSDKNegotiation in
// reclassify_test.go).
revisions sync.Map // map[string]mcpparser.Revision

// modernHintRefuted records backends whose Legacy-handshake protocol-version
// hint was contradicted by an authoritative server/discover probe, keyed by
// target.WorkloadID. See legacyInit: a backend can negotiate 2026-07-28 on
// the Legacy initialize while its discover response is not a valid Modern
// envelope, and the hint must not override the probe in that case. Recording
// the refutation stops legacyInit re-probing on every subsequent call for a
// backend already known to lie.
//
// NOTE: never evicted, bounded by backend count like revisions. A backend
// that genuinely becomes Modern later is still corrected through the other
// paths -- probeRevision runs whenever the cache is absent, and dispatch
// reclassifies on a revision mismatch.
modernHintRefuted sync.Map // map[string]struct{}
}

// NewHTTPBackendClient creates a new HTTP-based backend client.
Expand Down Expand Up @@ -1401,7 +1415,7 @@ func (h *httpBackendClient) legacyListCapabilities(
}()

// Initialize the client and get server capabilities
serverCaps, err := h.legacyInit(ctx, c, target.WorkloadID)
serverCaps, err := h.legacyInit(ctx, c, target)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1472,15 +1486,51 @@ var errLegacyInitFailed = errors.New("legacy initialize step failed")
// returned by initializeClient is genuine either way (discover or plain
// initialize), so when it equals MCPVersionModern the cache is flipped here
// instead of waiting for an error that will never come.
//
// The negotiated version is a HINT, not proof, and it is only allowed to
// override an existing classification once server/discover has confirmed it.
// The two genuinely disagree in the field: github-mcp-server v1.6.0 negotiates
// 2026-07-28 on the Legacy initialize while answering server/discover with a
// body carrying no resultType, which modernCall rightly rejects as
// Legacy-shaped. Promoting on the hint alone made the two self-corrections
// fight -- the probe cached Legacy, this promoted back to Modern, the next
// Modern call failed on that same body and reclassified to Legacy -- so every
// other health check failed indefinitely (#6154).
//
// So: with no cached revision the hint is trusted outright (that is the
// uncached-Legacy fallback dispatch takes when a probe errors, and the case
// this self-heal was written for). Against a cached Legacy classification the
// hint must win a confirming probe first; probeRevision caches whatever it
// finds, so a genuinely Modern backend still self-heals in one extra round
// trip. A refuted hint is remembered (modernHintRefuted) so the confirming
// probe runs once per backend rather than on every call.
func (h *httpBackendClient) legacyInit(
ctx context.Context, c *client.Client, backendID string,
ctx context.Context, c *client.Client, target *vmcp.BackendTarget,
) (*mcp.ServerCapabilities, error) {
backendID := target.WorkloadID
caps, negotiatedVersion, err := initializeClient(ctx, c)
if err != nil {
return nil, fmt.Errorf("%w: %w", errLegacyInitFailed, wrapBackendError(err, backendID, "initialize client"))
}
if negotiatedVersion == mcpparser.MCPVersionModern {
if negotiatedVersion != mcpparser.MCPVersionModern {
return caps, nil
}

cached, isCached := h.cachedRevision(backendID)
switch {
case !isCached:
h.setRevision(backendID, mcpparser.RevisionModern)
case cached == mcpparser.RevisionLegacy:
if _, refuted := h.modernHintRefuted.Load(backendID); refuted {
break
}
// probeRevision caches its own verdict, so a confirmation needs no
// setRevision here. A probe error leaves the cache untouched.
if probed, perr := h.probeRevision(ctx, target); perr == nil && probed != mcpparser.RevisionModern {
slog.DebugContext(ctx, "legacy handshake negotiated Modern but discover disagrees; keeping Legacy",
"backend", backendID, "negotiated", negotiatedVersion)
h.modernHintRefuted.Store(backendID, struct{}{})
}
}
return caps, nil
}
Expand Down Expand Up @@ -1689,7 +1739,7 @@ func (h *httpBackendClient) legacyCallTool(
}()

// Initialize the client and capture the backend's advertised capabilities.
serverCaps, err := h.legacyInit(ctx, c, target.WorkloadID)
serverCaps, err := h.legacyInit(ctx, c, target)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1887,7 +1937,7 @@ func (h *httpBackendClient) legacyReadResource(
}()

// Initialize the client
if _, err := h.legacyInit(ctx, c, target.WorkloadID); err != nil {
if _, err := h.legacyInit(ctx, c, target); err != nil {
return nil, err
}

Expand Down Expand Up @@ -2005,7 +2055,7 @@ func (h *httpBackendClient) legacyGetPrompt(
}()

// Initialize the client
if _, err := h.legacyInit(ctx, c, target.WorkloadID); err != nil {
if _, err := h.legacyInit(ctx, c, target); err != nil {
return nil, err
}

Expand Down Expand Up @@ -2146,7 +2196,7 @@ func (h *httpBackendClient) legacyComplete(
}()

// Initialize the client and capture the backend's advertised capabilities.
serverCaps, err := h.legacyInit(ctx, c, target.WorkloadID)
serverCaps, err := h.legacyInit(ctx, c, target)
if err != nil {
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/vmcp/client/reclassify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ func TestLegacyInit_SelfHealsRevisionCache(t *testing.T) {
require.NoError(t, err)
t.Cleanup(func() { _ = c.Close() })

_, err = h.legacyInit(context.Background(), c, target.WorkloadID)
_, err = h.legacyInit(context.Background(), c, target)
require.NoError(t, err, "the SDK client negotiates Modern transparently on the Legacy path")

rev, _ := h.cachedRevision(target.WorkloadID)
Expand Down
85 changes: 85 additions & 0 deletions pkg/vmcp/client/revision_realbackend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@
package client

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"strings"
"sync"
"testing"
Expand Down Expand Up @@ -229,3 +235,82 @@ func TestCallTool_MisCachedLegacy_ForwardingAgainstStatelessBackend(t *testing.T
assert.Equal(t, mcpparser.RevisionModern, rev,
"a successful mis-cached-Legacy call self-heals the cache to Modern")
}

// newHintLyingServer emulates github-mcp-server v1.6.0's dual-era behaviour: a
// backend that negotiates 2026-07-28 on the Legacy initialize handshake while
// answering server/discover with a body that is NOT a valid Modern envelope
// (no resultType), which modernCall rejects as Legacy-shaped.
//
// It delegates everything except server/discover to a real stateless go-sdk
// backend -- which is what makes the handshake genuinely negotiate 2026-07-28
// rather than a hand-rolled approximation -- and substitutes the resultType-less
// discover body that produces the mismatch.
func newHintLyingServer(t *testing.T) *httptest.Server {
t.Helper()

backend := newRealEchoServer(t, true, nil) // stateless => negotiates 2026-07-28
backendURL, err := url.Parse(backend.URL)
require.NoError(t, err)
rp := httputil.NewSingleHostReverseProxy(backendURL)

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, readErr := io.ReadAll(r.Body)
require.NoError(t, readErr)
r.Body = io.NopCloser(bytes.NewReader(body))

if !bytes.Contains(body, []byte(`"server/discover"`)) {
rp.ServeHTTP(w, r)
return
}

var req struct {
ID json.RawMessage `json:"id"`
}
_ = json.Unmarshal(body, &req)
id := req.ID
if len(id) == 0 {
id = json.RawMessage(`null`)
}
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"result":{`+
`"capabilities":{"tools":{}},`+
`"supportedVersions":["2026-07-28","2025-11-25"],`+
`"serverInfo":{"name":"hint-liar","version":"1.0.0"}}}`, id)
}))
t.Cleanup(ts.Close)
return ts
}

// TestLegacyInit_DoesNotPromoteOnHandshakeHintAlone pins #6154.
//
// The backend negotiates 2026-07-28 on the Legacy initialize -- the SDK client
// offers its own latest and the dual-era server accepts it -- while its
// server/discover answer is not a valid Modern envelope. github-mcp-server
// v1.6.0 behaves exactly this way in production.
//
// Promoting the cache to Modern on that handshake hint alone made the two
// self-corrections fight: probeRevision cached Legacy, legacyInit promoted back
// to Modern, the next Modern call failed on the non-Modern discover body and
// reclassified to Legacy, and so on. Every other health check failed, forever.
// The cached revision must therefore stay Legacy across repeated calls.
func TestLegacyInit_DoesNotPromoteOnHandshakeHintAlone(t *testing.T) {
t.Parallel()

srv := newHintLyingServer(t)
h := newProbeClient(t)
target := &vmcp.BackendTarget{
WorkloadID: "b",
BaseURL: srv.URL + "/mcp",
TransportType: "streamable-http",
}

for i := range 3 {
_, err := h.ListCapabilities(context.Background(), target)
require.NoError(t, err, "ListCapabilities call %d", i)

rev, ok := h.cachedRevision(target.WorkloadID)
require.True(t, ok, "revision should be cached after call %d", i)
assert.Equal(t, mcpparser.RevisionLegacy, rev,
"cached revision must stay Legacy after call %d; flipping to Modern is the #6154 oscillation", i)
}
}
Loading