experimental-ext-variants/go/sdk/variants/backend.go
Steven Chen 150c110128
Preserve context.Context across variant dispatch by bypassing in-memory transport (#6)
## Summary
- Switched variant dispatch from `ClientSession` RPC calls to direct
inner server handler invocation via a new `backendSession` type,
preserving `context.Context` values (e.g. `RequestContext`) that were
previously lost crossing the in-memory transport boundary
- Added `backendSession` abstraction in `session.go` that mirrors the
`mcp.ClientSession` API but calls the inner server's handler chain
directly, forwarding `RequestExtra` (token info, HTTP headers, etc.)
- Captured the inner server's handler chain via receiving middleware
(`captureMCPMethodHandler`) in `backend.go`, reused across all
connections
- Simplified `innerConnection` to hold `*backendSession` + cleanup;
in-memory transport/client kept alive only for notification forwarding
(progress, logging)
- Refactored `getSession` → `getConnection` returning `*innerConnection`
to reduce indirection in dispatch code
- Moved `injectVariantMeta` calls to after type assertion and nil checks
in all dispatch handlers for consistency and nil-safety

## Test plan
- [x] Existing variant dispatch tests pass (`go test ./variants/...`)
- [x] Verify context values (e.g. `RequestContext`) are available in
tool handlers when invoked through variant dispatch
- [x] Verify `RequestExtra` fields (token info, HTTP headers) are
forwarded to inner server handlers
2026-03-18 10:53:06 +00:00

158 lines
5.4 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"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// backend abstracts how a variant connects to its backing MCP server.
// The in-memory implementation lives in this file; future implementations
// (HTTP, remote) will satisfy the same interface.
type backend interface {
// connect creates a connection to the backing server and returns an
// innerConnection for dispatching requests. frontSession may be nil
// in stateless mode.
connect(ctx context.Context, variant ServerVariant, frontSession *mcp.ServerSession) (*innerConnection, error)
// capabilities performs an ephemeral connect to discover the server's
// advertised capabilities, then tears down the probe connection.
capabilities(ctx context.Context) (*mcp.ServerCapabilities, error)
// close releases any resources held by the backend.
close() error
}
// inMemoryBackend connects to a co-located *mcp.Server via in-memory
// transports.
//
// Limitation: list-changed and resource-updated notifications from
// inner servers are silently dropped. The mcp.ServerSession API only
// exposes NotifyProgress and Log; there is no public method to send
// tool/resource/prompt list-changed or resource-updated notifications
// to the front client. This means that if an inner server dynamically
// adds or removes tools/resources/prompts at runtime, the front
// client will NOT be notified.
//
// In practice this is acceptable because inner servers are typically
// statically configured (tools registered at startup). If dynamic
// capability changes are needed, this will require the Go MCP SDK to
// expose generic notification sending on ServerSession.
type inMemoryBackend struct {
server *mcp.Server
}
// captureMCPMethodHandler captures and returns a reference to the inner
// server's handler chain. This is a workaround using AddReceivingMiddleware
// to gain a reference to mcp.Server.receivingMethodHandler_, since the SDK
// does not expose a public accessor for it. This can be replaced once the
// SDK exposes a public accessor for the receiving handler chain.
func captureMCPMethodHandler(server *mcp.Server) (mcp.MethodHandler, error) {
var handler mcp.MethodHandler
// The middleware is identity (returns next unmodified), so the handler
// chain is unchanged, no extra hop introduced even if called multiple times.
server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler {
handler = next
return next
})
if handler == nil {
return nil, fmt.Errorf("failed to capture backend MCP method handler")
}
return handler, nil
}
// connect creates an in-memory transport pair and connects the inner server.
// Requests bypass the transport via serverHandler to preserve context values.
// The transport is kept alive only for notification forwarding (progress,
// logging) from the inner server to the front client.
func (b *inMemoryBackend) connect(ctx context.Context, variant ServerVariant, frontSession *mcp.ServerSession) (*innerConnection, error) {
mcpMethodHandler, err := captureMCPMethodHandler(b.server)
if err != nil {
return nil, err
}
serverTransport, clientSideTransport := mcp.NewInMemoryTransports()
serverSession, err := b.server.Connect(ctx, serverTransport, nil)
if err != nil {
return nil, err
}
client := mcp.NewClient(&mcp.Implementation{
Name: "variant-proxy-client",
Version: "1.0.0",
}, &mcp.ClientOptions{
ProgressNotificationHandler: func(ctx context.Context, req *mcp.ProgressNotificationClientRequest) {
if frontSession != nil {
injectVariantMeta(req.Params, variant.ID)
_ = frontSession.NotifyProgress(ctx, req.Params)
}
},
LoggingMessageHandler: func(ctx context.Context, req *mcp.LoggingMessageRequest) {
if frontSession != nil {
injectVariantMeta(req.Params, variant.ID)
_ = frontSession.Log(ctx, req.Params)
}
},
ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) {},
ResourceListChangedHandler: func(context.Context, *mcp.ResourceListChangedRequest) {},
PromptListChangedHandler: func(context.Context, *mcp.PromptListChangedRequest) {},
ResourceUpdatedHandler: func(context.Context, *mcp.ResourceUpdatedNotificationRequest) {},
})
clientSession, err := client.Connect(ctx, clientSideTransport, nil)
if err != nil {
serverSession.Close()
return nil, err
}
return &innerConnection{
backendSession: &backendSession{
variantID: variant.ID,
serverSession: serverSession,
mcpMethodHandler: mcpMethodHandler,
},
cleanupFn: func() {
clientSession.Close()
serverSession.Close()
},
}, nil
}
// capabilities performs an ephemeral in-memory connect to discover the
// server's advertised capabilities.
func (b *inMemoryBackend) capabilities(ctx context.Context) (*mcp.ServerCapabilities, error) {
st, ct := mcp.NewInMemoryTransports()
ss, err := b.server.Connect(ctx, st, nil)
if err != nil {
return nil, err
}
c := mcp.NewClient(&mcp.Implementation{Name: "cap-probe", Version: "1.0.0"}, nil)
cs, err := c.Connect(ctx, ct, nil)
if err != nil {
ss.Close()
return nil, err
}
var caps *mcp.ServerCapabilities
if ir := cs.InitializeResult(); ir != nil {
caps = ir.Capabilities
}
cs.Close()
ss.Close()
return caps, nil
}
// close is a no-op for in-memory backends.
func (b *inMemoryBackend) close() error {
return nil
}