Your first MCP server in Go
- Spec
2026-07-28- Evidence
- E2Reproducedarm64
- Go SDK
github.com/modelcontextprotocol/go-sdk@v1.8.0- MCP Inspector
@modelcontextprotocol/inspector@2.12.0- Verified
The official Go SDK compiles to a single binary, which makes it a good fit for MCP servers you hand to other people: there is no runtime to install. This guide builds the add tool over stdio, then serves it over HTTP with progress updates.
Before you start
Section titled “Before you start”- Go 1.25 or later.
github.com/modelcontextprotocol/go-sdk@v1.8.0declaresgo 1.25.0in itsgo.mod. With an older Go, thegocommand downloads the 1.25 toolchain automatically, as it did on our machine with Go 1.24.4. - Node.js 22.19 or later, for the MCP Inspector used to test.
A stdio server
Section titled “A stdio server”mkdir hello-server && cd hello-servergo mod init example.com/hello-servergo get github.com/modelcontextprotocol/go-sdk@v1.8.0// The smallest useful MCP server: one tool with typed input and structured output.// Every example in examples/mcp/ exposes the same `add` tool so the harness can// compare SDKs with the same calls.package main
import ( "context" "log"
"github.com/modelcontextprotocol/go-sdk/mcp")
type AddInput struct { A float64 `json:"a" jsonschema:"first number"` B float64 `json:"b" jsonschema:"second number"`}
type AddOutput struct { Sum float64 `json:"sum"`}
func add(ctx context.Context, req *mcp.CallToolRequest, in AddInput) (*mcp.CallToolResult, AddOutput, error) { // Returning a nil result lets the SDK fill content and structuredContent from AddOutput. return nil, AddOutput{Sum: in.A + in.B}, nil}
func main() { server := mcp.NewServer(&mcp.Implementation{Name: "hello-go", Version: "1.0.0"}, nil) mcp.AddTool(server, &mcp.Tool{Name: "add", Description: "Add two numbers and return the sum."}, add)
// The log package writes to stderr, which keeps stdout clean for JSON-RPC. log.Println("hello-go: ready on stdio") if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { log.Fatal(err) }}mcp.AddToolis generic: the input and output types of your handler become the tool’s input and output JSON schemas. Thejsonschemastruct tag becomes the property description.- Returning
nilas the result tells the SDK to fill in bothstructuredContentand a text copy of it from your output value. server.Runwithmcp.StdioTransportserves JSON-RPC on stdin and stdout. Thelogpackage writes to stderr, so it does not interfere with the protocol on stdout.
go build -o hello-server .npx -y @modelcontextprotocol/inspector@2.12.0 --cli \ ./hello-server --protocol-era auto \ --method tools/call --tool-name add --tool-arg a=2 b=3 --format jsonExpectedstructuredContent is {"sum": 5}, and _meta names the server hello-go.
A client starts the binary directly. Use the absolute path to hello-server as the command, with no arguments.
Over Streamable HTTP
Section titled “Over Streamable HTTP”// The add tool from hello-server, served over Streamable HTTP at /mcp.// Listen address: the ADDR environment variable, 127.0.0.1:8331 by default.package main
import ( "context" "fmt" "log" "net/http" "os" "time"
"github.com/modelcontextprotocol/go-sdk/mcp")
type AddInput struct { A float64 `json:"a" jsonschema:"first number"` B float64 `json:"b" jsonschema:"second number"`}
type AddOutput struct { Sum float64 `json:"sum"`}
func add(ctx context.Context, req *mcp.CallToolRequest, in AddInput) (*mcp.CallToolResult, AddOutput, error) { return nil, AddOutput{Sum: in.A + in.B}, nil}
type CountInput struct { N int `json:"n" jsonschema:"how many seconds to count, 1 to 10"`}
type CountOutput struct { Counted int `json:"counted"`}
// count takes n seconds and reports progress once a second. Over HTTP each progress// notification is a separate event in the response stream, which makes proxy buffering visible.func count(ctx context.Context, req *mcp.CallToolRequest, in CountInput) (*mcp.CallToolResult, CountOutput, error) { if in.N < 1 || in.N > 10 { return nil, CountOutput{}, fmt.Errorf("n must be between 1 and 10, got %d", in.N) } token := req.Params.GetProgressToken() for i := 1; i <= in.N; i++ { select { case <-ctx.Done(): return nil, CountOutput{}, ctx.Err() case <-time.After(time.Second): } if token != nil { req.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ ProgressToken: token, Progress: float64(i), Total: float64(in.N), }) } } return nil, CountOutput{Counted: in.N}, nil}
func main() { server := mcp.NewServer(&mcp.Implementation{Name: "hello-go-http", Version: "1.0.0"}, nil) mcp.AddTool(server, &mcp.Tool{Name: "add", Description: "Add two numbers and return the sum."}, add) mcp.AddTool(server, &mcp.Tool{Name: "count", Description: "Count n seconds, reporting progress each second."}, count)
// Stateless: no Mcp-Session-Id, so any instance behind a load balancer can answer any request. handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, &mcp.StreamableHTTPOptions{Stateless: true})
// The SDK checks the Host header of localhost requests (DNS rebinding) but not Origin. // CrossOriginProtection rejects cross-site browser requests; non-browser clients send no Origin. mux := http.NewServeMux() mux.Handle("/mcp", http.NewCrossOriginProtection().Handler(handler))
addr := os.Getenv("ADDR") if addr == "" { addr = "127.0.0.1:8331" } log.Printf("hello-go-http: listening on http://%s/mcp", addr) log.Fatal(http.ListenAndServe(addr, mux))}mcp.NewStreamableHTTPHandleris an ordinaryhttp.Handler, so it mounts on any mux or router. It receives a function that picks the*mcp.Serverper request.Stateless: truedropsMcp-Session-Id. Any instance can answer any request, andGET /mcpreturns405. That matches the 2026-07-28 spec, which has no protocol sessions.http.NewCrossOriginProtection()comes from the Go standard library (1.25). It rejects cross-site browser requests. The SDK’s ownCrossOriginProtectionoption is deprecated in favor of wrapping the handler like this.countshows progress. When the request carries aprogressToken, eachNotifyProgresscall is sent as its own event in the response stream before the result.
go build -o http-server . && ./http-server# in another terminalnpx -y @modelcontextprotocol/inspector@2.12.0 --cli \ http://127.0.0.1:8331/mcp --protocol-era auto --method tools/listExpectedTwo tools, add and count.
Both protocol generations
Section titled “Both protocol generations”We connected both servers with the TypeScript client SDK in its three negotiation modes:
| Client setting | stdio server | HTTP server |
|---|---|---|
| Default (2025 handshake) | 2025-11-25 | 2025-11-25 |
auto |
2026-07-28 | 2026-07-28 |
| Pinned to 2026-07-28 | 2026-07-28 | 2026-07-28 |
The SDK handles both generations; you write nothing extra.
What the HTTP handler checks
Section titled “What the HTTP handler checks”Sending requests by hand with curl is a good way to learn the 2026-07-28 headers. Against the HTTP server above, we got:
| Request | Response |
|---|---|
2026-07-28 request without the MCP-Protocol-Version header |
400, Mcp-Protocol-Version header is required for requests carrying "io.modelcontextprotocol/protocolVersion" (code -32020) |
Without the Mcp-Method header |
400, missing required Mcp-Method header (code -32020) |
GET /mcp |
405 Method Not Allowed |
Host: evil.example.com on a connection to 127.0.0.1 |
403, Forbidden: invalid Host header "evil.example.com" |
Origin: https://evil.example.com |
403 with CrossOriginProtection; 200 without it |
The Host check is the SDK’s DNS rebinding protection: requests that arrive on a localhost address must name localhost. It matters when you put a proxy on the same machine; see Deploy behind a reverse proxy.
Related
Section titled “Related”Sources
MCPDev is community documentation. It is not affiliated with the Model Context Protocol project or with the vendors of the clients it covers.