Skip to main content
Glama

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 配置与代理指示

Related MCP server: XRPL Data MCP

配置(环境变量 / .env)

服务启动时会自动读取项目根目录的 .env(dotenv 在入口 src/index.ts 顶部加载):

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 锁住了这个顺序。

快速开始

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:

{
  "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 的客户端用):

MCP_DEMO_REQUIRE_AUTH=off npm start   # 默认 on

验证是否真的对第三方客户端可用(用官方 SDK 的 auth() 跑完整流程,不需要人工点浏览器):

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,不含任何令牌:

{
  "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:

npm install -g @openai/codex     # 安装 Codex CLI(需配置 CODEX_API_KEY)
npm run test:codex               # 等价于 codex exec "...运行 npm test 并回报 URL..."

或直接本地运行(无需 Codex):

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 中不再出现令牌。

Related MCP Connectors

Related MCP Servers