Skip to content

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.

  • Go 1.25 or later. github.com/modelcontextprotocol/go-sdk@v1.8.0 declares go 1.25.0 in its go.mod. With an older Go, the go command 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.
TerminalCreate the module
Terminal window
mkdir hello-server && cd hello-server
go mod init example.com/hello-server
go get github.com/modelcontextprotocol/go-sdk@v1.8.0
Editormain.go
main.go
// 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.AddTool is generic: the input and output types of your handler become the tool’s input and output JSON schemas. The jsonschema struct tag becomes the property description.
  • Returning nil as the result tells the SDK to fill in both structuredContent and a text copy of it from your output value.
  • server.Run with mcp.StdioTransport serves JSON-RPC on stdin and stdout. The log package writes to stderr, so it does not interfere with the protocol on stdout.
TerminalBuild and call it
Terminal window
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 json

ExpectedstructuredContent 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.

Editormain.go for the HTTP server
main.go
// 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.NewStreamableHTTPHandler is an ordinary http.Handler, so it mounts on any mux or router. It receives a function that picks the *mcp.Server per request.
  • Stateless: true drops Mcp-Session-Id. Any instance can answer any request, and GET /mcp returns 405. 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 own CrossOriginProtection option is deprecated in favor of wrapping the handler like this.
  • count shows progress. When the request carries a progressToken, each NotifyProgress call is sent as its own event in the response stream before the result.
TerminalRun it and list the tools
Terminal window
go build -o http-server . && ./http-server
# in another terminal
npx -y @modelcontextprotocol/inspector@2.12.0 --cli \
http://127.0.0.1:8331/mcp --protocol-era auto --method tools/list

ExpectedTwo tools, add and count.

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.

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.

Sources

MCPDev is community documentation. It is not affiliated with the Model Context Protocol project or with the vendors of the clients it covers.