MCP Server 这个词,最近几个月在 AI 工程圈里出现的频率高得吓人。我自己的体感是:去年聊 AI 还在说怎么调 prompt,今年上半年已经开始聊怎么把 Agent 接上外部工具,到了现在,几乎每一个做 Agent 落地的团队,都在讨论要不要自己写一个 MCP Server。这篇文章就是我从零搭一个 Go 版 MCP Server 的全过程记录,包括协议设计到底在解决什么问题、SDK 怎么选、代码怎么写、跟客户端联调时有哪些坑,以及我踩过之后总结出来的一套比较稳的实践方式。如果你正好在评估"用 Go 构建 MCP Server"这件事,或者已经决定上手但想少走弯路,这篇应该能帮上忙。
1. MCP 到底解决了什么问题——先搞清楚协议在干什么
很多人上手 MCP 第一步就去看代码,结果被一堆术语搞懵:Tool、Resource、Prompt、Capability、Transport……其实这些东西背后就一个朴素诉求:让 AI 应用能稳定地调用外部能力和数据,而不是靠一顿提示词让模型"猜"出来。
在 MCP 出现之前,让 AI 模型连接外部系统基本是各自为战。你在应用 A 里写了一套函数调用逻辑,换到应用 B 就得重写;模型想读一个文件、查一个数据库、调一个 API,每个厂商给你一套完全不同的接入方式。MCP(Model Context Protocol)干脆把这件事标准化了:它定义了一套"AI 应用"跟"外部工具/数据源"之间的通信协议,大家按同一个规矩说话,接一次就能到处用。
打个比方,MCP 之于 AI Agent,就相当于 USB-C 之于各种外设。以前每个设备一个充电口,现在统一了接口,plug and play。MCP 就是这个"统一接口"的协议定义。
1.1 三个核心原语:工具、资源、提示词
MCP 暴露给 AI 应用的无非三种能力,理解这三种原语,整个协议就懂了一半。
工具(Tools):类似函数调用。你定义好函数名、参数 JSON Schema、执行逻辑,AI 根据用户请求决定"要不要调用、传什么参"。典型例子:天气查询、订单查询、发邮件、执行 SQL。
资源(Resources):给 AI 提供"可以读"的数据内容,比如配置文件内容、数据库 schema、文档正文。资源通常有 URI,支持按需读取,AI 需要上下文时主动去取。
提示词(Prompts):预置的 prompt 模板,客户端可以拉取并组合进对话上下文。比如"代码审查"、"日报生成"这种固定套路,可以做成提示词模板复用。
这三种能力通过 JSON-RPC 2.0 消息通信,主流传输层主要有两个,接着往下看。
1.2 传输层:stdio 与 Streamable HTTP 怎么选
MCP 目前最常见的两种传输方式是stdio(标准输入输出)和Streamable HTTP(前缀 SSE 的可流式 HTTP)。
stdio 模式下,MCP Server 是一个子进程,由客户端拉起,客户端把 JSON-RPC 请求写到子进程的标准输入,子进程把响应写到标准输出,两端通过标准输入输出对话。这个模式在本地开发体验极好,不用管端口、鉴权、跨域,而且进程生命周期跟客户端绑定,客户端退出子进程自动回收。
Streamable HTTP 则是走网络,Server 作为一个 HTTP 服务端,支持远程连接,适合部署在服务器上供多个客户端或远端访问。代价是你要额外处理鉴权、并发、网络超时这些问题。
选型逻辑其实很直接:本地工具、私有化部署,优先 stdio;多客户端共享、跨机器访问,走 HTTP。我用 Go 做的那个项目最终是 stdio 模式,本地跑、配合各种 AI 客户端工具用,集成成本最低。如果后面要发布成出去,再在同一个业务层外面套 HTTP transport 也不难,这正好是 Go 的优势所在——业务逻辑和传输层可以解耦,后面展开说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么偏偏用 Go 写 MCP Server
Go 语言做 MCP Server,在我看来是一个"越用越顺"的选择。不是说 Python 或 TypeScript 不行——Python 生态里的 MCP 示例最多,TypeScript 跟前端客户端集成方便——但 Go 在几个关键场景下的优势非常突出。
2.1 Go 做服务端的硬核优势
编译成单文件,分发部署零依赖。 这是我最看重的点。MCP Server 通常要配置进 AI 客户端的配置文件里,用户需要下载、安装、运行。Go 编译出来就是一个可执行文件,拷贝到哪都能跑,不需要装 Python 环境、不需要 npm install,对非技术用户非常友好。你可以直接给协作同事一个二进制,省掉一整套环境搭建的沟通成本。
并发能力是语言级天赋。 MCP Server 本质上是一个要同时服务多个 tool 调用的进程。AI Agent 的调用模式是"发起多个工具请求,等待结果",尤其是并行调用多个工具时,Server 需要高效处理并发请求。Go 的 goroutine 让你写并发逻辑就像写普通同步代码,不需要像 Node 那样纠结回调,也不像 Python 那样受 GIL 约束。
静态类型 + 编译期检查。 MCP 的核心数据结构——工具定义(Tool)、参数 Schema、调用结果——都是强类型 JSON 结构。Go 的 struct + JSON tag 天然适合做协议层建模,字段写错编译期就报错,不会等到线上运行时才发现字段名拼错。
2.2 SDK 选型:官方 go-sdk 还是 mcp-go
现在 Go 生态里能用的 MCP SDK,主要是两个方向。
一个是 mark3labs/mcp-go,社区里用得最广、文档最全的 Go 实现,API 设计贴近官方 TypeScript SDK,上手快。另一个是官方推出的 modelcontextprotocol/go-sdk,起步相对晚一些,但官方技术支持有保障,协议新特性跟进快。
我实际用的是 mcp-go,理由很简单:文档丰富、示例多、issue 社区活跃,遇到问题能搜到解决方案。官方 SDK 我也跑过,API 设计更"正统",但当时文档还不够完善,有些新协议特性没有。我的建议是:追求稳定快速落地用 mcp-go,项目周期长、需要紧跟协议版本用官方 SDK。两个库的架构思路相近,就算中途切换,重写成本也可控。
2.3 环境准备与项目初始化
不管用什么库,前置条件就两个:Go 1.22+ 的编译环境,以及能正常访问模块代理。
创建项目很简单:
bash复制mkdir demo-mcp-server && cd demo-mcp-server
go mod init github.com/yourname/demo-mcp-server
go get github.com/mark3labs/mcp-go
拉完依赖后,你的 go.mod 里应该能看到 mcp-go 包。我在第一次拉取时遇到过一个版本兼容问题:某个较旧的 SDK 版本对 Go 版本有要求,报错提示 go.mod requires go >= 1.23。处理方式是把本机 Go 升级到 1.23 以上,或者手动在 go.mod 里降低 SDK 版本。建议直接升级 Go,省心。
项目结构上,我推荐保持简单但分层清晰:
text复制demo-mcp-server/
├── main.go # 入口:组装 server、注册工具、启动
├── tools/ # 具体的工具实现,按业务域拆分
│ ├── weather.go
│ └── database.go
├── resources/ # 资源读取逻辑
└── internal/ # 业务内部逻辑
别把几十个工具全塞进 main.go,后面维护会让你想骂人。工具按业务域拆文件,每个文件里放"定义 + handler",直观好找。
3. 从零实现一个可运行的 MCP Server
3.1 创建 Server 实例与服务能力声明
代码入门的第一个步骤,是创建 Server 实例。这一步同时在做"能力协商"的铺垫:MCP 协议里,客户端连接后双方要做 handshake,Client 会问"你支持哪些能力",Server 通过 WithToolCapabilities 等选项声明自己的支持范围。
go复制package main
import (
"log"
"github.com/mark3labs/mcp-go/server"
)
func main() {
s := server.NewMCPServer(
"demo-server", // Server 名称,客户端会显示
"1.0.0", // 版本号
server.WithToolCapabilities(true),
server.WithResourceCapabilities(true, true),
server.WithPromptCapabilities(true),
)
// 中间会在这里注册 tool/resource/prompt
if err := server.ServeStdio(s); err != nil {
log.Fatalf("server error: %v", err)
}
}
ServeStdio 这里会阻塞进程,持续从标准输入读请求。此时你的 Server 已经是一个完整的 MCP 端点——用 MCP Inspector 等工具连接上,能看到它正常返回协议信息。身边总有朋友觉得"MCP Server 很难",其实骨架代码就这么点,难的是你要在里面填充真正交付价值的工具逻辑。
3.2 实现第一个 Tool:从定义到处理函数
工具是 MCP 里用得最多的原语,我拿"天气查询"举例,完整走一遍定义到处理函数的过程。
工具定义分两部分:元数据(名字、描述、参数 Schema)和处理函数。参数 Schema 直接用 mcp.WithString 这类辅助函数声明,底层会帮你生成 JSON Schema,你不用手写那串冗长的 JSON 结构。
go复制s.AddTool(mcp.NewTool(
"get_weather",
mcp.WithDescription("查询指定城市当前的天气情况"),
mcp.WithString("city",
mcp.Required(),
mcp.Description("城市名称,如:北京、上海"),
),
mcp.WithString("unit",
mcp.Description("温度单位,可选 celsius/fahrenheit,默认 celsius"),
),
), handleGetWeather)
func handleGetWeather(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
city, _ := request.Params.Arguments["city"].(string)
unit, _ := request.Params.Arguments["unit"].(string)
if unit == "" {
unit = "celsius"
}
// 这里接真实天气 API,示例直接返回固定数据
result := fmt.Sprintf("%s 今天晴转多云,最高 28°C,最低 19°C(单位:%s)", city, unit)
return mcp.NewToolResultText(result), nil
}
几个细节值得注意。
参数描述要写具体。这个描述不是给人看的注释,是给 AI 模型看的。模型需要根据描述决定什么时候调用工具、传什么参数。"城市名称"这种描述比"城市"好用得多,因为模型不知道传什么。描述越具体,调用准确率越高。
处理函数的入参 ctx 不要忽略。当客户端取消请求或超时,ctx 会被取消,你长时间运行的工具逻辑可以通过 ctx.Err() 感知并提前退出,避免白白消耗资源。我见过不少示例代码把 ctx 扔一边,这在生产环境下会出问题。
返回结果格式。mcp.NewToolResultText 返回纯文本结果。如果要返回结构化数据,用 mcp.NewToolResultText 配合 JSON 序列化,或者使用结构化内容类型。AI 客户端对 JSON 格式的文本也会有不错的解析能力,但显式声明类型更规范。
3.3 资源与提示词的实现方式
资源(Resources)在协议里是"供读取的数据内容"。我在项目里实现了一个"读取系统配置"的资源,代码如下:
go复制s.AddResource(mcp.NewResource(
"config://system",
"系统配置信息",
mcp.WithResourceDescription("系统当前运行配置,包括环境、监听端口、日志级别等"),
), handleReadConfig)
func handleReadConfig(ctx context.Context, request mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
configData := map[string]string{
"env": "production",
"log_level": "info",
"port": "8080",
}
jsonData, _ := json.MarshalIndent(configData, "", " ")
return mcp.NewReadResourceResult(
mcp.NewTextContent(string(jsonData)),
), nil
}
资源可以做两种:静态资源是定义时就知道内容,客户端可以直接读取;动态资源则是提供 URI 模板,内容按需生成,比如 db://tables/{table_name}/schema,客户端请求时传入具体参数,你的 handler 动态查数据库返回。MCP 客户端在启动时会先拿到资源列表,但具体内容是否读取由模型判断——资源的意义在于让模型"知道有什么可读",并在需要时去读。
提示词(Prompts)实现类似,定义一个带参数的模板:
go复制s.AddPrompt(mcp.NewPrompt(
"code_review",
mcp.WithPromptDescription("生成代码审查请求"),
mcp.WithArgument("language",
mcp.Required(),
mcp.Description("代码语言"),
),
mcp.WithArgument("code_snippet",
mcp.Required(),
mcp.Description("要审查的代码片段"),
),
), handleCodeReviewPrompt)
func handleCodeReviewPrompt(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
args := request.Params.Arguments
language := args["language"]
snippet := args["code_snippet"]
promptText := fmt.Sprintf("请对以下 %s 代码进行审查,关注潜在 bug、性能问题和安全风险:\n%s", language, snippet)
return mcp.NewGetPromptResult([]mcp.PromptMessage{
mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(promptText)),
}), nil
}
这个 prompt 在客户端里会变成一个"可见的模板",用户选择后可自动填充对话,模型拿到的是一段精心设计的指令。做团队内部工具时,把常用的分析、审查套路沉淀成 prompt,效率提升非常明显。
3.4 用 MCP Inspector 做本地联调
代码写完了,怎么验证?官方提供了一个叫 MCP Inspector 的调试工具,完全为这个场景设计。启动方式:
bash复制npx @modelcontextprotocol/inspector go run main.go
注意:这条命令里 go run main.go 就是你的 Server 启动方式,Inspector 会拉起它并通过 stdio 连接。启动完成后,浏览器访问 Inspector 提供的本地地址(默认 http://localhost:6274),就能看到 Server 的能力列表,逐个测试工具调用。
Inspector 是个好东西,它能做到三件事:看到握手后服务端声明的能力、手动调用工具关闭、查看原始 JSON-RPC 消息交互过程。最后一条尤其有用,联调时出问题,看原始消息是最快的定位方式——是 Schema 不对,还是参数解析失败,一眼就能看出来。
我强烈建议任何 MCP Server 开发流程里都保留"先用 Inspector 测一遍"这个步骤。别直接丢给 Claude Desktop 之类客户端去试,客户端缓存的坑会让你排查到怀疑人生(后面讲)。
4. 生产环境里容易踩的坑
说实话,跑通一个 demo 是很简单的,但要把 MCP Server 做得稳定、可维护、无怪癖,你会遇到一些文档里不写的东西。我把实际操作中踩过的坑整理成了一份"避坑清单"。
4.1 stdio 传输的"隐形杀手":别污染标准输出
这是 stdio 模式最典型、也最有迷惑性的一个坑。
协议规定,Server 的所有协议消息都走标准输出(stdout)。正常逻辑下 stdout 里只能有 JSON-RPC 消息。可你要是图省事在代码里写了:
go复制fmt.Println("server started")
坏事了。这行纯文本会被客户端当作协议消息去解析,百分百报错。报错信息五花八门:Expected JSON-RPC message、parse error、甚至直接崩溃。而且这种问题只在 stdio 模式出现,HTTP 模式下 fmt.Println 打到哪儿都无所谓,很容易产生"本地好好的,配到客户端就崩"的错觉。
正确的排查和规避方式是:所有业务日志一律写标准错误输出(stderr)。Go 的 log 包默认就是输出到 stderr,这正好符合协议约定。如果你用了自己的日志库,确认输出目标被设置为 os.Stderr。
记住这条规范:stdout 只留给协议,stderr 留给日志。这不是最佳实践,而是 stdio 模式下的硬性要求。
4.2 参数 Schema 与类型校验的坑
工具参数 Schema 描述的是"合法参数长什么样",但真正决定你代码稳不稳的,是你自己在 handler 里做的类型断言。AI 模型的自由度比你想象的大得多,它可能传数字 123 而不是字符串 "123",可能漏传非必填字段,甚至可能发明一个你 Schema 里没定义的字段。
我在实现中就遇到过一次:一个查询接口,我期望 limit 是整数,但模型返回了一个字符串 "10",直接类型断言 int 就崩了。从那之后我的做法是:handler 里所有从 request.Params.Arguments 取出来的值都做防御性处理,字符串转整数要显式转换并捕获错误,未知字段直接忽略而不是报错。
工具的错误处理也要讲究。业务逻辑出错(比如查数据库失败)时,返回一个 error 给框架,客户端会收到一个工具执行失败的结果。但如果只是"参数不对、业务规则不允许",返回一个带 isError 标记的 ToolResult 更合适,协议对这两种场景的处理语义不同,前者表示基础设施问题,后者表示业务拒绝。
4.3 超时、取消与并发控制
AI 客户端调用工具,一般都会设置超时时间。一个长时间运行的工具如果无视 ctx 的取消信号,客户端那边会超时报错,但你的服务端进程还在后台跑,白白消耗 CPU 和内存。
处理方式是在耗时操作之前、之中都检查 ctx.Err():
go复制select {
case <-ctx.Done():
return nil, ctx.Err()
default:
// 继续执行
}
另外有一个不常被提及的点:客户端可能会并发调用同一个工具。比如模型一次性要求查询十几个城市天气,核心并发调用十几个请求。你的工具实现里如果有共享资源(数据库连接、缓存、全局变量),要注意并发安全。Go 的 goroutine 让并发调用天然安全,但共享状态不能不加锁就直接读写。用 sync.Mutex 或者干脆设计成无状态 handler,是最省心的方案。
我这边是把每个工具设计成无状态的:所有数据通过参数传入,结果通过返回值传出,不依赖任何包级可变变量。这样既能天然应对并发,也方便单测。
4.4 错误码与日志规范
MCP 协议基于 JSON-RPC 2.0,错误码有一套自己的语义。-32700 是解析错误,-32600 是无效请求,-32601 是找不到方法。SDK 一般会帮你处理好协议层的错误码,你不需要手动构造,但你要了解:如果你在 handler 里返回了自定义错误,SDK 会把它包装成错误响应。我建议在错误信息里带上上下文,比如 "tool get_weather: parse argument city failed: ...",这样联调排查时日志跟请求能对上,找问题快很多。
日志的级别也要控制。Debug 级别的日志在生产环境会刷屏,尤其工具调用频繁时。我的做法是:info 级别记录工具名、参数耗时,error 级别记录完整堆栈。日志全部走 stderr,这样不影响协议消息,也能完整保留现场用于排查。
4.5 集成到 AI 客户端的细节
写好后要接入 AI 客户端,比如 Claude Desktop 或者 opencode 这类开发助手。配置方式大同小异,在客户端的 MCP 配置文件里声明命令:
json复制{
"mcpServers": {
"demo-server": {
"command": "/usr/local/bin/demo-mcp-server",
"args": []
}
}
}
这里有个非常隐蔽的坑:客户端对命令路径和启动失败的处理比较粗糙。你把二进制放到某个路径,配置里写相对路径,客户端可能找不到进程,而且只会在日志里留下一句含糊的启动失败。我建议:
- 写绝对路径,禁用符号链接(某些客户端不解析)
- 启动后先在终端手动跑一遍
demo-mcp-server,确认不报错 - 配置好后重启客户端,而不是热加载
另外,如果你改了 Server 代码重新编译,客户端那边不会自动加载新二进制。很多 MCP 客户端会缓存进程,你需要彻底退出客户端进程再重启,甚至要清掉一些缓存目录。这个"改了不生效"的问题,十有八九不是代码问题,而是客户端没重启。联调流程我建议是:改代码 → go build → 重启客户端 → 验证。
5. 再聊几句实操中的体会
做 MCP Server 这个事,技术难度其实不高,真正考验人的是对协议的理解和对细节的把控。我自己做完这轮之后的几点体会,写在这里算是收个尾。
第一,工具设计要比接口设计更贴近"人的意图"。普通 API 是给程序员用的,参数名可以缩写、返回值可以很底层;MCP 工具是给 AI 用的,参数名和描述越接近自然语言越好。比如 city 比 c 好,"城市名称,如:北京、上海" 比 "城市" 好。这一步做得好,AI 调用工具的准确率会有质的提升。
第二,多看看真实世界的 MCP Server。像浏览器的 MCP Server(控制浏览器、抓取网页)、数据库 MCP Server(把数据库表结构暴露给 AI),都会让你学到"怎么把复杂系统暴露成结构化能力"。看代码不是抄,而是要琢磨它们做工具拆分和 Schema 设计时怎么取舍的。
第三,从"够用"到"好用"要持续迭代。最初版本可能只有一两个工具,但一旦跑起来,你会收到各种反馈:模型在什么场景下调用错了、哪些参数容易传错值、哪些流程可以沉淀成提示词模板。这些都是下一轮迭代的素材。MCP Server 不是一个一次性交付的产品,它更像一个持续演进的能力层,随着你对业务和 AI 交互模式的深入理解,会变得越来越顺手。
