Astrolabe / ajxpublic /korvus
Repository

MCP Client, Registry and Server Connection

reference scope internal/mcp Verification failed
  • internal/mcp/client.go
  • internal/mcp/client_streamable.go
  • internal/mcp/registry.go
  • internal/mcp/connect.go
  • internal/mcp/tracing.go
  • internal/mcp/types.go
  • MCPClient
  • StreamableHTTPClient
  • CallResult
  • ToolCall
  • ConnectAll
  • RegisterServer
  • FailedServers
Relevant source files · 13

The internal/mcp package is Korvus's gateway to the outside world. During an incident investigation the agent is allowed exactly one channel of external I/O — the tools published by Model Context Protocol servers — and this package owns that channel end to end: it dials each configured server, negotiates the MCP handshake, discovers the tools each server advertises, merges them into one namespace, routes a model's tool call back to the server that owns it, and reports what happened to the transport layer so an operator can see which servers are live.

The package is deliberately small and deliberately forgiving. Discovery happens once at startup, and a server that cannot be reached never takes the process down: it is recorded as failed, it is excluded from the connected set, and the skills gated on it simply do not appear in the catalog. The registry comment states the contract plainly — it "is populated once at startup (RegisterServer per configured server) and read-only afterwards" Registry — which is what makes the runtime path a pure lookup with no locking hazards.

The abstraction the whole package is built around is a two-method interface. MCPClient exposes ListTools and CallTool, and its own comment says the streamable-HTTP implementation is wired in while "the registry and agent depend only on this interface" MCPClient. The agent sees the registry through a structurally identical three-method view, ToolExecutor, satisfied by *mcp.Registry ToolExecutor, and the transport sees it through ToolRegistry, also satisfied by *mcp.Registry ToolRegistry. Because both consumers declare the interface in their own package, the MCP layer stays free of knowledge about who calls it.

How it works

One client per server

StreamableHTTPClient is the concrete MCPClient: it wraps the official Go SDK's streamable-HTTP transport, which the doc comment identifies as spec revision 2025-03-26 and "the successor of the superseded HTTP/SSE transport" StreamableHTTPClient. The struct holds the server's configured name, its endpoint, the SDK client, and the live session guarded by a mutex StreamableHTTPClient.

NewStreamableHTTPClient only records the endpoint and configures the SDK client — the doc comment is explicit that "nothing is dialed until Connect" NewStreamableHTTPClient. Two details in that constructor matter later. The SDK client options set KeepAlive to the package constant keepAliveInterval, 30 seconds, and the comment on that constant explains why: the keep-alive pings are a liveness signal, so "a server that stops answering is detected on a timer rather than on the next RPC" keepAliveInterval. The other detail is the client identity — the implementation announces itself as korvus with version dev NewStreamableHTTPClient.

Connect performs the initialize handshake. It takes the mutex, closes any previous session so the method is safe to retry after a failure or after Close, builds a StreamableClientTransport pointed at the endpoint, and asks the SDK to open a session Connect. Two transport options are set explicitly. DisableStandaloneSSE is true because, as the comment puts it, "Korvus is a pure request/response consumer; the standalone SSE stream only carries server-initiated messages we never use" Connect. The HTTP client is built with a tracing transport rather than the default one, so that "an MCP server's own spans join the incident's trace instead of starting their own" Connect. A successful handshake logs the server name and endpoint Connect; a failed one returns a wrapped error naming both the server and the endpoint Connect.

Close releases the session and is idempotent — it returns nil on a client that was never connected Close.

Listing and calling tools

ListTools walks the SDK's tool iterator and converts each entry into the package's own Tool value, carrying the name, the description, and the JSON input schema when the server supplied one ListTools. The schema is marshaled to raw JSON at this point, so the registry can hand it to the model as a schema document without knowing anything about the SDK's schema types.

CallTool invokes the tool and then flattens the protocol's content blocks into a single text string. Text blocks are concatenated in order; anything that is not text is marshaled to JSON so "nothing the server returned is silently dropped" CallTool. The protocol's isError flag is preserved on the returned CallResult, which the package documents as "the call executed but the server reports a failure the model should see" CallResult.

Both methods start by obtaining the session through liveSession, which returns an error naming the fix — "not connected (call Connect first)" — rather than dereferencing a nil session liveSession. The comment on that helper is explicit about why: a nil session is the normal state between Close and Connect and after a failed Connect, and it must be "never a panic in the agent's tool-dispatch path" liveSession.

The registry

Registry holds three maps under a read-write mutex: tools by name, clients by server name, and failures by server name Registry. NewRegistry allocates all three NewRegistry.

