Skip to main content
Glama
beaconfire-projects

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
```