Documentation
¶
Overview ¶
Package proxy implements per-call routing of MCP tool calls from a long-lived `crdb-sql mcp` server to a sibling crdb-sql-vXXX backend whose parser quarter matches the caller's requested target_version.
The router lives next to internal/mcp/routing.go: the wrapper decides whether routing is needed (resolved target_version's quarter differs from the running binary's), and on a positive answer hands off to a Router here. Sibling discovery reuses internal/versionroute (FindBackend, Discover, Quarter), so the CLI's startup-time MaybeReexec and the MCP server's per-call dispatch share one source of truth for "where do my siblings live?" and "what backend do I need for v26.1?".
PoolRouter (pool.go) is the production implementation: one warm child per quarter, lazy spawn on first call, idle eviction after a configurable window, transparent re-spawn on transport failure. It implements issue #145 and replaces the spawn-per-call first cut from #129. NoopRouter is the default when no Router is installed; it surfaces a "routing not enabled" tool error rather than silently dispatching locally.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type NoopRouter ¶
type NoopRouter struct{}
NoopRouter is the default Router used when per-call routing has not been wired into a server (e.g. unit tests for the wrapper that only care about the local-handler path, or a future build that disables routing). Every Dispatch returns a tool-error result rather than silently falling through to the local handler — silent fallback would mask the wiring bug this issue exists to prevent.
func (NoopRouter) Dispatch ¶
func (NoopRouter) Dispatch( _ context.Context, want versionroute.Quarter, _ mcp.CallToolRequest, ) (*mcp.CallToolResult, error)
Dispatch always returns a tool-error result naming the requested sibling. Returning IsError=true (rather than a Go error) lets the wrapper distinguish "no router configured" from "router blew up".
type PoolOption ¶
type PoolOption interface {
// contains filtered or unexported methods
}
PoolOption configures NewPoolRouter. Matches the project's functional-options convention (.claude/rules/go-conventions.md).
func WithIdleTimeout ¶
func WithIdleTimeout(d time.Duration) PoolOption
WithIdleTimeout overrides the default idle-eviction window. A non-positive value disables idle eviction entirely (warm children live until Close).
func WithInitTimeout ¶
func WithInitTimeout(d time.Duration) PoolOption
WithInitTimeout overrides the default MCP initialize handshake budget. Must be positive.
type PoolRouter ¶
type PoolRouter struct {
// contains filtered or unexported fields
}
PoolRouter implements Router by keeping at most one warm sibling child per Quarter for the life of the parent `crdb-sql mcp` process. The first Dispatch for a Quarter spawns the child and runs the MCP initialize handshake; subsequent Dispatch calls reuse the warm child, amortizing spawn + handshake cost across calls.
Lifecycle:
- Lazy spawn: a Quarter's child is only started on the first Dispatch for that Quarter.
- Bounded concurrency per Quarter: one child, serialized by the entry's mutex. mark3labs/mcp-go's stdio Client is not safe for concurrent CallTool, so per-quarter requests queue.
- Idle eviction: the janitor goroutine sweeps every janitorTick; entries unused for longer than idleTimeout are closed. The next Dispatch re-spawns transparently.
- Dead-child recovery: if CallTool returns a transport-layer error, the entry is evicted and closed before Dispatch returns. The next Dispatch re-spawns transparently.
- Graceful shutdown: Close stops the janitor and closes every pooled child. cmd/mcp.go defers it after server.ServeStdio returns so a clean parent exit propagates to all children.
The error contract is the same as the package-level Router doc: transport failures (spawn, init, broken pipe) propagate as Go errors; missing-sibling and tool-level failures come back as IsError=true *mcp.CallToolResult with a nil error.
func NewPoolRouter ¶
func NewPoolRouter(opts ...PoolOption) *PoolRouter
NewPoolRouter returns a pool with production defaults: 10s init timeout, 5m idle window, 1m janitor tick. The janitor goroutine starts immediately and stops on Close. Pass options to override any default.
func (*PoolRouter) Close ¶
func (p *PoolRouter) Close() error
Close stops the janitor and closes every pooled child. The first call returns the joined Close errors from any pooled children (nil when every child closed cleanly); subsequent calls are no-ops that return nil. After Close, every Dispatch returns a transport-level "pool: closed" error.
Close holds each entry's mutex briefly to wait for any in-flight CallTool. A misbehaving sibling that ignores stdin EOF is killed by mark3labs/mcp-go's Client.Close, which escalates stdin EOF → SIGTERM (after ~2s) → SIGKILL (after a further ~3s) with another ~3s grace, so a single hung child can stall Close for up to ~8s. We do not add a second timer on top of that.
func (*PoolRouter) Dispatch ¶
func (p *PoolRouter) Dispatch( ctx context.Context, want versionroute.Quarter, req mcp.CallToolRequest, ) (*mcp.CallToolResult, error)
Dispatch implements Router. See PoolRouter's doc for the lifecycle and the package-level Router doc for the error contract. After Close, every Dispatch returns a transport error naming the closed pool — not a tool-error result, because the caller's per-call timeout is irrelevant at that point and the shape matches "the connection is gone".
type Router ¶
type Router interface {
Dispatch(
ctx context.Context, want versionroute.Quarter, req mcp.CallToolRequest,
) (*mcp.CallToolResult, error)
}
Router dispatches an MCP tool call to a sibling crdb-sql backend whose parser quarter matches the caller's requested target_version. A successful Dispatch returns the sibling's *mcp.CallToolResult verbatim — including the sibling's parser_version stamp on the envelope, which is the visible signal that routing actually happened.
Error contract: implementations distinguish two failure modes because the wrapper layer surfaces them differently to the caller.
- Transport failures (cannot spawn the sibling, broken pipe, JSON-RPC framing error, initialize timeout, any error returned by the sibling's MCP client other than an IsError=true result) are returned as a non-nil Go error. The wrapper propagates these as Go errors from the tool handler so the MCP server reports a transport-layer failure rather than fabricating an envelope.
- Tool-level failures (sibling answered with IsError=true, or the requested sibling is not installed) are returned as a non-nil *mcp.CallToolResult with IsError=true and a nil error. The wrapper forwards these verbatim so the client sees the same shape it would for a local tool error.
A Router that constructs results itself (NoopRouter, missingBackendResult) must NOT touch output.Envelope — those results bypass the local handler's envelope stamping entirely, so the saved "preserve env.Errors" rule does not apply here.