/** * S-481 —— **无 run 可读的逐台 MCP 状态面**的 server 半场(设计稿 `2026-09-19-s481-mcp-status-face-v1`)。 * * ## 病(deploy 报案 I-1 / cli L-303) * 壳的一次性命令(`sema mcp list`)与交互会话对同一台 MCP 服务器给出**相反**的答案:命令行报 Failed、 * 会话里那台 server 的工具却能用。根因不是判定不同,而是**没有面** —— 一个从未起过 run 的会话在本服务上 * 只有 `GET /v1/capabilities.mcp` 那一枚布尔,壳只能自己去拨号、自己造词。 * * ## 这里立的两条面(词表与行形与 run 完全同源) * · `GET /v1/capabilities/mcp` —— 本部署**中心配置**的逐台状态; * · `POST /v1/capabilities/mcp/probe` —— 探测**调用方自带**的规格(壳的 `.mcp.json`)。 * * ## 🔴 一条铸点纪律:**本模块一行都不拼** * 行 = core 7.26.0 `probeMcpServers` 的 `entries`,那是 `mcpManifestEntries` 的产物,**逐字**等于一条 * 准备好的腿推上 `wiring_manifest.mcp[]` 的那些行(core `@contract` 原话:same projection, same failure * vocabulary, same neutralized and bounded remote text)。server 若自己按 `McpServerStatus` 拼一份,那就是 * 同一个语义面上的**第二份**铸点 —— 它与引擎面的漂移没有任何门看得见(既有 `GET /v1/sessions/:id/mcp` * 面板正是那一形,它的行是手拼的;本面刻意不沿用它,理由记在 DEBTS 而不是在这里再复制一遍)。 * * ## 亲核到的 core 签名(7.26.0 `dist/core/mcp-probe.d.ts`,**以 d.ts 为准**) * `probeMcpServers(specs: readonly McpServerSpec[], opts?: McpProbeOptions): Promise` * 设计小稿写的是 `probeMcpServers(specs, principal, opts)` —— **d.ts 赢**:`principal` 在 `opts` 里,不是 * 第二个位置参。`{ entries, servers }` **恒与 `specs` 同序同长**(`@contract mcp.probe.index_aligned`), * 失败台占位带 `errorCode` ⇒ 消费方按**下标**对拍,不按 `name`(名不保证唯一)。 * * ## 三问(behavior-facing,契约与 DEPLOY-PREREQS 同文) * · **谁需要**:壳的一次性命令与交互车道要同源(同一台 server、同一套词表、同一个判定); * · **谁受伤**:探测是**真拨号**(HTTP 连接 / stdio 起子进程),被滥用就是探测风暴与子进程堆积; * · **什么补偿**:每 principal 固定限速({@link MCP_PROBE_RATE_POLICY})+ 同规格 30s 有界缓存 * ({@link MCP_PROBE_CACHE_TTL_MS} / {@link MCP_PROBE_CACHE_MAX_ENTRIES},single-flight)+ 每台裁决有上界 * ({@link MCP_PROBE_VERDICT_TIMEOUT_MS})+ 自带规格那一口整条被 {@link mcpInjectionHonored} 挡在多租户 * 之外。**如实交代**:core 的 `timeoutMs` 界的是**裁决**不是拨号,而一个答完握手就卡住 SEND 的对端 * 在 core 那边没有任何期限(core `docs/KNOWN-LIMITS.md`)—— 那一形上本面同样没有期限,补偿只有限速与缓存。 */ import { type McpServerSpec } from "@sema-agent/core"; /** * 本面的**拒码闭集**(设计稿 §2 的四枚)。闭集写成型,是为了让 {@link MCP_PROBE_HTTP_STATUS} 那张表 * 「漏一枚 = 编译红」—— [ref] 的安全轴纪律:词表是闭集,未处置的成员必须是编译错误而不是运行期惊喜。 */ export declare const MCP_PROBE_REFUSAL_CODES: readonly ["capability.mcp_injection_required", "limit.rate_exceeded", "request.body_shape", "request.field_invalid", "state.mcp_probe_incomplete"]; export type McpProbeRefusalCode = (typeof MCP_PROBE_REFUSAL_CODES)[number]; /** * 🔴 **状态档取单表**(`GOVERNANCE_HTTP_STATUS` 的同族形,S-201② 的那条律):一个码在本服务上只有**一个** * 状态,而路由层**一个字面量都不许手写**。写成 `as const satisfies` 两头都占:格是字面量型(直接当 * `sendError` 的状态参数用,零 `?? 400` 兜底 —— 兜底就是第二份真源),`satisfies` 保住闭集门。 */ export declare const MCP_PROBE_HTTP_STATUS: { readonly "capability.mcp_injection_required": 501; readonly "limit.rate_exceeded": 429; readonly "request.body_shape": 400; readonly "request.field_invalid": 400; readonly "state.mcp_probe_incomplete": 503; }; /** * 每 principal 的固定窗限速。**刻意不挂任何无关旋钮**(S-470 的同一条教训,`DEVICE_ENROLL_RATE_POLICY` * 先例):全局 `RATE_LIMIT_RPM` 缺省是 `0` = 关断,把一条真拨号的面挂在它上面 = 出厂形不限速。 * 12/60s 是**安全地板**,不是可调参数;窗是**每副本**的(多副本部署上有效上界 = 12 × 副本数,如实记)。 */ export declare const MCP_PROBE_RATE_POLICY: { readonly limit: 12; readonly windowMs: 60000; }; /** 同 principal 同规格的结果复用窗(设计稿 §3)。命中**不计**限速、**回原 `probedAt` 时刻**。 */ export declare const MCP_PROBE_CACHE_TTL_MS = 30000; /** **已落地**结果的条数硬帽 —— 一只缓存自己绝不能变成资源耗尽点(`createFixedWindowRateLimiter` 同一条有界形)。 */ export declare const MCP_PROBE_CACHE_MAX_ENTRIES = 256; /** * **同时在跑**的走查条数硬帽(codex r1 [high] 的第二半)。 * * 为什么它必须是一道**拒**、不能靠逐出:在途的那一格**就是** single-flight 本身,把它从表里逐出去等于 * 撤掉这条面唯一的「同一份申报只拨一次」保证 —— 而那正是这道帽本来要保护的资源(连接 / 子进程)。 * ⇒ 帽满时**响亮拒**(429 + 退避提示),让调用方稍后再问,而不是悄悄多开一次拨号。 * 数字取限速额度的量级(12/60s/身份):在一台健康的部署上一次走查以毫秒~秒计,这道帽结构上摸不到; * 摸到它就说明这台机器上真的有一批拨号卡着,而那时候**少开**才是对的方向。 */ export declare const MCP_PROBE_MAX_INFLIGHT = 32; /** * 每台**裁决**上界,喂 core 的 `opts.timeoutMs`。 * * 🔴 **不是新旋钮**:没有 env、不进 config-catalog、部署方改不了它 —— 「不新铸旋钮」这条要求管的是 * **旋钮**,不是常量。数值与既有 `GET /v1/sessions/:id/mcp` 面板的物化上界同阶(那边 10s),而**名字刻意 * 独立**:本仓 `sessions.ts` 的四枚同族时限逐字写着理由 ——「两条腿的物化成本不同族,将来任一侧调窗时不该 * 被另一侧的名字绑架」。那边界的是**整次物化**,这边界的是**每台的裁决**,连语义都不是一回事。 * * ⚠️ core 的硬条款(d.ts 逐字):`timeoutMs` 界**裁决**不界**拨号** —— 被时钟放弃的那次拨号仍在跑, * core 在它落地时才 dispose。所以本面的「探测完零遗留」有一个**残留上界**,不是「立刻」:见 {@link MCP_PROBE_RESIDUE_SLACK_MS}。 */ export declare const MCP_PROBE_VERDICT_TIMEOUT_MS = 10000; /** * **整次走查**的上界(本面自己的钟,不是 core 的)—— **按申报条数派生**,不是一个魔法常量。 * * 🔴 为什么非有这口钟不可(异源复核 [high],亲读 core d.ts 后确认):core 的 `timeoutMs` 界的是**每台的裁决**, * 而整次调用**不会**在最后一次拨号落地之前 resolve —— 而「答完握手就卡住发送」的那一形在 core 那边 * **没有任何期限**(`docs/KNOWN-LIMITS.md`)。没有本钟时:那只 promise 永不落地 ⇒ 它永久占住一个缓存格与 * 一个在途位,而每一个后来问同一份申报的请求都会 join 上它、跟着一起永久挂住。 * * 🔴 为什么**派生**而不是钉一个数:钉死的那个数必须照最大申报(32 台)取,于是一次**只问一台**的请求要 * 白等 60s 才被告知「答不出来」—— 一个与它问的东西无关的宽限。派生式 = `ceil(n / 并发) × 每台裁决 + 收尸阶梯`, * 与 core 自己成文的走查时长公式**逐字同形**(d.ts:"its own duration is those per-dial times summed over * `ceil(n / concurrency)` rounds"),所以在一台**对端会答或会失败**的部署上这口钟结构上摸不到 —— 摸到它 * 就是真有一台对端在卡发送。读数:1 台 ⇒ 14s;32 台 ⇒ 44s。 * 兄弟先例:`GET /v1/sessions/:id/mcp` 面板的 `MCP_MATERIALIZE_TIMEOUT_MS`(那边界的也是整次物化,但它是 * 一个定值;本面的申报条数由调用方决定,所以定值在这里就是那个「与所问无关的宽限」)。 * **纯数据** ⇒ `build*`。 */ export declare function buildMcpProbeWalkTimeoutMs(declarations: number): number; /** * 一次走查里**同时追裁决**的台数,喂 core 的 `opts.concurrency`。 * * 为什么不用缺省的 1:core 的 d.ts 明写整次调用的时长 = 每台时长在 `ceil(n / concurrency)` 轮上的和。 * 缺省 1 下一个 32 台(={@link MAX_REQUEST_MCP_SERVERS})的申报最坏是 32 × {@link MCP_PROBE_VERDICT_TIMEOUT_MS} * —— 一条 HTTP 请求挂五分钟。8 把最坏收进 4 轮。代价 core 也写明了:并发只影响 `connectMs` 这个读数的 * 争用度,**行与下标对齐不受影响**(而本面根本不发 `connectMs`)。 * ⚠️ 它**不是**「同时开几条传输」的帽:被时钟放弃的拨号仍在飞,最坏可达一条/台申报(core 原话)。 */ export declare const MCP_PROBE_CONCURRENCY = 8; /** * 「探测完子进程零遗留」的**残留上界**(判据 G6 用它,**不自定**)。 * * 逐字抄 core 7.26.0 CHANGELOG 的「界」句与 `mcp-probe.d.ts` 的 `@contract mcp.probe.zero_residue_bound`: * *the stdio close ladder then adds ≤ 4 000 ms (SIGTERM, then SIGKILL)*,整句是 * *that dial's residue is gone within `max(timeoutMs, the handshake budget + the listing budget) + 4 000 ms`*。 * 4 000 就是那个 `+ 4 000 ms`;同一句话的另一半 —— *a server that exits when its input closes is gone at * return* —— 是规矩服务器的那一形(G6 的正控钉在这一半上)。 */ export declare const MCP_PROBE_RESIDUE_SLACK_MS = 4000; /** 观测计数的 `outcome` 闭集(`mcp_probe_total{outcome}`)。 */ export declare const MCP_PROBE_OUTCOMES: readonly ["probed", "cached", "empty", "gate_closed", "rate_limited", "bad_request", "failed"]; export type McpProbeOutcome = (typeof MCP_PROBE_OUTCOMES)[number]; /** 两条面的**同一个**体形(设计稿 §2:`POST` 回「200 同形体」)。行**只**来自 core 的 `entries`。 */ export interface McpProbeFaceBody { /** 这份读数是**哪一刻**的(缓存命中回原时刻 —— 一个复用的答案假装自己是新的,就是一条假事实)。 */ readonly probedAt: string; /** 这份读数还能复用多久(秒)= {@link MCP_PROBE_CACHE_TTL_MS}。 */ readonly ttlSec: number; /** * 逐字 = `wiring_manifest.mcp[]` 在 wire 上的那一行 —— **同一只投影** * ({@link projectWiringManifestMcpRows},契约 §G.7),不是本面自己挑的一份键。与入参申报**同序同长** * (core `@contract mcp.probe.index_aligned`;下标对齐,不按 name)。 * * 🔴 **`error`(远端原话)因此在本面上同样缺席**,而这**不是**本车的选择:§G.7 第 3 条是一条成文裁定 * —— 那一格是这帧上唯一的远端作者自由文本,core 7.5.0 [ref] 已把 MCP 远端错误文本的脱敏收敛到**一个** * 铸点,server 在读面再脱一遍就是同一语义面的第二个写者(两遍脱敏的重叠通常比原缺陷更坏且静默)。 * 本车的 G7 当场量到了这条裁定的分量:不走这只投影时,一条 `http://user:pw@host` 形申报的**用户名** * 会逐字上 wire(core 的中立化只遮口令位)。可操作的因由在 `errorCode` 闭集上;要远端原话得先走 * §G.7 第 3 条那条「带触发条件的裁定」。 */ readonly servers: readonly Record[]; } /** 一次拒绝的**结构性**产物:码 + 句 + 可机读的键名表(消费端不必解析文案)。状态由 {@link MCP_PROBE_HTTP_STATUS} 单表答。 */ export interface McpProbeRefusal { /** 🔴 键名是 `errorCode`,**不是** `code`:3.0.0 起 wire 错误体只有一个机器判别键,`test/error-code-key-gate.test.ts` * 连「错误体附近的对象字面量里出现 `code:`」都拦(本批红先实证:第一版写成 `code` 当场被那道门逮住)。 */ readonly errorCode: McpProbeRefusalCode; readonly message: string; /** `request.body_shape` 专用:词表外 / 本面不接线的键,全路径形(已清洗+截断+排序+封顶)。 */ readonly unknownKeys?: readonly string[]; } /** * `POST …/probe` 的体读器:闭形、逐条点名、零静默丢。成功 ⇒ 交给 core 的 `specs`(**原样**转发被读出来的 * 条目,`McpServerSpec` 的可选键一个都不重写 —— 重写就是在 core 的型上做第二次投影)。 */ export declare function readMcpProbeBody(raw: unknown): { ok: true; specs: McpServerSpec[]; } | { ok: false; refusal: McpProbeRefusal; }; /** * 本面**唯一**的调用方身份归一(异源复核 [low]:此前缓存键用 `principal ?? ""`、限速键用 `… : "local"` * —— 两份归一,于是一个 principal **字面就叫 `local`** 的调用方与「无 principal」共用同一个限速窗, * 而缓存键又不撞。⇒ 两条键从此读同一只函数)。 * * 无 principal(单用户部署上恒如此)折成 `local`:那台机器上只有一个人。 */ export declare function buildMcpProbeIdentity(principal: string | undefined): string; /** 缓存键 = `(身份, sha256(规范化 specs))`。**纯数据** ⇒ `build*`。 */ export declare function buildMcpProbeCacheKey(principal: string | undefined, specs: readonly McpServerSpec[]): string; /** 限速键(设计稿 §3:`mcp-probe:<身份>`)。**纯数据** ⇒ `build*`。 */ export declare function buildMcpProbeRateKey(principal: string | undefined): string; /** 本域独占的运行期状态(每 server 实例一份 —— 模块级会把缓存与限速窗跨实例串味)。 */ export interface McpProbeLocal { /** * 固定窗限速({@link MCP_PROBE_RATE_POLICY});键由 {@link buildMcpProbeRateKey} 铸。 * * 🔴 实现**不在本文件**:它是既有的 {@link createFixedWindowRateLimiter}(device 注册口那一只)。 * 异源复核 [high] 逐行对出本文件此前手抄了同一套算法(同一份 `{startedAtMs,count}`、同一个 * `MAX_WINDOWS = 10_000`、同一句「过期先清、仍超清最旧一半」、同一个 `retryAfterSec` 算式),而注里 * 还**引着**那只函数的名字 —— 一条规则两份实现、没有任何编译期连接,正是 [ref] 的第一排查点。 */ check(key: string): Promise<{ allowed: boolean; retryAfterSec?: number; }>; /** * single-flight + TTL 的结果表;命中回**同一只** promise(并发首拨塌成一次走查)。 * * 🔴 **两条判据,不是一条**(codex 对抗复审 r1 [high],亲跑复现后按类修): * · 这一格**还在跑** ⇒ 恒命中,**与年龄无关** —— 它就是 single-flight 本身; * · 这一格**已落地** ⇒ 从**落地那一刻**起算 {@link MCP_PROBE_CACHE_TTL_MS}。 * 修前的起点是**拨号开始**那一刻,于是一次跑得比窗口长的走查(慢服务器 / 卡住的对端)30s 之后从表里 * 消失而它还在跑 ⇒ 同一份申报的下一次请求另起一次走查,single-flight 恰好在最该生效的那一形上失效, * 连接与子进程按限速额度叠加。红先读数(G5-bis):一台答完握手就不再答列表的 stdio 服务器,两次请求 * 之间跨过窗口 ⇒ **两个**子进程。 */ cached(key: string, now: number): Promise | undefined; /** * 登记一次走查。🔴 **格的寿命由 `walk.dials` 驱动,不由 `walk.answer` 驱动**(合并树 codex ① [high]): * · `dials` 还没落地 ⇒ 这一格**在途**:恒命中(同规格不再拨号)、不计复用窗、算一个在途位; * · `dials` **resolve** ⇒ 从那一刻起算 {@link MCP_PROBE_CACHE_TTL_MS}(正常的结果复用); * · `dials` **reject** ⇒ 整格丢掉(下一次请求重新拨,而不是被一条缓存起来的失败钉住半分钟)。 * 应答面(`answer`)可能**早于** `dials` 就有结论(本面的期限到点)—— 那一段窗口里这一格仍然是「在途」, * 后来者拿到的是**同一只** `answer`(立刻那句 503),资源计数也仍然算着它。 */ remember(key: string, at: number, walk: McpProbeWalk, now?: () => number): void; /** 缓存里现在有几条(判据面读它,不去摸内部表)。 */ size(): number; /** 现在有几次走查**还在跑**(帽 = {@link MCP_PROBE_MAX_INFLIGHT};判据面读它,不去摸内部表)。 */ inflight(): number; } /** 带行为的东西 ⇒ `create*`(工厂命名律:`build*` 出纯数据,`create*` 出活对象)。 */ export declare function createMcpProbeLocal(): McpProbeLocal; /** * 一次走查。**两只 promise,两件事**(合并树 codex ① [high],亲跑复现后按类修): * · `answer` —— **HTTP 应答面**:到本面的期限就 reject {@link McpProbeIncomplete},调用腿据它答 503; * · `dials` —— **拨号生命周期**:core 真的把它开过的每一条都 dispose 完了才 settle。 * * 🔴 为什么必须分家(修前是同一只):core 的拨号**不收取消**、要等 dial 落地才 dispose,所以「本面不再等它」 * 与「那条连接已经没了」是**两个时刻**。修前两者共用一只 promise ⇒ 超时那一拍就把缓存格与在途计数一起释放: * ① 那条**仍然开着**的拨号不再计入在途帽,② 同规格重试会**重新拨一次**。对一个握手后卡 SEND 的对端, * 限速只能降累积速度、限不住资源**总量** —— 红先实测:反复问同一份申报,对端存活连接从 1 涨到 6。 * 分家之后:在途格与资源计数**直到 `dials` 落地才释放**,期间同规格请求挂在**同一只** `answer` 上 * (立刻拿到那句 503,不再拨号)。 * * `entries` 一个字都不改写(不排序、不过滤、不补键):它与入参 `specs` 同序同长是 core 的成文契约, * 消费方按**下标**对拍自己的申报表。**带行为** ⇒ `create*`。 */ export interface McpProbeWalk { readonly answer: Promise; readonly dials: Promise; } export declare function createMcpProbeWalk(specs: readonly McpServerSpec[], principal: string | undefined, now?: () => number): McpProbeWalk; /** 整次走查没在 {@link MCP_PROBE_WALK_TIMEOUT_MS} 内落地。**具名型**而不是一个字符串比较:调用腿要据它 * 分诊(503 + 退避),而按错误文案分诊是本仓明令不许的形。 */ export declare class McpProbeIncomplete extends Error { readonly boundMs: number; constructor(boundMs: number); } /** 整次走查超界那一句。**不是**「探测失败」——它说的是「这台机器现在答不出来,稍后可能就能」。 */ export declare function buildMcpProbeIncompleteRefusal(boundMs: number): McpProbeRefusal; /** 零申报时的诚实空面(**不**进缓存、**不**占限速:一次没有拨号的回答不该消耗任何配额)。 */ export declare function buildEmptyMcpProbeFace(now?: number): McpProbeFaceBody; /** * {@link mcpInjectionHonored} 为假时那一句 —— **三条否决各自点名**(多租户 / 锁 / 合规档位),因为调用方 * 的下一步动作三条各不相同,而一句「not honored」把三件事压成一件。码只有一枚:消费端的分支是同一个 * (换部署形态 / 改旋钮,而不是重试)。 */ export declare function buildMcpInjectionRefusal(): McpProbeRefusal; /** * 限速那一句。与上面三只同住一个模块,是本面**四句 wire 文案的唯一属主** —— 一条面的措辞散在路由体里, * 下一个人改一个字时没有任何地方会红。 * * ⚠️ **如实交代一条覆盖边界**:全仓的 `api-error-text-freeze` 门锚的是 `sendError(res, <字面三位数>, * "<字面码>", …)`,而本面刻意**不在调用点写字面状态**(状态从 {@link MCP_PROBE_HTTP_STATUS} 单表取, * S-201② 那条律),于是这四句结构上够不着那道门。⇒ 它们由 `test/mcp-status-face-s481.test.ts` 的 * 「四句逐字节」那一格钉住(改一个字当场红)。把那道全局门扩到「常量表出站」形是另一台车。 */ export declare function buildMcpProbeRateRefusal(): McpProbeRefusal; /** * 在途帽满那一句(codex r1 [high] 的第二半)。**沿用** `limit.rate_exceeded` 而不新铸第五枚码:消费端的 * 分支与限速逐字相同(稍后再问),而拒码闭集每多一枚,按闭集写 `switch` 的消费端就多一次改。 * `retryAfterSec` 是**提示**不是承诺,而这一点必须说出来:每台的裁决有上界,但一个答完握手就卡住发送的 * 对端在引擎那边没有任何期限(core `docs/KNOWN-LIMITS.md`),所以「前面那次走查应该在这之内结束」是 * 常态下的话,不是最坏情况的保证。 */ export declare function buildMcpProbeInflightRefusal(): McpProbeRefusal; /** * 自带申报那一口上、core 的物化**拒了整份申报集**那一句(唯一的成文因由:两台在同一个命名空间前缀下挂载, * 在任何传输被触碰**之前**就被拒)。 * * 异源复核 [medium]:修前这一形在两条口上都被抛给分派器尾 ⇒ `500 internal.error`。可自带申报那一口的申报 * 是**调用方**写的,把它答成 500 是把调用方的错说成这台机器故障;中心申报那一口反过来 —— 那是部署方写的, * 5xx 才是对的方向。⇒ 一条规则:**谁写的申报,谁的错**。 * * 🔴 **不回显因由**:那句话来自申报集本身,可能带调用方的内情(名字、地址形)。调用方手里有自己的体, * 点名「两台撞了命名空间」就够他自己找;原文留在那一层的日志里。 */ export declare function buildMcpProbeDeclarationRefusal(): McpProbeRefusal; //# sourceMappingURL=mcp-probe.d.ts.map