The Model Context Protocol (MCP) is a standardized interface for connecting AI models to external tools and data sources. Building an MCP server in Go offers distinct advantages for production deployments, combining Go's concurrency model and single-binary compilation with a protocol designed to end the fragmented, vendor-specific integration pattern that has held back AI tooling. This tutorial walks through the complete process of constructing a production-ready MCP server, from initial scaffolding to graceful shutdown, using the mcp-go SDK.
How to Build an MCP Server in Go
- Initialize a Go module and pin the
mcp-goSDK to a specific version withgo get. - Create the server entry point using
server.NewMCPServerwith capability options for tools, resources, and prompts. - Define tool schemas with
mcp.NewTool, specifying input parameters, types, and descriptions. - Implement tool handlers that validate inputs, call external APIs with timeouts, and return results via
CallToolResult. - Register resources and prompt templates to expose read-only data and reusable interaction patterns.
- Configure structured logging to stderr and add input validation with allowlist patterns for security.
- Add graceful shutdown using OS signal capture and context cancellation instead of
os.Exit. - Start the server on stdio transport for local clients or HTTP/SSE transport for remote deployment.
Table of Contents
- What Is the Model Context Protocol (MCP) and Why Does It Matter?
- Why Build Your MCP Server in Go?
- Prerequisites and Project Setup
- Building Your First MCP Server with stdio Transport
- Adding Resources and Prompts
- Building a Real-World Tool: GitHub Issue Lookup
- Production Hardening
- Switching to HTTP/SSE Transport for Remote Deployment
- Production-Ready Implementation Checklist
- Complete Server Code
- Wrapping Up and Next Steps
What Is the Model Context Protocol (MCP) and Why Does It Matter?
The Problem MCP Solves
Large language models are isolated. Without external integrations, they cannot access real-time data, interact with APIs, or perform actions in the world. Before MCP, connecting an AI model to a tool or data source required building vendor-specific integrations for each combination of model and service. This created an M×N problem: M models times N tools, each requiring a custom connector.
MCP eliminates this by defining a single, open protocol that any AI model can use to communicate with any compatible server. The protocol is open-source. Clients across the ecosystem have adopted it, including Claude Desktop, VS Code with GitHub Copilot, Cursor, and other MCP-compatible clients.
MCP Architecture at a Glance
MCP follows a client-server architecture with three distinct roles. Hosts are the AI applications (like Claude Desktop) that initiate connections. Clients are protocol-level connectors maintained within the host, each establishing a one-to-one session with a server. Servers expose capabilities to the AI model through three core primitives.
Tools are executable functions the model can invoke, roughly analogous to POST endpoints. Read-only data access comes through Resources, which are identified by URIs and behave like GET endpoints. Prompts are reusable template messages with arguments that structure how the model interacts with the server's capabilities.
Communication happens over one of several transport mechanisms: stdio (standard input/output) for local processes, HTTP with Server-Sent Events (SSE), or the newer Streamable HTTP transport for stateless remote deployments.
MCP eliminates this by defining a single, open protocol that any AI model can use to communicate with any compatible server.
Why Build Your MCP Server in Go?
Go's Strengths for MCP Servers
Go's goroutine-based concurrency model maps naturally to MCP's requirement for handling multiple simultaneous client connections and tool invocations. A single MCP server may field concurrent requests from several AI sessions. Goroutines start with a stack of roughly 2-8 KB, compared to the 1-8 MB typical of OS thread stacks, so a server can sustain thousands of concurrent handlers without significant memory pressure.
The single-binary compilation model simplifies deployment considerably. An MCP server built in Go compiles to a standalone executable with no runtime dependencies, making it easy to distribute, containerize, or reference from a client's configuration file. Go's standard library provides HTTP primitives with configurable timeouts, connection pooling, and HTTP/2 support, which the mcp-go SDK builds upon for MCP's transport layer. Go's type system enforces handler interface compliance at compile time; tool input schema validation occurs at runtime within the SDK.
Prerequisites and Project Setup
What You'll Need
This tutorial requires Go 1.21 or later installed and available on the system PATH (verify with go version). You should know Go modules, structs, and interface patterns. Any editor with Go support will work, though VS Code with the Go extension or GoLand provides jump-to-definition for SDK types, which helps when exploring the API surface.
You need an MCP-compatible client for testing. Claude Desktop requires only a JSON config entry pointing to the binary plus a restart, making it the fastest option for local stdio servers. VS Code with GitHub Copilot's agent mode also supports MCP server connections.
Initializing the Project
mkdir mcp-go-server
cd mcp-go-server
go mod init github.com/yourname/mcp-go-server
go get github.com/mark3labs/mcp-go@v0.26.0
Note: Pin the SDK version explicitly. Replace v0.26.0 with the current stable release listed at github.com/mark3labs/mcp-go/releases. Running go get without a version tag fetches the latest, which may introduce breaking changes after this tutorial was published.
The mcp-go SDK by Mark3Labs is a well-adopted Go implementation of the MCP specification. It provides server construction primitives, transport handlers, and helper functions for defining tool schemas.
Building Your First MCP Server with stdio Transport
Creating the Server Entry Point
The minimal MCP server requires initialization with a name and version, then startup on a transport. The stdio transport communicates over standard input and output, which is the expected mechanism when an MCP client launches the server as a subprocess.
package main
import (
"fmt"
"os"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
func main() {
s := server.NewMCPServer(
"my-mcp-server",
"1.0.0",
server.WithToolCapabilities(true), // advertise tool-list-change notifications
server.WithResourceCapabilities(true, false), // listResources=true, subscribe=false
server.WithPromptCapabilities(true),
)
if err := server.ServeStdio(s); err != nil {
fmt.Fprintf(os.Stderr, "Server error: %v
", err)
os.Exit(1)
}
}
The server.NewMCPServer function takes the server name and version as its first two arguments. The option functions declare which MCP capabilities this server supports. WithToolCapabilities(true) advertises that the server can notify clients when its tool list changes. WithResourceCapabilities(true, false) enables resource listing but not resource subscriptions. The server.ServeStdio call blocks, reading JSON-RPC messages from stdin and writing responses to stdout.
Registering Your First Tool
Tools are the primary mechanism through which an AI model takes action via an MCP server. Each tool requires a schema defining its inputs and a handler function that executes the logic.
package main
import (
"context"
"fmt"
"os"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
func main() {
s := server.NewMCPServer(
"my-mcp-server",
"1.0.0",
server.WithToolCapabilities(true),
)
helloTool := mcp.NewTool("hello",
mcp.WithDescription("Greets a user by name"),
mcp.WithString("name",
mcp.Required(),
mcp.Description("The name of the person to greet"),
),
)
s.AddTool(helloTool, helloHandler)
if err := server.ServeStdio(s); err != nil {
fmt.Fprintf(os.Stderr, "Server error: %v
", err)
os.Exit(1)
}
}
func helloHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
name, ok := request.Params.Arguments["name"].(string)
if !ok || name == "" {
return mcp.NewToolResultError("name parameter is required"), nil
}
greeting := fmt.Sprintf("Hello, %s! Welcome to the MCP server.", name)
return mcp.NewToolResultText(greeting), nil
}
The mcp.NewTool function creates a tool definition with a JSON Schema for its inputs. Helper functions like mcp.WithString, mcp.Required(), and mcp.Description() build the schema declaratively. The handler receives a CallToolRequest and returns a *mcp.CallToolResult. Note that the "context" import is required for the handler's context.Context parameter.
Testing with an MCP Client
To test with Claude Desktop, add a server entry to the configuration file. On macOS, find it at ~/Library/Application Support/Claude/claude_desktop_config.json. On Windows, it lives at %APPDATA%\Claude\claude_desktop_config.json. On Linux, it lives at ~/.config/Claude/claude_desktop_config.json.
{
"mcpServers": {
"my-mcp-server": {
"command": "/absolute/path/to/mcp-go-server"
}
}
}
Compile the binary first with go build -o mcp-go-server . and use the absolute path to the resulting executable. After restarting Claude Desktop, the tool should appear in the available tools list. Asking Claude to "say hello to Alice" should trigger the tool invocation. If the tool doesn't appear, check Claude Desktop's MCP log output for configuration or path errors.
Adding Resources and Prompts
Exposing Resources
MCP resources represent read-only data that the AI model can retrieve. Each resource is identified by a URI and returns content with a specified MIME type. Resources are suitable for exposing configuration data, documentation, or database records.
s.AddResource(mcp.NewResource(
"config://app/settings",
"Application Settings",
mcp.WithResourceDescription("Current application configuration"),
mcp.WithMIMEType("application/json"),
), func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
settings := `{"debug": false, "max_connections": 100, "region": "us-east-1"}`
return []mcp.ResourceContents{
mcp.NewTextResourceContents(request.Params.URI, "application/json", settings),
}, nil
})
The resource handler returns a slice of ResourceContents, allowing a single URI to return multiple pieces of content. For dynamic resources where the URI contains variable segments, mcp.NewResourceTemplate can be used with URI templates following RFC 6570 syntax.
Defining Prompt Templates
Prompts provide reusable message templates that guide how the AI model interacts with the server. They accept arguments and return structured message sequences.
s.AddPrompt(mcp.NewPrompt("summarize_issue",
mcp.WithPromptDescription("Summarize a GitHub issue for a status report"),
mcp.WithArgument("issue_title",
mcp.ArgumentDescription("The title of the issue"),
mcp.RequiredArgument(),
),
mcp.WithArgument("issue_body",
mcp.ArgumentDescription("The body content of the issue"),
),
), func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
title, ok := request.Params.Arguments["issue_title"].(string)
if !ok || title == "" {
return nil, fmt.Errorf("issue_title is required and must be a non-empty string")
}
body, _ := request.Params.Arguments["issue_body"].(string)
return &mcp.GetPromptResult{
Description: "Summarize a GitHub issue",
Messages: []mcp.PromptMessage{
{
Role: mcp.RoleUser,
Content: mcp.TextContent{
Type: "text",
Text: fmt.Sprintf("Summarize this GitHub issue for a status report.
Title: %s
Body: %s", title, body),
},
},
},
}, nil
})
The prompt handler returns a GetPromptResult containing a sequence of messages with defined roles. This allows servers to provide structured interaction patterns that clients can present to users or inject into model conversations.
Building a Real-World Tool: GitHub Issue Lookup
Designing the Tool Schema
A practical MCP tool demonstrates the integration pattern for external APIs. A GitHub issue lookup tool needs three parameters: the repository owner, repository name, and issue number.
Implementing the Tool Handler
import "regexp"
// compile once at package level
var validIdentifier = regexp.MustCompile(`^[a-zA-Z0-9_.-]{1,100}$`)
const maxBodyBytes = 1 << 20 // 1 MiB
var githubHTTPClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
ResponseHeaderTimeout: 5 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
MaxIdleConnsPerHost: 10,
},
}
func githubIssueTool() (mcp.Tool, server.ToolHandlerFunc) {
tool := mcp.NewTool("github_issue_lookup",
mcp.WithDescription("Look up a GitHub issue by owner, repo, and issue number"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")),
mcp.WithNumber("issue_number", mcp.Required(), mcp.Description("Issue number")),
)
handler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, _ := request.Params.Arguments["owner"].(string)
repo, _ := request.Params.Arguments["repo"].(string)
issueNum, _ := request.Params.Arguments["issue_number"].(float64)
if owner == "" || repo == "" || issueNum < 1 {
return mcp.NewToolResultError("owner, repo, and issue_number are required (issue_number must be >= 1)"), nil
}
// Validate owner/repo against a strict allowlist pattern
if !validIdentifier.MatchString(owner) || !validIdentifier.MatchString(repo) {
return mcp.NewToolResultError("owner and repo must contain only alphanumeric characters, hyphens, underscores, or dots (max 100 chars)"), nil
}
slog.Info("tool invoked", "tool", "github_issue_lookup", "owner", owner, "repo", repo, "issue_number", int(issueNum))
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues/%d", owner, repo, int(issueNum))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to build request: %v", err)), nil
}
req.Header.Set("User-Agent", "mcp-go-server/1.0.0")
req.Header.Set("Accept", "application/vnd.github+json")
// Use a GITHUB_TOKEN if available to avoid rate limits
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := githubHTTPClient.Do(req)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("API request failed: %v", err)), nil
}
defer resp.Body.Close()
// Rate limiting: GitHub returns 403 for primary rate limits (with X-RateLimit-Remaining: 0)
// and 429 for secondary/abuse rate limits.
if resp.StatusCode == http.StatusForbidden {
remaining := resp.Header.Get("X-RateLimit-Remaining")
if remaining == "0" {
return &mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent{
Type: "text",
Text: "GitHub API primary rate limit exceeded. Set GITHUB_TOKEN to raise limits.",
}},
IsError: true,
}, nil
}
return mcp.NewToolResultError("GitHub API returned 403 Forbidden (check token permissions or repo visibility)"), nil
}
if resp.StatusCode == http.StatusTooManyRequests {
return &mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent{
Type: "text",
Text: "GitHub API secondary rate limit hit. Wait before retrying.",
}},
IsError: true,
}, nil
}
if resp.StatusCode != http.StatusOK {
return mcp.NewToolResultError(fmt.Sprintf("GitHub API returned status %d", resp.StatusCode)), nil
}
var issue struct {
Title string `json:"title"`
State string `json:"state"`
Body string `json:"body"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
}
limitedBody := io.LimitReader(resp.Body, maxBodyBytes)
if err := json.NewDecoder(limitedBody).Decode(&issue); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to parse response: %v", err)), nil
}
labelNames := make([]string, len(issue.Labels))
for i, l := range issue.Labels {
labelNames[i] = l.Name
}
result := fmt.Sprintf("Title: %s
State: %s
Labels: %s
%s",
issue.Title, issue.State, strings.Join(labelNames, ", "), issue.Body)
return mcp.NewToolResultText(result), nil
}
return tool, handler
}
Note that issue_number arrives as float64 because JSON numbers are unmarshaled to float64 in Go's interface{} type system. This is a common source of bugs when working with JSON-RPC in Go.
Important: GitHub's API requires a User-Agent header on all requests. Calls without one may receive an HTTP 403 response. The handler above sets a User-Agent and also supports an optional GITHUB_TOKEN environment variable to raise rate limits from 60 requests/hour (unauthenticated) to 5,000 requests/hour.
Handling Errors Gracefully
The tool handler above already includes rate-limit detection, but the pattern is worth highlighting explicitly:
// Rate limiting: GitHub returns 403 for primary rate limits (with X-RateLimit-Remaining: 0)
// and 429 (http.StatusTooManyRequests) for secondary/abuse rate limits.
if resp.StatusCode == http.StatusForbidden {
remaining := resp.Header.Get("X-RateLimit-Remaining")
if remaining == "0" {
return &mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent{
Type: "text",
Text: "GitHub API primary rate limit exceeded. Set GITHUB_TOKEN to raise limits.",
}},
IsError: true,
}, nil
}
return mcp.NewToolResultError("GitHub API returned 403 Forbidden (check token permissions or repo visibility)"), nil
}
if resp.StatusCode == http.StatusTooManyRequests {
return &mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent{
Type: "text",
Text: "GitHub API secondary rate limit hit. Wait before retrying.",
}},
IsError: true,
}, nil
}
MCP error reporting uses the IsError: true field on CallToolResult rather than returning a Go error. Returning a Go error from the handler signals a protocol-level failure, while IsError: true signals an application-level error that the model can reason about and potentially retry or explain to the user.
Returning a Go error from the handler signals a protocol-level failure, while
IsError: truesignals an application-level error that the model can reason about and potentially retry or explain to the user.
Production Hardening
Structured Logging
When using stdio transport, stdout is exclusively reserved for MCP protocol messages. Any log output written to stdout will corrupt the JSON-RPC stream and break communication. Direct all logging to stderr or a file.
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
// Inside a tool handler:
slog.Info("tool invoked", "tool", "github_issue_lookup", "owner", owner, "repo", repo)
Go's slog package, available since Go 1.21, provides structured logging with JSON output that integrates well with log aggregation systems. Writing to stderr keeps the stdio transport clean while providing full observability.
Input Validation and Security
All tool inputs must be validated before use. String parameters destined for URL construction should be validated against a strict allowlist pattern (e.g., ^[a-zA-Z0-9_.-]{1,100}$) rather than a denylist of specific characters, as denylist approaches miss URL-encoded variants and other bypass techniques. When tool outputs include content retrieved from external sources, that content could contain prompt injection attempts. Servers should not attempt to sanitize this content, as that is the model's responsibility, but should be aware that tool outputs flow directly into the model's context window.
Add rate limiting (e.g., a per-client token bucket) for any HTTP-transported server serving multiple clients. The mcp-go SDK does not provide built-in rate limiting (verify against the SDK version you are using), so middleware or external solutions are needed.
Graceful Shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
select {
case sig := <-sigChan:
slog.Info("shutdown signal received", "signal", sig.String())
cancel()
case <-ctx.Done():
// server exited normally; goroutine exits cleanly
}
}()
This pattern captures interrupt and termination signals and cancels the context, which should be threaded through to active tool handlers to enable cooperative cancellation of in-flight requests. The select on both sigChan and ctx.Done() ensures the goroutine exits cleanly even if the server shuts down for a reason other than an OS signal. After cancel() is called, the ServeStdio call (or HTTP server) should detect the cancelled context and return, allowing deferred cleanup functions in main to execute normally.
Warning: Avoid calling os.Exit() inside the signal goroutine. os.Exit terminates the process immediately, bypassing all deferred functions, meaning database connections, file handles, and log buffers will not be cleaned up. Instead, cancel the context and let the server shut down through its normal return path.
Switching to HTTP/SSE Transport for Remote Deployment
When to Use HTTP vs. stdio
The stdio transport is appropriate when the MCP client spawns the server as a local subprocess. This is the standard mode for Claude Desktop and most IDE integrations. For remote deployments, multi-client access, or cloud-hosted servers, HTTP-based transports are required.
Note: HTTP and stdio transports are mutually exclusive startup paths. Use one or the other in a given binary invocation, not both.
// SSE transport (HTTP + Server-Sent Events) — for local development:
sseServer := server.NewSSEServer(s, server.WithBaseURL("http://localhost:8080"))
if err := sseServer.Start(":8080"); err != nil {
slog.Error("SSE server failed", "error", err)
}
// Streamable HTTP transport (stateless, newer spec)
httpServer := server.NewStreamableHTTPServer(s)
if err := httpServer.Start(":8080"); err != nil {
slog.Error("HTTP server failed", "error", err)
}
For remote or production deployments, use your TLS-terminated HTTPS URL as the base URL (e.g., server.WithBaseURL("https://your-server.example.com")). TLS termination can be handled by a reverse proxy such as Nginx or Caddy, as discussed in Next Steps below.
The SSE transport maintains a persistent connection for server-to-client notifications, while Streamable HTTP supports stateless request-response patterns suitable for serverless and load-balanced deployments. The server logic remains identical across transports; only the startup call changes.
Production-Ready Implementation Checklist
Before deploying an MCP server, verify each of these items:
- Go module initialized with a pinned
mcp-goversion ingo.mod - Server name and version set in
ServerInfoviaNewMCPServer - All tools have complete input schemas with descriptions for every parameter
- Tool handlers validate all inputs before use, using allowlist patterns for URL-destined strings
- HTTP requests to external APIs include a
User-Agentheader and a timeout - Response bodies from external APIs are size-limited (e.g., via
io.LimitReader) - Report errors via
IsError: trueonCallToolResult, never panics - Direct logging to stderr (stdio transport) or a structured logger (HTTP transport)
- Graceful shutdown on OS signals implemented via context cancellation (not
os.Exit) - Resources use appropriate MIME types
- If deploying via HTTP, add rate limiting at the server or reverse-proxy level
- Transport selected based on deployment target: stdio for local, HTTP/SSE for remote
- Tested with at least one MCP client (Claude Desktop or VS Code)
- Build and test the binary on target OS/architecture via
GOOSandGOARCH
Complete Server Code
The following consolidated implementation combines all elements covered in this tutorial into a single, copy-paste-ready main.go file.
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/signal"
"regexp"
"strings"
"syscall"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// compile once at package level
var validIdentifier = regexp.MustCompile(`^[a-zA-Z0-9_.-]{1,100}$`)
const maxBodyBytes = 1 << 20 // 1 MiB
var githubHTTPClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
ResponseHeaderTimeout: 5 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
MaxIdleConnsPerHost: 10,
},
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
s := server.NewMCPServer(
"go-mcp-production-server",
"1.0.0",
server.WithToolCapabilities(true), // advertise tool-list-change notifications
server.WithResourceCapabilities(true, false), // listResources=true, subscribe=false
server.WithPromptCapabilities(true),
)
// Register GitHub issue lookup tool
tool, handler := githubIssueTool()
s.AddTool(tool, handler)
// Register resource
s.AddResource(mcp.NewResource(
"config://app/settings",
"Application Settings",
mcp.WithResourceDescription("Current application configuration"),
mcp.WithMIMEType("application/json"),
), func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
return []mcp.ResourceContents{
mcp.NewTextResourceContents(request.Params.URI, "application/json",
`{"debug": false, "max_connections": 100}`),
}, nil
})
// Register prompt
s.AddPrompt(mcp.NewPrompt("summarize_issue",
mcp.WithPromptDescription("Summarize a GitHub issue"),
mcp.WithArgument("issue_title",
mcp.ArgumentDescription("The title of the issue"),
mcp.RequiredArgument(),
),
mcp.WithArgument("issue_body",
mcp.ArgumentDescription("The body content of the issue"),
),
), func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
title, ok := request.Params.Arguments["issue_title"].(string)
if !ok || title == "" {
return nil, fmt.Errorf("issue_title is required and must be a non-empty string")
}
body, _ := request.Params.Arguments["issue_body"].(string)
return &mcp.GetPromptResult{
Messages: []mcp.PromptMessage{{
Role: mcp.RoleUser,
Content: mcp.TextContent{
Type: "text",
Text: fmt.Sprintf("Summarize: %s
%s", title, body),
},
}},
}, nil
})
// Graceful shutdown via context cancellation
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
select {
case sig := <-sigChan:
slog.Info("shutdown signal received", "signal", sig.String())
cancel()
case <-ctx.Done():
// server exited normally; goroutine exits cleanly
}
}()
slog.Info("starting MCP server", "transport", "stdio")
if err := server.ServeStdio(s, server.WithContext(ctx)); err != nil {
slog.Error("server error", "error", err)
// deferred cancel() and any other deferred cleanups will run normally
return
}
}
func githubIssueTool() (mcp.Tool, server.ToolHandlerFunc) {
tool := mcp.NewTool("github_issue_lookup",
mcp.WithDescription("Look up a GitHub issue"),
mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")),
mcp.WithNumber("issue_number", mcp.Required(), mcp.Description("Issue number")),
)
return tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, _ := req.Params.Arguments["owner"].(string)
repo, _ := req.Params.Arguments["repo"].(string)
num, _ := req.Params.Arguments["issue_number"].(float64)
if owner == "" || repo == "" || num < 1 {
return mcp.NewToolResultError("missing required parameters (issue_number must be >= 1)"), nil
}
// Validate owner/repo against a strict allowlist pattern
if !validIdentifier.MatchString(owner) || !validIdentifier.MatchString(repo) {
return mcp.NewToolResultError("owner and repo must contain only alphanumeric characters, hyphens, underscores, or dots (max 100 chars)"), nil
}
slog.Info("tool invoked", "tool", "github_issue_lookup", "owner", owner, "repo", repo, "issue_number", int(num))
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues/%d", owner, repo, int(num))
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to build request: %v", err)), nil
}
httpReq.Header.Set("User-Agent", "mcp-go-server/1.0.0")
httpReq.Header.Set("Accept", "application/vnd.github+json")
// Use a GITHUB_TOKEN if available to avoid rate limits
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
httpReq.Header.Set("Authorization", "Bearer "+token)
}
resp, err := githubHTTPClient.Do(httpReq)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("request failed: %v", err)), nil
}
defer resp.Body.Close()
// GitHub returns 403 for primary rate limits (with X-RateLimit-Remaining: 0)
// and 429 for secondary/abuse limits
if resp.StatusCode == http.StatusForbidden {
remaining := resp.Header.Get("X-RateLimit-Remaining")
if remaining == "0" {
return &mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent{
Type: "text",
Text: "GitHub API primary rate limit exceeded. Set GITHUB_TOKEN to raise limits.",
}},
IsError: true,
}, nil
}
return mcp.NewToolResultError("GitHub API returned 403 Forbidden (check token permissions or repo visibility)"), nil
}
if resp.StatusCode == http.StatusTooManyRequests {
return &mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent{
Type: "text",
Text: "GitHub API secondary rate limit hit. Wait before retrying.",
}},
IsError: true,
}, nil
}
if resp.StatusCode != http.StatusOK {
return &mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("API error: %d", resp.StatusCode)}},
IsError: true,
}, nil
}
var issue struct {
Title string `json:"title"`
State string `json:"state"`
Body string `json:"body"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
}
limitedBody := io.LimitReader(resp.Body, maxBodyBytes)
if err := json.NewDecoder(limitedBody).Decode(&issue); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to parse response: %v", err)), nil
}
labels := make([]string, len(issue.Labels))
for i, l := range issue.Labels {
labels[i] = l.Name
}
return mcp.NewToolResultText(fmt.Sprintf("Title: %s
State: %s
Labels: %s
%s",
issue.Title, issue.State, strings.Join(labels, ", "), issue.Body)), nil
}
}
Note on server.WithContext(ctx): If your version of the mcp-go SDK does not support passing a context to ServeStdio, the signal goroutine can call os.Exit(0) as a fallback, but be aware that this bypasses deferred cleanup. Check the SDK documentation for your pinned version.
Avoid calling
os.Exit()inside the signal goroutine.os.Exitterminates the process immediately, bypassing all deferred functions, meaning database connections, file handles, and log buffers will not be cleaned up.
Wrapping Up and Next Steps
This tutorial showed how to construct a complete MCP server in Go, from project scaffolding through tool registration, resource exposure, prompt templates, and production hardening with structured logging and graceful shutdown. The server supports both stdio and HTTP/SSE transports with minimal code changes between them.
Natural next steps include adding authentication for HTTP-transported servers (the MCP specification supports OAuth 2.0 flows), deploying behind a reverse proxy like Nginx or Caddy for TLS termination, and implementing Streamable HTTP transport for stateless cloud deployments. The full MCP specification is available at spec.modelcontextprotocol.io. The mcp-go SDK repository at github.com/mark3labs/mcp-go contains additional examples and transport options. For discovering existing MCP servers and patterns, the registries at mcp.so and Smithery provide searchable catalogs of community implementations.

