6 Commits
Author SHA1 Message Date
gitea-admin 2dee2c27de fix: batch XFT task details and reduce single lookup pages
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
2026-11-05 15:54:23 +00:00
gitea-admin 295310ab9a feat: support gateway-only deployment without client releases
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
2026-10-28 03:44:04 +00:00
gitea-admin 267a65305e fix: raise and configure per-user gateway session capacity 2026-10-28 03:40:04 +00:00
gitea-admin 2213ce7570 fix: reclaim disconnected gateway MCP sessions 2026-10-28 03:05:10 +00:00
gitea-admin 430d45014b test: keep invoice fixtures within the retention window 2026-10-28 03:04:43 +00:00
gitea-admin d796ae16c5 chore: release 1.1.0-rc.20
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
2026-09-03 17:15:25 +00:00
17 changed files with 793 additions and 150 deletions
+2
View File
@@ -22,6 +22,8 @@ XFT_OPEN_API_ENABLED=true
# XFT_OPEN_API_DINGTALK_APP_CODE=DDingTalk # XFT_OPEN_API_DINGTALK_APP_CODE=DDingTalk
# 调优 # 调优
# 每账号 MCP 连接会话上限;1–1000,默认 32,全局总上限固定为 1000。
# MCP_SESSION_MAX_PER_USER=32
# OFFICIAL_OA_LOOKBACK_DAYS=120 # OFFICIAL_OA_LOOKBACK_DAYS=120
# 审批写入批量上限固定为 20,不提供环境变量覆盖。 # 审批写入批量上限固定为 20,不提供环境变量覆盖。
# MCP_ACCESS_TOKEN_TTL_SECONDS=900 # MCP_ACCESS_TOKEN_TTL_SECONDS=900
+41 -7
View File
@@ -1,6 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
gateway_only=false
if [[ "$#" -eq 1 && "$1" == "--gateway-only" ]]; then
gateway_only=true
elif [[ "$#" -ne 0 ]]; then
echo "用法:./deploy/install.sh [--gateway-only]" >&2
exit 1
fi
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
domain="approval.justconn.top" domain="approval.justconn.top"
public_base_url="https://${domain}" public_base_url="https://${domain}"
@@ -18,7 +26,11 @@ npm_cache="${project_root}/.npm-cache"
release_private_key="${project_root}/.secrets/release-signing-private.pem" release_private_key="${project_root}/.secrets/release-signing-private.pem"
release_public_key="${project_root}/distribution/release-signing-public.pem" release_public_key="${project_root}/distribution/release-signing-public.pem"
for command_name in node npm curl systemctl sudo nginx certbot stat sha256sum tar find sort tail cut date git tea; do required_commands=(node npm curl systemctl cut git)
if [[ "${gateway_only}" == false ]]; then
required_commands+=(sudo nginx certbot stat sha256sum tar find sort tail date tea)
fi
for command_name in "${required_commands[@]}"; do
command -v "${command_name}" >/dev/null 2>&1 || { command -v "${command_name}" >/dev/null 2>&1 || {
echo "缺少部署命令:${command_name}" >&2 echo "缺少部署命令:${command_name}" >&2
exit 1 exit 1
@@ -36,9 +48,36 @@ fi
release_commit="$(git rev-parse HEAD)" release_commit="$(git rev-parse HEAD)"
remote_main="$(git ls-remote origin refs/heads/main | cut -f1)" remote_main="$(git ls-remote origin refs/heads/main | cut -f1)"
if [[ -z "${remote_main}" || "${remote_main}" != "${release_commit}" ]]; then if [[ -z "${remote_main}" || "${remote_main}" != "${release_commit}" ]]; then
echo "当前 main 提交尚未同步到 Gitea origin/main,拒绝创建发布标签。" >&2 echo "当前 main 提交尚未同步到 Gitea origin/main,拒绝部署。" >&2
exit 1 exit 1
fi fi
if [[ ! -f "${project_root}/.runtime/dingtalk-mcp.env" ]]; then
echo "缺少 .runtime/dingtalk-mcp.env;首次部署前需先配置网关企业凭证。" >&2
exit 1
fi
if [[ "${gateway_only}" == true ]]; then
echo "[1/3] 测试并构建网关"
npm --cache "${npm_cache}" test
node --env-file=.runtime/dingtalk-mcp.env --input-type=module -e '
import { loadConfig } from "./dist/src/config.js";
const config = loadConfig();
if (!config.remoteAuth || !config.xftOpenApi) throw new Error("网关必须同时启用 OAuth、钉钉 OA 与薪福通");
console.log(`网关配置检查通过;每账号会话上限 ${config.mcpSessionMaxPerUser}`);
'
echo "[2/3] 更新并重启网关服务"
node dist/src/admin.js service start --project-root "${project_root}"
echo "[3/3] 验证网关服务与 OAuth 元数据"
systemctl --user is-active --quiet dingtalk-approval-mcp.service
curl --fail --silent --show-error --connect-timeout 5 --max-time 15 \
--retry 10 --retry-delay 1 --retry-all-errors "${public_base_url}/healthz" >/dev/null
curl --fail --silent --show-error --connect-timeout 5 --max-time 15 \
"${public_base_url}/.well-known/oauth-protected-resource/mcp" >/dev/null
echo "网关部署完成:${release_commit};客户端发行物未变更。"
exit 0
fi
release_tag_commit="$(git ls-remote origin "refs/tags/${release_tag}" "refs/tags/${release_tag}^{}" | tail -n1 | cut -f1)" release_tag_commit="$(git ls-remote origin "refs/tags/${release_tag}" "refs/tags/${release_tag}^{}" | tail -n1 | cut -f1)"
release_created_at_commit="${release_tag_commit:-${release_commit}}" release_created_at_commit="${release_tag_commit:-${release_commit}}"
if ! git cat-file -e "${release_created_at_commit}^{commit}" 2>/dev/null; then if ! git cat-file -e "${release_created_at_commit}^{commit}" 2>/dev/null; then
@@ -47,11 +86,6 @@ if ! git cat-file -e "${release_created_at_commit}^{commit}" 2>/dev/null; then
fi fi
release_created_at="$(git show -s --format=%cI "${release_created_at_commit}")" release_created_at="$(git show -s --format=%cI "${release_created_at_commit}")"
if [[ ! -f "${project_root}/.runtime/dingtalk-mcp.env" ]]; then
echo "缺少 .runtime/dingtalk-mcp.env;首次部署前需先配置网关企业凭证。" >&2
exit 1
fi
if [[ ! -f "${release_private_key}" || ! -f "${release_public_key}" ]]; then if [[ ! -f "${release_private_key}" || ! -f "${release_public_key}" ]]; then
echo "缺少 Ed25519 发布密钥;首次部署请先执行 npm run release:keygen 并安全备份私钥。" >&2 echo "缺少 Ed25519 发布密钥;首次部署请先执行 npm run release:keygen 并安全备份私钥。" >&2
exit 1 exit 1
+2
View File
@@ -44,6 +44,8 @@ Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId
历史列表不与待办混合,也不签发 `selectionId`。未指定时间时默认查询最近 30 天;`startDate/endDate` 接受按 `Asia/Shanghai` 解释的 `YYYY-MM-DD` 或带时区的 ISO 8601 时间,结束日期包含全天,单次跨度最多 120 天。钉钉 `handled` 从本人已完成工作记录取得,`submitted` 在该时间窗口内按当前用户可见模板查询本人发起实例;薪福通 `handled` 从本人的已处理任务取得,`submitted` 使用服务器派生的本人员工号查询单据,再由网关统一按时间窗口过滤。读取历史详情时再次验证当前用户确实处理过该任务或确实是发起人;审批正文、费用明细和附件仍只实时读取、不落库。 历史列表不与待办混合,也不签发 `selectionId`。未指定时间时默认查询最近 30 天;`startDate/endDate` 接受按 `Asia/Shanghai` 解释的 `YYYY-MM-DD` 或带时区的 ISO 8601 时间,结束日期包含全天,单次跨度最多 120 天。钉钉 `handled` 从本人已完成工作记录取得,`submitted` 在该时间窗口内按当前用户可见模板查询本人发起实例;薪福通 `handled` 从本人的已处理任务取得,`submitted` 使用服务器派生的本人员工号查询单据,再由网关统一按时间窗口过滤。读取历史详情时再次验证当前用户确实处理过该任务或确实是发起人;审批正文、费用明细和附件仍只实时读取、不落库。
薪福通任务详情查询先对任务 ID 去重,再按每批最多 300 个顺序请求并按任务 ID 合并;任何一批失败都使本次查询失败,不将部分结果视为完整核验,也不触发审批写入重试。单笔待办及已办详情的归属核验采用每页 100 条、找到即停止的扫描方式,仍最多覆盖 100,000 条任务;翻页依据原始列表条数,不能依据筛选后的结果条数。列表字段可能不完整,因此仍先读取任务详情再识别目标单据,只有匹配目标才查询单据摘要和正文;未找到或超过扫描上限均不能绕过归属核验。
发起费用审批时,`approval_prepare_submission` 在网关内部完成跨来源发票查重。它从待提交表单和本人近 30 天 `submitted` 历史详情中有界提取发票代码/号码、数电票唯一编号、票据号码和附件哈希;金额、日期、商户三项组合仅作为弱匹配。首次索引只读取近 30 天尚未索引的本人历史详情,后续准备仍实时刷新两端精简历史列表和状态,但只为新增业务单补读详情,不会重复逐张下载已索引发票。任一来源异常、历史被上游截断或详情读取失败都会把覆盖标记为不完整,不会静默返回“无重复”。 发起费用审批时,`approval_prepare_submission` 在网关内部完成跨来源发票查重。它从待提交表单和本人近 30 天 `submitted` 历史详情中有界提取发票代码/号码、数电票唯一编号、票据号码和附件哈希;金额、日期、商户三项组合仅作为弱匹配。首次索引只读取近 30 天尚未索引的本人历史详情,后续准备仍实时刷新两端精简历史列表和状态,但只为新增业务单补读详情,不会重复逐张下载已索引发票。任一来源异常、历史被上游截断或详情读取失败都会把覆盖标记为不完整,不会静默返回“无重复”。
发票指纹以服务器密钥进行 HMAC,另以用户 HMAC 分区。SQLite 只保存用户 HMAC、发票 HMAC、匹配类型、来源、上游业务单号、规范化状态和时间;不保存发票原号、审批正文、费用详情、商户、附件或上游 Schema。指纹、最小审批索引和非活跃扫描水位最多保留 35 天,网关启动及每次同步时自动清理,删除审批索引会级联删除全部关联指纹。活跃确认还为每个发票 HMAC 建立 10 分钟唯一预留,到期自动删除,因此两个 Agent 会话不能同时为同一用户、同一发票取得可提交确认。历史重复或覆盖不完整只能在用户明确确认后继续,该决定和预留均绑定到一次性 `confirmationId`。 发票指纹以服务器密钥进行 HMAC,另以用户 HMAC 分区。SQLite 只保存用户 HMAC、发票 HMAC、匹配类型、来源、上游业务单号、规范化状态和时间;不保存发票原号、审批正文、费用详情、商户、附件或上游 Schema。指纹、最小审批索引和非活跃扫描水位最多保留 35 天,网关启动及每次同步时自动清理,删除审批索引会级联删除全部关联指纹。活跃确认还为每个发票 HMAC 建立 10 分钟唯一预留,到期自动删除,因此两个 Agent 会话不能同时为同一用户、同一发票取得可提交确认。历史重复或覆盖不完整只能在用户明确确认后继续,该决定和预留均绑定到一次性 `confirmationId`。
+22
View File
@@ -81,6 +81,12 @@ cd /path/to/dingMCP
脚本自动测试、构建、用 Ed25519 私钥签署 schema v3 清单、重启服务、原子发布静态文件、安装 Nginx 配置并验证公网健康检查、OAuth 元数据和安装链接。首次签证书时,临时 HTTP 配置只开放 ACME challenge,其余请求返回 403;不会在明文 HTTP 上代理 OAuth、Token 或 MCP。证书通过 Certbot webroot 模式签发,正式配置启用 HTTPS 跳转与不带 `includeSubDomains` 的 HSTS。已有证书会自动复用;签名私钥缺失、权限不是 `0600` 或与固定公钥不匹配时发行会失败。 脚本自动测试、构建、用 Ed25519 私钥签署 schema v3 清单、重启服务、原子发布静态文件、安装 Nginx 配置并验证公网健康检查、OAuth 元数据和安装链接。首次签证书时,临时 HTTP 配置只开放 ACME challenge,其余请求返回 403;不会在明文 HTTP 上代理 OAuth、Token 或 MCP。证书通过 Certbot webroot 模式签发,正式配置启用 HTTPS 跳转与不带 `includeSubDomains` 的 HSTS。已有证书会自动复用;签名私钥缺失、权限不是 `0600` 或与固定公钥不匹配时发行会失败。
仅修改服务端、无需发布新客户端时,执行 `./deploy/install.sh --gateway-only`。
该模式同样要求无未提交改动且已同步到 Gitea 的 `main`,运行全量测试和配置检查后重启
网关,再验证公网健康和 OAuth 元数据。它保留服务器凭证、OAuth 数据库、Nginx 配置和
现有客户端发行物,不创建 Release 或标签,也不需要读取发布私钥。服务重启会断开
现有 MCP 连接;客户端需重新建立连接,已有 OAuth 登录凭证保留。
发行物先复制到独立 staging 目录,再通过符号链接原子切换;客户端不会看到新清单配旧 artifact 的中间状态。服务器保留当前版本和最近两个旧版本用于排障,较老版本自动清理。`install.md`、`compat-install.md`、清单、签名、安装器、Skill、校验文件和测试包均以 `no-cache` 提供;内容寻址的客户端 tgz 才使用 immutable 缓存。签名清单的 `createdAt` 超过 180 天时兼容客户端拒绝安装,避免旧签名版本冻结攻击。 发行物先复制到独立 staging 目录,再通过符号链接原子切换;客户端不会看到新清单配旧 artifact 的中间状态。服务器保留当前版本和最近两个旧版本用于排障,较老版本自动清理。`install.md`、`compat-install.md`、清单、签名、安装器、Skill、校验文件和测试包均以 `no-cache` 提供;内容寻址的客户端 tgz 才使用 immutable 缓存。签名清单的 `createdAt` 超过 180 天时兼容客户端拒绝安装,避免旧签名版本冻结攻击。
卸载: 卸载:
@@ -128,6 +134,22 @@ https://approval.justconn.top/downloads/dingmcp/install.md
## 运维检查 ## 运维检查
网关会话上限默认为每个已认证用户 32 个、全局 1000 个,同时计入尚未完成初始化的连接。
运维人员可在服务端环境配置中设置 `MCP_SESSION_MAX_PER_USER=64` 等正整数(范围 1–1000),
并通过上述部署流程更新网关后生效;无需修改或更新客户端。未配置时使用 32,非法值拒绝启动。
该阈值用于限制一个账号占用的连接和内存,不是审批条数、同时执行审批的配额或上游平台限制。
现有 Bridge 客户端退出或刷新登录时,即使没有发送 MCP DELETE,网关也会在观察到
Streamable HTTP GET 长连接断开后保留 60 秒重连宽限期,再回收遗留会话;后台每 15 秒
检查一次,新连接进入时也会检查,因此通常在断开后的 60–75 秒内释放名额。
宽限期内恢复长连接或继续发送请求会保留原会话;仅使用 POST 的客户端仍使用 2 小时
闲置期限。正常连接中的长会话不按闲置期限清理,最长存活时间仍为 24 小时。
实际请求处理结束前不会因为断线、取消或超时回收会话,也不会重试任何审批写入。
回收状态只保存在内存中,不记录审批参数、正文、Token 或用户身份到日志。
如果会话确实仍在连接并达到配置上限,网关仍返回 HTTP 429 和 `Retry-After: 60`,需要关闭不用的
Agent 连接再重试。旧客户端可能仍将 429 显示为“审批网关暂时无法连接”;此次服务端
修复无需用户更新客户端,但不会改变客户端内置的错误文案。
```bash ```bash
systemctl --user status dingtalk-approval-mcp.service systemctl --user status dingtalk-approval-mcp.service
curl --fail http://127.0.0.1:3010/healthz curl --fail http://127.0.0.1:3010/healthz
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "dingtalk-approval-mcp", "name": "dingtalk-approval-mcp",
"version": "1.1.0-rc.19", "version": "1.1.0-rc.20",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dingtalk-approval-mcp", "name": "dingtalk-approval-mcp",
"version": "1.1.0-rc.19", "version": "1.1.0-rc.20",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0", "@modelcontextprotocol/sdk": "^1.30.0",
"express": "^5.2.1", "express": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dingtalk-approval-mcp", "name": "dingtalk-approval-mcp",
"version": "1.1.0-rc.19", "version": "1.1.0-rc.20",
"description": "A lightweight Agent bridge for a unified DingTalk OA and XFT approval gateway.", "description": "A lightweight Agent bridge for a unified DingTalk OA and XFT approval gateway.",
"private": true, "private": true,
"type": "module", "type": "module",
+9
View File
@@ -1,6 +1,9 @@
import { readFileSync, statSync } from "node:fs"; import { readFileSync, statSync } from "node:fs";
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js"; import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js";
export const DEFAULT_MCP_SESSION_MAX_PER_USER = 32;
export const MCP_SESSION_MAX_TOTAL = 1000;
export interface AppConfig { export interface AppConfig {
appMode?: "gateway"; appMode?: "gateway";
clientId: string; clientId: string;
@@ -11,6 +14,7 @@ export interface AppConfig {
publicBaseUrl: string; publicBaseUrl: string;
bindHost?: string; bindHost?: string;
port: number; port: number;
mcpSessionMaxPerUser?: number;
requestTimeoutMs: number; requestTimeoutMs: number;
officialLookbackDays: number; officialLookbackDays: number;
officialScanConcurrency: number; officialScanConcurrency: number;
@@ -167,6 +171,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
if (!remoteAuthEnabled || !xftOpenApiEnabled) { if (!remoteAuthEnabled || !xftOpenApiEnabled) {
throw new Error("gateway 模式必须启用 MCP 远程 OAuth 和薪福通正式 OpenAPI"); throw new Error("gateway 模式必须启用 MCP 远程 OAuth 和薪福通正式 OpenAPI");
} }
const mcpSessionMaxPerUser = positiveInteger(env, "MCP_SESSION_MAX_PER_USER", DEFAULT_MCP_SESSION_MAX_PER_USER);
if (mcpSessionMaxPerUser > MCP_SESSION_MAX_TOTAL) {
throw new Error(`MCP_SESSION_MAX_PER_USER 不能超过全局会话上限 ${MCP_SESSION_MAX_TOTAL}`);
}
return { return {
appMode, appMode,
clientId: dingTalkCredential(env, "DINGTALK_CLIENT_ID", "clientId"), clientId: dingTalkCredential(env, "DINGTALK_CLIENT_ID", "clientId"),
@@ -177,6 +185,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
publicBaseUrl, publicBaseUrl,
bindHost, bindHost,
port, port,
mcpSessionMaxPerUser,
requestTimeoutMs: positiveInteger(env, "DINGTALK_REQUEST_TIMEOUT_MS", 15_000), requestTimeoutMs: positiveInteger(env, "DINGTALK_REQUEST_TIMEOUT_MS", 15_000),
officialLookbackDays: Math.min(120, positiveInteger(env, "OFFICIAL_OA_LOOKBACK_DAYS", 120)), officialLookbackDays: Math.min(120, positiveInteger(env, "OFFICIAL_OA_LOOKBACK_DAYS", 120)),
officialScanConcurrency: positiveInteger(env, "OFFICIAL_OA_SCAN_CONCURRENCY", 2), officialScanConcurrency: positiveInteger(env, "OFFICIAL_OA_SCAN_CONCURRENCY", 2),
+148
View File
@@ -0,0 +1,148 @@
import { randomUUID } from "node:crypto";
import type { Express, Request, Response, RequestHandler } from "express";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import type { AuthenticatedDingTalkUser } from "./xft.js";
import { McpSessionLifecycle } from "./mcp-session-lifecycle.js";
import { DEFAULT_MCP_SESSION_MAX_PER_USER, MCP_SESSION_MAX_TOTAL } from "./config.js";
type TransportEntry =
| { kind: "streamable"; transport: StreamableHTTPServerTransport; userId: string; lifecycle: McpSessionLifecycle }
| { kind: "sse"; transport: SSEServerTransport; userId: string; lifecycle: McpSessionLifecycle };
interface McpTransportOptions {
authenticate?: RequestHandler;
// Production always resolves this from verified OAuth, never request parameters.
requestUser: (req: Request) => AuthenticatedDingTalkUser;
createServer: (user: AuthenticatedDingTalkUser, lifecycle: McpSessionLifecycle) => McpServer;
now?: () => number;
maxSessionsPerUser?: number;
}
export function installMcpTransports(app: Express, options: McpTransportOptions) {
const now = options.now ?? Date.now;
const maxSessionsPerUser = options.maxSessionsPerUser ?? DEFAULT_MCP_SESSION_MAX_PER_USER;
const transports = new Map<string, TransportEntry>();
let pendingTransportSessions = 0;
const pendingTransportSessionsByUser = new Map<string, number>();
const closeTransport = (id: string, entry: TransportEntry) => {
transports.delete(id);
void entry.transport.close().catch(() => undefined);
};
const cleanupTransports = () => {
for (const [id, entry] of transports) {
if (entry.lifecycle.expired()) {
closeTransport(id, entry);
}
}
};
const sessionCapacityAvailable = (userId: string) => transports.size + pendingTransportSessions < MCP_SESSION_MAX_TOTAL
&& [...transports.values()].filter((entry) => entry.userId === userId).length
+ (pendingTransportSessionsByUser.get(userId) ?? 0) < maxSessionsPerUser;
const reserveSession = (userId: string) => {
pendingTransportSessions += 1;
pendingTransportSessionsByUser.set(userId, (pendingTransportSessionsByUser.get(userId) ?? 0) + 1);
};
const releaseSessionReservation = (userId: string) => {
pendingTransportSessions = Math.max(0, pendingTransportSessions - 1);
const remaining = Math.max(0, (pendingTransportSessionsByUser.get(userId) ?? 1) - 1);
if (remaining === 0) pendingTransportSessionsByUser.delete(userId);
else pendingTransportSessionsByUser.set(userId, remaining);
};
const transportCleanupTimer = setInterval(cleanupTransports, 15_000);
transportCleanupTimer.unref();
const mcpHandler = async (req: Request, res: Response) => {
let reservedUserId: string | undefined;
try {
cleanupTransports();
const user = options.requestUser(req);
const sessionId = req.get("mcp-session-id");
let entry = sessionId ? transports.get(sessionId) : undefined;
if (entry && entry.kind !== "streamable") {
res.status(400).json({ error: "Invalid MCP session transport" });
return;
}
if (entry && entry.userId !== user.userId) {
res.status(403).json({ error: "MCP session belongs to another authenticated user" });
return;
}
if (entry) entry.lifecycle.touch();
if (!entry && req.method === "POST" && !sessionId && isInitializeRequest(req.body)) {
if (!sessionCapacityAvailable(user.userId)) {
res.set("Retry-After", "60").status(429).json({ error: "MCP session limit reached; close an old Agent session and retry" });
return;
}
reserveSession(user.userId);
reservedUserId = user.userId;
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => {
transports.set(id, entry!);
// The live entry now owns the slot; do not count its reservation twice.
releaseSessionReservation(user.userId);
reservedUserId = undefined;
},
});
transport.onclose = () => { if (transport.sessionId) transports.delete(transport.sessionId); };
entry = { kind: "streamable", transport, userId: user.userId, lifecycle: new McpSessionLifecycle(now) };
await options.createServer(user, entry.lifecycle).connect(transport);
}
if (!entry || entry.kind !== "streamable") {
res.status(sessionId ? 404 : 400).json({ error: "Missing or invalid MCP session" });
return;
}
if (req.method === "GET") entry.lifecycle.trackStream(res);
await entry.transport.handleRequest(req, res, req.body);
} catch (error) { sendServerError(res, error); }
finally { if (reservedUserId) releaseSessionReservation(reservedUserId); }
};
app.all("/mcp", ...(options.authenticate ? [options.authenticate, mcpHandler] : [mcpHandler]));
const sseHandler = async (req: Request, res: Response) => {
const user = options.requestUser(req);
cleanupTransports();
if (!sessionCapacityAvailable(user.userId)) {
res.set("Retry-After", "60").status(429).send("MCP session limit reached");
return;
}
const transport = new SSEServerTransport("/messages", res);
const lifecycle = new McpSessionLifecycle(now);
lifecycle.trackStream(res);
transports.set(transport.sessionId, { kind: "sse", transport, userId: user.userId, lifecycle });
res.on("close", () => transports.delete(transport.sessionId));
await options.createServer(user, lifecycle).connect(transport);
};
app.get("/sse", ...(options.authenticate ? [options.authenticate, sseHandler] : [sseHandler]));
const messagesHandler = async (req: Request, res: Response) => {
const user = options.requestUser(req);
const entry = transports.get(String(req.query.sessionId ?? ""));
if (!entry || entry.kind !== "sse") {
res.status(404).send("Invalid SSE session");
return;
}
if (entry.userId !== user.userId) {
res.status(403).send("Invalid SSE session");
return;
}
entry.lifecycle.touch();
await entry.transport.handlePostMessage(req, res, req.body);
};
app.post("/messages", ...(options.authenticate ? [options.authenticate, messagesHandler] : [messagesHandler]));
return {
cleanup: cleanupTransports,
async close() {
clearInterval(transportCleanupTimer);
const entries = [...transports.values()];
transports.clear();
await Promise.all(entries.map((entry) => entry.transport.close()));
},
};
}
function sendServerError(res: Response, _error: unknown): void {
console.error("[dingtalk-approval-mcp] MCP transport request failed");
if (!res.headersSent) res.status(500).json({ error: "Internal server error" });
}
+63
View File
@@ -0,0 +1,63 @@
import type { Response } from "express";
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
const DISCONNECT_GRACE_MS = 60_000;
const IDLE_TTL_MS = 2 * 60 * 60_000;
const MAX_AGE_MS = 24 * 60 * 60_000;
/** Only transport metadata lives here; never approval arguments or results. */
export class McpSessionLifecycle {
private readonly createdAt: number;
private lastSeenAt: number;
private disconnectedAt?: number;
private openStreams = 0;
private activeRequests = 0;
constructor(private readonly now: () => number = Date.now) {
this.createdAt = this.lastSeenAt = now();
}
/**
* Install before registering tools, using the SDK's public handler API.
* Track actual handler completion, not HTTP close or JSON-RPC replies:
* cancelled requests may finish upstream without ever sending a reply.
*/
trackRequests(server: Server): void {
const setRequestHandler = server.setRequestHandler.bind(server);
server.setRequestHandler = (schema, handler) => {
setRequestHandler(schema, async (request, extra) => {
this.activeRequests += 1;
try { return await handler(request, extra); }
finally {
this.activeRequests -= 1;
this.touch();
}
});
};
}
touch(): void { this.lastSeenAt = this.now(); }
trackStream(res: Response): void {
this.openStreams += 1;
res.once("close", () => {
this.openStreams -= 1;
// A rejected duplicate GET (409) must not start the disconnect timer.
if (res.statusCode === 200) {
this.disconnectedAt = this.now();
}
});
}
expired(): boolean {
if (this.activeRequests > 0) return false;
const now = this.now();
if (now - this.createdAt >= MAX_AGE_MS) return true;
if (this.openStreams > 0) return false;
if (this.disconnectedAt !== undefined
&& now - Math.max(this.disconnectedAt, this.lastSeenAt) >= DISCONNECT_GRACE_MS) return true;
// GET streams are optional in Streamable HTTP. POST-only clients retain
// the existing idle timeout instead of being mistaken for dead clients.
return now - this.lastSeenAt >= IDLE_TTL_MS;
}
}
+12 -119
View File
@@ -1,13 +1,11 @@
import { randomUUID } from "node:crypto"; import type { McpSessionLifecycle } from "./mcp-session-lifecycle.js";
import { installMcpTransports } from "./mcp-http.js";
import express, { type NextFunction, type Request, type Response } from "express"; import express, { type NextFunction, type Request, type Response } from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { hostHeaderValidation } from "@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js"; import { hostHeaderValidation } from "@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js";
import { getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js"; import { getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod"; import { z } from "zod";
import { import {
APPROVAL_BATCH_LIMIT, APPROVAL_BATCH_LIMIT,
@@ -28,15 +26,6 @@ import { InvoiceDeduplicationService, InvoiceDedupStore, invoiceDedupDatabasePat
import { APPLICATION_VERSION } from "./version.js"; import { APPLICATION_VERSION } from "./version.js";
import { SignedReferenceCodec } from "./signed-reference.js"; import { SignedReferenceCodec } from "./signed-reference.js";
type TransportEntry =
| { kind: "streamable"; transport: StreamableHTTPServerTransport; userId: string; createdAt: number; lastSeenAt: number }
| { kind: "sse"; transport: SSEServerTransport; userId: string; createdAt: number; lastSeenAt: number };
const MCP_SESSION_IDLE_TTL_MS = 2 * 60 * 60_000;
const MCP_SESSION_MAX_AGE_MS = 24 * 60 * 60_000;
const MCP_SESSION_MAX_PER_USER = 8;
const MCP_SESSION_MAX_TOTAL = 1000;
export const APPROVAL_MCP_INSTRUCTIONS = [ export const APPROVAL_MCP_INSTRUCTIONS = [
"先调用 approval_list_todos 获取当前待办。薪福通待办不按单据类型过滤;availableActions=[] 且 manualAction.reason=unsupported_xft_task_type 的项目仍要展示给用户,但只能引导到薪福通客户端查看和处理,禁止尝试 MCP 写入。用户只是在查看时,仅回答标题、申请人、金额、时间等业务信息,不主动讨论审批接口、批处理方式或索取写操作授权。用户明确要求处理后,在其授权范围内内部选择整组引用或自动分批;技术分批不得造成重复确认。", "先调用 approval_list_todos 获取当前待办。薪福通待办不按单据类型过滤;availableActions=[] 且 manualAction.reason=unsupported_xft_task_type 的项目仍要展示给用户,但只能引导到薪福通客户端查看和处理,禁止尝试 MCP 写入。用户只是在查看时,仅回答标题、申请人、金额、时间等业务信息,不主动讨论审批接口、批处理方式或索取写操作授权。用户明确要求处理后,在其授权范围内内部选择整组引用或自动分批;技术分批不得造成重复确认。",
"approvalId、selectionId、targetId、单次条数限制、分批方式以及内部引用是否返回均属于 Agent 与 MCP 的内部调用细节,绝不向用户展示、解释或要求用户操作。面向用户只说明目标审批、业务动作、成功结果和需要用户决策的业务失败;不要说引用是否签发、是否失效、是否存在一键全选或如何分组。", "approvalId、selectionId、targetId、单次条数限制、分批方式以及内部引用是否返回均属于 Agent 与 MCP 的内部调用细节,绝不向用户展示、解释或要求用户操作。面向用户只说明目标审批、业务动作、成功结果和需要用户决策的业务失败;不要说引用是否签发、是否失效、是否存在一键全选或如何分组。",
@@ -61,11 +50,13 @@ export function createApprovalMcpServer(
authenticatedUser?: AuthenticatedDingTalkUser, authenticatedUser?: AuthenticatedDingTalkUser,
audit?: (userId: string, action: string, reference: string | undefined, outcome: string) => void, audit?: (userId: string, action: string, reference: string | undefined, outcome: string) => void,
invoiceDedup?: InvoiceDeduplicationService, invoiceDedup?: InvoiceDeduplicationService,
lifecycle?: McpSessionLifecycle,
): McpServer { ): McpServer {
const server = new McpServer( const server = new McpServer(
{ name: "dingtalk-approval-mcp", version: APPLICATION_VERSION }, { name: "dingtalk-approval-mcp", version: APPLICATION_VERSION },
{ instructions: APPROVAL_MCP_INSTRUCTIONS }, { instructions: APPROVAL_MCP_INSTRUCTIONS },
); );
lifecycle?.trackRequests(server.server);
const unified = new UnifiedApprovalService(config, service, xft, xftChallenges, officialOaReturns, invoiceDedup); const unified = new UnifiedApprovalService(config, service, xft, xftChallenges, officialOaReturns, invoiceDedup);
const user = authenticatedUser ?? config.trustedLocalUser; const user = authenticatedUser ?? config.trustedLocalUser;
if (!user) throw new Error("MCP 会话缺少已认证用户"); if (!user) throw new Error("MCP 会话缺少已认证用户");
@@ -469,114 +460,16 @@ export async function startHttpServer(config: AppConfig): Promise<void> {
...(authMiddleware ? [authMiddleware, attachmentBody, attachmentUploadHandler] : [attachmentBody, attachmentUploadHandler]), ...(authMiddleware ? [authMiddleware, attachmentBody, attachmentUploadHandler] : [attachmentBody, attachmentUploadHandler]),
); );
const transports = new Map<string, TransportEntry>(); installMcpTransports(app, {
let pendingTransportSessions = 0; authenticate: authMiddleware,
const pendingTransportSessionsByUser = new Map<string, number>(); maxSessionsPerUser: config.mcpSessionMaxPerUser,
const closeTransport = (id: string, entry: TransportEntry) => { requestUser: (req) => requestUser(config, oauth, req.auth),
transports.delete(id); createServer: (user, lifecycle) => createApprovalMcpServer(
void entry.transport.close().catch(() => undefined);
};
const cleanupTransports = () => {
const now = Date.now();
for (const [id, entry] of transports) {
if (entry.lastSeenAt < now - MCP_SESSION_IDLE_TTL_MS || entry.createdAt < now - MCP_SESSION_MAX_AGE_MS) {
closeTransport(id, entry);
}
}
};
const sessionCapacityAvailable = (userId: string) => transports.size + pendingTransportSessions < MCP_SESSION_MAX_TOTAL
&& [...transports.values()].filter((entry) => entry.userId === userId).length
+ (pendingTransportSessionsByUser.get(userId) ?? 0) < MCP_SESSION_MAX_PER_USER;
const reserveSession = (userId: string) => {
pendingTransportSessions += 1;
pendingTransportSessionsByUser.set(userId, (pendingTransportSessionsByUser.get(userId) ?? 0) + 1);
};
const releaseSessionReservation = (userId: string) => {
pendingTransportSessions = Math.max(0, pendingTransportSessions - 1);
const remaining = Math.max(0, (pendingTransportSessionsByUser.get(userId) ?? 1) - 1);
if (remaining === 0) pendingTransportSessionsByUser.delete(userId);
else pendingTransportSessionsByUser.set(userId, remaining);
};
const transportCleanupTimer = setInterval(cleanupTransports, 10 * 60_000);
transportCleanupTimer.unref();
const mcpHandler = async (req: Request, res: Response) => {
let reservedUserId: string | undefined;
try {
cleanupTransports();
const user = requestUser(config, oauth, req.auth);
const sessionId = headerString(req.headers["mcp-session-id"]);
let entry = sessionId ? transports.get(sessionId) : undefined;
if (entry && entry.kind !== "streamable") {
res.status(400).json({ error: "Invalid MCP session transport" });
return;
}
if (entry && entry.userId !== user.userId) {
res.status(403).json({ error: "MCP session belongs to another authenticated user" });
return;
}
if (entry) entry.lastSeenAt = Date.now();
if (!entry && req.method === "POST" && !sessionId && isInitializeRequest(req.body)) {
if (!sessionCapacityAvailable(user.userId)) {
res.status(429).json({ error: "MCP session limit reached; close an old Agent session and retry" });
return;
}
reserveSession(user.userId);
reservedUserId = user.userId;
const createdAt = Date.now();
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => {
transports.set(id, { kind: "streamable", transport, userId: user.userId, createdAt, lastSeenAt: Date.now() });
},
});
transport.onclose = () => { if (transport.sessionId) transports.delete(transport.sessionId); };
entry = { kind: "streamable", transport, userId: user.userId, createdAt, lastSeenAt: createdAt };
await createApprovalMcpServer(
config, service, xft, undefined, officialOaReturns, user, config, service, xft, undefined, officialOaReturns, user,
oauth ? (userId, action, reference, outcome) => oauth.audit(userId, action, reference, outcome) : undefined, oauth ? (userId, action, reference, outcome) => oauth.audit(userId, action, reference, outcome) : undefined,
invoiceDedup, invoiceDedup, lifecycle,
).connect(transport); ),
} });
if (!entry || entry.kind !== "streamable") {
res.status(sessionId ? 404 : 400).json({ error: "Missing or invalid MCP session" });
return;
}
await entry.transport.handleRequest(req, res, req.body);
} catch (error) { sendServerError(res, error); }
finally { if (reservedUserId) releaseSessionReservation(reservedUserId); }
};
app.all("/mcp", ...(authMiddleware ? [authMiddleware, mcpHandler] : [mcpHandler]));
const sseHandler = async (req: Request, res: Response) => {
const user = requestUser(config, oauth, req.auth);
cleanupTransports();
if (!sessionCapacityAvailable(user.userId)) {
res.status(429).send("MCP session limit reached");
return;
}
const transport = new SSEServerTransport("/messages", res);
const createdAt = Date.now();
transports.set(transport.sessionId, { kind: "sse", transport, userId: user.userId, createdAt, lastSeenAt: createdAt });
res.on("close", () => transports.delete(transport.sessionId));
await createApprovalMcpServer(config, service, xft, undefined, officialOaReturns, user, undefined, invoiceDedup).connect(transport);
};
app.get("/sse", ...(authMiddleware ? [authMiddleware, sseHandler] : [sseHandler]));
const messagesHandler = async (req: Request, res: Response) => {
const user = requestUser(config, oauth, req.auth);
const entry = transports.get(String(req.query.sessionId ?? ""));
if (!entry || entry.kind !== "sse") {
res.status(404).send("Invalid SSE session");
return;
}
if (entry.userId !== user.userId) {
res.status(403).send("Invalid SSE session");
return;
}
entry.lastSeenAt = Date.now();
await entry.transport.handlePostMessage(req, res, req.body);
};
app.post("/messages", ...(authMiddleware ? [authMiddleware, messagesHandler] : [messagesHandler]));
app.get("/healthz", (_req, res) => res.json({ status: "ok", mode: config.appMode })); app.get("/healthz", (_req, res) => res.json({ status: "ok", mode: config.appMode }));
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
+1 -1
View File
@@ -1 +1 @@
export const APPLICATION_VERSION = "1.1.0-rc.19"; export const APPLICATION_VERSION = "1.1.0-rc.20";
+18 -12
View File
@@ -31,6 +31,10 @@ import { SignedReferenceCodec } from "./signed-reference.js";
type FetchLike = typeof fetch; type FetchLike = typeof fetch;
type JsonObject = Record<string, unknown>; type JsonObject = Record<string, unknown>;
const { sm2, sm3, sm4 } = smCrypto; const { sm2, sm3, sm4 } = smCrypto;
const TASK_DETAIL_BATCH_LIMIT = 300;
const TASK_LOOKUP_PAGE_SIZE = 100;
// Preserve the previous 100 pages × 1000 records ownership scan budget.
const TASK_LOOKUP_MAX_PAGES = 100_000 / TASK_LOOKUP_PAGE_SIZE;
interface XftIdentity { interface XftIdentity {
enterpriseUserId: string; enterpriseUserId: string;
@@ -993,8 +997,8 @@ export class XftOpenApiClient implements XftApprovalProvider {
} }
private async findHandledHistory(identity: XftIdentity, billId: string): Promise<CurrentHistory | undefined> { private async findHandledHistory(identity: XftIdentity, billId: string): Promise<CurrentHistory | undefined> {
const pageSize = 1000; const pageSize = TASK_LOOKUP_PAGE_SIZE;
for (let currentPage = 1; currentPage <= 100; currentPage += 1) { for (let currentPage = 1; currentPage <= TASK_LOOKUP_MAX_PAGES; currentPage += 1) {
const page = await this.loadTaskPage(identity, currentPage, pageSize, "done", billId); const page = await this.loadTaskPage(identity, currentPage, pageSize, "done", billId);
const found = page.items.find((item) => item.billId === billId); const found = page.items.find((item) => item.billId === billId);
if (found) return toHistoryItem(found, "handled"); if (found) return toHistoryItem(found, "handled");
@@ -1070,19 +1074,21 @@ export class XftOpenApiClient implements XftApprovalProvider {
); );
const pageRecords = objectRecords(page); const pageRecords = objectRecords(page);
if (pageRecords.length === 0) return { items: [] as CurrentTodo[], pageRecordCount: 0 }; if (pageRecords.length === 0) return { items: [] as CurrentTodo[], pageRecordCount: 0 };
const todoTaskIds = pageRecords.flatMap((record) => { const todoTaskIds = uniqueStrings(pageRecords.flatMap((record) => {
const value = stringAt(record, ["todoTaskId"]); const value = stringAt(record, ["todoTaskId"]);
return value ? [value] : []; return value ? [value] : [];
}); }));
const detailResult = todoTaskIds.length const detailed: JsonObject[] = [];
? await this.request( for (let offset = 0; offset < todoTaskIds.length; offset += TASK_DETAIL_BATCH_LIMIT) {
const detailResult = await this.request(
"/xftmsg/msg/xft-message/v1/todo-task/detail/by-ids", "/xftmsg/msg/xft-message/v1/todo-task/detail/by-ids",
{ todoTaskIds }, { todoTaskIds: todoTaskIds.slice(offset, offset + TASK_DETAIL_BATCH_LIMIT) },
"POST", "POST",
identity, identity,
) );
: []; // A failed batch must propagate; partial details cannot prove ownership.
const detailed = objectRecords(detailResult); detailed.push(...objectRecords(detailResult));
}
const byTaskId = new Map(detailed.map((record) => [stringAt(record, ["todoTaskId"]) ?? "", record])); const byTaskId = new Map(detailed.map((record) => [stringAt(record, ["todoTaskId"]) ?? "", record]));
const todoRecords = pageRecords.map((record) => ({ ...record, ...(byTaskId.get(stringAt(record, ["todoTaskId"]) ?? "") ?? {}) })); const todoRecords = pageRecords.map((record) => ({ ...record, ...(byTaskId.get(stringAt(record, ["todoTaskId"]) ?? "") ?? {}) }));
const selectedRecords = billId const selectedRecords = billId
@@ -1131,8 +1137,8 @@ export class XftOpenApiClient implements XftApprovalProvider {
} }
private async findCurrentTodo(identity: XftIdentity, billId: string): Promise<CurrentTodo | undefined> { private async findCurrentTodo(identity: XftIdentity, billId: string): Promise<CurrentTodo | undefined> {
const pageSize = 1000; const pageSize = TASK_LOOKUP_PAGE_SIZE;
for (let currentPage = 1; currentPage <= 100; currentPage += 1) { for (let currentPage = 1; currentPage <= TASK_LOOKUP_MAX_PAGES; currentPage += 1) {
const page = await this.loadTodoPage(identity, currentPage, pageSize, billId); const page = await this.loadTodoPage(identity, currentPage, pageSize, billId);
if (page.items[0]) return page.items[0]; if (page.items[0]) return page.items[0];
if (page.pageRecordCount < pageSize) return undefined; if (page.pageRecordCount < pageSize) return undefined;
+18
View File
@@ -70,6 +70,24 @@ test("loadConfig rejects the removed local DWS architecture", () => {
}), /禁止配置本地 DWS/); }), /禁止配置本地 DWS/);
}); });
test("gateway session capacity defaults to 32 and validates operator overrides", () => {
const env = {
APP_MODE: "gateway", DINGTALK_CLIENT_ID: "mock-key", DINGTALK_CLIENT_SECRET: "mock-secret",
DINGTALK_CORP_ID: "mock-corp", MCP_PUBLIC_BASE_URL: "https://approval.example.test",
MCP_REMOTE_AUTH_ENABLED: "true", MCP_SESSION_SECRET: "mock-session-secret-with-at-least-32-bytes",
MCP_APPROVAL_REF_SECRET: "mock-reference-secret-with-at-least-32-bytes",
XFT_OPEN_API_ENABLED: "true", XFT_OPEN_API_APP_ID: "mock-app",
XFT_OPEN_API_AUTHORITY_SECRET: "1".repeat(64), XFT_OPEN_API_ENTERPRISE_ID: "mock-enterprise",
};
assert.equal(loadConfig(env).mcpSessionMaxPerUser, 32);
for (const value of ["1", "64", "1000"]) {
assert.equal(loadConfig({ ...env, MCP_SESSION_MAX_PER_USER: value }).mcpSessionMaxPerUser, Number(value));
}
for (const value of ["0", "-1", "1.5", "invalid", "1001"]) {
assert.throws(() => loadConfig({ ...env, MCP_SESSION_MAX_PER_USER: value }), /MCP_SESSION_MAX_PER_USER/);
}
});
test("loadConfig reads AuthoritySecret from the second line of an XFT portal credential file", () => { test("loadConfig reads AuthoritySecret from the second line of an XFT portal credential file", () => {
const directory = mkdtempSync(join(tmpdir(), "ding-mcp-xft-openapi-config-")); const directory = mkdtempSync(join(tmpdir(), "ding-mcp-xft-openapi-config-"));
const secretPath = join(directory, "xft.key"); const secretPath = join(directory, "xft.key");
+70
View File
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
test("gateway-only deployment runs checks and restart without publishing clients or touching secrets", () => {
const root = mkdtempSync(join(tmpdir(), "mock-gateway-deploy-"));
try {
for (const dir of ["deploy", ".runtime", "bin", "release"]) mkdirSync(join(root, dir));
copyFileSync("deploy/install.sh", join(root, "deploy/install.sh"));
writeFileSync(join(root, ".runtime/dingtalk-mcp.env"), "mock-config-preserved");
writeFileSync(join(root, "release/manifest.json"), "mock-existing-client-manifest");
const calls = join(root, "calls.log");
const stub = `#!/bin/bash
printf '%s\\n' "$(basename "$0") $*" >> "$MOCK_DEPLOY_CALLS"
case "$(basename "$0")" in
git)
case "$1" in
branch) echo "\${MOCK_DEPLOY_BRANCH:-main}" ;;
diff) exit "\${MOCK_DEPLOY_DIRTY:-0}" ;;
rev-parse) echo mock-commit ;;
ls-remote) printf 'mock-commit\\trefs/heads/main\\n' ;;
*) exit 77 ;;
esac ;;
npm) exit "\${MOCK_DEPLOY_TEST_EXIT:-0}" ;;
node)
case "$1" in
-p) echo 1.1.0-rc.20 ;;
--env-file=*) exit "\${MOCK_DEPLOY_CONFIG_EXIT:-0}" ;;
dist/src/admin.js) exit 0 ;;
*) exit 77 ;;
esac ;;
curl|systemctl) exit 0 ;;
*) exit 77 ;;
esac
`;
for (const name of ["git", "node", "npm", "curl", "systemctl", "tea", "sudo"]) {
writeFileSync(join(root, "bin", name), stub, { mode: 0o755 });
}
const run = (overrides: NodeJS.ProcessEnv = {}) => {
writeFileSync(calls, "");
const result = spawnSync("bash", [join(root, "deploy/install.sh"), "--gateway-only"], {
cwd: root, encoding: "utf8",
env: { ...process.env, PATH: `${join(root, "bin")}:${process.env.PATH}`, MOCK_DEPLOY_CALLS: calls, ...overrides },
});
return { ...result, calls: readFileSync(calls, "utf8") };
};
const success = run();
assert.equal(success.status, 0, success.stderr);
assert.match(success.calls, /npm .* test\n/);
assert.match(success.calls, /node --env-file=.*loadConfig/s);
assert.match(success.calls, /node dist\/src\/admin.js service start --project-root/);
assert.match(success.calls, /systemctl --user is-active --quiet/);
assert.match(success.calls, /\/healthz/);
assert.match(success.calls, /oauth-protected-resource\/mcp/);
assert.doesNotMatch(success.calls, /runtime:verify|release:build|release:verify|test-bundle|refs\/tags|tea |sudo /);
assert.equal(readFileSync(join(root, ".runtime/dingtalk-mcp.env"), "utf8"), "mock-config-preserved");
assert.equal(readFileSync(join(root, "release/manifest.json"), "utf8"), "mock-existing-client-manifest");
for (const failure of [
{ MOCK_DEPLOY_TEST_EXIT: "1" }, { MOCK_DEPLOY_CONFIG_EXIT: "1" },
{ MOCK_DEPLOY_BRANCH: "feature" }, { MOCK_DEPLOY_DIRTY: "1" },
]) {
const result = run(failure);
assert.notEqual(result.status, 0);
assert.doesNotMatch(result.calls, /node dist\/src\/admin.js service start/);
}
} finally { rmSync(root, { recursive: true, force: true }); }
});
+5 -3
View File
@@ -14,6 +14,8 @@ import type { XftApprovalProvider } from "../src/xft.js";
import { UnifiedApprovalService } from "../src/unified-approval.js"; import { UnifiedApprovalService } from "../src/unified-approval.js";
const secret = "invoice-dedup-test-secret-with-at-least-32-bytes"; const secret = "invoice-dedup-test-secret-with-at-least-32-bytes";
// Retained fixtures must stay inside the production retention window as calendar time advances.
const recentSubmittedAt = new Date(Date.now() - 86_400_000).toISOString();
function config(): AppConfig { function config(): AppConfig {
return { return {
@@ -51,7 +53,7 @@ test("invoice store persists only HMAC fingerprints and isolates users across re
const evidence = extractInvoiceEvidence({ invoiceCode: "044031900111", invoiceNumber: "87654321" }); const evidence = extractInvoiceEvidence({ invoiceCode: "044031900111", invoiceNumber: "87654321" });
const first = new InvoiceDedupStore(path, secret); const first = new InvoiceDedupStore(path, secret);
const userOne = first.userHash("private-user-one"); const userOne = first.userHash("private-user-one");
first.indexDetail(userOne, "xft", "business-1", "pending", "2026-08-01T01:00:00.000Z", evidence); first.indexDetail(userOne, "xft", "business-1", "pending", recentSubmittedAt, evidence);
assert.ok(first.findMatches(userOne, evidence).length >= 1); assert.ok(first.findMatches(userOne, evidence).length >= 1);
assert.equal(first.findMatches(first.userHash("private-user-two"), evidence).length, 0); assert.equal(first.findMatches(first.userHash("private-user-two"), evidence).length, 0);
@@ -91,7 +93,7 @@ test("dedup preparation blocks historical duplicates and concurrent confirmation
observedRange = range; observedRange = range;
return { items: [{ return { items: [{
source: "official_oa", historyScope: "submitted", processInstanceId: "oa-existing", source: "official_oa", historyScope: "submitted", processInstanceId: "oa-existing",
status: "COMPLETED", result: "agree", submittedAt: "2026-08-01T01:00:00.000Z", status: "COMPLETED", result: "agree", submittedAt: recentSubmittedAt,
}], diagnostics: {} }; }], diagnostics: {} };
}, },
async getHistoryDetail() { async getHistoryDetail() {
@@ -161,7 +163,7 @@ test("rejected prior bills warn instead of masquerading as active approvals", as
const root = mkdtempSync(join(tmpdir(), "ding-invoice-rejected-")); const root = mkdtempSync(join(tmpdir(), "ding-invoice-rejected-"));
const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret); const store = new InvoiceDedupStore(join(root, "dedup.sqlite"), secret);
const evidence = extractInvoiceEvidence({ invoiceCode: "044031900111", invoiceNumber: "87654321" }); const evidence = extractInvoiceEvidence({ invoiceCode: "044031900111", invoiceNumber: "87654321" });
store.indexDetail(store.userHash("user-1"), "xft", "xft-rejected", "rejected", "2026-08-01T00:00:00.000Z", evidence); store.indexDetail(store.userHash("user-1"), "xft", "xft-rejected", "rejected", recentSubmittedAt, evidence);
const ding = { async listMyHistory() { return { items: [], diagnostics: {} }; } } as unknown as ApprovalService; const ding = { async listMyHistory() { return { items: [], diagnostics: {} }; } } as unknown as ApprovalService;
const xft = { async listMyHistory() { return { items: [], totalCount: 0, diagnostics: {} }; } } as unknown as XftApprovalProvider; const xft = { async listMyHistory() { return { items: [], totalCount: 0, diagnostics: {} }; } } as unknown as XftApprovalProvider;
const service = new InvoiceDeduplicationService(config(), store, ding, xft); const service = new InvoiceDeduplicationService(config(), store, ding, xft);
+246
View File
@@ -0,0 +1,246 @@
import assert from "node:assert/strict";
import { once } from "node:events";
import { setTimeout as delay } from "node:timers/promises";
import test, { type TestContext } from "node:test";
import express from "express";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { installMcpTransports } from "../src/mcp-http.js";
import { createApprovalMcpServer } from "../src/server.js";
import type { ApprovalService } from "../src/approval-service.js";
import type { AppConfig } from "../src/config.js";
const config: AppConfig = {
clientId: "mock-client", clientSecret: "mock-secret", apiBaseUrl: "https://api.test",
oapiBaseUrl: "https://oapi.test", publicBaseUrl: "http://localhost:3000", port: 3000,
requestTimeoutMs: 1000, officialLookbackDays: 120, officialScanConcurrency: 2,
officialClientOnlyProcessCodes: [], batchLimit: 20, approvalRefSecret: "mock-session-test-key",
};
async function until(condition: () => boolean) {
for (let i = 0; i < 200; i += 1) {
if (condition()) return;
await delay(10);
}
assert.fail("HTTP lifecycle event did not arrive");
}
async function harness(t: TestContext, readTodos: () => Promise<void> = async () => {},
limits: { maxSessionsPerUser?: number } = { maxSessionsPerUser: 8 }) {
let time = Date.now();
let closedStreams = 0;
const app = express();
app.use(express.json());
app.use((req, res, next) => {
if (req.method === "GET" && req.path === "/mcp") res.once("close", () => { closedStreams += 1; });
next();
});
const routes = installMcpTransports(app, {
now: () => time,
maxSessionsPerUser: limits.maxSessionsPerUser,
authenticate: (req, res, next) => {
// Synthetic bearer credentials; no external approval or OAuth writes.
const token = req.get("authorization");
if (token !== "Bearer mock-a" && token !== "Bearer mock-b") { res.sendStatus(401); return; }
req.auth = { token: "mock", clientId: "mock-client", scopes: ["mcp:tools"], extra: { userId: token === "Bearer mock-a" ? "mock-user-a" : "mock-user-b" } };
next();
},
requestUser: (req) => ({ userId: String(req.auth!.extra!.userId) }),
createServer: (user, lifecycle) => createApprovalMcpServer(config, {
async listMyTodos() { await readTodos(); return { items: [], diagnostics: {} }; },
} as unknown as ApprovalService, undefined, undefined, undefined, user, undefined, undefined, lifecycle),
});
const http = app.listen(0, "127.0.0.1");
await once(http, "listening");
const address = http.address();
assert.ok(address && typeof address !== "string");
const endpoint = new URL(`http://127.0.0.1:${address.port}/mcp`);
const clients: Client[] = [];
t.after(async () => {
await Promise.all(clients.map((client) => client.close()));
await routes.close();
http.closeAllConnections();
await new Promise<void>((resolve) => http.close(() => resolve()));
});
async function connect(account = "a") {
let streamReady = false;
const client = new Client({ name: "mock-old-bridge", version: "1.0" });
clients.push(client);
const transport = new StreamableHTTPClientTransport(endpoint, {
requestInit: { headers: { authorization: `Bearer mock-${account}` } },
fetch: async (input, init) => {
const res = await fetch(input, init);
if (init?.method === "GET" && res.status === 200) streamReady = true;
return res;
},
});
await client.connect(transport);
await until(() => streamReady);
return { client, transport };
}
async function rpc(sessionId?: string, account = "a", method = "initialize") {
const response = await fetch(endpoint, {
method: "POST",
headers: { authorization: `Bearer mock-${account}`, "content-type": "application/json",
accept: "application/json, text/event-stream", ...(sessionId ? { "mcp-session-id": sessionId } : {}) },
body: JSON.stringify({ jsonrpc: "2.0", id: 123, method,
...(method === "initialize" ? { params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "mock", version: "1" } } } : {}) }),
});
const body = await response.text();
return { response, body };
}
return { connect, rpc, endpoint, cleanup: routes.cleanup,
advance: (ms: number) => { time += ms; },
async disconnect(client: Client) {
const before = closedStreams;
await client.close(); // Deliberately no terminateSession(), like existing clients.
await until(() => closedStreams > before);
},
};
}
test("old bridge disconnects release eight occupied slots without a client upgrade", async (t) => {
const h = await harness(t);
const sessions = [];
for (let i = 0; i < 8; i += 1) sessions.push(await h.connect());
const full = await h.rpc();
assert.equal(full.response.status, 429);
assert.equal(full.response.headers.get("retry-after"), "60");
assert.match(full.body, /MCP session limit reached/);
for (const { client } of sessions) await h.disconnect(client);
h.advance(59_000);
assert.equal((await h.rpc()).response.status, 429, "retain reconnect grace");
h.advance(1_001);
const replacement = await h.connect(); // Admission itself sweeps expired sessions.
const result = await replacement.client.callTool({ name: "approval_list_todos", arguments: { source: "dingtalk" } });
assert.notEqual(result.isError, true);
assert.equal((await h.rpc(sessions[0]!.transport.sessionId, "a", "tools/list")).response.status, 404);
});
test("default gateway capacity admits 32 live connections for one user", async (t) => {
const h = await harness(t, undefined, {});
const sessions = [];
for (let i = 0; i < 32; i += 1) sessions.push(await h.connect());
assert.equal((await h.rpc()).response.status, 429);
assert.ok((await sessions[0]!.client.listTools()).tools.length > 0);
assert.ok((await sessions[31]!.client.listTools()).tools.length > 0);
});
test("operator override permits 64 sessions while retaining its configured cap", async (t) => {
const h = await harness(t, undefined, { maxSessionsPerUser: 64 });
const results = await Promise.all(Array.from({ length: 65 }, () => h.rpc()));
assert.equal(results.filter(({ response }) => response.status === 200).length, 64);
assert.equal(results.filter(({ response }) => response.status === 429).length, 1);
});
test("live streams survive idle cleanup and capacity does not evict another user", async (t) => {
const h = await harness(t);
const sessions = [];
for (let i = 0; i < 8; i += 1) sessions.push(await h.connect());
h.advance(3 * 60 * 60_000);
h.cleanup();
assert.equal((await h.rpc()).response.status, 429);
const other = await h.connect("b");
assert.ok((await other.client.listTools()).tools.length > 0);
assert.ok((await sessions[0]!.client.listTools()).tools.length > 0);
assert.equal((await h.rpc(sessions[0]!.transport.sessionId, "b", "tools/list")).response.status, 403);
});
test("GET reconnect within grace preserves the original session", async (t) => {
const h = await harness(t);
const original = await h.connect();
const id = original.transport.sessionId!;
await h.disconnect(original.client);
h.advance(30_000);
const abort = new AbortController();
const response = await fetch(h.endpoint, { headers: {
authorization: "Bearer mock-a", accept: "text/event-stream", "mcp-session-id": id,
}, signal: abort.signal });
t.after(() => abort.abort());
assert.equal(response.status, 200);
h.advance(90_000);
h.cleanup();
assert.equal((await h.rpc(id, "a", "tools/list")).response.status, 200);
abort.abort();
});
test("clients without GET retain the existing two-hour idle lifetime", async (t) => {
const h = await harness(t);
const initialized = await h.rpc();
const id = initialized.response.headers.get("mcp-session-id")!;
assert.ok(id);
h.advance(5 * 60_000);
h.cleanup();
assert.equal((await h.rpc(id, "a", "tools/list")).response.status, 200);
h.advance(2 * 60 * 60_000 + 1);
h.cleanup();
assert.equal((await h.rpc(id, "a", "tools/list")).response.status, 404);
});
test("disconnected in-flight approval reads finish before their session is reclaimed", async (t) => {
let started = false;
let finish!: () => void;
const gate = new Promise<void>((resolve) => { finish = resolve; });
t.after(() => finish());
const h = await harness(t, async () => { started = true; await gate; });
const active = await h.connect();
const id = active.transport.sessionId!;
const pending = active.client.callTool({ name: "approval_list_todos", arguments: { source: "dingtalk" } }).catch(() => undefined);
await until(() => started);
await h.disconnect(active.client);
await pending;
h.advance(25 * 60 * 60_000);
h.cleanup();
assert.equal((await h.rpc(id, "a", "tools/list")).response.status, 200, "even max age must not interrupt work");
finish();
await delay(20);
h.cleanup();
assert.equal((await h.rpc(id, "a", "tools/list")).response.status, 404);
});
test("concurrent initialization cannot bypass the per-user session cap", async (t) => {
const h = await harness(t);
const results = await Promise.all(Array.from({ length: 12 }, () => h.rpc()));
assert.equal(results.filter(({ response }) => response.status === 200).length, 8);
assert.equal(results.filter(({ response }) => response.status === 429).length, 4);
});
test("cancelled requests retain their slot until the handler actually settles", async (t) => {
let started = false;
let finish!: () => void;
const gate = new Promise<void>((resolve) => { finish = resolve; });
t.after(() => finish());
const h = await harness(t, async () => { started = true; await gate; });
const active = await h.connect();
const id = active.transport.sessionId!;
const abort = new AbortController();
const pending = active.client.callTool({ name: "approval_list_todos", arguments: { source: "dingtalk" } }, undefined, { signal: abort.signal }).catch(() => undefined);
await until(() => started);
abort.abort(); // SDK sends notifications/cancelled; the server suppresses the reply.
await pending;
await h.disconnect(active.client);
h.advance(60_001);
h.cleanup();
assert.equal((await h.rpc(id, "a", "tools/list")).response.status, 200);
finish();
await delay(20);
h.advance(60_001);
h.cleanup();
assert.equal((await h.rpc(id, "a", "tools/list")).response.status, 404, "cancellation must not leak the slot forever");
});
test("rejected duplicate GET does not evict the valid stream", async (t) => {
const h = await harness(t);
const active = await h.connect();
const response = await fetch(h.endpoint, { headers: {
authorization: "Bearer mock-a", accept: "text/event-stream", "mcp-session-id": active.transport.sessionId!,
} });
await response.text();
assert.equal(response.status, 409);
h.advance(60_001);
h.cleanup();
assert.ok((await active.client.listTools()).tools.length > 0);
});
+128
View File
@@ -3,6 +3,10 @@ import test from "node:test";
import smCrypto from "sm-crypto"; import smCrypto from "sm-crypto";
import type { AppConfig } from "../src/config.js"; import type { AppConfig } from "../src/config.js";
import { XftOpenApiClient } from "../src/xft-open-api.js"; import { XftOpenApiClient } from "../src/xft-open-api.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import type { ApprovalService } from "../src/approval-service.js";
import { createApprovalMcpServer } from "../src/server.js";
const privateKey = "1".padStart(64, "0"); const privateKey = "1".padStart(64, "0");
const { sm3, sm4 } = smCrypto; const { sm3, sm4 } = smCrypto;
@@ -1270,3 +1274,127 @@ test("Xft OpenAPI never calls an action endpoint for a bill outside the user's l
assert.equal(result.failed, 1); assert.equal(result.failed, 1);
assert.equal(actionCalls, 0); assert.equal(actionCalls, 0);
}); });
// All regression reads enter through MCP; only the upstream HTTP service is mocked.
async function withTaskQueryMcp(fakeFetch: typeof fetch, run: (client: Client) => Promise<void>) {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = createApprovalMcpServer(config(), {} as ApprovalService, new XftOpenApiClient(config(), fakeFetch));
const client = new Client({ name: "xft-task-query-test", version: "1.0.0" });
await server.connect(serverTransport);
try {
await client.connect(clientTransport);
await run(client);
} finally {
await client.close();
await server.close();
}
}
function taskQueryFixture(count: number, targetIndex = Math.min(count - 1, 499)) {
const records = Array.from({ length: count }, (_, index) => ({ todoTaskId: `SYNTHETIC-${index}` }));
const details = records.map((record, index) => ({
...record, businessId: String(90000000000 + index), appCode: "xft-itrip",
dealTime: "2026-08-14T04:00:00.000Z",
}));
const calls: Array<{ path: string; body: Record<string, unknown> }> = [];
const state = { lookup: false, absent: false, failBatch: 0, batches: 0, duplicate: false };
const fetcher: typeof fetch = async (input, init) => {
const path = new URL(String(input)).pathname;
const body = init?.body ? decodedBody(init) : {};
calls.push({ path, body });
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [
{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" },
] } });
if (path.endsWith("todo-task/page/by-user")) {
// Oversized list responses also exercise the hard cap at the detail boundary.
const start = state.lookup ? (Number(body.currentPage) - 1) * Number(body.pageSize) : 0;
const page = state.lookup ? records.slice(start, start + Number(body.pageSize)) : records;
return encrypted({ code: "0", data: { records: state.absent ? []
: state.duplicate ? [...page, ...page.slice(0, 20)] : page } });
}
if (path.endsWith("todo-task/detail/by-ids")) {
state.batches += 1;
const ids = body.todoTaskIds as string[];
assert.ok(ids.length <= 300);
if (state.batches === state.failBatch) return encrypted({ code: "XFTBIZ0001", message: "synthetic batch failure" });
// Reverse order to ensure enrichment joins by ID, not response position.
return encrypted({ code: "0", data: details.filter((item) => ids.includes(item.todoTaskId)).reverse() });
}
if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: (body.billIds as Array<string | number>).map((id) => ({
billId: String(id), eventType: "COMMON_REIM",
title: String(id) === String(90000000000 + targetIndex) ? "目标报销" : "其他报销",
applyTime: "2026-08-14T04:00:00.000Z",
})) } });
if (path.includes("/detail")) return encrypted({ code: "0", data: { billRemark: "目标报销" } });
throw new Error(`Unexpected upstream operation: ${path}`);
};
return { calls, state, fetcher };
}
const taskHistoryArguments = {
source: "xft", scope: "handled", query: "目标报销", limit: 20,
startDate: "2026-08-01", endDate: "2026-08-31",
};
for (const count of [300, 301, 1000]) {
test(`MCP XFT history batches ${count} task IDs and joins all detail batches`, async () => {
const fixture = taskQueryFixture(count);
fixture.state.duplicate = count === 301;
await withTaskQueryMcp(fixture.fetcher, async (client) => {
const response = await client.callTool({ name: "approval_list_history", arguments: taskHistoryArguments });
assert.notEqual(response.isError, true);
const result = (response.structuredContent as any).result;
assert.equal(result.sourceStatus.xft.status, "ok");
assert.equal(result.items.length, 1);
assert.equal(result.items[0].title, "目标报销");
const batches = fixture.calls.filter((call) => call.path.endsWith("detail/by-ids"))
.map((call) => call.body.todoTaskIds as string[]);
assert.deepEqual(batches.map((batch) => batch.length), count === 300 ? [300] : count === 301 ? [300, 1] : [300, 300, 300, 100]);
assert.equal(new Set(batches.flat()).size, count);
});
});
}
test("MCP XFT history reports a later batch failure without returning partial success or retrying", async () => {
const fixture = taskQueryFixture(1000);
fixture.state.failBatch = 2;
await withTaskQueryMcp(fixture.fetcher, async (client) => {
const response = await client.callTool({ name: "approval_list_history", arguments: taskHistoryArguments });
const result = (response.structuredContent as any).result;
assert.equal(result.sourceStatus.xft.status, "unavailable");
assert.deepEqual(result.items, []);
assert.equal(fixture.state.batches, 2);
assert.equal(fixture.calls.some((call) => call.path.endsWith("bill-list")), false);
});
});
for (const scope of ["todo", "done"]) {
for (const targetIndex of [0, 100, 199]) {
test(`MCP XFT ${scope} single detail finds index ${targetIndex} with small pages and stops early`, async () => {
const fixture = taskQueryFixture(250, targetIndex);
await withTaskQueryMcp(fixture.fetcher, async (client) => {
const listed = await client.callTool(scope === "done"
? { name: "approval_list_history", arguments: taskHistoryArguments }
: { name: "approval_list_todos", arguments: { source: "xft", query: "目标报销", limit: 20 } });
const approvalId = (listed.structuredContent as any).result.items[0].approvalId;
fixture.calls.length = 0;
fixture.state.lookup = true;
const response = await client.callTool({ name: "approval_get_detail", arguments: { approvalId } });
assert.notEqual(response.isError, true);
const pages = fixture.calls.filter((call) => call.path.endsWith("page/by-user"));
assert.deepEqual(pages.map((call) => call.body), Array.from({ length: Math.floor(targetIndex / 100) + 1 }, (_, index) => ({
currentPage: index + 1, dealStatus: scope, pageSize: 100,
})));
const summaries = fixture.calls.filter((call) => call.path.endsWith("bill-list"));
assert.equal(summaries.length, 1);
assert.deepEqual(summaries[0]!.body.billIds, [90000000000 + targetIndex]);
// Removing the user's live task must invalidate the previously issued reference.
fixture.state.absent = true;
fixture.calls.length = 0;
const missing = await client.callTool({ name: "approval_get_detail", arguments: { approvalId } });
assert.equal(missing.isError, true);
assert.equal(fixture.calls.some((call) => call.path.includes("/detail")), false);
});
});
}
}