Skip to content

fix(scope): scope-first refusal precedence, hidden equals nonexistent on every surface (Spec 105 PR G, FR-010) - #1328

Merged
Dumbris merged 2 commits into
mainfrom
scope-refusal-shapes
Sep 20, 2026
Merged

Dumbris merged 2 commits into
mainfrom
scope-refusal-shapes

Conversation

@Dumbris

@Dumbris Dumbris commented Sep 20, 2026

Copy link
Copy Markdown
Member

Summary

Spec 105 PR G — scope-refusal-shapes (FR-010). Closes tasks T094-T106.

Every fixed MCP surface now answers a hidden server/tool and a
nonexistent one with the same refusal shape, and a tier denial on an
authorized server reaches the handler (insufficient-permission) instead
of being swallowed into a generic "not found" — closing gaps FR010-G1, G2,
G3, G4, G6 and G7 (G5 was closed in a prior PR).

Changes

  • G1: the "no client found" hint's Available servers: list is now
    filtered through the caller's effective scope (profile ∩ token), not the
    raw fleet (mcp.go).
  • G2: describe_tool's toolVisibleToSession checks scope before index
    presence for scoped callers, and its case-correction suggestion is
    attempted for the same reason set — so a hidden document occupying the
    exact (server, tool) pair a caller asked about no longer silently
    swallows the did-you-mean it would otherwise get (mcp_visibility.go,
    mcp_describe_tool.go).
  • G3: the direct-mode catalog gained a canonicalShadow map and
    LookupCanonicalForAuth, so an authorized canonical id resolves at
    request time even when a hidden display-name entry collides with it on
    the same string — resolveDirectDescribeIDIn falls through to that
    shadow resolution, filtered to full visibility (scope + tier +
    callability), not scope alone (mcp_direct_catalog.go,
    mcp_describe_direct.go).
  • G4: the direct surface's case-correction resolver
    (suggestDirectToolID) no longer gives up at the first case-fold match
    that happens to be invisible — it keeps scanning for a later, authorized
    one.
  • G6: at call time, mcp-go's WithToolFilter chain (which re-runs
    unchanged at both tools/list and tools/call) now lets an
    over-tier-but-authorized tool through instead of excluding it into an
    unregistered-name -32602 — the registered handler's own
    scope→tier→callability precedence answers instead. Mechanism: a mutable
    directRequestKindBox installed on every request's ctx by
    mcpAuthMiddleware, written by BeforeListTools/BeforeCallTool hooks,
    letting the filter chain tell a listing from a call-time re-evaluation for
    the first time (mcp_direct_scope.go, mcp_direct_callability.go,
    mcp_routing.go, server.go).
  • G7: the sandbox (jsruntime) and the retrieve surface (call_tool_*)
    each used to run the profile-derived allow-list check and the token's own
    server-scope check independently, answering two different refusal bodies
    depending on which one excluded a server — a server inside the pin but
    outside the token was distinguishable from a nonexistent one. Both are
    now one evaluation, one body, for agent callers (mcp.go,
    internal/jsruntime/runtime.go).

Caller-kind correctness (found by adversarial review, fixed in this PR)

Cross-model review caught two classes of real bug across 4 rounds:

  • Several fixes above initially used authInfo/authCtx != nil where "is
    this actually a scoped/restricted caller" was meant, silently treating a
    profile-scoped administrator (and, separately, a server-edition
    AuthTypeUser caller) as unrestricted. Introduced isAdmin() on
    jsruntime.AuthInfo and isScopeRestrictedCaller() in internal/server,
    and gated every new G1–G7 behavior change on the correct caller-kind
    predicate — preserving SC-005 byte-parity for administrators and closing
    the same scope gaps for AuthTypeUser callers that FR-010 closes for
    agent tokens.
  • The canonical-shadow disambiguation predicate (G3) initially filtered
    candidates by scope alone; a candidate that was scope-authorized but
    callability-locked (pending/changed/disabled) still counted as
    "authorized," making a genuinely reachable candidate look ambiguous
    against it. Fixed to use full visibility
    (directEntryVisibleToSession).

A related instance of the AuthTypeUser pattern was found in
mcp_direct_callability.go's callability-hiding gates and the
aggregated-prompt filters (mcp_direct_scope.go) — deliberately left
unfixed here per the reviewer's own confirmation that the agent-vs-operator
callability split is intentional design, not a scope bug. Flagged as a
separate follow-up task.

Test plan

  • T094–T099 written first (failing), then T100–T104a implementation
  • go build ./..., go vet ./...: clean
  • go test ./internal/server/... ./internal/jsruntime/... (full
    packages, non-race and -race): all green
  • go test -race -tags server across
    serveredition/config/oauth/server/httpapi/storage (CLAUDE.md's -skip
    regex): all green
  • golangci-lint (bare + --build-tags server), gofmt: zero new
    findings
  • Cross-model review: opencode (terra/sol/astra) quota-exhausted,
    confirmed; fell back to codex exec -m gpt-5.6-sol per CLAUDE.md. 4
    rounds, VERDICT: clean (7 real findings total across rounds 1–3, all
    fixed and covered by new regression tests)
  • roadmap.yaml's scope-refusal-shapes status is intentionally left
    untouched — for the maintainer to flip, per this repo's convention for
    these security PRs. Not merged, per instruction.

🤖 Generated with Claude Code

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 20, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 62fb994
Status: ✅  Deploy successful!
Preview URL: https://15a166e1.mcpproxy-docs.pages.dev
Branch Preview URL: https://scope-refusal-shapes.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 86.86131% with 18 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/server/mcp_direct_catalog.go 60.86% 5 Missing and 4 partials ⚠️
internal/server/mcp_direct_scope.go 82.92% 5 Missing and 2 partials ⚠️
internal/server/mcp.go 87.50% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: scope-refusal-shapes

Available Artifacts

  • archive-darwin-amd64 (30 MB)
  • archive-darwin-arm64 (27 MB)
  • archive-linux-amd64 (18 MB)
  • archive-linux-arm64 (16 MB)
  • archive-windows-amd64 (30 MB)
  • archive-windows-arm64 (26 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (24 MB)
  • installer-dmg-darwin-arm64 (22 MB)
  • smart-mcp-proxymcpproxy-goZCNZGY.dockerbuild (0 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 35524053671 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

Dumbris and others added 2 commits September 20, 2026 19:45
… on every surface (Spec 105 PR G, FR-010)

Closes T094-T106. Every fixed MCP surface now answers a hidden server/tool
and a nonexistent one with the same refusal shape, and a tier denial on an
authorized server reaches the handler instead of being swallowed into a
generic "not found" - closing gaps FR010-G1, G2, G3, G4, G6 and G7 (G5 was
closed in a prior PR).

## Changes

- G1: the "no client found" hint's `Available servers:` list is filtered
  through the caller's effective scope (profile ∩ token), not the raw fleet
  (mcp.go).
- G2: describe_tool's toolVisibleToSession checks scope before index
  presence for scoped callers, and its case-correction suggestion is
  attempted for the same reason set - so a hidden document occupying the
  exact (server, tool) pair a caller asked about no longer silently
  swallows the did-you-mean it would otherwise get (mcp_visibility.go,
  mcp_describe_tool.go).
- G3: the direct-mode catalog gained a canonicalShadow map and
  LookupCanonicalForAuth, so an authorized canonical id resolves at request
  time even when a hidden display-name entry collides with it on the same
  string - resolveDirectDescribeIDIn now falls through to that shadow
  resolution, filtered to full visibility (scope + tier + callability), not
  scope alone (mcp_direct_catalog.go, mcp_describe_direct.go).
- G4: the direct surface's case-correction resolver (suggestDirectToolID)
  no longer gives up at the first case-fold match that happens to be
  invisible - it keeps scanning for a later, authorized one.
- G6: at call time, mcp-go's WithToolFilter chain (which re-runs unchanged
  at both tools/list and tools/call) now lets an over-tier-but-authorized
  tool through instead of excluding it into an unregistered-name -32602 -
  the registered handler's own scope->tier->callability precedence answers
  instead. The mechanism is a mutable directRequestKindBox installed on
  every request's ctx by mcpAuthMiddleware and written by
  BeforeListTools/BeforeCallTool hooks, letting the filter chain tell a
  listing from a call-time re-evaluation for the first time
  (mcp_direct_scope.go, mcp_direct_callability.go, mcp_routing.go,
  server.go).
- G7: the sandbox (jsruntime) and the retrieve surface (call_tool_*) each
  used to run the profile-derived allow-list check and the token's own
  server-scope check independently, answering two different refusal bodies
  depending on which one excluded a server - a server inside the pin but
  outside the token was distinguishable from a nonexistent one. Both are
  now one evaluation, one body, for agent callers (mcp.go,
  internal/jsruntime/runtime.go).

## Caller-kind correctness (found by adversarial review, fixed in this PR)

Cross-model review caught two classes of real bug across 4 rounds:

- Several fixes above initially used `authInfo/authCtx != nil` where "is
  this actually a scoped/restricted caller" was meant, silently treating a
  profile-scoped ADMINISTRATOR (and, separately, a server-edition
  AuthTypeUser caller) as unrestricted. Introduced isAdmin() on
  jsruntime.AuthInfo and isScopeRestrictedCaller() on
  auth.AuthContext-consuming code in internal/server, and gated every new
  G1-G7 behavior change on the correct caller-kind predicate, preserving
  SC-005 byte-parity for administrators and closing the same scope gaps for
  AuthTypeUser callers that FR-010 closes for agent tokens.
- The canonical-shadow disambiguation predicate (G3) initially filtered
  candidates by scope alone; a candidate that was scope-authorized but
  callability-locked (pending/changed/disabled) still counted as
  "authorized," making a genuinely reachable candidate look ambiguous
  against it. Fixed to use full visibility (directEntryVisibleToSession).

A related instance of the AuthTypeUser pattern was found in
internal/server/mcp_direct_callability.go's callability-hiding gates and
the aggregated-prompt filters (internal/server/mcp_direct_scope.go) -
deliberately left unfixed here per the reviewer's own confirmation that the
agent-vs-operator callability split is intentional design, not a scope bug,
and flagged as a separate follow-up.

## Testing

T094-T099 written first (failing), then T100-T104a implementation:
mcp_auth_scope_test.go, mcp_describe_tool_scope_test.go (new),
mcp_describe_direct_test.go, mcp_direct_scope_test.go,
mcp_code_execution_scope_test.go, plus jsruntime/runtime_test.go and
scope_cache_fixtures_test.go (one inverted pinned-reversal assertion).

- go build ./..., go vet ./...: clean
- go test ./internal/server/... ./internal/jsruntime/... (full packages,
  non-race and -race): all green
- go test -race -tags server (serveredition/config/oauth/server/httpapi/storage,
  CLAUDE.md's -skip regex): all green
- golangci-lint (bare + --build-tags server), gofmt: zero new findings
- Cross-model review: opencode (terra/sol/astra) quota-exhausted, confirmed;
  fell back to codex exec -m gpt-5.6-sol per CLAUDE.md. 4 rounds, VERDICT:
  clean (7 real findings total across rounds 1-3, all fixed and covered by
  new regression tests).

ROADMAP.md regenerated (scripts/gen-roadmap.py) to reflect the tasks.md
checkbox updates; roadmap.yaml's scope-refusal-shapes status is left
untouched for the maintainer, per this repo's convention for these security
PRs.

Related #105

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nary noise (Spec 105 PR G follow-up)

CI's Server Edition job flagged TestProfileIndex_SelectableAllocatesNothing
with 13 allocations for "scoped, absent slug" over the 4096-server fleet
(#1328), but the identical commit's other CI run passed with zero
everywhere, and running the full internal/server suite locally under
-race -tags server never reproduces it. profileIndex.selectable/reach
(profile_tool.go) are unchanged by PR G and remain allocation-free in
isolation.

testing.AllocsPerRun counts process-wide mallocs, so a goroutine still
winding down from an earlier test in this package's shared test binary
(an SSE/HTTP client closing against an already-stopped httptest server,
a runtime fixture's shutdown) inflates whichever case's measurement
window it happens to overlap. This is the exact, already-diagnosed
failure mode documented on the neighboring
TestSelectableProfileNames_PinOutcomesDoSameWork, which takes the
minimum AllocsPerRun over several samples per case because noise only
ever adds allocations. TestProfileIndex_SelectableAllocatesNothing
predates that hardening and took a single sample, making it the one
allocation test in this file still vulnerable to the same known
scheduler noise.

Apply the identical minimum-of-7-samples pattern here. The assertion
stays require.Zero — this does not widen the allocation budget, it
only filters transient noise from unrelated goroutines the way its
sibling test already does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Dumbris
Dumbris force-pushed the scope-refusal-shapes branch from f0fbf3a to 62fb994 Compare September 20, 2026 16:52
@Dumbris
Dumbris merged commit 75cfc27 into main Sep 20, 2026
52 of 53 checks passed
@Dumbris
Dumbris deleted the scope-refusal-shapes branch September 20, 2026 18:10
Dumbris added a commit that referenced this pull request Sep 20, 2026
Spec 105 PR G (#1328) merged; roadmap.yaml still read status: todo.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Dumbris added a commit that referenced this pull request Sep 21, 2026
…ng holds (Spec 105 PR H1) (#1332)

Spec 105 PR H1 — `scope-regression-suite` part 2 (FR-011/013/014, SC-007,
gaps FR01x-G4..G7). Closes tasks T107-T116. This is the FINAL phase of the
nine-PR agent-scope-hardening epic (A, B, D, E, H0, C, F, G already merged).

## Changes

- **Two-fixture differential oracle** (`internal/server/scope_differential_test.go`,
  new): `newScopeFixture`/`runScopeScenario`/`normalizeScopeResponse` per
  contracts/differential-oracle.md, plus `TestScopeCoverage_EveryUserStoryScenario`
  registering every US1.1-1.8/US2.1-2.6/US3.1-3.4 scenario by id — some
  directly via the general three-server harness, most by re-running each
  prior PR's own dedicated differential fixture (plan.md: "H1 re-registers
  them by User Story id").
- **Pinned-reversal grep guard** (`internal/server/scope_pinned_reversal_guard_test.go`,
  new): `TestScopePinnedReversalsStayInverted` proves none of the four
  pre-105 insecure assertions gap-map.md's FR01x-G7 entry names have crept
  back to their original form, traced to their exact pre/post-105 diffs.
- **Retained-effect fixtures** (`internal/server/scope_retained_effects_test.go`,
  new): real reproductions of 5 of 7 SC-001-excluded effects (shared-limiter
  contention, cross-server scan admission via the actual shadowing.cross_server
  impersonation-clone mechanism, prompt-name collision, global prompt cap,
  direct display-name collision), 1 via a companion package
  (`internal/upstream/manager_prompts_deadline_test.go`, new — shared vs
  independent prompt-refresh deadline, two discriminating regimes), 1 cited
  cross-package (log rotation).
- **HTTP credential matrix** (`internal/server/scope_http_matrix_test.go`,
  new): real minted tokens over real loopback HTTP through mcpAuthMiddleware,
  covering every FR-014 applicability-matrix row at least once plus a
  content-level (raw wire-text) non-disclosure proof for retrieve_tools.
- **Latency harness finalized** (`internal/server/scope_latency_test.go`):
  all four FR-011 operations (retrieve_tools, read_cache, prompts/list,
  tools/list) with real workloads, plus `.github/workflows/scope-latency.yml`
  (new) and `cmd/scope-latency-compare` (new, stdlib-only) for the
  merge-base p95 regression gate.
- **Bug fix found via this suite**: `internal/logs.ReadUpstreamServerLogTail`/
  `ReadUpstreamServerLogTailAttributed` dereferenced a nil `*config.LogConfig`
  — fixed with a TDD red/green pair (`internal/logs/nil_log_config_test.go`).
- Docs and roadmap sync: `docs/features/agent-tokens.md` invariant wording
  finalized; `roadmap.yaml`'s `scope-refusal-shapes` task (stale `todo`
  despite PR G #1328 already merged) flipped to `done`. `scope-regression-suite`
  and the epic-level status are deliberately left for the maintainer to flip
  on merge.

## Testing

- `go build`/`go vet` (bare + `-tags server`): clean
- Full `-race` suite across internal/server, internal/upstream,
  internal/cache, internal/logs, cmd/scope-latency-compare: green
- `golangci-lint --new-from-rev=origin/main` (bare + `--build-tags server`):
  0 new issues
- `./scripts/test-api-e2e.sh`: 70/70 passed
- Cross-model review: opencode terra/sol/astra confirmed quota-exhausted;
  fell back to `codex exec --model gpt-5.6-sol`. 4 rounds, every finding
  verified genuine before fixing (non-vacuous confirmed by temporarily
  reverting the underlying guard and observing red). `VERDICT: clean`
  (round 4) — full history in tasks.md T116.

Related #1223 #1224 #1225 #1226 #1227 #1279 #1282 #1283 #1284 #1285 #1325 #1326 #1328
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.

2 participants