Repository navigation
MCP: coding_repos_list returns mismatched instanceId, plus intermittent tool-registration/schema loop #692
Description
Summary
Two distinct faults observed via the PAGS MCP server on 16 Aug 2026.
Fault 1 — Wrong instance attribution in coding_repos_list
Calling coding_repos_list with a specific instance_id returned repo records whose instanceId field belonged to a different instance than the one requested.
Observed across five concurrent calls for instances:
f5e577a7-7147-443b-a491-795f1582544d0f75dc66-b1f4-46df-898a-429868d39f455ef85042-e270-429d-8eb4-dd1d8d4fbbf600b91906-6613-4ef1-b16b-0fc1b3d097d173c4aced-d7a3-4dc4-84fc-48fe7c284d09
Each response carried a repo tagged to a different instance in the batch. Sequential single calls appear self-consistent, so the suspect mechanism is response routing/correlation under concurrency — replies from one request being delivered to the wrong concurrent caller.
Impact: Callers cannot trust which instance owns which repo, blocking automated rename or cleanup workflows.
Fault 2 — Intermittent tool-registration / schema loop
Tools that worked earlier in the same session became uncallable mid-session, cycling through the following error sequence:
"has not been loaded yet"— with a valid schema echoed back in the error"tool not found""not registered"- Rejection of a call using the exact parameter names from the schema the error itself supplied
Affected tools: coding_repos_list and chat_with_instance — pointing to the MCP layer rather than a fault in any individual tool.
Additionally, tool naming flipped mid-session between two forms:
mcp_<uuid>_<name>ProAgentStore:<name>
Steps to reproduce (Fault 1)
- Issue five concurrent
coding_repos_listcalls, each with a distinctinstance_idfrom the list above. - Inspect the
instanceIdfield on each returned repo record. - Observe that the
instanceIdin the response does not match theinstance_idpassed in the request.
Steps to reproduce (Fault 2)
- Open an MCP session and call any tool successfully.
- Continue calling tools in the same session; observe intermittent "not loaded / not found / not registered" errors even for tools called successfully moments before.
- Note that the error messages themselves echo a valid schema, yet a call using those exact parameter names is also rejected.
Environment
- Date observed: 16 Aug 2026
- Server: PAGS MCP server
- Both faults reproducible in the same session
Related
- A coding instance can push to and open PRs on any repo the user can reach, not just its registered one #676 (credential scope visibility)
Activity
proagentstore commentedon Aug 16, 2026
3. No MCP tool to remove a repo from a coding instance
A third gap found in the same session:
The MCP surface exposes
coding_repo_addandcoding_repos_listbut nocoding_repo_remove/coding_repo_detach. There is aremove_repotool, but it applies to repo-chat vector ingestion, not coding-instance bindings — so it doesn't help here.Consequence: a repo attached in error can only be cleaned up in the console. The same automation that created a binding cannot undo it. Concrete cases motivating this:
Instance
55f5f034-3243-4203-bd06-0e5019922afe("FAGS Platform coder") carries a stray GitHub binding toproagentstore-online/platform, an org the user does not own. The correct repoFreeAgentStore/platformhas since been attached alongside it, but the wrong binding cannot be removed via MCP.Instance
3cbb1d1d-5e6d-418f-8917-19e955707468("Parents Clubs coder") holds two local bindings with absolute/Users/serge-ivo/...workdirs that no longer exist on disk (cloneStatus: needs_attention), plus an obsoleteapps/school-clubsentry — all three are stuck and unremovable via MCP.Requested tool:
coding_repo_remove(orcoding_repo_detach) acceptinginstance_idplus repo id or name.Addendum on fault #2 (tool-registration/schema loop): the loop is reliably worked around by retrying the identical call unchanged. This points to a registration/caching race rather than a validation problem — the retry succeeds without any change to parameters or schema.
fix(mcp): guard coding_repos_list against cross-instance contamination (
serge-ivo commentedon Aug 29, 2026
Resolution
Part 1 fixed — commit 759978f.
What was wrong
coding_repos_listmade a correctly-scoped API call but had no defence against the MCP transport layer mis-routing a concurrent response. When five concurrent calls each asked for a different instance, the transport could deliver response B to the caller who asked for instance A — so the returned repos would carryinstanceId: Bagainst a request forinstance_id: A. The tool had no way to detect or reject that.Additionally, API errors (404 for stale/wrong instance ids) were silently collapsed to
{repos:[]}rather than surfaced to the caller.What the fix does
Extracted a pure function
filterReposByInstance(repos, instanceId)that drops any repo whoseinstanceIdfield does not match the requested instance. In the normal single-request path every repo already carries the correctinstanceId, so the filter is a no-op. Under concurrent mis-routing it returns empty rather than leaking the wrong instance's repos.Added an explicit
r.errorcheck before consuming the repos, so API errors surface as the actual message rather than as empty repos.Added
instanceId?: stringto theCodingRepoRowinterface (the field was returned by the API but not typed).Five unit tests pin the filter contract: matching pass-through, mismatching filtered out, mixed-response partial filter, no-instanceId defensive pass-through, and empty input.
Part 2 — tool-registration / schema loop
This is the same root cause as #759 ("has not been loaded yet, retry with exact parameter names"). That ticket is the canonical one and carries more context. Leaving this issue open until #759 is resolved (or until it's confirmed #759 covers the full symptom set). The retry-succeeds characterisation, the self-contradicting error message, and the occurrence on first touch of any tool in a session are all documented there.
feat(mcp): coding_repo_remove — the counterpart coding_repo_add never…
serge-ivo commentedon Sep 10, 2026
Status: fault 1 verified fixed, the missing removal tool shipped, fault 2 still external
Two of the three items on this ticket are now closed out. The third is unchanged and is the reason this cannot close yet.
1. Mismatched
instanceId— fixed in759978fe, re-verified at HEADWorth stating the mechanism precisely, because it is not what it looks like: the API was never wrong. Its query is scoped by
instanceIdANDuserId, and sequential calls were always self-consistent — as this issue's own repro notes. The corruption was in the MCP transport layer delivering response B to the caller who asked for instance A. The tool had no way to detect that, so it reported the wrong instance's repos as the answer to a correctly-scoped question.filterReposByInstancedid not exist before that commit — it is the fix, not a pre-existing function that was misbehaving. It drops any repo whoseinstanceIddoes not match the one requested:On the normal single-request path every repo already carries the right
instanceId, so it is a no-op; under mis-routing it returns empty rather than leaking another instance's repos. The same commit also stopped API errors being collapsed to{repos:[]}(a 404 from a stale instance id read as "no repos") and addedinstanceId?: stringtoCodingRepoRow, which the API returned but nothing typed.Re-verified at
4fa09145: 20 tests green incoding-tools.test.ts, 5 of them pinning the filter contract — matching pass-through, mismatching filtered out, mixed-response partial filter, no-instanceIddefensive pass-through, empty input.2.
coding_repo_remove— shipped in4fa09145The gap raised in the comment above, and the one thing here that needed new code.
The endpoint already existed.
DELETE /v1/instances/:id/coding/repos/:repoIdhas been inroutes/coding-repos.tsall along; its only caller wasagents/coder/web. That is also why no guard ever caught the gap —check-mcp-parity.mjsinventoriesstore/console/src, so a capability reachable only from the Coder UI is invisible to it.It is not a bare row delete: it asks the runner to end any ACTIVE session on the repo first, because removing the binding is the last handle on that engine process. If a runner does not confirm, the removal still completes (a flaky runner must not trap an owner with a repo they cannot remove) and the reply carries a
warningnaming how many engines may still be running. That warning is passed through verbatim — swallowing it would recreate the orphaned-process problem one layer up.destructive, notwrite. The binding is cheap to recreate withcoding_repo_add, which alone would argue forwrite— but the call reaches out and stops engines on the owner's machine, and a scope is about what a call can disturb. Confirm string"coding_repo_remove", plusdry_run.repo_idorrepo_name, as the comment asked. The name resolves throughfilterReposByInstance— this issue's other half, load-bearing here: under concurrent mis-routing, resolving a name against a foreign listing would land a destructive call on an instance the caller never named.remove_repo, which detaches a repo from repo-chat vector ingestion. Both facts are in the tool's own description, since that is what a model actually reads.16 tests, all green. Surface
0.1.24→0.1.25; surface-gated besidecoding_repo_add, so 153 registrations become 154,MCP_TOOL_GATED23 → 24,MCP_TOOL_ALWAYS_ONunchanged at 130. Fourteenth confirm-gated tool.The three bindings named in the comment (
55f5f034…'s wrong-org GitHub binding,3cbb1d1d…'s two dead local workdirs plus the obsoleteapps/school-clubsentry) are now removable over MCP.3. The registration / "has not been loaded yet" loop — still external
Unchanged, and one correction to how it is often summarised: this is not a server-side registration race, and the server does not emit those strings at all. #759 traced it:
grep -r "has not been loaded yet"overworkers/ packages/ store/ agents/returns nothing. The strings are also absent from@modelcontextprotocol/sdk@1.29.0,agents/mcp,mcp-remote, and the Claude Desktop app bundle. They are synthesized by the client.ProAgentStore:<name>is Claude Code's own client-side registry format.partyserver's#initialize()wrapsonStart()(which awaitsinit(), includinguserGroups()) inctx.blockConcurrencyWhile(), a Durable Object primitive that holds every incoming request until initialization finishes.tools/listresponse has been processed. The identical retry succeeds because by then it has been — which is exactly the "retry-succeeds-unchanged" characterisation in the comment above.#759 is labelled
invalid/P3: later, and its BA triage recommends closing as external with a docs note. So this issue cannot close on its stated terms — it is waiting on "#759 resolved", and #759 is waiting on someone to accept its own diagnosis. Either closing #759 as external or recording that acceptance here would unblock it.4. A security invariant was tightened on the way through
coding_repo_removecollided withrepo-policies.test.ts, which enforces #322: promotion of a repo policy toactmust stay a human action, because a policy is the one thing on this platform that acts with nobody present.It enforced that by forbidding any MCP source from constructing a per-repo path at all — a path-shape proxy for a method-specific rule. The promotion route is
PUT …/coding/repos/:repoId;DELETEon the same path cannot promote anything. I checked the real invariant first: no MCP source PUTs to a repo path, so it held.Left as a path guard it would refuse every legitimate per-repo tool forever, and the likely response is to widen the allowed list — which is what would eventually admit a real PUT. So it now states the rule directly: no MCP
authedCallmay pair a repo path withmethod: "PUT". Comments are stripped first, because two of the three "offenders" it reported were this commit's own docblock naming the endpoint, and a guard that cannot tell an explanation from an instruction trains people to delete the explanation.Reported honestly: my first version of that guard was broken. It passed against a deliberately planted PUT — the path is held in a const and the call site is 400 characters away, so the window measured nothing. It now resolves path-holding identifiers and judges each call by its own first argument, and carries a G1 assertion that the scan saw at least three repo-path calls, so it cannot pass vacuously. Verified red three ways: planted PUT fails, blinded predicate fails, restored passes. 38 tests green.
Suite at
4fa09145pnpm test11,123 / 11,124 (619/621 files);pnpm typecheck10/10 projects plus all threetsconfig.test.jsonprograms; biome clean over 1,412 files; 13/13check-*.mjsguards;docs:driftclean. The single failure is environmental and pre-existing —migration-hygiene.test.tsruns its guard with--require-historyagainst a shallow checkout; CI usesfetch-depth: 0.fix(mcp): userGroups gets one bounded retry, so a blip cannot erase t…