← Back to Blog

Building an MCP Server in Go with the Official SDK

You already run Go services. Now you want an agent to poke at them: hit a health endpoint, read a config file, follow a triage checklist. The reflex answer is to write the MCP server in Python, which means a second runtime on the box, a virtualenv, pinned pip dependencies, and a packaging step for something that is ultimately one HTTP call and a JSON response.

The official Go SDK for the Model Context Protocol exists now (v1.8.0, maintained in collaboration with Google), and it turns that job into a single static binary. No interpreter, no node_modules, no venv. Compile once, copy the file anywhere.

What follows is a working MCP server in Go with one tool, one resource, one resource template, and one prompt. It speaks stdio by default and streamable HTTP when you pass a flag. I compiled and ran every snippet below while writing this, so the command output pasted here is copied from a real run.

Prerequisites

  • Go 1.25 or newer. The SDK's go.mod declares go 1.25.0, and new SDK releases only target supported Go versions.
  • An MCP client to point at the binary. Claude Desktop is the easiest, but anything that speaks stdio works.
  • A few minutes for the first build, mostly module downloads.

Step 1: project skeleton

mkdir ops-mcp && cd ops-mcp
go mod init opsmcp
go get github.com/modelcontextprotocol/go-sdk/mcp

Step 2: the smallest server that works

package main

import (
	"context"
	"log"
	"github.com/modelcontextprotocol/go-sdk/mcp"
)

type Input struct {
	Name string `json:"name" jsonschema:"the name of the person to greet"`
}

type Output struct {
	Greeting string `json:"greeting" jsonschema:"the greeting to tell to the user"`
}

func SayHi(ctx context.Context, req *mcp.CallToolRequest, input Input) (
	*mcp.CallToolResult,
	Output,
	error,
) {
	return nil, Output{Greeting: "Hi " + input.Name}, nil
}

func main() {
	server := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "v1.0.0"}, nil)
	mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi)
	if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
		log.Fatal(err)
	}
}

Three things matter here. mcp.NewServer takes an Implementation (name plus version) that clients display in their tool list. server.Run blocks until the client disconnects. And since stdout is the protocol channel, anything you print goes to stderr or it corrupts the stream.

Step 3: your struct is the schema

mcp.AddTool is a generic function, and that is where most of the value sits. It takes a tool definition and a Go function shaped like this:

func(ctx context.Context, req *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error)

If you leave Tool.InputSchema empty, the SDK infers it from In. Same for Tool.OutputSchema from Out, unless Out is any. Then it validates the incoming arguments against that schema, unmarshals them into your struct, runs the function, marshals the returned value into StructuredContent and Content, and sets IsError when you return a plain error. The lower-level Server.AddTool exists too, but it leaves all of that bookkeeping to you.

The inference rules are worth knowing before you write your first tool:

  • In must be a struct or a map. A bare string gets you a runtime panic at registration.
  • A field without omitempty is required.
  • The generated schema sets additionalProperties: false, so a client that invents an extra argument gets rejected instead of silently ignored.
  • The jsonschema struct tag becomes the argument description the model reads. Treat it as prompt text, not boilerplate.

Here is the schema the SDK generated for the tool in Step 4, dumped straight from the client:

{"additionalProperties":false,"properties":{"expect_contains":{"description":"optional substring the response body must contain","type":"string"},"timeout_seconds":{"description":"request timeout in seconds, defaults to 10","type":"integer"},"url":{"description":"absolute URL to check, for example https://example.com/health","type":"string"}},"required":["url"],"type":"object"}

Only url is required, because the other two carry omitempty.

Step 4: a tool worth shipping

A hello world proves the wiring, not the value. Here is a tool that tells you whether a deploy actually landed:

type CheckEndpointInput struct {
	URL            string `json:"url" jsonschema:"absolute URL to check, for example https://example.com/health"`
	ExpectContains string `json:"expect_contains,omitempty" jsonschema:"optional substring the response body must contain"`
	TimeoutSeconds int    `json:"timeout_seconds,omitempty" jsonschema:"request timeout in seconds, defaults to 10"`
}

