Skip to content

Commit aa3830f

Browse files
committed
A connection is one signed-in account; the SDK reads them per account
The grant slot dies. oauth_connections holds one row per sign-in — N per provider — owned by the druks account that completed the consent, and the engine's cache and refresh lock key on the connection id. The declaration becomes Gmail.with_scopes(...): it feeds the consent union, and workflow code loops the sign-ins through it — list_for_account(account_id), then mint_access_token() on each connection. The connect door creates a connection for the session account, or reconsents an existing one via ?connection=<id>. Settings grows the Connections page — everything the user has authenticated to, revoked per connection — and the service detail lists that service's sign-ins. MCP keeps its one-per-(server, account) policy over the same table.
1 parent 35e4944 commit aa3830f

20 files changed

Lines changed: 541 additions & 363 deletions

‎backend/druks/mcp/models.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from druks.mcp.helpers import get_grant_account, grant_provider
1818
from druks.models import Base
1919
from druks.secrets.fields import EncryptedJsonField, EncryptedTextField, Secret
20-
from druks.services.models import OauthGrant
20+
from druks.services.models import OauthConnection
2121

2222

2323
class McpServer(Base, Uuid7Pk):
@@ -109,7 +109,9 @@ def get_resolved(cls, account_id: str | None) -> dict[str, dict]:
109109
if server["identity_mode"]:
110110
grant_account = get_grant_account(server["identity_mode"], account_id)
111111
server["has_token"] = bool(
112-
OauthGrant.get_for_account(grant_provider(server["name"]), grant_account)
112+
OauthConnection.list_for_account(
113+
grant_provider(server["name"]), grant_account
114+
)
113115
)
114116
else:
115117
server["has_token"] = bool(server["token"])
@@ -174,7 +176,7 @@ class McpClientRegistration(Base, Uuid7Pk):
174176

175177
# One RFC 7591 registration per grant: druks registers a fresh client on
176178
# every connect, so each account's grant refreshes as the client it
177-
# consented through. The grant itself lives on the platform's OauthGrant.
179+
# consented through. The refresh token lives on the platform's OauthConnection.
178180
server_id: Mapped[str] = mapped_column(ForeignKey("mcp_servers.id", ondelete="CASCADE"))
179181
account_id: Mapped[str] = mapped_column(
180182
ForeignKey("accounts.id", ondelete="RESTRICT"), default=SYSTEM_ACCOUNT_ID

‎backend/druks/mcp/oauth.py‎

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from druks.mcp.models import McpClientRegistration, McpServer
1313
from druks.services import OauthClient, OauthExchangeError, OauthRefreshError
1414
from druks.services.constants import OAUTH_MINT_WAIT_ATTEMPTS, OAUTH_MINT_WAIT_INTERVAL_SECONDS
15-
from druks.services.models import OauthGrant
15+
from druks.services.models import OauthConnection
1616
from druks.services.oauth import complete_connect as complete_oauth_exchange
1717

1818

@@ -21,12 +21,14 @@ def _http() -> httpx.AsyncClient:
2121
return httpx.AsyncClient(timeout=30.0, follow_redirects=True)
2222

2323

24-
def get_grant(name: str, account_id: str) -> OauthGrant | None:
25-
return OauthGrant.get_for_account(grant_provider(name), account_id)
24+
def get_connection(name: str, account_id: str) -> OauthConnection | None:
25+
# One connection per (server, account) — MCP's policy over the shared table.
26+
rows = OauthConnection.list_for_account(grant_provider(name), account_id)
27+
return rows[0] if rows else None
2628

2729

28-
def list_grants(name: str) -> list[OauthGrant]:
29-
return OauthGrant.list_for_provider(grant_provider(name))
30+
def list_connections(name: str) -> list[OauthConnection]:
31+
return OauthConnection.list_for_provider(grant_provider(name))
3032

3133

3234
def _origin(url: str) -> str:
@@ -232,22 +234,31 @@ async def complete_connect(*, state: str, code: str) -> str:
232234
client_id=pending["client_id"],
233235
client_secret=pending["client_secret"],
234236
)
235-
OauthGrant.store(
236-
provider=grant_provider(name),
237-
account_id=account_id,
238-
refresh_token=tokens["refresh_token"],
239-
)
240-
# A reconsent's stale cached token must not serve until its TTL runs out.
241-
await evict_access_token(name, account_id)
237+
connection = get_connection(name, account_id)
238+
if connection:
239+
connection.reconnect(refresh_token=tokens["refresh_token"], scopes=[])
240+
# A reconsent's stale cached token must not serve until its TTL runs out.
241+
await evict_access_token(name, account_id)
242+
else:
243+
OauthConnection.create(
244+
provider=grant_provider(name),
245+
account_id=account_id,
246+
refresh_token=tokens["refresh_token"],
247+
scopes=[],
248+
)
242249
return name
243250

244251

245252
async def evict_access_token(name: str, account_id: str) -> None:
246-
await OauthClient(provider=grant_provider(name)).evict_access_token(account_id)
253+
connection = get_connection(name, account_id)
254+
if connection:
255+
await OauthClient(provider=grant_provider(name)).evict_access_token(connection.id)
247256

248257

249258
async def disconnect(name: str, account_id: str) -> None:
250-
await OauthClient(provider=grant_provider(name)).disconnect(account_id)
259+
connection = get_connection(name, account_id)
260+
if connection:
261+
await OauthClient(provider=grant_provider(name)).disconnect(connection)
251262
registration = McpClientRegistration.get_for_account(name, account_id)
252263
if registration:
253264
registration.delete()
@@ -257,9 +268,10 @@ async def mint_access_token(name: str, account_id: str) -> str:
257268
"""The delivery-side token for a connected server, minted by the shared
258269
engine from this server's grant — delivery never ships a server the agent
259270
can't authenticate to."""
271+
connection = get_connection(name, account_id)
260272
registration = McpClientRegistration.get_for_account(name, account_id)
261273
server = McpServer.get_for_name(name)
262-
if not registration or not server:
274+
if not connection or not registration or not server:
263275
raise MissingGrantError(name, account_id)
264276
client = OauthClient(
265277
provider=grant_provider(name),
@@ -273,6 +285,6 @@ async def mint_access_token(name: str, account_id: str) -> str:
273285
mint_wait_attempts=OAUTH_MINT_WAIT_ATTEMPTS,
274286
)
275287
try:
276-
return await client.mint_access_token(account_id=account_id)
288+
return await client.mint_access_token(connection=connection)
277289
except OauthRefreshError as error:
278290
raise GrantRefreshError(name, error.reason) from error

‎backend/druks/mcp/routes.py‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,11 @@ async def remove_mcp_server(name: str) -> None:
163163
server = McpServer.get_for_name(name)
164164
if not server:
165165
raise HTTPException(status_code=404, detail=f"MCP server {name!r} not found")
166-
grants = oauth.list_grants(name)
166+
connections = oauth.list_connections(name)
167167
server.delete()
168-
for grant in grants:
169-
grant.delete()
170-
await oauth.evict_access_token(name, grant.account_id)
168+
for connection in connections:
169+
await oauth.evict_access_token(name, connection.account_id)
170+
connection.delete()
171171

172172

173173
@router.post("/{name}/connect", response_model=ConnectMcpServerResponse)
@@ -179,7 +179,7 @@ async def connect_mcp_server(
179179
server = McpServer.get_resolved(current_account_id.get()).get(name)
180180
if not server or server["token_source"] != TokenSource.OAUTH:
181181
raise HTTPException(status_code=404, detail=f"MCP server {name!r} is not an OAuth server.")
182-
if oauth.list_grants(name) and server["identity_mode"] != identity_mode:
182+
if oauth.list_connections(name) and server["identity_mode"] != identity_mode:
183183
raise HTTPException(
184184
status_code=409,
185185
detail=f"MCP server {name!r} already uses {server['identity_mode']!r} identity.",
@@ -238,14 +238,14 @@ async def disconnect_mcp_server(name: str) -> None:
238238
if not server["identity_mode"]:
239239
raise HTTPException(status_code=404, detail=f"MCP server {name!r} has no grant.")
240240
account_id = get_grant_account(server["identity_mode"], current_account_id.get())
241-
grant = oauth.get_grant(name, account_id)
242-
if not grant:
241+
connection = oauth.get_connection(name, account_id)
242+
if not connection:
243243
raise HTTPException(
244244
status_code=404,
245245
detail=f"MCP server {name!r} has no grant for account {account_id!r}.",
246246
)
247247
await oauth.disconnect(name, account_id)
248-
if not oauth.list_grants(name):
248+
if not oauth.list_connections(name):
249249
# The last grant leaving reopens the mode choice: the next connect is
250250
# a first connect again.
251251
server_row = McpServer.get_for_name(name)

‎backend/druks/services/base.py‎

Lines changed: 48 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,48 @@
22

33
from pydantic import BaseModel, ValidationError
44

5-
from druks.accounts.constants import SYSTEM_ACCOUNT_ID
65
from druks.extensions.base import NAME_RE
76
from druks.extensions.loader import iter_extensions
87
from druks.extensions.registry import services
98
from druks.extensions.settings import field_kind, field_multiline
109

1110
from .exceptions import ServiceConnectError, ServiceNotConnectedError
12-
from .models import OauthGrant, ServiceIdentity
11+
from .models import OauthConnection, ServiceIdentity
1312
from .oauth import OauthClient
1413

1514

16-
class ServiceConnection:
17-
"""One extension's declared use of a service — the class attribute
18-
(``acme = Acme.connection("profile.read")``) is both the declaration
19-
and the mint handle."""
15+
class Connection:
16+
"""One signed-in provider account, reached through the extension's
17+
declared handle. Mint and disconnect act on this sign-in only."""
18+
19+
def __init__(self, service: "type[Service]", row: OauthConnection) -> None:
20+
self.service = service
21+
self.row = row
22+
23+
@property
24+
def id(self) -> str:
25+
return self.row.id
26+
27+
@property
28+
def scopes(self) -> list[str]:
29+
return self.row.scopes
30+
31+
@property
32+
def connected_at(self):
33+
return self.row.connected_at
34+
35+
async def mint_access_token(self) -> str:
36+
return await self.service.get_oauth_client().mint_access_token(connection=self.row)
37+
38+
async def disconnect(self) -> None:
39+
await OauthClient(provider=self.service.name).disconnect(self.row)
40+
41+
42+
class ScopedService:
43+
"""A service seen through one extension's declared scopes
44+
(``gmail = Gmail.with_scopes("gmail.readonly")``). The declaration
45+
feeds the consent union; the handle reads the connections that grant
46+
it."""
2047

2148
def __init__(self, service: "type[Service]", scopes: tuple[str, ...]) -> None:
2249
self.service = service
@@ -30,8 +57,16 @@ def __set_name__(self, owner: type, name: str) -> None:
3057
def label(self) -> str:
3158
return f"{self.owner.name}.{self.name}"
3259

33-
async def mint_access_token(self) -> str:
34-
return await self.service.mint_access_token()
60+
def list_for_account(self, account_id: str) -> list[Connection]:
61+
return [
62+
Connection(self.service, row)
63+
for row in OauthConnection.list_for_account(self.service.name, account_id)
64+
]
65+
66+
def get(self, connection_id: str) -> Connection | None:
67+
row = OauthConnection.get(connection_id)
68+
if row and row.provider == self.service.name:
69+
return Connection(self.service, row)
3570

3671

3772
class Service:
@@ -115,53 +150,28 @@ def get(cls) -> ServiceIdentity:
115150
return ServiceIdentity.get(cls.name)
116151

117152
@classmethod
118-
def connection(cls, *scopes: str) -> ServiceConnection:
153+
def with_scopes(cls, *scopes: str) -> ScopedService:
119154
"""Declare this extension's use of the service and the scopes its
120155
calls need."""
121156
if not cls.token_endpoint:
122157
raise TypeError(f"{cls.__name__} declares no OAuth endpoints")
123-
return ServiceConnection(cls, scopes)
158+
return ScopedService(cls, scopes)
124159

125160
@classmethod
126-
def connections(cls) -> "list[ServiceConnection]":
161+
def declarations(cls) -> "list[ScopedService]":
127162
return [
128163
value
129164
for extension in iter_extensions()
130165
for value in vars(extension).values()
131-
if isinstance(value, ServiceConnection) and value.service is cls
166+
if isinstance(value, ScopedService) and value.service is cls
132167
]
133168

134169
@classmethod
135170
def required_scopes(cls) -> tuple[str, ...]:
136171
"""The union of every installed declaration's scopes — the consent ask."""
137-
scopes = {scope for connection in cls.connections() for scope in connection.scopes}
172+
scopes = {scope for declaration in cls.declarations() for scope in declaration.scopes}
138173
return tuple(sorted(scopes))
139174

140-
# The grant is the appliance's own consent at the provider; v1 holds one,
141-
# under the system account.
142-
@classmethod
143-
def get_grant(cls) -> "OauthGrant | None":
144-
return OauthGrant.get_for_account(cls.name, SYSTEM_ACCOUNT_ID)
145-
146-
@classmethod
147-
async def store_grant(cls, *, refresh_token: str, scopes: list[str]) -> None:
148-
OauthGrant.store(
149-
provider=cls.name,
150-
account_id=SYSTEM_ACCOUNT_ID,
151-
refresh_token=refresh_token,
152-
scopes=scopes,
153-
)
154-
# A reconsent's narrower cached token must not serve until its TTL runs out.
155-
await OauthClient(provider=cls.name).evict_access_token(SYSTEM_ACCOUNT_ID)
156-
157-
@classmethod
158-
async def disconnect_grant(cls) -> None:
159-
await OauthClient(provider=cls.name).disconnect(SYSTEM_ACCOUNT_ID)
160-
161-
@classmethod
162-
async def mint_access_token(cls) -> str:
163-
return await cls.get_oauth_client().mint_access_token(account_id=SYSTEM_ACCOUNT_ID)
164-
165175
@classmethod
166176
def get_oauth_client(cls) -> OauthClient:
167177
"""The connected identity as a configured ``OauthClient``, keyed by

0 commit comments

Comments
 (0)