Building an MCP server in 260 lines of Go
Update — May 2026. A day after publishing this post we hit a wall: any non-trivial video shipped as base64 inside a tool-call payload blows past the model's context window. We rewrote the upload tool so the MCP returns a presigned URL the agent uploads to with curl — bytes never enter the LLM. The downloadable binary collapsed to a thin stdio↔HTTP bridge under 75 lines (all tool semantics now live server-side). The walk-through below is still accurate as a primer on writing an MCP server with the standard library; the current tool surface is documented in /docs/mcp.
The Model Context Protocol is small. Smaller than the SDKs imply. When we sat down to give Claude direct access to our subtitling pipeline, we spent about an hour reading the spec and another afternoon writing the server. The whole thing fit in a single Go file under 260 lines, with no dependencies outside the standard library.
This post walks through how the binary at github.com/kirillzubovsky/subtitlesking-mcp is wired together. If you have ever wanted to ship an MCP server but got scared off by the framework matrix, this is the floor.
What MCP actually is
MCP is JSON-RPC 2.0 with a fixed shape. A client (Claude Desktop, Claude Code, Cursor, whatever) launches your server as a subprocess and talks to it over stdin and stdout. Each line is a JSON-RPC message. The client asks for a list of tools. You return descriptors. The client calls a tool by name with arguments. You run the work and return a result.
That is it. There is no transport magic, no socket dance, no service discovery. If you can read a line, parse JSON, and write a line, you can serve MCP.
Why no SDK
We tried two MCP SDKs first. Both were fine. Neither survived contact with our actual deployment because:
- The hosted HTTP MCP at
https://brains.subtitlesking.com/mcpand the open-source stdio binary share tool definitions. We wanted one source of truth, not two SDK opinions. - We ship the binary as a static cross-compiled artifact. Adding a
dependency tree adds CVE surface and breaks
go buildon minimal build agents. - The protocol is small enough that the SDK abstraction was paying for itself in lines of glue.
So we ripped it out and wrote the loop directly.
The wire format
Every message is a rpcRequest or rpcResponse. The types are exactly
what you would expect:
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
ID is json.RawMessage because the spec lets it be a string, a
number, or null. Decoding it as any works but RawMessage round-trips
cleanly without forcing us to care about its type.
The main loop
The server reads one line, dispatches by method name, and writes one line. That is the whole runtime:
func main() {
in := bufio.NewScanner(os.Stdin)
in.Buffer(make([]byte, 1024*1024), 16*1024*1024)
out := json.NewEncoder(os.Stdout)
for in.Scan() {
var req rpcRequest
if err := json.Unmarshal(in.Bytes(), &req); err != nil {
continue
}
switch req.Method {
case "initialize":
out.Encode(initResponse(req.ID))
case "tools/list":
out.Encode(toolsList(req.ID))
case "tools/call":
out.Encode(toolsCall(req.ID, req.Params))
case "notifications/initialized":
// no response required
}
}
}
We bump the scanner buffer because tool responses with embedded transcripts can be large. Otherwise the loop is a textbook reactor.
Tool definitions
tools/list returns the four tools we expose:
add_subtitles_to_videoget_video_statusdownload_subtitled_videoget_transcript(and the relateddownload_transcript)
Each is a Go literal with a name, a description, and a JSON Schema for
its input. The schemas are hand-written. If you have ever spent an
afternoon fighting reflection-based schema generation, you will
appreciate that the entire tools/list body is maybe forty lines of
declarative data.
A tool handler
The interesting one is add_subtitles_to_video. It takes a video URL or
local path, kicks off the pipeline, and returns a job ID:
func handleAddSubtitles(args addSubtitlesArgs) (any, error) {
body, err := json.Marshal(map[string]string{
"video_url": args.VideoURL,
"language": args.Language,
})
if err != nil {
return nil, err
}
resp, err := http.Post(apiBase+"/jobs", "application/json", bytes.NewReader(body))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var job struct {
ID string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&job); err != nil {
return nil, err
}
return map[string]string{"job_id": job.ID}, nil
}
The MCP server is a thin shim. The actual pipeline (ffmpeg compress → OpenAI Whisper → ffmpeg burn) lives behind the HTTP API. Keeping the shim thin is what lets it stay this small.
get_video_status polls the job. download_subtitled_video either
returns a URL or streams the bytes back as a base64 resource, depending
on the client capability advertised in initialize.
Running it
Once compiled, the binary takes no arguments. You point your MCP client at the executable and it does the rest:
go build -o subtitlesking ./cmd/mcp
claude mcp add subtitlesking ./subtitlesking
For Claude Desktop, drop a stanza into claude_desktop_config.json. For
Cursor, add it to the MCP settings panel. The wire protocol is the same
in every case.
The full reference, including every tool argument and the exact stdio handshake, lives at /docs/mcp. The source is at github.com/kirillzubovsky/subtitlesking-mcp.
What we learned
Three things you can take to your own MCP server:
- The protocol is genuinely a one-afternoon project. If a framework feels heavy, it probably is.
- Keep the server stateless. Push state into the upstream API. Each tool call should be a translation, not a workflow engine.
- Write the JSON Schemas by hand. They are documentation. Generating them from Go structs hides exactly the field your model needs to see.
If you want to skip the build and just use the hosted version, the fastest path is one CLI call and a video URL. Try it at /try or read the MCP install guide.