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.
- keywright_server/__init__.py +3 -0
- keywright_server/__main__.py +71 -0
- keywright_server/access.py +131 -0
- keywright_server/access_view.py +144 -0
- keywright_server/actions.py +30 -0
- keywright_server/approvals.py +433 -0
- keywright_server/audit.py +58 -0
- keywright_server/bundles.py +87 -0
- keywright_server/config.py +102 -0
- keywright_server/db.py +119 -0
- keywright_server/discovery.py +277 -0
- keywright_server/events.py +78 -0
- keywright_server/friction.py +242 -0
- keywright_server/integrations.py +64 -0
- keywright_server/inventory.py +415 -0
- keywright_server/inventory_mcp.py +288 -0
- keywright_server/jev.py +80 -0
- keywright_server/knowledge.py +403 -0
- keywright_server/knowledge_chunking.py +118 -0
- keywright_server/knowledge_index.py +233 -0
- keywright_server/knowledge_mcp.py +82 -0
- keywright_server/llm.py +338 -0
- keywright_server/llm_proxy.py +271 -0
- keywright_server/llm_usage.py +99 -0
- keywright_server/mail.py +312 -0
- keywright_server/mcp_oauth.py +422 -0
- keywright_server/mcp_proxy.py +128 -0
- keywright_server/mcp_server.py +1025 -0
- keywright_server/models.py +442 -0
- keywright_server/network_map.py +128 -0
- keywright_server/notify/__init__.py +8 -0
- keywright_server/notify/base.py +156 -0
- keywright_server/notify/homeassistant.py +221 -0
- keywright_server/notify/log.py +30 -0
- keywright_server/notify/manager.py +131 -0
- keywright_server/notify/webhook.py +79 -0
- keywright_server/nudges.py +121 -0
- keywright_server/omada.py +179 -0
- keywright_server/policy.py +265 -0
- keywright_server/policy_preview.py +151 -0
- keywright_server/reconciliation.py +188 -0
- keywright_server/result_inspection.py +293 -0
- keywright_server/semantic.py +451 -0
- keywright_server/semantic_questions.json +120 -0
- keywright_server/settings_store.py +44 -0
- keywright_server/ssh.py +290 -0
- keywright_server/tool_access.py +260 -0
- keywright_server/usage.py +121 -0
- keywright_server/web/__init__.py +0 -0
- keywright_server/web/admin.py +1126 -0
- keywright_server/web/app.py +154 -0
- keywright_server/web/auth.py +144 -0
- keywright_server/web/discovery_ui.py +112 -0
- keywright_server/web/friction_ui.py +92 -0
- keywright_server/web/inventory_ui.py +192 -0
- keywright_server/web/knowledge_ui.py +177 -0
- keywright_server/web/mail_ui.py +190 -0
- keywright_server/web/mcp_oauth_ui.py +87 -0
- keywright_server/web/network_map_ui.py +37 -0
- keywright_server/web/oidc.py +231 -0
- keywright_server/web/security.py +77 -0
- keywright_server/web/templates/_activity_row.html +25 -0
- keywright_server/web/templates/_agent_setup.html +50 -0
- keywright_server/web/templates/_collections_style.html +88 -0
- keywright_server/web/templates/_constraints.html +6 -0
- keywright_server/web/templates/_decide.html +39 -0
- keywright_server/web/templates/_device_kind_picker.html +66 -0
- keywright_server/web/templates/_hazards.html +6 -0
- keywright_server/web/templates/_icons.html +19 -0
- keywright_server/web/templates/_infrastructure_nav.html +8 -0
- keywright_server/web/templates/_integration_style.html +30 -0
- keywright_server/web/templates/_inventory_forms.html +36 -0
- keywright_server/web/templates/_inventory_style.html +36 -0
- keywright_server/web/templates/_live.html +10 -0
- keywright_server/web/templates/_mail_nav.html +5 -0
- keywright_server/web/templates/_mail_style.html +10 -0
- keywright_server/web/templates/_network_map_script.html +5 -0
- keywright_server/web/templates/_nudge_card.html +12 -0
- keywright_server/web/templates/_patterns.html +6 -0
- keywright_server/web/templates/_policy_preview_results.html +13 -0
- keywright_server/web/templates/_request_card.html +46 -0
- keywright_server/web/templates/_semantic.html +10 -0
- keywright_server/web/templates/_topology_controller.js +217 -0
- keywright_server/web/templates/_topology_layout.js +174 -0
- keywright_server/web/templates/_topology_render.js +117 -0
- keywright_server/web/templates/access.html +119 -0
- keywright_server/web/templates/agents.html +49 -0
- keywright_server/web/templates/audit.html +6 -0
- keywright_server/web/templates/base.html +198 -0
- keywright_server/web/templates/dashboard.html +226 -0
- keywright_server/web/templates/decision.html +26 -0
- keywright_server/web/templates/friction.html +36 -0
- keywright_server/web/templates/friction_review.html +21 -0
- keywright_server/web/templates/grants.html +61 -0
- keywright_server/web/templates/integration_picker.html +47 -0
- keywright_server/web/templates/inventory.html +21 -0
- keywright_server/web/templates/inventory_asset.html +42 -0
- keywright_server/web/templates/inventory_discovery.html +32 -0
- keywright_server/web/templates/inventory_discovery_review.html +34 -0
- keywright_server/web/templates/inventory_map.html +45 -0
- keywright_server/web/templates/inventory_networks.html +12 -0
- keywright_server/web/templates/inventory_subnet.html +41 -0
- keywright_server/web/templates/inventory_topology.html +22 -0
- keywright_server/web/templates/knowledge.html +25 -0
- keywright_server/web/templates/knowledge_document.html +7 -0
- keywright_server/web/templates/knowledge_proposal.html +7 -0
- keywright_server/web/templates/login.html +9 -0
- keywright_server/web/templates/mail.html +45 -0
- keywright_server/web/templates/mail_rules.html +24 -0
- keywright_server/web/templates/mail_settings.html +19 -0
- keywright_server/web/templates/mcp_oauth_continue.html +14 -0
- keywright_server/web/templates/policies.html +30 -0
- keywright_server/web/templates/request_detail.html +27 -0
- keywright_server/web/templates/requests.html +9 -0
- keywright_server/web/templates/resource_form.html +198 -0
- keywright_server/web/templates/resources.html +76 -0
- keywright_server/web/templates/settings.html +53 -0
- keywright_server/web/templates/setup.html +8 -0
- keywright_server/web/templates/usage.html +84 -0
- keywright_server/web/usage_ui.py +41 -0
- keywright_server-0.0.1.dist-info/METADATA +152 -0
- keywright_server-0.0.1.dist-info/RECORD +127 -0
- keywright_server-0.0.1.dist-info/WHEEL +4 -0
- keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
- keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
- keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
- 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)
|