Skip to content

fix(mcp): cut the facade repository-selector cost and explain its refusals - #583

Merged
zzet merged 2 commits into
mainfrom
fix/facade-repo-selector-cost-and-guidance
Aug 15, 2026
Merged

fix(mcp): cut the facade repository-selector cost and explain its refusals#583
zzet merged 2 commits into
mainfrom
fix/facade-repo-selector-cost-and-guidance

Conversation

@zzet

@zzet zzet commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Summary

Follow-up to #553, which closed the silent wrong-repository fallback behind #549. Four commits: two fix what that merge shipped with, two close the residual it left open.

1. The published-schema lookup ran per request

facadePublicRepositoryField builds the operation's whole public capability schema, which re-materialises the immutable operation table and re-probes every legacy property. It ran on every call carrying a repository selector — including options.repo, the canonical one. Memoized on the registry and dropped whenever capture changes the captured set, since optional handlers register after NewServer and a pre-registration answer must not survive.

call before after
change.detect, no selector 1,488 ns / 1,128 B / 17 allocs 1,380 ns / 1,128 B / 17 allocs
change.detect + options.repo 89,707 ns / 160,884 B / 659 allocs 2,416 ns / 2,200 B / 27 allocs
read.file + options.repo 118,789 ns / 174,940 B / 832 allocs 3,461 ns / 2,865 B / 39 allocs

alloc_space attributed 62% of the removed cost to facadeCanonicalOperationNamesfacadeOperationSpecsaddFacadeGroup, plus 16% to regexp compilation.

2. Refusals on operations with no selector said nothing useful

Most operations publish no repository selector; those refusals read unknown field "options.repo", which looks like a typo and invites retrying spellings that can never be accepted. The refusal now names the operation, points at workspace_admin.set_active_project, and carries reason: "no_repository_selector". The facade specification's own §8.1 envelope passed options.repo to read.file, which read.file does not accept and the server now refuses — replaced with an envelope whose every field read.file publishes.

3. The guard matched four names, so the same bug had other doors

options.workspace, options.root, options.cwd, options.repo_root, options.worktree and friends were still silently dropped, and on edit.file that is a write to the wrong repository reported as success — the same failure the four-name guard was added to close, reachable by writing the intent a different way. The class now covers the spellings a caller may reasonably reach for. Membership is by name; refusal is still decided per operation by the consumption probe, so an operation that genuinely reads workspace or root keeps receiving it. path and scope stay out — on this surface they name a file or a working-tree scope far more often than a repository.

4. The edit facade's aliases were eating another handler's vocabulary

match/replacement are the edit facade's caller-facing names for the legacy old_string/new_string pair, and the lowering rewrote them on every facade. edit_memory declares replacement itself, so remember.edit_memory's replacement text was renamed into a field that handler does not declare: the memory was edited with no replacement text and the call reported success, while the published schema advertised arguments.replacement. Now only the facade that publishes the pair translates it.

Measured

Every reachable operation × 7 locations (top level + 6 containers) × the whole 342-name facade vocabulary — 378,094 rows — against merged main:

rows
consumed before, refused now (regressions) 0
silently dropped before, refused now 10,353
consumed rows preserved 7,547
newly reaching the handler (item 4) 7

The nine-door edit.file write probe refuses every spelling at every location with the target file unchanged; options.workspace — the door this PR opened with — is now closed.

Tests

Five guards, each mutation-verified to go red when its mechanism is removed: the allocation bound on the selector path, memo invalidation on late registration, the no_repository_selector guidance, refusal of every invented spelling before a write, and remember.edit_memory receiving its own vocabulary. Plus drift pins: the alias predicate against the facade definitions, and the deliberate exclusion of path/scope.

Verification

  • go test -race ./internal/mcp — pass (127 s)
  • golangci-lint run ./internal/mcp/... — 0 issues
  • go vet ./internal/mcp, go build ./cmd/gortex/, git diff --check — pass

Known limit, recorded rather than implied

Fields outside the target-selector class that an operation does not consume are still forwarded and ignored. Refusing those generically needs an enumeration of every server-side reader, and the handler, the response layer and facade middleware each read the normalized arguments: output.format, options.new_user_task and options.fields are each honoured by a reader no handler schema mentions. I built that generic rule, measured it, and it refused working calls in exactly those places — an incomplete enumeration trades a silent-drop bug for a broken feature. The specification now states the limit instead of implying a guarantee that does not hold.

Refs #549

zzet added 2 commits August 15, 2026 09:32
Resolving an operation's published repository selector builds its whole
public capability schema, which re-materialises the immutable operation
table and re-probes every legacy property. That ran on every request
carrying a repository selector — including options.repo, the canonical
one — putting two orders of magnitude more work on the hottest path than
the rest of facade dispatch.

Measured with handleFacade over a stubbed legacy handler (M1 Pro):

  change.detect, no selector      1,488 ns    1,128 B     17 allocs
  change.detect + options.repo   89,707 ns  160,884 B    659 allocs
  read.file     + options.repo  118,789 ns  174,940 B    832 allocs

alloc_space put 62% of it in facadeCanonicalOperationNames ->
facadeOperationSpecs -> addFacadeGroup, plus 16% in regexp compilation.

The answer depends only on the operation spec and the captured legacy
schema, so memoize it on the registry and drop the memo whenever capture
changes the captured set — optional handlers register after NewServer, so
a pre-registration answer must not survive.

  change.detect + options.repo    6,437 ns    2,200 B     27 allocs
  read.file     + options.repo   12,668 ns    2,096 B     33 allocs
Refusing an unconsumable repository selector is right — dropping it
silently answers about the active repository while the caller believes
another one was addressed, and on a write it writes to the wrong place.
But most operations publish no repository selector at all, and those
refusals said only `unknown field "options.repo"`, which reads as a typo
and invites the caller to retry other spellings of a selector that will
never be accepted.

Name the operation, say it accepts no selector, and point at the way to
change scope instead. The refusal now carries reason=no_repository_selector
so a caller can branch on it without parsing prose; operations that do
publish a selector keep naming it in suggested_field.

Also fix the request envelope in the facade specification: its example
passed options.repo to read.file, which read.file does not accept and the
server now refuses. Every field in the replacement is published by
read.file, and the surrounding text states the refusal rule and that
capabilities remains authoritative for which selector an operation takes.
@zzet
zzet merged commit ff36d80 into main Aug 15, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant