Summary
Builds on the types and architecture from the previous PR to deliver a
working variant-aware MCP
server that routes requests to inner mcp.Server instances based on
per-request _meta variant selection,
enabling a single MCP endpoint to serve multiple tool/resource/prompt
variants simultaneously.
```go
// Create standard mcp.Server instances — one per variant
codeReviewServer := mcp.NewServer(&mcp.Implementation{Name:
"devplatform", Version: "v1.0.0"}, nil)
mcp.AddTool(codeReviewServer, &mcp.Tool{Name: "list_pull_requests",
...}, handler)
mcp.AddTool(codeReviewServer, &mcp.Tool{Name: "get_diff", ...}, handler)
pmServer := mcp.NewServer(&mcp.Implementation{Name: "devplatform",
Version: "v1.0.0"}, nil)
mcp.AddTool(pmServer, &mcp.Tool{Name: "list_issues", ...}, handler)
mcp.AddTool(pmServer, &mcp.Tool{Name: "create_issue", ...}, handler)
// Register them as variants on a single server
vs := variants.NewServer(&mcp.Implementation{Name: "devplatform",
Version: "v1.0.0"}).
WithVariant(variants.ServerVariant{
ID: "code-review",
Description: "PR and code review operations",
Status: variants.Stable,
}, codeReviewServer, 0).
WithVariant(variants.ServerVariant{
ID: "project-management",
Description: "Issue and project tracking operations",
Status: variants.Stable,
}, pmServer, 1)
// Future: register variants backed by remote MCP servers
// WithHTTPVariant(v, mcpServer, priority) — variant backed by an
mcp.Server exposed over HTTP
// WithRemoteVariant(v, "https://...", priority) — variant backed by a
remote MCP endpoint
// Serve over HTTP for multiple concurrent clients
handler := variants.NewStreamableHTTPHandler(vs, nil)
http.ListenAndServe(":8080", handler)
// Or serve over stdio for single-client use:
// vs.Run(ctx, &mcp.StdioTransport{})
```
Key additions
- Backend abstraction (inMemoryBackend) — creates in-memory transport pairs between the variant server and
inner mcp.Server instances, with notification forwarding for progress and logging events.
- Request dispatcher (dispatch.go) — handles routing for tools/list, tools/call, resources/list,
resources/read, resources/subscribe, prompts/list, prompts/get, and completion/complete methods.
Implements variant-scoped cursor wrapping to correctly paginate across multiple inner servers.
- Session middleware (session.go) — manages per-session inner connections in stateful mode and shared
connections in stateless HTTP mode, ensuring proper lifecycle management.
- Initialize enrichment — injects availableVariants into the server's capabilities response so clients can
discover which variants are available.
- Capability discovery — merges inner server capabilities via union to present a unified capability set to
clients.
- NewStreamableHTTPHandler — provides an HTTP handler for multi-client serving over Streamable HTTP
transport.
- Integration tests (server_test.go, 458 lines) — covers end-to-end variant selection, backward
compatibility (no variant specified), HTTP transport, and stateless modes.
- Examples — stdio-based (examples/server/variants/main.go) and HTTP-based
(examples/server/variants-http/main.go) example servers demonstrating the API.
- README — rewritten with API documentation and architecture diagrams.
TODO (subsequent PRs):
- Unit Testing
- More integration tests
Signed-off-by: Kurt Degiorgio <kdegiorgio@bloomberg.net>
448 lines
13 KiB
Go
448 lines
13 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"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"reflect"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// dispatcher routes requests to inner variant servers. In stateful mode one
|
|
// dispatcher exists per client session; in stateless mode a single dispatcher
|
|
// is shared across all requests.
|
|
type dispatcher struct {
|
|
server *Server
|
|
connections map[string]*innerConnection
|
|
}
|
|
|
|
// handle dispatches a request to the appropriate inner variant server.
|
|
// Unknown methods are passed through to next.
|
|
func (d *dispatcher) handle(ctx context.Context, method string, req mcp.Request, next mcp.MethodHandler) (mcp.Result, error) {
|
|
switch method {
|
|
case "tools/list", "resources/list", "prompts/list", "resources/templates/list":
|
|
return d.handleList(ctx, method, req)
|
|
case "tools/call", "resources/read", "prompts/get":
|
|
return d.handleCall(ctx, method, req)
|
|
case "resources/subscribe":
|
|
return d.handleSubscribe(ctx, req)
|
|
case "resources/unsubscribe":
|
|
return d.handleUnsubscribe(ctx, req)
|
|
case "completion/complete":
|
|
return d.handleCompletion(ctx, req)
|
|
default:
|
|
return next(ctx, method, req)
|
|
}
|
|
}
|
|
|
|
// createInvalidVariantError creates a spec-compliant error for invalid variant selection.
|
|
func (d *dispatcher) createInvalidVariantError(ctx context.Context, requestedVariant string) error {
|
|
ranked := d.server.RankedVariants(ctx, VariantHints{})
|
|
availableIDs := make([]string, len(ranked))
|
|
for i, v := range ranked {
|
|
availableIDs[i] = v.ID
|
|
}
|
|
|
|
errorData := map[string]any{
|
|
"requestedVariant": requestedVariant,
|
|
"availableVariants": availableIDs,
|
|
}
|
|
dataJSON, err := json.Marshal(errorData)
|
|
if err != nil {
|
|
dataJSON = []byte("{}")
|
|
}
|
|
|
|
return &jsonrpc.Error{
|
|
Code: jsonrpc.CodeInvalidParams,
|
|
Message: "Invalid server variant",
|
|
Data: json.RawMessage(dataJSON),
|
|
}
|
|
}
|
|
|
|
// variantIDFromMeta extracts the variant ID from the request's _meta field.
|
|
// Returns empty string if no variant is specified. Guards against typed-nil
|
|
// params (e.g. (*ListToolsParams)(nil) wrapped in the mcp.Params interface)
|
|
// which the SDK can produce for requests with no parameters.
|
|
func variantIDFromMeta(req mcp.Request) string {
|
|
params := req.GetParams()
|
|
if params == nil {
|
|
return ""
|
|
}
|
|
if v := reflect.ValueOf(params); v.Kind() == reflect.Ptr && v.IsNil() {
|
|
return ""
|
|
}
|
|
meta := params.GetMeta()
|
|
if meta == nil {
|
|
return ""
|
|
}
|
|
id, _ := meta[metaKeyVariant].(string)
|
|
return id
|
|
}
|
|
|
|
// getSession extracts the variant ID from request _meta and returns the
|
|
// corresponding client session for dispatching. Falls back to the
|
|
// first-ranked variant when no variant is specified.
|
|
func (d *dispatcher) getSession(ctx context.Context, req mcp.Request) (string, *mcp.ClientSession, error) {
|
|
variantID := variantIDFromMeta(req)
|
|
|
|
// If no variant specified, use first-ranked (default).
|
|
//
|
|
// BUG: This re-ranks with empty hints, which may differ from the
|
|
// ranking returned during initialize (where client hints were used).
|
|
// Per SEP-2053, the default should be the first variant from the
|
|
// initialize response. To fix this properly, the per-session ranked
|
|
// order should be stored during initialize and reused here.
|
|
if variantID == "" {
|
|
ranked := d.server.RankedVariants(ctx, VariantHints{})
|
|
if len(ranked) == 0 {
|
|
return "", nil, errors.New("no variants available")
|
|
}
|
|
variantID = ranked[0].ID
|
|
}
|
|
|
|
conn, ok := d.connections[variantID]
|
|
if !ok {
|
|
return "", nil, d.createInvalidVariantError(ctx, variantID)
|
|
}
|
|
|
|
return variantID, conn.clientSession, nil
|
|
}
|
|
|
|
// enrichError adds activeVariant to a jsonrpc.Error's Data field for
|
|
// variant-scoped resolution failures. Per SEP-2053 Implementation Notes:
|
|
// "Servers SHOULD include activeVariant in error data for variant-scoped
|
|
// resolution failures (unknown tool/prompt/resource, invalid cursor, invalid
|
|
// subscription context)."
|
|
//
|
|
// Only errors with codes -32602 (InvalidParams) or -32601 (MethodNotFound)
|
|
// are enriched; business-logic errors from tool execution are passed through
|
|
// unmodified.
|
|
func enrichError(err error, variantID string) error {
|
|
var jErr *jsonrpc.Error
|
|
if !errors.As(err, &jErr) {
|
|
return err
|
|
}
|
|
|
|
// Only enrich resolution-class errors.
|
|
switch jErr.Code {
|
|
case jsonrpc.CodeInvalidParams, jsonrpc.CodeMethodNotFound:
|
|
// fall through to enrich
|
|
default:
|
|
return err
|
|
}
|
|
|
|
// Parse existing data (if any) and inject activeVariant.
|
|
// Copy the error to avoid mutating the original
|
|
data := make(map[string]any)
|
|
if len(jErr.Data) > 0 {
|
|
_ = json.Unmarshal(jErr.Data, &data)
|
|
}
|
|
data["activeVariant"] = variantID
|
|
|
|
enriched := &jsonrpc.Error{
|
|
Code: jErr.Code,
|
|
Message: jErr.Message,
|
|
}
|
|
if encoded, mErr := json.Marshal(data); mErr == nil {
|
|
enriched.Data = json.RawMessage(encoded)
|
|
}
|
|
return enriched
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// List methods
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// handleList handles list methods by forwarding to the appropriate variant.
|
|
// Implements cursor scoping per SEP-2053: unwraps incoming cursors and wraps outgoing cursors.
|
|
func (d *dispatcher) handleList(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
|
|
variantID, session, err := d.getSession(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
params := req.GetParams()
|
|
|
|
switch method {
|
|
case "tools/list":
|
|
p, _ := params.(*mcp.ListToolsParams)
|
|
if p != nil && p.Cursor != "" {
|
|
innerCursor, err := unwrapCursor(p.Cursor, variantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Cursor = innerCursor
|
|
}
|
|
result, err := session.ListTools(ctx, p)
|
|
if err != nil {
|
|
return nil, enrichError(err, variantID)
|
|
}
|
|
if result != nil && result.NextCursor != "" {
|
|
result.NextCursor = wrapCursor(result.NextCursor, variantID)
|
|
}
|
|
return result, nil
|
|
|
|
case "resources/list":
|
|
p, _ := params.(*mcp.ListResourcesParams)
|
|
if p != nil && p.Cursor != "" {
|
|
innerCursor, err := unwrapCursor(p.Cursor, variantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Cursor = innerCursor
|
|
}
|
|
result, err := session.ListResources(ctx, p)
|
|
if err != nil {
|
|
return nil, enrichError(err, variantID)
|
|
}
|
|
if result != nil && result.NextCursor != "" {
|
|
result.NextCursor = wrapCursor(result.NextCursor, variantID)
|
|
}
|
|
return result, nil
|
|
|
|
case "prompts/list":
|
|
p, _ := params.(*mcp.ListPromptsParams)
|
|
if p != nil && p.Cursor != "" {
|
|
innerCursor, err := unwrapCursor(p.Cursor, variantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Cursor = innerCursor
|
|
}
|
|
result, err := session.ListPrompts(ctx, p)
|
|
if err != nil {
|
|
return nil, enrichError(err, variantID)
|
|
}
|
|
if result != nil && result.NextCursor != "" {
|
|
result.NextCursor = wrapCursor(result.NextCursor, variantID)
|
|
}
|
|
return result, nil
|
|
|
|
case "resources/templates/list":
|
|
p, _ := params.(*mcp.ListResourceTemplatesParams)
|
|
if p != nil && p.Cursor != "" {
|
|
innerCursor, err := unwrapCursor(p.Cursor, variantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Cursor = innerCursor
|
|
}
|
|
result, err := session.ListResourceTemplates(ctx, p)
|
|
if err != nil {
|
|
return nil, enrichError(err, variantID)
|
|
}
|
|
if result != nil && result.NextCursor != "" {
|
|
result.NextCursor = wrapCursor(result.NextCursor, variantID)
|
|
}
|
|
return result, nil
|
|
|
|
default:
|
|
return nil, errors.New("unsupported list method: " + method)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Call methods
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// handleCall handles call methods (tools/call, resources/read, prompts/get).
|
|
func (d *dispatcher) handleCall(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
|
|
variantID, session, err := d.getSession(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
params := req.GetParams()
|
|
var result mcp.Result
|
|
switch method {
|
|
case "tools/call":
|
|
// The server SDK unmarshals tools/call params as *CallToolParamsRaw
|
|
// (with json.RawMessage arguments). Convert to *CallToolParams for
|
|
// the client-side call.
|
|
raw, _ := params.(*mcp.CallToolParamsRaw)
|
|
if raw == nil {
|
|
return nil, &jsonrpc.Error{
|
|
Code: jsonrpc.CodeInvalidParams,
|
|
Message: "missing or invalid tools/call params",
|
|
}
|
|
}
|
|
result, err = session.CallTool(ctx, &mcp.CallToolParams{
|
|
Meta: raw.Meta,
|
|
Name: raw.Name,
|
|
Arguments: raw.Arguments,
|
|
})
|
|
case "resources/read":
|
|
p, _ := params.(*mcp.ReadResourceParams)
|
|
if p == nil {
|
|
return nil, &jsonrpc.Error{
|
|
Code: jsonrpc.CodeInvalidParams,
|
|
Message: "missing or invalid resources/read params",
|
|
}
|
|
}
|
|
result, err = session.ReadResource(ctx, p)
|
|
case "prompts/get":
|
|
p, _ := params.(*mcp.GetPromptParams)
|
|
if p == nil {
|
|
return nil, &jsonrpc.Error{
|
|
Code: jsonrpc.CodeInvalidParams,
|
|
Message: "missing or invalid prompts/get params",
|
|
}
|
|
}
|
|
result, err = session.GetPrompt(ctx, p)
|
|
default:
|
|
return nil, errors.New("unsupported call method: " + method)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, enrichError(err, variantID)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subscription methods
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// handleSubscribe handles resources/subscribe.
|
|
func (d *dispatcher) handleSubscribe(ctx context.Context, req mcp.Request) (mcp.Result, error) {
|
|
variantID, session, err := d.getSession(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
params, _ := req.GetParams().(*mcp.SubscribeParams)
|
|
if params == nil {
|
|
return nil, &jsonrpc.Error{
|
|
Code: jsonrpc.CodeInvalidParams,
|
|
Message: "missing or invalid resources/subscribe params",
|
|
}
|
|
}
|
|
if err := session.Subscribe(ctx, params); err != nil {
|
|
return nil, enrichError(err, variantID)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// handleUnsubscribe handles resources/unsubscribe.
|
|
// Per SEP-2053: "Servers MUST continue to accept resources/unsubscribe for
|
|
// existing subscription ids even if the underlying resource is no longer available."
|
|
func (d *dispatcher) handleUnsubscribe(ctx context.Context, req mcp.Request) (mcp.Result, error) {
|
|
variantID, session, err := d.getSession(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
params, _ := req.GetParams().(*mcp.UnsubscribeParams)
|
|
if params == nil {
|
|
return nil, &jsonrpc.Error{
|
|
Code: jsonrpc.CodeInvalidParams,
|
|
Message: "missing or invalid resources/unsubscribe params",
|
|
}
|
|
}
|
|
if err := session.Unsubscribe(ctx, params); err != nil {
|
|
return nil, enrichError(err, variantID)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Completion
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// handleCompletion handles completion/complete.
|
|
func (d *dispatcher) handleCompletion(ctx context.Context, req mcp.Request) (mcp.Result, error) {
|
|
variantID, session, err := d.getSession(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
params, _ := req.GetParams().(*mcp.CompleteParams)
|
|
if params == nil {
|
|
return nil, &jsonrpc.Error{
|
|
Code: jsonrpc.CodeInvalidParams,
|
|
Message: "missing or invalid completion/complete params",
|
|
}
|
|
}
|
|
result, err := session.Complete(ctx, params)
|
|
if err != nil {
|
|
return nil, enrichError(err, variantID)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cursor scoping
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// variantCursor wraps pagination cursors with variant ID for scoping.
|
|
// Per SEP-2053: "Cursors MUST be treated as opaque and variant-scoped"
|
|
type variantCursor struct {
|
|
VariantID string `json:"v"`
|
|
InnerCursor string `json:"c"`
|
|
}
|
|
|
|
// wrapCursor wraps a cursor from an inner server with the variant ID.
|
|
// Returns empty string if the inner cursor is empty.
|
|
func wrapCursor(cursor string, variantID string) string {
|
|
if cursor == "" {
|
|
return ""
|
|
}
|
|
wrapped := variantCursor{
|
|
VariantID: variantID,
|
|
InnerCursor: cursor,
|
|
}
|
|
data, err := json.Marshal(wrapped)
|
|
if err != nil {
|
|
// Should never happen with simple struct
|
|
return ""
|
|
}
|
|
return base64.StdEncoding.EncodeToString(data)
|
|
}
|
|
|
|
// unwrapCursor validates and unwraps a cursor for the expected variant.
|
|
// Returns the inner cursor if valid, or an error if the cursor is invalid
|
|
// or belongs to a different variant.
|
|
func unwrapCursor(cursor string, expectedVariant string) (string, error) {
|
|
if cursor == "" {
|
|
return "", nil
|
|
}
|
|
|
|
data, err := base64.StdEncoding.DecodeString(cursor)
|
|
if err != nil {
|
|
return "", &jsonrpc.Error{
|
|
Code: jsonrpc.CodeInvalidParams,
|
|
Message: "Invalid cursor format",
|
|
}
|
|
}
|
|
|
|
var wrapped variantCursor
|
|
if err := json.Unmarshal(data, &wrapped); err != nil {
|
|
return "", &jsonrpc.Error{
|
|
Code: jsonrpc.CodeInvalidParams,
|
|
Message: "Invalid cursor format",
|
|
}
|
|
}
|
|
|
|
if wrapped.VariantID != expectedVariant {
|
|
errorData := map[string]any{
|
|
"cursorVariant": wrapped.VariantID,
|
|
"requestedVariant": expectedVariant,
|
|
}
|
|
dataJSON, _ := json.Marshal(errorData)
|
|
|
|
return "", &jsonrpc.Error{
|
|
Code: jsonrpc.CodeInvalidParams,
|
|
Message: "Cursor invalid for requested variant",
|
|
Data: json.RawMessage(dataJSON),
|
|
}
|
|
}
|
|
|
|
return wrapped.InnerCursor, nil
|
|
}
|