All articles

Adding MCP to a production Go server: JSON-RPC over stdio, tool discovery, and why it is not as simple as the spec suggests

Suman Akkisetty
Builder of KaryoSpace
May 2026
11 min read

The Model Context Protocol specification is clean. JSON-RPC 2.0 messages over stdio, a tools/list method, a tools/call method, and a handshake to start. It reads like something you could implement in an afternoon.

I spent closer to three days getting it right in Go. Not because the spec is wrong, but because the gap between what the spec describes and what a real MCP server actually sends over the wire is substantial. This article covers what I built, what broke, and what the spec does not tell you.

What MCP is, and what KaryoSpace uses it for

MCP is a protocol for connecting AI systems to external data and tool sources. Each MCP server is an independent process that exposes a set of tools via a standard JSON-RPC interface. The client discovers those tools with tools/list, then invokes them with tools/call. The transport layer is typically stdio: the client launches the server as a subprocess and communicates over stdin and stdout.

KaryoSpace implements an MCP client, not a server. The system connects to external MCP servers (a compiled GitHub integration binary and a Slack MCP server) as data sources for the RAG pipeline. Each source exposes structured documents via MCP tools, and the client pulls them into the vectorizer as RawDocument records during each sync cycle.

The design choice here is deliberate. Rather than writing bespoke API integrations for GitHub and Slack, MCP gives us a uniform interface: discover tools, call them, parse the content. The same client code works for any MCP server.

The MCPClient struct

The core type is straightforward:

type MCPClient struct {
    transport mcpTransport
    mu        sync.Mutex
    nextID    atomic.Int64
}

Two fields deserve explanation. The mu sync.Mutex serializes all calls through the transport. MCP's stdio transport is inherently sequential: you write a request, read a response, write the next request. The mutex enforces that. Without it, concurrent callers would interleave their reads and writes, corrupting the message stream.

nextID atomic.Int64 generates unique JSON-RPC IDs without taking the mutex. IDs only need to be unique and monotonically increasing; an atomic counter is enough.

The transport interface

I abstracted the transport behind an interface:

type mcpTransport interface {
    RoundTrip(ctx context.Context, reqBytes []byte) ([]byte, error)
    Notify([]byte) error
    Close() error
}

Two implementations exist: stdioTransport for subprocess-based MCP servers and an HTTP transport for servers that expose an HTTP endpoint. The HTTP transport is much simpler: no subprocess, no NDJSON parsing, no process lifecycle management. But most MCP servers in the wild use stdio, so that is where the complexity lives.

The stdio transport: launching a subprocess

The stdio transport wraps a subprocess:

func NewStdioMCPClient(ctx context.Context, command string, args ...string) (*MCPClient, error) {
    cmd := exec.CommandContext(ctx, command, args...)
    stdin, err := cmd.StdinPipe()
    if err != nil {
        return nil, err
    }
    stdout, err := cmd.StdoutPipe()
    if err != nil {
        return nil, err
    }
    if err := cmd.Start(); err != nil {
        return nil, err
    }
    t := &stdioTransport{
        cmd:    cmd,
        stdin:  stdin,
        reader: bufio.NewReader(stdout),
    }
    c := &MCPClient{transport: t}
    return c, c.initialize(ctx)
}

bufio.NewReader wraps stdout for line-by-line reading. MCP over stdio uses NDJSON: each message is a single JSON object terminated by a newline. The reader calls ReadString('\n') to get one complete message at a time.

The last line calls c.initialize(ctx). This is mandatory. A freshly started MCP server will reject tools/list or any other method call if the client hasn't sent an initialize handshake first. The spec requires it; real servers enforce it.

Wire types

The JSON-RPC wire format is standard, but the Go structs need careful design:

type mcpReq struct {
    Jsonrpc string          `json:"jsonrpc"`
    ID      int64           `json:"id"`
    Method  string          `json:"method"`
    Params  json.RawMessage `json:"params,omitempty"`
}

type mcpResp struct {
    Jsonrpc string          `json:"jsonrpc"`
    ID      *int64          `json:"id"`
    Result  json.RawMessage `json:"result,omitempty"`
    Error   *mcpRespErr     `json:"error,omitempty"`
}

type MCPContent struct {
    Type string `json:"type"` // always "text" in practice
    Text string `json:"text"`
}

Notice that ID in mcpResp is a pointer (*int64), while in mcpReq it is a plain int64. This distinction matters enormously. A missing id field in a response means it is a notification, not a reply. If ID were a value type, a missing field would deserialize as zero, which is indistinguishable from a response to request ID 0. The pointer lets us check resp.ID == nil to identify notifications.

