mcp-scan-demo
by Tigo9527
README.md
# MCP Demo 服务(TypeScript)
一个**开箱即跑**的 MCP(Model Context Protocol)服务端示例:
- 使用官方 **`@modelcontextprotocol/sdk`**,基于 **Streamable HTTP** 传输(不自己造轮子)
- 内置**认证管理模块**:JWT 签发/校验、Bearer 鉴权、**账号密码注册 / 登录**、一键注册
- 实现 **MCP 标准授权发现(OAuth 2.1 Discovery)**:RFC 9728 受保护资源元数据 +
RFC 8414 授权服务器元数据 + RFC 7591 动态客户端注册 + RFC 7636 PKCE + RFC 8707 `resource`,
Codex / Claude Desktop / MCP Inspector 等客户端可**自行发现并完成登录**
- 接入 **GitHub OAuth 2.0**(用 `simple-oauth2`,可在 Admin 管理端**在线配置**,既可注册也可登录)
- **Admin 管理端**(带页面):用户列表/详情、GitHub 参数设置、签发令牌、调用统计总览
- **用户 Profile 页面**:查看自己的资料与**个人维度**调用统计
- **MCP 调用统计**:按用户维度 + 总计(请求数 / 工具调用 / 按天 / 最近调用)
- 用 **Codex** 跑集成测试,测试会把**可访问 URL** 打印出来供用户打开
## 目录结构
```
src/
config.ts 运行时配置(端口 / JWT 密钥 / 实例 ID / admin 令牌)
db.ts 存储层(Sequelize ORM:自动建表 + 防抖写库 + 旧 JSON 自动导入;
默认 SQLite,可切 MySQL——见下方「存储后端」)
settings.ts GitHub OAuth 运行时可变设置(落盘)
stats.ts MCP 调用统计(计数器 + 环形缓冲 + 落盘)
auth/
store.ts 用户存储(内存 + 落盘 + 按需落库 + 列表/详情)
context.ts 请求级鉴权上下文(AsyncLocalStorage)
manager.ts 注册 / 签发 / 校验 JWT / 请求鉴权
github.ts GitHub OAuth 2.0 接入(凭据每次现读,state 为自签名 JWT)
mcp/
server.ts MCP 服务端:server_info / login / whoami / register_user / my_stats / search_repos
oauth/
metadata.ts RFC 9728 / RFC 8414 元数据文档 + 401 头取值(改前先读文件头注释)
tickets.ts 自包含加密票据(HKDF 派生密钥 + AES-256-GCM + AAD 绑定用途)
clients.ts RFC 7591 动态客户端注册(client_id 即票据,回环地址忽略端口)
register.ts POST /oauth/register —— DCR 端点
authorize.ts GET/POST /oauth/authorize —— 登录页与授权码签发
token.ts POST /oauth/token —— authorization_code(PKCE S256)/ refresh_token
web/
app.ts Express 入口:门户 / 注册 / GitHub OAuth / /mcp / 挂载 admin 与 profile
http.ts 请求工具:基础地址推导、令牌提取、async 包装、Cookie 读取
layout.ts 共享页面布局 + HTML 转义 esc()
admin.ts Admin 管理端(路由 + 页面)
profile.ts 用户 Profile 页面
index.ts 启动入口(顶部加载 .env)+ 连库预热 + 打印访问 URL 横幅 + 退出前刷库
test/
integration.test.ts 集成测试:健康检查 / 注册 / 握手 / 登录引导 / 405 / XSS / my_stats
admin.test.ts Admin:登录鉴权 / 用户管理 / GitHub 设置 / 统计接口
stats.test.ts 调用统计增量断言 / 按需落库 / Profile 页面
admin-token-guard.test.ts 公网默认令牌防护(503)
setup-page.test.ts 免登录接入页 /setup:无令牌泄露、配置可复制、MCP 工具返回 setupUrl
dotenv.test.ts .env 自动加载(入口 import 顺序 + 平台 env 优先于 .env)
codex.json / AGENTS.md Codex 配置与代理指示
```
## 配置(环境变量 / `.env`)
服务启动时会自动读取项目根目录的 `.env`(`dotenv` 在入口 `src/index.ts` 顶部加载):
```bash
cp .env.example .env # 按需修改;.env 已在 .gitignore 中,不会被提交
npm start # 无需再手动 export / 写前缀
```
两条必须记住的语义:
1. **`.env` 不覆盖已存在的环境变量**(`dotenv` 默认 `override: false`)。
所以部署平台注入的 `JWT_SECRET` / `ADMIN_TOKEN` / `PUBLIC_BASE_URL` **永远优先**于仓库里的 `.env`,
线上配置不会因为仓库里多了个 `.env` 而被悄悄改掉。本地想临时压过 `.env`,照旧用前缀即可:
`PORT=4001 npm start`。
2. **`.env` 只影响本地开发**。公网部署用的是平台注入的环境变量,跟文件无关。
> 实现上 `src/config.ts` 在模块顶层就把 env 快照成常量,因此 `import 'dotenv/config'`
> 必须是 `src/index.ts` 的**第一条** import——重排会让 `.env` 静默失效。`test/dotenv.test.ts` 锁住了这个顺序。
## 快速开始
```bash
npm install
npm start # 启动后控制台会打印可访问 URL,含 /admin 与 /profile
```
打开门户地址(默认 http://localhost:3000/):
### 接入 MCP 客户端(免登录,可直接转发)
新用户不需要先注册就能连接:支持标准 OAuth Discovery 的客户端会在第一次请求时被
**自动引导登录**,无需任何手工配置。
所以准备了一个**免登录、不含任何令牌**的接入页:<https://你的域名/setup>
- 页面直接给出可一键复制的 `mcpServers` 配置(URL 里没有 token)
- 三步引导:复制配置 → 客户端自动弹登录页 → 不支持的客户端手工填令牌
- 附 `curl` 冒烟命令与常见坑(405 是正常的、别用 `Authorization` 头、401 是登录入口不是故障)
- 首页 `/` 的「MCP 客户端配置」卡片里也放了一份同样的配置块
> 这个链接可以放心发给任何人——页面里没有任何令牌。测试 `test/setup-page.test.ts`
> 专门断言了页面与首页**不出现** `mcp_demo_` 开头的真实令牌。
### 用户侧:注册与登录(三种方式)
| 方式 | 入口 | 说明 |
| --- | --- | --- |
| **账号密码注册** | `GET /register` → 填写表单 `POST /register` | 用户名(3~32 位,仅字母/数字/`_`/`-`)+ 密码(≥8 位);口令以 scrypt 哈希存储,绝不回显、绝不进 JWT |
| **账号密码登录** | `GET /login` → 填写表单 `POST /login` | 成功后直接返回 Bearer 令牌 |
| **GitHub 注册 / 登录** | `/auth/github` | 首次即注册,之后每次登录复用同一账号(`search_repos` 等需 GitHub 权限的工具可用) |
| 一键注册(Agent 用) | `/register?username=alice` 或 MCP `register_user` 工具 | 直接发令牌、**无密码**,供钉钉等 AI Agent 自动注册 |
> 网页注册/登录均为 `POST` 表单,配合 `SameSite=Lax` Cookie + Origin 校验防 CSRF;口令只经表单提交,绝不以 URL / `GET` / `Authorization` 传递。
> **登录态自动保持**:注册 / 登录(账号密码、GitHub、钱包)成功后会下发会话 Cookie
> `mcp_demo_user`(HttpOnly + SameSite=Lax + 7 天),之后直接访问 `/profile`、`/recharge`
> 就是登录态,不必再往 URL 上拼 `?token=`(令牌也不该进浏览器历史)。
> `/mcp` 端点**刻意不读**这份 Cookie —— 否则第三方页面能带着浏览器登录态调受保护工具(CSRF),
> MCP 客户端必须显式带令牌。退出登录走 `/logout`。
>
> 注意:部署平台网关会把响应里的 `SameSite=Lax` 改写成 `SameSite=None`(跨站也会带 Cookie),
> 所以 CSRF 防护不靠 SameSite,而是靠「`/mcp` 不读 Cookie」+「所有写路由校验 Origin」两道。
1. 在 `/register`(或 `/auth/github`)完成注册 → 拿到 Bearer 令牌(浏览器已自动登录,页面上有「查看我的 Profile →」入口)
2. 把令牌拼到端点 URL 后,或放到请求头 `X-Authorization`:
```json
{
"mcpServers": {
"mcp-demo": {
"url": "http://localhost:3000/mcp?token=<你的令牌>",
"transport": "streamable-http"
}
}
}
```
### Admin 管理端
- 登录地址 `/admin`,令牌来自环境变量 `ADMIN_TOKEN`(本地不设置时用默认 `dev-admin-change-me`)。
- 功能:用户列表(搜索 / 详情 / 签发新令牌 / 删除)、GitHub OAuth 参数在线设置、
调用统计总览(总计卡片 + 工具 Top + 调用方 Top + 最近 14 天 + 最近调用)。
### 用户 Profile
- 登录后访问 `/profile` 查看自己的资料、按工具/按天的个人统计,并复制 MCP 客户端配置(登录态由会话 Cookie 保持,无需拼 `?token=`)。
- 充值页会在转账框下方显示该钱包在收款资产上的可用余额(ERC20 走 `balanceOf`,原生币走
`eth_getBalance`,都用服务端配置的 RPC 读)。余额不足会被拦下;**读不到则只提示、不阻拦**——
RPC 抖一下不该让用户充不了值。
- 充值页在钱包转账前会比对 `eth_chainId`:不在收款链上就自动唤起切换网络,
钱包里没这条链时请求添加(RPC 用充值设置里配的地址)。链 ID 由保存配置时从 RPC 自动识别,
手填与 RPC 不一致时以 RPC 为准——填错链等于让用户把钱转到另一条链上。
### 调用统计
- 总计与用户维度在 Admin 仪表盘查看;个人维度可在 MCP 客户端调用 `my_stats` 工具,或在 `/profile` 页查看。
### GitHub OAuth(可选,可在线配置)
1. 在 https://github.com/settings/developers 新建 OAuth App,回调填 `<base>/auth/github/callback`
2. 二选一配置凭据:
- 在 `.env`(或环境变量)填 `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`(启动时读入)
- 或登录 **Admin 管理端 → GitHub 设置**在线填写(**立即生效,无需重启**)
3. 访问 `/auth/github` 完成登录,登录后返回的令牌即可调用 `search_repos`
## 标准 OAuth 2.1 授权发现(Authorization Discovery)
不带令牌请求 `/mcp` 会拿到 **401 + `WWW-Authenticate`**,客户端据此自行完成整条登录流程:
```
客户端 ──POST /mcp──▶ 401 WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource/mcp"
──GET 该地址──▶ { resource, authorization_servers: ["https://host"] } (RFC 9728)
──GET /.well-known/oauth-authorization-server──▶ { authorization_endpoint, token_endpoint,
registration_endpoint, … } (RFC 8414)
──POST /oauth/register──▶ { client_id } (RFC 7591 DCR)
──浏览器导航 /oauth/authorize?code_challenge=…&resource=…──▶ 登录页 (RFC 7636 PKCE)
──POST /oauth/token(code + code_verifier + resource)──▶ access_token (RFC 8707)
──POST /mcp(Authorization: Bearer …)──▶ 200
```
| 端点 | 说明 |
| --- | --- |
| `GET /.well-known/oauth-protected-resource/mcp` | 受保护资源元数据(canonical 路径版本,主用) |
| `GET /.well-known/oauth-protected-resource` | 同上,给把 canonical 当成纯 origin 的客户端兜底 |
| `GET /.well-known/oauth-authorization-server` | 授权服务器元数据;`/.well-known/openid-configuration` 是同内容别名 |
| `POST /oauth/register` | 动态客户端注册,免鉴权,只发公开客户端(`token_endpoint_auth_method: none`) |
| `GET/POST /oauth/authorize` | 登录页 + 授权码签发(授权码 60 秒有效) |
| `POST /oauth/token` | `authorization_code`(强制 PKCE S256)与 `refresh_token` 两种 grant |
几条实现上的取舍,改代码前值得知道:
1. **元数据地址必须带 `/mcp` 后缀**。canonical URI 是 `https://host/mcp`,按 RFC 9728 §3.1
well-known 要插在 host 与 path 之间;只挂根路径会让客户端 404 后放弃发现。
2. **401 头里只给 `resource_metadata`**,授权服务器地址由客户端从 PRM 派生。
3. **AS 元数据里刻意不声明 `client_id_metadata_document_supported`** —— 官方 SDK 一见到它
就走 CIMD,绕过我们的 DCR 端点。
4. **受众(`aud`)不符返回 403 而不是 401**。官方 SDK 有熔断:鉴权流程完成后再收到 401 会
直接抛错而不是重跑流程,401 会把「受众不符」伪装成「还没登录」,表现为死循环。
5. **所有 OAuth 状态都是自包含加密票据**(`src/oauth/tickets.ts`),不落盘。
`persist.ts` 无锁无 CAS(并发写会互相覆盖)、`settings.json` 每个进程只读一次,
所以必须用「签名的、自带内容的」票据。代价见「已知限制」。
想关掉强制登录(恢复旧的匿名握手,例如给钉钉这类不带 OAuth 的客户端用):
```bash
MCP_DEMO_REQUIRE_AUTH=off npm start # 默认 on
```
验证是否真的对第三方客户端可用(用官方 SDK 的 `auth()` 跑完整流程,不需要人工点浏览器):
```bash
npm run oauth:e2e
```
> ⚠️ **公网部署必须显式注入 `PUBLIC_BASE_URL`**。上面所有地址(canonical resource、
> 元数据 URL、令牌的 `aud`)都由它推导。部署平台网关只转发**内部** host,
> 缺了这个变量会推导出内网地址 —— 表现为客户端发现到的资源是内网域名、令牌 `aud` 对不上而被 403。
## 部署到公网(钉钉等远程客户端测试)
本项目已发布为在线应用,公网地址:
```
https://a8b79d8a477856f1e.app.workbuddy.link
```
> 部署适配:平台网关会改写 `Authorization` 头。本服务已做:
> 1. **鉴权无状态化**:JWT 内嵌用户声明,校验不依赖服务端内存存储,重启后依然有效。
> 2. **MCP 传输无状态化**:每次请求新建独立 transport(关闭会话管理),不依赖进程内会话。
> 3. **鉴权通道避开 `Authorization`**:服务端只认 `X-Authorization` 头或 `?token=` 参数
> (必须带本服务前缀 `mcp_demo_`),网关注入的 `Authorization` 一律忽略。
> 4. **支持匿名握手 + 登录引导**:未携带令牌时不会拒绝连接,而是暴露 `login` 工具、
> 受保护工具返回中文登录引导(含可点击的登录 URL)。
> 5. **存储后端可切换**:默认所有数据集落在 `data/mcp-demo.sqlite`(users / billing / recharge_records /
> recharge_settings / github_settings / stats 六张表),启动自动建表、旧 JSON 自动导入。
> 也可切换为 MySQL(见下方「存储后端」)。存储不存在按进程 / 实例拆分——见下方单实例说明。
### 存储后端(SQLite / MySQL)
存储层由 `src/db.ts` 实现,通过 `STORAGE_DRIVER` 选择方言(默认 `sqlite`):
| 变量 | 说明 | 默认 |
| --- | --- | --- |
| `STORAGE_DRIVER` | `sqlite`(默认)或 `mysql` | `sqlite` |
| `MYSQL_URL` | MySQL 连接串(完整 `mysql://user:pass@host:port/db`),优先于下方各项 | 空 |
| `MYSQL_HOST` / `MYSQL_PORT` | 分项连接参数 | `127.0.0.1` / `3306` |
| `MYSQL_USER` / `MYSQL_PASSWORD` | 账号 / 口令(口令原样传入,不做 trim) | `root` / 空 |
| `MYSQL_DATABASE` | 数据库名 | `mcp_demo` |
`/health` 与 Admin 控制台会回显当前 `db` 方言与连接描述(不含口令)。
**重要限制(务必先读):**
- **单实例持久化**:MySQL 后端沿用「内存态为唯一权威 + 写库为整表替换(事务保护)」的模型,
与 SQLite 一致。**多副本 / 多进程共用同一 MySQL 库会导致跨实例数据覆盖**——整表替换会清掉其它实例
刚写入的行、再写入本进程的内存快照。因此 MySQL 仅适合**单实例**部署,请勿多副本共享同一数据库。
- **SQLite→MySQL 不自动迁移**:若 `DATA_DIR` 下已存在 `mcp-demo.sqlite`(说明之前跑在 SQLite 上、旧 JSON 已迁走),
直接设 `STORAGE_DRIVER=mysql` 会启动失败(fail-fast),避免静默以空库启动丢失数据。请先手动把 SQLite 数据迁到
MySQL,或确认不再需要旧数据后删除该 `.sqlite` 文件再启动。MySQL 启动时会把 `DATA_DIR` 下尚未导入的旧 JSON 按表为空才导入:
- 真正导入过的数据集,其文件移入 `DATA_DIR/_migrated_json/`;
- 表非空而被跳过的文件(可能含额外用户/配置),移入 `DATA_DIR/_migrated_json/_skipped_for_review/`
(待人工处理,**不会留在 `DATA_DIR`**),并写 `_migrated_json/_status.json` 记录每个数据集的
`imported` / `skipped` 标记,后续启动会忽略已处理的数据集,避免清空某表后重启又重放陈旧数据。
- 已有 MySQL 表(早期 `TEXT` 列)如需更大的统计容量,请将 stats 相关列改为 `LONGTEXT`(`DataTypes.TEXT('long')`)。
### 钉钉 MCP 配置(不含 token,靠登录引导)
配置见 `mcp-config-dingtalk.json`,**只有干净的端点 URL,不含任何令牌**:
```json
{
"mcpServers": {
"mcp-demo": {
"url": "https://a8b79d8a477856f1e.app.workbuddy.link/mcp",
"transport": "streamable-http"
}
}
}
```
接入后在钉钉里**无需任何令牌即可连接**,服务以「匿名」身份握手并提供 `login` 工具。
完成登录的流程如下:
1. 在钉钉对话里问「怎么登录 / 你是谁」,AI 会调用 `login` 或 `whoami` 工具,
返回**一键注册链接**(`…/register`)和 GitHub 登录链接(`/auth/github`)。
2. 用户**在浏览器**打开注册链接 → 一键拿到令牌(形如 `mcp_demo_xxx`)。
3. 把令牌填回钉钉的 MCP 配置「鉴权 / 自定义请求头」:
- 推荐用 **`X-Authorization: Bearer <token>`**(不要用 `Authorization`,会被平台网关改写吞掉);
- 或在端点 URL 后追加 `?token=<token>`。
4. 之后钉钉即可调用 `whoami` / `search_repos` / `my_stats` 等受保护工具。
要点:
- 令牌有效期 7 天;无状态 JWT,重新部署后旧令牌仍有效。
- 若钉钉只提供「SSE」类型选项,选它即可(底层仍 POST 到该 URL)。
- 部署时用 `--start-cmd "PUBLIC_BASE_URL=... ADMIN_TOKEN=... npm start"` 注入对外域名与 admin 令牌;
不注入 `ADMIN_TOKEN` 时,公网地址会被判定为非本地环境,Admin 管理端返回 503 禁用。
## 测试
本仓库已配好 Codex:
```bash
npm install -g @openai/codex # 安装 Codex CLI(需配置 CODEX_API_KEY)
npm run test:codex # 等价于 codex exec "...运行 npm test 并回报 URL..."
```
或直接本地运行(无需 Codex):
```bash
npm test
```
测试会启动服务,把可访问 URL 打印到 stdout,并断言:健康检查可用、一键注册返回令牌、
带令牌能调用 `whoami`、Admin 鉴权与用户管理、GitHub 设置即时生效、
调用统计计数正确(通知类不计入、工具调用单列)、Profile 页面转义安全等。
与授权相关的三份测试单独说:
| 文件 | 覆盖 |
| --- | --- |
| `test/oauth.test.ts` | 发现契约:元数据地址(含 `/mcp` 后缀)、AS 必填字段、不声明 CIMD、401 头可被 SDK 正则解析、CORS |
| `test/oauth-flow.test.ts` | 扮演真实客户端跑完 DCR → 授权 → 换令牌 → 调 MCP → 刷新,重点覆盖失败路径(重放 / PKCE 错误 / 篡改 / 过期 / 受众不符) |
| `scripts/oauth-e2e.ts`(`npm run oauth:e2e`) | 用**官方 SDK 的 `auth()`** 真实走一遍完整流程并调通工具 —— 唯一能证明第三方客户端真能自己登录的验证 |
## 暴露的 MCP 工具
| 工具 | 说明 | 是否需要鉴权 |
| --- | --- | --- |
| `server_info` | 返回服务元信息 + 登录入口 | 否(匿名可用) |
| `login` | 返回一键注册页与 GitHub OAuth 登录链接(引导登录) | 否(匿名可用) |
| `whoami` | 返回当前登录用户;未登录时返回登录引导 | 建议(匿名返回引导) |
| `register_user` | 一键注册新用户并返回令牌 | 否(匿名可用) |
| `my_stats` | 查看当前用户自己的调用统计(工具/按天) | 是(需登录) |
| `search_repos` | 用当前用户 GitHub 令牌搜索仓库(演示 OAuth) | 是 + 需 GitHub 登录 |
| `list_cfx_transfers` | 列出某 Conflux Core 账户的原生 CFX 转账记录(ConfluxScan Open API) | 是(需登录) |
| `list_latest_transactions` | 列出 Conflux Core 最新交易(ConfluxScan 浏览器 API) | 是(需登录) |
| `whole_chain_cfx_transfer_list` | 列出全网最新的 CFX 转账记录(ConfluxScan v1/transfer,默认测试网) | 是(需登录) |
| `whole_chain_cfx_holder_list` | 列出全网 CFX 持仓地址排行榜(ConfluxScan stat/top-cfx-holder,默认测试网) | 是(需登录) |
> `/mcp` **默认要求登录**(`MCP_DEMO_REQUIRE_AUTH` 默认 `on`):未携带有效令牌返回 401 +
> `WWW-Authenticate`,由客户端走标准 OAuth 流程。设成 `off` 后恢复匿名握手,
> 此时上表「匿名可用」的工具会照旧返回登录引导。
## 安全说明
- 所有页面输出经过 **HTML 转义**,用户名等用户输入不会造成 XSS。
- Admin 鉴权两通道(`Cookie` `mcp_admin` / `X-Admin-Token` 请求头),令牌比较用 `timingSafeEqual`;
UI 任何链接都**不**把令牌拼进 URL(避免泄漏到地址栏 / 历史 / 截图 / Referer / 平台日志),
登录态靠 `mcp_admin` Cookie 保持。所有副作用操作均为 POST,并有登录失败限流与
`SameSite=Lax` + Origin 校验防 CSRF。
- 公网环境下使用默认 admin 令牌会被直接禁用(503),强制要求注入真实 `ADMIN_TOKEN`。
- 部署平台网关会改写 `Authorization` 头,因此本服务**永不**信任传入的 `Authorization` 头传令牌。
## 已知限制
- **部署平台网关会剥离 `Authorization` 头**(实测:带不带 Cookie 都一样,只认
`X-Authorization` / `?token=` / `?access_token=`,且值需带 `mcp_demo_` 前缀),
而官方 SDK 只会用 `Authorization` 头带令牌。结论:**Discovery 与登录流程在本平台公网能跑通,
最后一跳(带令牌调 `/mcp`)会被网关吞掉**;本地直连 / 自托管无此限制。
- **动态注册的客户端无法单独吊销**:`client_id` 是自包含票据、不落盘,换 `JWT_SECRET`
才能让全部已注册客户端失效。
- **授权码重放只在单进程内被拦截**:「已用授权码」表是进程内 `Map`;防线退化为
「60 秒有效期 + PKCE」——攻击者必须同时截获授权码与 `code_verifier`。
- **refresh_token 不轮换**:同理,无状态方案检测不到旧令牌是否被重放,轮换不会更安全。
- **磁盘**:平台磁盘可能不持久化,重启/重新部署后数据可能丢失(退出前会尽量 flush)。
- `?token=`(user 端访问令牌)会进入浏览器历史与平台访问日志,已用 `Referrer-Policy: no-referrer`
缓解;登录后该令牌会被落到 `mcp_demo_user` Cookie,后续免挂参数。admin 端已改用 `mcp_admin`
Cookie,URL 中不再出现令牌。
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues