Skip to content

feat(mcp): loopKey support for MCP-wrapped tools - #74

Merged
LukasParke merged 29 commits into
mainfrom
feat/mcp-loop-key
Aug 3, 2026
Merged

feat(mcp): loopKey support for MCP-wrapped tools#74
LukasParke merged 29 commits into
mainfrom
feat/mcp-loop-key

Conversation

@LukasParke

@LukasParke LukasParke commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Stacked on #73 (feat/doom-loop-detector). Closes the review finding that MCP tools had no way to declare a doom-loop loopKey — the entire MCP surface was stuck on whole-arguments identity.

What's added

Two declaration channels, both landing on tool.function.loopKey via markMcp's injection point (shipped in #73):

  1. Client configloopKeys on createMCPTools / rehydrateMCPTools, keyed by the tool's unprefixed MCP name. Accepts any ToolLoopKey form:
const handle = await createMCPTools({
  url: 'https://mcp.example.com',
  toolNamePrefix: 'svc_',
  loopKeys: {
    run_command: ['command', 'cwd'],   // field list — the bash identity
    web_search: (args) => String(args.query).toLowerCase(), // function
    poll_job: false,                    // exempt — repetition is its job
  },
});
  1. Server-advertised_meta['openrouter/loopKey'] on the MCP tool definition: a field-name array or false. Data-only by design (functions can't cross the wire); unknown shapes ignored. Captured in listToolDefs, carried on McpToolDef.loopKey, and round-tripped through cache snapshots (SerializedMCPToolDef.loopKey), so rehydrated tool sets keep their identities without a listTools() round-trip.

Client config takes precedence over the server declaration. Both wrap branches (regular + emitProgress generator) 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, buildTools plumbing, and cache serialize → JSON round-trip for both field-list and false declarations.

Verification

  • pnpm test (mcp): 10 files, 49 passed (10 new)
  • pnpm typecheck, pnpm lint: clean

Surface changes

  • CreateMCPToolsOptions.loopKeys / RehydrateMCPToolsOptions.loopKeys
  • McpToolDef.loopKey, WrapToolOptions.loopKeys, BuildToolsOptions.loopKeys
  • SerializedMCPToolDef.loopKey (additive within snapshot version 1)
  • Changeset: minor (@openrouter/mcp)

Open in Devin Review

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.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

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.
perry-the-pr-reviewer[bot]

This comment was marked as resolved.

@LukasParke LukasParke added the cortex-keep-updated cortex keeps this PR up to date with its base branch label Jul 24, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

LukasParke and others added 4 commits July 24, 2026 17:05
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>

@cortex-github-agent cortex-github-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@cortex-github-agent cortex-github-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

devin-ai-integration[bot]

This comment was marked as resolved.

`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.

@cortex-github-agent cortex-github-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

perry-the-pr-reviewer[bot]

This comment was marked as resolved.

LukasParke and others added 3 commits July 29, 2026 11:49
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.

@cortex-github-agent cortex-github-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

LukasParke and others added 2 commits July 29, 2026 11:58
`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.
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Re-review at c3ce7b4c

Verified locally at this head: pnpm typecheck, pnpm lint clean; full suite 702 tests across 61 files (643 agent + 59 mcp).

c3ce7b4c is a merge of the base branch, and it carries exactly 68eb0bc (#73's advisor: false fix — 11 lines of doom-loop.ts plus 39 lines of tests) with no conflict resolution of its own: git diff c3ce7b4c^1 c3ce7b4c is that commit verbatim. This PR's own diff against its base is unchanged apart from 6e5148c.

6e5148c is a strict improvement over what I suggested. The early return doesn't just deduplicate isJsonSchemaObject(value) — it removes the trap that the ternary created, where a non-object value produced loopKey === undefined and therefore hasValidLoopKey === true, a local asserting validity about a value that was never inspected. Harmless before only because the conjunction re-checked object-ness; now it's not load-bearing. The added comment recording why each accepted shape is accepted (absent = optional, false = exempt, string[] = field list, everything else = poisoned or stale snapshot) is the part that will keep the guard honest when someone widens SerializedMCPToolDef later.

One standing item, same as on #73: .changeset/mcp-loop-key.md has no fenced code block, and loopKeys on createMCPTools/rehydrateMCPTools plus McpToolDef.loopKey are public API — .agents/skills/public-api-examples/SKILL.md requires the example in the changeset. The loopKeys: { run_command: ['command', 'cwd'], poll_job: false } snippet from the PR description is exactly what's missing there.

Verdict: ✅ LGTM. Merge order matters — this lands after #73.

Base automatically changed from feat/doom-loop-detector to main July 29, 2026 17:42
`.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.
@cortex-github-agent

Copy link
Copy Markdown
Contributor

Base switched to main — the effective diff is no longer the MCP change alone.

Previous passes (heads e45973f6e5148c) reviewed 9–11 files, all in packages/mcp, against base feat/doom-loop-detector. This head (c1b0c02) targets main, so the PR now carries the entire stacked #73/#75 surface as its own change: 34 files, including packages/agent/src/lib/doom-loop.ts (+1088), model-result.ts (+878/-38), the escalate ladder, run cancellation (signal on callModel, dispatchRequestOptions), three new changesets for @openrouter/agent, and ~4,700 lines of new agent tests. The GitHub diff is truncated at 200 KB, so part of it is not inspectable in this pass.

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 @openrouter/agent minor release surface too, under a PR titled feat(mcp) whose body still says "Stacked on #73" and lists only MCP surface changes.

Two possibilities:

  • feat(agent): doom-loop detection for the tool-execution loop #73 is still meant to merge first → re-target this PR at feat/doom-loop-detector and the diff collapses back to the MCP subset (already approved).
  • The stack is being collapsed deliberately → please update the title/body to describe the full surface, and point me at where the doom-loop/cancellation code was reviewed so this PR isn't the first review of record for it.

Advisory notes on the newly-in-scope code, from what is visible:

  • packages/agent/src/lib/model-result.ts:2985AbortSignal.any(...) requires Node ≥ 20.3 and globalThis.crypto.subtle (doom-loop.ts:520) requires Node ≥ 19, but packages/agent/package.json declares no engines field. Consumers on Node 18 get a runtime TypeError only when they opt into signal/doomLoop. Worth declaring the floor.
  • packages/agent/src/lib/model-result.ts:2981this.options.client._options.timeoutMs reads an underscore-prefixed SDK internal. If @openrouter/sdk renames it, the client-level timeout silently stops applying on the composed-signal path (no type error, no test failure unless one pins it). A narrow accessor or a documented dependency would make that breakage loud.

@cortex-github-agent cortex-github-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@LukasParke

Copy link
Copy Markdown
Contributor Author

CI note on c1b0c02: structural-gate is failing, and the attribution is worth stating precisely because it is not what the red X suggests.

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 0.43 / 133-edge baseline).

What the evidence says:

  • The gate only ran on c1b0c02, which is a changeset-only commit (.changeset/mcp-loop-key.md, +29 lines of markdown). It cannot add a complex function.
  • My other commit here, 6e5148c, converts a ternary + conjunction into an early return — that reduces branching, and its diff is 8 insertions in one guard function.
  • feat(agent): doom-loop detection for the tool-execution loop #73 passes this gate on its own68eb0bc is green with the identical 0.4739 / 9 complex fn baseline.

So the +1 appears only when #73's code is combined with this branch's, via the dae91464 merge. Neither PR trips it alone. That is a composition effect, not a regression introduced by either changeset.

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:

  1. Merge feat(agent): doom-loop detection for the tool-execution loop #73 first (devin's note on this PR says the same: "Merge order matters — this lands after feat(agent): doom-loop detection for the tool-execution loop #73"). Once feat(agent): doom-loop detection for the tool-execution loop #73 is on main, its function is baseline, and re-running this gate should measure only this PR's own delta.
  2. Refresh the baseline on main, which is the same underlying ask as on feat(mcp)!: support both MCP protocol revisions (2025-11-25 and 2026-07-28) #86 — the committed baseline is stale there too, and there is precedent in fcbf9aa.

All review threads here are resolved and Devin Review is green at this head.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Re-review — c1b0c02

The changeset example is correct — it compiles against this branch (loopKeys keyed by unprefixed name, cache: { store }, handle.tools into doomLoop: true), and it covers the changed surface rather than adjacent code. But two things need to happen before this merges, one of which contradicts the triage of the failing check.

1. structural-gate ❌ is this PR's own regression, not inherited drift

Reproduced with sentrux 0.5.7 (the version CI pins) and isolated by file. Reverting packages/mcp/src/rehydrate.ts to main while keeping the rest of the PR clears it; keeping only that file and reverting everything else reintroduces it; and removing the single added hunk

...(options.loopKeys !== undefined && {
  loopKeys: options.loopKeys,
}),

from toCreateOptions clears it. toCreateOptions sat at cc=15 — the max_cc in .sentrux/rules.toml — so the 15th conditional spread is one over the ceiling.

You already fixed this exact function on #86 (6a87464). Porting the same shape here — a as const satisfies readonly (keyof RehydrateMCPToolsOptions & keyof CreateMCPToolsOptions)[] key list plus one copy loop, mirroring forwardedRehydrateOptions — gives, verified locally: ✓ No degradation detected, tsc --noEmit clean, 59/59 mcp unit tests passing.

Both PRs edit toCreateOptions, so whichever lands second must add loopKeys to the key list rather than re-adding a spread.

2. loopKey: [] collapses a tool's entire identity — and this PR makes it server-controllable

advertisedLoopKey accepts an empty array ([].every(...) is vacuously true), isSerializedToolDef accepts it through the cache, and resolveLoopKeyMaterial([]) returns an empty subset as key material. So every call of that tool hashes to one fingerprint. Verified:

const a = resolveLoopKeyMaterial([], { command: 'ls', cwd: '/a' });
const b = resolveLoopKeyMaterial([], { command: 'rm -rf /', cwd: '/b' });
await fingerprintToolCall('run_command', a.keyMaterial)
  === await fingerprintToolCall('run_command', b.keyMaterial); // true

Before this PR that required a local typo. After it, a server can put _meta['openrouter/loopKey']: [] on a tool definition and drive a client's run to block/stop on its 2nd or 3rd unrelated call to that tool — remote input deciding that someone else's agent is looping. The codebase already treats identity collapse as a bug for the function form: a loopKey returning undefined falls back to full arguments with a warning, on the stated grounds that treating it as key material "would collide every call of the tool onto one fingerprint". An empty field list is the same failure with the same reasoning, so it should get the same treatment.

The cheapest choke point is resolveLoopKeyMaterial — one guard covers the server channel, the cache channel, and the client loopKeys map — but that file is on main now, so it's either a small follow-up or an addition here plus validation in advertisedLoopKey / isSerializedToolDef.

3. Review surface (not blocking)

This branch is still stacked on the pre-merge feat/doom-loop-detector, and #73 was squash-merged, so the merge base predates it: GitHub's three-dot diff shows 34 files / +8145 with all of doom-loop, escalation, and run-cancellation re-presented as new. git diff origin/main HEAD is the real change — 11 files / +559. Merging main in collapses the diff to what actually wants reviewing.

@perry-the-pr-reviewer perry-the-pr-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@LukasParke

Copy link
Copy Markdown
Contributor Author

Head is now 6f9f3c1 (main merged in). Both of Devin's blockers addressed.

structural-gate. Ported the key-list + copy-loop from 6a87464 on the dual-protocol branch exactly as suggested — PASS_THROUGH_CREATE_KEYS with as const satisfies readonly (keyof RehydrateMCPToolsOptions & keyof CreateMCPToolsOptions)[], so a key that isn't valid on both types fails to compile instead of silently dropping. loopKeys is in the list. Noting your cross-PR point: whichever of #74 / #86 lands second inherits the other's list, and since both now carry it the merge is a no-op either way.

loopKey: [] collapsing tool identity. Confirmed and fixed. Traced it end to end: advertisedLoopKey (handle.ts:41) accepts [] because [].every(...) is vacuously true, and resolveLoopKeyMaterial (doom-loop.ts:459) then builds an empty subset, so identity is constant and a detector armed to block a repeat refuses the second unrelated call. Both degenerate shapes now warn and fall back to full arguments, mirroring the function form's existing warns-and-falls-back behavior:

  • empty field list → kind: 'fallback' + warning
  • every declared field absent from the arguments → same (the same collapse by another route, which your comment didn't mention but has identical impact)

false remains the documented way to exempt a tool. Guarded in resolveLoopKeyMaterial as you preferred, so it covers the wire path and local declarations together.

Three tests added, including empty field array does not collapse two unrelated calls to one identity asserting ls and rm -rf / no longer fingerprint the same. Mutation-verified: replacing the guard condition with if (false) fails exactly one test.

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.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment on lines +35 to +46
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • falseresolveLoopKeyMaterial returns {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 in 6f9f3c1 while 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:

  1. trustServerLoopKeys defaulting 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.
  2. 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.
  3. 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.

Comment on lines +35 to +46
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • falseresolveLoopKeyMaterial returns {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 in 6f9f3c1 while 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:

  1. trustServerLoopKeys defaulting 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.
  2. 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.
  3. 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.

@LukasParke
LukasParke merged commit f412281 into main Aug 3, 2026
6 checks passed
LukasParke added a commit that referenced this pull request Aug 3, 2026
#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.
LukasParke added a commit that referenced this pull request Aug 3, 2026
#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.
LukasParke added a commit that referenced this pull request Aug 3, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cortex-keep-updated cortex keeps this PR up to date with its base branch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant