Building an MCP server in Go

Kashish Hora

Kashish Hora

Co-founder of AgentCat

Try out AgentCat

What you are building and which SDK to use

An MCP server exposes tools, resources, and prompts to an AI client like Claude Desktop, Claude Code, or Cursor. In Go you build one with the official SDK, github.com/modelcontextprotocol/go-sdk, which was built with Google.

You might also run into github.com/mark3labs/mcp-go, the older community library. It still works, and there's no rush to move an existing server off it while clients are still on 2025-11-25 — though as of this writing mcp-go (v0.57.0) has no 2026-07-28 support, so a server that needs the new revision has to be on the official SDK, which is what this guide uses. Just don't try to mix the two in one project, since they aren't source-compatible.

You'll need Go 1.25 or newer (go.mod). Create a module and add the dependency:

$go mod init example.com/weather-server
$go get github.com/modelcontextprotocol/go-sdk

The smallest server that actually does something

Here's the shape of it: define a Go struct for your tool's input, write a typed handler, and register it with the generic mcp.AddTool. The SDK infers the tool's JSON Schema from the struct (reading descriptions from jsonschema struct tags), unmarshals and validates the arguments for you, and hands the handler a typed value. This tool looks up a value instead of adding two numbers, because a real tool wraps something the model can't do on its own.

// main.go
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

type ForecastInput struct {
	City string `json:"city" jsonschema:"the city to get the forecast for"`
}

func getForecast(ctx context.Context, req *mcp.CallToolRequest, in ForecastInput) (*mcp.CallToolResult, any, error) {
	// Call a real weather API here; returning a canned string keeps the example runnable.
	text := fmt.Sprintf("Forecast for %s: 18C, clear", in.City)
	return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}, nil, nil
}

func main() {
	server := mcp.NewServer(&mcp.Implementation{Name: "weather-server", Version: "1.0.0"}, nil)
	mcp.AddTool(server, &mcp.Tool{Name: "get_forecast", Description: "Current forecast for a city"}, getForecast)
	if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
		log.Fatal(err)
	}
}

Run it in development with go run .. mcp.NewServer takes an *mcp.Implementation (name and version), and the generic mcp.AddTool registers a typed handler whose input struct becomes the tool's input schema (go-sdk README, v1.7.0). You don't hand-write a JSON Schema; the struct and its jsonschema tags are the schema.

There's one contract to get straight before you write error handling. With a typed handler registered through AddTool, returning an error produces a tool error: the SDK sets IsError on the result and packs the message into the content, so the model sees it as a failed tool call rather than a broken connection. A protocol-level error is reserved for the low-level ToolHandler path (tool.go, v1.7.0). For a normal failure you just return nil, nil, fmt.Errorf(...).

Structured output: return data, not just a string

Tools can also hand back machine-readable results alongside the text block, a capability the spec added in the 2025-06-18 revision. With the Go SDK you get this almost for free: declare a second struct as the handler's output type and return a value of it. The SDK infers an output schema from that type and populates the result's structured content from your returned value (tool.go, v1.7.0).

type BMIInput struct {
	WeightKg float64 `json:"weight_kg" jsonschema:"weight in kilograms"`
	HeightM  float64 `json:"height_m" jsonschema:"height in meters"`
}

type BMIOutput struct {
	BMI float64 `json:"bmi"`
}

func computeBMI(ctx context.Context, req *mcp.CallToolRequest, in BMIInput) (*mcp.CallToolResult, BMIOutput, error) {
	return nil, BMIOutput{BMI: in.WeightKg / (in.HeightM * in.HeightM)}, nil
}

Register it the same way: mcp.AddTool(server, &mcp.Tool{Name: "bmi", Description: "Body mass index"}, computeBMI). Returning a nil *mcp.CallToolResult is fine here; when you only care about the output value, the SDK fills in the content block from the JSON of your output struct (tool.go, v1.7.0). Clients that predate structured output still get a readable text block; newer clients get typed data.

Resources and prompts

Tools are actions. Resources are addressable data the client can read, registered with server.AddResource. Prompts are reusable message templates, registered with server.AddPrompt. Both take a handler that receives a typed request and returns a result (resource.go, v1.7.0; prompt.go, v1.7.0).

server.AddResource(
	&mcp.Resource{URI: "config://settings", MIMEType: "application/json"},
	func(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
		return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{
			{URI: "config://settings", MIMEType: "application/json", Text: `{"version":"1.0.0"}`},
		}}, nil
	},
)

server.AddPrompt(
	&mcp.Prompt{Name: "review_code", Description: "Review a snippet of code"},
	func(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
		code := req.Params.Arguments["code"]
		return &mcp.GetPromptResult{Messages: []*mcp.PromptMessage{
			{Role: "user", Content: &mcp.TextContent{Text: "Review this code:\n\n" + code}},
		}}, nil
	},
)

Resource handlers return a ReadResourceResult whose Contents carry the URI and payload; prompt handlers return GetPromptResult messages. Unlike a tool's typed input, a prompt reads its arguments from req.Params.Arguments.

The two transports

The MCP spec defines exactly two transports: stdio and Streamable HTTP (MCP spec, Transports). The old two-endpoint HTTP+SSE transport from the 2024-11-05 spec is deprecated and kept only for backwards compatibility, so this guide doesn't use it.

stdio is for local use: the client launches your server as a subprocess over stdin/stdout, which is the &mcp.StdioTransport{} argument to server.Run in the first example. It's fine for a desktop integration or a quick local test, but it serves one client per process.

Streamable HTTP is the transport for remote, multi-client, and production servers, and it's the one most real deployments use. It uses a single endpoint (commonly /mcp). Historically (and still, for clients that haven't upgraded) it correlates requests into sessions with an Mcp-Session-Id header, minted on an initialize call; the 2026-07-28 spec revision removes both initialize and session IDs in favor of a stateless, per-request model. go-sdk v1.7.0 (2026-07-27) is the first release with full support for that model, but it's opt-in: the transport only accepts 2026-07-28 traffic when StreamableHTTPOptions.Stateless is true, and stateful sessions otherwise negotiate down to 2025-11-25. Set it and the SDK still negotiates down for older, session-based clients, so one handler serves both eras. In the Go SDK you wrap your server in an http.Handler with mcp.NewStreamableHTTPHandler, which takes a function that returns the *mcp.Server to use for each request (streamable example test, v1.7.0).

func main() {
	server := mcp.NewServer(&mcp.Implementation{Name: "weather-server", Version: "1.0.0"}, nil)
	mcp.AddTool(server, &mcp.Tool{Name: "get_forecast", Description: "Current forecast"}, getForecast)

	handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
		return server
	}, &mcp.StreamableHTTPOptions{Stateless: true})
	log.Fatal(http.ListenAndServe(":8080", handler))
}

Returning the same server for every request shares one server across all clients. With Stateless: true, as set above, there's nothing to track per client — every request is served independently, which is what makes horizontally scaled deployments work, and any state that needs to span calls has to travel as an explicit handle in the tool arguments instead. (Omit the option, or pass nil, and you get the older session-tracking behavior instead, where the handler keeps per-client state for you but only serves 2025-11-25 and earlier.) The options argument is a *mcp.StreamableHTTPOptions; besides Stateless, JSONResponse: true returns plain JSON instead of an event stream (streamable example test, v1.7.0). The full runnable HTTP server lives in the SDK's examples/http directory pinned to the tag you installed (go-sdk examples, v1.7.0).

Building a binary and connecting it to a client

For deployment, compile a static binary so the client can launch it with no toolchain present:

$CGO_ENABLED=0 go build -o weather-server .

CGO_ENABLED=0 produces a statically linked binary that runs on a bare host or a scratch container. Test it in isolation with the MCP Inspector (npx @modelcontextprotocol/inspector /absolute/path/to/weather-server), which lists your tools and lets you call them by hand. To run it locally over stdio, point a client at the binary, for example in Claude Code:

$claude mcp add weather -- /absolute/path/to/weather-server

Use an absolute path, since the client sets its own working directory. A Streamable HTTP server is reached by URL instead.

Two things that will bite you

Never write to stdout on a stdio server. stdout is the JSON-RPC channel, and the spec says the server must never put anything there that isn't a valid MCP message. A stray fmt.Println drops a non-JSON line into the stream; the client hits it as a parse error, which depending on the client can surface as a broken connection (often -32000: Connection closed). Send diagnostics to stderr instead, with the standard log package (which writes to stderr by default) or fmt.Fprintln(os.Stderr, ...). It's one of the most common reasons a server "starts but the client sees nothing."

Let the struct define the schema. The generic mcp.AddTool derives the input schema from your Go type and its jsonschema tags, and rejects arguments that fail validation before your handler runs. If a field never reaches your handler, check its json tag and that it's exported: an unexported field is invisible to both the schema and the unmarshaler.

Where to go next

The SDK's examples/ directory has runnable clients and servers for stdio, HTTP, and auth, all pinned to the tag you installed (go-sdk examples, v1.7.0). For the stateless model and the dual-era support a multi-client HTTP deployment still needs, see Configuring MCP servers for multiple simultaneous connections. To build the same server in another language, see the TypeScript and Python FastMCP guides.