
1. 项目缘起与整体设计思路1.1 为什么会有这次跨栈 MCP 接入事情的起因其实很朴素团队内部有一套自研的设计协作工具链日常在蓝湖上标注、在 Figma 上切图、在本地跑 Next.js 前端同时后端有一批 Go 写的服务。产品经理提了个需求希望把设计稿的元数据、切图资源、标注信息通过 MCP 协议暴露出来让 AI 编码助手能直接读取设计上下文减少人肉搬运设计稿的重复劳动。MCP 这个词最近一年在开发者圈子里出现频率极高全称是 Model Context Protocol本质上是给 AI 助手和外部工具之间定义的一套标准通信协议。你可以把它理解成AI 世界的 USB-C 接口——以前每个工具都要为每个 AI 客户端单独写适配现在只要实现一次 MCP Server所有支持 MCP 的客户端都能接。这个类比虽然被用烂了但确实贴切。这次接入的难点不在于 MCP 协议本身而在于它横跨了三个技术栈Go 写的后端服务、Next.js 写的前端 BFF 层、以及第三方 OAuth 授权体系。任何一环出问题整条链路就断。我接手的时候前一位同事留下的只有半份方案文档和一堆 403 报错日志。1.2 核心需求拆解与方案选型先把需求翻译成人话一共三条设计资源可被 AI 读取MCP Server 要能把设计稿的图层、标注、切图 URL 以结构化形式吐出来。授权链路安全可控设计资源属于内部资产不能裸奔必须走 OAuth 2.0 授权且是面向公共客户端的 PKCE 流程。跨栈调用要顺畅Next.js 前端负责用户交互和 token 管理Go 服务负责实际的资源代理和 MCP 协议实现。方案选型上我做了几个关键决策这里把背后的逻辑摊开讲。第一个决策MCP Server 用 Go 写还是用 Node 写网上大量 MCP 教程默认用 TypeScript因为官方 SDK 对 Node 支持最完善。但我们后端主力是 Go团队对 Go 的部署、监控、日志体系已经很成熟。如果为了 MCP 单独引入一套 Node 服务运维成本会翻倍。我查了下社区Go 语言的 MCP 实现虽然不如 Node 丰富但核心的 stdio 和 SSE 传输层已经有可用库自己封装一层完全可行。最终选了 Go用net/http加 SSE 做传输协议层手写 JSON-RPC 2.0 消息处理。第二个决策OAuth 走授权码模式还是 PKCEMCP 客户端很多是本地运行的桌面应用或浏览器扩展属于公共客户端没法安全保存 client_secret。授权码模式在这种场景下会暴露密钥所以必须用 PKCEProof Key for Code Exchange。PKCE 的核心是在授权请求里带一个code_challenge换取 token 时再带code_verifier服务端校验两者匹配才发 token。这样即使授权码被截获没有 verifier 也换不到 token。第三个决策Next.js 层做 BFF 还是纯前端如果纯前端直接调 Go 服务和 OAuth 授权端点会遇到跨域和 token 存储两个麻烦。Next.js 的 Route Handler 天然适合做 BFFBackend for Frontend把 token 存在服务端的 httpOnly Cookie 里前端只跟同源的 Next.js 接口打交道。这样既规避了 CORS又避免了 token 暴露在浏览器 localStorage 里的风险。1.3 整体架构与数据流架构定下来之后是这样的AI 客户端 (MCP Client) │ stdio / SSE ▼ Go MCP Server ──► 设计资源 API (蓝湖/Figma) │ │ HTTP Bearer Token ▼ Next.js BFF (Route Handler) │ │ OAuth 2.0 PKCE ▼ 授权服务器 (openapi 授权端点)数据流分两条一条是授权流用户首次使用时Next.js 发起 PKCE 授权请求跳转到授权服务器用户同意后回调Next.js 用 code verifier 换 token存进 httpOnly Cookie另一条是资源流Go MCP Server 收到 AI 客户端的工具调用请求带上从 Next.js 拿到的 token去设计资源 API 拉数据转成 MCP 格式返回。这个架构的关键在于token 只在 Next.js 服务端和 Go 服务端之间流转永远不进浏览器。这一点在后面的排查中帮了大忙。2. 核心细节解析与实操要点2.1 MCP 协议层到底要处理什么很多人第一次接触 MCP 会懵觉得协议很神秘。其实剥开看MCP 就是一套基于 JSON-RPC 2.0 的约定规定了几个核心方法方法名作用方向initialize握手交换能力声明Client → Servertools/list列出可用工具Client → Servertools/call调用某个工具Client → Servernotifications/initialized握手完成通知Client → Serverresources/list列出可读资源Client → ServerGo 这边我实现的时候核心是维护一个tools注册表每个工具是一个结构体包含名字、描述、输入 schema 和执行函数。收到tools/call请求后根据name路由到对应执行函数把结果包成 JSON-RPC 响应。这里有个容易踩的坑JSON-RPC 的 id 必须原样回传。AI 客户端靠 id 匹配请求和响应如果你自己生成一个新 id客户端会一直等表现为调用卡死。我一开始就是随手写了个自增 id结果调试了半天才发现。另一个坑是错误码的语义。JSON-RPC 定义了标准错误码比如 -32700 是解析错误-32601 是方法不存在。但 MCP 在标准错误码之外还要求工具执行失败时返回isError: true而不是抛 JSON-RPC 错误。这两者的区别是前者是协议层错误后者是业务层错误。搞混了会导致客户端把业务失败当成协议崩溃处理。2.2 PKCE 流程的每个参数都不能马虎PKCE 看起来简单但每个参数都有讲究。我按顺序拆一遍。code_verifier一个高熵随机字符串长度 43 到 128 位字符集限定在[A-Za-z0-9-._~]。我用 Go 的crypto/rand生成 32 字节然后 base64url 编码得到 43 字符。为什么不用 UUID因为 UUID 只有 122 位熵而且格式固定不够随机。code_challenge把 verifier 做 SHA-256 哈希再 base64url 编码。注意是 base64url 不是标准 base64要去掉填充把换成-/换成_。这个细节错了服务端校验必然失败。code_challenge_method固定填S256。虽然规范也允许plain但那就失去 PKCE 的意义了等于明文传输 verifier。state防 CSRF 的随机值授权请求带上回调时比对。这个和 PKCE 是两回事别混。授权请求拼出来大概长这样https://openapi.example.com/oauth/2.0/authorize ?client_idxxx redirect_urihttps://your-app.com/api/auth/callback response_typecode scopedesign.read staterandom_state code_challengexxx code_challenge_methodS256换 token 的请求则是 POSTbody 里带grant_typeauthorization_code、code、redirect_uri、client_id、code_verifier。注意redirect_uri必须和授权请求里完全一致差一个斜杠都会失败。提示PKCE 的 verifier 和 state 必须存在服务端 session 里不能放前端。我见过有人图省事塞进 Cookie 明文等于白做。2.3 Go 侧 SSE 传输的实现要点MCP 支持两种传输stdio 和 SSE。stdio 适合本地进程SSE 适合远程服务。我们选 SSE因为 Go 服务是独立部署的。SSE 在 Go 里实现不难关键是几个响应头w.Header().Set(Content-Type, text/event-stream) w.Header().Set(Cache-Control, no-cache) w.Header().Set(Connection, keep-alive) w.Header().Set(X-Accel-Buffering, no)最后那个X-Accel-Buffering: no是给 Nginx 看的不加的话 Nginx 会缓冲 SSE 流导致消息延迟甚至不推送。这个坑我在测试环境没遇到一上预发就复现了查了半天。SSE 消息格式是data: {json}\n\n注意结尾要两个换行。Go 里用fmt.Fprintf(w, data: %s\n\n, payload)之后必须调flusher.Flush()否则数据留在缓冲区里发不出去。flusher, ok : w.(http.Flusher) if !ok { http.Error(w, streaming unsupported, http.StatusInternalServerError) return } // 每次写完都要 flush flusher.Flush()还有一个细节SSE 连接是长连接要处理客户端断开。用r.Context().Done()监听断开后清理资源否则连接泄漏跑久了服务就崩。2.4 Next.js BFF 层的 token 管理Next.js 这边我用 App Router 的 Route Handler 做 BFF。核心是三个接口/api/auth/login生成 PKCE 参数存 session重定向到授权服务器。/api/auth/callback接收 code用 verifier 换 token存 httpOnly Cookie。/api/proxy/[...path]代理 Go 服务的请求自动附加 Bearer Token。token 存储用 httpOnly Secure SameSiteLax 的 Cookie。为什么是 Lax 不是 Strict因为 OAuth 回调是从外部域跳回来的Strict 模式下 Cookie 不会带上会导致回调后拿不到 session。Lax 允许顶级导航携带 Cookie正好满足回调场景。token 刷新这块要单独说。access_token 一般有效期短refresh_token 长。我在 BFF 里做了自动刷新每次代理请求前检查 token 是否快过期如果是就用 refresh_token 换新的。刷新要加锁避免并发请求同时触发多次刷新导致 refresh_token 失效。我用了一个简单的内存锁let refreshPromise: PromiseToken | null null; async function ensureFreshToken() { if (refreshPromise) return refreshPromise; refreshPromise doRefresh().finally(() { refreshPromise null; }); return refreshPromise; }这个模式叫单飞single-flight保证同一时刻只有一个刷新在跑其他请求等它的结果。3. 实操过程与核心环节实现3.1 从零搭建 Go MCP Server先建项目结构mcp-server/ ├── main.go ├── internal/ │ ├── protocol/ # JSON-RPC 消息定义 │ ├── transport/ # SSE 传输层 │ ├── tools/ # 工具注册与实现 │ └── client/ # 调用设计资源 API └── go.modgo.mod里主要依赖标准库加一个github.com/google/uuid用于生成请求 id。MCP 协议层我自己写没引第三方库因为需求不复杂自己写反而可控。协议层的核心结构体type Request struct { JSONRPC string json:jsonrpc ID json.RawMessage json:id,omitempty Method string json:method Params json.RawMessage json:params,omitempty } type Response struct { JSONRPC string json:jsonrpc ID json.RawMessage json:id,omitempty Result interface{} json:result,omitempty Error *RPCError json:error,omitempty }注意ID用json.RawMessage而不是string因为 JSON-RPC 的 id 可以是数字也可以是字符串用 RawMessage 原样透传最安全。工具注册表type Tool struct { Name string Description string InputSchema map[string]interface{} Handler func(ctx context.Context, args map[string]interface{}) (interface{}, error) } var registry map[string]*Tool{} func Register(t *Tool) { registry[t.Name] t }tools/list就是把 registry 里的工具转成 MCP 要求的格式返回tools/call就是查表执行。3.2 设计资源工具的输入 schema 设计MCP 工具的输入 schema 用 JSON Schema 描述AI 客户端靠这个知道怎么传参。我设计了三个工具get_design_meta获取设计稿元数据。{ type: object, properties: { design_id: { type: string, description: 设计稿唯一标识 }, include_layers: { type: boolean, description: 是否包含图层树, default: false } }, required: [design_id] }list_slices列出切图资源。{ type: object, properties: { design_id: { type: string }, format: { type: string, enum: [png, svg, webp], default: png } }, required: [design_id] }get_annotations获取标注信息。{ type: object, properties: { design_id: { type: string }, layer_ids: { type: array, items: { type: string } } }, required: [design_id] }schema 里的description字段非常重要AI 客户端就是靠它理解参数含义的。写得太简略AI 会传错参数。我一开始design_id只写了设计稿 ID结果 AI 经常把项目 ID 当设计稿 ID 传进来。后来改成设计稿唯一标识格式为 design_xxx不是项目 ID准确率立刻上去了。3.3 OAuth 授权链路的完整实现Next.js 的 login 接口import { randomBytes, createHash } from crypto; function base64url(buf: Buffer): string { return buf.toString(base64) .replace(/\/g, -) .replace(/\//g, _) .replace(//g, ); } export async function GET() { const verifier base64url(randomBytes(32)); const challenge base64url( createHash(sha256).update(verifier).digest() ); const state base64url(randomBytes(16)); // 存 session const session await getSession(); session.pkce { verifier, state }; await session.save(); const params new URLSearchParams({ client_id: process.env.CLIENT_ID!, redirect_uri: process.env.REDIRECT_URI!, response_type: code, scope: design.read, state, code_challenge: challenge, code_challenge_method: S256, }); return Response.redirect( ${process.env.AUTH_ENDPOINT}?${params} ); }callback 接口export async function GET(req: Request) { const url new URL(req.url); const code url.searchParams.get(code); const state url.searchParams.get(state); const session await getSession(); if (state ! session.pkce?.state) { return new Response(state mismatch, { status: 400 }); } const body new URLSearchParams({ grant_type: authorization_code, code: code!, redirect_uri: process.env.REDIRECT_URI!, client_id: process.env.CLIENT_ID!, code_verifier: session.pkce.verifier, }); const tokenRes await fetch(process.env.TOKEN_ENDPOINT!, { method: POST, headers: { Content-Type: application/x-www-form-urlencoded }, body, }); if (!tokenRes.ok) { const err await tokenRes.text(); return new Response(token exchange failed: ${err}, { status: 502 }); } const tokens await tokenRes.json(); session.tokens tokens; await session.save(); return Response.redirect(/); }这里有个细节redirect_uri在授权请求和换 token 请求里必须完全一致。我一开始在授权请求里用了https://app.com/callback换 token 时写成了https://app.com/callback/多了个斜杠服务端直接返回 400。这种错误日志里不会明说只能靠比对。3.4 端到端联调的关键节点联调阶段我按这个顺序推进每步都验证通过再往下MCP 握手用 curl 手动发initialize确认返回能力声明。工具列表发tools/list确认三个工具都在。无授权调用发tools/call确认返回 401说明鉴权生效。授权流程浏览器走一遍 login → 授权 → callback确认 Cookie 里有 token。带授权调用再发tools/call确认返回真实数据。AI 客户端接入配置真实 MCP 客户端跑通完整对话。第 3 步和第 5 步是分水岭。第 3 步验证没 token 进不来第 5 步验证有 token 能出去。这两步都过了链路基本就通了。联调时我用了一个小技巧在 Go 服务里加了个 debug 中间件把每个请求的 method、params、以及最终响应都打到日志里带 trace id。这样出问题时能一眼看出是协议层还是业务层的问题。func debugMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { traceID : uuid.NewString() log.Printf([%s] %s %s, traceID, r.Method, r.URL.Path) ctx : context.WithValue(r.Context(), trace_id, traceID) next.ServeHTTP(w, r.WithContext(ctx)) }) }4. 常见问题与排查技巧实录4.1 授权阶段的典型报错联调过程中踩的坑我整理成速查表报错信息根因解决400 invalid_request参数缺失或格式错逐项比对授权请求参数400 invalid_grantcode 已用过或过期code 只能用一次重新走授权403 request failedscope 不足或 client 未授权检查 scope 配置和 client 权限400 missing_session_idsession 丢失检查 Cookie 的 SameSite 和域名state mismatchsession 里的 state 对不上确认 session 存储正常那个missing_session_id我印象最深。现象是回调时拿不到 session导致 PKCE verifier 丢失。排查发现是 Cookie 的SameSiteStrict导致跨站回调时 Cookie 没带上。改成Lax就好了。这个错误的提示信息很误导它说缺 session id但实际是 Cookie 没传过来。还有一个403是 scope 问题。授权服务器对 scope 做了白名单我请求的design.read没在 client 配置里服务端直接拒绝。这种要在授权服务器的 client 配置里加 scope光改代码没用。4.2 MCP 协议层的诡异现象现象一AI 客户端一直转圈不返回结果。排查思路先看 Go 服务日志确认请求收到了没。如果收到了看响应发出去了没。如果发出去了看 SSE 有没有 flush。我遇到的就是忘了 flush数据卡在缓冲区。加上flusher.Flush()解决。现象二工具调用返回方法不存在。检查tools/list返回的工具名和tools/call请求的工具名是否完全一致。MCP 工具名区分大小写get_design_meta和getDesignMeta是两个不同的工具。我一开始在 schema 里写了下划线在 handler 注册时写成了驼峰对不上。现象三中文返回乱码。SSE 响应头要带charsetutf-8即Content-Type: text/event-stream; charsetutf-8。不加的话某些客户端会按 latin-1 解析中文全乱。现象四长连接跑一段时间后断开。检查有没有设置读超时。Go 的http.Server默认ReadTimeout会掐断长连接。SSE 场景要把ReadTimeout设为 0 或很大同时用WriteTimeout控制单次写超时。srv : http.Server{ Addr: :8080, Handler: mux, ReadTimeout: 0, // SSE 长连接不设读超时 WriteTimeout: 0, // 由业务层控制 IdleTimeout: 120 * time.Second, }4.3 跨栈调试的独家心得跨栈项目最痛苦的是问题出在哪一层说不清。我总结了几个定位技巧。第一给每层加 trace id 并透传。Next.js 生成 trace id通过 header 传给 GoGo 再传给设计资源 API。这样一条链路的所有日志能用同一个 id 串起来。没有这个跨栈排查就是盲人摸象。第二先隔离再联调。不要一上来就端到端跑。先用 curl 单独测 Go 服务用 Postman 单独测 OAuth 流程各自通了再串起来。串起来出问题时因为每层都验证过范围立刻缩小到层与层之间的衔接。第三日志要打全但要有层次。协议层打 method 和 id业务层打关键参数错误层打完整堆栈。全打在一起会淹没重点分层打才能快速定位。第四善用 MCP 客户端的调试模式。很多 MCP 客户端支持打印原始 JSON-RPC 消息打开后能看到请求和响应的原文比看日志直观得多。注意调试阶段可以把 token 有效期调短方便测试刷新逻辑。但上线前一定要改回来我见过有人忘了改token 5 分钟过期用户用一会儿就掉线。4.4 上线前的检查清单正式发布前我列了个清单逐项过[ ] PKCE verifier 和 state 存在服务端 session不在前端[ ] token 存 httpOnly CookieSameSiteLax[ ] refresh 逻辑有单飞锁避免并发刷新[ ] SSE 响应头带X-Accel-Buffering: no[ ] 每次 SSE 写后调 flush[ ] JSON-RPC id 原样回传[ ] 工具执行失败返回isError: true而非协议错误[ ] 长连接处理Context().Done()清理[ ] 日志带 trace id 且分层[ ] 生产环境 token 有效期恢复正常值这份清单看着琐碎但每一条都是踩过坑才加上的。尤其是前三条涉及安全出问题就是事故。5. 性能优化与后续扩展方向5.1 设计资源 API 的调用优化Go 服务调设计资源 API 时最初是每次工具调用都实时拉取延迟高且容易触发限流。后来加了两层优化。第一层是本地缓存。设计稿元数据变化不频繁用内存缓存加 TTL比如 5 分钟。缓存 key 用design_id 参数哈希避免不同参数命中同一份缓存。缓存用sync.Map加过期时间戳实现简单够用。type cacheEntry struct { data interface{} expiresAt time.Time } var cache sync.Map func getCached(key string) (interface{}, bool) { v, ok : cache.Load(key) if !ok { return nil, false } entry : v.(cacheEntry) if time.Now().After(entry.expiresAt) { cache.Delete(key) return nil, false } return entry.data, true }第二层是请求合并。同一时刻多个工具调用请求同一个设计稿合并成一次 API 调用。这个用 single-flight 模式实现和前面 token 刷新是同一个思路。优化后P95 延迟从 800ms 降到 120ms效果明显。5.2 后续可以扩展的能力当前实现只覆盖了读设计资源后续可以往几个方向扩。写回能力让 AI 助手能修改设计稿标注比如批量更新间距、颜色。这需要 OAuth scope 升级到design.writeMCP 工具也要加对应的写操作。多设计平台适配现在只接了蓝湖和 Figma架构上可以抽象一层DesignProvider接口不同平台实现各自的适配器。这样加新平台不用改 MCP 层。资源订阅MCP 支持resources/subscribe客户端可以订阅某个设计稿的变化。设计稿更新时服务端主动推送通知。这对实时协作场景很有用。本地文件工具社区里mcp本地文件是个高频需求可以加一个工具让 AI 读取本地设计规范文件和远程设计稿结合使用。5.3 关于 MCP 生态的一点个人观察做这个项目期间我把社区里各种 MCP 实现都翻了一遍。有个明显感受MCP 的价值不在于协议本身多复杂而在于它把AI 和工具集成这件事标准化了。以前每接一个 AI 客户端就要写一套适配现在写一次 MCP Server 到处能用。但标准化也带来约束。MCP 的工具 schema 是 JSON Schema表达能力有限复杂的参数校验还得在 handler 里自己做。而且不同客户端对 MCP 的实现程度参差不齐有的支持 SSE有的只支持 stdio有的对错误处理不规范。所以做 MCP Server 时兼容性测试要覆盖多个客户端不能只测一个。另外安全这块要格外上心。MCP Server 本质上是把内部能力暴露给 AI如果鉴权没做好等于开了后门。PKCE 只是第一步后续还要考虑工具级别的权限控制、调用频率限制、敏感操作审计。这些在项目初期可能觉得过度设计但真出事的时候有和没有是天壤之别。我在实际使用中发现把 trace id 贯穿全链路这个习惯价值远超预期。不只是排查问题快做性能分析、用户行为分析时也能用上。建议做跨栈项目的同行从一开始就把这个基础设施搭好后面省的事不是一点半点。