type CheckEndpointOutput struct {
	URL         string `json:"url"`
	StatusCode  int    `json:"status_code"`
	LatencyMs   int64  `json:"latency_ms"`
	ContentType string `json:"content_type"`
	Matched     bool   `json:"matched"`
	Message     string `json:"message"`
}

func checkEndpoint(ctx context.Context, _ *mcp.CallToolRequest, in CheckEndpointInput) (*mcp.CallToolResult, CheckEndpointOutput, error) {
	timeout := 10 * time.Second
	if in.TimeoutSeconds > 0 {
		timeout = time.Duration(in.TimeoutSeconds) * time.Second
	}
	ctx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, in.URL, nil)
	if err != nil {
		return nil, CheckEndpointOutput{}, fmt.Errorf("build request: %w", err)
	}
	req.Header.Set("User-Agent", "ops-mcp/0.1")
	start := time.Now()
	resp, err := http.DefaultClient.Do(req)
	latency := time.Since(start).Milliseconds()
	if err != nil {
		return nil, CheckEndpointOutput{}, fmt.Errorf("request failed: %w", err)
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
	if err != nil {
		return nil, CheckEndpointOutput{}, fmt.Errorf("read body: %w", err)
	}
	out := CheckEndpointOutput{
		URL:         in.URL,
		StatusCode:  resp.StatusCode,
		LatencyMs:   latency,
		ContentType: resp.Header.Get("Content-Type"),
		Matched:     true,
	}
	if in.ExpectContains != "" {
		out.Matched = strings.Contains(string(body), in.ExpectContains)
		if !out.Matched {
			out.Message = fmt.Sprintf("body does not contain %q", in.ExpectContains)
		}
	}
	if out.Message == "" {
		out.Message = "ok"
	}
	return nil, out, nil
}

Two design choices are deliberate. Returning a typed CheckEndpointOutput gives the model a structured result it can reason over, and the SDK still fills the text content so clients that ignore structured output keep working. Returning an error instead of IsError: true by hand means a failed request reads as a failed tool call, which is what the model should see.

Step 5: resources and resource templates

Tools are for actions. Resources are for read-only data the client can pull by URI, which keeps lookup data out of your tool arguments.

const allowlistURI = "ops://config/allowlist"

func readAllowlist(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
	if req.Params.URI != allowlistURI {
		return nil, mcp.ResourceNotFoundError(req.Params.URI)
	}
	body := "api.example.com\nstatus.example.com\n"
	return &mcp.ReadResourceResult{
		Contents: []*mcp.ResourceContents{{
			URI:      allowlistURI,
			MIMEType: "text/plain",
			Text:     body,
		}},
	}, nil
}

For a collection, register a template instead of one resource per item:

server.AddResourceTemplate(&mcp.ResourceTemplate{
	URITemplate: "incident://{id}",
	Name:        "Incident notes",
	Description: "Markdown notes for a single incident id",
	MIMEType:    "text/markdown",
}, readIncident)

The handler receives the full URI and reads the id out of it. Two details from that handler are not optional in real code. mcp.ResourceNotFoundError(uri) produces the spec's "Resource not found" error, so a client can tell a missing resource from a broken server and shows the difference to the model. And the id must be validated before it touches the filesystem, or incident://../ssh/id_rsa becomes a file read primitive:

id := strings.TrimPrefix(uri, "incident://")
if id == uri || strings.ContainsAny(id, "/\\") || id == "" {
	return nil, mcp.ResourceNotFoundError(uri)
}

Step 6: prompts as reusable checklists

A prompt is a named template the user can invoke, with declared arguments. This is where you encode the process you keep repeating in chat:

func triagePrompt(_ context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
	service := req.Params.Arguments["service"]
	if service == "" {
		return nil, fmt.Errorf("argument %q is required", "service")
	}
	return &mcp.GetPromptResult{
		Description: "Triage checklist for a failing service",
		Messages: []*mcp.PromptMessage{{
			Role: "user",
			Content: &mcp.TextContent{Text: strings.Join([]string{
				"Triage the service " + service + ".",
				"1. Call check_endpoint on its health URL.",
				"2. Read " + allowlistURI + " and confirm the host is listed.",
				"3. Report status code, latency, and whether the body matched.",
			}, "\n")},
		}},
	}, nil
}

Step 7: stdio locally, HTTP when you need it

The default transport is stdio, which is what desktop clients spawn. Add a flag and the same server becomes a network service:

func main() {
	httpAddr := flag.String("http", "", "serve streamable HTTP on this address instead of stdio, for example :8080")
	flag.Parse()
	if *httpAddr == "" {
		if err := newServer().Run(context.Background(), &mcp.StdioTransport{}); err != nil {
			log.Fatal(err)
		}
		return
	}
	handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return newServer() }, nil)
	log.Printf("ops-mcp listening on %s", *httpAddr)
	if err := http.ListenAndServe(*httpAddr, handler); err != nil {
		log.Fatal(err)
	}
}

mcp.NewStreamableHTTPHandler returns a plain http.Handler, so you can mount it behind Caddy, put it under an auth middleware, or drop it into an existing mux without touching the MCP code. The callback decides which server handles each request; returning a fresh one per request keeps sessions from sharing state, and returning a single shared instance also works since servers handle concurrent sessions.

Step 8: test it without a client

The SDK ships in-memory transports, which means your MCP server is as testable as any other Go package. No subprocess, no inspector, no mocks:

func connect(t *testing.T) *mcp.ClientSession {
	t.Helper()
	ctx := context.Background()
	ct, st := mcp.NewInMemoryTransports()
	serverSession, err := newServer().Connect(ctx, st, nil)
	if err != nil {
		t.Fatalf("server connect: %v", err)
	}
	t.Cleanup(func() { serverSession.Close() })
	client := mcp.NewClient(&mcp.Implementation{Name: "ops-mcp-test", Version: "v0.1.0"}, nil)
	session, err := client.Connect(ctx, ct, nil)
	if err != nil {
		t.Fatalf("client connect: %v", err)
	}
	t.Cleanup(func() { session.Close() })
	return session
}

Wire that helper into a test that points the tool at an httptest server and asserts on the structured result:

func TestCheckEndpoint(t *testing.T) {
	target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.Write([]byte(`{"status":"healthy"}`))
	}))
	defer target.Close()
	session := connect(t)
	res, err := session.CallTool(context.Background(), &mcp.CallToolParams{
		Name: "check_endpoint",
		Arguments: map[string]any{
			"url":             target.URL + "/health",
			"expect_contains": "healthy",
		},
	})
	if err != nil {
		t.Fatalf("CallTool: %v", err)
	}
	if res.IsError {
		t.Fatalf("tool reported an error: %+v", res.Content)
	}
	structured, ok := res.StructuredContent.(map[string]any)
	if !ok {
		t.Fatalf("StructuredContent has type %T", res.StructuredContent)
	}
	if structured["status_code"] != float64(200) {
		t.Errorf("status_code = %v, want 200", structured["status_code"])
	}
}

On a machine with no Go toolchain, run the whole thing in a container and keep the module cache mounted so repeated runs stay fast:

docker run --rm -v "$PWD":/w -v /tmp/gomodcache:/gomodcache \
	-e GOMODCACHE=/gomodcache -w /w golang:1.25-alpine \
	sh -c 'go build -o opsmcp . && go test ./...'

The result from that run:

ok  	opsmcp	0.011s

Step 9: point a real client at it

For a desktop client that spawns the binary over stdio, the config is three lines:

{
  "mcpServers": {
    "ops-mcp": {
      "command": "/usr/local/bin/opsmcp",
      "args": []
    }
  }
}

