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,242 @@
|
|
|
1
|
+
"""Approval friction from request identities and terminal call evidence, with exact-rule proposals."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from statistics import median
|
|
9
|
+
|
|
10
|
+
from keywright.patterns import validate
|
|
11
|
+
from sqlalchemy import select
|
|
12
|
+
|
|
13
|
+
from . import audit, policy, policy_preview
|
|
14
|
+
from .access import operation_matches
|
|
15
|
+
from .models import AccessRequest, Agent, AuditEvent, Policy, Resource, now
|
|
16
|
+
from .semantic import SECRET_TEXT
|
|
17
|
+
|
|
18
|
+
ROW_LIMIT = 2000
|
|
19
|
+
MIN_APPROVALS = 3
|
|
20
|
+
HUMAN_CHANNELS = frozenset({"ui", "link", "homeassistant", "webhook", "log"})
|
|
21
|
+
TOOL_LABELS = {"ssh_run": "SSH command", "ssh_get": "File read", "ssh_put": "File write",
|
|
22
|
+
"ssh_capture": "Host-to-host transfer", "mcp_call": "MCP tool", "llm.call": "LLM call",
|
|
23
|
+
"request": "Access request"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fingerprint(value) -> str:
|
|
27
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True, allow_nan=False).encode()).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def request_tool(detail) -> str:
|
|
31
|
+
if not isinstance(detail, dict) or "operations" in detail:
|
|
32
|
+
return "request"
|
|
33
|
+
if "server" in detail:
|
|
34
|
+
return "mcp_call"
|
|
35
|
+
if "gateway" in detail:
|
|
36
|
+
return "llm.call"
|
|
37
|
+
if "host" in detail:
|
|
38
|
+
if "to_host" in detail:
|
|
39
|
+
return "ssh_capture"
|
|
40
|
+
if "command" in detail:
|
|
41
|
+
return "ssh_run"
|
|
42
|
+
if "sha256" in detail:
|
|
43
|
+
return "ssh_put"
|
|
44
|
+
if "path" in detail:
|
|
45
|
+
return "ssh_get"
|
|
46
|
+
return "request"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def evidence(tool, detail, resources):
|
|
50
|
+
"""Never recover scrubbed fields or expose operation inputs in report projections."""
|
|
51
|
+
try:
|
|
52
|
+
paths, op = policy_preview._operation(AuditEvent(tool=tool, detail=audit._scrub(detail)), resources)
|
|
53
|
+
if not paths or SECRET_TEXT.search(json.dumps(op, sort_keys=True, allow_nan=False)):
|
|
54
|
+
return None
|
|
55
|
+
return paths, op
|
|
56
|
+
except (ValueError, TypeError, KeyError, AttributeError):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def outcome(event):
|
|
61
|
+
"""A permission decision is separate from a result, including allowed calls that failed."""
|
|
62
|
+
detail = event.detail if isinstance(event.detail, dict) else {}
|
|
63
|
+
if event.decision in {"deny", "locked", "refused"}:
|
|
64
|
+
return "blocked"
|
|
65
|
+
if event.decision == "error":
|
|
66
|
+
return "failed"
|
|
67
|
+
if event.decision != "allow":
|
|
68
|
+
return "lifecycle"
|
|
69
|
+
if event.tool == "mcp_call":
|
|
70
|
+
return ("failed" if detail["is_error"] else "succeeded") if type(detail.get("is_error")) is bool else "unknown"
|
|
71
|
+
if type(event.exit_code) is int:
|
|
72
|
+
failed = event.exit_code >= 400 or detail.get("response_complete") is False if event.tool == "llm.call" \
|
|
73
|
+
else event.exit_code != 0
|
|
74
|
+
return "failed" if failed else "succeeded"
|
|
75
|
+
return "unknown"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _group_key(agent_id, tool, detail, resources, fallback):
|
|
79
|
+
ev = evidence(tool, detail, resources)
|
|
80
|
+
# Missing or scrubbed arguments cannot establish that two operations were identical.
|
|
81
|
+
return (agent_id, tool, fingerprint(ev) if ev else fallback), ev
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _new_group(agent_id, tool, detail, agents, resources):
|
|
85
|
+
detail = detail if isinstance(detail, dict) else {}
|
|
86
|
+
resource = resources.get(detail.get("resource_id")) if isinstance(detail.get("resource_id"), str) else None
|
|
87
|
+
agent = agents.get(agent_id)
|
|
88
|
+
return {"agent_id": agent_id, "agent": agent.name if agent else "Deleted / unknown agent",
|
|
89
|
+
"resource": resource.name if resource else "Unrecorded / removed target",
|
|
90
|
+
"tool": TOOL_LABELS.get(tool, "Access request"), "requests": [], "events": [],
|
|
91
|
+
"statuses": Counter(), "waits": [], "failures": 0, "blocked": 0, "eligible": [],
|
|
92
|
+
"evidence": None, "suggestion": None, "hazard_history": False}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _eligible(req, ev, at):
|
|
96
|
+
if req.status != "approved" or req.decided_via not in HUMAN_CHANNELS or req.decided_at is None:
|
|
97
|
+
return False
|
|
98
|
+
if not req.created_at <= req.decided_at <= at or req.decision_scope not in {"once", "ttl", "session"}:
|
|
99
|
+
return False
|
|
100
|
+
if not isinstance(req.decision_options, dict) or req.decision_options.get("extent") != "exact" or ev is None:
|
|
101
|
+
return False
|
|
102
|
+
paths, op = ev
|
|
103
|
+
# Keep recorded hazards too, even when the current catalog no longer flags the operation.
|
|
104
|
+
if (req.detail or {}).get("hazards") or op.get("hazards") or any("*" in p for p in paths):
|
|
105
|
+
return False
|
|
106
|
+
# A partially approved request may omit paths; don't broaden it to the whole operation.
|
|
107
|
+
return (isinstance(req.patterns, list) and all(isinstance(p, str) for p in req.patterns)
|
|
108
|
+
and set(req.patterns) == set(paths))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _proposal(group, agents, resources, policies, at):
|
|
112
|
+
if (len(group["eligible"]) < MIN_APPROVALS or group["statuses"]["denied"]
|
|
113
|
+
or group["failures"] or group["blocked"] or group["hazard_history"]):
|
|
114
|
+
return None
|
|
115
|
+
agent = agents.get(group["agent_id"])
|
|
116
|
+
if agent is None or agent.disabled:
|
|
117
|
+
return None
|
|
118
|
+
paths, op = group["evidence"]
|
|
119
|
+
constraints = {k: v for k, v in op.items() if k != "hazards"}
|
|
120
|
+
applicable = [p for p in policies if p.agent_id in (None, agent.id)]
|
|
121
|
+
state = {"resources": {r.path_prefix: r for r in resources.values()}, "requests": {},
|
|
122
|
+
"grants": [], "at": at, "operation": op}
|
|
123
|
+
# Ignore temporary grants here: an existing lease must not hide whether standing access asks.
|
|
124
|
+
before, _ = policy.decide(agent, None, paths, policies=applicable, **state)
|
|
125
|
+
if before.status != "pending":
|
|
126
|
+
return None
|
|
127
|
+
source = group["eligible"][0]
|
|
128
|
+
try:
|
|
129
|
+
paths = [validate(p) for p in paths]
|
|
130
|
+
except ValueError:
|
|
131
|
+
return None
|
|
132
|
+
rules = [Policy(id=f"preview-{i}", agent_id=agent.id, pattern=p, effect="allow",
|
|
133
|
+
constraints=constraints, request_id=source.id, created_by="ui",
|
|
134
|
+
note="Exact operation reviewed in approval friction report") for i, p in enumerate(paths)]
|
|
135
|
+
after, _ = policy.decide(agent, None, paths, policies=[*applicable, *rules], **state)
|
|
136
|
+
if not after.allowed:
|
|
137
|
+
return None
|
|
138
|
+
return {"request_id": source.id, "agent": agent.name, "resource": group["resource"],
|
|
139
|
+
"tool": group["tool"], "support": len(group["eligible"]),
|
|
140
|
+
"examples": [r.id for r in group["eligible"][:5]], "rules": rules,
|
|
141
|
+
# Digest only: raw constraints and resource credentials never enter the review token.
|
|
142
|
+
"fingerprint": fingerprint({"agent": agent.id, "paths": paths, "operation": constraints,
|
|
143
|
+
"support": [r.id for r in group["eligible"]],
|
|
144
|
+
"targets": [(r.id, r.kind, r.name, r.config) for r in resources.values()
|
|
145
|
+
if r.id in (op.get("resource_id"), op.get("to_resource_id"))],
|
|
146
|
+
"policies": [(p.id, p.pattern, p.effect, p.constraints) for p in applicable]})}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def report(db, start, end, *, agent_id=""):
|
|
150
|
+
at = now()
|
|
151
|
+
if end <= start or end - start > timedelta(days=90):
|
|
152
|
+
raise ValueError("Choose a date range of 1 to 90 days.")
|
|
153
|
+
req_query = select(AccessRequest).where(AccessRequest.created_at >= start, AccessRequest.created_at < end)
|
|
154
|
+
ev_query = select(AuditEvent).where(AuditEvent.ts >= start, AuditEvent.ts < end,
|
|
155
|
+
AuditEvent.tool.in_(policy_preview.CALL_TOOLS))
|
|
156
|
+
if agent_id:
|
|
157
|
+
req_query = req_query.where(AccessRequest.agent_id == agent_id)
|
|
158
|
+
ev_query = ev_query.where(AuditEvent.agent_id == agent_id)
|
|
159
|
+
requests = list((await db.scalars(req_query.order_by(AccessRequest.created_at.desc(), AccessRequest.id)
|
|
160
|
+
.limit(ROW_LIMIT + 1))).all())
|
|
161
|
+
events = list((await db.scalars(ev_query.order_by(AuditEvent.ts.desc(), AuditEvent.id.desc())
|
|
162
|
+
.limit(ROW_LIMIT + 1))).all())
|
|
163
|
+
limited = len(requests) > ROW_LIMIT or len(events) > ROW_LIMIT
|
|
164
|
+
agents = {a.id: a for a in (await db.scalars(select(Agent).order_by(Agent.name))).all()}
|
|
165
|
+
resources = {r.id: r for r in (await db.scalars(select(Resource))).all()}
|
|
166
|
+
policies = list((await db.scalars(select(Policy).order_by(Policy.id))).all())
|
|
167
|
+
groups, totals, coverage = {}, Counter(), Counter()
|
|
168
|
+
waits = []
|
|
169
|
+
for req in requests[:ROW_LIMIT]:
|
|
170
|
+
tool = request_tool(req.detail)
|
|
171
|
+
key, ev = _group_key(req.agent_id, tool, req.detail, resources, "request:" + req.id)
|
|
172
|
+
group = groups.setdefault(key, _new_group(req.agent_id, tool, req.detail, agents, resources))
|
|
173
|
+
group["evidence"] = ev
|
|
174
|
+
if isinstance(req.detail, dict) and req.detail.get("hazards"):
|
|
175
|
+
group["hazard_history"] = True
|
|
176
|
+
status = req.status
|
|
177
|
+
if status == "pending" and req.expires_at is not None and req.expires_at <= at:
|
|
178
|
+
status = "expired"
|
|
179
|
+
if status == "approved":
|
|
180
|
+
status = "human_approved" if req.decided_via in HUMAN_CHANNELS else "other_approved"
|
|
181
|
+
group["statuses"][status] += 1
|
|
182
|
+
totals[status] += 1
|
|
183
|
+
totals["requests"] += 1
|
|
184
|
+
group["requests"].append({"id": req.id, "status": status, "at": req.created_at})
|
|
185
|
+
if req.status in {"approved", "denied"} and req.decided_via in HUMAN_CHANNELS:
|
|
186
|
+
if req.decided_at is not None and req.created_at <= req.decided_at <= at:
|
|
187
|
+
seconds = (req.decided_at - req.created_at).total_seconds()
|
|
188
|
+
group["waits"].append(seconds); waits.append(seconds)
|
|
189
|
+
else:
|
|
190
|
+
coverage["missing_wait"] += 1
|
|
191
|
+
if ev is None:
|
|
192
|
+
coverage["incomplete_requests"] += 1
|
|
193
|
+
if _eligible(req, ev, at):
|
|
194
|
+
group["eligible"].append(req)
|
|
195
|
+
for event in events[:ROW_LIMIT]:
|
|
196
|
+
result = outcome(event)
|
|
197
|
+
totals[result] += 1
|
|
198
|
+
totals["call_records"] += 1
|
|
199
|
+
if result not in {"failed", "blocked"}:
|
|
200
|
+
continue
|
|
201
|
+
key, ev = _group_key(event.agent_id, event.tool, event.detail, resources, "audit:" + str(event.id))
|
|
202
|
+
group = groups.setdefault(key, _new_group(event.agent_id, event.tool, event.detail, agents, resources))
|
|
203
|
+
group["failures" if result == "failed" else "blocked"] += 1
|
|
204
|
+
group["events"].append({"id": event.id, "status": result, "at": event.ts})
|
|
205
|
+
if ev is None:
|
|
206
|
+
coverage["incomplete_events"] += 1
|
|
207
|
+
proposals = {}
|
|
208
|
+
for group in groups.values():
|
|
209
|
+
group["median_wait"] = round(median(group["waits"]), 1) if group["waits"] else None
|
|
210
|
+
if not limited and group["evidence"] is not None:
|
|
211
|
+
proposal = _proposal(group, agents, resources, policies, at)
|
|
212
|
+
if proposal:
|
|
213
|
+
proposals[proposal["request_id"]] = proposal
|
|
214
|
+
group["suggestion"] = {k: proposal[k] for k in ("request_id", "support", "examples")}
|
|
215
|
+
# Strip private inputs before returning report data to templates.
|
|
216
|
+
del group["evidence"], group["eligible"], group["waits"]
|
|
217
|
+
rows = sorted(groups.values(), key=lambda g: (len(g["requests"]) + g["failures"] + g["blocked"]), reverse=True)
|
|
218
|
+
return {"rows": rows, "totals": totals, "coverage": coverage, "proposals": proposals,
|
|
219
|
+
"agents": list(agents.values()), "median_wait": round(median(waits), 1) if waits else None,
|
|
220
|
+
"wait_samples": len(waits), "limited": limited, "limit": ROW_LIMIT,
|
|
221
|
+
"at": at, "start": start, "end": end}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def save_proposal(db, proposal):
|
|
225
|
+
"""Persist only the freshly reconstructed exact rules; caller must verify its review token."""
|
|
226
|
+
added = 0
|
|
227
|
+
for candidate in proposal["rules"]:
|
|
228
|
+
current = list((await db.scalars(select(Policy).where(
|
|
229
|
+
Policy.agent_id == candidate.agent_id, Policy.pattern == candidate.pattern,
|
|
230
|
+
Policy.effect == "allow"))).all())
|
|
231
|
+
if any(operation_matches(p.constraints, candidate.constraints) for p in current):
|
|
232
|
+
continue
|
|
233
|
+
db.add(Policy(agent_id=candidate.agent_id, pattern=candidate.pattern, effect="allow",
|
|
234
|
+
constraints=candidate.constraints, request_id=candidate.request_id,
|
|
235
|
+
created_by="ui", note=candidate.note))
|
|
236
|
+
added += 1
|
|
237
|
+
await audit.record(db, tool="policy.suggestion", decision="created",
|
|
238
|
+
detail={"source_request_id": proposal["request_id"], "rules_added": added,
|
|
239
|
+
"agent_id": proposal["rules"][0].agent_id,
|
|
240
|
+
"support": proposal["support"], "extent": "exact"}, commit=False)
|
|
241
|
+
await db.commit()
|
|
242
|
+
return added
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Resource setup catalog. Presets supply defaults; adapters and OAuth remain reusable."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class Integration:
|
|
7
|
+
id: str
|
|
8
|
+
title: str
|
|
9
|
+
description: str
|
|
10
|
+
badge: str
|
|
11
|
+
icon: str
|
|
12
|
+
kind: str
|
|
13
|
+
adapter: str = ""
|
|
14
|
+
default_name: str = ""
|
|
15
|
+
url: str = ""
|
|
16
|
+
auth_type: str = "headers"
|
|
17
|
+
instructions: str = ""
|
|
18
|
+
keywords: str = ""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def search_text(self) -> str:
|
|
22
|
+
return f"{self.title} {self.description} {self.badge} {self.keywords}".lower()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
PRESETS = (
|
|
26
|
+
Integration(
|
|
27
|
+
"notion", "Notion", "Pages, databases, and workspace search.", "Browser sign-in", "notion", "mcp",
|
|
28
|
+
default_name="Notion", url="https://mcp.notion.com/mcp", auth_type="oauth",
|
|
29
|
+
instructions="Name this connection, then continue to Notion to sign in and choose your workspace.",
|
|
30
|
+
keywords="notes documents oauth cloud",
|
|
31
|
+
),
|
|
32
|
+
Integration(
|
|
33
|
+
"omada", "TP-Link Omada", "Sites, network devices, and connected clients.", "App credentials", "omada", "mcp",
|
|
34
|
+
adapter="omada", default_name="Omada",
|
|
35
|
+
instructions="Create an Open API app in your controller, then enter its address and app credentials below.",
|
|
36
|
+
keywords="network wifi switches access points controller",
|
|
37
|
+
),
|
|
38
|
+
Integration(
|
|
39
|
+
"keywright", "Keywright inventory", "Hosts, topology, subnets, and IP allocations.", "Built in", "shield", "mcp",
|
|
40
|
+
adapter="keywright", default_name="Keywright",
|
|
41
|
+
instructions="Add Keywright's inventory tools for your agents. No account or credentials are needed.",
|
|
42
|
+
keywords="local infrastructure devices",
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
GENERICS = (
|
|
46
|
+
Integration("ssh", "SSH host", "Connect a server by its address and SSH account.", "SSH key", "ssh", "ssh"),
|
|
47
|
+
Integration("mcp", "MCP server", "Use any Streamable HTTP server with OAuth or headers.", "Custom connection", "mcp", "mcp"),
|
|
48
|
+
Integration("llm", "LLM gateway", "Connect a LiteLLM gateway with models and spending limits.", "API key", "llm", "llm"),
|
|
49
|
+
)
|
|
50
|
+
PRESET_BY_ID = {item.id: item for item in PRESETS}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def for_resource(resource) -> Integration | None:
|
|
54
|
+
config = resource.config or {}
|
|
55
|
+
if resource.kind != "mcp":
|
|
56
|
+
return None
|
|
57
|
+
if preset := PRESET_BY_ID.get(config.get("integration")):
|
|
58
|
+
return preset
|
|
59
|
+
if preset := PRESET_BY_ID.get(config.get("adapter")):
|
|
60
|
+
return preset
|
|
61
|
+
# Recognize Notion connections created before the catalog existed.
|
|
62
|
+
if config.get("url", "").rstrip("/") == PRESET_BY_ID["notion"].url and config.get("auth_type") == "oauth":
|
|
63
|
+
return PRESET_BY_ID["notion"]
|
|
64
|
+
return None
|