MCP Client, Registry and Server Connection
- 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
- internal/mcp/registry.go 11–22, 24–30, 32–42, 44–55 +4 more
- internal/mcp/client.go 5–11
- internal/agent/agent.go 70–78
- internal/transport/transport.go 26–33
- internal/mcp/client_streamable.go 16–32, 34–51, 53–55, 57–83 +3 more
- internal/mcp/types.go 15–21
- internal/mcp/connect.go 9–11, 20–44
- cmd/korvus/main.go 135–147
- internal/mcp/tracing.go 13–26, 28–40, 42–55, 57–67
- internal/skill/catalog.go 18–44
- internal/agent/toolexec.go 38–114
- internal/config/config.go 167–176
- internal/transport/system.go 38–55, 75–82, 142–193
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 --> ToolReference
| 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.