RegisterServer is the only writer. It calls ListTools on the client, and if that fails the registration fails with the server named. Otherwise it takes the write lock, stamps each tool with the owning server name, and stores both the tools and the client — the two are populated together under a single lock, which is what makes the later nil-client branch unreachable in normal operation RegisterServer. A tool name already claimed by another server is rejected outright: tool names are the model-facing namespace, and the comment says they "must be unambiguous" RegisterServer.

The read side is three methods. Tools returns every registered tool sorted by name Tools, Has reports whether a name is registered Has, and Servers returns the names of the connected servers, which the comment identifies as "the set the skill catalog filters against" Servers.

Call is the routing path. It looks up the tool, finds the client of the server that owns it, and delegates. An unknown tool is an error; so is a tool whose owning server has no client, and that second branch is a deliberate defensive guard — the comment explains that a nil-interface call there "panics in the agent's tool-dispatch path — taking down the whole run rather than surfacing as an error-flagged tool result" [Call](src/internal/mcp/registry.go#L113-L138].

Connecting everything at startup

ConnectAll is the startup entry point. It takes a map of server name to endpoint and, for each one, creates a client and dials it under a per-server timeout of 10 seconds — the constant exists so "a hanging endpoint cannot stall startup discovery for the others" connectTimeout.

A dial failure is logged, recorded on the registry via RecordFailure, and the server's name is appended to the returned failure list; the loop continues to the next server ConnectAll. A server that connects but whose tools collide at registration is also failed: the session is closed so it does not linger unregistered, the error is recorded, and the name is appended ConnectAll. Both branches keep the failed server out of registry.Servers(), which is what the skill catalog filters on.

RecordFailure is the registry-side half of that deal. It stores the error and logs a warning, and its doc comment states the intent: the server is absent from Servers() so skills gated on it are excluded, while the failure stays visible through FailedServers RecordFailure. FailedServers returns a copy of the failure map for status reporting FailedServers.

Startup wiring is in the CLI. The process builds the registry, maps the configured servers into a name-to-URL map, and calls ConnectAll; the surrounding comment says discovery "is best-effort: an unreachable server degrades the skill catalog and is reported through SystemService, but never fails startup" main.go. The same block logs configured, connected, failed and tool counts. See korvus CLI and Process Wiring for the surrounding startup order.

Tracing across the wire

tracingTransport is an http.RoundTripper that wraps the base transport and starts a client span per round-trip, carrying the server name, the HTTP method and the full URL tracingTransport. It also injects the W3C trace context into the request headers. Because RoundTrip must not modify the request it is handed, the injection happens on a shallow clone of the request RoundTrip.

The package's own explanation of why the transport is instrumented rather than only the call wrapper is worth repeating: the MCP servers are separate deployment units that emit their own spans, and without the injected context "those spans start new traces and the query an incident actually ran is in a different trace from the agent that asked for it" tracing.go. Transport errors are recorded on the span and the span status is set to error; any response at or above 400 gets the same treatment RoundTrip. A nil base transport falls back to http.DefaultTransport, matching what the SDK does when its HTTP client is left unset newTracingTransport. The tracing setup itself is described in OpenTelemetry Setup and GenAI Semantic Attributes.

Error handling and degradation

The package's failure model is uniform: nothing in it panics, and every failure becomes an error value that a caller can turn into a user-visible message.

The client layer returns errors for an unconnected session, a failed handshake, a failed tool listing, and a failed tool call, each wrapped with the server name so the message is actionable liveSession, ListTools, CallTool.

The registry layer turns an unreachable or ambiguous server into a recorded failure rather than a fatal error, and the package's own comment on RecordFailure says discovery "must never crash startup" RecordFailure. The consequence of a failed server is a degraded catalog rather than a broken process: the skill loader filters out any skill whose mcp_server frontmatter names a server that is not in the connected set Load, and the surrounding machinery is described in Skill Catalog and Frontmatter Parsing.

The agent side completes the picture. executeToolCall dispatches through the registry and converts every failure — cancelled session, unknown tool, malformed arguments, MCP error, timeout — into an error-flagged tool result rather than a returned error, so "the transcript pairing invariant holds by construction" executeToolCall. The full dispatch path is in Agent Run Loop, Tool Execution and Provider Calls.

Configuration

The MCP surface of the configuration file is small: a list of servers, each with a name and a URL MCPServer. Names matter twice over — they are the identifiers used to gate skills, and they are the keys the registry and the status report use. The full configuration surface is in Configuration Loading and Validation, and the bundled servers that a typical deployment points at are described in Bundled MCP Servers Overview.

Two timeouts govern the package, and both are constants in code rather than configuration: the 10-second per-server connect timeout connectTimeout and the 30-second SDK keep-alive interval keepAliveInterval.

Reporting to the transport

The transport reads the registry through its own three-method interface, ToolRegistry ToolRegistry, and the system service builds its server list from it. That list deliberately covers every configured server, not only the connected ones, because "reporting only the registry's view would silently drop exactly the server an operator is trying to diagnose" mcpServers. Each entry carries the server's name and URL, whether it is connected, its tools, and — when the registry recorded a failure — the error text mcpServers. ListMCPServers returns the same list ListMCPServers, and GetSystemStatus embeds it in the response GetSystemStatus. The service handlers are described in Connect API Server and Service Handlers.

Relationships

graph TD
    main["cmd/korvus main.go"] -->|"ConnectAll"| ConnectAll
    ConnectAll -->|"creates"| Client
    Client -->|"RegisterServer"| Registry
    Client -->|"implements"| MCPClient
    MCPClient -.->|"satisfied by"| Registry
    Registry -->|"Call"| MCPClient
    Client -->|"tracing transport"| Tracing
    Registry -->|"Tools / Servers / FailedServers"| Transport
    Transport -->|"mcpServers"| Status
    Registry -->|"Servers"| SkillLoad
    Agent["internal/agent executeToolCall"] -->|"Call"| Registry
    Client --> CallResult
    Registry --> CallResult
    Client --> Tool
    Registry --> Tool

Reference

Symbol Kind What it does Location
MCPClient interface Two-method view of one MCP server: ListTools and CallTool src/internal/mcp/client.go#L8-L11
StreamableHTTPClient struct MCPClient over the SDK's streamable-HTTP transport; holds name, endpoint, SDK client and session src/internal/mcp/client_streamable.go#L25-L32
NewStreamableHTTPClient function Builds a client and configures the SDK keep-alive; dials nothing src/internal/mcp/client_streamable.go#L37-L51
Connect method Opens the session and completes the MCP initialize handshake src/internal/mcp/client_streamable.go#L60-L83
Close method Releases the session; idempotent src/internal/mcp/client_streamable.go#L86-L98
ListTools method Discovers the server's tools with their JSON input schemas src/internal/mcp/client_streamable.go#L102-L125
CallTool method Invokes a tool and flattens content blocks into one text payload src/internal/mcp/client_streamable.go#L129-L166
liveSession method Returns the session or an error naming the fix src/internal/mcp/client_streamable.go#L167-L171
keepAliveInterval constant 30-second SDK session keep-alive src/internal/mcp/client_streamable.go#L53-L55
Registry struct Merges the tools of all connected servers and routes calls to their owner src/internal/mcp/registry.go#L14-L22
NewRegistry function Allocates an empty registry src/internal/mcp/registry.go#L24-L30
RegisterServer method Discovers and registers one server's tools; rejects name collisions src/internal/mcp/registry.go#L60-L78
Tools method All registered tools, name-sorted src/internal/mcp/registry.go#L81-L90
Has method Whether a tool name is registered src/internal/mcp/registry.go#L93-L98
Servers method Names of the connected servers src/internal/mcp/registry.go#L102-L111
Call method Routes a tool call to the owning server's client src/internal/mcp/registry.go#L114-L138
RecordFailure method Marks a configured server unavailable src/internal/mcp/registry.go#L37-L42
FailedServers method Failed server names with their errors src/internal/mcp/registry.go#L47-L55
ConnectAll function Dials every configured server and registers the reachable ones src/internal/mcp/connect.go#L20-L47
connectTimeout constant 10-second per-server initialize handshake budget src/internal/mcp/connect.go#L9-L11
tracingTransport struct Round-tripper that starts client spans and injects trace context src/internal/mcp/tracing.go#L21-L26
newTracingTransport function Builds the transport, defaulting the base to http.DefaultTransport src/internal/mcp/tracing.go#L30-L40
RoundTrip method Starts the span, clones and injects the request, records errors src/internal/mcp/tracing.go#L42-L67
Tool struct One tool as offered to the model, with its owning server src/internal/mcp/types.go#L5-L13
CallResult struct Outcome of one tool call: flattened content and the error flag src/internal/mcp/types.go#L18-L21
ToolCall struct Tool invocation with already-parsed arguments src/internal/mcp/types.go#L23-L27
ToolCall struct Model-side tool invocation, arguments kept as raw JSON src/internal/model/provider.go#L54-L58

Generated by astrolabe from commit 0c2dca73 on branch main.