首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >本地 stdio MCP 改成远程服务:OAuth 自动登录、本地文件和长任务超时怎么处理

本地 stdio MCP 改成远程服务:OAuth 自动登录、本地文件和长任务超时怎么处理

原创
作者头像
用户7679731
发布于 2026-10-25 18:40:14
发布于 2026-10-25 18:40:14
550
举报

说明:本文在 AI 辅助下生成。文中的代码节选来自作者维护的开源项目,接口返回是 2026-10-25 实际请求得到的结果。

本地跑的 stdio MCP Server 有两个绕不过去的限制:用户得先装 Node.js,还得把 API key 存在自己电脑上;ChatGPT、Claude.ai 这类网页客户端只能填 URL,根本起不了本地进程。把它改成远程服务之后,原来不用操心的三件事都要重新设计:谁来登录,本地文件怎么传,几分钟才跑完的任务怎么等。

远程 MCP Server 指的是用 Streamable HTTP 对外提供工具、由客户端自己走 OAuth 完成登录的 MCP 服务。它适合同时给网页端和桌面端 Agent 提供工具、又不想让用户手动复制 key 的场景。下面按登录、无状态、文件、超时四个部分讲具体做法,每部分都附上代码节选。

先说利益关系:下文的实现来自我们自己维护的开源项目 apimodels-mcp(MIT 协议),它是 apimodels.app 的 MCP 服务器,远程地址是 https://api.apimodels.app/mcp。

改造前后的结构

改造后,同一份工具代码有两种跑法:apimodels-mcp 默认走 stdio,apimodels-mcp serve 起一个 HTTP 服务。HTTP 模式下一共四个路径:

  • POST /mcp:Streamable HTTP,无状态,每个请求新建一个 McpServer 和 transport。
  • GET /.well-known/oauth-protected-resource/mcp:RFC 9728 受保护资源元数据,告诉客户端去哪个授权服务器登录。
  • PUT /upload/<token>:接收 Agent 用 curl -T 传上来的本地文件。
  • GET /healthz:健康检查。

授权服务器不在 MCP 服务里,放在主站 apimodels.app 上,负责客户端注册、授权页和换 token。MCP 服务本身只认 Bearer token,不接触用户在网站上的登录态。

登录:让客户端自己找到授权服务器

MCP 客户端发现登录入口的方式,是先不带 token 请求一次,看服务器返回的 401。服务器必须在 WWW-Authenticate 头里带上 resource_metadata,指向受保护资源元数据。

代码语言:bash
复制
curl -i -X POST https://api.apimodels.app/mcp \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'

返回(节选):

代码语言:plaintext
复制
HTTP/2 401
www-authenticate: Bearer resource_metadata="https://api.apimodels.app/.well-known/oauth-protected-resource/mcp"
{"error":"unauthorized","error_description":"Sign in to apimodels to use this server."}

客户端接着读这份元数据,从 authorization_servers 里拿到授权服务器地址:

代码语言:json
复制
{
  "resource": "https://api.apimodels.app/mcp",
  "authorization_servers": ["https://apimodels.app"],
  "bearer_methods_supported": ["header"],
  "resource_name": "apimodels",
  "resource_documentation": "https://apimodels.app/docs/mcp"
}

再去授权服务器的 /.well-known/oauth-authorization-server(RFC 8414)拿到注册、授权和换 token 三个端点。其中 code_challenge_methods_supported 只写 S256,也就是强制 PKCE。

跨域也要处理。网页客户端是在浏览器里发请求的,401 响应必须带 Access-Control-Expose-Headers: WWW-Authenticate,否则前端脚本读不到这个头,发现流程在第一步就断了。

动态客户端注册可以做成无状态

MCP 客户端事先不可能在你这里登记过,所以要支持 RFC 7591 动态注册:客户端把自己的名字和回调地址 POST 上来,换一个 client_id。

我们没有为此建表,而是把客户端名称和回调地址签进 client_id 本身:

