mcp-oauth-test
README.md
# FastMCP OIDC Server
这是一个使用 [FastMCP](https://gofastmcp.com/) 编写的、通过 OIDC 登录保护的 MCP server。它使用 FastMCP 的 `OIDCProxy`:MCP 客户端通过服务端暴露的 OAuth 元数据完成认证,实际登录和 token exchange 转发到 QA OIDC provider。
当前已接入 QA MGT OpenAPI,生成 trainee、订单、商品、客户、校园招聘和国际招聘相关 MCP tools。
OIDC discovery 地址已默认配置为:
```text
https://auth-qa.drillinsight.com/.well-known/openid-configuration
```
## 认证准备
先在 `auth-qa.drillinsight.com` 注册 OAuth 应用,并将以下回调地址加入白名单:
```text
http://localhost:8000/auth/callback
```
部署到其他地址时,将 `http://localhost:8000` 替换为 `BASE_URL` 的值。回调地址必须与 FastMCP 的 `BASE_URL` 完全匹配。
## 本地运行
```bash
cp .env.example .env
# 编辑 .env,至少填写 OIDC_CLIENT_ID 和 OIDC_CLIENT_SECRET
uv sync
uv run mcp-oidc-server
```
也可以直接运行模块:
```bash
uv run python -m oidc_mcp_server.server
```
服务默认监听 `http://127.0.0.1:8000`。如果 MCP 客户端运行在其他机器或使用容器,请设置可被客户端访问的 `BASE_URL` 和合适的 `HOST`(例如 `0.0.0.0`)。
## Claude Code 插件
仓库包含一个私有 Claude Code marketplace 和 MCP 插件:
```text
.claude-plugin/marketplace.json
└── plugins/mcp-oauth-test/
├── .claude-plugin/plugin.json
├── .mcp.json
└── README.md
```
插件只负责把 Claude Code 连接到已经部署的远程 MCP 服务,不会在本地启动 Python 服务。开发测试时可以直接加载:
```bash
claude --plugin-dir ./plugins/mcp-oauth-test
```
插件已固定连接到开发环境 MCP Server:
```text
https://api-mcp-oauth-dev.beaconfireinc.com/mcp
```
也可以从私有 marketplace 安装:
```text
/plugin marketplace add /path/to/mcp-oauth-test
/plugin install mcp-oauth-test@authsome-internal
```
当前 marketplace 根目录就是仓库根目录。请保持该 marketplace 在公司私有 GitHub 仓库中,不要提交到公开 marketplace。共享环境应使用 HTTPS 地址,并在公司 IdP 和 MCP Server 侧限制公司用户访问。
## 为什么使用 OIDCProxy
上游 `auth-qa.drillinsight.com` 不需要支持 DCR 或 CIMD。`OIDCProxy` 正是用于这种场景:
```text
ChatGPT ── MCP OAuth / CIMD ──> FastMCP OIDCProxy
│
└── 固定 client_id/client_secret ──> auth-qa.drillinsight.com
```
需要在上游提前注册的只有 FastMCP 这个 OAuth 应用,并配置 `${BASE_URL}/auth/callback`。ChatGPT 使用的 CIMD 由 FastMCP 代理层处理,不会转发给上游 OAuth server。
## ChatGPT CIMD 配置
在 ChatGPT 创建自定义 MCP 时,OAuth 高级设置中的“客户端注册”请选择:
```text
客户端标识元数据文档(CIMD)
```
当前 ChatGPT 连接器生成的信息为:
```text
CIMD Client ID / 客户端元数据 URL:
https://chatgpt.com/oauth/0Buhw3sHVv1-/client.json
ChatGPT Callback URL:
https://chatgpt.com/connector/oauth/0Buhw3sHVv1-
```
CIMD URL 本身就是 ChatGPT 访问 FastMCP OAuth 代理时使用的 `client_id`。它不需要、也不应该注册到上游 `auth-qa.drillinsight.com`。
本项目存在两层不同的 OAuth Client ID:
| OAuth 链路 | `client_id` | 配置位置 |
| --- | --- | --- |
| ChatGPT → FastMCP OIDCProxy | `https://chatgpt.com/oauth/0Buhw3sHVv1-/client.json` | ChatGPT 自动提供,选择 CIMD 后无需手动填写 |
| FastMCP OIDCProxy → `auth-qa.drillinsight.com` | `app_74a4b555-5b87-4212-9dda-d584fa78caf8` | MCP Server 的 `OIDC_CLIENT_ID` |
对应的数据流为:
```text
ChatGPT
│ client_id=https://chatgpt.com/oauth/0Buhw3sHVv1-/client.json
▼
FastMCP OIDCProxy
│ client_id=app_74a4b555-5b87-4212-9dda-d584fa78caf8
▼
auth-qa.drillinsight.com
```
MCP Server 的环境变量配置:
```bash
OIDC_CLIENT_ID=app_74a4b555-5b87-4212-9dda-d584fa78caf8
OIDC_CLIENT_SECRET=<上游 OAuth Server 颁发的客户端密钥>
```
上游 OAuth Server 只需为该 `app_...` 应用配置 FastMCP 的回调地址:
```text
https://heroic-verbally-crawdad.ngrok-free.app/auth/callback
```
不要将 ChatGPT 的回调地址 `https://chatgpt.com/connector/oauth/...` 配置到上游 OAuth Server;该地址由 FastMCP 代理层在完成认证后使用。
认证开始时,正常日志应先出现 ChatGPT 的 CIMD Client ID:
```text
CIMD document fetched and validated
GET /authorize?client_id=https://chatgpt.com/oauth/.../client.json ... 302
```
随后 FastMCP 才会使用 `app_74a4b555-...` 跳转到上游 OAuth Server。
## MCP 客户端配置
将 MCP 地址配置为:
```text
http://localhost:8000/mcp
```
FastMCP 会提供以下认证发现地址:
```text
http://localhost:8000/.well-known/oauth-authorization-server
http://localhost:8000/.well-known/oauth-protected-resource/mcp
```
客户端应自动读取这些 MCP/OAuth discovery endpoint。登录成功后可调用两个受保护工具:
- `ping`:健康检查。
- `who_am_i`:返回 FastMCP 从当前认证 token 中提取的 `client_id`、scope 和 claims。
## 配置项
| 环境变量 | 必需 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `OIDC_CLIENT_ID` | 是 | - | 上游 OIDC 客户端 ID |
| `OIDC_CLIENT_SECRET` | 二选一 | - | confidential client secret |
| `JWT_SIGNING_KEY` | 二选一 | - | public PKCE client 或生产环境的 FastMCP token 签名密钥 |
| `OIDC_CONFIG_URL` | 否 | QA discovery URL | OIDC discovery 地址 |
| `BASE_URL` | 否 | `http://localhost:8000` | MCP server 公网地址 |
| `OIDC_REQUIRED_SCOPES` | 否 | `openid` | OAuth 授权请求/默认广告的 scope;不用于 access token scope 校验 |
| `OIDC_TOKEN_ISSUER` | 否 | OIDC discovery issuer | JWT `iss` 校验值;auth middleware 改写 issuer 时设置 |
| `OIDC_JWKS_URI` | 否 | `https://auth-qa.drillinsight.com/oauth/jwks` | 自定义 token issuer 的 JWKS 地址 |
| `OIDC_TOKEN_AUDIENCE` | 否 | - | 可选 JWT `aud` 校验值 |
| `HOST` | 否 | `127.0.0.1` | 监听地址 |
| `PORT` | 否 | `8000` | 监听端口 |
| `MGT_API_BASE_URL` | 否 | QA MGT 地址 | MGT API 实际调用 Base URL |
| `MGT_OPENAPI_SPEC_PATH` | 否 | `specs/mgt-qa-openapi.json` | 本地 OpenAPI spec 路径 |
生产环境请显式设置随机的 `JWT_SIGNING_KEY`,并使用 HTTPS 的 `BASE_URL`。不要把 `.env` 或任何 client secret 提交到 Git。
### 自定义 Token Issuer
如果 token 的 `iss` 不是 OIDC discovery 返回的 issuer,而是由 auth middleware 改写成租户地址,例如:
```text
实际 token iss:
https://api-authsome-qa.drillinsight.com/auth-middleware/t_adecdb63-afab-4346-a1aa-b50bbbae7aee/
```
设置:
```bash
OIDC_TOKEN_ISSUER=https://api-authsome-qa.drillinsight.com/auth-middleware/t_adecdb63-afab-4346-a1aa-b50bbbae7aee/
OIDC_JWKS_URI=https://auth-qa.drillinsight.com/oauth/jwks
```
`OIDC_CONFIG_URL` 仍用于 OAuth 登录和授权端点发现;`OIDC_TOKEN_ISSUER` 只用于 JWT access token 的 `iss` 校验。两者可以不同。`OIDC_TOKEN_ISSUER` 必须与 token 中的 `iss` 完全一致,包括末尾 `/`。
当前项目不会校验 access token 的 `scope` 或 `scp` claim,因为 MGT 历史 Token 使用的是非标准 scope 格式。`OIDC_REQUIRED_SCOPES` 仍用于 OAuth 授权请求,但不会阻止缺少标准 scope claim 的有效 Token。签名、issuer、audience、过期时间和 JWKS 校验仍然保留。
## QA MGT OpenAPI 接入
QA OpenAPI spec 已固定保存到:
```text
specs/mgt-qa-openapi.json
```
MCP 不会在运行时访问线上 `/api-docs`,因此未来生产环境不开放 API 文档也不影响运行。通过 `MGT_API_BASE_URL` 切换实际 API 地址即可。Docker 镜像会将 `specs/mgt-qa-openapi.json` 复制到 `/app/specs/mgt-qa-openapi.json`,并自动设置 `MGT_OPENAPI_SPEC_PATH`。
第一版暴露的 API 范围:
```text
/api/v1/user/current
/course/list
/batch/list
/batch/trainee/list
/equity/userequity/give
/api/v1/order/**
/api/v1/item/**
/api/v1/open/getSku*
/api/v1/customers
/api/v1/campus-recruitment/**(排除 export)
/api/v1/recruitment-info/**(排除 export)
```
订单支付链接接口按当前需求接入:
```text
/api/v1/order/queryPayLink
/api/v1/order/reGenaratePayLink
```
仍然排除退款、支付回调和客户数据导出接口:
```text
/mall/v1/order/refund
/alipay/**
/stripe/**
/weixin/refund/**
/api/v1/customers/export
/api/v1/campus-recruitment/export
/api/v1/recruitment-info/export
```
每次调用 MGT 时,OpenAPI client 会从当前 FastMCP 请求中取得用户的上游 OAuth access token,并发送:
```http
Authorization: Bearer <user access token>
X-Application-Id: <token.app_id>
```
其中 `X-Application-Id` 不需要额外配置,直接从已验证 JWT 的 `app_id` claim 读取。没有 `app_id` 的 Token 会被拒绝,避免向 MGT 发送不完整的请求。
因此 MGT 必须信任 `auth-qa.drillinsight.com` 签发的用户 Token,并按照用户身份执行权限控制。
## ChatGPT CIMD timeout 排查
如果日志包含:
```text
CIMD fetch failed for https://chatgpt.com/.../client.json: Timeout fetching
Unregistered client_id=https://chatgpt.com/.../client.json
```
说明 FastMCP 无法直接访问 ChatGPT 托管的客户端元数据。若当前机器必须通过受信任的出站代理访问外网,请配置:
```bash
FASTMCP_SSRF_TRUST_PROXY=true
HTTPS_PROXY=http://127.0.0.1:7897
```
然后彻底停止并重新启动服务。程序会在导入 FastMCP 之前自动加载项目根目录的 `.env`;FastMCP 默认会对 CIMD/JWKS 请求进行 DNS 校验和 IP 固定,因此不会自动使用普通代理环境变量。开启该选项后,会把 SSRF 防护责任交给指定代理,并忽略 `NO_PROXY`。仅可对可信代理开启。
日志中的首次 `POST /mcp 401` 是客户端在认证前探测受保护资源;对多个 `/.well-known/...` 地址的 404 也是 ChatGPT 的兼容性探测。只要 `/.well-known/oauth-authorization-server` 返回 200,它们不是本次失败原因。
如果日志显示 `Unregistered client_id=app_...` 或其他非 URL client ID,说明 ChatGPT 缓存了已从服务端存储中丢失的旧 DCR 注册。固定 `JWT_SIGNING_KEY`、重启服务,然后在 ChatGPT 中删除并重新创建该自定义 MCP,使其重新调用 `/register`。仅重试登录不会恢复服务端未知的旧 client ID。
## 测试
```bash
uv run pytest
```
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing