proxy

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 24, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

README

proxy

Per-call target_version routing for the long-lived crdb-sql mcp server. When a tool call's resolved target_version quarter does not match the running binary's quarter, internal/mcp/routing.go hands off to a Router here, which forwards the call to the matching sibling crdb-sql-vXXX backend and returns the sibling's *mcp.CallToolResult verbatim. The visible signal that routing fired is the sibling's parser_version stamp on the envelope.

Routers

  • Router — interface. Dispatch(ctx, want, req) returns the sibling's result. 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. See the doc on the interface for the full contract.

  • NoopRouter — default when routing is not wired into a server (e.g. unit tests of the wrapper that only exercise the local-handler path). Every Dispatch returns a tool-error result naming the requested sibling, so a wiring bug is loud rather than silent.

  • PoolRouter — production implementation. Keeps at most one warm sibling child per versionroute.Quarter for the life of the parent crdb-sql mcp process. Lazy spawn on first request, idle eviction after 5 minutes (WithIdleTimeout to override), and transparent re-spawn on transport failure or eviction. Per-quarter requests serialize through the entry's mutex because mark3labs/mcp-go's stdio Client is not safe for concurrent CallTool. cmd/mcp.go defers Close after server.ServeStdio returns so a clean parent exit closes every warm sibling.

Benchmark

proxy_bench_test.go (build tag integration) measures one routed parse_sql call under three configurations. Numbers are developer-tool reference points — there is no CI gate, because small-machine runners are too noisy.

Run:

go test -tags integration -run '^$' \
    -bench=BenchmarkRoute -benchtime=5x ./internal/mcp/proxy
Reference numbers

Single sample, AMD Ryzen 9 5900X (24 logical cores), Linux, -benchtime=5x. Order-of-magnitude only; expect 2–3× variance on slower hardware or shared CI runners.

Benchmark ns/op B/op allocs/op Notes
RouteSpawnPerCall ~12.5 ms ~34 KB ~200 spawn + MCP initialize handshake per call (#129 router, since replaced)
RoutePooledWarm ~0.49 ms ~4.7 KB ~68 JSON-RPC round-trip only — pool's steady-state cost
RoutePooledCold ~12.8 ms ~35 KB ~207 fresh pool per iteration; matches SpawnPerCall as expected
What this tells you
  • Warm reuse is roughly 25× cheaper per call than spawning. The pool earns its complexity any time a quarter receives more than one call in its idle window.
  • PooledCold ≈ SpawnPerCall confirms the pool's only saving is reuse — it is not a faster spawn path. There is no point in resurrecting SpawnRouter for any workload that hits the same quarter twice.
  • The 5-minute default idle window (WithIdleTimeout) is sized for agent sessions that re-issue routed calls within a few minutes; shorter windows risk paying spawn cost on every request, longer windows leak children that are unlikely to be used again. Re-run the benchmark before changing the default if request patterns shift.

Out of scope (separate investigations if either dominates): JSON-RPC framing overhead, parser cold-start cost on the first call into a fresh child.

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

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

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL