Skip to content

feat(connectors): let team members edit a team-shared MCP connector - #2095

Open
AlexLiu190625 wants to merge 11 commits into
xorbitsai:mainfrom
AlexLiu190625:feat/mcp-team-connector-edit
Open

feat(connectors): let team members edit a team-shared MCP connector#2095
AlexLiu190625 wants to merge 11 commits into
xorbitsai:mainfrom
AlexLiu190625:feat/mcp-team-connector-edit

Conversation

@AlexLiu190625

@AlexLiu190625 AlexLiu190625 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

What

A connector's shared configuration can be edited only by the user whose user_mcpservers row carries is_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. GET and PUT /api/mcp/servers/{id} widen their first gate, the edit predicate gains a second source, and every place that reports can_edit_global for 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_access asks 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_mcpservers row 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 _TeamOwnedUserMCP stand-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 MCPServer row 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 its env. That configuration is the platform's, not any one team's. An installing application may still answer can_edit=True for 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. PUT takes a FOR UPDATE on 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_request is the one place GET and PUT resolve 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's edit branch gains the verdict as a second source: the caller may edit when their own row says is_owner, when they are a platform administrator, or when the verdict grants it. The delete branch is untouched and reads no verdict — this change grants no delete right to anyone.

The helper takes an on_resolution_failure argument because only the caller knows what a hook failure means for it. PUT raises: the verdict is the gate there, and the seam's typed 503 is translated into the route's own answer. GET degrades to can_edit_global=False with 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_row holds the rule, once. Every place a verdict is produced passes through it: the GET/PUT resolution, both loops of the /servers listing, 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_edit is cleared. team_owned stays 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_keys against 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_name answers "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 in auth — and builtin OAuth is the majority of the built-in catalog apps. _catalog_server_has_platform_key answers "catalog row that also holds the platform key", which is False for every keyless and mcp_oauth row 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/servers and 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_owner decides 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_env or is_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_global from 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:

Population Hook calls
Owner 0
Platform admin (no personal row) 1
Non-owner personal row, verdict grants edit 2
Non-owner personal row, verdict denies edit 2
No personal row (stand-in), verdict grants edit 2

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_env excluded) 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 on main before 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 def delays every request the process is serving, not just its own; a plain def goes to the threadpool, where it occupies one worker.

toggle_mcp_server becomes a plain def for 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 plain def. One member is exempt and named rather than left implicit — delete_mcp_server awaits an external OAuth revocation call and cannot be converted without changing how that call is made. The exemption must itself carry an await that is not the seam call, so a route whose only await is 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.

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 main with 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 changes api/mcp.py only, that one changes api/custom_api.py only. 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_global for Custom API rows as well as MCP rows, and list_mcp_apps also decides each row's can_configure from 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 GET and PUT /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 main today, and each is hedged as such: two in api/mcp.py (on _custom_api_to_mcp_response and on the listing's verdict lookup) and the module docstring of tests/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 on GET and PUT, 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_global asserted equal across the list, GET, PUT and 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.py additionally 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 with is_active after 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 in test-migrations.yml, in both hand-maintained path lists and as its own step.

Run locally:

Suite Result
test_mcp_team_connector_edit.py 54 passed
test_mcp_reported_edit_permission.py 47 passed
test_connector_team_scope.py 129 passed
test_mcp_server_edit_lock_postgresql.py + test_connector_hook_session_fault_postgresql.py + test_mcp_oauth_lifecycle_postgresql.py against PostgreSQL 17 40 passed
tests/web/api (whole directory) 2916 passed, 68 skipped

The two failures seen on an earlier run of the whole tests/web suite, in tests/web/test_health_degradations.py, pre-date this change: they pass in isolation and are reproduced by running tests/web/services ahead 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.

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/xagent/web/api/mcp.py
Comment thread src/xagent/web/api/mcp.py
Comment thread src/xagent/web/api/mcp.py
Comment thread src/xagent/web/api/mcp.py
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.
@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/xagent/web/api/mcp.py
Comment thread src/xagent/web/api/mcp.py
Comment thread src/xagent/web/api/mcp.py
Comment thread src/xagent/web/api/mcp.py
Comment thread src/xagent/web/api/mcp.py
…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.
@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

Rebuilt on top of current main, which now carries #2105.

That PR added teardown_mcp_app_server, an async def that calls
delete_team_connector on the connector team seam and takes three
with_for_update() row locks. This PR adds an invariant that no function
reaching that seam may be a coroutine: an installed team hook answers from the
installing application's own tables, so it can be slow, and on the event loop
thread a slow hook stalls every other request the process is serving. The
invariant reported the new function on the merge tree, correctly, so it is
fixed here rather than exempted.

The locked local transaction -- identity revalidation, the seam call, the row
locks, the single commit -- is now a plain def
(_teardown_mcp_app_server_locally) that the coroutine runs through
asyncio.to_thread. The coroutine keeps only the post-commit provider
revocation, which is the one step that genuinely needs to await, and which must
still run after the commit has released every lock. The signature, the return
value and the order of work are unchanged, so all existing call sites still
await it unchanged.

The session crosses into that worker thread and back. That is the pattern the
login route in web/api/auth.py already uses -- an async route handing a
Depends(get_db) session to asyncio.to_thread, including the commit -- and
the await means only one thread ever uses the session at a time.

Verified with the tests #2105 shipped, unchanged: test_mcp_oauth_flow.py and
test_mcp_oauth_lifecycle_postgresql.py (the latter against a real
PostgreSQL, where its pg_blocking_pids lock evidence still holds, since the
session keeps the same backend connection across the thread hand-off).

@OliverBryant flagging this since it touches your teardown primitive.

@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

The hook session contract and the boundary check are now carried by a separate PR, #2134, opened from main so that this PR and #2094 share one seam instead of each carrying half of it.

What lives there:

  • the contract text in connector_team_scope (what a hook may and may not do with the caller's session, and what each declared call site holds when its hook runs);
  • the root-transaction-end counter and the refusal path in _call_connector_hook_gate, opt-in per call site through caller_holds_lock=True; the three MCP call sites on main (rename, app teardown, delete) are declared there;
  • the application-level handler that answers one fixed 500 for that refusal.

What stays here: the post-lock re-authorization gaps on the MCP edit route, this route's own caller_holds_lock=True declaration at the post-lock access call, and their tests. One note for the rebase: #2134 registers teardown_mcp_app_server by name in the contract table; this branch renames that function, so the static parity test will point at the new name and the table entry needs the same rename.

Merge order: #2134 first, then #2094, then this PR. I will rebase this branch once #2134 lands and update the description accordingly.

@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

Merged main (f69c4ec69) into this branch to clear the conflict in src/xagent/web/api/mcp.py against the owner-scoped OAuth APIs from #2111. The conflict was confined to the import block — this branch had expanded the typing import and added TYPE_CHECKING, while main added from enum import Enum — and both sides are kept; the rest of the file merged without semantic overlap, since the two changesets touch disjoint regions and neither redefines the other's helpers.

Verified after the merge: the targeted MCP OAuth, connector team scope, and team-edit suites pass (316), the full tests/web/api/ suite passes (2910 passed, 62 skipped — all PostgreSQL-gated), the PostgreSQL-marked lifecycle and edit-lock tests pass against a real PostgreSQL 17 instance (34), and pre-commit is clean over the merge range including mypy.

Head is now 91f4cd10f.

… 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.
@AlexLiu190625

Copy link
Copy Markdown
Collaborator Author

Head moved from 91f4cd10f to 986da2f83, three commits:

  • f6b97d58f merges main at b2b0dc028, which brings in fix(connectors): state the hook session contract and refuse a hook that ends the caller transaction #2134 (the hook session contract and the boundary check). The only conflict was in .github/workflows/test-migrations.yml, where both sides had added a PostgreSQL-only step and its path filter; both are kept.
  • a8190d64d does the post-lock re-authorization this branch still owed once the seam contract landed: the team decision is re-asked whenever the freshly read personal row does not grant the edit on its own, the post-lock 404 branch is replaced by a fresh stand-in object plus a post-lock guard for the personal-only fields, and that re-ask lives in a small helper _recheck_team_access_under_definition_lock declared with caller_holds_lock=True. The contract table in connector_team_scope gains the rows this branch's call sites owe it, and the teardown row now names _teardown_mcp_app_server_locally, the function that holds the three locks on this branch.
  • 986da2f83 adds the tests: one SQLite test pinning that a hook ending the transaction inside the post-lock re-ask is refused, six PostgreSQL tests for the interleavings where the personal row is deleted or downgraded while the request waits for the lock, and one more call-count cell.

Verified on this head: the targeted MCP suites (230), the full tests/web/api/ directory (2916 passed, 68 PostgreSQL-gated skips), the three PostgreSQL-marked files against a real PostgreSQL 17 (40), and pre-commit including mypy. The description is updated to match.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants