本教程面向已熟悉一种后端语言、想让 Claude Code 调用自建工具链的开发者。你将从零搭建一个用 Go 编写的 MCP(Model Context Protocol)服务器,先以 stdio 传输作为本地子进程接入 Claude Code,再迁移到 2025-03-26 规范引入的 Streamable HTTP 传输,把它部署成可被团队、云端、跨机器共享的远程服务。教程同时对比官方 modelcontextprotocol/go-sdk(已发布 v1.0.0)与社区 mark3labs/mcp-go 两套 SDK 的取舍,并给出生产加固(CORS、OAuth 受保护资源元数据、限流中间件、会话清理)的落地代码。
method/params,响应带 result/error)绝大多数入门教程用 Python(FastMCP)或 TypeScript(@modelcontextprotocol/sdk)起步。它们上手快,但在"把 MCP 当成长期服务运行"时,Go 有三个结构性优势:
tools/call 时重复加载依赖的开销。对 stdio 模式尤其关键——Claude Code 会在需要时反复 spawn 你的进程,启动越快,首次调用越顺滑。context.Context 做超时与取消非常自然。go build 产出一个无依赖的二进制,扔进 Docker scratch 镜像只有十几兆,团队共享和 CI 流水线集成成本极低。代价是:Go 的 MCP 生态比 Python/TS 年轻,配套的"脚手架生成器"较少。但官方 Go SDK 已在 2025 年 9 月中旬推出并达到 v1.0.0,能力已足够覆盖生产场景。
| 维度 | modelcontextprotocol/go-sdk(官方) |
mark3labs/mcp-go(社区) |
|---|---|---|
| 维护方 | Anthropic 协作维护,规范跟随最快 | 社区主导,迭代激进 |
| 工具定义 | 泛型 AddTool[In, Out],struct tag 自动生成 JSON Schema |
mcp.NewTool + 链式选项 WithString/WithNumber |
| 传输 | stdio / Streamable HTTP / SSE / Command | stdio / Streamable HTTP / SSE |
| 生产特性 | 较精简,留给你自己组装 | 内置中间件、CORS、OAuth 元数据、采样、会话清理 |
| 心智负担 | 低,类型安全 | 中,API 表面积大但开箱即用 |
选型建议:学习与新项目首选官方 SDK,代码干净、泛型让 schema 与 handler 强绑定,重构时编译器替你兜底;当你需要"装上即用"的中间件链、RFC 9728 OAuth 受保护资源元数据、跨域、采样等成熟能力时,再评估 mcp-go。本教程主线用官方 SDK 走完全流程,第七节用 mcp-go 演示生产加固。
mkdir logseek && cd logseek
go mod init github.com/yourname/logseek
go get github.com/modelcontextprotocol/go-sdk
创建一个示例日志目录,方便后面测试:
mkdir -p logs
echo "2026-06-16T08:12:01 INFO 用户 alice 登录" > logs/2026-06-16.log
echo "2026-06-16T08:13:44 ERROR 数据库连接超时 timeout=5s" >> logs/2026-06-16.log
echo "2026-06-16T08:14:02 WARN 重试第 2 次" >> logs/2026-06-16.log
新建 main.go,实现一个"按正则检索某天日志"的工具,并用 stdio 传输跑起来。
package main
import (
"bufio"
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// --- 工具入参:struct tag 自动生成 JSON Schema 供客户端(LLM)理解 ---
type SearchParams struct {
Pattern string `json:"pattern" jsonschema:"正则或关键字"`
Date string `json:"date" jsonschema:"日期 YYYY-MM-DD,留空表示今天"`
Limit int `json:"limit" jsonschema:"返回行数上限,默认 50"`
}
type CountParams struct {
Pattern string `json:"pattern"`
Date string `json:"date"`
}
func logDir() string {
if d := os.Getenv("LOGSEEK_DIR"); d != "" {
return d
}
return "logs"
}
func today() string { return time.Now().Format("2006-01-02") }
// --- handler 签名固定:泛型版 ToolHandlerFor[In] ---
func searchLogs(ctx context.Context, _ *mcp.ServerSession,
p *mcp.CallToolParamsFor[SearchParams]) (*mcp.CallToolResultFor[any], error) {
date := p.Arguments.Date
if date == "" {
date = today()
}
path := filepath.Join(logDir(), date+".log")
f, err := os.Open(path)
if err != nil {
// 返回 error 会被 SDK 转成 tool 级错误(IsError=true),不会让进程崩溃
return nil, fmt.Errorf("打开日志失败 %s: %w", path, err)
}
defer f.Close()
re, err := regexp.Compile(p.Arguments.Pattern)
if err != nil {
return nil, fmt.Errorf("非法正则: %w", err)
}
limit := p.Arguments.Limit
if limit <= 0 {
limit = 50
}
var hits []string
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1<<20), 1<<20) // 单行最大 1MB,避免超长行截断
for sc.Scan() {
if ctx.Err() != nil { // 响应客户端取消
return nil, ctx.Err()
}
line := sc.Text()
if re.MatchString(line) {
hits = append(hits, line)
if len(hits) >= limit {
break
}
}
}
text := fmt.Sprintf("命中 %d 行(上限 %d):\n%s",
len(hits), limit, strings.Join(hits, "\n"))
return &mcp.CallToolResultFor[any]{
Content: []mcp.Content{&mcp.TextContent{Text: text}},
}, nil
}
func countLogs(ctx context.Context, _ *mcp.ServerSession,
p *mcp.CallToolParamsFor[CountParams]) (*mcp.CallToolResultFor[any], error) {
date := p.Arguments.Date
if date == "" {
date = today()
}
data, err := os.ReadFile(filepath.Join(logDir(), date+".log"))
if err != nil {
return nil, err
}
re, err := regexp.Compile(p.Arguments.Pattern)
if err != nil {
return nil, err
}
n := len(re.FindAll(data, -1))
return &mcp.CallToolResultFor[any]{
Content: []mcp.Content{
&mcp.TextContent{Text: strconv.Itoa(n)},
},
}, nil
}
func newServer() *mcp.Server {
s := mcp.NewServer(&mcp.Implementation{Name: "logseek", Version: "1.0.0"}, nil)
mcp.AddTool(s,
&mcp.Tool{Name: "search_logs", Description: "按正则搜索某天日志的匹配行"},
searchLogs)
mcp.AddTool(s,
&mcp.Tool{Name: "count_logs", Description: "统计某天日志中某模式的出现次数"},
countLogs)
return s
}
func main() {
mode := flag.String("mode", "stdio", "stdio | http")
flag.Parse()
s := newServer()
switch *mode {
case "http":
runHTTP(s)
default:
// 阻塞直到客户端断开或 context 取消
if err := s.Run(context.Background(), mcp.NewStdioTransport()); err != nil {
log.Fatal(err)
}
}
}
func runHTTP(s *mcp.Server) {
handler := mcp.NewStreamableHTTPHandler(
func(*http.Request) *mcp.Server { return s },
&mcp.StreamableHTTPOptions{},
)
http.HandleFunc("/mcp", handler.ServeHTTP)
log.Println("logseek HTTP 监听 :8080/mcp")
log.Fatal(http.ListenAndServe(":8080", nil))
}
几个关键点:
SearchParams 的 jsonschema tag 会被 SDK 反射成 JSON Schema 挂到 tools/list 的响应里。LLM 据此决定何时调用、传什么参数。你改结构体,schema 自动同步,不会出现"文档说支持 limit,代码却读不出来"的漂移。error 是"工具级失败"(结果里 IsError=true,文本给到模型);只有 panic 或传输层错误才会让连接异常。永远不要在 handler 里 log.Fatal。ctx 要用:长任务务必检查 ctx.Err(),否则 Claude Code 取消时你的扫描还在空跑。先编译出二进制并放到 PATH,或直接用绝对路径:
go build -o logseek .
在项目根目录添加服务器,使用 stdio 传输:
claude mcp add --transport stdio logseek -- /home/yourname/logseek/logseek
-- 之后是真正启动进程的命令,Claude Code 不会把它当自己的参数解析。配置默认写入 ~/.claude.json 的 local scope(仅当前项目、私有)。验证:
claude mcp list
claude mcp get logseek
进入交互会话用 /mcp 查看连接状态,应看到 logseek 后面带工具数 2。然后直接提问:
帮我看看 2026-06-16 的日志里有没有 ERROR,统计一下出现了几次。
Claude 会自行决定调用 search_logs(pattern=ERROR)和 count_logs,你无需手动指定工具。
tools 数显示 0:说明服务器进程起来了但没注册工具,检查 mcp.AddTool 是否执行;也可能是 handler 签名与泛型不匹配导致注册被静默跳过——go vet ./... 能抓到一部分。/mcp 显示连接失败:八成是 command 路径不对。用 which logseek 确认,或写绝对路径。claude mcp add --env LOGSEEK_DIR=/var/log/app --transport stdio logseek -- ./logseek。stdio 适合"个人本地工具",但它把进程绑定在你本机,无法跨机器、无法被团队共享、无法做集中式鉴权。把同一个服务器切换到 Streamable HTTP 传输,它就变成了一个普通的远程 web 服务。
关键背景:MCP 规范 2025-03-26 版本用 Streamable HTTP 取代了旧的 HTTP+SSE 传输。旧方案要两个端点(/sse 接收、/sse/messages 发送),像用两部手机对话;新方案只用单个端点(通常是 /mcp),既能像普通 HTTP 请求/响应那样处理快速调用,又能在长任务时动态升级成 SSE 流,还能让服务器反向通过同一连接发通知(采样、进度)。旧的 SSE 传输已标记废弃。
好消息是:上文的 runHTTP 已经是 Streamable HTTP 实现,-mode http 即可:
./logseek -mode http
# logseek HTTP 监听 :8080/mcp
用 Claude Code 接入远程服务器:
claude mcp add --transport http logseek http://localhost:8080/mcp
等价的项目级配置(写入项目根的 .mcp.json,可提交版本控制与团队共享):
{
"mcpServers": {
"logseek": {
"type": "http",
"url": "http://localhost:8080/mcp"
}
}
}
type 字段同时接受 http 和 streamable-http,后者与 MCP 规范用词一致,所以从服务器文档里复制过来的配置无需改动。带鉴权头时:
claude mcp add --transport http logseek https://mcp.yourcorp.com/mcp \
--header "Authorization: Bearer ${LOGSEEK_TOKEN}"
.mcp.json 里支持环境变量展开 "url": "${LOGSEEK_BASE:-https://mcp.yourcorp.com}/mcp" 与 "headers": {"Authorization": "Bearer ${LOGSEEK_TOKEN}"},敏感值不落库。
需要 OAuth 时,无需手动配置流程:Claude Code 在服务器返回 401/403 时自动把它标记为"需认证",在 /mcp 面板里走浏览器登录;服务器若按 RFC 9728 暴露 /.well-known/oauth-protected-resource,会被自动发现。
理解下面四点,调试和设计远程 MCP 时就不会抓瞎。
1. 单端点 + 会话 ID。 客户端 POST /mcp 发 initialize,服务器可选地回一个 Mcp-Session-Id 响应头(如 abc-123)。此后每次请求,客户端都要在 Mcp-Session-Id 请求头里带上它,服务器据此把同一会话的工具列表、订阅、日志级别等状态归并起来。
2. 动态降级/升级。 简单调用就是一次普通 POST,返回 application/json;如果调用过程中服务器要推进度通知,同一个 POST 响应会被升级成 text/event-stream(SSE);客户端想持续监听服务器主动推送(如采样请求),可发 GET /mcp 建立长连接。DELETE /mcp 结束会话。这正是"Streamable"的含义——按需流式,不必为短查询维持长连接。
3. 有状态 vs 无状态。 默认有状态:会话内可累积上下文。横向扩展(多副本、负载均衡)时,要么开启粘性路由(同一 session 落到同一实例),要么走无状态模式——每个请求自包含所有信息,状态外置到 Redis/数据库。无状态模式放弃会话亲和,换来任意副本可处理的水平扩展能力。
4. 会话清理。 客户端断开却没发 DELETE,会话状态会泄漏内存。生产环境务必设置空闲 TTL,定期清扫长时间无请求的会话并使其 ID 失效。
官方 SDK 刻意精简,把组装权留给你。若你想"装上即用",mark3labs/mcp-go 提供了一整套生产选项。下面代码切换到 mcp-go(go get github.com/mark3labs/mcp-go),同样的服务器,加固四件套:
package main
import (
"context"
"log"
"net/http"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
func main() {
s := server.NewMCPServer("logseek", "1.0.0",
server.WithToolCapabilities(true),
server.WithResourceCapabilities(true, true),
server.WithLogging(),
)
s.AddTool(mcp.NewTool("search_logs",
mcp.WithDescription("按正则搜索某天日志"),
mcp.WithString("pattern", mcp.Required()),
mcp.WithString("date"),
mcp.WithNumber("limit", mcp.DefaultNumber(50), mcp.Max(500)),
), handleSearch)
// ① 工具中间件链:鉴权 + 限流 + 缓存
s.AddToolMiddleware(func(next server.ToolHandler) server.ToolHandler {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
token := req.Header.Get("Authorization") // 传输层自动透传 HTTP 头
if token == "" {
return mcp.NewToolResultError("authentication required"), nil
}
return next(ctx, req)
}
})
// ② Streamable HTTP 服务器,含端点、会话清理、CORS、OAuth 元数据
httpServer := server.NewStreamableHTTPServer(s,
server.WithEndpointPath("/mcp"),
server.WithStateLess(false),
server.WithSessionIdleTTL(10*time.Minute), // 清扫空闲 10 分钟的会话
server.WithStreamableHTTPCORS(
server.WithCORSAllowedOrigins("https://app.yourcorp.com"),
server.WithCORSAllowCredentials(),
server.WithCORSMaxAge(300),
),
server.WithProtectedResourceMetadata(server.ProtectedResourceMetadataConfig{
Resource: "https://mcp.yourcorp.com/mcp",
AuthorizationServers: []string{"https://auth.yourcorp.com"},
ScopesSupported: []string{"logs:read"},
BearerMethodsSupported: []string{"header"},
}),
)
log.Println("生产 logseek 监听 :8080/mcp")
log.Fatal(httpServer.Start(":8080"))
}
func handleSearch(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// req.GetString("pattern", ""), req.GetInt("limit", 50) …
return mcp.NewToolResultText("..."), nil
}
四个加固点的意义:
AddToolMiddleware 在每个 tools/call 前后切面,可叠加鉴权、限流(如 golang.org/x/time/rate)、埋点、缓存。注意顺序——鉴权应放最外层。WithSessionIdleTTL 启动后台清扫器,干掉泄漏会话,Shutdown() 时自动停止。WithStreamableHTTPCORS 显式放行;* + 凭证会被规范要求回显具体 Origin。/mcp)会自动挂到 /.well-known/oauth-protected-resource/mcp。采样(sampling)——服务器反向请求客户端的 LLM 能力——也已被 Streamable HTTP 支持,且规范强制要求"人在回路"审批。生产中若开启 mcpServer.EnableSampling(),务必实现一个会向用户展示并允许拒绝的采样 handler,否则服务器可能在你不知情时以你的名义产生 LLM 调用与费用。
把上面拼起来,一个可交付的最小服务长这样:本地用 stdio 开发调试 → 编译成单二进制 → 容器化以 HTTP 部署 → 团队经 Claude Code 接入。
Dockerfile(scratch 镜像,约 15MB):
FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/logseek .
FROM gcr.io/distroless/static
COPY --from=build /out/logseek /logseek
ENV LOGSEEK_DIR=/data
EXPOSE 8080
ENTRYPOINT ["/logseek", "-mode", "http"]
docker build -t logseek .
docker run -p 8080:8080 -v $(pwd)/logs:/data logseek
claude mcp add --transport http logseek http://localhost:8080/mcp
flowchart LR
subgraph 本地开发[本地开发 stdio]
CC1[Claude Code] -->|spawn + stdin/stdout| LS1[logseek 二进制]
LS1 --> F1[(本地 logs/)]
end
subgraph 远程部署[团队/云端 Streamable HTTP]
CC2[Claude Code] -->|POST /mcp\nMcp-Session-Id| GW[网关: CORS+鉴权+OAuth元数据]
GW --> LS2[logseek HTTP :8080]
LS2 --> F2[(共享日志卷)]
end
sequenceDiagram
participant CC as Claude Code
participant S as logseek /mcp
CC->>S: POST /mcp initialize
S-->>CC: 200 Mcp-Session-Id: abc-123
CC->>S: POST /mcp tools/list (Header: Mcp-Session-Id)
S-->>CC: search_logs, count_logs
CC->>S: POST /mcp tools/call search_logs {pattern:"ERROR"}
Note over S: 命中行多 → 响应升级为 text/event-stream
S-->>CC: 200 命中结果(流式分块)
CC->>S: DELETE /mcp 结束会话
S-->>CC: 200
/mcp 手动重试。所以你的服务器做滚动升级时,客户端会自愈。tools/list 里给工具加 _meta["anthropic/maxResultSizeChars"](最高 50 万字符),把大 schema/全量日志这种"天生就大"的结果放行,避免被转存成文件引用。instructions("本服务处理日志检索,需要查日志时调用"),Claude 据此判断何时来搜你的工具——这和 Skill 的触发逻辑异曲同工。描述与指令各截断在 2KB,重要信息放最前面。list_changed 通知,Claude Code 会自动刷新工具列表,无需重连。WithStateLess(true) + 状态外置。/mcp 一直 pending:检查防火墙/端口;首次连接失败 Claude Code 会重试 3 次(v2.1.121 起),仍失败才标记 failed。WithStreamableHTTPCORS 放行来源。tools/list 返回里 inputSchema 非空;官方 SDK 靠 struct tag,tag 写错 schema 就空。/mcp 没提示登录:确认返回了 401/403,并按 RFC 9728 暴露了 /.well-known/oauth-protected-resource。claude mcp add --transport http、.mcp.json 配置、scope 层级、OAuth 自动发现、工具搜索、大输出与 anthropic/maxResultSizeChars 等客户端侧行为。[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
