keywright-server 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. keywright_server/__init__.py +3 -0
  2. keywright_server/__main__.py +71 -0
  3. keywright_server/access.py +131 -0
  4. keywright_server/access_view.py +144 -0
  5. keywright_server/actions.py +30 -0
  6. keywright_server/approvals.py +433 -0
  7. keywright_server/audit.py +58 -0
  8. keywright_server/bundles.py +87 -0
  9. keywright_server/config.py +102 -0
  10. keywright_server/db.py +119 -0
  11. keywright_server/discovery.py +277 -0
  12. keywright_server/events.py +78 -0
  13. keywright_server/friction.py +242 -0
  14. keywright_server/integrations.py +64 -0
  15. keywright_server/inventory.py +415 -0
  16. keywright_server/inventory_mcp.py +288 -0
  17. keywright_server/jev.py +80 -0
  18. keywright_server/knowledge.py +403 -0
  19. keywright_server/knowledge_chunking.py +118 -0
  20. keywright_server/knowledge_index.py +233 -0
  21. keywright_server/knowledge_mcp.py +82 -0
  22. keywright_server/llm.py +338 -0
  23. keywright_server/llm_proxy.py +271 -0
  24. keywright_server/llm_usage.py +99 -0
  25. keywright_server/mail.py +312 -0
  26. keywright_server/mcp_oauth.py +422 -0
  27. keywright_server/mcp_proxy.py +128 -0
  28. keywright_server/mcp_server.py +1025 -0
  29. keywright_server/models.py +442 -0
  30. keywright_server/network_map.py +128 -0
  31. keywright_server/notify/__init__.py +8 -0
  32. keywright_server/notify/base.py +156 -0
  33. keywright_server/notify/homeassistant.py +221 -0
  34. keywright_server/notify/log.py +30 -0
  35. keywright_server/notify/manager.py +131 -0
  36. keywright_server/notify/webhook.py +79 -0
  37. keywright_server/nudges.py +121 -0
  38. keywright_server/omada.py +179 -0
  39. keywright_server/policy.py +265 -0
  40. keywright_server/policy_preview.py +151 -0
  41. keywright_server/reconciliation.py +188 -0
  42. keywright_server/result_inspection.py +293 -0
  43. keywright_server/semantic.py +451 -0
  44. keywright_server/semantic_questions.json +120 -0
  45. keywright_server/settings_store.py +44 -0
  46. keywright_server/ssh.py +290 -0
  47. keywright_server/tool_access.py +260 -0
  48. keywright_server/usage.py +121 -0
  49. keywright_server/web/__init__.py +0 -0
  50. keywright_server/web/admin.py +1126 -0
  51. keywright_server/web/app.py +154 -0
  52. keywright_server/web/auth.py +144 -0
  53. keywright_server/web/discovery_ui.py +112 -0
  54. keywright_server/web/friction_ui.py +92 -0
  55. keywright_server/web/inventory_ui.py +192 -0
  56. keywright_server/web/knowledge_ui.py +177 -0
  57. keywright_server/web/mail_ui.py +190 -0
  58. keywright_server/web/mcp_oauth_ui.py +87 -0
  59. keywright_server/web/network_map_ui.py +37 -0
  60. keywright_server/web/oidc.py +231 -0
  61. keywright_server/web/security.py +77 -0
  62. keywright_server/web/templates/_activity_row.html +25 -0
  63. keywright_server/web/templates/_agent_setup.html +50 -0
  64. keywright_server/web/templates/_collections_style.html +88 -0
  65. keywright_server/web/templates/_constraints.html +6 -0
  66. keywright_server/web/templates/_decide.html +39 -0
  67. keywright_server/web/templates/_device_kind_picker.html +66 -0
  68. keywright_server/web/templates/_hazards.html +6 -0
  69. keywright_server/web/templates/_icons.html +19 -0
  70. keywright_server/web/templates/_infrastructure_nav.html +8 -0
  71. keywright_server/web/templates/_integration_style.html +30 -0
  72. keywright_server/web/templates/_inventory_forms.html +36 -0
  73. keywright_server/web/templates/_inventory_style.html +36 -0
  74. keywright_server/web/templates/_live.html +10 -0
  75. keywright_server/web/templates/_mail_nav.html +5 -0
  76. keywright_server/web/templates/_mail_style.html +10 -0
  77. keywright_server/web/templates/_network_map_script.html +5 -0
  78. keywright_server/web/templates/_nudge_card.html +12 -0
  79. keywright_server/web/templates/_patterns.html +6 -0
  80. keywright_server/web/templates/_policy_preview_results.html +13 -0
  81. keywright_server/web/templates/_request_card.html +46 -0
  82. keywright_server/web/templates/_semantic.html +10 -0
  83. keywright_server/web/templates/_topology_controller.js +217 -0
  84. keywright_server/web/templates/_topology_layout.js +174 -0
  85. keywright_server/web/templates/_topology_render.js +117 -0
  86. keywright_server/web/templates/access.html +119 -0
  87. keywright_server/web/templates/agents.html +49 -0
  88. keywright_server/web/templates/audit.html +6 -0
  89. keywright_server/web/templates/base.html +198 -0
  90. keywright_server/web/templates/dashboard.html +226 -0
  91. keywright_server/web/templates/decision.html +26 -0
  92. keywright_server/web/templates/friction.html +36 -0
  93. keywright_server/web/templates/friction_review.html +21 -0
  94. keywright_server/web/templates/grants.html +61 -0
  95. keywright_server/web/templates/integration_picker.html +47 -0
  96. keywright_server/web/templates/inventory.html +21 -0
  97. keywright_server/web/templates/inventory_asset.html +42 -0
  98. keywright_server/web/templates/inventory_discovery.html +32 -0
  99. keywright_server/web/templates/inventory_discovery_review.html +34 -0
  100. keywright_server/web/templates/inventory_map.html +45 -0
  101. keywright_server/web/templates/inventory_networks.html +12 -0
  102. keywright_server/web/templates/inventory_subnet.html +41 -0
  103. keywright_server/web/templates/inventory_topology.html +22 -0
  104. keywright_server/web/templates/knowledge.html +25 -0
  105. keywright_server/web/templates/knowledge_document.html +7 -0
  106. keywright_server/web/templates/knowledge_proposal.html +7 -0
  107. keywright_server/web/templates/login.html +9 -0
  108. keywright_server/web/templates/mail.html +45 -0
  109. keywright_server/web/templates/mail_rules.html +24 -0
  110. keywright_server/web/templates/mail_settings.html +19 -0
  111. keywright_server/web/templates/mcp_oauth_continue.html +14 -0
  112. keywright_server/web/templates/policies.html +30 -0
  113. keywright_server/web/templates/request_detail.html +27 -0
  114. keywright_server/web/templates/requests.html +9 -0
  115. keywright_server/web/templates/resource_form.html +198 -0
  116. keywright_server/web/templates/resources.html +76 -0
  117. keywright_server/web/templates/settings.html +53 -0
  118. keywright_server/web/templates/setup.html +8 -0
  119. keywright_server/web/templates/usage.html +84 -0
  120. keywright_server/web/usage_ui.py +41 -0
  121. keywright_server-0.0.1.dist-info/METADATA +152 -0
  122. keywright_server-0.0.1.dist-info/RECORD +127 -0
  123. keywright_server-0.0.1.dist-info/WHEEL +4 -0
  124. keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
  125. keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
  126. keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
  127. keywright_server-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
@@ -0,0 +1,422 @@
1
+ """Reusable authorization-code + PKCE connections for HTTP MCP resources.
2
+
3
+ Authlib handles OAuth requests; MCP SDK helpers implement discovery conventions.
4
+ Only an administrator can start consent. Tool calls can refresh but never start a
5
+ browser flow or replay an upstream operation. Credentials are encrypted separately
6
+ from resource config and refresh rotation uses a database lease across workers.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import base64
12
+ import hashlib
13
+ import json
14
+ import secrets
15
+ import time
16
+ from datetime import timedelta
17
+ from urllib.parse import urlsplit
18
+
19
+ import httpx2 as httpx
20
+ from authlib.integrations.httpx_client import AsyncOAuth2Client, OAuthError
21
+ from cryptography.fernet import Fernet, InvalidToken
22
+ from mcp.client.auth.oauth2 import PKCEParameters, check_registration_usable
23
+ from mcp.client.auth.utils import (
24
+ build_oauth_authorization_server_metadata_discovery_urls,
25
+ build_protected_resource_metadata_discovery_urls,
26
+ extract_resource_metadata_from_www_auth,
27
+ extract_scope_from_www_auth,
28
+ )
29
+ from mcp.shared.auth import OAuthClientInformationFull
30
+ from mcp.shared.auth_utils import check_resource_allowed
31
+ from sqlalchemy import delete, or_, update
32
+
33
+ from .config import settings
34
+ from .db import session
35
+ from .models import MCPOAuthConnection, MCPOAuthFlow, new_id, now
36
+
37
+ CALLBACK_PATH = "/ui/mcp-oauth/callback"
38
+ FLOW_TTL = 600
39
+ AUTH_METHODS = ("none", "client_secret_basic", "client_secret_post")
40
+
41
+
42
+ class ConnectionError(ValueError):
43
+ """Messages are fixed text safe for the UI, audit log, and agent context."""
44
+
45
+
46
+ def digest(value: str) -> str:
47
+ return hashlib.sha256(value.encode()).hexdigest()
48
+
49
+
50
+ def cipher() -> Fernet:
51
+ key = hashlib.sha256(b"keywright:mcp-oauth:v1\0" + settings.secret_key()).digest()
52
+ return Fernet(base64.urlsafe_b64encode(key))
53
+
54
+
55
+ def seal(data: dict) -> str:
56
+ return cipher().encrypt(json.dumps(data).encode()).decode()
57
+
58
+
59
+ def unseal(value: str) -> dict:
60
+ try:
61
+ return json.loads(cipher().decrypt(value.encode()))
62
+ except (InvalidToken, ValueError):
63
+ raise ConnectionError("OAuth credentials cannot be opened. Save the settings and reconnect this resource.") from None
64
+
65
+
66
+ def origin(value: str) -> str:
67
+ try:
68
+ url = urlsplit(value)
69
+ if (url.scheme != "https" or not url.hostname or url.username or url.password
70
+ or url.fragment or url.port == 0 or any(c.isspace() for c in value)):
71
+ raise ValueError
72
+ return f"https://{url.netloc.lower()}"
73
+ except (ValueError, TypeError):
74
+ raise ConnectionError("OAuth URLs must use HTTPS without credentials or fragments.") from None
75
+
76
+
77
+ def callback_url() -> str:
78
+ value = settings.public_url.rstrip("/")
79
+ if origin(value) != value or urlsplit(value).query:
80
+ raise ConnectionError("Set KW_PUBLIC_URL to Keywright's public HTTPS origin before connecting OAuth.")
81
+ return value + CALLBACK_PATH
82
+
83
+
84
+ def config_key(config: dict) -> str:
85
+ return digest(json.dumps([config["url"], config.get("oauth", {}), settings.public_url], sort_keys=True))
86
+
87
+
88
+ def checked_url(value: str, config: dict) -> str:
89
+ allowed = {origin(config["url"]), *config.get("oauth", {}).get("origins", [])}
90
+ if origin(value) not in allowed:
91
+ raise ConnectionError("The provider uses another OAuth origin. Add it under Additional OAuth origins.")
92
+ return value
93
+
94
+
95
+ async def configure(db, resource, config: dict, *, secret: str = "") -> None:
96
+ """Called inside the resource save transaction. Changes invalidate credentials and flows."""
97
+ previous = resource.config or {}
98
+ old_id = previous.get("oauth_id")
99
+ row = await db.get(MCPOAuthConnection, old_id) if old_id else None
100
+ if config.get("auth_type") != "oauth":
101
+ if row:
102
+ await db.execute(delete(MCPOAuthFlow).where(MCPOAuthFlow.connection_id == row.id))
103
+ await db.delete(row)
104
+ config.pop("oauth_id", None)
105
+ config.pop("oauth", None)
106
+ return
107
+ origin(config["url"])
108
+ opts = config["oauth"]
109
+ for item in opts["origins"]:
110
+ if origin(item) != item or urlsplit(item).query:
111
+ raise ConnectionError("Additional OAuth origins must be HTTPS origins without paths.")
112
+ method = opts["token_endpoint_auth_method"]
113
+ if method not in AUTH_METHODS:
114
+ raise ConnectionError("Unsupported OAuth client authentication method.")
115
+ unreadable = False
116
+ try:
117
+ old_data = unseal(row.sealed_data) if row else {}
118
+ except ConnectionError:
119
+ old_data, unreadable = {}, True
120
+ client_secret = old_data.get("client_secret", "") if secret in ("", "***") else secret
121
+ if method != "none" and (not opts["client_id"] or not client_secret):
122
+ raise ConnectionError("This authentication method requires a registered client ID and secret.")
123
+ changed = (row is None or unreadable or row.config_key != config_key(config)
124
+ or client_secret != old_data.get("client_secret", ""))
125
+ if changed:
126
+ if row:
127
+ await db.execute(delete(MCPOAuthFlow).where(MCPOAuthFlow.connection_id == row.id))
128
+ await db.delete(row)
129
+ await db.flush()
130
+ await db.flush()
131
+ row = MCPOAuthConnection(resource_id=resource.id, config_key=config_key(config),
132
+ sealed_data=seal({"client_secret": client_secret}))
133
+ db.add(row)
134
+ await db.flush()
135
+ config["oauth_id"] = row.id
136
+
137
+
138
+ async def load(config: dict) -> MCPOAuthConnection:
139
+ async with session() as db:
140
+ row = await db.get(MCPOAuthConnection, config.get("oauth_id", ""))
141
+ if row is None or row.config_key != config_key(config):
142
+ raise ConnectionError("OAuth settings changed. Save and reconnect this resource.")
143
+ return row
144
+
145
+
146
+ async def status(config: dict) -> str:
147
+ try:
148
+ return (await load(config)).status
149
+ except ConnectionError:
150
+ return "disconnected"
151
+
152
+
153
+ async def get_json(client, url: str, config: dict) -> dict | None:
154
+ response = await client.get(checked_url(url, config))
155
+ if response.status_code in (404, 405):
156
+ return None
157
+ response.raise_for_status()
158
+ value = response.json()
159
+ if not isinstance(value, dict):
160
+ raise ConnectionError("OAuth provider returned invalid metadata.")
161
+ return value
162
+
163
+
164
+ async def discover(config: dict) -> dict:
165
+ """Discovery sends no configured headers or tokens and never follows redirects.
166
+
167
+ Discovered network destinations must belong to an origin the admin configured.
168
+ This permits internal providers without trusting arbitrary metadata URLs.
169
+ """
170
+ async with httpx.AsyncClient(timeout=15, follow_redirects=False) as client:
171
+ response = await client.get(checked_url(config["url"], config))
172
+ hint = extract_resource_metadata_from_www_auth(response)
173
+ scope = extract_scope_from_www_auth(response)
174
+ resource = None
175
+ for url in dict.fromkeys(build_protected_resource_metadata_discovery_urls(hint, config["url"])):
176
+ resource = await get_json(client, url, config)
177
+ if resource:
178
+ break
179
+ if not resource or not isinstance(resource.get("resource"), str):
180
+ raise ConnectionError("The server did not publish OAuth protected-resource metadata.")
181
+ if not check_resource_allowed(config["url"], resource["resource"]):
182
+ raise ConnectionError("OAuth metadata does not match this MCP resource.")
183
+ issuers = resource.get("authorization_servers")
184
+ if not isinstance(issuers, list) or not issuers:
185
+ raise ConnectionError("The server did not advertise an OAuth authorization server.")
186
+ issuer = checked_url(issuers[0], config)
187
+ metadata = None
188
+ for url in build_oauth_authorization_server_metadata_discovery_urls(issuer, config["url"]):
189
+ metadata = await get_json(client, url, config)
190
+ if metadata:
191
+ break
192
+ if not metadata or metadata.get("issuer") != issuer:
193
+ raise ConnectionError("OAuth discovery issuer mismatch.")
194
+ for field in ("authorization_endpoint", "token_endpoint"):
195
+ checked_url(metadata.get(field, ""), config)
196
+ for field in ("registration_endpoint", "revocation_endpoint"):
197
+ if metadata.get(field):
198
+ checked_url(metadata[field], config)
199
+ if "S256" not in metadata.get("code_challenge_methods_supported", []):
200
+ raise ConnectionError("The OAuth provider must support PKCE with S256.")
201
+ scope = config["oauth"].get("scope") or scope or " ".join(resource.get("scopes_supported", []))
202
+ return {"metadata": metadata, "scope": scope, "resource": resource["resource"]}
203
+
204
+
205
+ def oauth_client(data: dict) -> AsyncOAuth2Client:
206
+ info = data["client"]
207
+ return AsyncOAuth2Client(
208
+ client_id=info["client_id"], client_secret=info.get("client_secret"),
209
+ token_endpoint_auth_method=info.get("token_endpoint_auth_method", "none"),
210
+ redirect_uri=callback_url(), scope=data.get("scope") or None,
211
+ code_challenge_method="S256", timeout=15, follow_redirects=False,
212
+ )
213
+
214
+
215
+ async def begin(config: dict, browser: str, admin: str) -> str:
216
+ row = await load(config)
217
+ data = unseal(row.sealed_data)
218
+ try:
219
+ discovered = await discover(config)
220
+ opts = config["oauth"]
221
+ info = data.get("client") if data.get("metadata", {}).get("issuer") == discovered["metadata"]["issuer"] else None
222
+ if opts.get("client_id"):
223
+ info = {"client_id": opts["client_id"], "client_secret": data.get("client_secret", ""),
224
+ "token_endpoint_auth_method": opts["token_endpoint_auth_method"]}
225
+ if not info:
226
+ endpoint = discovered["metadata"].get("registration_endpoint")
227
+ if not endpoint:
228
+ raise ConnectionError("This provider requires a pre-registered OAuth client ID.")
229
+ async with httpx.AsyncClient(timeout=15, follow_redirects=False) as client:
230
+ response = await client.post(checked_url(endpoint, config), json={
231
+ "client_name": "Keywright", "redirect_uris": [callback_url()],
232
+ "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"],
233
+ "token_endpoint_auth_method": "none",
234
+ })
235
+ response.raise_for_status()
236
+ registration = OAuthClientInformationFull.model_validate(response.json())
237
+ check_registration_usable(registration)
238
+ if callback_url() not in [str(u) for u in registration.redirect_uris or []]:
239
+ raise ConnectionError("OAuth registration returned a different callback URL.")
240
+ info = registration.model_dump(mode="json", exclude_none=True)
241
+ data.update(discovered, client=info)
242
+ data.pop("token", None)
243
+ state = secrets.token_urlsafe(32)
244
+ pkce = PKCEParameters.generate()
245
+ async with oauth_client(data) as client:
246
+ url, _ = client.create_authorization_url(
247
+ data["metadata"]["authorization_endpoint"], state=state,
248
+ code_verifier=pkce.code_verifier, resource=data["resource"],
249
+ )
250
+ except ConnectionError:
251
+ raise
252
+ except Exception: # noqa: BLE001 - provider errors must not expose credentials
253
+ raise ConnectionError("OAuth discovery or registration failed. Check the provider and client settings.") from None
254
+ generation = new_id("gen")
255
+ async with session() as db:
256
+ changed = await db.execute(update(MCPOAuthConnection).where(
257
+ MCPOAuthConnection.id == row.id, MCPOAuthConnection.generation == row.generation,
258
+ ).values(generation=generation, sealed_data=seal(data), status="connecting", lease_owner=None, lease_until=None))
259
+ if changed.rowcount != 1:
260
+ raise ConnectionError("OAuth connection changed. Start connecting again.")
261
+ await db.execute(delete(MCPOAuthFlow).where(or_(
262
+ MCPOAuthFlow.connection_id == row.id, MCPOAuthFlow.expires_at <= now(),
263
+ )))
264
+ db.add(MCPOAuthFlow(state_hash=digest(state), connection_id=row.id, generation=generation,
265
+ browser_hash=digest(browser), admin_hash=digest(admin),
266
+ sealed_data=seal({"verifier": pkce.code_verifier, "redirect_uri": callback_url()}),
267
+ expires_at=now() + timedelta(seconds=FLOW_TTL)))
268
+ await db.commit()
269
+ return url
270
+
271
+
272
+ def normalize_token(token: dict, previous: dict | None = None) -> dict:
273
+ if (not isinstance(token.get("access_token"), str) or not token["access_token"]
274
+ or token.get("token_type", "").lower() != "bearer"):
275
+ raise ConnectionError("OAuth provider did not return a usable bearer token.")
276
+ result = {k: v for k, v in token.items() if k in
277
+ ("access_token", "refresh_token", "token_type", "scope", "expires_at", "expires_in")}
278
+ if previous and "refresh_token" not in result:
279
+ result["refresh_token"] = previous.get("refresh_token")
280
+ if "expires_in" in result:
281
+ result["expires_at"] = time.time() + float(result["expires_in"])
282
+ return result
283
+
284
+
285
+ async def finish(params, browser: str, admin: str) -> str:
286
+ state = params.get("state", "")
287
+ if not state or len(state) > 256 or not browser or not admin:
288
+ raise ConnectionError("OAuth attempt expired or could not be verified. Connect again.")
289
+ async with session() as db:
290
+ flow = (await db.execute(delete(MCPOAuthFlow).where(
291
+ MCPOAuthFlow.state_hash == digest(state), MCPOAuthFlow.browser_hash == digest(browser),
292
+ MCPOAuthFlow.admin_hash == digest(admin), MCPOAuthFlow.expires_at > now(),
293
+ ).returning(MCPOAuthFlow))).scalar_one_or_none()
294
+ await db.commit()
295
+ row = await db.get(MCPOAuthConnection, flow.connection_id) if flow else None
296
+ if not row or row.generation != flow.generation:
297
+ raise ConnectionError("OAuth attempt expired or could not be verified. Connect again.")
298
+ from .models import Resource
299
+ async with session() as db:
300
+ resource = await db.get(Resource, row.resource_id)
301
+ if not resource or row.config_key != config_key(resource.config):
302
+ raise ConnectionError("OAuth settings changed. Save and reconnect this resource.")
303
+ data = unseal(row.sealed_data)
304
+ flow_data = unseal(flow.sealed_data)
305
+ metadata = data["metadata"]
306
+ try:
307
+ if params.get("error") or not params.get("code"):
308
+ raise ConnectionError("OAuth authorization was cancelled or denied. Connect again.")
309
+ if (params.get("iss") is not None and params["iss"] != metadata["issuer"]
310
+ or metadata.get("authorization_response_iss_parameter_supported") and not params.get("iss")):
311
+ raise ConnectionError("OAuth callback issuer mismatch.")
312
+ async with oauth_client(data) as client:
313
+ token = await client.fetch_token(
314
+ metadata["token_endpoint"], grant_type="authorization_code", code=params["code"],
315
+ code_verifier=flow_data["verifier"], redirect_uri=flow_data["redirect_uri"], resource=data["resource"],
316
+ )
317
+ data["token"] = normalize_token(token)
318
+ async with session() as db:
319
+ result = await db.execute(update(MCPOAuthConnection).where(
320
+ MCPOAuthConnection.id == row.id, MCPOAuthConnection.generation == flow.generation,
321
+ ).values(sealed_data=seal(data), status="connected"))
322
+ await db.commit()
323
+ if result.rowcount != 1:
324
+ raise ConnectionError("OAuth connection changed. Connect again.")
325
+ except Exception as exc:
326
+ await mark_reconnect(row.id, row.generation)
327
+ if isinstance(exc, ConnectionError):
328
+ raise
329
+ raise ConnectionError("OAuth token exchange failed. Connect again.") from None
330
+ return row.resource_id
331
+
332
+
333
+ async def mark_reconnect(connection_id: str, generation: str) -> None:
334
+ async with session() as db:
335
+ await db.execute(update(MCPOAuthConnection).where(
336
+ MCPOAuthConnection.id == connection_id, MCPOAuthConnection.generation == generation,
337
+ ).values(status="needs_reconnect"))
338
+ await db.commit()
339
+
340
+
341
+ async def disconnect(config: dict) -> None:
342
+ row = await load(config)
343
+ data = unseal(row.sealed_data)
344
+ data.pop("token", None)
345
+ async with session() as db:
346
+ await db.execute(update(MCPOAuthConnection).where(MCPOAuthConnection.id == row.id).values(
347
+ generation=new_id("gen"), sealed_data=seal(data), status="disconnected", lease_owner=None, lease_until=None,
348
+ ))
349
+ await db.execute(delete(MCPOAuthFlow).where(MCPOAuthFlow.connection_id == row.id))
350
+ await db.commit()
351
+
352
+
353
+ async def access_token(config: dict) -> str:
354
+ """Refresh before expiry; a DB lease prevents rotating a refresh token twice."""
355
+ deadline = time.monotonic() + 20
356
+ while True:
357
+ row = await load(config)
358
+ if row.status != "connected":
359
+ raise ConnectionError("OAuth is not connected. Connect this resource in the admin UI.")
360
+ data = unseal(row.sealed_data)
361
+ token = data.get("token", {})
362
+ if token.get("access_token") and (not token.get("expires_at") or token["expires_at"] > time.time() + 60):
363
+ return token["access_token"]
364
+ if not token.get("refresh_token"):
365
+ await mark_reconnect(row.id, row.generation)
366
+ raise ConnectionError("OAuth authorization expired. Reconnect this resource.")
367
+ owner = new_id("refresh")
368
+ async with session() as db:
369
+ result = await db.execute(update(MCPOAuthConnection).where(
370
+ MCPOAuthConnection.id == row.id, MCPOAuthConnection.generation == row.generation,
371
+ MCPOAuthConnection.sealed_data == row.sealed_data, MCPOAuthConnection.status == "connected",
372
+ or_(MCPOAuthConnection.lease_until.is_(None), MCPOAuthConnection.lease_until < now()),
373
+ ).values(lease_owner=owner, lease_until=now() + timedelta(seconds=60)))
374
+ await db.commit()
375
+ if result.rowcount:
376
+ break
377
+ if time.monotonic() > deadline:
378
+ raise ConnectionError("OAuth refresh is busy. Try again shortly.")
379
+ await asyncio.sleep(0.1)
380
+ try:
381
+ async with oauth_client(data) as client:
382
+ fresh = await client.refresh_token(data["metadata"]["token_endpoint"],
383
+ refresh_token=token["refresh_token"], resource=data["resource"])
384
+ data["token"] = normalize_token(fresh, token)
385
+ async with session() as db:
386
+ result = await db.execute(update(MCPOAuthConnection).where(
387
+ MCPOAuthConnection.id == row.id, MCPOAuthConnection.generation == row.generation,
388
+ MCPOAuthConnection.lease_owner == owner,
389
+ ).values(sealed_data=seal(data)))
390
+ await db.commit()
391
+ if result.rowcount != 1:
392
+ raise ConnectionError("OAuth connection changed during refresh. Connect again.")
393
+ return data["token"]["access_token"]
394
+ except OAuthError as exc:
395
+ if exc.error in ("invalid_grant", "invalid_client", "unauthorized_client"):
396
+ await mark_reconnect(row.id, row.generation)
397
+ raise ConnectionError("OAuth refresh failed. Check the connection in the admin UI.") from None
398
+ except ConnectionError:
399
+ raise
400
+ except Exception: # noqa: BLE001 - provider errors must not expose credentials
401
+ raise ConnectionError("OAuth provider is unavailable. Try again shortly.") from None
402
+ finally:
403
+ async with session() as db:
404
+ await db.execute(update(MCPOAuthConnection).where(
405
+ MCPOAuthConnection.id == row.id, MCPOAuthConnection.lease_owner == owner,
406
+ ).values(lease_owner=None, lease_until=None))
407
+ await db.commit()
408
+
409
+
410
+ async def reject_token(config: dict, rejected: str) -> None:
411
+ """Invalidate only the rejected token; never replay a potentially mutating MCP request."""
412
+ row = await load(config)
413
+ data = unseal(row.sealed_data)
414
+ token = data.get("token", {})
415
+ if token.get("access_token") != rejected:
416
+ return
417
+ token["expires_at"] = 1
418
+ async with session() as db:
419
+ await db.execute(update(MCPOAuthConnection).where(
420
+ MCPOAuthConnection.id == row.id, MCPOAuthConnection.sealed_data == row.sealed_data,
421
+ ).values(sealed_data=seal(data)))
422
+ await db.commit()
@@ -0,0 +1,128 @@
1
+ """Upstream MCP servers. Resource config for kind=mcp:
2
+ {"url": "https://.../mcp", "headers": {"Authorization": "Bearer ..."}}
3
+ Only Keywright holds the headers; agents see tools re-exported as <server>__<tool>."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import time
8
+ from contextlib import asynccontextmanager
9
+ from typing import Any
10
+
11
+ import mcp_types as types
12
+ from mcp import Client
13
+ from mcp.client.streamable_http import streamable_http_client
14
+ from mcp.shared._httpx_utils import create_mcp_http_client
15
+
16
+ from . import inventory_mcp, knowledge_mcp, mcp_oauth, omada
17
+
18
+ TOOL_CACHE_TTL = 60.0
19
+ SEP = "__"
20
+
21
+ _tool_cache: dict[str, tuple[float, list[types.Tool]]] = {}
22
+ _oauth_tool_generations: dict[str, tuple[str, str]] = {}
23
+
24
+
25
+ @asynccontextmanager
26
+ async def _client(config: dict[str, Any]):
27
+ headers = dict(config.get("headers") or {})
28
+ token = None
29
+ if config.get("auth_type") == "oauth":
30
+ token = await mcp_oauth.access_token(config)
31
+ headers = {k: v for k, v in headers.items() if k.lower() != "authorization"}
32
+ headers["Authorization"] = "Bearer " + token
33
+ hc = create_mcp_http_client(headers=headers)
34
+ if token:
35
+ async def auth_response(response):
36
+ if response.status_code == 401:
37
+ await mcp_oauth.reject_token(config, token)
38
+ hc.event_hooks["response"].append(auth_response)
39
+ try:
40
+ async with (
41
+ hc,
42
+ Client(streamable_http_client(config["url"], http_client=hc)) as client,
43
+ ):
44
+ yield client
45
+ except Exception:
46
+ if token:
47
+ # HTTP/SDK exceptions may include provider bodies. Never put OAuth material in audit logs.
48
+ raise mcp_oauth.ConnectionError("OAuth MCP request failed. Check the connection in the admin UI.") from None
49
+ raise
50
+
51
+
52
+ async def list_tools(resource_name: str, config: dict[str, Any], *, force: bool = False) -> list[types.Tool]:
53
+ if config.get("adapter") == "knowledge":
54
+ return knowledge_mcp.list_tools()
55
+ if config.get("adapter") == "keywright":
56
+ return inventory_mcp.list_tools()
57
+ if config.get("adapter") == "omada":
58
+ return omada.list_tools()
59
+ if config.get("auth_type") == "oauth":
60
+ connection = await mcp_oauth.load(config)
61
+ generation = (connection.id, connection.generation)
62
+ if connection.status != "connected":
63
+ forget(resource_name)
64
+ raise mcp_oauth.ConnectionError("OAuth is not connected. Connect this resource in the admin UI.")
65
+ if _oauth_tool_generations.get(resource_name) != generation:
66
+ forget(resource_name)
67
+ _oauth_tool_generations[resource_name] = generation
68
+ hit = _tool_cache.get(resource_name)
69
+ if hit and not force and time.monotonic() - hit[0] < TOOL_CACHE_TTL:
70
+ return hit[1]
71
+ async with _client(config) as c:
72
+ res = await c.list_tools()
73
+ _tool_cache[resource_name] = (time.monotonic(), list(res.tools))
74
+ return list(res.tools)
75
+
76
+
77
+ async def call_tool(config: dict[str, Any], tool: str, arguments: dict[str, Any] | None) -> types.CallToolResult:
78
+ if config.get("adapter") in ("keywright", "knowledge"):
79
+ raise ValueError("Internal inventory calls require the broker's gated transaction.")
80
+ if config.get("adapter") == "omada":
81
+ return await omada.call_tool(config, tool, arguments or {})
82
+ async with _client(config) as c:
83
+ return await c.call_tool(tool, arguments or {})
84
+
85
+
86
+ async def probe(config: dict[str, Any]) -> tuple[bool, str]:
87
+ if config.get("adapter") == "knowledge":
88
+ return True, "Built-in knowledge collection: search, get, list and propose."
89
+ if config.get("adapter") == "keywright":
90
+ return True, f"Built-in Keywright inventory: {len(inventory_mcp.list_tools())} tools available."
91
+ if config.get("adapter") == "omada":
92
+ return await omada.probe(config)
93
+ try:
94
+ async with _client(config) as c:
95
+ res = await c.list_tools()
96
+ name = (c.server_info.name if c.server_info else "server")
97
+ return True, f"Connected to {name}: {len(res.tools)} tools ({', '.join(t.name for t in res.tools[:8])}...)"
98
+ except Exception as e: # noqa: BLE001
99
+ return False, describe(e)
100
+
101
+
102
+ def describe(e: BaseException) -> str:
103
+ """The SDK fails inside task groups; the wrapper says nothing ("unhandled errors in a TaskGroup"),
104
+ the leaves say what went wrong (a 404 on a mistyped URL, a refused connection)."""
105
+ leaves = _leaves(e)
106
+ return "; ".join(dict.fromkeys(f"{type(x).__name__}: {x}" for x in leaves))[:500]
107
+
108
+
109
+ def _leaves(e: BaseException) -> list[BaseException]:
110
+ if isinstance(e, BaseExceptionGroup):
111
+ return [leaf for sub in e.exceptions for leaf in _leaves(sub)]
112
+ return [e]
113
+
114
+
115
+ def export_name(resource_name: str, tool: str) -> str:
116
+ return f"{resource_name}{SEP}{tool}"
117
+
118
+
119
+ def split_name(name: str) -> tuple[str, str] | None:
120
+ if SEP not in name:
121
+ return None
122
+ a, b = name.split(SEP, 1)
123
+ return (a, b) if a and b else None
124
+
125
+
126
+ def forget(resource_name: str) -> None:
127
+ _tool_cache.pop(resource_name, None)
128
+ _oauth_tool_generations.pop(resource_name, None)