← Kembali ke Blog

Bikin MCP Server pakai Go dan SDK Resminya

Kamu sudah punya service Go yang jalan. Sekarang kamu ingin agent bisa menyentuhnya: cek endpoint health, baca file config, ikuti checklist triage. Jawaban refleksnya biasanya bikin MCP server pakai Python, yang artinya runtime kedua di server, virtualenv, dependency pip yang dipin, plus langkah packaging untuk sesuatu yang ujungnya cuma satu HTTP call dan satu respons JSON.

SDK Go resmi untuk Model Context Protocol sudah ada (v1.8.0, dikelola bersama Google), dan itu mengubah pekerjaan tadi jadi satu binary statis. Tanpa interpreter, tanpa node_modules, tanpa venv. Compile sekali, copy file-nya ke mana saja.

Di bawah ini MCP server lengkap pakai Go dengan satu tool, satu resource, satu resource template, dan satu prompt. Default-nya lewat stdio, dan bisa jalan lewat streamable HTTP kalau kamu kasih flag. Semua potongan kode di bawah sudah saya compile dan jalankan saat menulis artikel ini, jadi output perintah yang ditempel di sini datang dari run nyata.

Prasyarat

  • Go 1.25 atau lebih baru. File go.mod SDK mendeklarasikan go 1.25.0, dan rilis SDK baru hanya menargetkan versi Go yang masih didukung.
  • MCP client untuk menunjuk ke binary-nya. Claude Desktop paling gampang, tapi apa pun yang bicara stdio bisa dipakai.
  • Beberapa menit untuk build pertama, sebagian besar habis di download modul.

Step 1: kerangka project

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

Step 2: server paling sederhana yang jalan

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)
	}
}

Tiga hal penting di sini. mcp.NewServer menerima Implementation (nama dan versi) yang akan ditampilkan client di daftar tool. server.Run memblok sampai client memutus koneksi. Dan karena stdout adalah jalur protokolnya, semua yang kamu print harus ke stderr, kalau tidak aliran datanya rusak.

Step 3: struct kamu adalah schema-nya

mcp.AddTool adalah fungsi generic, dan di situ sebagian besar nilainya. Dia menerima definisi tool dan fungsi Go dengan bentuk seperti ini:

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

Kalau Tool.InputSchema kamu biarkan kosong, SDK menurunkannya dari In. Sama juga Tool.OutputSchema dari Out, kecuali Out bertipe any. Setelah itu dia memvalidasi argumen masuk terhadap schema tadi, mengubahnya jadi struct kamu, menjalankan fungsi, lalu mengubah nilai baliknya jadi StructuredContent dan Content, dan menandai IsError kalau kamu balikin error biasa. Server.AddTool level rendah juga tersedia, tapi semua pembukuan itu jadi tanggung jawab kamu.

Aturan penurunannya perlu kamu tahu sebelum menulis tool pertama:

  • In harus struct atau map. Tipe seperti string biasa akan bikin panic saat registrasi.
  • Field tanpa omitempty dianggap wajib.
  • Schema hasil generate memakai additionalProperties: false, jadi client yang mengirim argumen tambahan ditolak, bukan diabaikan diam-diam.
  • Struct tag jsonschema jadi deskripsi argumen yang dibaca model. Perlakukan itu sebagai teks prompt, bukan hiasan.

Ini schema yang SDK hasilkan untuk tool di Step 4, hasil dump langsung dari 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"}

Cuma url yang wajib, karena dua field lain membawa omitempty.

Step 4: tool yang layak dipakai

Hello world cuma membuktikan kabelnya nyambung, bukan nilai gunanya. Ini tool yang memberi tahu apakah deploy kamu benar-benar landing:

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
}

Dua keputusan desain di sini disengaja. Nilai balik bertipe CheckEndpointOutput memberi model hasil terstruktur yang bisa dia nalar, dan SDK tetap mengisi konten teks supaya client yang mengabaikan structured output tidak rusak. Lalu mengembalikan error alih-alih mengisi IsError: true sendiri bikin request gagal terbaca sebagai tool call gagal, yang memang itu yang harus dilihat model.

Step 5: resource dan resource template

Tool untuk aksi. Resource untuk data read-only yang bisa ditarik client lewat URI, jadi data lookup tidak perlu masuk ke argumen tool.

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
}

Untuk koleksi, daftarkan template, bukan satu 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)

Handler menerima URI utuh dan mengambil id dari situ. Dua detail dari handler itu bukan opsional di kode sungguhan. mcp.ResourceNotFoundError(uri) menghasilkan error "Resource not found" sesuai spec, jadi client bisa membedakan resource yang hilang dari server yang rusak. Dan id harus divalidasi sebelum menyentuh filesystem, kalau tidak incident://../ssh/id_rsa berubah jadi alat baca file sembarang:

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

Step 6: prompt sebagai checklist yang bisa dipakai ulang

Prompt adalah template bernama yang bisa dipanggil user, lengkap dengan argumen yang dideklarasikan. Di sinilah kamu menyimpan proses yang selalu kamu ulang di 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 buat lokal, HTTP kalau perlu

Transport default-nya stdio, dan itu yang di-spawn client desktop. Tambah satu flag, server yang sama berubah jadi layanan jaringan:

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 mengembalikan http.Handler biasa, jadi kamu bisa menaruhnya di belakang Caddy, membungkusnya dengan middleware auth, atau menyelipkannya ke mux yang sudah ada tanpa menyentuh kode MCP-nya. Callback di situ yang menentukan server mana yang menangani tiap request; mengembalikan instance baru per request mencegah session berbagi state, dan mengembalikan satu instance bersama juga jalan karena server menangani session secara concurrent.

Step 8: tes tanpa client

SDK menyediakan transport in-memory, jadi MCP server kamu bisa dites seperti package Go biasa. Tanpa subprocess, tanpa inspector, tanpa mock:

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
}

Sambungkan helper itu ke test yang mengarahkan tool ke server httptest dan memeriksa hasil terstrukturnya:

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"])
	}
}

Kalau mesinmu tidak punya toolchain Go, jalankan semuanya di container dan mount module cache-nya supaya run berikutnya tidak download ulang:

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 ./...'

Hasil dari run itu:

ok  	opsmcp	0.011s

Step 9: arahkan client sungguhan ke situ

Untuk client desktop yang men-spawn binary lewat stdio, config-nya tiga baris:

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

Kalau kamu lebih suka memverifikasi dari Go, CommandTransport men-spawn binary-nya dan bicara persis seperti client:

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)
}

Itu run yang saya pakai untuk mengecek artikel ini, melawan HTTP server sekali pakai di 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"

Hal-hal yang bakal bikin kamu kesandung

HTTP handler default cuma bicara sampai versi protokol 2025-11-25. Kirim initialize untuk 2026-07-28 ke StreamableHTTPHandler stateful, dan kamu dapat error JSON-RPC yang menyebut versi mana yang didukung server:

{"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"}}}

Versi 1.8.0 sengaja menjawab dengan error terstruktur, bukan 400 teks polos, supaya logika renegosiasi di client bisa pulih. Untuk melayani spec terbaru, bikin handler-nya dengan Stateless: true. Mode itu membuang session sepenuhnya, dan ikut membuang handshake initialize. Di pertukaran tanpa session tidak ada panggilan initialize: kamu kirim tools/list langsung, dengan versi di metadata per-request plus header Mcp-Method. Ini butuh beberapa percobaan buat saya pastikan, dan jawaban server di sepanjang jalan itu informatif:

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",...}]}}

Perhatikan field ttlMs dan cacheScope di respons itu. Petunjuk cache untuk hasil list adalah bagian dari spec yang lebih baru, dan SDK mengisi cacheScope: "public" kapan pun handler membiarkannya kosong. Kamu bisa menentukan kebijakan sendiri lewat ServerOptions.SetCacheable, yang jalan setelah handler dan bisa menyesuaikan hint per method atau per session.

Request yang datang ke alamat localhost tapi membawa header Host non-localhost ditolak dengan 403. Ini proteksi DNS rebinding, dan aktif secara default:

403 Forbidden: invalid Host header "evil.example"

Mematikannya cuma satu baris (DisableLocalhostProtection), dan sebaiknya kamu lakukan hanya kalau ancamannya sudah kamu tangani dengan cara lain. Kalau kamu expose server remote, package auth dan oauthex di SDK memberi kamu primitif OAuth yang diminta spec, bukan shared secret di environment variable.

Satu catatan migrasi yang perlu diketahui sebelum menyalin contoh lama: roots, sampling, dan logging sudah deprecated sejak versi protokol 2026-07-28. SDK tetap menjalankannya minimal dua belas bulan, tapi server baru sebaiknya tidak membangun di atasnya.

Go vs Python vs TypeScript buat MCP server

Go Python TypeScript
Distribusi satu binary statis interpreter plus virtualenv runtime Node plus node_modules
Sumber schema struct tag, reflection model Pydantic schema Zod
Cold start milidetik lebih lambat, makin terasa di container cepat begitu Node jalan
Paling cocok server yang membungkus service Go yang sudah ada, atau dikirim sebagai satu file prototipe cepat, kerjaan data dan ML server yang berbagi kode dengan web app

Pilih Go kalau tool-nya cuma plumbing di sekitar infrastruktur yang sudah kamu punya, atau kalau server harus dikirim sebagai satu artefak ke mesin yang tidak kamu kuasai. Pilih Python kalau tool butuh data stack kamu. Pilih TypeScript kalau dia hidup bersebelahan dengan frontend web.

Langkah lanjut

Tambahkan Server.AddReceivingMiddleware untuk logging request, itu cara termurah buat melihat apa yang sebenarnya dikirim client. Bungkus HTTP handler-nya dengan middleware auth kamu sebelum di-expose ke luar localhost. Dan pantau tabel versi SDK di README, karena matriks dukungan versi protokolnya berubah tiap rilis.

Butuh Bantuan Implementasi?

Saya membantu tim mendesain dan membangun infrastruktur cloud scalable, pipeline DevOps, dan sistem production-grade.

Konsultasi Gratis