experimental-ext-variants/go/sdk/variants/dispatch_test.go
Steven Chen b95923d172
variants: simplify dispatch and session by reducing method boilerplate (#8)
Simplify variant dispatch and session by replacing per-method
boilerplate with generic handlers backed by reflect.

## Motivation and Context
The dispatcher and backend session had separate handler functions for
every MCP method (ListTools, ListResources, CallTool, Subscribe, etc.),
each following an identical pattern: extract params, inject metadata,
call inner handler, cast result. This meant ~250 lines of near-identical
code across dispatch.go and session.go, and every new MCP method
required adding another copy.

## How Has This Been Tested?
All existing unit and integration tests pass (go test -v ./...)

## Breaking Changes
None. All changes are internal to the variants package. Public API is
unchanged.

## Types of changes
Refactor (non-breaking change which fixes an issue)
2026-05-01 12:29:10 +01:00

684 lines
19 KiB
Go

// Copyright 2025 The MCP Variants Authors. All rights reserved.
// Use of this source code is governed by a Apache-2.0
// license that can be found in the LICENSE file.
package variants
import (
"context"
"errors"
"reflect"
"testing"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// isNilInterface
// ---------------------------------------------------------------------------
func TestIsNilInterface(t *testing.T) {
tests := []struct {
name string
val any
want bool
}{
{"untyped nil", nil, true},
{"typed nil pointer", (*mcp.ListToolsParams)(nil), true},
{"non-nil pointer", &mcp.ListToolsParams{}, false},
{"non-pointer value", 42, false},
{"string value", "hello", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, isNilInterface(tt.val))
})
}
}
// ---------------------------------------------------------------------------
// handleList
// ---------------------------------------------------------------------------
// newTestDispatcher builds a dispatcher with a single variant backed by a
// custom mcpMethodHandler. The handler receives the request exactly as the
// dispatcher sends it, so tests can inspect params/session mutations.
func newTestDispatcher(variantID string, handler mcp.MethodHandler) *dispatcher {
return &dispatcher{
server: NewServer(&mcp.Implementation{Name: "test", Version: "v0.0.1"}).
WithVariant(ServerVariant{ID: variantID, Status: Stable}, mcp.NewServer(&mcp.Implementation{Name: "inner", Version: "v0.0.1"}, nil), 0),
connections: map[string]*innerConnection{
variantID: {
backendSession: &backendSession{
variantID: variantID,
mcpMethodHandler: handler,
},
},
},
}
}
func TestHandleList_NilResult(t *testing.T) {
const variantID = "test-variant"
tests := []struct {
name string
handler func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error)
}{
{
name: "untyped nil",
handler: func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
return nil, nil
},
},
{
name: "typed nil",
handler: func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
return (*mcp.ListToolsResult)(nil), nil
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := newTestDispatcher(variantID, tt.handler)
req := &mcp.ListToolsRequest{
Params: &mcp.ListToolsParams{
Meta: mcp.Meta{metaKeyVariant: variantID},
},
}
result, err := d.handleList(context.Background(), "tools/list", req)
require.NoError(t, err)
assert.Nil(t, result)
})
}
}
func TestHandleList_CursorRoundTrip(t *testing.T) {
const variantID = "v1"
const innerCursor = "page-2-token"
tests := []struct {
name string
method string
req func(wrappedCursor string) mcp.Request
result func() mcp.Result // result returned by backend with NextCursor set
}{
{
name: "tools/list",
method: "tools/list",
req: func(c string) mcp.Request {
return &mcp.ListToolsRequest{
Params: &mcp.ListToolsParams{
Meta: mcp.Meta{metaKeyVariant: variantID},
Cursor: c,
},
}
},
result: func() mcp.Result {
return &mcp.ListToolsResult{NextCursor: "next-inner"}
},
},
{
name: "resources/list",
method: "resources/list",
req: func(c string) mcp.Request {
return &mcp.ListResourcesRequest{
Params: &mcp.ListResourcesParams{
Meta: mcp.Meta{metaKeyVariant: variantID},
Cursor: c,
},
}
},
result: func() mcp.Result {
return &mcp.ListResourcesResult{NextCursor: "next-inner"}
},
},
{
name: "prompts/list",
method: "prompts/list",
req: func(c string) mcp.Request {
return &mcp.ListPromptsRequest{
Params: &mcp.ListPromptsParams{
Meta: mcp.Meta{metaKeyVariant: variantID},
Cursor: c,
},
}
},
result: func() mcp.Result {
return &mcp.ListPromptsResult{NextCursor: "next-inner"}
},
},
{
name: "resources/templates/list",
method: "resources/templates/list",
req: func(c string) mcp.Request {
return &mcp.ListResourceTemplatesRequest{
Params: &mcp.ListResourceTemplatesParams{
Meta: mcp.Meta{metaKeyVariant: variantID},
Cursor: c,
},
}
},
result: func() mcp.Result {
return &mcp.ListResourceTemplatesResult{NextCursor: "next-inner"}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
wrappedCursor := wrapCursor(innerCursor, variantID)
var receivedCursor string
d := newTestDispatcher(variantID, func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
// Capture what cursor the backend actually receives.
params := req.GetParams()
f := reflect.ValueOf(params).Elem().FieldByName("Cursor")
receivedCursor = f.String()
return tt.result(), nil
})
result, err := d.handleList(context.Background(), tt.method, tt.req(wrappedCursor))
require.NoError(t, err)
// Backend should have received the inner (unwrapped) cursor.
assert.Equal(t, innerCursor, receivedCursor, "cursor should be unwrapped before reaching backend")
// Result's NextCursor should now be wrapped.
nextCursor := reflect.ValueOf(result).Elem().FieldByName("NextCursor").String()
unwrapped, err := unwrapCursor(nextCursor, variantID)
require.NoError(t, err)
assert.Equal(t, "next-inner", unwrapped, "NextCursor should be wrapped with variant ID")
})
}
}
func TestHandleList_NoCursor(t *testing.T) {
const variantID = "v1"
d := newTestDispatcher(variantID, func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
return &mcp.ListToolsResult{}, nil
})
req := &mcp.ListToolsRequest{
Params: &mcp.ListToolsParams{
Meta: mcp.Meta{metaKeyVariant: variantID},
},
}
result, err := d.handleList(context.Background(), "tools/list", req)
require.NoError(t, err)
// No NextCursor should be set when backend returns empty cursor.
r := result.(*mcp.ListToolsResult)
assert.Empty(t, r.NextCursor)
}
func TestHandleList_InvalidCursor(t *testing.T) {
const variantID = "v1"
d := newTestDispatcher(variantID, func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
t.Fatal("backend should not be called with invalid cursor")
return nil, nil
})
req := &mcp.ListToolsRequest{
Params: &mcp.ListToolsParams{
Meta: mcp.Meta{metaKeyVariant: variantID},
Cursor: "not-valid-base64!@#$",
},
}
_, err := d.handleList(context.Background(), "tools/list", req)
require.Error(t, err)
var jErr *jsonrpc.Error
require.True(t, errors.As(err, &jErr))
assert.EqualValues(t, jsonrpc.CodeInvalidParams, jErr.Code)
}
func TestHandleList_CrossVariantCursor(t *testing.T) {
const variantID = "v1"
otherVariantCursor := wrapCursor("page2", "other-variant")
d := newTestDispatcher(variantID, func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
t.Fatal("backend should not be called with cross-variant cursor")
return nil, nil
})
req := &mcp.ListToolsRequest{
Params: &mcp.ListToolsParams{
Meta: mcp.Meta{metaKeyVariant: variantID},
Cursor: otherVariantCursor,
},
}
_, err := d.handleList(context.Background(), "tools/list", req)
require.Error(t, err)
var jErr *jsonrpc.Error
require.True(t, errors.As(err, &jErr))
assert.EqualValues(t, jsonrpc.CodeInvalidParams, jErr.Code)
assert.Contains(t, jErr.Message, "Cursor invalid for requested variant")
}
func TestHandleList_ErrorEnrichment(t *testing.T) {
const variantID = "v1"
backendErr := &jsonrpc.Error{
Code: jsonrpc.CodeInvalidParams,
Message: "unknown tool",
}
d := newTestDispatcher(variantID, func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
return nil, backendErr
})
req := &mcp.ListToolsRequest{
Params: &mcp.ListToolsParams{
Meta: mcp.Meta{metaKeyVariant: variantID},
},
}
_, err := d.handleList(context.Background(), "tools/list", req)
require.Error(t, err)
var jErr *jsonrpc.Error
require.True(t, errors.As(err, &jErr))
assert.Contains(t, string(jErr.Data), `"activeVariant"`)
assert.Contains(t, string(jErr.Data), variantID)
}
func TestHandleList_NilParams(t *testing.T) {
const variantID = "v1"
called := false
d := newTestDispatcher(variantID, func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
called = true
return &mcp.ListToolsResult{}, nil
})
// Typed-nil params — the SDK produces these for parameterless requests.
req := &mcp.ListToolsRequest{
Params: (*mcp.ListToolsParams)(nil),
}
_, err := d.handleList(context.Background(), "tools/list", req)
require.NoError(t, err)
assert.True(t, called, "backend should still be called with nil params")
}
// ---------------------------------------------------------------------------
// handleDirect
// ---------------------------------------------------------------------------
func TestHandleDirect_MethodRouting(t *testing.T) {
const variantID = "v1"
tests := []struct {
name string
method string
req mcp.Request
}{
{
name: "tools/call",
method: "tools/call",
req: &mcp.CallToolRequest{
Params: &mcp.CallToolParamsRaw{
Meta: mcp.Meta{metaKeyVariant: variantID},
Name: "my-tool",
},
},
},
{
name: "resources/read",
method: "resources/read",
req: &mcp.ReadResourceRequest{
Params: &mcp.ReadResourceParams{
Meta: mcp.Meta{metaKeyVariant: variantID},
URI: "file:///test",
},
},
},
{
name: "prompts/get",
method: "prompts/get",
req: &mcp.GetPromptRequest{
Params: &mcp.GetPromptParams{
Meta: mcp.Meta{metaKeyVariant: variantID},
Name: "my-prompt",
},
},
},
{
name: "resources/subscribe",
method: "resources/subscribe",
req: &mcp.SubscribeRequest{
Params: &mcp.SubscribeParams{
Meta: mcp.Meta{metaKeyVariant: variantID},
URI: "file:///watch",
},
},
},
{
name: "resources/unsubscribe",
method: "resources/unsubscribe",
req: &mcp.UnsubscribeRequest{
Params: &mcp.UnsubscribeParams{
Meta: mcp.Meta{metaKeyVariant: variantID},
URI: "file:///watch",
},
},
},
{
name: "completion/complete",
method: "completion/complete",
req: &mcp.CompleteRequest{
Params: &mcp.CompleteParams{
Meta: mcp.Meta{metaKeyVariant: variantID},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var receivedMethod string
d := newTestDispatcher(variantID, func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
receivedMethod = method
return nil, nil
})
_, err := d.handleDirect(context.Background(), tt.method, tt.req)
require.NoError(t, err)
assert.Equal(t, tt.method, receivedMethod, "backend should receive the correct method")
})
}
}
func TestHandleDirect_MetaInjection(t *testing.T) {
const variantID = "v1"
var receivedMeta mcp.Meta
d := newTestDispatcher(variantID, func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
receivedMeta = req.GetParams().GetMeta()
return &mcp.CallToolResult{}, nil
})
// Start without variant meta — handleDirect should inject it.
req := &mcp.CallToolRequest{
Params: &mcp.CallToolParamsRaw{
Name: "my-tool",
},
}
_, err := d.handleDirect(context.Background(), "tools/call", req)
require.NoError(t, err)
assert.Equal(t, variantID, receivedMeta[metaKeyVariant], "variant meta should be injected into params")
}
func TestHandleDirect_ErrorEnrichment(t *testing.T) {
const variantID = "v1"
d := newTestDispatcher(variantID, func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
return nil, &jsonrpc.Error{
Code: jsonrpc.CodeInvalidParams,
Message: "tool not found",
}
})
req := &mcp.CallToolRequest{
Params: &mcp.CallToolParamsRaw{
Meta: mcp.Meta{metaKeyVariant: variantID},
Name: "nonexistent",
},
}
_, err := d.handleDirect(context.Background(), "tools/call", req)
require.Error(t, err)
var jErr *jsonrpc.Error
require.True(t, errors.As(err, &jErr))
assert.Contains(t, string(jErr.Data), `"activeVariant"`)
assert.Contains(t, string(jErr.Data), variantID)
}
func TestHandleDirect_NonEnrichableError(t *testing.T) {
const variantID = "v1"
plainErr := errors.New("internal error")
d := newTestDispatcher(variantID, func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
return nil, plainErr
})
req := &mcp.CallToolRequest{
Params: &mcp.CallToolParamsRaw{
Meta: mcp.Meta{metaKeyVariant: variantID},
Name: "my-tool",
},
}
_, err := d.handleDirect(context.Background(), "tools/call", req)
assert.Equal(t, plainErr, err, "non-jsonrpc errors should pass through unmodified")
}
func TestHandleDirect_NilParams(t *testing.T) {
const variantID = "v1"
called := false
d := newTestDispatcher(variantID, func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
called = true
return nil, nil
})
req := &mcp.SubscribeRequest{
Params: (*mcp.SubscribeParams)(nil),
}
_, err := d.handleDirect(context.Background(), "resources/subscribe", req)
require.NoError(t, err)
assert.True(t, called, "backend should still be called when params are typed-nil")
}
// ---------------------------------------------------------------------------
// handle (top-level router)
// ---------------------------------------------------------------------------
func TestHandle_UnknownMethodPassthrough(t *testing.T) {
const variantID = "v1"
d := newTestDispatcher(variantID, func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
t.Fatal("dispatcher handler should not be called for unknown methods")
return nil, nil
})
nextCalled := false
next := func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
nextCalled = true
return nil, nil
}
req := &mcp.ListToolsRequest{
Params: &mcp.ListToolsParams{},
}
_, err := d.handle(context.Background(), "custom/method", req, next)
require.NoError(t, err)
assert.True(t, nextCalled, "unknown methods should fall through to next")
}
func TestHandle_RoutesToCorrectHandler(t *testing.T) {
const variantID = "v1"
tests := []struct {
name string
method string
req mcp.Request
}{
{"list routes to handleList", "tools/list", &mcp.ListToolsRequest{
Params: &mcp.ListToolsParams{Meta: mcp.Meta{metaKeyVariant: variantID}},
}},
{"call routes to handleDirect", "tools/call", &mcp.CallToolRequest{
Params: &mcp.CallToolParamsRaw{Meta: mcp.Meta{metaKeyVariant: variantID}, Name: "t"},
}},
{"subscribe routes to handleDirect", "resources/subscribe", &mcp.SubscribeRequest{
Params: &mcp.SubscribeParams{Meta: mcp.Meta{metaKeyVariant: variantID}, URI: "u"},
}},
{"unsubscribe routes to handleDirect", "resources/unsubscribe", &mcp.UnsubscribeRequest{
Params: &mcp.UnsubscribeParams{Meta: mcp.Meta{metaKeyVariant: variantID}, URI: "u"},
}},
{"complete routes to handleDirect", "completion/complete", &mcp.CompleteRequest{
Params: &mcp.CompleteParams{Meta: mcp.Meta{metaKeyVariant: variantID}},
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var receivedMethod string
d := newTestDispatcher(variantID, func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
receivedMethod = method
if method == "tools/list" {
return &mcp.ListToolsResult{}, nil
}
return nil, nil
})
next := func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
t.Fatalf("next should not be called for method %s", method)
return nil, nil
}
_, err := d.handle(context.Background(), tt.method, tt.req, next)
require.NoError(t, err)
assert.Equal(t, tt.method, receivedMethod)
})
}
}
// ---------------------------------------------------------------------------
// handleReceive (session.go)
// ---------------------------------------------------------------------------
func TestHandleReceive_SessionInjection(t *testing.T) {
originalSession := &mcp.ServerSession{}
targetSession := &mcp.ServerSession{}
var receivedSession *mcp.ServerSession
bs := &backendSession{
variantID: "v1",
serverSession: targetSession,
mcpMethodHandler: func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
receivedSession = req.GetSession().(*mcp.ServerSession)
return &mcp.ListToolsResult{}, nil
},
}
req := &mcp.ListToolsRequest{
Session: originalSession,
Params: &mcp.ListToolsParams{},
}
_, err := bs.handleReceive(context.Background(), "tools/list", req)
require.NoError(t, err)
assert.Same(t, targetSession, receivedSession, "handleReceive should replace Session with inner server session")
assert.Same(t, originalSession, req.Session, "handleReceive should not mutate the original request")
}
// ---------------------------------------------------------------------------
// getConnection
// ---------------------------------------------------------------------------
func TestGetConnection_InvalidVariant(t *testing.T) {
d := newTestDispatcher("v1", nil)
req := &mcp.ListToolsRequest{
Params: &mcp.ListToolsParams{
Meta: mcp.Meta{metaKeyVariant: "nonexistent"},
},
}
_, err := d.getConnection(context.Background(), req)
require.Error(t, err)
var jErr *jsonrpc.Error
require.True(t, errors.As(err, &jErr))
assert.EqualValues(t, jsonrpc.CodeInvalidParams, jErr.Code)
assert.Contains(t, jErr.Message, "Invalid server variant")
assert.Contains(t, string(jErr.Data), "nonexistent")
}
func TestGetConnection_DefaultVariant(t *testing.T) {
d := newTestDispatcher("v1", nil)
// No variant in meta — should fall back to default (first-ranked).
req := &mcp.ListToolsRequest{
Params: &mcp.ListToolsParams{},
}
conn, err := d.getConnection(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, "v1", conn.backendSession.variantID)
}
// ---------------------------------------------------------------------------
// enrichError
// ---------------------------------------------------------------------------
func TestEnrichError(t *testing.T) {
tests := []struct {
name string
err error
wantEnrich bool
}{
{
name: "InvalidParams enriched",
err: &jsonrpc.Error{
Code: jsonrpc.CodeInvalidParams,
Message: "bad param",
},
wantEnrich: true,
},
{
name: "MethodNotFound enriched",
err: &jsonrpc.Error{
Code: jsonrpc.CodeMethodNotFound,
Message: "no method",
},
wantEnrich: true,
},
{
name: "InternalError not enriched",
err: &jsonrpc.Error{
Code: jsonrpc.CodeInternalError,
Message: "server error",
},
wantEnrich: false,
},
{
name: "plain error not enriched",
err: errors.New("some error"),
wantEnrich: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := enrichError(tt.err, "v1")
var jErr *jsonrpc.Error
if errors.As(result, &jErr) && tt.wantEnrich {
assert.Contains(t, string(jErr.Data), `"activeVariant"`)
assert.Contains(t, string(jErr.Data), `"v1"`)
} else if !tt.wantEnrich {
if errors.As(result, &jErr) {
// Should NOT have activeVariant
assert.NotContains(t, string(jErr.Data), `"activeVariant"`)
}
}
})
}
}