代码语言:plaintext
复制
client_id = "mcp_" + base64url({ name, redirectUris }) + "." + HMAC

后续授权和换 token 时,服务器先验 HMAC,再从 client_id 里解出回调地址做比对。伪造或改过的 client_id 验签失败,数据库里什么都不用存。HMAC 的密钥由站点主密钥加用途标签派生,不同用途的签名不会互相通过。

回调地址的白名单规则(节选自 src/lib/oauth.ts):

代码语言:ts
复制
/** https anywhere; http only on loopback (native apps, RFC 8252); app schemes like cursor:// allowed. */
export function redirectUriAllowed(raw: string): boolean {
  let u: URL
  try { u = new URL(raw) } catch { return false }
  if (u.hash) return false
  if (BLOCKED_SCHEMES.has(u.protocol)) return false   // javascript: data: file: …
  if (u.protocol === 'https:') return true
  if (u.protocol === 'http:') return LOOPBACK_HOSTS.has(u.hostname)
  return /^[a-z][a-z0-9+.-]*:$/.test(u.protocol)
}

access token 直接就是一把 API key

授权成功后发出去的 access token 不是另起一套的 JWT,而是新建的一把普通 API key,名字形如 MCP · <客户端名> · <日期>。这样做有三个好处:

  • MCP 服务器只需要验证 API key,不用另写一套 token 校验逻辑。
  • 用户想断开某个客户端,在控制台删掉对应的 key 就行。
  • 用量按 key 统计,哪个客户端花了多少一目了然。

代价是没有 refresh token,key 也不会自动过期,撤销全靠手动删除。

MCP 服务器验 token 的方式是拿它请求一次余额接口,结果按 token 的 SHA-256 缓存:有效的缓存 5 分钟,无效的缓存 1 分钟。上游接口出错时不写缓存,让客户端重试,免得一次网络抖动把好用户锁在外面 1 分钟。这也意味着删掉的 key 在 MCP 服务器上最多还能用 5 分钟。

两个踩过的坑

第一个坑是 Next.js 改写回环地址。Codex 注册时用的回调地址是 http://127.0.0.1:<端口>/callback,但请求到了 Next.js 里,URL 中的 127.0.0.1 和 [::1] 会被改写成 localhost,连查询参数里的值也会被改。结果是授权请求里的 redirect_uri 和注册时的对不上,直接报错。

修法依据 RFC 8252 第 7.3 节:回环地址的回调接受任意端口,并把 localhost、127.0.0.1、[::1] 视为同一个主机;跳回去时用注册时的主机名加请求里的端口,保证浏览器回到客户端真正监听的地址。

第二个坑是复用表带来的 PKCE 绕过风险。授权码复用了命令行登录已有的一次性验证码表,只需要记录"存在、未过期、只用一次"。但命令行的换 key 接口不校验 PKCE,如果 OAuth 授权码的行 ID 和命令行的格式一样,拿到授权码的人就可以绕开 PKCE,从那个接口直接换出 key。所以 OAuth 的行 ID 统一加上 oauth_ 前缀,而命令行接口只接受恰好 32 位十六进制的 ID,两条路互相走不通。发给客户端的授权码还绑定了 client、回调地址和 PKCE challenge,有效期 10 分钟。

无状态 Streamable HTTP:每个请求一个 Server

我们用的是无状态模式:sessionIdGenerator 设为 undefined,每个 POST 新建一个 McpServer 和 transport,响应结束就关掉。没有会话,GET(独立 SSE 流)和 DELETE(结束会话)都直接返回 405。好处是任意一个实例都能处理任意请求,重启或滚动发布时也没有会话可丢。

调用者的 token 放在 AsyncLocalStorage 里,跟着这一次请求的整个调用链走。工具内部调 API 时从这里取 key,远程模式下取不到就直接报错,绝不回退到服务器本机的 key。节选自 src/http.ts:

