feat(mcp): loopKey support for MCP-wrapped tools - #74
Conversation
Opt-in via doomLoop on callModel. Deterministic detection of runs that stop making progress: consecutive identical tool calls (per-tool fingerprint streaks over tool-declared loopKey identity, incl. repeated empty and invalid-JSON calls) and repeated text tokens (within-response block repetition + cross-step identical-text streaks). Graduated response ladder observe -> steer -> block -> stop, per-event override via the new DoomLoopDetected hook, streaks persisted in ConversationState.doomLoop across serialize/resume, and SessionEnd.reason 'doom_loop' + ModelResult.getDoomLoopVerdict() on stop.
Fingerprints: RFC 8785 (JCS) canonicalization + SHA-256/UTF-8 via WebCrypto replaces cyrb53/UTF-16 (cross-port contract now real; vectors in tests/vectors/doom-loop-fingerprints.json; bigint/NaN/circular/deep key material rejected with engine fallback to full-args identity). Streaks are round-scoped: N identical parallel calls in one round count once and share the round's decision (evaluations serialized in model-emission order under the async hash). loopKey is now function | field-list | false on the tool definition (declarative forms are data — serializable, MCP-transportable); undefined returns fall back with a warning instead of colliding. markMcp accepts a loopKey override. Stop verdicts seal state (synthesized halt outputs for unresolved calls — no dangling function_call 400s on resume), gate the allow-final-response and approval-resume request paths, persist across decision-only resumes, and clear on fresh conversational turns. Text-only no-tools stops report SessionEnd reason doom_loop. Steer guidance queued before a pause persists and delivers on resume. Server tools fingerprint at the step checkpoint (observe/steer/stop). Ladder configs warn on dead rungs and block-without-stop. Documented, test-locked misses: nonce-varying args without loopKey, paraphrased text.
Client-side: a loopKeys map on createMCPTools/rehydrateMCPTools (keyed by unprefixed MCP name; function | field list | false), threaded through buildTools into wrapMcpTool and attached via markMcp's injection point on both regular and generator wraps. Server-side: _meta['openrouter/loopKey'] on a tool definition (field list or false; data-only — functions can't cross the wire) is captured by listToolDefs, carried on McpToolDef, and round-trips through cache snapshots (SerializedMCPToolDef.loopKey) so rehydrated tool sets keep their identities. Client config wins over the server declaration.
The committed baseline (coupling 0.43, 133 import edges) predates the mcp package landing — origin/main itself measures 0.47 with 306 edges against it, passing only within tolerance. The doom-loop feature's real structural delta over current main is +0.012 coupling (0.47 -> 0.49, the new lib/doom-loop module's fan-in from model-result, tool-types, tool, async-params, index, and the mcp wrapper), which is within the gate's tolerance. Baseline regenerated from origin/main (61a2a9a) via sentrux v0.5.7 gate --save, matching the CI binary version. Cycle count (1, in the mcp package) and complex-function count (9) are pre-existing and unchanged by this branch.
Fix 3 (SDK): new 'signal' option on callModel — aborting stops the
tool loop at the next turn boundary AND aborts the in-flight request/
stream, rejecting with the abort reason; pre-aborted signals fail
before any dispatch. RequestOptions.timeoutMs now reliably bounds EACH
request even when a signal is present: the SDK skips its timeoutMs
wiring whenever a request carries a signal, so the engine composes
{run signal, caller signal, fresh per-dispatch AbortSignal.timeout}
via AbortSignal.any at every send site (initial, tool rounds, final,
retry, resume). 7 new unit tests (fake-timer driven) pin the composed
semantics.
Fixes 1+2 (DEV-658 e2e deflake): measured healthy path was 70-80s
across three GLM requests — the final turn alone burned 50-65s on a
750-1150-token reasoning burst — leaving no margin under the old 120s
budget (one CI window timed out attempt AND retry back-to-back).
Now: 300s budget, bounded generation (reasoning.maxTokens 512 +
maxOutputTokens 1024), a shallow unanswerable-without-search prompt
that preserves the leak pressure without inviting multi-hop parametric
reasoning, and a 90s per-request timeoutMs so a stalled provider fails
fast and the vitest retry gets a fresh draw. Verified 3/3 live passes
at 10-20s each.
The per-option conditional-spread chain pushed callModel to cc=16 (sentrux max 15) once the signal option landed. Build the options object once and strip undefined keys — identical absent-key semantics, one loop instead of thirteen branches.
The unit suite pins the signal/timeout composition against mocks; these five e2e tests prove what mocks cannot — the composed abort signal reaches the real fetch/stream. Covered: pre-aborted signal fails with zero dispatches; aborting mid-generation kills a live request fast (bounded wall-clock assertion, not exact timing); per-request timeoutMs bounds a live request even with a run signal present (the SDK-disabling configuration DEV-658 relies on); abort during tool execution stops before the follow-up dispatch; and a composed-signal run completes normally when neither bound fires. Verified 3/3 locally (~8s a run).
Unit: drop fake timers around AbortSignal.timeout — vitest fake timers cannot fake its Node-internal timer, so advancing the mocked clock proved nothing (a WRONG shared per-run timer would never fire in near-zero real time either, passing the per-dispatch test vacuously). Real tiny budgets (50-120ms, documented exception) make the two timeout tests actually discriminate; suite cost ~215ms. E2E: assert rejection IDENTITY, not just 'anything threw' — the mid-flight abort test walks the cause chain to OUR abort reason (the SDK wraps it in UnexpectedClientError), and the timeoutMs test matches the TimeoutError DOMException, so an unrelated transport/auth failure can no longer pass either test. Verified live: rejection shapes probed against the real API; 5/5 passing.
…tore
Review finding (cortex): DoomLoopMonitor's streak store was a plain
object; restoring a persisted blob {"tools":{"__proto__":{...}}} from
client-writable state storage reassigned the store's prototype, so
every unseen tool inherited the seeded streak (false stop verdicts /
detection bypass). The store is now a Map — hostile keys are inert
data, and a tool legitimately named __proto__ works. getState() keeps
emitting a plain-JSON record via Object.fromEntries (own-property
defines, no setter hits). Regression tests for both cases.
Also corrects the WebCrypto doc claim (Devin): globalThis.crypto is
unflagged in Node >=19, not >=18 (18 needed
--experimental-global-webcrypto and is EOL); supported floor is the
active LTS (CI runs Node 22).
…a turn New 'escalate' ladder rung between steer and block: instead of refusing or halting a stuck run, throw more intelligence at the NEXT turn and revert. Two mechanisms via doomLoop.escalation, combinable: - model: one-turn model swap (the base resolvedRequest is never mutated, so the following dispatch reverts automatically; a single- model override clears any fallback models list) - advisor: append the openrouter:advisor server tool with forwardTranscript and loop-diagnosing instructions, and pin toolChoice to it (allowed_tools/required) so the stuck model must consult before acting; object form passes through as advisor parameters A steer notice naming the detected loop accompanies the escalated turn. Budgeted: maxEscalations (default 2) per conversation, consumed at APPLICATION time (verdicts the engine never applies do not spend), escalationsUsed persisted in ConversationState.doomLoop so resumes cannot reset it, first-verdict latch so concurrent detectors in one window escalate once. Exhausted/unconfigured escalations fall through to weaker rungs; resolve-time warnings flag rung/mechanism mismatches. DoomLoopDetected action/overrideAction enums gain 'escalate' (override without config/budget downgrades to observe). 15 new tests.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Summary
Update resolves the coverage gaps flagged in the prior pass: server-advertised loopKey parsing (advertisedLoopKey/listToolDefs) is now directly unit-tested against invalid/missing _meta shapes, and cache-types.ts gained loopKey shape validation in isSerializedToolDef so a corrupted/malformed cached loopKey now fails isSerializedMCPServer instead of being silently trusted. Core wiring (client-config precedence over server-advertised, markMcp injection, cache round-trip) is unchanged from the previous revision and remains correct.
Findings (1)
⚪ nit · packages/mcp/src/cache/cache-types.ts:64
isJsonSchemaObject(value) is evaluated twice (once for the loopKey extraction, once in the final return's boolean chain) — harmless but slightly redundant.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Summary
This update replaces the options.loopKeys?.[def.name] ?? def.loopKey precedence lookup in tool-wrapper.ts with an Object.hasOwn()-guarded lookup, fixing a real (if narrow) correctness bug: bracket access on a plain object follows the prototype chain, so an MCP tool literally named toString/constructor/valueOf/etc. would previously pick up the inherited Object.prototype method as its loopKey even when the caller's loopKeys map was empty or didn't mention that tool — silently discarding any server-advertised declaration. The fix is now covered by a dedicated regression test. No other logic changed; prior findings stand as noted below.
Findings (1)
⚪ nit · packages/mcp/src/cache/cache-types.ts:64
isJsonSchemaObject(value) is still evaluated twice in isSerializedToolDef (once for the loopKey extraction, once in the final conjunction) — unchanged from the prior revision, still just cosmetic.
`createMCPTools` forwards an explicit allowlist of options into `rehydrateMCPTools` (FORWARDED_REHYDRATE_KEYS). `loopKeys` was missing from it, so with caching enabled a client-configured loop identity was silently discarded on every cache hit — doom-loop detection went dead on warm handles only, with no error and nothing failing. `loopKeys` is declared on both CreateMCPToolsOptions and RehydrateMCPToolsOptions, and rehydrate.ts already consumes it, so the omission was the whole bug. Adds a regression test asserting the loopKey lands on the wrapped tool after a cache hit (verified to fail without the one-line fix), and a comment on the allowlist noting that anything omitted is silently dropped — the failure mode has no signal, so the next option deserves the warning. Reported by devin on #74.
There was a problem hiding this comment.
Summary
This update closes a real latent gap: loopKeys was missing from FORWARDED_REHYDRATE_KEYS, so with caching enabled the warm (cache-hit) path silently dropped client-configured doom-loop identities while cold connects honored them. The fix adds the key to the type-checked allowlist (create-mcp-tools.ts:47), documents the drop-on-omission hazard, and adds an end-to-end regression test that drives createMCPTools through the cache hit and asserts loopKey lands on the rehydrated tool. No new issues; only a cosmetic nit remains.
Findings (1)
⚪ nit · packages/mcp/src/cache/cache-types.ts:63-72
Unchanged from prior revisions: isJsonSchemaObject(value) is evaluated twice in isSerializedToolDef (once to extract loopKey, once in the return conjunction). Cosmetic only.
extractServerToolIdentity built `identity` as a plain `{}`. Server-tool
output items are JSON-parsed API responses, so a `__proto__` key arrives
as a real own property — assigning it hit the prototype setter instead of
creating an own property, and canonicalizeKeyMaterial's Object.keys walk
then skipped it.
Perry flagged this as a consistency nit with minimal impact. It is
actually a false-positive source: two server-tool calls differing ONLY in
a `__proto__`-named field fingerprint identically, so they collide into a
streak and the detector reports a doom loop that is not one.
Adds a regression test with three same-query web_search items carrying
distinct `__proto__` values, asserting no detections. With the plain `{}`
it fails with two spurious detections.
Consistent with the field-list subset in resolveLoopKeyMaterial and the
Map-backed streak store.
Drops the duplicate isJsonSchemaObject call and, more usefully, removes a trap: the old form extracted loopKey via a ternary, so for a non-object value loopKey silently became `undefined` and hasValidLoopKey read as true. It was harmless because the conjunction re-checked the object-ness, but the local said "valid" about a value that was never inspected. Also records why each accepted loopKey shape is accepted. Suggested by Perry on #74.
There was a problem hiding this comment.
Summary
Only change since the last head is the isSerializedToolDef refactor in packages/mcp/src/cache/cache-types.ts:62-76: it early-returns on a non-object value and reads loopKey once, which resolves the standing cosmetic nit about the duplicated isJsonSchemaObject(value) call with identical accept/reject semantics. No functional changes elsewhere and no new issues; all prior findings are now closed.
✅ No findings.
`advisor` is `boolean | Record<string, unknown>`, so `advisor: false` is
the documented way to switch it off. Config resolution counted any
non-undefined advisor as a mechanism, while the recovery step skips a
disabled one (`config.advisor !== undefined && config.advisor !== false`).
So `{ escalation: { advisor: false } }` resolved a working escalate rung
that, on trigger, called consumeEscalation() and applied no override at
all — burning one of the run's limited escalations and announcing recovery
while doing nothing. Note consumeEscalation() runs before the advisor
check, so the budget was spent regardless.
Now `{ advisor: false }` alone reads the same as `{}` (no rung), and a
disabled advisor is dropped from the resolved config rather than carried
through inert.
Adds three tests: advisor:false alone resolves no escalation; alongside a
real `model` it does not appear in the resolved config; advisor:true still
resolves. The first two fail without the fix.
Reported by devin on #73.
Re-review at
|
`.agents/skills/public-api-examples/SKILL.md` requires a fenced example in
the changeset for any public-API change. `loopKeys` on createMCPTools /
rehydrateMCPTools and `SerializedMCPToolDef.loopKey` are public API, and
the changeset was prose-only.
Shows the client-side `loopKeys` map (field-list and `false` forms, keyed
by unprefixed MCP name) feeding a `doomLoop` run, plus the server-advertised
`_meta['openrouter/loopKey']` alternative and the precedence rule.
Shapes verified against source: loopKeys at types.ts:97, cache
{store, key?} at :73, handle.tools at :112.
|
Base switched to Previous passes (heads The MCP portion is byte-identical to the head I approved and I have no new findings there. What changed is what an approval means: it would now bless the Two possibilities:
Advisory notes on the newly-in-scope code, from what is visible:
|
There was a problem hiding this comment.
Summary
The MCP loopKey code is unchanged from the head I approved (6e5148c) and remains clean — all prior findings stay resolved. What changed is the base: retargeting from feat/doom-loop-detector to main pulled the whole stacked doom-loop/escalation/cancellation surface (~5,000 new lines across packages/agent, plus three @openrouter/agent changesets) into this PR's own diff, which is also truncated at 200 KB, so I can neither review it here nor approve it as if I had.
Findings (4)
🟠 major · packages/agent/src/lib/doom-loop.ts:1-1088
Base switch to main expanded this PR from 9 MCP files to 34, adding ~5,000 lines of agent-engine code (doom-loop detector, escalate ladder, run cancellation) plus three @openrouter/agent changesets, none of which the PR title/body describes and none of which was in scope for the four prior review passes. Either re-target at feat/doom-loop-detector (diff collapses to the already-approved MCP subset) or update the description and point to where this engine code was reviewed.
🟡 minor · packages/agent/src/lib/model-result.ts:2985
AbortSignal.any (Node >= 20.3) and globalThis.crypto.subtle in doom-loop.ts:520 (Node >= 19) raise the effective runtime floor, but packages/agent/package.json declares no engines field — Node 18 consumers hit a runtime TypeError only once they opt into signal/doomLoop.
🟡 minor · packages/agent/src/lib/model-result.ts:2981
this.options.client._options.timeoutMs reads an underscore-prefixed SDK internal; an upstream rename would silently drop the client-level timeout on the composed-signal path with no type error or failing test.
⚪ nit · packages/agent/src/inner-loop/call-model.ts:140-169
The option assembly was rewritten to a Record<string, unknown> plus a delete-undefined loop and a double cast (as unknown as GetResponseOptions), trading the previous compile-time checking of each forwarded key for runtime pruning — a future renamed option would no longer fail typecheck.
|
CI note on The failing criterion is complex functions 9 → 10 — not coupling (this PR is a different case from #86, which fails on coupling against a much older What the evidence says:
So the +1 appears only when #73's code is combined with this branch's, via the I am not touching the gate config or thresholds, and I am not restructuring working code to chase a metric that neither PR moved on its own. Two reasonable paths, and this is a human call:
All review threads here are resolved and |
Re-review —
|
There was a problem hiding this comment.
Perry's Review
Verdict: 💬 Comments / questions
Risk: 🟡 Medium
Details
Re-reviewing after c1b0c02 (changeset example fix). All prior review threads are resolved, cortex approved, and the loopKey implementation is clean: server-advertised _meta parsing, client-side loopKeys map with Object.hasOwn precedence, cache snapshot validation in isSerializedToolDef, and the FORWARDED_REHYDRATE_KEYS fix for cache hits. The changeset now includes the required API example.
CI: structural-gate FAILURE (complex functions 9 to 10). The effective diff against main is only 11 files / 559 insertions (the gh pr diff includes merge history from the merged doom-loop PR). The one additional complex function is in the new loopKey code. Whether this is acceptable is a human judgment call — it's a single function crossing the sentrux threshold, not a structural regression across the codebase.
One cross-PR concern worth flagging: this PR still imports from the v1 MCP SDK and uses the three-argument callTool signature with the undefined middle parameter. PR #86 migrates to the v2 SDK and removes that middle argument. When whichever merges first lands, the other will need a follow-up to reconcile the import and callTool signature. This isn't a defect in either PR individually, but the merge order matters.
An empty `loopKey` field list — or one whose every field is absent from the arguments — produced an empty key subset, so every call to the tool fingerprinted identically. A detector armed to block a repeat would then refuse the SECOND unrelated call: `ls` and `rm -rf /` share an identity. `advertisedLoopKey` accepts `[]` (`[].every` is vacuously true), so once a server can advertise `_meta['openrouter/loopKey']` this is reachable from the wire, not just from a local mistake. Both shapes now warn and fall back to full arguments, mirroring how the function form already handles a degenerate result. `false` remains the way to exempt a tool. refactor(mcp): keep toCreateOptions under the complexity ceiling Ports the key-list + copy-loop from 6a87464 on the dual-protocol branch. `toCreateOptions` was at the max_cc=15 ceiling and this PR's `loopKeys` spread pushed it over, failing the structural gate. Whichever of the two PRs lands second inherits the other's key list.
|
Head is now structural-gate. Ported the key-list + copy-loop from
Three tests added, including Verification: 646 agent + 59 mcp tests pass, typecheck and lint clean. Non-blocking note you raised: the branch is still stacked on the pre-merge base, so GitHub shows inflated file/line counts. |
| function advertisedLoopKey( | ||
| meta: Record<string, unknown> | undefined, | ||
| ): readonly string[] | false | undefined { | ||
| const advertised = meta?.['openrouter/loopKey']; | ||
| if (advertised === false) { | ||
| return false; | ||
| } | ||
| if (Array.isArray(advertised) && advertised.every((f) => typeof f === 'string')) { | ||
| return advertised as string[]; | ||
| } | ||
| return undefined; | ||
| } |
There was a problem hiding this comment.
🔍 Server-advertised loopKey is trusted unconditionally, including false
advertisedLoopKey accepts false (full exemption) and any string array straight off _meta['openrouter/loopKey'], and wrapMcpTool applies it whenever the client hasn't configured an override (packages/mcp/src/tool-wrapper.ts:101-104). The agent-side guard added in this PR only protects against the degenerate empty list (packages/agent/src/lib/doom-loop.ts:469-476); a server can still fully disable doom-loop detection for its own tools by advertising false, or collapse all its calls onto one identity by advertising a field that is constant across calls (e.g. ['api_version']), which bypasses both new guards since the subset is non-empty. Worth deciding whether server declarations should be opt-in (e.g. a trustServerLoopKeys flag) rather than honored by default.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Both of these are the same finding at two severities, and I'm escalating rather than deciding it — it's a question about this feature's trust model, not a defect in its implementation.
Verified the mechanism at head. advertisedLoopKey (handle.ts:35-46) returns false or a string array verbatim, and wrapMcpTool (tool-wrapper.ts:101-104) applies it whenever the client hasn't set loopKeys[name]. So server declarations are honored by default, and:
false→resolveLoopKeyMaterialreturns{kind:'exempt'}, disabling detection for that tool entirely.['api_version'](or any field constant across calls) → a non-empty subset, so it passes both guards I added in6f9f3c1while still collapsing every call onto one identity.
You're right that my guards don't cover these. They were scoped to the degenerate shapes — empty list, all-fields-absent — where no legitimate intent exists. A constant-valued field is indistinguishable from a correct narrow key without knowing the tool's semantics, and false is a legitimate declaration that a tool repeats by design. Neither can be fixed by validating the value.
The real question is the default, and there are three answers:
trustServerLoopKeysdefaulting to false (your suggestion). Servers can advertise; clients opt in. Safest, but it makes the feature inert out of the box, which may defeat the point of wire advertisement.- Honor field lists, ignore
false. A server can narrow its identity but never exempt itself. Keeps most of the value; removes the total-bypass case. - Keep it as-is, document the trust boundary. Defensible if MCP servers are already trusted — they execute arbitrary tool calls, so a hostile server has far worse options than weakening a loop detector.
My read is (2), because it's the only one that removes the sharp edge without making the feature opt-in — but it's a judgment about how much an MCP server is trusted, which belongs to whoever owns that boundary. Leaving both threads open.
Worth noting for whoever decides: loopKey is a safety control, not a security boundary. It bounds wasted spend on a looping agent. A hostile server neutralizing it costs money, not integrity — which argues this is lower severity than the 🟨 marking suggests, though still worth a deliberate default.
| function advertisedLoopKey( | ||
| meta: Record<string, unknown> | undefined, | ||
| ): readonly string[] | false | undefined { | ||
| const advertised = meta?.['openrouter/loopKey']; | ||
| if (advertised === false) { | ||
| return false; | ||
| } | ||
| if (Array.isArray(advertised) && advertised.every((f) => typeof f === 'string')) { | ||
| return advertised as string[]; | ||
| } | ||
| return undefined; | ||
| } |
There was a problem hiding this comment.
🟨 Remote MCP server can disable doom-loop detection for its own tools
A tool definition fetched from a remote MCP server can now declare _meta['openrouter/loopKey'] and that value is trusted verbatim (advertisedLoopKey at packages/mcp/src/handle.ts:35-46, applied at packages/mcp/src/tool-wrapper.ts:101-104). A hostile or compromised server can send false, which makes the wrapped tool statically exempt from doom-loop detection (resolveLoopKeyMaterial returns {kind:'exempt'} for false, packages/agent/src/lib/doom-loop.ts:454-458), or a field list naming a constant/absent-in-practice field to collapse many distinct calls onto one identity. The agent-side guard added in this PR only catches the empty-array and all-fields-absent degenerate cases, not false or a constant single-field list, so the server-controlled path can still neutralize a client-side safety control the operator explicitly enabled via doomLoop.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Both of these are the same finding at two severities, and I'm escalating rather than deciding it — it's a question about this feature's trust model, not a defect in its implementation.
Verified the mechanism at head. advertisedLoopKey (handle.ts:35-46) returns false or a string array verbatim, and wrapMcpTool (tool-wrapper.ts:101-104) applies it whenever the client hasn't set loopKeys[name]. So server declarations are honored by default, and:
false→resolveLoopKeyMaterialreturns{kind:'exempt'}, disabling detection for that tool entirely.['api_version'](or any field constant across calls) → a non-empty subset, so it passes both guards I added in6f9f3c1while still collapsing every call onto one identity.
You're right that my guards don't cover these. They were scoped to the degenerate shapes — empty list, all-fields-absent — where no legitimate intent exists. A constant-valued field is indistinguishable from a correct narrow key without knowing the tool's semantics, and false is a legitimate declaration that a tool repeats by design. Neither can be fixed by validating the value.
The real question is the default, and there are three answers:
trustServerLoopKeysdefaulting to false (your suggestion). Servers can advertise; clients opt in. Safest, but it makes the feature inert out of the box, which may defeat the point of wire advertisement.- Honor field lists, ignore
false. A server can narrow its identity but never exempt itself. Keeps most of the value; removes the total-bypass case. - Keep it as-is, document the trust boundary. Defensible if MCP servers are already trusted — they execute arbitrary tool calls, so a hostile server has far worse options than weakening a loop detector.
My read is (2), because it's the only one that removes the sharp edge without making the feature opt-in — but it's a judgment about how much an MCP server is trusted, which belongs to whoever owns that boundary. Leaving both threads open.
Worth noting for whoever decides: loopKey is a safety control, not a security boundary. It bounds wasted spend on a looping agent. A hostile server neutralizing it costs money, not integrity — which argues this is lower severity than the 🟨 marking suggests, though still worth a deliberate default.
#74 landed on main as f412281, which conflicted in four places. All four are additive — both sides kept: - build-tools.ts: this branch's SDK import path (@modelcontextprotocol/client, the point of the dual-revision work) plus main's ToolLoopKey symbol. - rehydrate.ts PASS_THROUGH_CREATE_KEYS: this branch's protocolNegotiation and probeTimeoutMs plus main's loopKeys. This is the cross-PR inheritance Devin predicted — whichever landed second takes the other's key list. - rehydrate.test.ts: this branch's snapshotWithDuplicateToolNames and two staleness describes plus main's loopKeyOf helper and its cache-hit forwarding describe. Verified: 14 mcp + 51 agent test files pass (18/18 in rehydrate.test.ts, covering both sides' additions), typecheck and lint clean.
#74 (loopKey) and #91 (toolCall in hook context) landed on main. Three conflicts, all resolved as unions: - tool-executor.ts: both sides changed buildExecuteCtx's call sites — this branch added `extras`, main added `toolCall`. The signature had already auto-merged to accept both; the three call sites now pass both. - doom-loop.ts / doom-loop.test.ts: main's loopKey field-array guard (the empty-list and all-fields-absent fallbacks) arrived wholesale; this branch had nothing there. Two fixes the merge made necessary: - tool-types.ts: this branch carried a pre-#74 `ToolLoopKey` without the `readonly string[]` member, so `tool-wrapper.ts` failed to typecheck against the field-array form main now produces. Restored main's union. - model-result.ts crossed the fan-out ceiling again (16, limit 15) once the merge landed. `normalizeInputToArray` now comes from `conversation-state.js`, which already imported it and is the natural owner of conversation-input normalization. God files back to 0. Also carries a review fix (Devin, tool-context.ts): NEVER_ABORT_SIGNAL was one process-wide signal shared by every context built without cancellation sources. A tool body subscribing to it leaked a listener for the process lifetime, since the signal never fires and nothing removes them. Replaced with a per-context factory; two tests pin it, mutation-verified. Verified: 62 agent + 10 mcp test files pass, typecheck and lint clean, gate reports God files 0 -> 0 and complex functions at baseline.
loop-key.test.ts arrived from main (#74) importing @modelcontextprotocol/sdk/client/index.js, which this branch removed from packages/mcp dependencies in favour of @modelcontextprotocol/client. It resolved locally only because the old package is still present transitively; a clean CI install would fail typecheck and lint, both of which compile tests/** per turbo.json. The last site in the migration — nothing else under src/ or tests/ still names the old package.
Summary
Stacked on #73 (
feat/doom-loop-detector). Closes the review finding that MCP tools had no way to declare a doom-looploopKey— the entire MCP surface was stuck on whole-arguments identity.What's added
Two declaration channels, both landing on
tool.function.loopKeyviamarkMcp's injection point (shipped in #73):loopKeysoncreateMCPTools/rehydrateMCPTools, keyed by the tool's unprefixed MCP name. Accepts anyToolLoopKeyform:_meta['openrouter/loopKey']on the MCP tool definition: a field-name array orfalse. Data-only by design (functions can't cross the wire); unknown shapes ignored. Captured inlistToolDefs, carried onMcpToolDef.loopKey, and round-tripped through cache snapshots (SerializedMCPToolDef.loopKey), so rehydrated tool sets keep their identities without alistTools()round-trip.Client config takes precedence over the server declaration. Both wrap branches (regular +
emitProgressgenerator) get the same wiring.Tests
10 new tests (
tests/unit/loop-key.test.ts): all three client-config forms, unprefixed-name keying, server-advertised pickup, client-over-server precedence, generator-tool coverage, absent-by-default,buildToolsplumbing, and cache serialize → JSON round-trip for both field-list andfalsedeclarations.Verification
pnpm test(mcp): 10 files, 49 passed (10 new)pnpm typecheck,pnpm lint: cleanSurface changes
CreateMCPToolsOptions.loopKeys/RehydrateMCPToolsOptions.loopKeysMcpToolDef.loopKey,WrapToolOptions.loopKeys,BuildToolsOptions.loopKeysSerializedMCPToolDef.loopKey(additive within snapshot version 1)@openrouter/mcp)