如何用 Go 編寫 MCP Server,讓 LLM Agent 調用工具
Writing an MCP Server in Go: Exposing Tools to LLM Agents
Pradip Parmar 示範如何用 Go 編寫 MCP Server,讓 Cursor 透過工具讀取及更新其 sprint-management 專案的工作項目。文章以型別化工具、輸入驗證、沿用 REST API 的授權檢查及審計記錄,説明如何設計寫入操作;亦交代 stdio 與 streamable HTTP 的適用情境。
The Model Context Protocol (MCP) gives AI clients such as Cursor a standard way to discover and call tools that live in your systems. Instead of writing a custom plugin for every assistant, you write one MCP server, and any MCP-capable client can use it.
I built an MCP server for sprint-management, a Jira-style project management product I built with a Go API, React and PostgreSQL. The server lets Cursor read and update work items directly from the editor. This post covers how to build that kind of server in Go, and the design choices that matter more than the protocol itself. The code is a simplified example, not the product's source.
What an MCP server actually is
At its core, an MCP server is a process that:
- Tells the client which tools it offers (name, description, JSON Schema for input and output).
- Receives tool calls with JSON arguments.
- Returns results the model can read.
It talks JSON-RPC over a transport: usually stdio (the client launches your binary) or streamable HTTP (your server runs as a service). Go is a good fit: a single static binary is easy to point a client at, and struct tags give you schemas almost for free.
Setup
Use the official Go SDK:
go mod init example.com/sprint-mcp
go get github.com/modelcontextprotocol/go-sdk/mcp
A typed read tool
With the official SDK, a tool is a normal Go function with typed input and output. The SDK infers the JSON Schema from your structs, and the jsonschema tag becomes the field description the model sees.
type ListIssuesInput struct {
ProjectKey string `json:"project_key" jsonschema:"project key, for example CORE"`
Status string `json:"status,omitempty" jsonschema:"optional filter: todo, in_progress or done"`
Limit int `json:"limit,omitempty" jsonschema:"max issues to return, 1-50, default 20"`
}
type Issue struct {
Key string `json:"key"`
Title string `json:"title"`
Status string `json:"status"`
Assignee string `json:"assignee,omitempty"`
}
type ListIssuesOutput struct {
Issues []Issue `json:"issues"`
}
func (s *Server) listIssues(ctx context.Context, req *mcp.CallToolRequest, in ListIssuesInput) (
*mcp.CallToolResult, ListIssuesOutput, error,
) {
if in.Limit <= 0 || in.Limit > 50 {
in.Limit = 20
}
if in.Status != "" && !validStatus[in.Status] {
return nil, ListIssuesOutput{}, fmt.Errorf("unknown status %q; use todo, in_progress or done", in.Status)
}
issues, err := s.store.ListIssues(ctx, s.user, in.ProjectKey, in.Status, in.Limit)
if err != nil {
return nil, ListIssuesOutput{}, err
}
return nil, ListIssuesOutput{Issues: issues}, nil
}
Notice the error message. It's written for the model, not for a log file. When the model passes a bad status, a clear message telling it the valid values lets it correct itself on the next call. With the official SDK, an error returned from a tool handler is sent back to the client as a tool result with IsError set, so the model sees it and can retry. The SDK also validates arguments against the generated schema before your handler runs, but schema checks can't know your business rules, so validate those yourself.
A write tool, with guardrails
Read tools are low risk. Write tools are where an agent can do real damage, so they get stricter handling:
type MoveIssueInput struct {
IssueKey string `json:"issue_key" jsonschema:"issue key, for example CORE-123"`
ToStatus string `json:"to_status" jsonschema:"target status: todo, in_progress or done"`
}
type MoveIssueOutput struct {
IssueKey string `json:"issue_key"`
FromStatus string `json:"from_status"`
ToStatus string `json:"to_status"`
}
func (s *Server) moveIssue(ctx context.Context, req *mcp.CallToolRequest, in MoveIssueInput) (
*mcp.CallToolResult, MoveIssueOutput, error,
) {
if !issueKeyRE.MatchString(in.IssueKey) {
return nil, MoveIssueOutput{}, fmt.Errorf("issue_key %q is not in the form ABC-123", in.IssueKey)
}
if !validStatus[in.ToStatus] {
return nil, MoveIssueOutput{}, fmt.Errorf("unknown status %q", in.ToStatus)
}
// Same permission check the REST API uses. The MCP server is just another client.
if !s.authz.Can(s.user, "issue:update", in.IssueKey) {
return nil, MoveIssueOutput{}, fmt.Errorf("user %s may not update %s", s.user.Email, in.IssueKey)
}
from, err := s.store.MoveIssue(ctx, in.IssueKey, in.ToStatus)
if err != nil {
return nil, MoveIssueOutput{}, err
}
s.audit.Log(ctx, s.user, "mcp.move_issue", in.IssueKey, from, in.ToStatus)
return nil, MoveIssueOutput{IssueKey: in.IssueKey, FromStatus: from, ToStatus: in.ToStatus}, nil
}
Four rules I apply to every write tool:
-
Narrow, typed operations.
move_issuewith an enum, notrun_sqlorupdate_issue(fields map[string]any). The smaller the surface, the fewer ways to be wrong. - Reuse your real authorization. The MCP server should call the same domain service and RBAC checks as your REST API. Never give it a privileged database connection that bypasses them.
-
Return what changed. Including
from_statuslets the model (and the human) confirm the effect. - Audit everything. Agent-initiated writes should be traceable separately from human ones.
Wiring up the server
func main() {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) // never stdout with stdio transport
srv, err := NewServer(os.Getenv("SPRINT_API_TOKEN"), logger)
if err != nil {
logger.Error("startup", "err", err)
os.Exit(1)
}
server := mcp.NewServer(&mcp.Implementation{Name: "sprint-management", Version: "v0.1.0"}, nil)
mcp.AddTool(server, &mcp.Tool{
Name: "list_issues",
Description: "List issues in a project, optionally filtered by status. Read-only.",
}, srv.listIssues)
mcp.AddTool(server, &mcp.Tool{
Name: "move_issue",
Description: "Move one issue to a new status. Changes data. Use list_issues first to find the key.",
}, srv.moveIssue)
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
logger.Error("server stopped", "err", err)
os.Exit(1)
}
}
The stdio gotcha: with the stdio transport, stdout is the protocol channel. A stray fmt.Println or a logger writing to stdout corrupts the JSON-RPC stream, and the client fails in confusing ways. Send all logs to stderr.
Descriptions matter more than they look. The model chooses tools from their names and descriptions alone. Say what the tool does, whether it changes data, and which tool to call first.
Connecting it to Cursor
Build the binary and register it in your project's .cursor/mcp.json:
{
"mcpServers": {
"sprint-management": {
"command": "/usr/local/bin/sprint-mcp",
"env": { "SPRINT_API_TOKEN": "your-token" }
}
}
}
Cursor launches the process, lists its tools, and the agent can now call list_issues and move_issue while you work. The token identifies the user, so the agent acts with that user's permissions, nothing more.
When to use HTTP instead of stdio
Stdio is the simplest choice for a developer tool on one machine. If several users or remote agents need the same server, run it as a service with the SDK's streamable HTTP handler:
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
return server
}, nil)
http.Handle("/mcp", authMiddleware(handler))
log.Fatal(http.ListenAndServe(":8080", nil))
Over HTTP, authentication is your job. Put it in middleware, derive the user from the request, and apply the same per-user permission checks inside each tool.
Testing
Tool handlers are plain Go functions, so most tests don't need the protocol at all: call srv.moveIssue directly with a fake store and assert on validation, authorization and audit behaviour. For one or two end-to-end tests, the SDK includes a client (mcp.NewClient) and an in-memory transport, so you can connect a real client to your server in-process and call tools by name.
Design checklist
- One tool per clear intent; avoid "do anything" tools.
- Typed inputs with descriptions and enums; validate anyway.
- Error messages that tell the model how to fix the call.
- Mark write tools clearly and keep them narrow.
- Same auth, RBAC and audit as your REST API.
- Logs to stderr on stdio.
MCP itself is a thin layer. The value of a good MCP server comes from the same things that make a good API: small typed operations, strong permissions and honest errors.
Pradip Parmar is a Go backend and AI agent engineer (writing Go since 2018) who builds MCP servers, RAG systems and LLM agents for businesses, including an MCP server that connects a Jira-style product to Cursor. If you're looking for an MCP server developer in Go, contact him on LinkedIn, Upwork or Contra. Code: GitHub.
來源:DEV Community · dev.to