代码语言:ts
复制
const mcp = deps.buildServer()
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
res.on('close', () => { transport.close().catch(() => {}); mcp.close().catch(() => {}) })
await mcp.connect(transport)
await deps.requestKey.run({ key: token }, () => transport.handleRequest(req, res, body))

无状态的代价是服务器不能主动推送进度通知,长任务只能靠下文的"等一会儿,再交回 task id"来解决。

本地文件:服务器读不到,就给 Agent 一个上传链接

stdio 模式下,工具参数里传一个本地路径,服务端就能直接读文件。到了远程模式,同样一个路径读的是我们服务器上的文件,这是安全漏洞。所以远程模式下,凡是文件路径,以及 localhost、127.x、10.x、192.168.x、172.16–31.x、.local 这类私有地址,一律拒绝。拒绝时的报错要写给 Agent 看,告诉它下一步该做什么:

代码语言:plaintext
复制
This is the hosted apimodels MCP server, so it cannot read files or private URLs on the user's machine. Call get_upload_url, upload the file with the curl command it returns, then pass the URL it prints.

get_upload_url 这个工具只在远程模式下注册。它返回一条 15 分钟有效的上传链接和一行 curl -T 命令;Agent 在用户的终端里跑这条命令,拿到文件的公网 URL,再传给生成工具。

难点在于上传请求是 curl 发的,不经过 MCP 客户端,带不上 OAuth token;而我们又不想为每个链接在库里存一条记录。做法是把调用者的 key 连同过期时间,用 AES-256-GCM 加密封进链接里。节选自 src/upload-token.ts:

代码语言:ts
复制
export function makeUploadToken(apiKey: string, ttlMs = UPLOAD_TTL_MS): string {
  const iv = randomBytes(12)
  const c = createCipheriv('aes-256-gcm', secret(), iv)
  const ct = Buffer.concat([c.update(JSON.stringify({ k: apiKey, e: Date.now() + ttlMs })), c.final()])
  return Buffer.concat([iv, c.getAuthTag(), ct]).toString('base64url')
}

上传处理器解开 token、确认没过期,再以这个用户的身份把文件转存。GCM 自带认证标签,链接被改动一个字节都会解密失败。即使链接泄露,拿到的人也只能在 15 分钟内往这个账号传文件,不能生成、不能花钱、也不能读任何东西。单个文件上限 50 MB,JSON 请求体上限 25 MB。

长任务:每次调用最多等 50 秒

视频生成通常要几分钟,但工具调用链路上有三道时间限制:

  • Codex 默认 60 秒中断一次工具调用(tool_timeout_sec)。
  • MCP SDK 客户端自己的请求超时默认也是 60 秒。
  • 服务放在 Cloudflare 后面时,一个响应超过约 100 秒没有任何数据,就会被直接切断。

0.2.x 及更早的版本会阻塞等待,最长 5 分钟。结果是调用要么被客户端在 60 秒时杀掉,要么 5 分钟到了视频还没好。两种情况下任务都在后台照常跑完、照常扣费,Agent 却拿不到结果 URL。

现在的做法是把生成拆成两步。生成工具提交任务后最多等 wait_seconds,远程模式的上限是 50 秒;到点还没完成,就返回 task id 和一段写给 Agent 的说明,由 Agent 调用 get_task 继续等。说明里明确写"不要重新提交",因为任务还在跑,完成时只扣一次费。返回给 Agent 的文字(节选):

代码语言:plaintext
复制
STILL RUNNING — video task <task_id>, 52s elapsed.
Next: call get_task with task_id "<task_id>" (it waits up to wait_seconds, default 50). Do NOT submit the job again — this task keeps running, is billed once when it completes, and its result stays retrievable for 30 days.

两种模式的上限不一样。本地 stdio 模式不经过 Cloudflare,上限是 900 秒,因为 Claude Code 的 stdio 空闲上限是 30 分钟,留足余量;远程模式固定 50 秒,保证一次调用落在所有客户端和 Cloudflare 的限制之内。另外 Node 的 server.requestTimeout 要设为 0,否则 Node 自己会先把长请求断掉,等待时长交给上面的上限去控制。

验证一下

不登录就能验证发现流程是否正确:第一条命令应该返回 401 和带 resource_metadata 的 WWW-Authenticate,后两条应该各返回一份 JSON 元数据。

代码语言:bash
复制
curl -si -X POST https://api.apimodels.app/mcp -d '{}' | grep -i www-authenticate
curl -s https://api.apimodels.app/.well-known/oauth-protected-resource/mcp
curl -s https://apimodels.app/.well-known/oauth-authorization-server

在客户端里接入,以 Claude Code 和 Codex 为例。

Claude Code:

代码语言:bash
复制
claude mcp add --transport http apimodels https://api.apimodels.app/mcp
# 然后在 Claude Code 里输入 /mcp → apimodels → Authenticate

Codex(写进 ~/.codex/config.toml,再运行 codex mcp login apimodels):

代码语言:toml
复制
[mcp_servers.apimodels]
url = "https://api.apimodels.app/mcp"

浏览器会打开授权页,点一次 Authorize 就完成登录,全程不用复制 key。

apimodels-mcp 适合在哪一步用

apimodels.app 是一个多模型 API 网关:用一个 API key、一个 OpenAI 兼容端点调用图像、视频、语音和语言模型。apimodels-mcp 是它的开源 MCP 服务器,把这些能力包装成 generate_image、generate_video、text_to_speech、chat、get_task 等工具。上文讲的远程登录、上传链接和两步等待,都是它已经上线的行为。

如果你的目标只是让 Claude Code、Codex、Cursor 这类 Agent 能生成图片和视频,不必自己从头实现上面这一套,直接在客户端里加 https://api.apimodels.app/mcp 即可。如果你要做的是自己的远程 MCP Server,可以把它的源码当作一个完整的参考实现:HTTP 服务、上传链接和工具定义分别在 src/http.ts、src/upload-token.ts、src/index.ts 三个文件里。

不适合的情况也说清楚:只调一个模型、而且已经接好官方 SDK 的项目,换过来省不了事;必须在内网离线运行、或单个输入文件超过 50 MB 的场景,远程版本也用不了。

常见报错

  • 401 invalid_token:对应的 key 已在控制台删除。在客户端里重新执行一次登录即可。
  • 405 Method not allowed. POST JSON-RPC to this endpoint.:用 GET 访问了 /mcp。无状态服务没有独立的 SSE 流,只接受 POST。
  • 403 This upload link is invalid or has expired.:上传链接超过了 15 分钟,或者被改动过。重新调用 get_upload_url 获取。
  • 413 File too large (max 50 MB).:单个文件超过上限,先压缩或裁剪再上传。

限制

  • 无状态模式下服务器不能主动推送进度,长任务只能轮询。
  • 没有 refresh token,key 不会过期,撤销需要在控制台手动删除;MCP 服务器的 token 缓存最长 5 分钟,删除后最多还会生效这么久。
  • 本文的行为基于 apimodels-mcp 0.7.0 和 2026-10-25 的线上接口,客户端的超时默认值以各客户端当时的版本为准。

你在把 MCP Server 搬到远程时,还遇到过哪些 stdio 时代没有的问题?欢迎在评论区交流。


源码:apimodels-mcp 的 GitHub 仓库;远程接入文档:apimodels.app 的 MCP 接入文档。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 改造前后的结构
  • 登录:让客户端自己找到授权服务器
    • 动态客户端注册可以做成无状态
    • access token 直接就是一把 API key
    • 两个踩过的坑
  • 无状态 Streamable HTTP:每个请求一个 Server
  • 本地文件:服务器读不到,就给 Agent 一个上传链接
  • 长任务:每次调用最多等 50 秒
  • 验证一下
  • apimodels-mcp 适合在哪一步用
  • 常见报错
  • 限制
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档