@securecoms/agent-bridge 0.35.0 → 0.36.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.
@@ -44,10 +44,12 @@ import { VOICE_CALL_START_MARKER } from "./voice/prompt-compiler.js";
44
44
  import { type CallBufferTurn } from "./voice/call-buffer.js";
45
45
  import { type VaultListNode, type VaultReadExpectations } from "./vault.js";
46
46
  import type { AgentActionNodeType, AgentActionNodeStatus, AgentActionNodeDetail } from "@agentvault/connect-core";
47
- import { removeManagedLlmSource, writeManagedFiles, writeManifestPolicy } from "./service/runtime-config-dir.js";
47
+ import { removeManagedLlmSource, writeManagedFiles, writeManifestPolicy, writeMcpPolicy } from "./service/runtime-config-dir.js";
48
48
  import { type ReplyCredit } from "./reply-credit.js";
49
49
  import { bindRuntimeConfig, type FeatureSet } from "./grants/binding.js";
50
50
  import type { RuntimeReplyReceipt } from "./reply-receipts.js";
51
+ import { type McpCatalog } from "./grants/mcp-catalog.js";
52
+ import { type McpCatalogLister } from "./mcp-catalog-message.js";
51
53
  import type { SearchProvider } from "./grants/search-providers.js";
52
54
  export type AgentAudience = "owner_dm" | "team_room" | "partner_room" | "other_human_dm" | "agent_to_agent" | "unknown";
53
55
  export type BridgeToolGrant =
@@ -169,6 +171,13 @@ export interface BridgeAuthorization {
169
171
  * for a harness socket and the HTTP/SSE paths (review m-3).
170
172
  */
171
173
  readonly memory_write?: true;
174
+ /**
175
+ * MCP client Task 16 (OQ-2 ruling, audit M-4): true ONLY on an owner-DM turn whose verified owner check read the DM's
176
+ * leaves as exactly the pinned owner and the agent (relayLeaf === false), and only while at least one `mcp:` grant is
177
+ * in tool_grants (renarrowManifestGrants and withMcpCapability drop it with the last one). The runtime offers an
178
+ * `mcp:` tool only with it. NOT a tool grant.
179
+ */
180
+ readonly relay_free?: true;
172
181
  }
173
182
  /**
174
183
  * Spec §8.2: what ws-server tells applyMemoryNote about the socket a
@@ -357,9 +366,10 @@ export declare const ROOM_VOICE_GUIDANCE_WITH_RESPONDER = "You are in a group vo
357
366
  * `voiceDefaultResponder` (never a body-supplied claim).
358
367
  */
359
368
  export declare function selectVoiceGuidance(isDm: boolean | undefined, voiceDefaultResponder: boolean | undefined): string;