Using json.RawMessage for Params and Result defers parsing until we know the method context. tools/list and tools/call return different shapes, and RawMessage lets us unmarshal them into the right struct after the fact.

The hidden complexity: interleaved notifications

This is the part the spec glosses over.

MCP servers can send notification messages at any time, including between a request and its response. Notifications are JSON-RPC messages with no id field. They carry server-side events: progress updates, log lines, warnings. A well-behaved client must handle them without confusing them for responses.

The naive RoundTrip implementation reads one line from stdout and returns it. This breaks immediately when the server sends a notification before the response. The read returns the notification JSON, the caller tries to parse it as a response, the ID field is nil, and everything falls apart.

The correct implementation loops:

func (t *stdioTransport) RoundTrip(ctx context.Context, reqBytes []byte) ([]byte, error) {
    // Write the request
    if _, err := fmt.Fprintf(t.stdin, "%s\n", reqBytes); err != nil {
        return nil, err
    }
    // Read lines until we get an actual response (ID != nil)
    for {
        line, err := t.reader.ReadString('\n')
        if err != nil {
            return nil, err
        }
        var peek struct {
            ID *int64 `json:"id"`
        }
        if err := json.Unmarshal([]byte(line), &peek); err != nil {
            continue // malformed line, skip it
        }
        if peek.ID == nil {
            // Notification — discard and read the next line
            continue
        }
        return []byte(line), nil
    }
}

We peek at the id field of every line. If it is nil, the message is a notification and we discard it. We keep reading until we get a message with an ID. That is the response to our request.

The real lesson

The spec describes notifications as an optional feature. Real MCP servers treat them as routine: startup logs, progress events, capability announcements. If your client doesn't handle them, it will stall on the first server that emits one.

Tool discovery and invocation

Once the client is initialized, tool discovery is a single call:

type MCPToolDef struct {
    Name        string          `json:"name"`
    Description string          `json:"description"`
    InputSchema json.RawMessage `json:"inputSchema"`
}

func (c *MCPClient) ListTools(ctx context.Context) ([]MCPToolDef, error) {
    c.mu.Lock()
    defer c.mu.Unlock()
    id := c.nextID.Add(1)
    req := mcpReq{Jsonrpc: "2.0", ID: id, Method: "tools/list"}
    reqBytes, _ := json.Marshal(req)
    respBytes, err := c.transport.RoundTrip(ctx, reqBytes)
    if err != nil {
        return nil, err
    }
    var resp mcpResp
    if err := json.Unmarshal(respBytes, &resp); err != nil {
        return nil, err
    }
    if resp.Error != nil {
        return nil, fmt.Errorf("MCP error %d: %s", resp.Error.Code, resp.Error.Message)
    }
    var result struct {
        Tools []MCPToolDef `json:"tools"`
    }
    return result.Tools, json.Unmarshal(resp.Result, &result)
}

Tool invocation follows the same pattern with tools/call:

type MCPCallResult struct {
    Content []MCPContent `json:"content"`
    IsError bool         `json:"isError"`
}

func (c *MCPClient) CallTool(ctx context.Context, name string, params map[string]any) (MCPCallResult, error) {
    c.mu.Lock()
    defer c.mu.Unlock()
    id := c.nextID.Add(1)
    p, _ := json.Marshal(map[string]any{"name": name, "arguments": params})
    req := mcpReq{Jsonrpc: "2.0", ID: id, Method: "tools/call", Params: p}
    reqBytes, _ := json.Marshal(req)
    respBytes, err := c.transport.RoundTrip(ctx, reqBytes)
    if err != nil {
        return MCPCallResult{}, err
    }
    var resp mcpResp
    if err := json.Unmarshal(respBytes, &resp); err != nil {
        return MCPCallResult{}, err
    }
    if resp.Error != nil {
        return MCPCallResult{}, fmt.Errorf("MCP error %d: %s", resp.Error.Code, resp.Error.Message)
    }
    var result MCPCallResult
    return result, json.Unmarshal(resp.Result, &result)
}

The content field is always a slice of MCPContent objects. In practice every MCP server I have encountered returns a single text-type content item. The slice exists in the spec for future extensibility (images, structured data), but today's tool results come back as content[0].Text.

The initialize handshake

Skipping the initialize step is the most common mistake I see in MCP client implementations. The server will either silently ignore subsequent calls or return a protocol error that looks like a network issue. The handshake sends the client's protocol version and capabilities:

func (c *MCPClient) initialize(ctx context.Context) error {
    c.mu.Lock()
    defer c.mu.Unlock()
    id := c.nextID.Add(1)
    params, _ := json.Marshal(map[string]any{
        "protocolVersion": "2024-11-05",
        "capabilities":    map[string]any{},
        "clientInfo": map[string]any{
            "name":    "karyospace-mcp-client",
            "version": "1.0.0",
        },
    })
    req := mcpReq{Jsonrpc: "2.0", ID: id, Method: "initialize", Params: params}
    reqBytes, _ := json.Marshal(req)
    _, err := c.transport.RoundTrip(ctx, reqBytes)
    return err
}

We discard the initialize response. The server's capabilities response matters if you need to negotiate features, but for a client that only uses tools/list and tools/call, the handshake is just a required formality.

Integrating GitHub and Slack

Both integrations register as DataSource implementations in the KaryoSpace registry. Each one creates an MCPClient at sync time, lists available tools, calls the relevant ones, and converts the results to RawDocument records:

type RawDocument struct {
    ID        string
    Title     string
    Body      string
    URL       string
    UpdatedAt time.Time
}

The GitHub MCP integration connects to a compiled workapp-mcp binary over stdio. It fetches repository issues and pull requests as structured documents. The Slack integration connects to a slack-mcp-server process and pulls channel messages. Both follow the same pattern: one client instance, ListTools once per sync to discover what is available, then targeted CallTool invocations for the data we need.

Design insight

The uniform RawDocument output type means the vectorizer does not know or care whether a document came from GitHub, Slack, or a future MCP integration. Adding a new data source is a matter of writing a new DataSource implementation, not changing the pipeline.

Subprocess lifecycle and the cost of per-call processes

My first implementation launched a new subprocess for every sync cycle and killed it when done. Subprocess startup on the test machine averaged about 50ms for the MCP binary, which is acceptable for a 15-minute polling interval. I considered connection pooling (keeping the subprocess alive between sync cycles) but decided against it for the initial version. The complexity of managing a long-lived subprocess, detecting crashes, and handling restarts outweighed the latency savings at current poll frequency.

When the context passed to NewStdioMCPClient is cancelled, exec.CommandContext kills the subprocess automatically. This is the one genuinely nice property of tying the subprocess lifetime to a context: cleanup is automatic and leak-free.

ConsiderationPer-sync subprocessLong-lived subprocess
Startup cost ~50ms per cycle Once at startup
Crash recovery Automatic (new process next cycle) Must detect and restart
State accumulation None (fresh process) Possible memory growth
Implementation complexity Low Medium to high

For 15-minute sync intervals, per-sync subprocess wins on simplicity. If the interval dropped to seconds, I would revisit this.

SSRF protection on the HTTP transport

The HTTP transport (used when an MCP server exposes an HTTP endpoint rather than stdio) routes all outbound requests through the workspace/backend/ssrfguard package. This blocks requests to private IP ranges, loopback addresses, and cloud metadata endpoints. It is easy to forget that an MCP server URL is user-configurable in some deployments. Without SSRF protection, a malicious server URL could be used to probe internal network resources from the KaryoSpace backend.

Security note

If you are building an MCP client that accepts server URLs from users or external configuration, treat those URLs as untrusted. SSRF guard is not optional in a multi-tenant environment.

What the spec does not tell you

Three things I had to learn from real servers rather than the specification:

  1. Notifications are not optional in practice. The spec presents them as an extension. Real MCP servers send them routinely during startup and tool execution. A client that doesn't loop past notifications will stall on the first one.
  2. The initialize handshake is strictly required. Sending tools/list without a prior initialize will result in a protocol error or silent rejection from most servers. The spec marks it as mandatory, but it is easy to miss if you are skimming.
  3. Tool result content is always a slice, even when there is one item. The content field in a tools/call response is []MCPContent, not a single object. The spec is clear about this, but it is a common source of JSON unmarshaling bugs when implementing in a strongly-typed language.

What I would do differently

Two things:

  1. Keep the subprocess alive across sync cycles from the start. The 50ms startup cost is small now, but I will need to address it eventually. Building the restart-and-recover logic early, when the codebase is simpler, would have been easier than retrofitting it later.
  2. Write a conformance test against a reference MCP server. I debugged the notification-handling bug by adding log lines and running against a live server. A synthetic test server that deliberately emits notifications before responses would have caught it in minutes rather than hours.

KaryoSpace is live at karyospace.com. Questions about MCP client implementation or enterprise AI integration: sumanakkisetty@gmail.com

Contact Us
Message sent!
We read every submission and will get back to you.