If you would rather verify it from Go, CommandTransport spawns the binary and talks to it exactly like a client would:

client := mcp.NewClient(&mcp.Implementation{Name: "verify", Version: "v0.1.0"}, nil)
transport := &mcp.CommandTransport{Command: exec.Command("./opsmcp")}
session, err := client.Connect(ctx, transport, nil)
if err != nil {
	log.Fatalf("connect: %v", err)
}
defer session.Close()

for tool, err := range session.Tools(ctx, nil) {
	if err != nil {
		log.Fatalf("list tools: %v", err)
	}
	fmt.Printf("tool %s\n", tool.Name)
}

That is the run I used to check the article, against a throwaway HTTP server on localhost:

tool check_endpoint -> schema {"additionalProperties":false,...}
call result: {"content_type":"application/json","latency_ms":2,"matched":true,"message":"ok","status_code":200,"url":"http://127.0.0.1:38535"}
prompt messages: 1
resource ops://config/allowlist: "api.example.com\nstatus.example.com\n"

Things that will bite you

The default HTTP handler only speaks up to protocol version 2025-11-25. Post an initialize for 2026-07-28 to a stateful StreamableHTTPHandler and you get a JSON-RPC error that names the versions the server does support:

{"jsonrpc":"2.0","id":1,"error":{"code":-32022,"message":"protocol version \"2026-07-28\" is not supported by this server","data":{"supported":["2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"requested":"2026-07-28"}}}

Version 1.8.0 deliberately answers with a structured error instead of a plain-text 400 so the client's renegotiation logic can recover. To serve the newest spec, build the handler with Stateless: true. That mode drops the session entirely, and with it the initialize handshake. There is no initialize call in a sessionless exchange: you send tools/list directly, with the version in per-request metadata and an Mcp-Method header. That took me a few probes to work out, and the answers the server gives along the way are informative:

stateless, initialize only        -> 404 {"error":{"code":-32601,"message":"method not found: \"initialize\""}}
stateless, tools/list + _meta     -> 200 event: message
data: {"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","ttlMs":0,"cacheScope":"public","tools":[{"name":"ping",...}]}}

Note the ttlMs and cacheScope fields in that response. Cache hints on list results are part of the newer spec, and the SDK fills cacheScope: "public" whenever a handler leaves it empty. You can set your own policy with ServerOptions.SetCacheable, which runs after the handler and can adjust the hint per method or per session.

Requests arriving on a localhost address with a non-localhost Host header are rejected with a 403. This is DNS rebinding protection, and it is on by default:

403 Forbidden: invalid Host header "evil.example"

Turning it off is a one-line change (DisableLocalhostProtection), and you should only do that if you have already handled the threat some other way. If you expose a remote server, the auth and oauthex packages in the SDK give you the OAuth primitives the spec expects instead of a shared secret in an environment variable.

Finally, a migration note worth knowing before you copy older examples: roots, sampling, and logging are deprecated as of protocol version 2026-07-28. The SDK keeps them working for at least twelve months, but new servers should not build on them.

Go vs Python vs TypeScript for MCP servers

Go Python TypeScript
Distribution single static binary interpreter plus virtualenv Node runtime plus node_modules
Schema source struct tags, reflection Pydantic models Zod schemas
Cold start milliseconds slower, worse in containers fast once node is up
Best fit servers that wrap existing Go services or ship as one file quick prototypes, data and ML work servers sharing code with a web app

Pick Go when the tool is mostly plumbing around infrastructure you already own, or when the server has to be deployed as one artifact to a machine you do not control. Pick Python when the tool needs your data stack. Pick TypeScript when it lives next to a web frontend.

Next steps

Add Server.AddReceivingMiddleware for request logging, which is the cheapest way to see what a client actually sends. Wrap the HTTP handler in your existing auth middleware before exposing it beyond localhost. And keep an eye on the SDK version table in the README, because the protocol version support matrix moves with each release.

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation