Skip to content

fix(scope): direct-surface definitions authorized by producing identity at every seam (Spec 105 PR F, FR-008) - #1326

Merged
Dumbris merged 3 commits into
mainfrom
105-f-direct-publication
Sep 20, 2026
Merged

Dumbris merged 3 commits into
mainfrom
105-f-direct-publication

Conversation

@Dumbris

@Dumbris Dumbris commented Sep 20, 2026

Copy link
Copy Markdown
Member

Summary

Spec 105 PR F — scope-direct-publication (FR-008, gaps FR008-G1…G7), tasks T080-T093.

Every direct-mode (/mcp/all) tools/list and tools/call now authorizes a rendered tool against the identity of the same publication that produced it, never against whatever directCatalog snapshot happens to be live when the scope/callability filter runs. Before this fix, both filters resolved a rendered mcp.Tool's owning server by looking its display name up in p.loadDirectCatalog() at filter-evaluation time — unsound during a publication rebuild, since SetTools lands the new registry before the catalog pointer swaps. Sharpest in an "origin flip": display name a__b__c can mean (server a, tool b__c) in one generation and (server a__b, tool c) in the next; a token scoped to old-only a could see the new owner's definition still attributed to the old one, and a subsequent dispatch (bound to the new registration) would be refused with a message naming the new server — a disclosure.

  • renderDirectTools stamps each rendered tool with a private directToolStamp{owner, rawName, tier} carrying the identity of the catalog entry that produced it and its dispatch handler. filterDirectModeToolsForAuth / filterDirectToolsForAgentCallability read the stamp first; only an unstamped tool (a built-in, or a bare mcp.Tool a test constructs directly) falls back to the pre-105 catalog/builtin resolution.
  • A terminal WithToolFilter(stripDirectToolStampFilter), registered last, removes the stamp before any tool reaches a client, for every caller including administrators — the admin early-return that used to skip the scope filter's per-tool loop is gone, so "no identity → withheld" applies to everyone (SC-005 exception).
  • buildDirectCatalog refuses to admit a tool with an empty raw name at all (FR008-G7); the "no __ separator → built-in" structural inference is removed, replaced by an explicit builtinDirectToolNames set populated from the surface's own constructors (FR008-G2, positive identification).
  • makeDirectModeHandler's two scope-refusal branches no longer name the server they denied (D12) — defense-in-depth for direct handler invocation that bypasses mcp-go's call-time filter re-evaluation, or for a narrow live profile/config race (see review round 2 below).
  • Prompt aggregation drops an empty-raw-name prompt and withholds an unstamped prompt for every caller (FR-006 consumer).

Test plan

  • T080-T086a written first (new files: mcp_direct_publication_identity_test.go, mcp_direct_underscore_test.go, mcp_direct_protocol_test.go) covering the origin-flip fixture (both serialization modes, full definition assertions), reverse-flip/plain-addition visibility, deferred-mode tier-change seam, mcp-go's own call-time filter refusal envelope (byte-equal to an unregistered name, no owner metadata), the __a server fixture, and the prompt-side empty-name/unstamped cases
  • Inverted pinned tests, including several discovered red by the full suite run beyond the literal task enumeration (mcp_routing_test.go, profile_pin_enforcement_test.go, mcp_prompt_scope_test.go) — documented in the commit message
  • go build ./..., go vet ./... clean
  • go test ./internal/server/... (full package, no skip beyond the standard CI regex): ok
  • go test -race ./internal/server/...: ok
  • golangci-lint (bare + --build-tags server): clean on every touched file
  • Frozen goldens (*.golden.json, toolslist_goldens/) unregenerated and byte-identical
  • Cross-model review: opencode quota exhausted on both terra and sol (confirmed, not assumed); fell back to codex exec -m gpt-5.6-sol --sandbox read-only per the CLAUDE.md ladder — 3 rounds, VERDICT: clean

Review round 2 follow-up (adversarial re-review, 2026-09-20)

A fresh codex exec --model gpt-5.6-sol pass found two real issues in the original PR, both fixed here, plus test-hardening gaps:

  1. Block-reason precedence mismatch (internal/server/mcp_direct_callability.go): directBlockReasonKey (telemetry) and directToolCallabilityResult (the response body) classified a tool that is BOTH config-denied AND pending/changed-approval differently — the response said config-denied, the counted reason said pending/changed. Both now share one classifyDirectRefusal helper with a single precedence order (quarantine → approval-lock → config-denied/generic), matching the order every other dispatch path already uses (toolGate.lockStatus in mcp.go's handleCallToolVariant/handleCallTool).

  2. D12 refusal-shape correction (internal/server/mcp_routing.go): makeDirectModeHandler's own defense-in-depth scope refusal (directScopeRefusalError, formerly directScopeRefusalMessage) used to build a mcp.NewToolResultError — a successful JSON-RPC result with isError:true — for a case mcp-go's own call-time filter refuses with a protocol-level -32602 error. That was a different envelope KIND, not just different wording, and the original PR's "byte-equal to an unregistered name" claim (below) did not hold for this branch, only for mcp-go's own filter-level refusal.

    Corrected claim: the handler-level branch now returns the handler's own error (fmt.Errorf("tool '%s' not found: %w", name, mcpserver.ErrToolNotFound)), which:

    • Is now the same envelope kind as mcp-go's filter refusal (a protocol-level error, not a tool-result).
    • Has byte-identical text to what mcp-go's filter would produce for the same name ("tool '<name>' not found: tool not found").
    • Is not byte-identical in the numeric JSON-RPC error code: mcp-go always maps a handler-returned error to INTERNAL_ERROR (-32603), a code a ToolHandlerFunc cannot override, while the filter path answers INVALID_PARAMS (-32602) for the identical case. This is a structural limit of mcp-go v1.0.0's dispatch (server.go handleToolCall), not something this PR's code controls, and mirrors an already-accepted, already-documented residual on the prompt side (authorizeAggregatedPromptServer's doc comment in mcp_direct_scope.go). No owner name or scope reason is ever disclosed either way — the residual is the numeric code alone, observable only by a caller that can time a request inside the narrow live-profile/config race this defense-in-depth branch exists for.
    • TestDirectProtocol_StampNeverOnWire_FilterReEvaluatedAtCallTime now asserts full envelope equality (code + message + exact key set) between a hidden-but-registered call and a genuinely unregistered one, not just error code + substring.
  3. Test hardening (chunk D/E findings, all closed):

    • Added TestReadDirectToolStamp_RejectsForgedValue / _AcceptsGenuineStamp (mcp_direct_scope_test.go) — the tool-side forged-stamp regression the prompt side already had.
    • TestUnstampedPrompt_WithheldFromListAndGet_ForAdminAndAgent now registers a properly-stamped sibling prompt as a positive control and asserts it IS listed and IS retrievable, so the test can no longer pass vacuously if prompt list/get were broken outright.
    • TestDirectModeHonorsTokenProfilePin's non-disclosure assertion switched from a loose Contains to an exact string match against the corrected refusal text, closing the gap where a message that appended extra scope-reason wording after the expected prefix would have still passed.

Not merging — leaving for review per this repo's convention for the Spec 105 security PRs.

🤖 Generated with Claude Code

…ty at every seam (Spec 105 PR F, FR-008)

Closes T080-T093. Every direct-mode (/mcp/all) tools/list and tools/call
now authorizes a rendered tool against the identity of the SAME
publication that produced it, never against whatever directCatalog
snapshot happens to be live when the scope/callability filter runs -
closing the publication-skew window an origin flip could exploit (e.g.
display name "a__b__c" meaning server "a" tool "b__c" in one generation
and server "a__b" tool "c" in the next).

## Changes

- renderDirectTools now stamps each rendered mcp.Tool with a private
  directToolStamp{owner, rawName, tier} carrying the identity of the
  catalog entry that produced it and its dispatch handler
  (mcp_direct_scope.go, mcp_routing.go). filterDirectModeToolsForAuth and
  filterDirectToolsForAgentCallability read the stamp first and authorize
  against it directly; only an unstamped tool (a built-in, or a bare
  mcp.Tool a test constructs directly) falls back to the pre-105
  catalog/builtin resolution unchanged.
- A terminal WithToolFilter(stripDirectToolStampFilter), registered last,
  removes the stamp before any tool reaches a client, for every caller
  including administrators - the admin early-return that used to skip the
  scope filter's per-tool loop entirely is removed, so the "no identity ->
  withheld" rule applies to everyone (SC-005 exception), not only scoped
  callers.
- buildDirectCatalog refuses to admit a tool with an empty raw name at
  all (FR008-G7); resolveDirectTool's "no `__` separator -> built-in"
  structural inference is removed, replaced by the explicit
  builtinDirectToolNames set populated from the surface's own
  constructors (FR008-G2, positive identification).
- makeDirectModeHandler's two scope-refusal branches no longer name the
  server they denied (D12); the message now echoes only the
  caller-supplied display name, matching what mcp-go's own call-time
  filter re-evaluation already answers for an unregistered name before
  the handler ever runs in real dispatch - this branch is defense in
  depth for direct handler invocation that bypasses that re-evaluation.
- buildAggregatedServerPrompts drops an upstream prompt with an empty raw
  name; filterAggregatedPromptsForAuth withholds an unstamped prompt for
  every caller, not only when scope enforcement was already active.

## Tests

T080-T086a written first (mcp_direct_publication_identity_test.go,
mcp_direct_underscore_test.go, mcp_direct_protocol_test.go - all new),
covering the origin-flip fixture in both serialization modes with full
definition assertions, reverse-flip/plain-addition visibility, the
tier-change seam in deferred mode, the protocol-level -32602 envelope
parity proof (byte-equal to an unregistered name, no owner metadata), the
`__a` server steady-state and seam fixtures, and the prompt-side empty-name
and unstamped-withholding cases.

Inverted the pinned tests that predated this fix, including several not
explicitly enumerated by the task list but discovered red by the full
suite run: mcp_direct_skew_test.go (added-name, origin-flip,
annotations-only-change scenarios), mcp_direct_catalog_test.go /
mcp_direct_catalog_publish_test.go (empty-raw-name admission, structural
builtin inference), mcp_routing_test.go (TestDirectModeHandler_
ServerAccessDenied's disclosing message; TestFilterDirectModeToolsForAuth_
KeepsNonDirectTools renamed to _DropsNonBuiltinSeparatorlessNames),
profile_pin_enforcement_test.go, and mcp_prompt_scope_test.go
(TestFilterAggregatedPromptsForAuth_UnstampedFailsClosed's
unscoped-caller assertion).

direct_full_prefeature.golden.json and every toolslist golden are
unregenerated and byte-identical.

## Verification

- go build ./..., go vet ./... clean
- go test ./internal/server/... (full package): ok, 278s
- go test -race ./internal/server/...: ok, 383s
- golangci-lint (bare + --build-tags server): clean on every touched file
  (pre-existing issues elsewhere in the repo are unrelated to this diff)
- Cross-model review: opencode quota exhausted on both terra and sol
  (confirmed); fell back to codex exec -m gpt-5.6-sol per the CLAUDE.md
  ladder. 3 rounds, VERDICT: clean.
- ROADMAP.md regenerated (scripts/gen-roadmap.py) to reflect the tasks.md
  checkbox updates and pass the pre-commit roadmap-verify hook; it was
  already drifted from an unrelated prior change, so this also fixes
  that drift.

Related #105

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@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: d61a1f5
Status: ✅  Deploy successful!
Preview URL: https://339e4fe5.mcpproxy-docs.pages.dev
Branch Preview URL: https://105-f-direct-publication.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

codecov-commenter commented Sep 20, 2026

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 82.90598% with 20 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/server/mcp_direct_scope.go 85.71% 5 Missing and 3 partials ⚠️
internal/server/mcp_direct_callability.go 85.36% 5 Missing and 1 partial ⚠️
internal/server/mcp_direct_catalog.go 40.00% 2 Missing and 1 partial ⚠️
internal/server/mcp_routing.go 80.00% 2 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: 105-f-direct-publication

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-goLXEZ1F.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 35512156453 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

Dumbris and others added 2 commits September 20, 2026 14:42
…sal shape (Spec 105 PR F follow-up)

Adversarial re-review of PR #1326 (codex exec, gpt-5.6-sol) found two real
issues in the direct-publication scope hardening, both fixed here, plus
test-hardening gaps the same pass flagged:

1. directBlockReasonKey (telemetry) and directToolCallabilityResult (the
   response body) disagreed on which gate "won" when a tool was BOTH
   config-denied and pending/changed-approval at once. Both now share one
   classifyDirectRefusal helper with a single precedence order (quarantine
   -> approval-lock -> config-denied/generic), matching the order every
   other dispatch path already uses (toolGate.lockStatus in mcp.go's
   handleCallToolVariant/handleCallTool).

2. makeDirectModeHandler's own D12 defense-in-depth scope refusal built a
   mcp.NewToolResultError (a successful result with isError:true) for a
   case mcp-go's own call-time filter refuses with a protocol-level -32602
   error — a different envelope KIND, not just different wording. It now
   returns the handler's own error, wrapping mcp-go's ErrToolNotFound, so
   the envelope kind and text converge (the JSON-RPC error CODE cannot be
   matched from a ToolHandlerFunc — mcp-go always maps a handler error to
   INTERNAL_ERROR — documented as an accepted residual, mirroring the
   already-accepted prompt-side twin in authorizeAggregatedPromptServer).

3. Test hardening: a forged-stamp regression test for directToolStamp (the
   tool-side analogue of the existing prompt-side test), a positive control
   on the unstamped-prompt withholding test, an exact-match (not Contains)
   assertion on the profile-pin non-disclosure check, and full JSON-RPC
   envelope equality (not just error code + substring) in
   TestDirectProtocol_StampNeverOnWire_FilterReEvaluatedAtCallTime.

go build/vet, race suite (internal/server, internal/server/tokens,
internal/serveredition/..., internal/config, internal/oauth,
internal/httpapi, internal/storage), and golangci-lint (bare +
--build-tags server) all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Dumbris
Dumbris merged commit 2751d9e into main Sep 20, 2026
63 of 64 checks passed
@Dumbris
Dumbris deleted the 105-f-direct-publication branch September 20, 2026 14:43
Dumbris added a commit that referenced this pull request Sep 20, 2026
…tion to done (#1327)

Spec 105 PR C (#1325) and PR F (#1326) both merged; roadmap.yaml still
read status: todo for both since the implementing agents intentionally
left them unflipped pending merge review, per this repo's "no merge
without instruction" convention for security PRs.

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