fix(mcp): fail closed when the tool scope gate cannot reach its auth config - #716
Open
vishal-bala wants to merge 2 commits into
Open
fix(mcp): fail closed when the tool scope gate cannot reach its auth config#716vishal-bala wants to merge 2 commits into
vishal-bala wants to merge 2 commits into
Conversation
…config Four tool wrappers carried an identical three-line prologue that read auth_config off the server, pulled a scope name from it, and passed that to ensure_tool_scope. Every step tolerated a miss, so renaming the server's auth_config attribute resolved a None scope and made the gate return early on every tool at once, silently. Replace the prologue with ensure_read_scope/ensure_write_scope, which resolve the scope field inside auth.py via an undefaulted getattr, and make ensure_tool_scope raise when auth is enabled but its config is unreachable. The tokenless-request exit is unchanged: an authenticated HTTP transport rejects those before a tool body runs.
vishal-bala
added a commit
that referenced
this pull request
Sep 9, 2026
…ache (#725) ## Motivation The LangCache integration suite fails semi-randomly in the Service Tests job, on pull requests that touch nothing related to LangCache, and a different test fails each time. A reproducible defect fails the same test every run, so the pattern itself points at a shared resource, not at any one branch: #716 saw `test_store_and_check_async` fail on `assert []`, its stored entry gone; #718 saw two different tests fail, and a third on re-run. Two things combine to cause it. `TestLangCacheSemanticCacheIntegrationWithAttributes` flushed the entire managed cache, through `delete()`, `clear()` and `aclear()`, and the suite runs under `pytest -n auto`, so a flush on one xdist worker wiped entries another worker had stored moments earlier. The fixtures then bind to a single `cache_id` from repo secrets, which every CI run reaches concurrently: pull requests, fork pull requests, pushes to main, and the nightly cron. Runs on separate branches therefore flushed each other. ## Changes ### Whole-cache flushes are removed, and blocked from returning `test_delete_and_clear_alias` is deleted and the trailing `aclear()` is removed from `test_async_delete_variants`. An autouse fixture patches `delete`, `adelete`, `clear` and `aclear` to fail the test, so the rule is now mechanical, not a convention recorded in a docstring. This trades away live coverage of the flush endpoint, which is a constraint and not a preference: no cache shared with other runs can safely be flushed, and a dedicated flushable `cache_id` would collide identically once two runs used it. The wrappers themselves are four one-line calls into the SDK and keep their mocked unit coverage; the flush HTTP path is deliberately untested. ### Every write is scoped and expiring A per-test `scope` token, from `uuid4().hex[:12]`, is threaded into every prompt, response and attribute value, so no test can observe or delete another's entries. Every entry carries a 60-second TTL, which lets the shared caches drain now that nothing flushes them. The TTL is passed per call, not set once on the fixtures, because `store()` ignores a constructor TTL entirely. That defect is filed separately, and the module docstring records it so the repetition is not tidied away into the fixtures. ### The TTL-expiry tests no longer depend on result ranking `num_results` is a client-side slice: `_build_search_kwargs` never sends `max_results`, and the installed SDK documents its own default as one result. Raising it therefore buys no headroom against a concurrent run's semantically identical prompt competing for that single slot. Filtering on a scope-unique attribute does, because the service can only return entries matching it. ```python # Before: reads back whichever single entry the service ranks first. hits = langcache_with_attrs.check(prompt=prompt, num_results=5) # After: the result set is provably this test's own. hits = langcache_with_attrs.check(prompt=prompt, attributes=metadata) ``` Both tests also move from a two-second TTL to five, with the sleep from three seconds to six. Two seconds had to cover a store round trip and a search round trip against a shared managed service, so the assertion that the entry exists was racing its own TTL. Secondary changes: - Delete-by-attribute assertions tighten from `>= 1` to the number of entries actually stored, which scope-unique attributes make knowable. - Scope tokens join punctuation-heavy attribute values with `_` rather than `-`, which survives percent-encoding and is a RediSearch text separator. - The unit-test SDK mock is autospecced, so a renamed method or a changed signature fails there instead of being auto-vivified. - Two unit tests were indented into another test and so were never collected. Dedenting them adds both to the run, and the file now collects 26, having also lost a name-checking test that autospec subsumes. - CONTRIBUTING.md gains a paragraph on namespacing writes to shared, stateful external services. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Test-only and documentation changes; no production LangCache client behavior is modified. > > **Overview** > Stops flaky **LangCache** integration failures caused by many workers and CI jobs sharing the same managed `cache_id`. > > **Whole-cache flush is removed from live tests** and enforced with an autouse fixture that makes `delete`/`clear` (sync and async) fail if called. Integration cases that flushed the cache are dropped; flush behavior stays covered only in **unit** mocks (now **autospecced** against the real SDK). Two previously nested unit tests are dedented so they actually run. > > **Writes are isolated**: per-test `scope` tokens in prompts/responses/attributes, **60s TTL** on routine stores, and assertions that look for *your* scoped row in `hits` (not `hits[0]`). TTL-expiry tests use **scoped attribute filters**, longer TTL/sleep, and tighter delete-by-attribute counts. > > **CONTRIBUTING.md** adds guidance to namespace data and never flush shared external services, pointing at the integration module docstring. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 68d378d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
vishal-bala
marked this pull request as ready for review
September 10, 2026 07:38
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
The MCP scope gate can be disabled by a rename, silently. Four tool wrappers each carry an identical three-line prologue that reads
auth_configoff the server, pulls a scope name from it, and hands that toensure_tool_scope. Every step of that chain tolerates a miss:getattr(server, "auth_config", None)yieldsNone, the conditional expression then yields aNonescope, andensure_tool_scopereturns early on aNonescope. So renaming the server'sauth_configattribute stops read and write scopes being enforced on every tool at once, with no error, no warning and no failing test.The prologue is also redundant.
ensure_tool_scopeperforms the samegetattrlookup itself, so the caller's copy exists only to name which scope field applies.Changes
The scope name is resolved inside
auth.pyensure_read_scope(server)andensure_write_scope(server)replace the prologue at all four call sites. Each resolves its own scope field from the server and delegates toensure_tool_scope, so a wrapper no longer needs to know that a read tool readsread_scope.becomes
The field lookup is a bare
getattr(auth_config, attribute)with no default, so renaming a field onMCPAuthConfigraisesAttributeErrorrather than resolving toNoneand turning the gate into a no-op.An unreachable auth config now fails closed
ensure_tool_scopechecks_auth_enabledfirst and, when auth is wired, treats a missingauth_configas an internal inconsistency rather than a reason to skip the check:The tokenless-request exit is unchanged and still returns early. That one is correct: an authenticated HTTP transport rejects tokenless requests before a tool body runs, so a missing token means stdio, where there is no scope to check.
Secondary changes
tools/search.py,tools/upsert.py,tools/list_indexes.py,tools/profiles.py. No wrapper reads anauth_configattribute any more.tests/unit/test_mcp/test_auth_scope.pycovering scope resolution, the auth-disabled no-op, the unreachable-config failure and the renamed-field failure.Notes
Both new guards were mutation-checked: reverting the fail-closed raise alone fails exactly one test, and weakening the bare
getattrto a defaulted one fails exactly one other. Neither guard is decorative.ensure_tool_scopekeeps its signature and stays public, because it is the right entry point for a caller that has a scope name in hand rather than a server to resolve one from. The new helpers are the preferred call-site form and its docstring says so.Next Steps
Note
Medium Risk
Changes authentication gating behavior for MCP tools when auth is enabled; misconfiguration now blocks tools rather than running ungated, which is safer but could surface new errors in production if wiring regresses.
Overview
Hardens MCP tool scope enforcement so auth cannot be bypassed silently when server wiring is wrong, and centralizes read/write scope checks at the tool entry points.
When auth is enabled but
auth_configis missing on the server,ensure_tool_scopenow raises an internal error and refuses to run the tool instead of treating a missing config like “no scope configured.” Scope names are resolved via newensure_read_scope/ensure_write_scopehelpers (using strict attribute lookup onMCPAuthConfig), replacing duplicated three-line prologues in list-indexes, search, profiles, and upsert-records. Unit tests cover helper resolution, fail-closed unreachable config, and loud failure when config field names are renamed.Reviewed by Cursor Bugbot for commit 586d9d4. Bugbot is set up for automated code reviews on this repo. Configure here.