Skip to content

Commit d60640b

Browse files
serge-the-devclaude
andcommitted
fix: don't latch truncated tool surface on transient userGroups failure (#803)
`init()` set `toolsRegistered = true` before the one network call registration depends on, so when `userGroups()` failed both of its attempts it returned the same empty set a user with no surfaced agents gets, the DO registered without the coding/apply/repo groups, and the latch kept it that way until eviction — with nothing thrown, logged or recorded, and no identical retry able to recover it. - `userGroups()` now throws after its bounded retry (both the returned `{error}` and the thrown-fetch path), carrying the last error. The no-token path is unchanged: anonymous resolving to no groups is a certainty, not a failure. - `init()` resolves the roster BEFORE the latch and before the registration pipeline is installed. A refused `initialize` therefore leaves nothing latched or registered; partyserver resets its status when `onStart` throws and re-runs it on the next request, so the client's retry starts from scratch. The latch is set right after the lookup, not after the registrations: everything below it is synchronous and not re-runnable (the pipeline install wraps `tool`). - The pinned `/mcp/i/<id>` path keeps its latch-before-lookup order on purpose: an unreadable pinned surface already registers one tool that says so, and "not your instance" is a permanent answer that must not refuse `initialize` forever. Tests: the "gives up" case now asserts a refusal with nothing published and nothing latched; two new cases prove the next `init()` registers the full surface and that a network-level throw is refused the same way. Harness gains `deferInit`. File-size pins raised with notes. Docs: workers/mcp/CLAUDE.md and platform-docs/mcp.md describe the refusal. Closes #803 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent f8e402a commit d60640b

5 files changed

Lines changed: 126 additions & 53 deletions

File tree

‎platform-docs/mcp.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,11 @@ Those three mean the session is gone — a redeploy ends the Durable Object hold
329329
the fix is to run `initialize` again, not to change the call. Anything else is one of the
330330
two honest failure shapes above.
331331

332+
One more refusal is possible at `initialize` itself: if the platform API cannot answer which
333+
agents the account runs (the lookup that decides which agent-specific tools you get), the
334+
server fails that `initialize` rather than publishing a `tools/list` that silently omits the
335+
coding, apply and repo groups. Run `initialize` again; the lookup is repeated from scratch.
336+
332337
## Correct Runtime Flows
333338

334339
Public trial preview:

‎scripts/check-file-size.mjs‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -746,7 +746,13 @@ const PINS = {
746746
// shape unreachable (`apiCall` RETURNS a non-2xx as `{error}` rather than throwing, so the
747747
// `catch` never fired for the failure it was written for) and states what the retry does NOT
748748
// fix — the latch — so the next reader does not mistake a narrowed window for a closed one.
749-
"workers/mcp/src/index.ts": 1016,
749+
// +23 at #803: the latch the #759 note above said was NOT fixed. `userGroups` now THROWS after
750+
// its bounded retry instead of returning the empty set a user with no surfaced agents also
751+
// gets, and `init()` resolves it BEFORE `toolsRegistered` and before the pipeline install, so a
752+
// refused `initialize` leaves nothing latched or registered and the next request starts over.
753+
// Seven of the lines are code (the error carried, the throw, the pinned branch's own latch);
754+
// the rest say why the order latch → pipeline → registrations must not change.
755+
"workers/mcp/src/index.ts": 1039,
750756
// +6 for #324: the "Runs on" machine picker had a <label> that named nothing — a label can
751757
// only name one control and what it labels is a GRID of tiles — so it becomes a named group,
752758
// which costs a useId, the two lines saying why, and the ignore explaining why not <fieldset>.
@@ -1660,7 +1666,8 @@ const PINS = {
16601666
// one that refuses to) and these three.
16611667
// +11 at #804: the tools.ts and coding-session.ts raises above (five and four lines of why)
16621668
// and these three.
1663-
"scripts/check-file-size.mjs": 1744,
1669+
// +8 at #803: the `workers/mcp/src/index.ts` raise above plus this line.
1670+
"scripts/check-file-size.mjs": 1751,
16641671
};
16651672

16661673
/**

‎workers/mcp/CLAUDE.md‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -210,15 +210,18 @@ tells you exactly what you changed about it.
210210
- **Models send JSON strings for object params.** `create_agent` / `update_agent` accept
211211
`z.union([z.record(z.unknown()), z.string()])` and parse a string, because rejecting it
212212
turns a working call into a retry loop. Do the same for any new object-shaped argument.
213-
- **`userGroups()` gets ONE retry, then swallows (#759).** No token → empty set
213+
- **`userGroups()` gets ONE retry, then THROWS (#759, #803).** No token → empty set
214214
immediately, which is correct and costs nothing. A failed lookup is retried once after
215-
200ms and only then yields empty, i.e. no agent-specific tools for the life of that DO.
216-
The retry covers both a thrown fetch AND the `{error}` object `apiCall` returns for a
217-
non-2xx — the second was the reachable one, and the original `catch` could not see it.
218-
What it does not fix is the LATCH: two failures still register an empty surface until the
219-
DO is evicted, because re-running registration would throw `already registered`. So "my
220-
tool disappeared" is still usually an auth problem rather than a registration bug — just
221-
no longer a single blip away.
215+
200ms; a second failure throws out of `init()`. The retry covers both a thrown fetch AND
216+
the `{error}` object `apiCall` returns for a non-2xx — the second was the reachable one,
217+
and the original `catch` could not see it. The throw is what closed the LATCH: `init()`
218+
resolves the roster BEFORE `toolsRegistered = true` and before the pipeline is installed,
219+
so a refused `initialize` leaves nothing registered and the next request starts over
220+
(partyserver resets its status when `onStart` throws). Before #803 two failures registered
221+
the always-on set without the gated groups and kept it until DO eviction, with no error
222+
anywhere. Keep that order: latch → pipeline → registrations, all after the one network
223+
call, because none of them is re-runnable. So "my tool disappeared" is now an auth or
224+
subscription fact, not a registration bug.
222225
- **`/health`'s `tools` count comes from `src/tool-count.ts`.** It answered a hardcoded
223226
`41` for months while 124 were registered — and `oauth-provider.test.ts` asserted the
224227
41, so the test locked the wrong number in rather than catching it. `index.test.ts` now

‎workers/mcp/src/index.test.ts‎

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ interface HarnessOpts {
123123
* the transient failure this retry exists for.
124124
*/
125125
rosterFailures?: number;
126+
/** Build the instance but do not call `init()` — for tests that drive it themselves (#803). */
127+
deferInit?: boolean;
126128
}
127129

128130
async function setup(opts: HarnessOpts = {}) {
@@ -181,7 +183,7 @@ async function setup(opts: HarnessOpts = {}) {
181183
};
182184
inst.server = fakeServer; // replace the real McpServer with our capturing double
183185

184-
await inst.init();
186+
if (!opts.deferInit) await inst.init();
185187

186188
return {
187189
inst,
@@ -229,14 +231,16 @@ describe("PagsMcp.init — tool registration", () => {
229231
});
230232

231233
/**
232-
* A transient roster lookup must not cost this connection every gated tool (#759).
234+
* A transient roster lookup must not cost this connection every gated tool (#759), and a
235+
* persistent one must not latch a truncated surface for the life of the DO (#803).
233236
*
234-
* `init()` latches `toolsRegistered = true` and registers ONCE per Durable Object, so an empty
235-
* group set is not a small failure: the whole `coding`, `apply` and `repo` surface disappears
236-
* from `tools/list` for the life of that DO, and the user sees the always-on tools working while
237-
* their coding tools have simply vanished.
237+
* `init()` registers ONCE per Durable Object, so an empty group set is not a small failure: the
238+
* whole `coding`, `apply` and `repo` surface disappears from `tools/list`, and the user sees the
239+
* always-on tools working while their coding tools have simply vanished. #759 gave the lookup
240+
* one retry. #803 made the second failure a refusal — `init()` throws before anything is latched
241+
* or registered — so the next request starts over instead of inheriting the truncation.
238242
*/
239-
describe("userGroups survives a transient roster failure (#759)", () => {
243+
describe("userGroups survives a transient roster failure (#759) and refuses rather than latches (#803)", () => {
240244
const rosterCalls = (h: Awaited<ReturnType<typeof setup>>) =>
241245
h.fetchStub.calls.filter((c) => c.url.endsWith("/v1/instances/my/instances")).length;
242246

@@ -258,15 +262,46 @@ describe("PagsMcp.init — tool registration", () => {
258262
expect(h.tools.has("coding_session_capture")).toBe(true);
259263
});
260264

261-
it("gives up after the one retry rather than looping", async () => {
265+
it("gives up after the one retry rather than looping — and refuses, rather than publishing a truncated surface (#803)", async () => {
262266
// The bound matters as much as the retry. An unbounded loop here would hold every new
263267
// connection's `initialize` open against an API that is genuinely down.
264-
const h = await setup({ groups: ["coding"], rosterFailures: 99 });
268+
const h = await setup({ groups: ["coding"], rosterFailures: 99, deferInit: true });
269+
await expect(h.inst.init()).rejects.toThrow(/roster could not be read after 2 attempts/);
265270
expect(rosterCalls(h), "exactly two attempts, never more").toBe(2);
266-
// And the fallback is the pre-#759 behaviour: no gated tools, but the always-on set is
267-
// intact, so the connection still works for everything that does not need a surface.
268-
expect(h.tools.has("coding_session_capture")).toBe(false);
271+
// The pre-#803 fallback registered the always-on set without the gated groups and latched
272+
// it. Now NOTHING is registered and nothing is latched: the DO can be initialised again.
273+
expect(h.tools.size, "no tool may be published from a failed roster").toBe(0);
274+
expect(h.inst.toolsRegistered).toBe(false);
275+
});
276+
277+
it("a refused initialize is not latched: the next init registers the full surface (#803)", async () => {
278+
// Two 500s spend the first init's two attempts; the third lookup succeeds. This is the
279+
// whole ticket: an identical retry RECOVERS, where before only DO eviction did.
280+
const h = await setup({ groups: ["coding"], rosterFailures: 2, deferInit: true });
281+
await expect(h.inst.init()).rejects.toThrow();
282+
expect(h.tools.size).toBe(0);
283+
284+
await h.inst.init();
285+
expect(rosterCalls(h), "two failed attempts, then the one that succeeded").toBe(3);
286+
expect(h.inst.toolsRegistered).toBe(true);
287+
expect(h.tools.has("coding_session_capture")).toBe(true);
269288
expect(h.tools.has("list_agents")).toBe(true);
289+
290+
// And now the latch does the job it exists for: a third init registers nothing twice.
291+
const size = h.tools.size;
292+
await h.inst.init();
293+
expect(h.tools.size).toBe(size);
294+
});
295+
296+
it("a network-level throw is refused the same way as a returned 500 (#803)", async () => {
297+
// `apiCall` returns a non-2xx but a failed fetch THROWS; both give-up paths must refuse.
298+
const h = await setup({ groups: ["coding"], deferInit: true });
299+
vi.stubGlobal("fetch", async () => {
300+
throw new TypeError("network down");
301+
});
302+
await expect(h.inst.init()).rejects.toThrow(/network down/);
303+
expect(h.tools.size).toBe(0);
304+
expect(h.inst.toolsRegistered).toBe(false);
270305
});
271306

272307
it("does not retry when there is no token — an anonymous connection is legitimately empty", async () => {

‎workers/mcp/src/index.ts‎

Lines changed: 54 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -156,38 +156,47 @@ export class PagsMcp extends McpAgent<Env, unknown, Props> {
156156
* No token: an unauthenticated connection legitimately has no groups, and retrying would add
157157
* 200ms to every anonymous `initialize` to re-derive a certainty.
158158
*
159-
* ── What this does not fix
159+
* ── Why a second failure THROWS rather than returning empty (#803)
160160
*
161-
* The LATCH. If both attempts fail, the DO still registers with an empty set and stays that way
162-
* until it is evicted. Making registration re-runnable is a larger change — registering a tool
163-
* twice on the same server throws and cancels the MCP stream, which is the failure the latch exists
164-
* to prevent — and it is not what #759 asks for. This narrows the window; it does not close it.
161+
* "Resolved to no groups" and "could not resolve" are different answers, and only the first is
162+
* an empty set. Returning empty for both is what latched a truncated surface: `init()` could not
163+
* tell a blip from a user with no surfaced agents, registered without the gated groups, and the
164+
* latch kept it that way until the DO was evicted — silently, since nothing was thrown, logged or
165+
* recorded, and an identical retry does not recover it. So after the bounded retry this throws.
166+
* `init()` calls it BEFORE the latch and before anything is registered, so the throw fails that
167+
* one `initialize` honestly and leaves the DO able to try again on the next request: partyserver
168+
* resets its status when `onStart` throws and re-runs it on the next fetch. The no-token path is
169+
* untouched — an anonymous connection resolving to no groups is a certainty, not a failure.
165170
*/
166171
private async userGroups(): Promise<Set<string>> {
167-
const groups = new Set<string>();
168-
if (!this.userToken) return groups;
169-
for (let attempt = 0; attempt < 2; attempt++) {
170-
try {
171-
const data = (await authedCall("/v1/instances/my/instances", this.userToken, {}, this.env)) as
172-
| Array<{ capabilities?: { surfaces?: string[] } }>
173-
| { instances?: Array<{ capabilities?: { surfaces?: string[] } }>; error?: string };
174-
// The error object `apiCall` returns instead of throwing. Checked BEFORE the list is
175-
// read, because `{error}` has no `instances` and would otherwise read as "no agents".
176-
if (!Array.isArray(data) && data?.error) {
172+
const groups = new Set<string>();
173+
if (!this.userToken) return groups;
174+
let lastError = "no response";
175+
for (let attempt = 0; attempt < 2; attempt++) {
176+
try {
177+
const data = (await authedCall("/v1/instances/my/instances", this.userToken, {}, this.env)) as
178+
| Array<{ capabilities?: { surfaces?: string[] } }>
179+
| { instances?: Array<{ capabilities?: { surfaces?: string[] } }>; error?: string };
180+
// The error object `apiCall` returns instead of throwing. Checked BEFORE the list is
181+
// read, because `{error}` has no `instances` and would otherwise read as "no agents".
182+
if (!Array.isArray(data) && data?.error) {
183+
lastError = data.error;
184+
if (attempt === 0) await new Promise((r) => setTimeout(r, USER_GROUPS_RETRY_MS));
185+
continue;
186+
}
187+
const list = Array.isArray(data) ? data : (data?.instances ?? []);
188+
for (const inst of list) for (const s of inst.capabilities?.surfaces ?? []) groups.add(s);
189+
return groups;
190+
} catch (e) {
191+
// A network-level throw. Same treatment as the error object above: one more try.
192+
lastError = e instanceof Error ? e.message : String(e);
177193
if (attempt === 0) await new Promise((r) => setTimeout(r, USER_GROUPS_RETRY_MS));
178-
continue;
179194
}
180-
const list = Array.isArray(data) ? data : (data?.instances ?? []);
181-
for (const inst of list) for (const s of inst.capabilities?.surfaces ?? []) groups.add(s);
182-
return groups;
183-
} catch {
184-
// A network-level throw. Same treatment as the error object above: one more try, then
185-
// give up with an empty set — the pre-#759 behaviour, which is still the honest
186-
// fallback once a second attempt has also failed.
187-
if (attempt === 0) await new Promise((r) => setTimeout(r, USER_GROUPS_RETRY_MS));
188195
}
189-
}
190-
return groups;
196+
throw new Error(
197+
`mcp: the account's agent roster could not be read after 2 attempts (${lastError}); ` +
198+
"refusing to publish a tool surface missing every gated group — retry initialize (#803)",
199+
);
191200
}
192201

193202
async init() {
@@ -201,17 +210,31 @@ export class PagsMcp extends McpAgent<Env, unknown, Props> {
201210
// same server throws "Tool ... is already registered", which cancels the
202211
// MCP stream and makes clients hang until they time out. Register once.
203212
if (this.toolsRegistered) return;
204-
this.toolsRegistered = true;
205213

206214
// A `/mcp/i/<id>` session registers ONLY that instance's surface (#783) — nothing below.
207-
if (this.props?.pinnedInstance) return this.initPinned(this.props.pinnedInstance);
215+
// Latched BEFORE its lookup, unlike the platform-wide path: a pinned surface that cannot be
216+
// read registers one tool that says so (`pinned.ts`), and "not your instance" is a permanent
217+
// answer that must not refuse `initialize` forever.
218+
if (this.props?.pinnedInstance) {
219+
this.toolsRegistered = true;
220+
return this.initPinned(this.props.pinnedInstance);
221+
}
222+
223+
// Which agent-specific tool groups this user gets — scoped to their agents. Resolved BEFORE
224+
// the latch and before the pipeline is installed (#803): this is the only network call
225+
// registration depends on, and it throws when the roster cannot be read. Nothing is latched or
226+
// registered at that point, so the failed `initialize` is retried from scratch on the next
227+
// request instead of a surface missing every gated group being served for the DO's lifetime.
228+
const groups = await this.userGroups();
229+
230+
// Latched HERE, not after the registrations: everything below is synchronous and
231+
// deterministic, and the one thing a re-run could do is register a tool twice — the hang the
232+
// latch exists to prevent. The pipeline install is also not re-runnable (it wraps `tool`).
233+
this.toolsRegistered = true;
208234

209235
// Must precede every registration below — it wraps the registrar itself.
210236
this.installRegistrationPipeline();
211237

212-
// Which agent-specific tool groups this user gets — scoped to their agents.
213-
const groups = await this.userGroups();
214-
215238
this.server.tool(
216239
"list_agents",
217240
"List all published agents on ProAgentStore",

0 commit comments

Comments
 (0)