说明:本文在 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,指向受保护资源元数据。
curl -i -X POST https://api.apimodels.app/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'返回(节选):
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 里拿到授权服务器地址:
{
"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 本身:
client_id = "mcp_" + base64url({ name, redirectUris }) + "." + HMAC后续授权和换 token 时,服务器先验 HMAC,再从 client_id 里解出回调地址做比对。伪造或改过的 client_id 验签失败,数据库里什么都不用存。HMAC 的密钥由站点主密钥加用途标签派生,不同用途的签名不会互相通过。
回调地址的白名单规则(节选自 src/lib/oauth.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 不是另起一套的 JWT,而是新建的一把普通 API key,名字形如 MCP · <客户端名> · <日期>。这样做有三个好处:
代价是没有 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 分钟。
我们用的是无状态模式:sessionIdGenerator 设为 undefined,每个 POST 新建一个 McpServer 和 transport,响应结束就关掉。没有会话,GET(独立 SSE 流)和 DELETE(结束会话)都直接返回 405。好处是任意一个实例都能处理任意请求,重启或滚动发布时也没有会话可丢。
调用者的 token 放在 AsyncLocalStorage 里,跟着这一次请求的整个调用链走。工具内部调 API 时从这里取 key,远程模式下取不到就直接报错,绝不回退到服务器本机的 key。节选自 src/http.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"来解决。
stdio 模式下,工具参数里传一个本地路径,服务端就能直接读文件。到了远程模式,同样一个路径读的是我们服务器上的文件,这是安全漏洞。所以远程模式下,凡是文件路径,以及 localhost、127.x、10.x、192.168.x、172.16–31.x、.local 这类私有地址,一律拒绝。拒绝时的报错要写给 Agent 看,告诉它下一步该做什么:
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:
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。
视频生成通常要几分钟,但工具调用链路上有三道时间限制:
tool_timeout_sec)。0.2.x 及更早的版本会阻塞等待,最长 5 分钟。结果是调用要么被客户端在 60 秒时杀掉,要么 5 分钟到了视频还没好。两种情况下任务都在后台照常跑完、照常扣费,Agent 却拿不到结果 URL。
现在的做法是把生成拆成两步。生成工具提交任务后最多等 wait_seconds,远程模式的上限是 50 秒;到点还没完成,就返回 task id 和一段写给 Agent 的说明,由 Agent 调用 get_task 继续等。说明里明确写"不要重新提交",因为任务还在跑,完成时只扣一次费。返回给 Agent 的文字(节选):
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 元数据。
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:
claude mcp add --transport http apimodels https://api.apimodels.app/mcp
# 然后在 Claude Code 里输入 /mcp → apimodels → AuthenticateCodex(写进 ~/.codex/config.toml,再运行 codex mcp login apimodels):
[mcp_servers.apimodels]
url = "https://api.apimodels.app/mcp"浏览器会打开授权页,点一次 Authorize 就完成登录,全程不用复制 key。
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).:单个文件超过上限,先压缩或裁剪再上传。你在把 MCP Server 搬到远程时,还遇到过哪些 stdio 时代没有的问题?欢迎在评论区交流。
源码:apimodels-mcp 的 GitHub 仓库;远程接入文档:apimodels.app 的 MCP 接入文档。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。