360
- /** Owner grants plan 4 Task 11: TEST SEAM for the refetch's writers and binder (call order, throws). Production omits it. */
361
369
  export interface GrantWriters {
362
370
  writeManifestPolicy?: typeof writeManifestPolicy;
371
+ /** MCP Task 16. */
372
+ writeMcpPolicy?: typeof writeMcpPolicy;
363
373
  writeManagedFiles?: typeof writeManagedFiles;
364
374
  bindRuntimeConfig?: typeof bindRuntimeConfig;
365
375
  removeManagedLlmSource?: typeof removeManagedLlmSource;
@@ -372,6 +382,15 @@ export interface BridgeAgentDeps {
372
382
  * UNIFIED_MEMORY_PART1 decides (switch.arch.test.ts pins both).
373
383
  */
374
384
  unifiedMemoryPart1?: boolean;
385
+ /**
386
+ * MCP client Task 16: TEST SEAM ONLY. The catalog the manifest verifier and mcp-policy.json use. Production (bin.ts)
387
+ * never passes it, so the compiled MCP_CATALOG (Phase 1: {linear}, Task 22) decides what MCP is grantable.
388
+ */
389
+ mcpCatalog?: McpCatalog;
390
+ /** PR-B2 audit M-2: TEST SEAM ONLY. The H-D delivered-turn record's bound (default MCP_DELIVERED_TRACK_MAX). */
391
+ mcpDeliveredTrackMax?: number;
392
+ /** PR-B2 follow-up test seam: the backoff of the mcp-policy.json write retry (the last value repeats). */
393
+ mcpPolicyRetryDelaysMs?: readonly number[];
375
394
  /**
376
395
  * Unified memory: TEST SEAM. Production builds the real worker from dist/memory-worker.js (Task 27 pins that bin.ts
377
396
  * never passes it).
@@ -757,12 +776,9 @@ export declare class SendForbiddenError extends Error {
757
776
  readonly channelId: string;
758
777
  constructor(channelId: string);
759
778
  }
760
- /**
761
- * Audit M-B1 (controller ruling 2026-10-05): a reply to a room-granted turn
762
- * would be sealed at an epoch the room was not classified at, and the room is
763
- * not internal there. Dropped. A SendForbiddenError, so both transports treat
764
- * it as terminal (send_forbidden: the runtime acks, nothing redelivers).
765
- */
779
+ export declare class McpReplyWithheldError extends SendForbiddenError {
780
+ constructor(channelId: string);
781
+ }
766
782
  export declare class RoomClassChangedSendError extends SendForbiddenError {
767
783
  constructor(channelId: string);
768
784
  }
@@ -1206,6 +1222,34 @@ export declare class BridgeAgent {
1206
1222
  private readonly roomDefaultGrants;
1207
1223
  /** Owner grants (spec §7.4): the two manifest switches, read once at construction. */
1208
1224
  private readonly ownerDmManifestGrants;
1225
+ /**
1226
+ * MCP client Task 16 (OQ-16, coordinator ruling D-7): SECURECOMS_MCP_GRANTS, read ONCE at construction. "off" writes
1227
+ * kill_switch (deleting); any value other than unset/"on"/"off" is suspended (non-deleting, one content-free warn).
1228
+ */
1229
+ private readonly mcpHostSwitch;
1230
+ /** MCP Task 16 test seam (BridgeAgentDeps.mcpCatalog); undefined in production, so MCP_CATALOG decides. */
1231
+ private readonly mcpCatalog;
1232
+ /**
1233
+ * MCP Task 16 (OQ-1; PR-B1 audit M-2): the two raw /files fields from the LAST fetch. Any failed or rejected fetch
1234
+ * sets `fetched: false` (no_config, non-deleting): the last values are never kept to keep an `ok` document alive.
1235
+ */
1236
+ private mcpConfig;
1237
+ /**
1238
+ * MCP Task 16: the mcp-policy.json document this process last WROTE (the file the runtime reads), or null before the
1239
+ * first write and after a failed one (fail closed: issuance reads only a document known to be on disk).
1240
+ */
1241
+ private mcpPolicyWritten;
1242
+ private mcpPolicyWrittenJson;
1243
+ /** PR-B2 follow-up: a failed write is retried on this backoff (the last value repeats) until one lands or close(). */
1244
+ private readonly mcpPolicyRetryDelaysMs;
1245
+ private mcpPolicyRetryTimer;
1246
+ private mcpPolicyRetryAttempt;
1247
+ /** MCP Task 18: the loopback catalog listers of the attached runtime sockets that declared mcp_client_v1 (ws-server.ts). */
1248
+ private readonly mcpCatalogListers;
1249
+ /** MCP Task 18 (OQ-15): per server 1 in flight, 3 / 60 s, 20 / hour; per agent 40 / hour. Memory only. */
1250
+ private readonly mcpCatalogRate;
1251
+ /** MCP Task 18: answers in progress (outside the ingest cycle). Observable by tests; close() does not wait on them. */
1252
+ private readonly mcpCatalogWork;
1209
1253
  private readonly roomManifestGrants;
1210
1254
  /**
1211
1255
  * Audit M-B4: DM channels whose roster was epoch-fresh at THIS ingest
@@ -1214,6 +1258,32 @@ export declare class BridgeAgent {
1214
1258
  private readonly ownerDmCycleFresh;
1215
1259
  private toolGrantManifest;
1216
1260
  private toolGrantCounter;
1261
+ /**
1262
+ * Tool status Phase 0 (spec §3.1, §3.2; plan Task 8): the latest owner-DM last-turn record, in memory only (no store
1263
+ * table: after a restart there is none until the next typed owner-DM turn). ADVISORY: nothing reads it to decide a
1264
+ * grant. Content-free: enums, booleans, one 15-minute bucket `at` (ruling M-2), one counter, built-in names from the
1265
+ * allowlist. This is the latest record, open bucket included; what leaves the host is the sender's closed record.
1266
+ */
1267
+ private ownerDmLastTurn;
1268
+ /**
1269
+ * Test seams (Task 8; ruling M-2): the cause function, the bridge clock the record and the sender read, and the
1270
+ * sender's timer and jitter source.
1271
+ */
1272
+ private toolStatusCause;
1273
+ private toolStatusNow;
1274
+ private toolStatusSetTimer;
1275
+ private toolStatusClearTimer;
1276
+ private toolStatusRandom;
1277
+ /**
1278
+ * Phase 0 Task 9 (spec §3.5), ruling M-2: the post of the record to the narrow route, only just after a 15-minute
1279
+ * UTC bucket boundary, and only a record whose bucket has closed.
1280
+ */
1281
+ private readonly toolStatusSender;
1282
+ /**
1283
+ * MCP Phase 1 Task 14 (OQ-3 ruling item 3): the sticky R-3 flag, mirrored from the sealed store. Starts false and is
1284
+ * set true at start() by the store (or by a failed read, fail closed). Only applyToolGrantManifest changes it after.
1285
+ */
1286
+ private mcpSeen;
1217
1287
  private manifestState;
1218
1288
  /** Failed owner-turn checks since the last successful runtime-config report (§7.7). */
1219
1289
  private ownerVerifyFailures;
@@ -1579,6 +1649,26 @@ export declare class BridgeAgent {
1579
1649
  */
1580
1650
  refetchRuntimeConfig(): Promise<void>;
1581
1651
  private refetchRuntimeConfigOnce;
1652
+ /** MCP Task 16 (OQ-1): the bridge's in-force entitlement reading; not fetched (or failed) is suspended. */
1653
+ private mcpEntitlementInForce;
1654
+ /** PR-B1 audit M-2: forget the last /files fields and rewrite mcp-policy.json (no_config: no grants, no deletion). */
1655
+ private noteMcpConfigUnfetched;
1656
+ /** MCP Task 16: the live inputs of mcp-policy.json (the pinned manifest in force only). */
1657
+ private mcpPolicyInput;
1658
+ /**
1659
+ * MCP Task 16 (spec §12.1): recompute mcp-policy.json and write it when it changed. The written document is what MCP
1660
+ * issuance and release-time re-narrowing read, so it is cleared BEFORE the write and set only after it lands: a failed
1661
+ * write leaves no document in force here (no MCP grant), and the next trigger tries again. Never throws.
1662
+ */
1663
+ private recomputeMcpPolicy;
1664
+ /** A document is in force: no pending retry, and the next failure starts the backoff from the first delay. */
1665
+ private clearMcpPolicyRetry;
1666
+ /**
1667
+ * PR-B2 follow-up: one pending retry at a time. The retry recomputes from the state THEN (never re-writes a stale
1668
+ * document); any other trigger that writes successfully meanwhile cancels it. Nothing is in force on the bridge side
1669
+ * until a write lands (mcpPolicyWritten stays null).
1670
+ */
1671
+ private scheduleMcpPolicyRetry;
1582
1672
  /**
1583
1673
  * Plan 4 Task 11: the refetch under a manifest in force. Returns false when the binding failed (H-4),
1584
1674
  * after the memory was applied; the caller then writes nothing else. Throws only from a file write, with
@@ -2043,6 +2133,41 @@ export declare class BridgeAgent {
2043
2133
  * there so a retry can resend the exact bytes).
2044
2134
  */
2045
2135
  private publishBody;
2136
+ /**
2137
+ * MCP Task 16 (round 2 H-D): message ids whose DELIVERED authorization (after release-time re-narrowing) carried an
2138
+ * `mcp:` grant, with their channel and whether the runtime acked them. Recorded at the buffer exit, the one place
2139
+ * every consumer's copy leaves from (over-recording a turn a non-capable consumer then received without the grant only
2140
+ * adds a check). Kept after the ack, so a late frame citing the turn is still checked. Bounded: the oldest ACKED entry
2141
+ * goes first.
2142
+ */
2143
+ private readonly mcpDeliveredTurns;
2144
+ private readonly mcpDeliveredTrackMax;
2145
+ /**
2146
+ * Record `m` when its authorization carries an `mcp:` grant. Returns false ONLY when `m` is a new turn, the record is
2147
+ * at its bound and no entry is acked: the caller (the buffer exit) must then issue no mcp: grant (audit M-2, fail
2148
+ * closed). `rebuild` (PR-B2 audit C-1 (b): a restored runtime-inbox row, or a replayed turn) records unconditionally:
2149
+ * those grants were issued before, so the record may exceed its bound by at most the runtime inbox's size.
2150
+ */
2151
+ private noteMcpDelivered;
2152
+ /**
2153
+ * H-D: whether a frame to `channelId` must pass the relay-free check: it answers a recorded MCP turn (any channel:
2154
+ * a frame of that turn sent elsewhere is checked against ITS channel), or it cites no turn while an MCP turn is
2155
+ * unacked in the same channel.
2156
+ */
2157
+ private mcpReplyGuarded;
2158
+ /**
2159
+ * H-D, synchronous: read the channel's local leaves from the warm manager in one step and tie the read to the
2160
+ * envelope's SEALED group and epoch, so the leaf set is exactly the membership that can open these bytes. Owner and
2161
+ * agent only: pass. Anyone else: withhold. The tree moved past the seal on the first attempt: StaleEpochError, which
2162
+ * sendWithSelfHeal answers with its one converge-and-re-seal (the existing semantics); still unprovable on the retry,
2163
+ * or no warm manager, no pin, or any throw: withhold (fail closed). Read-only; never touches the group.
2164
+ *
2165
+ * Scope (PR-B2 audit item 5): this guards the `send` path (publishBody) only. `status` detail, the `action_*` run
2166
+ * frames, `/v1/send-attachment` and summary frames are NOT checked; they are safe for MCP turns only because the
2167
+ * runtime emits none of them (pinned by connect-agent-runtime/src/outbound-frames.arch.test.ts). A runtime that starts
2168
+ * emitting any of them needs this guard extended to that frame first.
2169
+ */
2170
+ private assertMcpReplyRelayFree;
2046
2171
  /**
2047
2172
  * Audit M-B1 (controller ruling): a reply to a room-granted turn is sealed
2048
2173
  * only at the epoch the room was classified at. On a move, the room is
@@ -3057,6 +3182,13 @@ export declare class BridgeAgent {
3057
3182
  * never changes a grant in step 1. A save that throws keeps the old state.
3058
3183
  */
3059
3184
  private applyToolGrantManifest;
3185
+ /**
3186
+ * MCP Task 14: the R-3 hold Task 17 filters on and Task 15 writes as `r3_hold`. True while the manifest in force signs
3187
+ * an owner_dm `mcp:` string, or while the sticky flag is set (an old client's re-sign cannot lift it, audit C-1).
3188
+ */
3189
+ r3Hold(): boolean;
3190
+ /** Persist then mirror. Setting mirrors even on a failed write; clearing mirrors only after the write (fail closed). */
3191
+ private setMcpSeen;
3060
3192
  private logManifest;
3061
3193
  /** Classify one inbound turn at the agent's CURRENT epoch for its channel. */
3062
3194
  private authorizeTurn;
@@ -3101,6 +3233,20 @@ export declare class BridgeAgent {
3101
3233
  * once, outside server control, is an open design follow-up.
3102
3234
  */
3103
3235
  private authorizationFor;
3236
+ /**
3237
+ * Tool status Phase 0 (spec §3.2, §3.5; plan Task 8; round 1 H-2, M-4; round 2 H-4): build the candidate record and
3238
+ * keep the previous one (and its `at`) when the change tuple is the same, so `at` means "last changed". Never throws
3239
+ * into the authorization path. Never logs a channel id, sender id or tool name.
3240
+ */
3241
+ private noteOwnerDmTurn;
3242
+ /** R2-M1: the served manifest names another owner than the pin; the held record belongs to the old owner. */
3243
+ private dropOwnerDmLastTurn;
3244
+ /**
3245
+ * Phase 0 Task 9 (spec §3.5): POST the record, body = the record only, to the narrow agent-key route. The backend
3246
+ * resolves the identity from the device credential; no identity, channel or sender id is in the URL or the body.
3247
+ * Returns the status class only; never throws.
3248
+ */
3249
+ private postToolStatus;
3104
3250
  /**
3105
3251
  * One content-free INFO line per authorization decision (every call site of
3106
3252
  * authorizationFor stages exactly one runtime turn). It says WHY a turn got
@@ -3175,6 +3321,28 @@ export declare class BridgeAgent {
3175
3321
  * client's own wait already renders "Summary unavailable" — so there is
3176
3322
  * nothing to catch here besides the send itself.
3177
3323
  */
3324
+ /**
3325
+ * MCP client Task 18 (spec §8 item 2; audit M-3, H-3; OQ-2, OQ-15). Accept an `mcp_catalog_request` only when: its
3326
+ * body is exactly the §8 shape; it is a direct frame (never a voice channel's) in a channel the server marks as a DM
3327
+ * that IS the signed owner DM; verifyOwnerTurn says `verified` (the pinned owner's signature, the signed DM, the leaf
3328
+ * check, not a replay) with `relayLeaf === false`; mcp-policy.json as written is `ok` with this server `ok`; a runtime
3329
+ * socket that declared mcp_client_v1 is attached; and the rate limits admit it. The replay row is written HERE
3330
+ * (M-3: the request never reaches stageRuntimeInbox, where it is otherwise written), for every verified request, before
3331
+ * anything is answered; the ownerReplayPending entry is removed in `finally`. A failure to record answers nothing.
3332
+ * Never throws. The answer runs outside the ingest cycle, so the row is acked now.
3333
+ */
3334
+ private acceptMcpCatalogRequest;
3335
+ /** The signed server's vendor and the bridge catalog row's issuer, when mcp-policy.json as WRITTEN is `ok` with it `ok`. */
3336
+ private mcpCatalogTarget;
3337
+ /** Ask the runtime, re-check, build (384 KB cap) and send. Never throws. */
3338
+ private answerMcpCatalogRequest;
3339
+ /**
3340
+ * The `mcp_catalog` reply: a system_event envelope with a body-level type (the sendSummary shape), on the shared
3341
+ * self-heal seam and the one publish chokepoint. H-D applies to EVERY attempt (it carries vendor-supplied tool
3342
+ * descriptions): after the seal, before the POST, the leaves at the SEALED group and epoch must be exactly the owner
3343
+ * and the agent, or the frame is withheld. One message id across the retry.
3344
+ */
3345
+ private sendMcpCatalog;
3178
3346
  private handleSummarize;
3179
3347
  /**
3180
3348
  * A thin adapter over the daemon's one-shot completion seam. Voice-enabled
@@ -3307,6 +3475,23 @@ export declare class BridgeAgent {
3307
3475
  * audience that is still signed, else dropped. Only narrows; an unchanged message is returned by reference.
3308
3476
  */
3309
3477
  private renarrowManifestGrants;
3478
+ /**
3479
+ * MCP Task 16 (audit H-2; round 2 LOW), after the base re-narrowing: a buffered `mcp:` grant survives release only
3480
+ * when, NOW, the turn is still a plain owner-DM turn in the signed DM channel, the host switch reads "on", the
3481
+ * entitlement reading is in force, the name is not in skills.disabled, the mcp-policy.json document last written
3482
+ * lists the server ok with the tool, and a FRESH synchronous read of the DM's local leaves holds exactly the pinned
3483
+ * owner and the agent (MlsGroupKeyProvider.leafSnapshotCached: one `state` object, no await; null fails closed). Any
3484
+ * other mcp: grant is dropped, never kept as a fallback. relay_free is dropped whenever no mcp: grant survives.
3485
+ * Read-only: never touches the MLS group. Only narrows; returns `m` by reference when nothing changes.
3486
+ */
3487
+ private renarrowMcpGrants;
3488
+ /**
3489
+ * MCP Task 16 (OQ-2; audit H-2, round 2 H-D): a FRESH read of the channel's local leaf set holds exactly the pinned
3490
+ * owner and this agent. Synchronous (one manager `state`, so the leaves belong to one epoch); a cold manager or any
3491
+ * throw reads as NOT relay-free.
3492
+ */
3493
+ private ownerDmRelayFreeNow;
3494
+ private renarrowManifestGrantsBase;
3310
3495
  private takeBufferedRaw;
3311
3496
  /**
3312
3497
  * Phase 3 F2: per-channel ordered delivery for the /v1/ws runtime consumer.
@@ -3555,6 +3740,35 @@ export declare class BridgeAgent {
3555
3740
  private lastReportedUtilityTools;
3556
3741
  noteSearchProvidersSocket(token: object, declared: boolean): void;
3557
3742
  clearSearchProvidersSocket(token: object): void;
3743
+ /** MCP Task 16 (spec §15): the same AND-over-sockets state for mcp_client_v1, kept per socket. */
3744
+ private readonly mcpClientSockets;
3745
+ noteMcpClientSocket(token: object, declared: boolean): void;
3746
+ clearMcpClientSocket(token: object): void;
3747
+ /** MCP Task 18: ws-server registers (or, with null, removes) a socket's loopback catalog lister. */
3748
+ noteMcpCatalogLister(token: object, lister: McpCatalogLister | null): void;
3749
+ /**
3750
+ * MCP Task 19 (spec §12.3): "ok" when every attached runtime socket declared mcp_client_v1 (the AND, which harness
3751
+ * sockets hold false: a harness socket counts in the AND and never declares it); else "harness_consumer" when the
3752
+ * latest hello was a harness; else "missing".
3753
+ */
3754
+ private reportedMcpCapability;
3755
+ /**
3756
+ * MCP Task 19 (spec §12.3): at most 8 `{server_id, vendor, state}` for the signed servers of the manifest in force,
3757
+ * from mcp-policy.json as WRITTEN. Only the states the bridge itself knows: top `kill_switch` and `not_entitled` give
3758
+ * that state per server; top `ok` gives `ok` for a server the bridge lists `ok` and `malformed` for one it refused.
3759
+ * Every other top state (no capability, suspended, no config, switch off, section absent, malformed section) gives no
3760
+ * row. The runtime-only states (needs_login, login_expired, definition_changed, server_version_unsupported,
3761
+ * server_unreachable) have no runtime-to-bridge report path in Phase 1. `vendor` is reported only when it is a key of
3762
+ * the bridge's compiled catalog; a signed vendor outside it gives no row. Never a tool name, URL or issuer.
3763
+ */
3764
+ private reportedMcpServers;
3765
+ /**
3766
+ * MCP Task 16: every attached runtime socket declared mcp_client_v1; no socket attached means absent (false).
3767
+ * PR-B2 follow-up (documented): a HARNESS socket counts in this AND. ws-server notes every socket that said hello,
3768
+ * and a harness hello is always noted `false` (harnessClaimed never resets), so one attached harness connector holds
3769
+ * the capability false for files, the report and mcp-policy.json (top `no_capability`) while it is attached.
3770
+ */
3771
+ mcpClientCapable(): boolean;
3558
3772
  noteUtilityToolsSocket(token: object, declared: boolean): void;
3559
3773
  clearUtilityToolsSocket(token: object): void;
3560
3774
  /** U6: every attached runtime socket declared utility_tools_v1; no socket attached means absent (false). */
@@ -28,6 +28,8 @@ export declare const MANIFEST_GRANTS_FEATURE = "manifest_grants_v1";
28
28
  export declare const SEARCH_PROVIDERS_FEATURE = "search_providers_v2";
29
29
  /** Weather/currency spec §8.1 (U6): the /v1/ws hello feature of a runtime that knows weather_forecast and currency_convert. Pinned to the runtime's by capability-parity.test.ts. */
30
30
  export declare const UTILITY_TOOLS_FEATURE = "utility_tools_v1";
31
+ /** MCP client spec §15 (plan Task 16): the /v1/ws hello feature of a runtime that runs the read-only MCP client. Never a TOOL_FEATURE value. Pinned to the runtime's by capability-parity.test.ts. */
32
+ export declare const MCP_CLIENT_FEATURE = "mcp_client_v1";
31
33
  export declare const CONSTANT_TOOL_NAMES: Readonly<Record<"owner_dm" | "team_room", readonly string[]>>;
32
34
  /**
33
35
  * Spec §7.9: a consumer that did not declare manifest_grants_v1 (runtime 0.12.2, a harness,
@@ -64,3 +66,29 @@ export declare function withSearchCapability(m: BridgeMessage, consumerHasFeatur
64
66
  * policy file and TOOL_FEATURE. Returns `m` by reference when nothing drops; never mutates `m`.
65
67
  */
66
68
  export declare function withUtilityCapability(m: BridgeMessage, consumerHasFeature: boolean): BridgeMessage;
69
+ /** The exact prefix of an owner-signed MCP grant name (`mcp:<server_id>/<tool>`). */
70
+ export declare const MCP_GRANT_PREFIX = "mcp:";
71
+ /**
72
+ * Spec §15 per-delivery MCP filter, on every delivery path after withUtilityCapability, judged by the consumer's OWN
73
+ * hello. An `mcp:` grant survives only for a consumer that declared mcp_client_v1, on a turn whose audience is
74
+ * `owner_dm` and that carries no relay_speaker mark (any value: the mark disqualifies, T-10). Otherwise every `mcp:`
75
+ * grant is dropped, marked or unmarked (a harness, an older runtime and every HTTP path never receive one, T-14).
76
+ * Every other grant and field is unchanged. Only narrows; returns `m` by reference when nothing drops; never mutates.
77
+ */
78
+ export declare function withMcpCapability(m: BridgeMessage, consumerHasFeature: boolean): BridgeMessage;
79
+ /**
80
+ * R-3 "holds" keep set, the bridge copy (spec §10, audit M-1): an ALLOW-list, so a future built-in is withdrawn by
81
+ * default while the hold is on. Pinned equal to the runtime's (Task 12b) by consumer-grants.mcp.test.ts, and over
82
+ * MANIFEST_KNOWN_TOOLS.owner_dm it withdraws exactly fetch_url, web_extract, http_request and sql_query.
83
+ */
84
+ export declare const R3_KEEP_TOOLS: ReadonlySet<string>;
85
+ /**
86
+ * Spec §10 R-3 "holds" filter, on every delivery path to every consumer. `hold` is the agent's R-3 hold
87
+ * (BridgeAgent.r3Hold(): the in-force manifest's signedMcpPresent, computed on the raw verified owner_dm array, or the
88
+ * sticky mcp_seen flag). It is message-independent on purpose: it never reads this message's grants (which
89
+ * withMcpCapability, entitlement, kill switches or skills.disabled may already have emptied), so no narrowing step can
90
+ * lift it (audit C-2, OQ-13). While it holds, every owner-DM turn (audience owner_dm, or the owner-relayed voice turn
91
+ * marked relay_speaker "owner") keeps only R3_KEEP_TOOLS and `mcp:` grants. Other audiences are unchanged. Only
92
+ * narrows; returns `m` by reference when nothing drops; never mutates.
93
+ */
94
+ export declare function withR3Holds(m: BridgeMessage, hold: boolean): BridgeMessage;
@@ -69,7 +69,8 @@ export declare function legacyAssignToken(d: {
69
69
  * unknown name in manifest-policy.json as an unreadable file and drops EVERY manifest grant, so a tool listed here is
70
70
  * written to the policy file (and delivered) only when every attached runtime socket declared its feature.
71
71
  */
72
- export type Feature = "search_providers_v2" | "utility_tools_v1";
72
+ /** MCP Task 16: "mcp_client_v1" is a Feature (capabilityFeatures) but never a TOOL_FEATURE value (pinned by capability-parity.test.ts). */
73
+ export type Feature = "search_providers_v2" | "utility_tools_v1" | "mcp_client_v1";
73
74
  export type FeatureSet = ReadonlySet<Feature>;
74
75
  /** No socket attached, or none declared anything: fail closed. */
75
76
  export declare const NO_FEATURES: FeatureSet;
@@ -39,9 +39,17 @@ export interface EffectiveInput {
39
39
  };
40
40
  readonly roomSearchCapped: boolean;
41
41
  }
42
+ /** Phase 0 Task 6 (spec §3.2, round 2 M-12): why a signed tool was dropped owner-side, pushed at the line that drops it. */
43
+ export type WithheldWhy = "disabled_by_owner" | "no_endpoint" | "no_database";
44
+ export interface WithheldTool {
45
+ readonly tool: string;
46
+ readonly why: WithheldWhy;
47
+ }
42
48
  export interface EffectiveGrant {
43
49
  readonly grants: readonly ReadGrant[];
44
50
  readonly source: GrantSource;
45
51
  readonly withheldBySearchCap: boolean;
52
+ /** Signed tools the manifest branch dropped, in ceiling order, frozen. [] on every other branch. The team-room search cap is not an owner-side reason (it keeps withheldBySearchCap). */
53
+ readonly withheld: readonly WithheldTool[];
46
54
  }
47
55
  export declare function effectiveToolGrants(i: EffectiveInput): EffectiveGrant;
@@ -8,6 +8,8 @@
8
8
  */
9
9
  import { type SearchProvider } from "./search-providers.js";
10
10
  import { type OwnerGrantPin } from "./pin.js";
11
+ import { type SignedMcp } from "./mcp-section.js";
12
+ import { type McpCatalog } from "./mcp-catalog.js";
11
13
  export declare const TOOL_GRANTS_DOMAIN = "SECURECOMS-TOOL-GRANTS-V1:";
12
14
  export declare const MANIFEST_STATES: readonly ["ok", "unchanged", "no_pin", "no_manifest", "malformed", "unsupported_algo", "bad_signature", "wrong_agent", "wrong_owner", "rollback", "unknown_entry", "kept_last_good"];
13
15
  export type ManifestState = (typeof MANIFEST_STATES)[number];
@@ -33,6 +35,17 @@ export interface ToolGrantManifest {
33
35
  readonly databases: Readonly<Record<string, SignedDatabase>>;
34
36
  /** Plan 4 Task 7 (spec §8.1, §7.10): the signed values the config binding compares. Required. */
35
37
  readonly config: SignedConfig;
38
+ /**
39
+ * MCP Phase 1 Task 14 (spec §12.2, §13a). `mcp` is the parsed signed section, or null when the key is absent or the
40
+ * section is malformed (`mcpKey` tells them apart, OQ-3 ruling). A malformed section never makes the manifest malformed.
41
+ */
42
+ readonly mcp: SignedMcp | null;
43
+ readonly mcpKey: "absent" | "present";
44
+ /**
45
+ * R-3 raw predicate (audit C-2): some string in the signature-verified `grants.owner_dm` starts with `mcp:`, read after
46
+ * step 5 and BEFORE step 6, parseMcp, the ceiling and the catalog, so none of them can lift R-3.
47
+ */
48
+ readonly signedMcpPresent: boolean;
36
49
  }
37
50
  export interface SignedEndpoint {
38
51
  readonly baseUrl: string;
@@ -75,4 +88,5 @@ export declare function verifyToolGrantManifest(raw: unknown, ctx: {
75
88
  pin: OwnerGrantPin;
76
89
  agentIdentityId: string;
77
90
  storedCounter: number | null;
91
+ mcpCatalog?: McpCatalog;
78
92
  }): ManifestVerdict;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The bridge's copy of the compiled MCP catalog (spec §11, §12.2 "the vendor and tool are in the bridge's catalog copy").
3
+ * Phase 1 holds exactly one row, Linear (Task 22; Eric 2026-10-30, "ship Linear only"), identical to the runtime's
4
+ * (`connect-agent-runtime/src/mcp/catalog.ts`) value for value. No vendor value is ever written from memory; each cites
5
+ * its source below. `mcp-catalog.parity.test.ts` and `mcp-lists.parity.test.ts` pin this copy to the runtime's byte for
6
+ * byte, so the two cannot drift. How a row is added, changed or withdrawn: docs/runbooks/mcp-catalog-update.md.
7
+ */
8
+ /** The runtime's `CatalogRow`, field for field (OQ-6 ruling: the #1308 §11 fields exactly). */
9
+ export interface McpCatalogRow {
10
+ displayName?: string;
11
+ host: string;
12
+ readOnlyUrl: string;
13
+ readTools: readonly string[];
14
+ scopes: readonly string[];
15
+ redirectPort?: 47823;
16
+ issuer: string;
17
+ authorizationEndpoint: string;
18
+ tokenEndpoint: string;
19
+ revocationEndpoint?: string;
20
+ docs: Readonly<Record<string, string>>;
21
+ }
22
+ export type McpCatalog = Readonly<Record<string, McpCatalogRow>>;
23
+ export declare const MCP_CATALOG: McpCatalog;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * MCP client Phase 1, Task 16 (spec §12.2 "issued"; OQ-1, OQ-2 rulings; audit H-3, M-4): which `mcp:` grants an
3
+ * owner-DM turn is issued. Pure; the bridge calls it right after effectiveToolGrants, on the owner-DM branch only.
4
+ *
5
+ * issued = { mcp:s/t in manifest.grants.owner_dm (step 6 already applied the code ceiling and the catalog) }
6
+ * only when ALL of:
7
+ * effectiveToolGrants returned source "manifest" (so the turn was manifest-eligible: never a fallback);
8
+ * the owner verdict is `verified` with relayLeaf === false (OQ-2: exactly owner and agent leaves);
9
+ * the bridge's in-force entitlement reading is "entitled" (OQ-1);
10
+ * SECURECOMS_MCP_GRANTS reads "on" (D-7: off and suspended issue nothing);
11
+ * SECURECOMS_OWNER_DM_MANIFEST_GRANTS is on;
12
+ * the mcp-policy.json document the bridge last WROTE is top `ok` and lists the server `ok` with the tool;
13
+ * minus every name in skills.disabled.
14
+ *
15
+ * Fail closed throughout: an absent relayLeaf, an absent policy, a missing server or tool all issue nothing.
16
+ */
17
+ import type { GrantSource, ReadGrant } from "./effective.js";
18
+ import type { ToolGrantManifest } from "./manifest.js";
19
+ import type { McpEntitlementReading, McpHostSwitch } from "./mcp-policy.js";
20
+ import type { OwnerTurnVerdict } from "./owner-turn.js";
21
+ import type { McpPolicyDoc } from "../service/runtime-config-dir.js";
22
+ export interface McpIssuanceInput {
23
+ readonly source: GrantSource;
24
+ readonly verdict: OwnerTurnVerdict | undefined;
25
+ readonly entitlement: McpEntitlementReading;
26
+ readonly hostSwitch: McpHostSwitch;
27
+ readonly ownerDmManifestSwitchOn: boolean;
28
+ readonly disabled: ReadonlySet<string>;
29
+ readonly manifest: ToolGrantManifest | null;
30
+ readonly policy: McpPolicyDoc | null;
31
+ }
32
+ /** True when the written policy lists `serverId` as `ok` with `tool` among its tools. */
33
+ export declare function mcpPolicyAllows(policy: McpPolicyDoc | null, serverId: string, tool: string): boolean;
34
+ export declare function mcpIssuance(i: McpIssuanceInput): readonly ReadGrant[];
@@ -0,0 +1,75 @@
1
+ /**
2
+ * MCP client Phase 1, Task 15 (spec §6.5, §12.1; OQ-1 and OQ-3 rulings; round 2 H-B): the `mcp-policy.json` document.
3
+ *
4
+ * Pure, except mcpGrantsHostSwitch's one start-time warn. The bridge writes the result with writeMcpPolicy BEFORE it
5
+ * issues any MCP grant (Task 16 wires it). The runtime deletes a token only on `ok` for a server named in `remove`
6
+ * (and not listed), `not_entitled` or `kill_switch` (deletion.ts; a host typo is therefore `suspended`, never
7
+ * `kill_switch`). The rules that matter most here:
8
+ * - C-NEW-1 (2026-10-30): `remove` is copied ONLY from the signed `mcp.removed` of the manifest in force, and only into
9
+ * the `ok` document; every other document writes `remove: []`. Absence from `servers` never deletes, so a stale
10
+ * view re-signed by the owner cannot launder a deletion. No record of an earlier section is kept;
11
+ * - a server the bridge refuses for its own reasons stays LISTED as `refused`;
12
+ * - a verified manifest with NO `mcp` key is `section_absent`, never top `ok` with empty servers (OQ-3). An explicit
13
+ * signed `"mcp": {"servers": {}}` is top `ok` with empty servers and deletes nothing; `{"servers": {}, "removed": "*"}`
14
+ * is remove-all.
15
+ *
16
+ * `r3_hold` (= signedMcpPresent || mcp_seen) is in EVERY document, empty-servers ones included (round 2 H-B).
17
+ *
18
+ * Precedence, first match wins (the spec fixes no order; this one puts the host's own switch first, then what the bridge
19
+ * cannot know, then the backend's entitlement, then local switches, then the manifest, then the consumer):
20
+ * kill_switch | host suspended (D-7), no_config, not_entitled | suspended, switch_off, no_manifest, section_absent,
21
+ * malformed, no_capability, ok
22
+ */
23
+ import type { ToolGrantManifest } from "./manifest.js";
24
+ import { type McpCatalog } from "./mcp-catalog.js";
25
+ import type { McpPolicyDoc } from "../service/runtime-config-dir.js";
26
+ import { type ServiceLog } from "../service-log.js";
27
+ /**
28
+ * Architect skew ruling (2026-10-30): this bridge declares, in EVERY hello reply (ws-server.ts), that it is the sole
29
+ * writer of mcp-policy.json: it refuses backend delivery of that name (runtime-config-dir.ts isLocalOnlyName). The
30
+ * runtime deletes tokens and offers MCP tools from the file only on a turn from a declaring socket (connect-agent-runtime
31
+ * data-plane.ts MCP_POLICY_WRITER_FEATURE). Unconditional: a statement about this build, not about the runtime or the
32
+ * switches (the host kill switch travels IN the file and must keep deleting). Never declare it from a build where the
33
+ * backend can write that name (ws-server.test.ts same-build row).
34
+ */
35
+ export declare const MCP_POLICY_WRITER_FEATURE = "mcp_policy_writer_v1";
36
+ export type McpEntitlementReading = "entitled" | "not_entitled" | "suspended";
37
+ /**
38
+ * OQ-1 ruling (ADR G). Only `true`/"entitled" is in force and only `false`/"not_entitled" is the deleting downgrade.
39
+ * Anything else (missing, unknown, disagreeing, non-boolean) is `suspended`: no grants, no deletion.
40
+ */
41
+ export declare function mcpEntitlementReading(mcpEntitled: unknown, mcpCause: unknown): McpEntitlementReading;
42
+ /**
43
+ * Host kill switch SECURECOMS_MCP_GRANTS (OQ-16; coordinator ruling D-7). Tri-state, so a typo can never delete:
44
+ * unset or exactly "on" -> "on"; exactly "off" -> "off" (writes `kill_switch`, a deleting state);
45
+ * any other value -> "suspended" (writes top `suspended`: no grants, no deletion).
46
+ */
47
+ export type McpHostSwitch = "on" | "off" | "suspended";
48
+ /**
49
+ * Read once at bridge start by the caller. A suspended value logs ONE content-free warn per call carrying the enum,
50
+ * never the raw value (it may be anything an operator typed).
51
+ */
52
+ export declare function mcpGrantsHostSwitch(env: Readonly<Record<string, string | undefined>>, log?: ServiceLog): McpHostSwitch;
53
+ export interface McpPolicyInput {
54
+ /** mcpGrantsHostSwitch(), read once at bridge start. "off" writes `kill_switch`; "suspended" writes `suspended`. */
55
+ readonly hostSwitch: McpHostSwitch;
56
+ /** The last /runtime-config/files result: not yet fetched (or failed), or the two raw fields from a successful fetch. */
57
+ readonly config: {
58
+ readonly fetched: false;
59
+ } | {
60
+ readonly fetched: true;
61
+ readonly mcp_entitled: unknown;
62
+ readonly mcp_cause: unknown;
63
+ };
64
+ /** SECURECOMS_OWNER_DM_MANIFEST_GRANTS on. */
65
+ readonly ownerDmManifestSwitchOn: boolean;
66
+ /** The verified manifest in force, or null. */
67
+ readonly manifest: ToolGrantManifest | null;
68
+ /** `mcp_client_v1` on every attached runtime socket of the identity (absent with none). */
69
+ readonly capability: boolean;
70
+ /** The sticky flag from the sealed store (Task 14). */
71
+ readonly mcpSeen: boolean;
72
+ /** Test seam; the compiled bridge catalog by default. */
73
+ readonly catalog?: McpCatalog;
74
+ }
75
+ export declare function mcpPolicyFor(i: McpPolicyInput): McpPolicyDoc;