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.
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 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.
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 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.
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.
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 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.
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.
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.
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.
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.
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.
| Consideration | Per-sync subprocess | Long-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.
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.
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.
Three things I had to learn from real servers rather than the specification:
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.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.Two things:
KaryoSpace is live at karyospace.com. Questions about MCP client implementation or enterprise AI integration: sumanakkisetty@gmail.com