1. 项目缘起与整体方案设计
1.1 为什么会有这次跨栈接入
事情的起因其实很朴素:团队内部有一个基于 Go 写的后端服务,负责聚合若干业务数据,同时前端用 Next.js 做了一套管理面板。产品侧希望把一部分能力通过 MCP 协议暴露出去,让支持 MCP 的客户端能够直接调用,比如在对话式工具里查询数据、触发任务。听起来就是"加个接口"的事,但真正动手才发现,这是一次典型的跨栈接入:协议层、鉴权层、后端服务层、前端调试层,每一层都有自己的坑。
MCP 全称 Model Context Protocol,简单理解就是一套让外部工具和模型侧客户端对话的标准协议。它规定了工具怎么描述、参数怎么传、结果怎么回。你可以把它类比成"给模型用的 USB-C 接口"——只要插头对得上,谁都能接。这次我们要做的,就是让自家的 Go 服务变成一个合规的 MCP Server,同时在前端 Next.js 侧做联调和验证。
关键词里出现了 OAuth、PKCE、Go、Next.js,这四个词基本勾勒出了整个项目的技术轮廓:用 Go 实现 MCP Server,鉴权走 OAuth 2.0 加 PKCE,前端用 Next.js 做接入和端到端验证。下面我把整个复盘拆开讲,包括方案怎么定的、代码怎么写、坑怎么踩的。
1.2 方案选型的几个关键决策
第一个决策是协议实现方式。MCP 官方有 TypeScript 和 Python 的 SDK,Go 侧生态相对薄一些。我们评估了三条路:一是自己按协议规范手写 JSON-RPC 层;二是找社区维护的 Go 实现;三是用官方 SDK 起一个 Node 中间层,Go 服务通过内部接口对接。第三条路听起来省事,但多一层转发就多一层故障点,延迟和排查成本都上去了。最后选了第一条路为主、参考社区实现为辅,核心的 JSON-RPC 消息编解码自己控制,这样出问题能定位到具体字节。
第二个决策是鉴权方案。MCP 的远程接入场景下,鉴权是绕不开的。我们对比了 API Key 和 OAuth 2.0 两条路。API Key 实现简单,但有几个硬伤:无法细粒度授权、无法安全地在客户端侧流转、撤销麻烦。OAuth 2.0 配合 PKCE 是当前公开客户端场景下的推荐做法,PKCE 全称 Proof Key for Code Exchange,核心作用是防止授权码被拦截后直接换 token。对于我们的场景——客户端可能是桌面应用或浏览器扩展,没有安全的密钥存储——PKCE 几乎是必选项。
第三个决策是前端验证方式。Next.js 这边我们没打算做完整的用户界面,而是做一个最小可用的调试页,能发起授权、拿到 token、调用 MCP 工具、看到返回结果。这样做的理由是:端到端验证的重点是链路通不通,不是 UI 好不好看。把精力放在链路上,比堆界面有价值得多。
提示:方案选型阶段一定要把"最小验证路径"想清楚。很多跨栈项目卡住,不是因为技术难,而是因为一开始就想做全,结果每条线都半途而废。
1.3 整体架构长什么样
整个链路可以拆成四段。第一段是客户端发起授权请求,带上 client_id、redirect_uri、code_challenge 等参数,跳到授权端点。第二段是授权服务器校验后回调,客户端用授权码加 code_verifier 换 access_token。第三段是客户端拿着 token 调用 MCP Server 的工具列表接口,确认工具可用。第四段是实际调用某个工具,拿到业务数据。
Go 服务在这里承担两个角色:一是 OAuth 的资源服务器,负责校验 token;二是 MCP Server,负责处理 JSON-RPC 请求。Next.js 侧承担客户端角色,负责走完授权流程并发起调用。这个划分很清晰,各司其职,出问题也容易定位是哪一段的锅。
2. 核心细节解析与实操要点
2.1 MCP 协议层的消息结构
MCP 基于 JSON-RPC 2.0,这个基础得先吃透。一条请求消息长这样:jsonrpc 字段固定是 "2.0",method 是方法名,params 是参数对象,id 是请求标识。响应消息里要么有 result,要么有 error,两者互斥。这个结构看着简单,但实际写的时候有几个细节容易翻车。
第一个细节是id 的类型。JSON-RPC 规范里 id 可以是字符串、数字或 null,但不同客户端实现不一致。有的客户端发数字 id,有的发字符串。我们的 Go 侧解析时如果强转成某一种类型,就会在另一种客户端上炸掉。解决办法是用 json.RawMessage 接住 id,原样回传,不做类型假设。
第二个细节是错误码的语义。JSON-RPC 定义了标准错误码,比如 -32700 是解析错误,-32600 是无效请求,-32601 是方法不存在,-32602 是参数无效,-32603 是内部错误。MCP 在此基础上又扩展了一些。我们一开始把所有错误都塞成 -32603,结果客户端拿到的提示全是"内部错误",排查时完全不知道是参数问题还是逻辑问题。后来严格按语义分类,调试效率立刻上来了。
第三个细节是通知消息。JSON-RPC 里有不带 id 的通知,服务端收到后不应该回响应。MCP 里有些场景会用到通知,比如初始化完成后的确认。如果服务端傻乎乎地给通知也回一条响应,客户端可能会因为收到意料之外的消息而报错。
2.2 OAuth 2.0 加 PKCE 的完整流程
PKCE 的核心是三个值:code_verifier、code_challenge、code_challenge_method。code_verifier 是客户端生成的一串随机字符串,长度在 43 到 128 之间,字符集限定为字母、数字和几个符号。code_challenge 是 code_verifier 经过变换得到的,变换方式有两种:plain 和 S256。plain 就是原样,S256 是取 SHA-256 哈希再做 base64url 编码。生产环境必须用 S256,plain 只适合调试。
流程走起来是这样的:客户端先生成 code_verifier,算出 code_challenge,然后把 code_challenge 和 method 一起发给授权端点。用户授权后,授权服务器回调时带上授权码。客户端拿授权码加原始的 code_verifier 去换 token。授权服务器自己算一遍 challenge,和之前存的对上才发 token。这样即使授权码在传输中被截获,攻击者没有 code_verifier 也换不到 token。
Go 侧实现时,生成随机串要用 crypto/rand,别用 math/rand。math/rand 是可预测的,用在安全场景等于没设防。base64url 编码要注意去掉填充的等号,标准库的 RawURLEncoding 正好干这个。哈希用 crypto/sha256,算完再编码。
func generateCodeVerifier() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } func generateCodeChallenge(verifier string) string { h := sha256.Sum256([]byte(verifier)) return base64.RawURLEncoding.EncodeToString(h[:]) }注意:code_verifier 必须和生成 code_challenge 时用的是同一个。我们踩过一次坑,前端刷新页面后重新生成了 verifier,但 challenge 还是旧的,换 token 时一直报 invalid_grant,查了半天才发现是状态没对齐。
2.3 Go 侧 MCP Server 的骨架
Go 服务的骨架分三层:传输层、协议层、业务层。传输层负责 HTTP 的收发,协议层负责 JSON-RPC 的编解码和方法分发,业务层是具体的工具实现。这样分层的好处是,换传输方式(比如从 HTTP 换成 stdio)时,协议层和业务层不用动。
传输层用标准库的 net/http 就够了,没必要上框架。MCP 的请求量通常不大,标准库的性能完全够用。路由上,我们用一个端点处理所有 JSON-RPC 请求,方法名在 body 里区分,而不是用 URL 路径区分。这符合 JSON-RPC 的设计,也简化了路由配置。
协议层是核心。我们定义了一个 Request 结构体和一个 Response 结构体,用 encoding/json 做编解码。方法分发用一个 map,key 是方法名,value 是处理函数。这样加新方法只要往 map 里塞一条,不用改分发逻辑。
type Request struct { JSONRPC string `json:"jsonrpc"` Method string `json:"method"` Params json.RawMessage `json:"params,omitempty"` ID json.RawMessage `json:"id,omitempty"` } type Response struct { JSONRPC string `json:"jsonrpc"` Result interface{} `json:"result,omitempty"` Error *RPCError `json:"error,omitempty"` ID json.RawMessage `json:"id,omitempty"` }业务层每个工具实现成一个函数,输入是解析后的参数,输出是结果或错误。工具的参数校验要严格,缺字段、类型不对都要明确报错,别让错误往下传。
2.4 Next.js 侧的接入要点
Next.js 这边我们用的是 App Router。授权流程涉及跳转和回调,所以需要一个路由来处理 callback。callback 页面拿到授权码后,要在服务端发起换 token 的请求,因为 client_secret 不能暴露在浏览器里。虽然 PKCE 场景下公开客户端可以没有 secret,但我们的授权服务器还是要求带 secret,所以换 token 这一步放在 Next.js 的 Route Handler 里做。
Route Handler 里用 fetch 调授权服务器的 token 端点,把 code、code_verifier、client_id、redirect_uri 一起发过去。拿到 token 后,可以存到 httpOnly 的 cookie 里,或者存到服务端 session。存 cookie 时要注意 SameSite 和 Secure 属性,生产环境必须 Secure。
调用 MCP 工具时,Next.js 侧可以走服务端转发,也可以让浏览器直接调。走服务端转发的好处是 token 不用暴露给浏览器,坏处是多一跳。我们选了服务端转发,安全优先。
export async function POST(request) { const { code, verifier } = await request.json(); const body = new URLSearchParams({ grant_type: 'authorization_code', code, code_verifier: verifier, client_id: process.env.CLIENT_ID, redirect_uri: process.env.REDIRECT_URI, }); const res = await fetch(TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); const data = await res.json(); return Response.json(data); }3. 实操过程与核心环节实现
3.1 环境准备与依赖安装
Go 侧的环境准备比较直接。装好 Go 之后,初始化模块,拉几个必要的依赖。我们用到的主要是标准库,外加一个用于生成 UUID 的库。别小看依赖管理,Go 的 go.mod 和 go.sum 要提交到版本控制,保证不同机器上构建结果一致。
go mod init mcp-server go get github.com/google/uuidNext.js 侧用 create-next-app 起项目,选 TypeScript 和 App Router。依赖上除了 Next.js 本身,没装太多东西,fetch 是内置的,不需要 axios。少一个依赖就少一个潜在的坑。
环境变量要提前规划好。Go 侧需要授权服务器的地址、client_id、client_secret。Next.js 侧需要 client_id、redirect_uri、授权端点和 token 端点。这些值不要硬编码在代码里,用环境变量注入。本地开发用 .env.local,生产环境用部署平台的配置。
提示:环境变量命名要统一。我们一开始 Go 侧叫 OAUTH_CLIENT_ID,Next.js 侧叫 CLIENT_ID,联调时对不上,白折腾了半小时。后来统一成一套命名,问题消失。
3.2 授权流程的端到端跑通
跑通授权流程是整个项目最关键的一步。我们按顺序验证:先确认授权端点能正常跳转,再确认回调能拿到 code,再确认换 token 成功,最后确认 token 能调通 MCP 接口。每一步都单独验证,不要跳步。
第一步,构造授权 URL。参数包括 response_type=code、client_id、redirect_uri、code_challenge、code_challenge_method=S256、state。state 是防 CSRF 的,要随机生成并存在 session 里,回调时比对。我们一开始没加 state,后来补上,发现能挡住一些异常回调。
第二步,处理回调。回调页面从 URL 里取 code 和 state,比对 state 后,把 code 和之前存的 verifier 一起发给 Route Handler。这里要注意,verifier 存在哪。我们存在 sessionStorage 里,因为它是临时的,刷新页面后重新走授权流程即可。
第三步,换 token。Route Handler 拿到 code 和 verifier,调 token 端点。返回的 token 里通常有 access_token、token_type、expires_in,有的还有 refresh_token。access_token 存到 httpOnly cookie,refresh_token 如果不需要长期登录可以不存。
第四步,调 MCP 接口。带上 Authorization 头,值是 Bearer 加 token。先调工具列表接口,确认能拿到工具清单。再调一个具体的工具,确认业务逻辑通。
3.3 MCP 工具的定义与调用
MCP 工具的定义要遵循协议规范。每个工具有 name、description、inputSchema 三个核心字段。name 是唯一标识,description 是给模型看的说明,inputSchema 是 JSON Schema 格式的参数定义。description 写得好不好,直接影响模型能不能正确调用。我们一开始 description 写得很简略,模型经常传错参数,后来把每个参数的含义、格式、示例都写清楚,调用准确率明显提升。
inputSchema 用 JSON Schema 描述,支持 type、properties、required 等字段。参数类型要明确,字符串、数字、布尔、数组、对象都要标清楚。required 数组列出必填参数。我们有个工具的参数是日期范围,一开始只标了 type 是 string,模型传了各种格式,后来加了 pattern 约束,限定成 YYYY-MM-DD,问题就少了。
调用工具时,客户端发的是 tools/call 方法,params 里带 name 和 arguments。服务端解析后,找到对应的处理函数,校验参数,执行逻辑,返回结果。结果的结构要符合协议,通常是 content 数组,每个元素有 type 和 text。
func handleToolsCall(params json.RawMessage) (interface{}, *RPCError) { var req struct { Name string `json:"name"` Arguments json.RawMessage `json:"arguments"` } if err := json.Unmarshal(params, &req); err != nil { return nil, &RPCError{Code: -32602, Message: "invalid params"} } handler, ok := toolRegistry[req.Name] if !ok { return nil, &RPCError{Code: -32601, Message: "tool not found"} } return handler(req.Arguments) }3.4 端到端验证的检查清单
端到端验证不能靠感觉,要有清单。我们整理了一份,每次改动后按清单过一遍。
| 检查项 | 验证方式 | 通过标准 |
|---|---|---|
| 授权跳转 | 浏览器访问授权 URL | 正确跳到授权页 |
| 回调接收 | 授权后观察回调 URL | 带上 code 和 state |
| state 校验 | 篡改 state 后回调 | 被拒绝 |
| 换 token | 调 token 端点 | 返回 access_token |
| token 校验 | 带错误 token 调接口 | 返回 401 |
| 工具列表 | 调 tools/list | 返回工具清单 |
| 工具调用 | 调 tools/call | 返回业务数据 |
| 参数校验 | 传缺字段的参数 | 返回参数错误 |
这份清单看着简单,但每一条都对应一个曾经出过问题的点。比如 state 校验,我们一开始没做,后来补上后发现确实能挡住异常请求。token 校验也是,一开始只校验存在性,不校验有效性,过期 token 也能过,后来加了过期检查。
4. 常见问题与排查技巧实录
4.1 授权阶段的典型报错
授权阶段最常见的报错是 invalid_request 和 invalid_grant。invalid_request 通常是参数缺失或格式不对,比如 redirect_uri 和注册的不一致、code_challenge_method 拼错、response_type 不是 code。排查时把请求参数逐项对照文档,重点看 redirect_uri 有没有多斜杠、少斜杠、http 和 https 混用。
invalid_grant 通常是授权码的问题,比如码过期、码已用过、code_verifier 对不上。码的有效期通常很短,几分钟,超时就得重新走流程。码是一次性的,用过就失效,重复用会报错。code_verifier 对不上是最隐蔽的,因为码本身没问题,就是换不出 token。排查时把 verifier 和 challenge 都打日志,确认是同一对。
还有一个坑是时钟偏移。如果服务器时间不准,token 的签发时间和校验时间对不上,可能报 token 未生效或已过期。我们有一次测试环境的时间慢了十几分钟,token 一直报过期,查了半天才发现是时间问题。部署时确保服务器开了时间同步。
4.2 MCP 调用阶段的典型报错
MCP 调用阶段常见的报错是 method not found 和 invalid params。method not found 说明方法名不对,或者服务端没注册这个方法。排查时先确认客户端发的方法名,再确认服务端注册的方法名,两边对上才行。大小写敏感,tools/list 和 Tools/List 是两个不同的方法。
invalid params 说明参数不符合 schema。排查时把客户端发的参数和服务端的 schema 对照,看缺了哪个必填字段、哪个字段类型不对、哪个字段格式不符合 pattern。我们有个工具的参数是枚举值,客户端传了不在枚举里的值,报 invalid params,后来在 schema 里把枚举列全,并在 description 里说明可选值,问题就少了。
还有一个坑是响应体过大。MCP 的响应如果太大,客户端可能处理不了,或者传输超时。我们有个工具返回的数据有几十兆,客户端直接卡死。后来加了分页,每次最多返回一百条,问题解决。设计工具时就要考虑返回数据量,别等出问题再改。
4.3 跨栈联调的排查思路
跨栈联调最怕的是不知道问题出在哪一层。我们的排查思路是逐层隔离。先用 curl 直接调 Go 服务,确认服务本身没问题。再用 Postman 走授权流程,确认授权服务器没问题。最后用 Next.js 页面走完整流程,确认前端没问题。这样一层层排除,问题范围会迅速缩小。
日志要打全。Go 侧每个请求进来打一条,出去打一条,带上请求 id,方便串联。Next.js 侧每个 fetch 前后打日志,带上 URL 和状态码。授权服务器如果有日志,也打开。三边的日志对起来,问题基本无处遁形。
提示:请求 id 是跨栈排查的利器。我们在 Go 侧生成一个 request id,通过响应头返回,Next.js 侧把它记下来。出问题时,拿着这个 id 去各层日志里搜,能快速定位。
还有一个技巧是用最小复现。把出问题的请求参数精简到最少,去掉无关字段,看问题是否还在。很多时候精简着精简着,就发现是某个看似无关的字段导致的。这个方法在排查参数校验问题时特别有效。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 授权跳转失败 | redirect_uri 不匹配 | 对照注册值逐字符比对 |
| 换 token 报 invalid_grant | verifier 对不上 | 打日志确认 verifier 一致 |
| 接口报 401 | token 无效或过期 | 检查 token 有效期和时钟 |
| 方法不存在 | 方法名拼写错误 | 对照协议规范确认 |
| 参数无效 | schema 不匹配 | 对照 schema 逐字段检查 |
| 响应超时 | 数据量过大 | 加分页或限制返回条数 |
| 回调 state 不匹配 | session 丢失 | 检查 session 存储配置 |
这张表是我们踩坑踩出来的,每一条都对应一次真实的排查经历。遇到问题时先查表,能省不少时间。
5. 实操心得与后续扩展
5.1 几个值得记住的经验
第一个经验是协议实现要严格。JSON-RPC 和 MCP 的规范看着简单,但细节很多。严格按规范实现,能避免大量兼容性问题。我们一开始有些地方图省事,没按规范来,结果换个客户端就出问题。后来老老实实按规范改,兼容性立刻好了。
第二个经验是鉴权要早做。我们一开始想着先把功能跑通,鉴权后面再加。结果功能跑通后加鉴权,发现很多地方要改,工作量比一开始就做鉴权大得多。鉴权是横切关注点,越早做越好。
第三个经验是日志要打够。跨栈项目出问题时,日志是唯一的线索。日志打少了,排查全靠猜。日志打多了,又淹没在信息里。我们的做法是每个关键节点打一条,带上请求 id 和关键参数,不多不少。
第四个经验是验证要自动化。手动验证一次两次还行,次数多了容易漏。我们后来把端到端验证写成了脚本,每次改动后跑一遍,省心很多。脚本不用太复杂,能覆盖主要路径就行。
5.2 后续可以扩展的方向
这套东西跑通后,能扩展的方向不少。一是支持更多工具,把业务能力逐步暴露出去。二是支持流式响应,对于耗时长的工具,用流式返回中间结果。三是支持多租户,不同租户的 token 对应不同的数据权限。四是加监控和告警,把调用量、错误率、延迟这些指标采集起来,出问题能第一时间知道。
流式响应这块值得多说一句。MCP 协议支持流式,但实现起来比普通响应复杂。要在传输层用 chunked 编码,协议层要支持分片消息。我们暂时没做,但评估过,技术上可行,主要是工作量问题。如果后续有耗时工具的需求,会优先做这块。
多租户这块,核心是在 token 里带上租户标识,业务层根据租户标识做数据隔离。OAuth 的 scope 机制可以用来做权限控制,不同 scope 对应不同的工具访问权限。这块设计好了,扩展性会很好。
监控这块,我们打算用 Prometheus 采集指标,Grafana 做展示。Go 侧有现成的 Prometheus 客户端库,接入不难。关键是指标要设计好,哪些指标有意义、哪些是噪音,要提前想清楚。调用量、错误率、P95 延迟这三个是基础,先做这三个。
5.3 给后来者的几句实在话
跨栈接入这种事,难的不是某一层的技术,而是层与层之间的衔接。每一层单独看都不复杂,但拼在一起,问题就多了。我的建议是,先把链路画出来,标清楚每一层的输入输出,然后逐层实现、逐层验证。别想着一步到位,那只会让你在某个深夜对着日志怀疑人生。
还有一点,文档要边做边写。我们一开始想着做完再写文档,结果做完后很多细节都忘了,写出来的文档干巴巴的。后来改成边做边写,把踩过的坑、做过的决策都记下来,文档质量高了很多,回头看也更有价值。
最后,别怕返工。我们这次项目中途改过两次方案,一次是鉴权从 API Key 换成 OAuth,一次是前端从客户端直调改成服务端转发。每次改都花了时间,但改完后确实更稳。方案不是定下来就不能动的,发现更好的路,该改就改。