@sema-agent/server 7.92.2 → 7.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,318 @@
1
+ import { probeMcpServers } from "@sema-agent/core";
2
+ import { createHash } from "node:crypto";
3
+ import { createFixedWindowRateLimiter } from "../device-enrollment.js";
4
+ import { canonicalJson } from "../canonical-json.js";
5
+ import { echoKeyName, keyClosureClause, scanKeyClosure } from "../request-key-closure.js";
6
+ import { CONTENT_ORIGIN_WORDS, describeBadContentOrigin, isContentOriginWord } from "../mcp-content-origin.js";
7
+ import { HTTP_MCP_TRANSPORT_KEYS, MAX_REQUEST_MCP_SERVERS, MCP_ENTRY_EMPTY_ARRAY_KEYS, MCP_ENTRY_EMPTY_RECORD_KEYS, MCP_SERVER_ENTRY_KEYS, MCP_TRANSPORT_EMPTY_ARRAY_KEYS, MCP_TRANSPORT_EMPTY_RECORD_KEYS, STDIO_MCP_TRANSPORT_KEYS, validateRequestMcp, } from "../task-mcp.js";
8
+ import { buildDisplaySafeUrl } from "../trace/redact.js";
9
+ import { projectWiringManifestMcpRows } from "../trace/project.js";
10
+ export const MCP_PROBE_REFUSAL_CODES = [
11
+ "capability.mcp_injection_required",
12
+ "limit.rate_exceeded",
13
+ "request.body_shape",
14
+ "request.field_invalid",
15
+ "state.mcp_probe_incomplete",
16
+ ];
17
+ export const MCP_PROBE_HTTP_STATUS = {
18
+ "capability.mcp_injection_required": 501,
19
+ "limit.rate_exceeded": 429,
20
+ "request.body_shape": 400,
21
+ "request.field_invalid": 400,
22
+ "state.mcp_probe_incomplete": 503,
23
+ };
24
+ export const MCP_PROBE_RATE_POLICY = { limit: 12, windowMs: 60_000 };
25
+ export const MCP_PROBE_CACHE_TTL_MS = 30_000;
26
+ export const MCP_PROBE_CACHE_MAX_ENTRIES = 256;
27
+ export const MCP_PROBE_MAX_INFLIGHT = 32;
28
+ export const MCP_PROBE_VERDICT_TIMEOUT_MS = 10_000;
29
+ export function buildMcpProbeWalkTimeoutMs(declarations) {
30
+ return Math.ceil(Math.max(1, declarations) / MCP_PROBE_CONCURRENCY) * MCP_PROBE_VERDICT_TIMEOUT_MS + MCP_PROBE_RESIDUE_SLACK_MS;
31
+ }
32
+ export const MCP_PROBE_CONCURRENCY = 8;
33
+ export const MCP_PROBE_RESIDUE_SLACK_MS = 4_000;
34
+ export const MCP_PROBE_OUTCOMES = ["probed", "cached", "empty", "gate_closed", "rate_limited", "bad_request", "failed"];
35
+ const PROBE_BODY_KEYS = ["mcpServers"];
36
+ const ACCEPTED_PROBE_BODY = new Set(PROBE_BODY_KEYS);
37
+ const NO_TOLERATED_KEYS = new Set();
38
+ const shapeRefusal = (message, unknownKeys) => ({
39
+ errorCode: "request.body_shape",
40
+ message,
41
+ ...(unknownKeys !== undefined ? { unknownKeys } : {}),
42
+ });
43
+ const fieldRefusal = (message) => ({ errorCode: "request.field_invalid", message });
44
+ function entryKeyIssue(entry, at) {
45
+ const buckets = { unknown: [], unsupported: [] };
46
+ scanKeyClosure(entry, `${at}.`, new Set(MCP_SERVER_ENTRY_KEYS), NO_TOLERATED_KEYS, buckets);
47
+ const t = entry.transport;
48
+ if (t !== null && typeof t === "object" && !Array.isArray(t)) {
49
+ const kind = t.kind;
50
+ const keys = kind === "stdio" ? STDIO_MCP_TRANSPORT_KEYS : kind === "http" ? HTTP_MCP_TRANSPORT_KEYS : undefined;
51
+ if (keys !== undefined)
52
+ scanKeyClosure(t, `${at}.transport.`, new Set(keys), NO_TOLERATED_KEYS, buckets);
53
+ }
54
+ if (buckets.unknown.length === 0)
55
+ return null;
56
+ buckets.unknown.sort();
57
+ return shapeRefusal(`body: unrecognized key(s) on an MCP declaration — refused rather than dropped, because a dropped key reads as "in effect" to the caller. ` +
58
+ `${keyClosureClause(buckets.unknown, "unknown (not an mcpServers[] key — check the spelling)")}. ` +
59
+ `Accepted per entry: ${[...MCP_SERVER_ENTRY_KEYS].sort().join(",")} (transport.kind "stdio": ${[...STDIO_MCP_TRANSPORT_KEYS].sort().join(",")}; "http": ${[...HTTP_MCP_TRANSPORT_KEYS].sort().join(",")})`, buckets.unknown);
60
+ }
61
+ function entryValueIssue(entry, at) {
62
+ const read = validateRequestMcp([entry]);
63
+ if (read === null)
64
+ return fieldRefusal(`${at} is not a readable MCP declaration`);
65
+ const dropped = read.dropped[0];
66
+ if (dropped !== undefined || read.ok.length !== 1) {
67
+ const field = dropped?.field ?? "entry";
68
+ return fieldRefusal(`${at}.${field} is not a usable value${urlEchoFor(entry, field)} — this endpoint refuses the whole list rather than dialing a silently shortened one` +
69
+ ` (a task submission would instead drop just this declaration and mount its siblings; a probe answering "can I use these" must not quietly answer about fewer)`);
70
+ }
71
+ if (read.badContentOrigin.length > 0) {
72
+ return fieldRefusal(`${at}.contentOrigin must be one of ${CONTENT_ORIGIN_WORDS.map((w) => `"${w}"`).join(" | ")} — got ${describeBadContentOrigin(entry.contentOrigin)} (omit the key for the default: this server's tools classify as external content)`);
73
+ }
74
+ return declarationDowngradeIssue(entry, read.ok[0], at);
75
+ }
76
+ function declarationDowngradeIssue(entry, spec, at) {
77
+ if (spec === undefined)
78
+ return fieldRefusal(`${at} is not a readable MCP declaration`);
79
+ const diff = jsonPathDiff(stripEquivalentEmpties(entry), stripEquivalentEmpties(spec), at);
80
+ if (diff.length === 0)
81
+ return null;
82
+ diff.sort();
83
+ return fieldRefusal(`${keyClosureClause(diff, "unusable value(s)")} — this deployment's declaration reader could not use these exactly as written, and a probe must not dial a configuration that differs from the one you declared` +
84
+ " (a task submission would instead keep the server and silently drop just these, which is why the two faces disagree here on purpose; this endpoint refuses, because the reading would then describe a server you did not ask about)");
85
+ }
86
+ function stripEquivalentEmpties(entry) {
87
+ const emptyRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0;
88
+ const emptyArray = (v) => Array.isArray(v) && v.length === 0;
89
+ const keep = (bag, recordKeys, arrayKeys) => {
90
+ const out = {};
91
+ for (const [k, v] of Object.entries(bag)) {
92
+ if (recordKeys.includes(k) && emptyRecord(v))
93
+ continue;
94
+ if (arrayKeys.includes(k) && emptyArray(v))
95
+ continue;
96
+ out[k] = v;
97
+ }
98
+ return out;
99
+ };
100
+ const top = keep(entry, MCP_ENTRY_EMPTY_RECORD_KEYS, MCP_ENTRY_EMPTY_ARRAY_KEYS);
101
+ const t = top.transport;
102
+ if (t !== null && typeof t === "object" && !Array.isArray(t)) {
103
+ top.transport = keep(t, MCP_TRANSPORT_EMPTY_RECORD_KEYS, MCP_TRANSPORT_EMPTY_ARRAY_KEYS);
104
+ }
105
+ return top;
106
+ }
107
+ function jsonPathDiff(a, b, path) {
108
+ if (canonicalJson(a) === canonicalJson(b))
109
+ return [];
110
+ const aRec = a !== null && typeof a === "object" && !Array.isArray(a) ? a : undefined;
111
+ const bRec = b !== null && typeof b === "object" && !Array.isArray(b) ? b : undefined;
112
+ if (aRec !== undefined && bRec !== undefined) {
113
+ const out = [];
114
+ for (const k of new Set([...Object.keys(aRec), ...Object.keys(bRec)]))
115
+ out.push(...jsonPathDiff(aRec[k], bRec[k], `${path}.${k}`));
116
+ return out.length > 0 ? out : [path];
117
+ }
118
+ if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
119
+ const out = [];
120
+ for (let i = 0; i < a.length; i++)
121
+ out.push(...jsonPathDiff(a[i], b[i], `${path}[${i}]`));
122
+ return out.length > 0 ? out : [path];
123
+ }
124
+ return [path];
125
+ }
126
+ function urlEchoFor(entry, field) {
127
+ if (field !== "transport.url")
128
+ return "";
129
+ const t = entry.transport;
130
+ const raw = t !== null && typeof t === "object" ? t.url : undefined;
131
+ return typeof raw === "string" ? ` (got "${buildDisplaySafeUrl(raw)}"; an MCP declaration's address must be an http(s) URL — put credentials in transport.headers, never in the URL)` : "";
132
+ }
133
+ export function readMcpProbeBody(raw) {
134
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
135
+ return { ok: false, refusal: shapeRefusal("body must be a JSON object of the shape { mcpServers: [ … ] } (the same declaration list a task submits)") };
136
+ const buckets = { unknown: [], unsupported: [] };
137
+ scanKeyClosure(raw, "", ACCEPTED_PROBE_BODY, NO_TOLERATED_KEYS, buckets);
138
+ if (buckets.unknown.length > 0) {
139
+ buckets.unknown.sort();
140
+ return {
141
+ ok: false,
142
+ refusal: shapeRefusal(`body: unrecognized top-level key(s) — refused rather than dropped, because a dropped key reads as "in effect" to the caller. ` +
143
+ `${keyClosureClause(buckets.unknown, "unknown (this face takes only the declaration list)")}. ` +
144
+ `Accepted: ${[...ACCEPTED_PROBE_BODY].sort().join(",")}`, buckets.unknown),
145
+ };
146
+ }
147
+ const list = raw.mcpServers;
148
+ if (!Array.isArray(list))
149
+ return { ok: false, refusal: shapeRefusal("mcpServers must be an array of MCP server declarations (send [] to ask about nothing)") };
150
+ if (list.length > MAX_REQUEST_MCP_SERVERS) {
151
+ return { ok: false, refusal: fieldRefusal(`mcpServers carries ${list.length} declarations, above this deployment's ceiling of ${MAX_REQUEST_MCP_SERVERS} — the probe dials every one of them, so the list is refused whole rather than silently truncated to a partial answer`) };
152
+ }
153
+ for (let i = 0; i < list.length; i++) {
154
+ const at = `mcpServers[${i}]`;
155
+ const entry = list[i];
156
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry))
157
+ return { ok: false, refusal: shapeRefusal(`${at} must be an object`) };
158
+ const keyIssue = entryKeyIssue(entry, at);
159
+ if (keyIssue)
160
+ return { ok: false, refusal: keyIssue };
161
+ const valueIssue = entryValueIssue(entry, at);
162
+ if (valueIssue)
163
+ return { ok: false, refusal: valueIssue };
164
+ }
165
+ const validated = validateRequestMcp(list);
166
+ if (validated === null || validated.ok.length !== list.length) {
167
+ return {
168
+ ok: false,
169
+ refusal: fieldRefusal(`mcpServers: this deployment's declaration reader answered differently for the list (${validated === null ? 0 : validated.ok.length}) than it did entry by entry (${list.length}),` +
170
+ " so the probe refuses rather than dialing a silently shortened list. This is a belt on a path that should not be reachable (the reader is deterministic and the list was already capped)," +
171
+ " so nothing in your body needs changing — if you see it, this deployment has a bug worth reporting"),
172
+ };
173
+ }
174
+ return { ok: true, specs: validated.ok };
175
+ }
176
+ export function buildMcpProbeIdentity(principal) {
177
+ return principal !== undefined && principal.length > 0 ? principal : "local";
178
+ }
179
+ export function buildMcpProbeCacheKey(principal, specs) {
180
+ return `${buildMcpProbeIdentity(principal)}::${createHash("sha256").update(canonicalJson(specs), "utf8").digest("hex")}`;
181
+ }
182
+ export function buildMcpProbeRateKey(principal) {
183
+ return `mcp-probe:${buildMcpProbeIdentity(principal)}`;
184
+ }
185
+ export function createMcpProbeLocal() {
186
+ const rate = createFixedWindowRateLimiter(MCP_PROBE_RATE_POLICY);
187
+ const cache = new Map();
188
+ const pruneCache = (now) => {
189
+ const settled = (v) => v.settledAt !== undefined;
190
+ for (const [k, v] of cache)
191
+ if (v.settledAt !== undefined && now - v.settledAt > MCP_PROBE_CACHE_TTL_MS)
192
+ cache.delete(k);
193
+ if (cache.size <= MCP_PROBE_CACHE_MAX_ENTRIES)
194
+ return;
195
+ const victims = [...cache.entries()].filter(([, v]) => settled(v)).sort((a, b) => (a[1].settledAt ?? 0) - (b[1].settledAt ?? 0));
196
+ for (const [k] of victims) {
197
+ if (cache.size <= MCP_PROBE_CACHE_MAX_ENTRIES)
198
+ break;
199
+ cache.delete(k);
200
+ }
201
+ };
202
+ return {
203
+ check: async (key) => await rate.check(key),
204
+ cached: (key, now) => {
205
+ const hit = cache.get(key);
206
+ if (hit === undefined)
207
+ return undefined;
208
+ if (hit.settledAt === undefined)
209
+ return hit.body;
210
+ if (now - hit.settledAt > MCP_PROBE_CACHE_TTL_MS) {
211
+ cache.delete(key);
212
+ return undefined;
213
+ }
214
+ return hit.body;
215
+ },
216
+ remember: (key, at, walk, now = () => Date.now()) => {
217
+ cache.set(key, { settledAt: undefined, body: walk.answer });
218
+ void walk.dials.then(() => {
219
+ const cur = cache.get(key);
220
+ if (cur?.body === walk.answer)
221
+ cur.settledAt = now();
222
+ }, () => {
223
+ const cur = cache.get(key);
224
+ if (cur?.body === walk.answer)
225
+ cache.delete(key);
226
+ });
227
+ pruneCache(at);
228
+ },
229
+ size: () => cache.size,
230
+ inflight: () => [...cache.values()].filter((v) => v.settledAt === undefined).length,
231
+ };
232
+ }
233
+ export function createMcpProbeWalk(specs, principal, now = Date.now) {
234
+ const bound = buildMcpProbeWalkTimeoutMs(specs.length);
235
+ const controller = new AbortController();
236
+ const dials = probeMcpServers(specs, {
237
+ ...(principal !== undefined && principal.length > 0 ? { principal } : {}),
238
+ timeoutMs: MCP_PROBE_VERDICT_TIMEOUT_MS,
239
+ concurrency: MCP_PROBE_CONCURRENCY,
240
+ signal: controller.signal,
241
+ });
242
+ dials.catch(() => undefined);
243
+ let timer;
244
+ const deadline = new Promise((_resolve, reject) => {
245
+ timer = setTimeout(() => {
246
+ controller.abort(new McpProbeIncomplete(bound));
247
+ reject(new McpProbeIncomplete(bound));
248
+ }, bound);
249
+ });
250
+ const answer = (async () => {
251
+ try {
252
+ const result = await Promise.race([dials, deadline]);
253
+ const rows = projectWiringManifestMcpRows(result.entries) ?? [];
254
+ if (rows.length !== specs.length) {
255
+ throw new Error(`mcp probe row count ${rows.length} does not match the ${specs.length} declarations it must align with by index — refusing to publish a list a consumer would mis-pair`);
256
+ }
257
+ return { probedAt: new Date(now()).toISOString(), ttlSec: Math.floor(MCP_PROBE_CACHE_TTL_MS / 1000), servers: rows };
258
+ }
259
+ finally {
260
+ if (timer !== undefined)
261
+ clearTimeout(timer);
262
+ }
263
+ })();
264
+ answer.catch(() => undefined);
265
+ return { answer, dials };
266
+ }
267
+ export class McpProbeIncomplete extends Error {
268
+ boundMs;
269
+ constructor(boundMs) {
270
+ super("mcp probe walk did not settle within this deployment's bound");
271
+ this.boundMs = boundMs;
272
+ this.name = "McpProbeIncomplete";
273
+ }
274
+ }
275
+ export function buildMcpProbeIncompleteRefusal(boundMs) {
276
+ return {
277
+ errorCode: "state.mcp_probe_incomplete",
278
+ message: `the MCP probe did not finish within ${boundMs / 1000}s, so this endpoint has no honest per-server answer to give and will not return a partial one that reads as complete — ` +
279
+ "the usual cause is a declared server that completes its handshake and then stops answering, which the engine cannot cancel (its dial owns no deadline). " +
280
+ "Retrying may well succeed; if it keeps happening, the declaration that never answers is the thing to fix",
281
+ };
282
+ }
283
+ export function buildEmptyMcpProbeFace(now = Date.now()) {
284
+ return { probedAt: new Date(now).toISOString(), ttlSec: Math.floor(MCP_PROBE_CACHE_TTL_MS / 1000), servers: [] };
285
+ }
286
+ export function buildMcpInjectionRefusal() {
287
+ return {
288
+ errorCode: "capability.mcp_injection_required",
289
+ message: "this deployment does not honor caller-supplied MCP declarations, so it will not dial them on your behalf: " +
290
+ "either it is multi-tenant (REQUIRE_PRINCIPAL=true — a tenant must not make the shared worker run an arbitrary command or connect to a body-chosen address), " +
291
+ "or `mcp` is an administratively locked config key, or its compliance posture denies `mcp_servers`. " +
292
+ "Read `capabilities.mcpInjection` to tell before asking; `GET /v1/capabilities/mcp` reports this deployment's OWN declared servers and stays available",
293
+ };
294
+ }
295
+ export function buildMcpProbeRateRefusal() {
296
+ return {
297
+ errorCode: "limit.rate_exceeded",
298
+ message: "MCP probe rate limit exceeded — this face dials every declared server, so it is capped per caller identity " +
299
+ `(${MCP_PROBE_RATE_POLICY.limit} per ${MCP_PROBE_RATE_POLICY.windowMs / 1000}s per replica) independently of every other limit on this deployment; ` +
300
+ `a reading already taken is reused for ${MCP_PROBE_CACHE_TTL_MS / 1000}s and does NOT consume the cap`,
301
+ };
302
+ }
303
+ export function buildMcpProbeInflightRefusal() {
304
+ return {
305
+ errorCode: "limit.rate_exceeded",
306
+ message: `too many MCP probes are still dialing on this replica (${MCP_PROBE_MAX_INFLIGHT} at once) — this endpoint refuses rather than opening one more set of connections, ` +
307
+ "and it deliberately does not drop an in-flight walk to make room (that would remove the one guarantee that keeps a repeated question from re-dialing). " +
308
+ `Retry-After is a HINT, not a promise: each server's verdict is bounded (${MCP_PROBE_VERDICT_TIMEOUT_MS / 1000}s) but a peer that answers the handshake and then stalls holds its dial with no deadline at all`,
309
+ };
310
+ }
311
+ export function buildMcpProbeDeclarationRefusal() {
312
+ return {
313
+ errorCode: "request.field_invalid",
314
+ message: "mcpServers: the engine refused this declaration SET as a whole before dialing anything — the usual cause is two declarations that would mount under one tool-namespace prefix, " +
315
+ "which a task submission refuses the same way, so a probe must not answer as if one of them had been dropped. Give every declared server a name that stays distinct after namespacing",
316
+ };
317
+ }
318
+ //# sourceMappingURL=mcp-probe.js.map
@@ -30,6 +30,7 @@ import { mintAndDriveDurableRun } from "./durable-run-leg.js";
30
30
  import { createSessionsLocal } from "./routes/sessions.js";
31
31
  import { createSessionSyncLocal } from "./routes/session-sync.js";
32
32
  import { createImagesLocal } from "./routes/images.js";
33
+ import { createCapabilitiesLocal } from "./routes/capabilities.js";
33
34
  import { gatedPrincipal, explicitOperatorOk } from "./principal-gate.js";
34
35
  import { buildActiveRunConflict, resumeEntryForGate } from "./active-run-conflict.js";
35
36
  import { commitPlacement, devicePlacementErrorBody } from "./device-placement.js";
@@ -1244,7 +1245,7 @@ export function createResumeLegs(ctx) {
1244
1245
  deps,
1245
1246
  registry: { idemCache, inflightRuns, preemptableRuns, cancelledViaVerb, steerableRuns, wakeParkMints, counters },
1246
1247
  helpers: { readJson, readRawBody, rateLimited, quotaExceeded, ownerQuotaDenied, leaseDenied, ownerLeaseDenied, usageWindowDenied, safeDecode, isFleetWide, runOwnerOk, sessionOwnerScope, sessionOwnerScopeForWrite, runSessionAcceptOk },
1247
- local: { images: createImagesLocal(deps), sessions: createSessionsLocal(), sessionSync: createSessionSyncLocal() },
1248
+ local: { images: createImagesLocal(deps), sessions: createSessionsLocal(), sessionSync: createSessionSyncLocal(), capabilities: createCapabilitiesLocal() },
1248
1249
  legs: { prepareSpec, finalizeTaskResult, resumeCheckpoint, driveResumeIntoRunLog, resumePreempted, resumeWake, resumePlanReview },
1249
1250
  };
1250
1251
  return { routeCtxBase, resumeCheckpoint, driveResumeIntoRunLog, driveResumeLeg, mayDiscloseRowError, resumePreempted, startFreshHostTurn, resumeWake, resumePlanReview };
@@ -21,6 +21,7 @@ import type { FlatServiceDeps, RequestAuth } from "./server.js";
21
21
  import type { TaskRequestBody, DecideBinding } from "./wire-types.js";
22
22
  import type { IdempotencyCache, SubmitOutcome } from "./idempotency.js";
23
23
  import type { ImagesLocal } from "./routes/images.js";
24
+ import type { CapabilitiesLocal } from "./routes/capabilities.js";
24
25
  import type { SessionsLocal } from "./routes/sessions.js";
25
26
  import type { SessionSyncLocal } from "./routes/session-sync.js";
26
27
  import type { PlanApprovePermissionModeAfter } from "../task-settings.js";
@@ -87,6 +88,8 @@ export interface RouteHelpers {
87
88
  * (模块级会把 rate-limiter / 缓存跨实例串味,测试里一个进程建几十个 server)。 */
88
89
  export interface RouteLocals {
89
90
  images: ImagesLocal;
91
+ /** S-481:`GET /v1/capabilities/mcp` / `POST /v1/capabilities/mcp/probe` 的 TTL 缓存与每 principal 限速窗。 */
92
+ capabilities: CapabilitiesLocal;
90
93
  sessions: SessionsLocal;
91
94
  sessionSync: SessionSyncLocal;
92
95
  }
@@ -6,10 +6,19 @@
6
6
  * 本域零可变状态;deps 面宽(能力布尔逐条对应各路由的 501 门,单源不许漂移)但全是存在性只读。
7
7
  */
8
8
  import type { IncomingMessage, ServerResponse } from "node:http";
9
+ import { type McpProbeLocal } from "../../capabilities/mcp-probe.js";
9
10
  import type { RouteCtx, RouteMatch, RouteIdsOf } from "../route-ctx.js";
10
11
  /** 路由体 = 从 `server.ts` 的 `handle()` 里**整段剪切**过来的原文(唯一改动:统一去缩进两格)。裸 `return;`
11
12
  * 的语义原样 =「本域已应答」;走到函数尾才是「没匹配上」,置 `miss.fell` 交回装配器继续下一个域。
12
13
  * 跨域的东西(deps / 助手 / 单域状态 / 每请求量)一律在首行解构出来,于是下面每一行都逐字不变。 */
14
+ /**
15
+ * S-481 —— 本域独占的运行期状态(每 `createHttpServer` 实例一份)。**模块级不行**:测试里一个进程建几十台
16
+ * server,一张模块级缓存会把 A 台的探测读数发给 B 台,一只模块级限速窗会让第二台起来就被第一台的计数挡住。
17
+ */
18
+ export interface CapabilitiesLocal {
19
+ readonly mcpProbe: McpProbeLocal;
20
+ }
21
+ export declare function createCapabilitiesLocal(): CapabilitiesLocal;
13
22
  export declare function handleCapabilities(req: IncomingMessage, res: ServerResponse, match: RouteMatch<CapabilitiesRouteId>, ctx: RouteCtx): Promise<void>;
14
23
  export declare const CAPABILITIES_ROUTES: readonly [{
15
24
  readonly id: "models";
@@ -24,6 +33,14 @@ export declare const CAPABILITIES_ROUTES: readonly [{
24
33
  readonly pattern: RegExp;
25
34
  readonly label: "/v1/capabilities/scenarios/:name";
26
35
  readonly methods: readonly ["GET"];
36
+ }, {
37
+ readonly id: "capability-mcp";
38
+ readonly path: "/v1/capabilities/mcp";
39
+ readonly methods: readonly ["GET"];
40
+ }, {
41
+ readonly id: "capability-mcp-probe";
42
+ readonly path: "/v1/capabilities/mcp/probe";
43
+ readonly methods: readonly ["POST"];
27
44
  }];
28
45
  /** 本域可分派行的 `id` 闭集 —— handler 的 `switch` 按它判穷尽(漏一口 = 编译红)。 */
29
46
  export type CapabilitiesRouteId = RouteIdsOf<typeof CAPABILITIES_ROUTES>;
@@ -6,6 +6,8 @@ import { cwdHonored, deviceCwdHonored } from "../../task-cwd.js";
6
6
  import { DEVICE_PROTOCOL_VERSION, DEVICE_WS_PATH } from "../../device-ws-protocol.js";
7
7
  import { a2aInjectionHonored } from "../../task-a2a.js";
8
8
  import { mcpInjectionHonored } from "../../task-mcp.js";
9
+ import { buildEmptyMcpProbeFace, buildMcpInjectionRefusal, buildMcpProbeCacheKey, buildMcpProbeInflightRefusal, buildMcpProbeRateKey, buildMcpProbeRateRefusal, createMcpProbeLocal, buildMcpProbeDeclarationRefusal, buildMcpProbeIncompleteRefusal, McpProbeIncomplete, MCP_PROBE_HTTP_STATUS, MCP_PROBE_MAX_INFLIGHT, MCP_PROBE_VERDICT_TIMEOUT_MS, createMcpProbeWalk, readMcpProbeBody, } from "../../capabilities/mcp-probe.js";
10
+ import { mcpForScenario } from "../../config-center/facade.js";
9
11
  import { sendJson, sendError } from "../send.js";
10
12
  import { assertNever } from "../route-ctx.js";
11
13
  import { resolveStreamApprovalGate } from "../../tool-approval.js";
@@ -19,7 +21,7 @@ import { workflowModelAllowlistFor, selfOrchestrationDenial } from "../../task-w
19
21
  import { centerEntitlementSourceWired } from "../../runtime-caps-resolver.js";
20
22
  import { judgeAutoModeArming } from "../../auto-mode-face.js";
21
23
  import { coercePermissionModeStrict, SETTINGS_PERMISSION_MODE_WORDS } from "../../task-settings.js";
22
- import { gatedPrincipal } from "../principal-gate.js";
24
+ import { gatedPrincipal, sendPrincipalRefusal } from "../principal-gate.js";
23
25
  function routePairingOf(deps, name, m) {
24
26
  if (isSealedKeyPoison(deps.config.modelApiKeys?.[name]))
25
27
  return "unknown";
@@ -33,6 +35,9 @@ function retentionAdvertisement(config) {
33
35
  return null;
34
36
  return { mode: sweep.mode, maxAgeDays: policy.maxAgeDays };
35
37
  }
38
+ export function createCapabilitiesLocal() {
39
+ return { mcpProbe: createMcpProbeLocal() };
40
+ }
36
41
  export async function handleCapabilities(req, res, match, ctx) {
37
42
  const { deps } = ctx;
38
43
  switch (match.decl.id) {
@@ -138,6 +143,7 @@ export async function handleCapabilities(req, res, match, ctx) {
138
143
  modelUsage: Boolean(deps.runStore && deps.modelUsage),
139
144
  sessionInit: true,
140
145
  mcp: true,
146
+ mcpProbe: true,
141
147
  mcpElicitation: Boolean(deps.elicitation),
142
148
  askUserQuestion: Boolean(deps.question),
143
149
  toolApproval: Boolean(deps.toolApproval),
@@ -188,6 +194,33 @@ export async function handleCapabilities(req, res, match, ctx) {
188
194
  });
189
195
  return;
190
196
  }
197
+ case "capability-mcp": {
198
+ const principal = gatedPrincipal(req, deps.config);
199
+ if (deps.config.requirePrincipal && !principal) {
200
+ sendPrincipalRefusal(res, req, deps.config);
201
+ return;
202
+ }
203
+ await serveMcpProbeFace(res, ctx, principal, mcpForScenario(deps.config.mcpServers, deps.config.defaultScenario) ?? [], "deployment");
204
+ return;
205
+ }
206
+ case "capability-mcp-probe": {
207
+ const principal = gatedPrincipal(req, deps.config);
208
+ if (deps.config.requirePrincipal && !principal) {
209
+ sendPrincipalRefusal(res, req, deps.config);
210
+ return;
211
+ }
212
+ if (!mcpInjectionHonored(deps.config)) {
213
+ sendMcpProbeRefusal(res, ctx, buildMcpInjectionRefusal(), "gate_closed");
214
+ return;
215
+ }
216
+ const parsed = readMcpProbeBody(await ctx.helpers.readJson(req));
217
+ if (!parsed.ok) {
218
+ sendMcpProbeRefusal(res, ctx, parsed.refusal, "bad_request");
219
+ return;
220
+ }
221
+ await serveMcpProbeFace(res, ctx, principal, parsed.specs, "caller");
222
+ return;
223
+ }
191
224
  case "models": {
192
225
  const atCatalog = expandTiers(deps.config.models, deps.config.tiers ?? {}) ?? deps.config.models;
193
226
  const atAllowlist = deps.config.atModelAllowlist ?? [];
@@ -215,9 +248,68 @@ export async function handleCapabilities(req, res, match, ctx) {
215
248
  return assertNever(match.decl.id);
216
249
  }
217
250
  }
251
+ function sendMcpProbeRefusal(res, ctx, refusal, outcome, retryAfterSec) {
252
+ ctx.deps.metrics?.inc("mcp_probe_total", { outcome });
253
+ const status = MCP_PROBE_HTTP_STATUS[refusal.errorCode];
254
+ if (retryAfterSec !== undefined)
255
+ res.setHeader("retry-after", String(retryAfterSec));
256
+ sendError(res, status, refusal.errorCode, refusal.message, {
257
+ ...(refusal.unknownKeys !== undefined ? { unknownKeys: refusal.unknownKeys } : {}),
258
+ ...(retryAfterSec !== undefined ? { retryAfterSec } : {}),
259
+ });
260
+ }
261
+ async function serveMcpProbeFace(res, ctx, principal, specs, declaredBy) {
262
+ const { deps } = ctx;
263
+ const local = ctx.local.capabilities.mcpProbe;
264
+ if (specs.length === 0) {
265
+ deps.metrics?.inc("mcp_probe_total", { outcome: "empty" });
266
+ sendJson(res, 200, buildEmptyMcpProbeFace());
267
+ return;
268
+ }
269
+ const key = buildMcpProbeCacheKey(principal, specs);
270
+ const hit = local.cached(key, Date.now());
271
+ if (hit !== undefined) {
272
+ await answerMcpProbe(res, ctx, hit, "cached", declaredBy);
273
+ return;
274
+ }
275
+ if (local.inflight() >= MCP_PROBE_MAX_INFLIGHT) {
276
+ sendMcpProbeRefusal(res, ctx, buildMcpProbeInflightRefusal(), "rate_limited", Math.ceil(MCP_PROBE_VERDICT_TIMEOUT_MS / 1000));
277
+ return;
278
+ }
279
+ const verdict = await local.check(buildMcpProbeRateKey(principal));
280
+ if (!verdict.allowed) {
281
+ sendMcpProbeRefusal(res, ctx, buildMcpProbeRateRefusal(), "rate_limited", verdict.retryAfterSec);
282
+ return;
283
+ }
284
+ const walk = createMcpProbeWalk(specs, principal);
285
+ local.remember(key, Date.now(), walk);
286
+ await answerMcpProbe(res, ctx, walk.answer, "probed", declaredBy);
287
+ }
288
+ async function answerMcpProbe(res, ctx, answer, outcome, declaredBy) {
289
+ const { deps } = ctx;
290
+ try {
291
+ const body = await answer;
292
+ sendJson(res, 200, body);
293
+ deps.metrics?.inc("mcp_probe_total", { outcome });
294
+ }
295
+ catch (err) {
296
+ deps.metrics?.inc("mcp_probe_total", { outcome: "failed" });
297
+ if (err instanceof McpProbeIncomplete) {
298
+ sendMcpProbeRefusal(res, ctx, buildMcpProbeIncompleteRefusal(err.boundMs), "failed", Math.ceil(err.boundMs / 1000));
299
+ return;
300
+ }
301
+ if (declaredBy === "caller") {
302
+ sendMcpProbeRefusal(res, ctx, buildMcpProbeDeclarationRefusal(), "failed");
303
+ return;
304
+ }
305
+ throw err;
306
+ }
307
+ }
218
308
  export const CAPABILITIES_ROUTES = [
219
309
  { id: "models", path: "/v1/models", methods: ["GET"] },
220
310
  { id: "capabilities", path: "/v1/capabilities", methods: ["GET"] },
221
311
  { id: "capability-scenario", pattern: /^\/v1\/capabilities\/scenarios\/(?<name>.*)$/, label: "/v1/capabilities/scenarios/:name", methods: ["GET"] },
312
+ { id: "capability-mcp", path: "/v1/capabilities/mcp", methods: ["GET"] },
313
+ { id: "capability-mcp-probe", path: "/v1/capabilities/mcp/probe", methods: ["POST"] },
222
314
  ];
223
315
  //# sourceMappingURL=capabilities.js.map
@@ -105,7 +105,7 @@ export declare function buildClearanceRowWire(r: OriginClearanceRow): {
105
105
  requestId: string;
106
106
  reason: string;
107
107
  at: number;
108
- status: "failed" | "pending" | "done";
108
+ status: "pending" | "failed" | "done";
109
109
  originUnknownKeys?: string[] | undefined;
110
110
  clearanceId: string;
111
111
  entryId: string;
@@ -3,7 +3,7 @@ import { sendJson, sendError, httpErrorCode } from "../send.js";
3
3
  import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
4
4
  import { assertNever } from "../route-ctx.js";
5
5
  import { createLogger } from "../../observability/logger.js";
6
- import { RULE_IMPORT_RETRY_AFTER_SEC, MAX_RULE_SCOPE_CHARS, parseRuleScope, sameWireRuleIdentity, serializeRuleScope } from "../../rules-consent.js";
6
+ import { RULE_IMPORT_RETRY_AFTER_SEC, MAX_RULE_SCOPE_CHARS, parseRuleScope, serializeRuleScope } from "../../rules-consent.js";
7
7
  import { CONTROL_AND_BIDI_CHARS } from "../../text-bidi.js";
8
8
  import { HttpError } from "../../security.js";
9
9
  import { MAX_RULE_TEXT_CHARS, RULE_BEHAVIORS } from "@sema-agent/core";
@@ -246,6 +246,40 @@ async function handleRuleList(res, search, caller, lane, operatorPrincipals) {
246
246
  : undefined;
247
247
  sendJson(res, 200, { rules: page, rev: listing.rev, ...(nextCursor !== undefined ? { nextCursor } : {}) });
248
248
  }
249
+ function sendRuleWriteFailure(res, verb, committed) {
250
+ switch (verb) {
251
+ case "write":
252
+ switch (committed) {
253
+ case "no":
254
+ sendError(res, 503, "state.rule_write_failed", "this rule was not written and nothing landed in the store — send the same request again", { committed });
255
+ return;
256
+ case "unknown":
257
+ sendError(res, 503, "state.rule_write_failed", "this write may or may not have landed — reconcile with GET /v1/rules before sending it again", { committed });
258
+ return;
259
+ default: {
260
+ const unknownWord = committed;
261
+ throw new Error(`unknown rule-write committed word: ${JSON.stringify(unknownWord)}`);
262
+ }
263
+ }
264
+ case "revoke":
265
+ switch (committed) {
266
+ case "no":
267
+ sendError(res, 503, "state.rule_remove_failed", "this rule was not revoked and nothing landed in the store — send the same request again", { committed });
268
+ return;
269
+ case "unknown":
270
+ sendError(res, 503, "state.rule_remove_failed", "this revocation may or may not have landed — reconcile with GET /v1/rules before sending it again", { committed });
271
+ return;
272
+ default: {
273
+ const unknownWord = committed;
274
+ throw new Error(`unknown rule-remove committed word: ${JSON.stringify(unknownWord)}`);
275
+ }
276
+ }
277
+ default: {
278
+ const unknownVerb = verb;
279
+ throw new Error(`unknown rule write verb: ${JSON.stringify(unknownVerb)}`);
280
+ }
281
+ }
282
+ }
249
283
  async function handleRuleWrite(res, rawBody, caller, lane, operatorPrincipals) {
250
284
  const parsed = RuleIdentityBodySchema.safeParse(rawBody);
251
285
  if (!parsed.success) {
@@ -282,9 +316,9 @@ async function handleRuleWrite(res, rawBody, caller, lane, operatorPrincipals) {
282
316
  detail: written.detail,
283
317
  });
284
318
  return;
285
- case "indeterminate":
286
- logger.info("rule write indeterminate", { detail: written.detail });
287
- sendError(res, 503, "state.rule_write_failed", "this write could not be confirmed as a standing rule — retry, then reconcile with GET /v1/rules");
319
+ case "failed":
320
+ logger.info("rule write failed", { detail: written.detail, committed: written.committed });
321
+ sendRuleWriteFailure(res, "write", written.committed);
288
322
  return;
289
323
  default: {
290
324
  const unknown = written;
@@ -313,16 +347,12 @@ async function handleRuleRevoke(res, rawBody, caller, lane, operatorPrincipals)
313
347
  case "removed":
314
348
  sendJson(res, 200, { status: "removed", rev: outcome.rev, stillLive: outcome.stillLive });
315
349
  return;
316
- case "no-op": {
317
- const stillLive = (await lane.listRules(target.principal)).rows.some((r) => sameWireRuleIdentity(r, { behavior: parsed.data.behavior, rule: parsed.data.rule, scope: serializeRuleScope(scope) }))
318
- ? "yes"
319
- : "no";
320
- sendJson(res, 200, { status: "no-op", rev: outcome.rev, stillLive });
350
+ case "no-op":
351
+ sendJson(res, 200, { status: "no-op", rev: outcome.rev, stillLive: outcome.stillLive });
321
352
  return;
322
- }
323
353
  case "failed":
324
- logger.info("rule revoke failed", { error: outcome.error });
325
- sendError(res, 503, "state.rule_remove_failed", "the rule could not be revoked right now — retry, then reconcile with GET /v1/rules");
354
+ logger.info("rule revoke failed", { error: outcome.error, committed: outcome.committed });
355
+ sendRuleWriteFailure(res, "revoke", outcome.committed);
326
356
  return;
327
357
  default: {
328
358
  const unknown = outcome;
@@ -2,7 +2,7 @@ import { persistedPlaneOf } from "../../terminal.js";
2
2
  import { memoRedactedRead, memoRedactTerminalResult, redactErrorMessage, redactHead, redactLedgerEventData, redactTerminalResult } from "../../observability/run-terminal.js";
3
3
  import { isModelTextLedgerEventType } from "../../trace/ledger-events.js";
4
4
  import { z } from "zod";
5
- import { uuidv7, mintCheckpointToken, CheckpointError, validatePendingSteer } from "@sema-agent/core";
5
+ import { uuidv7, mintCheckpointToken, CheckpointError, validatePendingSteer, TASK_OUTPUT_REPLAY_KINDS } from "@sema-agent/core";
6
6
  import { isTerminalRunStatus, isParkedRunStatus, runProvenElsewhere } from "../../plugins/store-contracts.js";
7
7
  import { LIVE_RUN_GATE_KINDS, TERMINAL_PARK_GATE_KINDS } from "../../plugins/checkpoint-store-sql.js";
8
8
  import { redactSecrets } from "../../trace/redact.js";
@@ -1058,8 +1058,9 @@ export async function handleRunVerbs(req, res, match, ctx) {
1058
1058
  const g14 = (() => {
1059
1059
  const d = out.details;
1060
1060
  const replay = d?.details?.replay;
1061
- if (replay === "cursor" || replay === "full")
1061
+ if (typeof replay === "string" && TASK_OUTPUT_REPLAY_KINDS.includes(replay)) {
1062
1062
  return { cursorSemantics: replay };
1063
+ }
1063
1064
  return {};
1064
1065
  })();
1065
1066
  sendJson(res, 200, { taskId: runId, target, content: out.content, output: out.details, ...g14 });
@@ -47,7 +47,7 @@ function sendSubmitResult(res, resp) {
47
47
  : raw;
48
48
  sendJson(res, 200, body);
49
49
  }
50
- const MULTI_ATTEMPT_NO_EVENT_CHANNEL_NOTICE = "verify / cascade ran with their normal multi-attempt semantics, but the synchronous /v1/tasks endpoint has no event channel — no status/progress frames exist to replay on this shape. For observable events submit via POST /v1/runs and read GET /v1/runs/:id/events.";
50
+ const MULTI_ATTEMPT_NO_EVENT_CHANNEL_NOTICE = "verify / cascade ran with their normal multi-attempt semantics, but the synchronous /v1/tasks endpoint has no event channel — no status/progress frames exist to replay on this shape. For status / progress / delegated-child frames and the terminal done, submit via POST /v1/runs and read GET /v1/runs/:id/events; note that verify / cascade legs carry no per-turn stream there either (no wiring_manifest / text / turn_end frames — the engine runs those legs as result-returning attempts).";
51
51
  export async function handleTasks(req, res, match, ctx) {
52
52
  const { deps } = ctx;
53
53
  const { idemCache, inflightRuns, cancelledViaVerb, steerableRuns, counters } = ctx.registry;