feat(connectors): let team members edit a team-shared MCP connector - #2095
feat(connectors): let team members edit a team-shared MCP connector#2095AlexLiu190625 wants to merge 11 commits into
Conversation
GET and PUT /api/mcp/servers/{server_id} resolved the caller through a
personal UserMCPServer row alone and answered 404 without one, so a member
of a team that shares an MCP connector could see it in every listing and
attach it to an agent, but could neither open its configuration form nor
save a change to it. Only the connector's original owner could.
Both routes now resolve the caller from either source -- the personal link
row, or the caller's own team access verdict for the connector -- through
one helper, and the edit branch of _check_mcp_permission falls back to a
granting verdict when the caller owns no row. A caller reaching the route
through the verdict alone is represented by the existing _TeamOwnedUserMCP
stand-in, so nothing on an authorization path creates a row for them.
Three refusals keep that widening honest:
- A payload carrying user_env or is_active from a caller with no personal
row is 400, not a silently dropped field reported as 200: those two
fields live on the association row, and there is none to hold them.
- A stand-in whose verdict denies edit is 403 outright. Every payload such
a caller can send was already a no-op, and reporting 200 for it claimed a
write that never happened.
- A platform catalog application's shared row is never team-editable.
xagent provisions one MCPServer row per catalog app and every user of
that app attaches to it, so a verdict granting edit on it is downgraded
to read-only by _team_access_for_shared_row -- otherwise one team could
rewrite command/args/url/env/auth for every user of that app, including
users in no team at all. The row's own creator is unaffected: is_owner
decides the edit branch before any verdict is read.
The verdict is resolved once more after the definition row's lock is taken,
alongside the link-row and admin re-reads already there, and the write is
refused with 403 and zero side effects if the answer no longer grants what
the pre-lock answer granted. This narrows the revocation window rather than
closing it: the revoking side writes its own tables, which this lock does
not cover.
Every listing and response that reports can_edit_global now reports it from
the same verdict the write gate would read, so the field cannot advertise
an edit the PUT would refuse. The list endpoints resolve their verdicts in
one batched hook call, and the catalog key set the downgrade needs is built
at most once per request and only when some verdict actually grants edit.
toggle_mcp_server becomes a sync def: it resolves a verdict answered by an
installed hook that may issue database work, which a coroutine route would
run on the event loop thread.
test_mcp_team_connector_edit.py pins the whole widening on SQLite: the verdict fallback in _check_mcp_permission, GET/PUT resolving a caller with no personal row, the 400 for the two per-user fields, the 403 for a stand-in whose verdict denies edit, the post-lock re-resolution and its refusal, the hook-call budget each population pays, the catalog-row downgrade across every catalog shape (key-based, mcp_oauth, renamed builtin-oauth, a self-built row squatting a catalog id), and the query budget of the downgrade's catalog-key lookup across two population sizes. test_connector_hook_session_fault_postgresql.py needs a real server: a failed raw statement aborts the transaction on PostgreSQL and not on SQLite, so only there can it prove the access seam restores the shared session before returning. It is wired into the migrations workflow's path filter and given its own execution step alongside the two existing Postgres-only lock suites.
… event loop The reported can_edit_global and can_configure fields are emitted from several call sites; proving they agree with what this module's gates actually enforce needs a matrix over caller populations rather than one assertion. The suite also pins the standalone shape with no hook installed, the per-row degradation of a failing hook, session recovery after a hook poisons the shared session, and the query cost of the catalog check. The coroutine guard discovers the functions that can reach an installed hook by transitive reachability from this module's own imports, asserts the discovered set equals a written-out literal so it cannot pass by finding nothing, and asserts every member is a plain def. delete_mcp_server is exempt and named, and the exemption is only honoured for a function whose awaits are not merely the seam call.
There was a problem hiding this comment.
Code Review
This pull request implements team-based access control and permission fallbacks for MCP servers and Custom APIs, allowing team members to configure and view shared connectors. Key changes include batch resolution of team access verdicts to optimize performance, re-validation of permissions under database locks to prevent race conditions, and converting the toggle endpoint to a synchronous function to avoid blocking the event loop. The review feedback recommends preserving debug tracebacks by logging exceptions with exc_info=True and using f-strings for consistency across several warning logs, as well as improving type safety by casting user_mcp to UserMCPServer instead of Any.
Five call sites catch ConnectorRuntimeError and degrade a reported can_edit_global/can_configure verdict to False rather than fail the request: reading a single server, listing local connectors, listing servers, the connect route, and the toggle route. In every one of these, the warning log is the only record that a degradation happened at all -- the response itself stays 200 -- so a message that says only "resolution failed" with no code gives an operator nothing to search on when several rows in a listing degrade for different reasons. ConnectorRuntimeError.__str__ returns "<code>: <safe message>", so passing the exception object itself as one %s argument carries both without any new formatting work. exc_info is deliberately not added here: the seam that raises ConnectorRuntimeError already logs the full traceback of whatever underlying error it wrapped before re-raising as this typed exception, so a second traceback at each degrade site would duplicate that on the common path, while these five warnings exist specifically for the path that logs no traceback of its own and needs the code instead. Grepping every `except ConnectorRuntimeError` in this module turns up eight arms. The three not touched here already bind `as exc` and re-raise it wrapped in an HTTPException, so the code already reaches the caller through the HTTP error body; those three are left as they were. Two incidental cleanups in the same area: a resolution call inside _resolve_mcp_server_for_request re-int()'d a parameter the function signature already declares as `int`, and the cast(Any, user_mcp).env assignment now carries a comment explaining why the cast is needed -- what mypy rejects is assigning through a Column[...]-typed attribute, so a concrete cast fails there with 'expression has type "dict[Any, Any] | None", variable has type "Column[Any]"' -- matching the four other call sites in this module that take the same escape hatch.
…es off the event loop
Adds one behavioural test to TestListMcpServersPerRowDegradation that
installs a hook raising a typed ConnectorRuntimeError, calls
get_mcp_servers, and asserts the resulting warning log line contains
the exception's code ("team_directory_unreachable"), not just the
generic "resolution failed" text. This pins the degrade arms' new
behavior of naming the failure they degraded on.
A behavioural test can only exercise one arm per case, so a source
level pin covers the rest: it walks the module's AST, collects every
`except ConnectorRuntimeError` arm that answers with a logger.warning
instead of re-raising, and asserts each one formats the caught
exception into that call. The arm count is itself asserted, so the
enumeration cannot pass by finding nothing, and an arm added later has
to come through this test before it can skip the invariant. It follows
the reachability-based pin already in this file for the "no function
that reaches the connector seam is a coroutine" invariant.
Also converts eight `async def` test functions in these two files to
plain `def`. Each one's body has no await, async with, or async for --
pytest-asyncio's auto mode was still collecting and running them
inside a live event loop regardless, which means the route handlers
they call (synchronous by design, meant to run off the event loop)
were executing on the event loop thread during these tests instead of
in a worker thread the way the production ASGI server runs them.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request implements team-level connector access control for MCP servers and Custom APIs, updating several endpoints (listing, getting, connecting, updating, and toggling) to fall back to team access when personal association rows are missing. It also adds extensive test coverage for session recovery and permission consistency. The review feedback recommends that when catching ConnectorRuntimeError and degrading responses, the original exception details should be logged with exc_info=True to preserve debug traces, and f-strings should be used instead of lazy %-formatting to maintain codebase consistency.
…the event loop The app-scoped teardown primitive calls the connector team seam, and an installed team hook answers from the installing application's own tables, so it can be slow. Called from a coroutine, that hook runs on the event loop thread and stalls every other request the process is serving, which is the risk the invariant in test_mcp_team_connector_edit.py pins. Split the locked local transaction -- identity revalidation, the seam call, the row locks, the single commit -- into a plain def and run it in a worker thread. The coroutine keeps only the post-commit provider revocation, the one step that genuinely needs to await; the signature, the return value and the order of work are unchanged. The same session crosses into that thread and back, as it already does for the async login route, and the await means only one thread uses it at a time.
|
Rebuilt on top of current That PR added The locked local transaction -- identity revalidation, the seam call, the row The session crosses into that worker thread and back. That is the pattern the Verified with the tests #2105 shipped, unchanged: @OliverBryant flagging this since it touches your teardown primitive. |
|
The hook session contract and the boundary check are now carried by a separate PR, #2134, opened from What lives there:
What stays here: the post-lock re-authorization gaps on the MCP edit route, this route's own Merge order: #2134 first, then #2094, then this PR. I will rebase this branch once #2134 lands and update the description accordingly. |
|
Merged Verified after the merge: the targeted MCP OAuth, connector team scope, and team-edit suites pass (316), the full Head is now |
… team decision update_mcp_server's post-lock re-derivation 404'd a caller who reached the route on their team's verdict and never had a personal row, and skipped the recheck entirely for a caller whose pre-lock verdict had already denied edit -- both wrong once a personal row can appear or disappear during the wait for the definition-row lock. Replace the narrow revocation check with a cascade that re-asks the team decision whenever the caller's freshly-read personal row does not already grant edit on its own, and re-point the personal-row stand-in in both directions instead of 404ing on a row that is merely gone. The re-ask is pulled into its own function, _recheck_team_access_under_definition_lock, because the call-site table in connector_team_scope's module docstring keys a row by "module.function" and update_mcp_server already owns one for its rename call; two hook calls under one key collide. Register the six MCP call sites the table did not yet know about, and rename the teardown row to match the helper the hook call actually lives in now that the seam's own call-site check runs on this branch.
…ends Cover the wider re-ask cascade in update_mcp_server: a hook that ends the caller's transaction during the post-lock recheck, and the fifth hook-call-count population the wider condition adds. Add PostgreSQL coverage that exercises the cascade against a real row lock and a real concurrent writer -- an owning personal row deleted or downgraded during the wait, the same deletion paired with a denying verdict, the lock-side personal-field guard, and the response building correctly off the stand-in rather than a stale, deleted ORM object -- none of which a single-process hook sequence can tell apart from a pre-built Python object. Update the docstrings the wider cascade and the renamed call-site table row make stale: the existing revocation test's account of where its 404 originates, the recheck-cost class's per-population count table, one test's now-inaccurate literal of the recheck's own condition, and the wrapper test whose access slot now has a call site in this repository.
|
Head moved from
Verified on this head: the targeted MCP suites (230), the full |
What
A connector's shared configuration can be edited only by the user whose
user_mcpserversrow carriesis_owner. An application that installs the connector team hooks can now supply a second permission source, so an MCP server a team owns becomes editable by the members that application recognises.This is the MCP half.
GETandPUT /api/mcp/servers/{id}widen their first gate, the edit predicate gains a second source, and every place that reportscan_edit_globalfor an MCP server draws on the same verdict the gate used.Standalone deployments are unaffected: with no hook installed every route in this diff behaves exactly as it does today, issues no additional query, and that equivalence is asserted rather than assumed.
Why
The seam that answers the question already exists on
main:resolve_connector_accessasks an installed application whether the caller's team links a given connector and whether it may edit it. Nothing on the MCP routes consults it yet, so the answer has no effect: a team that owns a server still sees every member except the one owner refused.Three things follow from consulting it, and each is a separate hazard the code has to handle.
A caller with no personal row must be able to reach the server at all. Both routes read the definition row through a two-table join with the caller's own
user_mcpserversrow and answer 404 when there is none. A team member who never connected the server personally has none. The routes now fall back to a bare lookup of the definition row plus the caller's team verdict, and use the existing_TeamOwnedUserMCPstand-in in place of the missing association — the same stand-in the listing already constructs for this case.A platform catalog app's shared row must not become editable. xagent provisions one shared
MCPServerrow per catalog app (Gmail, Slack, Stripe and the rest) and attaches every user who connects that app to that same row; a key-based app's row may additionally hold the administrator's platform fallback key in itsenv. That configuration is the platform's, not any one team's. An installing application may still answercan_edit=Truefor such a ref — the seam does not police what a hook says — so this module downgrades that answer against the fact that the row is the catalog's own.A verdict resolved before a lock wait can be stale by the time the write happens.
PUTtakes aFOR UPDATEon the definition row, and that wait has no bound. The application that answers the verdict can revoke the team's link at any moment by writing its own tables, which this lock does not cover.How
The gate
_resolve_mcp_server_for_requestis the one placeGETandPUTresolve three things together: the caller's association (real row or stand-in), the definition row, and the caller's team verdict. It looks up the personal link row with the same join both routes have always run, and only when that finds nothing does it look the definition row up on its own and consult the seam. A caller with neither a personal row nor a verdict still gets the same 404 every caller without an association has always got.An owner's personal row already decides the edit answer on its own, so no hook call is made for an owner.
_check_mcp_permission'seditbranch gains the verdict as a second source: the caller may edit when their own row saysis_owner, when they are a platform administrator, or when the verdict grants it. Thedeletebranch is untouched and reads no verdict — this change grants no delete right to anyone.The helper takes an
on_resolution_failureargument because only the caller knows what a hook failure means for it.PUTraises: the verdict is the gate there, and the seam's typed 503 is translated into the route's own answer.GETdegrades tocan_edit_global=Falsewith a logged warning — but only for a caller who already holds a personal row, because for a caller with no personal row the verdict is the gate, and degrading it would answer "does not exist" for a server the call merely failed to ask about.The catalog downgrade
_team_access_for_shared_rowholds the rule, once. Every place a verdict is produced passes through it: theGET/PUTresolution, both loops of the/serverslisting, the connect response, the post-lock re-check, and the toggle response. That is two gates and four reported fields. Gating alone would leave a Configure button lit whose save returns 403.Only
can_editis cleared.team_ownedstays as the application answered it, so the connector remains reachable and readable by the team; dropping the verdict entirely would 404 a connector the team genuinely links.The predicate is
_server_catalog_keysagainst the catalog's own keys — the one the apps listing already uses, so this module holds a single definition of "catalog-managed". Two neighbours were measured and rejected._is_reserved_catalog_nameanswers "may a new row take this name" and reads the name alone, so it no longer recognises a builtin-OAuth catalog row an administrator renamed, even though that row still carries its app id inauth— and builtin OAuth is the majority of the built-in catalog apps._catalog_server_has_platform_keyanswers "catalog row that also holds the platform key", which is False for every keyless andmcp_oauthrow and for every key-based row whose key each user supplies, all of which are still platform-owned configuration.The check runs only for a verdict that already grants edit, the one case where it can change an answer. A deployment with no access hook installed issues no additional query at all. A listing whose verdicts do grant edit builds the catalog key set once per request and shares it across every row: one extra
SELECT, not one per row. That is asserted directly rather than argued: the statement count of a listing whose verdicts grant edit is exactly one more than the same listing with no hook installed, and both counts come out identical for the two population sizes the tests parametrise over, so a per-row cost would show up as a difference between them rather than as an absolute number nobody can read. The two aggregate listings additionally pin their absolute statement counts — 8 for/api/mcp/serversand 10 for/api/mcp/apps— measured by recording the statements that exact population issues.One boundary is resolved deliberately rather than correctly, and the function says so: a server someone built themselves under a name a catalog app later took. The catalog claims that name, so the row is treated as catalog-managed and the team edit is withheld. Its creator keeps their own edit right in full, because
is_ownerdecides the edit branch before any verdict is read; what is withheld is only a teammate editing it on the owner's behalf. Telling such a row apart from a real catalog row needs a stored "who created this definition" fact the schema does not carry.Refusing rather than silently ignoring
A caller with no personal row has nowhere to store
user_envoris_active: writing them would set a shadowing instance attribute on the stand-in that persists nothing, and the response would then read that shadow back and report a change that never happened. Such a payload now returns 400 naming the reason, on both sides of the definition-row lock — a personal row present at the gate can be gone by the time the lock is held, and the second copy of the guard catches that case on the same fresh read the write path already takes.A stand-in whose verdict denies edit gets 403 for any other payload, rather than a 200 that wrote nothing: its writable field set is empty — the personal fields have no row to land on, the tamper check refuses every shared field it can compare, and the secrets it deliberately cannot compare are emptied out of the payload. The 403 is ordered after the 400, so a personal-only payload still gets the more precise answer.
Re-establishing authority after the lock
The route already re-reads the caller's link row and the caller's admin flag once it holds the definition row, and recomputes
can_edit_globalfrom them. The team verdict is the third input to that same decision, so it is re-resolved in the same place, from the same locked row, and feeds the same recomputation.The caller's own freshly-read personal row is repointed in both directions rather than 404ing on one that is merely gone: a caller who reached the gate through their team's verdict alone may have acquired a personal row during the wait, and a caller who reached it through a personal row may have had that row deleted. Neither direction is refused by itself. Whether a caller with no personal row may still write is the re-resolved verdict's answer, asked next; refusing first would 404 a team editor whose team does authorise the edit. What must not survive this point is the gate's own ORM object — continuing to hold a row another session has deleted raises a stale-data error at commit for a payload that writes it, and a deleted-object error while building the response for one that does not.
The verdict is re-asked whenever the caller's freshly-read personal row does not already grant edit on its own — not, as an earlier version of this route had it, only when the pre-lock verdict had already granted edit. The narrower condition let two populations through unasked: a caller whose personal row granted edit at the gate (so no verdict was ever resolved there) and lost it during the wait, and a caller whose gate verdict granted nothing but whose personal row changed underneath them. Both got the wrong answer without a re-ask. The wider condition costs one extra hook call on a population that was previously free: a caller with a non-owning personal row whose verdict denies edit, on a payload that does write the shared definition row, now pays two hook calls instead of one — the recheck runs and confirms the same denial, changing nothing about the final answer. The full table, for a payload that writes the definition row:
A caller whose team access no longer grants edit is refused with 403 after an explicit rollback, before any field is read or mutated and before the rename hook runs, so the refusal has nothing to undo — zero side effects is structural here, not something a rollback has to achieve.
There is no separate "personal-only payload" exemption: the block already runs only when the payload writes the definition row, and the field set that decides that (
is_active,user_envexcluded) is exactly the set of fields a personal-only payload may carry. A payload that writes only the caller's own link row takes no lock, so there is no wait for a revocation to slip into.This narrows the window; it is not a fence. A fence needs the revoke path to take the same lock, and that path lives in the application that installs the hook, not here. The re-read is also only meaningful under READ COMMITTED, PostgreSQL's default, which nothing here overrides on the engine; under REPEATABLE READ or SERIALIZABLE the re-read reuses the transaction's original snapshot and the re-check degrades to a no-op — it stops refusing rather than starting to refuse wrongly. The code says so at the site.
Where the re-check runs, the 200 reports the verdict it produced, because that is the answer the write was authorised on.
The session boundary contract
The session contract a hook must honour, and the check that a locked call site declares against a hook that ends the caller's own transaction mid-request, are stated and enforced in
connector_team_scope's own module docstring (added in #2134) and are not this change's to restate — they are shared with #2094 and with the five call sites that already existed onmainbefore either PR. This change's own job against that contract is narrow: register its six call sites in the module docstring's table, one of them (the post-lock re-check above) declaring that it holds the definition row locked while the hook runs.One rename falls out of that registration rather than being made for its own sake. The row this table already carried for the app-teardown call site was keyed on
teardown_mcp_app_server, the coroutine route; the hook call has always lived one level down, in the plain function that coroutine dispatches to on a worker thread. The table key follows the call site, so it now names that function directly. This is not a behaviour change — the lock the row describes and the fact that nothing is committed before the hook runs are exactly as they were — only the row's own name catching up to where the call already was.Keeping hook calls off the event loop
An installed hook is slow synchronous work: the seam is designed on the assumption that the installing application answers from its own tables. FastAPI runs a coroutine route on the event loop thread itself, so a slow hook call inside an
async defdelays every request the process is serving, not just its own; a plaindefgoes to the threadpool, where it occupies one worker.toggle_mcp_serverbecomes a plaindeffor that reason. The invariant is asserted rather than left to per-route judgement: the functions in this module that can reach a hook are discovered by transitive reachability from the module's own imports, the discovered set is asserted equal to a named literal so the check cannot pass by finding nothing, and every member is asserted to be a plaindef. One member is exempt and named rather than left implicit —delete_mcp_serverawaits an external OAuth revocation call and cannot be converted without changing how that call is made. The exemption must itself carry anawaitthat is not the seam call, so a route whose onlyawaitis the seam call cannot claim it.Known limitations tracked separately
These are known and deliberately not addressed here. Each is either repo-wide work or a contract question wider than these routes.
update_mcp_server's outerexcept Exceptionhas no arm for the seam's own session-boundary error, so a hook that ends the caller's transaction mid-recheck is reported as"Failed to update MCP server: ..."rather than through the shared handler the other five declared call sites reach. The message itself carries neither a hook name nor a slot name either way, so nothing is disclosed; the shape of the answer is simply not uniform across call sites yet, and unifying it means touching this route's broad exception handler, which this change leaves alone.PUTthat only touches the caller's own fields (A failing connector access hook blocks a PUT that only changes the caller's own association fields #1775). The gate resolves a verdict before it knows the payload writes nothing shared, so a hook outage refuses a request the verdict has no say over.managedis rewritten toexternalon everyPUT(PUT /api/mcp/servers/{id} rewrites managed to "external" on every update #1817). Pre-existing, in code this change does not touch.delete_mcp_serverreaches the seam and stays a coroutine (delete_mcp_server calls the connector team seam synchronously on the event loop #1818). It awaits an external revocation call; converting it means changing how that call is made.list_mcp_appstreats its two hook failures differently (list_mcp_apps treats its two hook failures differently #1879), and its visibility-hook failure is unguarded whereget_mcp_servers's is guarded (The connector visibility hook's failure is guarded in get_mcp_servers and unguarded in list_mcp_apps #1918). Both are about the listing's existing failure handling, not about the verdict this change adds.KeyErrorrather than the seam's typed error.with_for_updateis a no-op on SQLite (fix(db): with_for_update takes no lock on SQLite, so read-modify-write routes are unserialized on the default backend #1944) and the PostgreSQL isolation level is neither checked nor documented (fix(db): the PostgreSQL isolation level the server hands us is neither checked nor documented, so REPEATABLE READ turns concurrent edits into 500s #1945). Inherited from the lock; the re-check's dependence on READ COMMITTED is written into the code.restart_policy/auto_startbeing reset when any field is edited. Both pre-date this change.load_team_mcp_envnever restores the shared session after a failing hook (load_team_mcp_env never restores the shared session after a failing hook #1837). The same seam shape as the door this change calls through, on a path outside these routes._TeamOwnedUserMCP.is_active/is_defaultare fixed placeholders, not read off any row. A caller whose personal row is deleted during the lock wait, whose re-resolved verdict still authorises the edit, gets a response reportingis_active=True/is_default=Falseregardless of what their connection looked like before it was removed — there is no real row left to read either value from. Documented on the stand-in class rather than changed: the alternative is a schema-level "what was this connector's activation state" fact this change does not add.Relationship to #1661
This is the MCP half of #1661, which covered both connector families and both the seam and the locks in one change. Three parts of it have already merged separately: the access seam itself (#1912), the Custom API definition-row lock (#2059) and the MCP definition-row lock (#2060). This change is built on
mainwith all three in place, not on that branch.The Custom API half — the same widening for
GET/PUT /api/custom-apis/{id}— is #2094. The two touch disjoint production files: this one changesapi/mcp.pyonly, that one changesapi/custom_api.pyonly. Neither depends on the other's code.They are not fully independent in behaviour, and the honest statement of it is this. The aggregate listings report a
can_edit_globalfor Custom API rows as well as MCP rows, andlist_mcp_appsalso decides each row'scan_configurefrom the same verdict. This change is what makes both fields reflect a team verdict, for both connector families.Until the Custom API half lands, a team member granted edit on a Custom API sees a lit Configure affordance for it, and both
GETandPUT /api/custom-apis/{id}answer 404 for that caller — not a refusal they can read as one, but a connector that appears to vanish when they open it. Nothing unauthorised is read or written; the cost is a control that does not work.Merging the Custom API half first removes that window entirely — with it in place, the fields this change starts reporting are already true. The reverse order is never wrong in the same way: a route that permits more than the client advertises is the safe direction.
Three comments in this change describe the Custom API routes as they behave on
maintoday, and each is hedged as such: two inapi/mcp.py(on_custom_api_to_mcp_responseand on the listing's verdict lookup) and the module docstring oftests/web/api/test_mcp_reported_edit_permission.py. They stop being true the moment the Custom API half merges. Whichever of the two lands second should update them in that same change.One piece of coverage is owed to whichever lands second and is filed rather than carried: an assertion that the value the aggregate listing reports for a Custom API row equals what
PUT /api/custom-apis/{id}actually does. That comparison needs both sides present, so it cannot be written on either branch on its own. The suite here says plainly, in its module docstring, that it makes no claim about the Custom API routes' write outcome.Tests
tests/web/api/test_mcp_team_connector_edit.py(new) covers the gate itself: the resolution helper onGETandPUT, the permission predicate's team fallback, the stand-in's personal-field 400 and denying-verdict 403, the catalog downgrade across four catalog row shapes, the verdict's re-validation under the definition lock and the hook-call cost across every population that reaches it, the typed-error arm, a hook that ends the caller's transaction during the post-lock re-check, the owner's immunity to a hook failure, and the decoration that degrades after a write has committed. It also carries the coroutine guard described above.tests/web/api/test_mcp_reported_edit_permission.py(new) covers what the routes report:can_edit_globalasserted equal across the list,GET,PUTand the toggle for the same caller and connector, parametrised over every constructible caller population; the hook-call budget for both listings, asserted identical for two population sizes so it is a per-request cost and not a per-row one; per-row degradation, where an answer that omits one connector degrades only that row and a failing hook does not blank a listing; session recovery after a hook poisons the shared session, for both poisoning shapes; the four OAuth routes keeping their own personal-row-only gate; rename staying scoped to its own connector; and the standalone shape with no hook installed, asserted row by row against pre-change behaviour.tests/web/api/test_mcp_server_edit_lock_postgresql.pyadditionally covers the post-lock cascade against a real row lock and a real concurrent writer rather than an in-process hook sequence: an owning personal row deleted or downgraded during the wait, with the re-resolved verdict authorising the edit; the same deletion with the re-resolved verdict also denying, landing on the gate's own 404; the lock-side personal-field guard refusing a payload that mixes a shared field withis_activeafter the caller's personal row is gone; and the response building correctly off the stand-in rather than a stale, deleted ORM object.tests/web/api/test_connector_hook_session_fault_postgresql.py(new) proves the session restore against a real server. A failed raw statement aborts the surrounding transaction on PostgreSQL and every later statement on that connection is refused until a rollback runs; SQLite does not enforce that, so the SQLite suite cannot tell a working restore from a missing one. The file is registered in the existing Postgres job intest-migrations.yml, in both hand-maintained path lists and as its own step.Run locally:
test_mcp_team_connector_edit.pytest_mcp_reported_edit_permission.pytest_connector_team_scope.pytest_mcp_server_edit_lock_postgresql.py+test_connector_hook_session_fault_postgresql.py+test_mcp_oauth_lifecycle_postgresql.pyagainst PostgreSQL 17tests/web/api(whole directory)The two failures seen on an earlier run of the whole
tests/websuite, intests/web/test_health_degradations.py, pre-date this change: they pass in isolation and are reproduced by runningtests/web/servicesahead of them, a directory this change does not touch. Earlier tests in the same process leave two degradation flags set on a module-level global that the health check then reports.