databricks-explore 0.5.0__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.
- databricks_explore/__init__.py +3 -0
- databricks_explore/__main__.py +6 -0
- databricks_explore/app.py +673 -0
- databricks_explore/canvases.py +333 -0
- databricks_explore/cli.py +80 -0
- databricks_explore/concepts.py +325 -0
- databricks_explore/dbx.py +537 -0
- databricks_explore/demo.py +653 -0
- databricks_explore/demo_history.py +157 -0
- databricks_explore/genie.py +281 -0
- databricks_explore/layout.py +1764 -0
- databricks_explore/mcp_server/__init__.py +93 -0
- databricks_explore/mcp_server/auth.py +95 -0
- databricks_explore/mcp_server/rpc.py +88 -0
- databricks_explore/mcp_server/schema.py +68 -0
- databricks_explore/mcp_server/sqlguard.py +59 -0
- databricks_explore/mcp_server/tooling.py +74 -0
- databricks_explore/mcp_server/tools_canvas.py +430 -0
- databricks_explore/mcp_server/tools_data.py +472 -0
- databricks_explore/mining.py +433 -0
- databricks_explore/ontology.py +508 -0
- databricks_explore/relationships.py +987 -0
- databricks_explore/sqltools.py +525 -0
- databricks_explore/web_dist/THIRD-PARTY-NOTICES.txt +5853 -0
- databricks_explore/web_dist/assets/index-6I1cPS7i.css +1 -0
- databricks_explore/web_dist/assets/index-CR9j5CJG.js +330 -0
- databricks_explore/web_dist/index.html +13 -0
- databricks_explore-0.5.0.dist-info/METADATA +207 -0
- databricks_explore-0.5.0.dist-info/RECORD +32 -0
- databricks_explore-0.5.0.dist-info/WHEEL +4 -0
- databricks_explore-0.5.0.dist-info/entry_points.txt +2 -0
- databricks_explore-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Demo mode's invented query history, table lineage and column lineage, as the warehouse's system tables return them.
|
|
2
|
+
|
|
3
|
+
Everything is invented and deterministic (the statements, users and their order never change; the timestamps are
|
|
4
|
+
anchored to the start of the current UTC day). `system_query` answers the SQL that mining.py and
|
|
5
|
+
Explorer.column_lineage send, filtering on the same parameters the real tables would.
|
|
6
|
+
|
|
7
|
+
Set EXPLORER_DEMO_REDACTED=1 (databricks-explore --demo --redacted) to make every statement_text read <REDACTED>, as it does
|
|
8
|
+
for a reader who is neither an account admin nor in databricks_pii_access. Without it, three rows are redacted, so
|
|
9
|
+
a run reports them as skipped.
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import random
|
|
13
|
+
import re
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
|
|
16
|
+
USERS = ["ana.k@example.invalid", "ben.o@example.invalid", "chloe.m@example.invalid", "dev.p@example.invalid",
|
|
17
|
+
"elif.s@example.invalid", "farid.n@example.invalid"]
|
|
18
|
+
REDACTED = "<REDACTED>"
|
|
19
|
+
LINEAGE_SEEN = "2026-11-10T08:00:00.000Z"
|
|
20
|
+
|
|
21
|
+
O, C, I, P = "demo.sales.orders", "demo.sales.customers", "demo.sales.order_items", "demo.sales.products"
|
|
22
|
+
R, S, E, D = "demo.sales.returns", "demo.sales.shipments", "demo.hr.employees", "demo.hr.departments"
|
|
23
|
+
T, V = "samples.nyctaxi.trips", "demo.sales.v_daily_revenue"
|
|
24
|
+
SYS = ["system.access.table_lineage", "system.query.history"]
|
|
25
|
+
|
|
26
|
+
# source table, source column, target table, target column
|
|
27
|
+
COLUMN_LINEAGE = [
|
|
28
|
+
(O, "order_date", V, "order_date"), (O, "amount", V, "revenue"), (O, "order_id", V, "orders"),
|
|
29
|
+
(T, "trip_id", "demo.metrics.wide_metrics", "id"), (T, "pickup_ts", "demo.metrics.wide_metrics", "event_ts"),
|
|
30
|
+
(T, "distance_mi", "demo.metrics.wide_metrics", "metric_01_throughput_per_sec"),
|
|
31
|
+
(T, "fare", "demo.metrics.wide_metrics", "metric_02_error_rate"),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# (count, user indexes, statement type, status, lineage tables or None for no lineage row, SQL with {date}/{email})
|
|
35
|
+
_SPECS = [
|
|
36
|
+
(70, [0, 1, 2, 3, 4, 5], "SELECT", "FINISHED", [O, C],
|
|
37
|
+
"SELECT o.order_id, c.name, o.amount FROM demo.sales.orders o JOIN demo.sales.customers c "
|
|
38
|
+
"ON o.customer_id = c.customer_id WHERE c.email = '{email}' AND o.order_date >= '{date}'"),
|
|
39
|
+
(60, [0, 1, 3], "SELECT", "FINISHED", [I, P],
|
|
40
|
+
"SELECT i.order_id, p.category, SUM(i.qty * i.unit_price) AS revenue FROM demo.sales.order_items i "
|
|
41
|
+
"INNER JOIN demo.sales.products p ON i.sku = p.sku WHERE p.list_price > 10 GROUP BY 1, 2"),
|
|
42
|
+
(35, [1, 2, 4], "SELECT", "FINISHED", [O, R],
|
|
43
|
+
"SELECT o.order_id, r.reason FROM demo.sales.orders o, demo.sales.returns r "
|
|
44
|
+
"WHERE o.order_id = r.order_id AND r.returned_at > '{date}'"),
|
|
45
|
+
(30, [0, 2, 5], "SELECT", "FINISHED", [O, C, S],
|
|
46
|
+
"WITH recent AS (SELECT order_id AS oid, customer_id AS cid, amount FROM demo.sales.orders "
|
|
47
|
+
"WHERE order_date >= '{date}'), shipped AS (SELECT * FROM demo.sales.shipments) "
|
|
48
|
+
"SELECT c.name, recent.amount, s.carrier FROM recent JOIN demo.sales.customers c ON c.customer_id = recent.cid "
|
|
49
|
+
"LEFT JOIN shipped s ON s.order_id = recent.oid"),
|
|
50
|
+
(25, [1, 3], "SELECT", "FINISHED", [I, R],
|
|
51
|
+
"SELECT * FROM demo.sales.order_items i LEFT JOIN demo.sales.returns r USING (order_id, line_no)"),
|
|
52
|
+
(20, [0, 4], "SELECT", "FINISHED", [I, S],
|
|
53
|
+
"SELECT i.order_id, i.units, s.carrier FROM (SELECT order_id, SUM(qty) AS units FROM demo.sales.order_items "
|
|
54
|
+
"GROUP BY order_id) i JOIN demo.sales.shipments s ON s.order_id = i.order_id"),
|
|
55
|
+
(22, [5, 2], "SELECT", "FINISHED", [E, D],
|
|
56
|
+
"SELECT e.name, d.budget FROM demo.hr.employees e JOIN demo.hr.departments d ON e.dept = d.dept"),
|
|
57
|
+
(8, [5], "SELECT", "FINISHED", [E, D],
|
|
58
|
+
"SELECT d.dept, e.name AS manager FROM demo.hr.departments d JOIN demo.hr.employees e ON e.emp_id = d.manager_id"),
|
|
59
|
+
(25, [1, 2, 3, 4], "SELECT", "FINISHED", [O, S], # short names: the lineage rows say which tables they are
|
|
60
|
+
"SELECT * FROM orders o JOIN shipments s ON s.order_id = o.order_id WHERE s.shipped_at >= '{date}'"),
|
|
61
|
+
(6, [2], "SELECT", "FINISHED", None, # a notebook run: no lineage row, short names cannot be resolved
|
|
62
|
+
"SELECT * FROM orders o JOIN shipments s ON s.order_id = o.order_id"),
|
|
63
|
+
(5, [3], "MERGE", "FINISHED", [I],
|
|
64
|
+
"MERGE INTO demo.sales.returns t USING (SELECT order_id, line_no FROM demo.sales.order_items) s "
|
|
65
|
+
"ON t.order_id = s.order_id AND t.line_no = s.line_no WHEN MATCHED THEN UPDATE SET t.reason = 'merged'"),
|
|
66
|
+
(10, [0, 1], "SELECT", "FINISHED", [O, R],
|
|
67
|
+
"SELECT * FROM demo.sales.orders o WHERE EXISTS (SELECT 1 FROM demo.sales.returns r WHERE r.order_id = o.order_id)"),
|
|
68
|
+
(60, [0, 1, 2, 3, 4, 5], "SELECT", "FINISHED", [O],
|
|
69
|
+
"SELECT count(*) FROM demo.sales.orders WHERE status = 'shipped' AND order_date >= '{date}'"),
|
|
70
|
+
(25, [4, 5], "SELECT", "FINISHED", [T], "SELECT zone, avg(fare) FROM samples.nyctaxi.trips GROUP BY zone"),
|
|
71
|
+
(20, [0, 2], "SELECT", "FINISHED", [V], "SELECT * FROM demo.sales.v_daily_revenue ORDER BY order_date DESC"),
|
|
72
|
+
(4, [1], "SELECT", "FINISHED", [O, C], # does not parse
|
|
73
|
+
"SELECT o.order_id FROM demo.sales.orders o JOIN demo.sales.customers c ON (o.customer_id = c.customer_id"),
|
|
74
|
+
(3, [3], "SELECT", "FINISHED", [O], "SELECT * FROM (SELECT * FROM demo.sales.orders"),
|
|
75
|
+
(3, [2], "SELECT", "FINISHED", [O, C], "SELECT o.order_id FROM demo.sales.orders o LEFT JOIN demo.sales.customers c ON c.customer_id = o.customer_id WHERE"),
|
|
76
|
+
(5, [4], "SELECT", "FINISHED", [O, C], # unqualified columns, two tables: no way to know which side each is on
|
|
77
|
+
"SELECT * FROM demo.sales.orders JOIN demo.sales.customers ON customer_id = lifetime_value"),
|
|
78
|
+
(6, [1], "SELECT", "FAILED", [O, S], # failed runs are not read
|
|
79
|
+
"SELECT * FROM demo.sales.orders o JOIN demo.sales.shipments s ON s.shipped_at = o.order_ts"),
|
|
80
|
+
(8, [0, 2], "SHOW", "FINISHED", None, "SHOW TABLES IN demo.sales"),
|
|
81
|
+
(5, [0], "SELECT", "FINISHED", SYS, # Databricks' own tables are not the user's data model
|
|
82
|
+
"SELECT * FROM system.access.table_lineage l JOIN system.query.history h ON l.statement_id = h.statement_id"),
|
|
83
|
+
]
|
|
84
|
+
_REDACTED_ROWS = 3 # in an ordinary demo, this many rows read <REDACTED>
|
|
85
|
+
_DATES = ["2026-09-01", "2026-09-15", "2026-10-01", "2026-10-20"]
|
|
86
|
+
_EMAILS = ["jane.doe@example.invalid", "li.wei@example.invalid", "sam.o@example.invalid"]
|
|
87
|
+
|
|
88
|
+
_cache = {}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def redacted_mode():
|
|
92
|
+
"""True when every statement_text should read <REDACTED> (EXPLORER_DEMO_REDACTED=1)."""
|
|
93
|
+
return os.environ.get("EXPLORER_DEMO_REDACTED") == "1"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _base():
|
|
97
|
+
return datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _fmt(dt):
|
|
101
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def history():
|
|
105
|
+
"""Every invented statement, oldest first: dicts with the columns of system.query.history and a `tables` list."""
|
|
106
|
+
base = _base()
|
|
107
|
+
if base in _cache:
|
|
108
|
+
return _cache[base]
|
|
109
|
+
r = random.Random(20261113)
|
|
110
|
+
rows = []
|
|
111
|
+
for count, users, kind, status, tables, sql in _SPECS:
|
|
112
|
+
for _ in range(count):
|
|
113
|
+
when = base - timedelta(hours=r.uniform(2, 38 * 24))
|
|
114
|
+
rows.append({"executed_by": USERS[r.choice(users)], "start_time": when, "statement_type": kind,
|
|
115
|
+
"execution_status": status, "tables": tables,
|
|
116
|
+
"statement_text": sql.format(date=r.choice(_DATES), email=r.choice(_EMAILS))})
|
|
117
|
+
rows.sort(key=lambda x: x["start_time"])
|
|
118
|
+
for n, row in enumerate(rows, 1):
|
|
119
|
+
row["statement_id"] = f"demo-hist-{n:04d}"
|
|
120
|
+
for k in range(1, _REDACTED_ROWS + 1): # a few, spread out
|
|
121
|
+
rows[len(rows) * k // (_REDACTED_ROWS + 1)]["redacted"] = True
|
|
122
|
+
_cache.clear()
|
|
123
|
+
_cache[base] = rows
|
|
124
|
+
return rows
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _param_time(params, name):
|
|
128
|
+
return datetime.fromisoformat(str(params[name]).replace("Z", "+00:00"))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _selected(low, params, cap):
|
|
132
|
+
"""The statements the history query's WHERE and LIMIT would return."""
|
|
133
|
+
since, until = _param_time(params, "since"), _param_time(params, "until")
|
|
134
|
+
types = re.search(r"statement_type in \(([^)]*)\)", low)
|
|
135
|
+
wanted = set(re.findall(r"'(\w+)'", types.group(1))) if types else None
|
|
136
|
+
rows = [x for x in history()
|
|
137
|
+
if since <= x["start_time"] < until and x["execution_status"] == "FINISHED"
|
|
138
|
+
and (wanted is None or x["statement_type"].lower() in {w.lower() for w in wanted})]
|
|
139
|
+
return rows[:cap]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def system_query(low, params, cap):
|
|
143
|
+
"""(columns, rows) for a statement on system.query.history, system.access.table_lineage or column_lineage."""
|
|
144
|
+
strs = lambda *names: [{"name": n, "type": "STRING"} for n in names] # noqa: E731
|
|
145
|
+
if "column_lineage" in low:
|
|
146
|
+
t = (params or {}).get("t")
|
|
147
|
+
hits = [c for c in COLUMN_LINEAGE if t in (c[0], c[2])]
|
|
148
|
+
return (strs("source_table_full_name", "source_column_name", "target_table_full_name", "target_column_name",
|
|
149
|
+
"last_seen"), [[a, b, c, d, LINEAGE_SEEN] for a, b, c, d in hits])
|
|
150
|
+
rows = _selected(low, params, cap)
|
|
151
|
+
if "table_lineage" in low:
|
|
152
|
+
return (strs("statement_id", "source_table_full_name"),
|
|
153
|
+
[[x["statement_id"], t] for x in rows if x["tables"] for t in x["tables"]])
|
|
154
|
+
redact_all = redacted_mode()
|
|
155
|
+
return (strs("statement_id", "executed_by", "start_time", "statement_text"),
|
|
156
|
+
[[x["statement_id"], x["executed_by"], _fmt(x["start_time"]),
|
|
157
|
+
REDACTED if redact_all or x.get("redacted") else x["statement_text"]] for x in rows])
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Genie One over MCP: a small JSON-RPC client for ask, poll, query result and cancel, plus the response shaping."""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from urllib.parse import unquote
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
PROTOCOL = "2025-06-18"
|
|
11
|
+
SERVER_PATH = "/ai-gateway/mcp-services/system.ai.genie_one_mcp"
|
|
12
|
+
CLIENT_INFO = {"name": "databricks-explore", "version": "0"}
|
|
13
|
+
TIMEOUT = 60 # seconds for one HTTP call; every tool call returns quickly (the browser polls)
|
|
14
|
+
MAX_QUESTION = 4000
|
|
15
|
+
POLL_RETRY_PAUSE = 0.5 # seconds before the one retry of a poll that failed in transit
|
|
16
|
+
MAX_ERROR = 300 # characters of an upstream error body kept in a message
|
|
17
|
+
|
|
18
|
+
_PERMISSION = re.compile(r"permission|EXECUTE|denied|forbidden|not authorized|unauthorized", re.I)
|
|
19
|
+
_SESSION = re.compile(r"session", re.I)
|
|
20
|
+
# https://<host>/explore/data/<catalog>/<schema>/<table>[?query][#fragment], as Genie writes them in its answers
|
|
21
|
+
_TABLE_URL = re.compile(r"https?://[^/\s)\]>\"']+/explore/data/([^/\s?#)\]>\"']+)/([^/\s?#)\]>\"']+)/([^/\s?#)\]>\"']+)[^\s)\]>\"']*")
|
|
22
|
+
_AGENT_COMMENT = re.compile(r"<!--.*?-->", re.S)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GenieError(Exception):
|
|
26
|
+
"""Genie could not be reached or refused. `status` is the HTTP status the route returns (403, 429 or 502).
|
|
27
|
+
`transient` marks a failure in transit (HTTP 5xx, no connection, timeout) that may work on a second try."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message, status=502, transient=False):
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.status = status
|
|
32
|
+
self.transient = transient
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GenieInvalid(ValueError):
|
|
36
|
+
"""A request body or query the ask routes cannot use (route returns HTTP 400)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------- validation
|
|
40
|
+
|
|
41
|
+
def _text(value, name, limit=None):
|
|
42
|
+
if not isinstance(value, str) or not value.strip():
|
|
43
|
+
raise GenieInvalid(f"{name}: must be a non-empty string")
|
|
44
|
+
if limit and len(value) > limit:
|
|
45
|
+
raise GenieInvalid(f"{name}: at most {limit} characters")
|
|
46
|
+
return value.strip()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_ask(body):
|
|
50
|
+
"""(question, conversation_id or None) from a POST /api/ask body."""
|
|
51
|
+
if not isinstance(body, dict):
|
|
52
|
+
raise GenieInvalid("(root): body must be a JSON object")
|
|
53
|
+
cid = body.get("conversation_id")
|
|
54
|
+
return _text(body.get("question"), "question", MAX_QUESTION), (_text(cid, "conversation_id") if cid else None)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_ids(source, *names):
|
|
58
|
+
"""The named non-empty string ids from a query string or body."""
|
|
59
|
+
if not hasattr(source, "get"):
|
|
60
|
+
raise GenieInvalid("(root): body must be a JSON object")
|
|
61
|
+
return tuple(_text(source.get(n), n) for n in names)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------- shaping
|
|
65
|
+
|
|
66
|
+
def tables_from_markdown(markdown):
|
|
67
|
+
"""Tables an answer links to, as [{"name": "cat.sch.tbl", "url": ...}], first mention first, no repeats."""
|
|
68
|
+
seen, out = set(), []
|
|
69
|
+
for m in _TABLE_URL.finditer(markdown or ""):
|
|
70
|
+
parts = [unquote(g).strip("`") for g in m.groups()]
|
|
71
|
+
name = ".".join(parts)
|
|
72
|
+
if all(parts) and name.lower() not in seen:
|
|
73
|
+
seen.add(name.lower())
|
|
74
|
+
out.append({"name": name, "url": m.group(0)})
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def shape_ask(sc):
|
|
79
|
+
"""The ask response: ids and the first status."""
|
|
80
|
+
return {"conversation_id": sc.get("conversation_id"), "response_id": sc.get("response_id"),
|
|
81
|
+
"status": sc.get("status") or "in_progress"}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def shape_poll(sc):
|
|
85
|
+
"""One poll, in the shape the page reads. `tables` is parsed from the answer's links."""
|
|
86
|
+
status = sc.get("status") or "in_progress"
|
|
87
|
+
answer = sc.get("final_answer")
|
|
88
|
+
items = [{"item_id": q.get("item_id"), "sql": q.get("sql") or ""} for q in (sc.get("query_items") or []) if q.get("item_id")]
|
|
89
|
+
return {"conversation_id": sc.get("conversation_id"), "response_id": sc.get("response_id"), "status": status,
|
|
90
|
+
"deep_link": sc.get("deep_link"), "final_answer": answer,
|
|
91
|
+
"progress_steps": [str(s) for s in (sc.get("progress_steps") or [])],
|
|
92
|
+
"query_items": items, "tables": tables_from_markdown(answer),
|
|
93
|
+
"error": str(sc.get("error") or sc.get("message")) if status in ("failed", "incomplete") and (sc.get("error") or sc.get("message")) else None}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def shape_result(sc):
|
|
97
|
+
"""A query result in the Result shape of POST /api/query (contracts/api.md section 3), so the results grid reads it."""
|
|
98
|
+
cols = []
|
|
99
|
+
for c in sc.get("columns") or []:
|
|
100
|
+
text = c.get("type_text")
|
|
101
|
+
base = re.split(r"[(<\s]", (text or "").strip())[0].upper() or None
|
|
102
|
+
cols.append({"name": c.get("name"), "type": c.get("type") or base, "type_text": text})
|
|
103
|
+
rows = [list(r) for r in (sc.get("rows") or [])]
|
|
104
|
+
return {"statement_id": sc.get("item_id"), "state": sc.get("statement_state"), "ready": bool(sc.get("ready")),
|
|
105
|
+
"columns": cols, "rows": rows, "row_count": len(rows), "total_row_count": sc.get("total_row_count"),
|
|
106
|
+
"truncated": bool(sc.get("truncated")), "ms": None}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------- transport
|
|
110
|
+
|
|
111
|
+
def default_post(url, headers, body, timeout):
|
|
112
|
+
"""POST a JSON body. Returns (status, response headers with lower-case keys, body text). Network failures are GenieError."""
|
|
113
|
+
try:
|
|
114
|
+
r = requests.post(url, headers=headers, data=json.dumps(body), timeout=timeout)
|
|
115
|
+
except requests.Timeout:
|
|
116
|
+
raise GenieError("The Genie One MCP server did not answer in time. Try again.", 502, transient=True) from None
|
|
117
|
+
except requests.RequestException as e:
|
|
118
|
+
raise GenieError(f"Could not reach the Genie One MCP server: {type(e).__name__}. Check the connection and the profile.", 502, transient=True) from None
|
|
119
|
+
return r.status_code, {k.lower(): v for k, v in r.headers.items()}, r.text
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _snippet(text):
|
|
123
|
+
return " ".join(str(text).split())[:MAX_ERROR]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _permission_message(detail=""):
|
|
127
|
+
base = ("Genie One refused the request. Your user needs EXECUTE on system.ai.genie_one_mcp, "
|
|
128
|
+
"and the workspace needs Genie One turned on.")
|
|
129
|
+
return f"{base} ({_snippet(detail)})" if detail else base
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _classify(message):
|
|
133
|
+
"""GenieError for an error message from the server: 403 when it reads like a permission problem, else 502."""
|
|
134
|
+
return GenieError(_permission_message(message), 403) if _PERMISSION.search(message) else GenieError(_snippet(message) or "Genie failed.", 502)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _parse_body(text, rpc_id):
|
|
138
|
+
"""The JSON-RPC message in a response body: plain JSON, or an event stream with the message in a data line."""
|
|
139
|
+
text = (text or "").strip()
|
|
140
|
+
if not text:
|
|
141
|
+
return None
|
|
142
|
+
if text.startswith(("{", "[")):
|
|
143
|
+
data = json.loads(text)
|
|
144
|
+
return data if isinstance(data, dict) else next((m for m in data if m.get("id") == rpc_id), None)
|
|
145
|
+
found = None
|
|
146
|
+
for line in text.splitlines():
|
|
147
|
+
if line.startswith("data:"):
|
|
148
|
+
try:
|
|
149
|
+
msg = json.loads(line[5:].strip())
|
|
150
|
+
except ValueError:
|
|
151
|
+
continue
|
|
152
|
+
if isinstance(msg, dict) and (msg.get("id") == rpc_id or found is None):
|
|
153
|
+
found = msg
|
|
154
|
+
return found
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class GenieClient:
|
|
158
|
+
"""Talks to the Genie One MCP server as the connected user. One MCP session is reused until the server drops it."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, w, warehouse_id=None, post=default_post, timeout=TIMEOUT):
|
|
161
|
+
self.w = w
|
|
162
|
+
self.warehouse_id = warehouse_id # sent in _meta with every tool call; the page's selected warehouse
|
|
163
|
+
self._post = post
|
|
164
|
+
self._timeout = timeout
|
|
165
|
+
self._session = None
|
|
166
|
+
self._next_id = 0
|
|
167
|
+
self._lock = threading.Lock()
|
|
168
|
+
|
|
169
|
+
# ---- plumbing
|
|
170
|
+
def url(self):
|
|
171
|
+
"""The MCP endpoint of the connected workspace."""
|
|
172
|
+
try:
|
|
173
|
+
wid = self.w.get_workspace_id()
|
|
174
|
+
except Exception as e:
|
|
175
|
+
raise GenieError(f"Could not read the workspace id: {_snippet(e)}", 502) from None
|
|
176
|
+
return f"{self.w.config.host.rstrip('/')}{SERVER_PATH}?o={wid}"
|
|
177
|
+
|
|
178
|
+
def _headers(self):
|
|
179
|
+
h = dict(self.w.config.authenticate()) # a fresh bearer token each call; never logged or returned
|
|
180
|
+
h.update({"Content-Type": "application/json", "Accept": "application/json, text/event-stream",
|
|
181
|
+
"MCP-Protocol-Version": PROTOCOL})
|
|
182
|
+
if self._session:
|
|
183
|
+
h["Mcp-Session-Id"] = self._session
|
|
184
|
+
return h
|
|
185
|
+
|
|
186
|
+
def _send(self, body):
|
|
187
|
+
"""(status, headers, text) of one POST; an HTTP error is turned into a GenieError except 404, which the caller handles."""
|
|
188
|
+
status, headers, text = self._post(self.url(), self._headers(), body, self._timeout)
|
|
189
|
+
if status in (401, 403):
|
|
190
|
+
raise GenieError(_permission_message(), 403)
|
|
191
|
+
if status == 429:
|
|
192
|
+
raise GenieError("Genie is rate limited right now. Wait a moment and try again.", 429)
|
|
193
|
+
if status >= 400 and status != 404:
|
|
194
|
+
raise GenieError(f"The Genie One MCP server answered HTTP {status}: {_snippet(text)}", 502, transient=status >= 500)
|
|
195
|
+
return status, headers, text
|
|
196
|
+
|
|
197
|
+
def _rpc_id(self):
|
|
198
|
+
self._next_id += 1
|
|
199
|
+
return self._next_id
|
|
200
|
+
|
|
201
|
+
def _initialize(self):
|
|
202
|
+
self._session = None
|
|
203
|
+
rid = self._rpc_id()
|
|
204
|
+
status, headers, text = self._send({"jsonrpc": "2.0", "id": rid, "method": "initialize",
|
|
205
|
+
"params": {"protocolVersion": PROTOCOL, "capabilities": {}, "clientInfo": CLIENT_INFO}})
|
|
206
|
+
if status == 404:
|
|
207
|
+
raise GenieError("This workspace has no Genie One MCP server (system.ai.genie_one_mcp). It may not be enabled here.", 502)
|
|
208
|
+
msg = _parse_body(text, rid)
|
|
209
|
+
if msg and msg.get("error"):
|
|
210
|
+
raise _classify(str(msg["error"].get("message", msg["error"])))
|
|
211
|
+
self._session = headers.get("mcp-session-id")
|
|
212
|
+
self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
213
|
+
|
|
214
|
+
def _call(self, name, args, retry=True):
|
|
215
|
+
with self._lock:
|
|
216
|
+
if not self._session:
|
|
217
|
+
self._initialize()
|
|
218
|
+
rid = self._rpc_id()
|
|
219
|
+
params = {"name": name, "arguments": args}
|
|
220
|
+
if self.warehouse_id:
|
|
221
|
+
params["_meta"] = {"warehouse_id": self.warehouse_id}
|
|
222
|
+
status, _, text = self._send({"jsonrpc": "2.0", "id": rid, "method": "tools/call", "params": params})
|
|
223
|
+
msg = None
|
|
224
|
+
try:
|
|
225
|
+
msg = _parse_body(text, rid) if status != 404 else None
|
|
226
|
+
except ValueError:
|
|
227
|
+
raise GenieError("The Genie One MCP server sent a reply that is not JSON.", 502) from None
|
|
228
|
+
expired = status == 404 or (msg and msg.get("error") and _SESSION.search(str(msg["error"].get("message", ""))))
|
|
229
|
+
if expired and retry: # the server forgot the session: start a new one and send the call once more
|
|
230
|
+
with self._lock:
|
|
231
|
+
self._session = None
|
|
232
|
+
return self._call(name, args, retry=False)
|
|
233
|
+
if status == 404:
|
|
234
|
+
raise GenieError("The Genie One MCP server lost the session. Ask again.", 502)
|
|
235
|
+
if msg is None:
|
|
236
|
+
raise GenieError("The Genie One MCP server sent an empty reply.", 502)
|
|
237
|
+
if msg.get("error"):
|
|
238
|
+
raise _classify(str(msg["error"].get("message", msg["error"])))
|
|
239
|
+
result = msg.get("result") or {}
|
|
240
|
+
sc = result.get("structuredContent")
|
|
241
|
+
if sc is None:
|
|
242
|
+
content = _AGENT_COMMENT.sub("", "".join(c.get("text", "") for c in result.get("content") or []))
|
|
243
|
+
try:
|
|
244
|
+
sc = json.loads(content)
|
|
245
|
+
except ValueError:
|
|
246
|
+
sc = {"message": content.strip()}
|
|
247
|
+
if result.get("isError"):
|
|
248
|
+
raise _classify(str(sc.get("message") or sc.get("error") or json.dumps(sc)))
|
|
249
|
+
return sc
|
|
250
|
+
|
|
251
|
+
# ---- the four tools
|
|
252
|
+
def ask(self, question, conversation_id=None):
|
|
253
|
+
"""Start a turn (or a follow-up in `conversation_id`). Returns ids and the status."""
|
|
254
|
+
args = {"question": question}
|
|
255
|
+
if conversation_id:
|
|
256
|
+
args["conversation_id"] = conversation_id
|
|
257
|
+
return shape_ask(self._call("genie_ask", args))
|
|
258
|
+
|
|
259
|
+
def poll(self, conversation_id, response_id):
|
|
260
|
+
"""One upstream poll, shaped by shape_poll. A transient failure (HTTP 5xx, no connection, timeout) is tried
|
|
261
|
+
once more after a short pause; only poll does this, because the page polls again anyway and a repeated ask,
|
|
262
|
+
result or cancel is not harmless."""
|
|
263
|
+
args = {"conversation_id": conversation_id, "response_id": response_id}
|
|
264
|
+
try:
|
|
265
|
+
sc = self._call("genie_poll_response", args)
|
|
266
|
+
except GenieError as e:
|
|
267
|
+
if not e.transient:
|
|
268
|
+
raise
|
|
269
|
+
time.sleep(POLL_RETRY_PAUSE)
|
|
270
|
+
sc = self._call("genie_poll_response", args)
|
|
271
|
+
return shape_poll(sc)
|
|
272
|
+
|
|
273
|
+
def query_result(self, conversation_id, response_id, item_id):
|
|
274
|
+
"""The rows of one SQL item, in the Result shape."""
|
|
275
|
+
return shape_result(self._call("genie_get_query_result", {"conversation_id": conversation_id,
|
|
276
|
+
"response_id": response_id, "item_id": item_id}))
|
|
277
|
+
|
|
278
|
+
def cancel(self, conversation_id):
|
|
279
|
+
"""Stop the in-flight turn of a conversation."""
|
|
280
|
+
self._call("genie_cancel_response", {"conversation_id": conversation_id})
|
|
281
|
+
return {"conversation_id": conversation_id, "cancel_requested": True}
|