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,93 @@
|
|
|
1
|
+
"""An MCP server inside the Flask app: streamable HTTP at /mcp, local only, bearer token, JSON-RPC handled directly.
|
|
2
|
+
|
|
3
|
+
`register(app, explorer, store, miner)` adds /mcp and the two /api/agent-access routes the page uses. Read access,
|
|
4
|
+
local edits and read-only SQL only: no tool writes to the workspace or connects a profile. See contracts/mcp.md.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from flask import Flask, Response, jsonify, request
|
|
10
|
+
|
|
11
|
+
from . import auth, rpc
|
|
12
|
+
from .tooling import REGISTRY, Context
|
|
13
|
+
|
|
14
|
+
SESSION_HEADER = "Mcp-Session-Id"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def access_info(port: str) -> dict:
|
|
18
|
+
"""The URL, the token and the ready-to-paste `claude mcp add` command for the Agent access panel."""
|
|
19
|
+
url = f"http://127.0.0.1:{port}/mcp"
|
|
20
|
+
token = auth.get_token()
|
|
21
|
+
return {"url": url, "token": token,
|
|
22
|
+
"command": f'claude mcp add --transport http databricks-explore {url} --header "Authorization: Bearer {token}"',
|
|
23
|
+
"tools": len(REGISTRY)}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def register(app: Flask, explorer: Callable, store: Callable, miner: Callable) -> None:
|
|
27
|
+
"""Add the MCP endpoint and the agent-access routes to the app."""
|
|
28
|
+
ctx = Context(explorer=explorer, store=store, miner=miner)
|
|
29
|
+
auth.get_token() # create the token file at startup, so it exists before the first request
|
|
30
|
+
|
|
31
|
+
def refuse_non_local():
|
|
32
|
+
"""403 for a Host or Origin that is not this machine (DNS rebinding); None when fine."""
|
|
33
|
+
if not auth.host_is_local(request.host) or not auth.origin_is_local(request.headers.get("Origin")):
|
|
34
|
+
return jsonify({"error": "forbidden: this endpoint only answers local requests"}), 403
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
def json_rpc(body: dict, status: int = 200, headers: dict | None = None) -> Response:
|
|
38
|
+
return Response(json.dumps(body), status=status, mimetype="application/json", headers=headers or {})
|
|
39
|
+
|
|
40
|
+
@app.route("/mcp", methods=["GET", "POST", "DELETE"])
|
|
41
|
+
def mcp_endpoint():
|
|
42
|
+
refused = refuse_non_local()
|
|
43
|
+
if refused:
|
|
44
|
+
return refused
|
|
45
|
+
if not auth.bearer_ok(request.headers.get("Authorization")):
|
|
46
|
+
return Response(json.dumps({"error": "unauthorized: send Authorization: Bearer <token> (see Agent access in the app)"}),
|
|
47
|
+
status=401, mimetype="application/json", headers={"WWW-Authenticate": 'Bearer realm="databricks-explore"'})
|
|
48
|
+
if request.method == "GET": # no server-to-client stream is offered
|
|
49
|
+
return Response("", status=405, headers={"Allow": "POST, DELETE"})
|
|
50
|
+
session = request.headers.get(SESSION_HEADER)
|
|
51
|
+
if request.method == "DELETE":
|
|
52
|
+
return Response("", status=204 if auth.session_ok(session) else 404)
|
|
53
|
+
version = request.headers.get("MCP-Protocol-Version")
|
|
54
|
+
if version is not None and version not in rpc.PROTOCOL_VERSIONS:
|
|
55
|
+
return json_rpc(rpc.error(rpc.INVALID_REQUEST, f"unsupported MCP-Protocol-Version {version}; use one of {', '.join(rpc.PROTOCOL_VERSIONS)}"), 400)
|
|
56
|
+
try:
|
|
57
|
+
body = json.loads(request.get_data(as_text=True) or "")
|
|
58
|
+
except ValueError:
|
|
59
|
+
return json_rpc(rpc.error(rpc.PARSE_ERROR, "the body is not valid JSON"), 400)
|
|
60
|
+
batch = body if isinstance(body, list) else [body]
|
|
61
|
+
if not batch:
|
|
62
|
+
return json_rpc(rpc.error(rpc.INVALID_REQUEST, "empty batch"), 400)
|
|
63
|
+
if all(isinstance(m, dict) and m.get("method") == "server/discover" for m in batch):
|
|
64
|
+
# The 2026-07-28 handshake-free probe. We speak the initialize handshake, so say "no such method" and let the client fall back.
|
|
65
|
+
return json_rpc(rpc.error(rpc.METHOD_NOT_FOUND, "method not found: server/discover; use initialize", batch[0].get("id")))
|
|
66
|
+
is_init = any(isinstance(m, dict) and m.get("method") == "initialize" for m in batch)
|
|
67
|
+
if not is_init:
|
|
68
|
+
if not session:
|
|
69
|
+
return json_rpc(rpc.error(rpc.INVALID_REQUEST, f"missing {SESSION_HEADER} header: send initialize first"), 400)
|
|
70
|
+
if not auth.session_ok(session):
|
|
71
|
+
return json_rpc(rpc.error(rpc.INVALID_REQUEST, "unknown session: send initialize again"), 404)
|
|
72
|
+
replies = [r for r in (rpc.handle(m, ctx) for m in batch) if r is not None]
|
|
73
|
+
headers = {SESSION_HEADER: auth.new_session_id()} if is_init else {}
|
|
74
|
+
if not replies:
|
|
75
|
+
return Response("", status=202, headers=headers)
|
|
76
|
+
if isinstance(body, list):
|
|
77
|
+
return Response(json.dumps(replies), mimetype="application/json", headers=headers)
|
|
78
|
+
status = 400 if replies[0].get("error", {}).get("code") in (rpc.INVALID_REQUEST, rpc.PARSE_ERROR) else 200
|
|
79
|
+
return json_rpc(replies[0], status, headers)
|
|
80
|
+
|
|
81
|
+
@app.get("/api/agent-access")
|
|
82
|
+
def agent_access():
|
|
83
|
+
"""The MCP URL, token and ready-to-paste command for the Agent access panel. Local requests only."""
|
|
84
|
+
return refuse_non_local() or jsonify(access_info(request.environ.get("SERVER_PORT", "8765")))
|
|
85
|
+
|
|
86
|
+
@app.post("/api/agent-access/token")
|
|
87
|
+
def agent_access_new_token():
|
|
88
|
+
"""Replace the token. Agents using the old one get a 401 until they are given the new one."""
|
|
89
|
+
refused = refuse_non_local()
|
|
90
|
+
if refused:
|
|
91
|
+
return refused
|
|
92
|
+
auth.new_token()
|
|
93
|
+
return jsonify(access_info(request.environ.get("SERVER_PORT", "8765")))
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""The MCP bearer token (a 0600 file under the data directory), the local-only Host and Origin checks, and session ids."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from .. import dbx
|
|
11
|
+
|
|
12
|
+
LOCAL_HOSTS = ("127.0.0.1", "localhost", "::1")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def token_path():
|
|
16
|
+
"""data/mcp_token, under whatever dbx.DATA is when called."""
|
|
17
|
+
return dbx.DATA / "mcp_token"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _write(path, token: str) -> None:
|
|
21
|
+
"""Write the token with mode 0600 from the start (never briefly readable by others), replacing any older file."""
|
|
22
|
+
tmp = path.parent / (path.name + ".tmp")
|
|
23
|
+
tmp.unlink(missing_ok=True)
|
|
24
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
25
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
26
|
+
f.write(token + "\n")
|
|
27
|
+
os.chmod(tmp, 0o600)
|
|
28
|
+
os.replace(tmp, path)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_token() -> str:
|
|
32
|
+
"""The token. Created on first use. A file with looser permissions is tightened to 0600."""
|
|
33
|
+
path = token_path()
|
|
34
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
try:
|
|
36
|
+
token = path.read_text(encoding="utf-8").strip()
|
|
37
|
+
except OSError:
|
|
38
|
+
token = ""
|
|
39
|
+
if not token:
|
|
40
|
+
token = secrets.token_urlsafe(32)
|
|
41
|
+
_write(path, token)
|
|
42
|
+
elif path.stat().st_mode & 0o077:
|
|
43
|
+
os.chmod(path, 0o600)
|
|
44
|
+
return token
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def new_token() -> str:
|
|
48
|
+
"""Replace the token. Every agent configured with the old one gets a 401 until it is given the new one."""
|
|
49
|
+
path = token_path()
|
|
50
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
token = secrets.token_urlsafe(32)
|
|
52
|
+
_write(path, token)
|
|
53
|
+
return token
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def bearer_ok(header: Optional[str]) -> bool:
|
|
57
|
+
"""True when the Authorization header is `Bearer <the token>`. Compared in constant time."""
|
|
58
|
+
if not header or not header.lower().startswith("bearer "):
|
|
59
|
+
return False
|
|
60
|
+
return hmac.compare_digest(header[7:].strip().encode(), get_token().encode())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _hostname(netloc_or_url: str) -> str:
|
|
64
|
+
parts = urlsplit(netloc_or_url if "//" in netloc_or_url else "//" + netloc_or_url)
|
|
65
|
+
return (parts.hostname or "").lower()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def host_is_local(host_header: Optional[str]) -> bool:
|
|
69
|
+
"""The Host header names this machine. A DNS-rebinding page reaches us under its own name, which fails this."""
|
|
70
|
+
return _hostname(host_header or "") in LOCAL_HOSTS
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def origin_is_local(origin: Optional[str]) -> bool:
|
|
74
|
+
"""No Origin (a non-browser client) is fine. A browser's Origin must be a local address; "null" is not."""
|
|
75
|
+
if origin is None:
|
|
76
|
+
return True
|
|
77
|
+
return _hostname(origin) in LOCAL_HOSTS
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def new_session_id() -> str:
|
|
81
|
+
"""A session id the server can recognize later without keeping a table: a random part and its HMAC under the token."""
|
|
82
|
+
sid = uuid.uuid4().hex
|
|
83
|
+
return f"{sid}.{_mac(sid)}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def session_ok(session_id: Optional[str]) -> bool:
|
|
87
|
+
"""True for an id new_session_id() made under the current token."""
|
|
88
|
+
if not session_id or "." not in session_id:
|
|
89
|
+
return False
|
|
90
|
+
sid, mac = session_id.rsplit(".", 1)
|
|
91
|
+
return hmac.compare_digest(mac, _mac(sid))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _mac(sid: str) -> str:
|
|
95
|
+
return hmac.new(get_token().encode(), sid.encode(), hashlib.sha256).hexdigest()[:24]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""JSON-RPC 2.0 for MCP: initialize, ping, tools/list and tools/call, with protocol version negotiation and proper errors."""
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from .. import dbx
|
|
6
|
+
|
|
7
|
+
from . import tools_canvas, tools_data # noqa: F401 (importing registers the tools)
|
|
8
|
+
from .schema import validate
|
|
9
|
+
from .tooling import REGISTRY, Context, ToolError
|
|
10
|
+
|
|
11
|
+
PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05") # newest first
|
|
12
|
+
SERVER_INFO = {"name": "databricks-explore", "title": "Databricks Explorer", "version": "0.5.0"}
|
|
13
|
+
INSTRUCTIONS = (
|
|
14
|
+
"Local Unity Catalog explorer. Read the catalog, relationships and concepts, run read-only SQL, and edit canvases next to the person using the browser. "
|
|
15
|
+
"Start with status. Tools that need a connection say so: the person connects in the app, you cannot. "
|
|
16
|
+
"No tool writes to the workspace. Canvas edits are saved at once and undoable with undo_canvas. "
|
|
17
|
+
"On canvases prefer structural placement (place: right_of/below/inside an anchor id) over absolute coordinates, and read the doctor score each edit returns.")
|
|
18
|
+
|
|
19
|
+
PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR = -32700, -32600, -32601, -32602, -32603
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def error(code: int, message: str, rid: Any = None) -> dict:
|
|
23
|
+
"""A JSON-RPC error response."""
|
|
24
|
+
return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def negotiate(requested: Optional[str]) -> str:
|
|
28
|
+
"""The protocol version to use: the client's when we speak it, else our newest."""
|
|
29
|
+
return requested if requested in PROTOCOL_VERSIONS else PROTOCOL_VERSIONS[0]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def handle(message: Any, ctx: Context) -> Optional[dict]:
|
|
33
|
+
"""Answer one JSON-RPC message. None for a notification or a response from the client (nothing to send back)."""
|
|
34
|
+
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
|
|
35
|
+
return error(INVALID_REQUEST, "not a JSON-RPC 2.0 message")
|
|
36
|
+
method, rid = message.get("method"), message.get("id")
|
|
37
|
+
if method is None:
|
|
38
|
+
return None if ("result" in message or "error" in message) else error(INVALID_REQUEST, "missing method", rid)
|
|
39
|
+
if not isinstance(method, str):
|
|
40
|
+
return error(INVALID_REQUEST, "method must be a string", rid)
|
|
41
|
+
if "id" not in message:
|
|
42
|
+
return None # notifications/initialized, notifications/cancelled, ...: accepted, no reply
|
|
43
|
+
if isinstance(rid, bool) or not isinstance(rid, (int, str)):
|
|
44
|
+
return error(INVALID_REQUEST, "id must be a string or an integer")
|
|
45
|
+
params = message.get("params", {})
|
|
46
|
+
if not isinstance(params, dict):
|
|
47
|
+
return error(INVALID_PARAMS, "params must be an object", rid)
|
|
48
|
+
if method == "initialize":
|
|
49
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {
|
|
50
|
+
"protocolVersion": negotiate(params.get("protocolVersion")), "capabilities": {"tools": {"listChanged": False}},
|
|
51
|
+
"serverInfo": SERVER_INFO, "instructions": INSTRUCTIONS}}
|
|
52
|
+
if method == "ping":
|
|
53
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {}}
|
|
54
|
+
if method == "tools/list":
|
|
55
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {"tools": [t.listing() for t in REGISTRY.values()]}}
|
|
56
|
+
if method == "tools/call":
|
|
57
|
+
return _call(params, rid, ctx)
|
|
58
|
+
return error(METHOD_NOT_FOUND, f"method not found: {method}", rid)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _call(params: dict, rid: Any, ctx: Context) -> dict:
|
|
62
|
+
name = params.get("name")
|
|
63
|
+
tool = REGISTRY.get(name) if isinstance(name, str) else None
|
|
64
|
+
if tool is None:
|
|
65
|
+
return error(INVALID_PARAMS, f"unknown tool: {name!r}", rid)
|
|
66
|
+
args = params.get("arguments", {})
|
|
67
|
+
problems = validate(args, tool.schema) if isinstance(args, dict) else ["arguments: must be an object"]
|
|
68
|
+
if problems: # a tool execution error, so the model can read it and correct the call
|
|
69
|
+
return _result(rid, [{"type": "text", "text": "Invalid arguments: " + "; ".join(problems[:6])}], True)
|
|
70
|
+
started = time.time()
|
|
71
|
+
try:
|
|
72
|
+
content, failed = tool.handler(ctx, args), False
|
|
73
|
+
except Exception as e: # ToolError and every domain error (validation, not found, warehouse): message back to the agent
|
|
74
|
+
content, failed = [{"type": "text", "text": _message(e)}], True
|
|
75
|
+
# The name and timing only: never arguments, SQL or results.
|
|
76
|
+
dbx.log({"op": f"mcp.{tool.name}", "ms": int((time.time() - started) * 1000), "ok": not failed})
|
|
77
|
+
return _result(rid, content, failed)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _message(e: Exception) -> str:
|
|
81
|
+
if isinstance(e, ToolError):
|
|
82
|
+
return str(e)
|
|
83
|
+
msg = dbx.clean_error(e)
|
|
84
|
+
return f"{type(e).__name__}: {msg}" if msg and not isinstance(e, (ValueError, LookupError)) else (msg or type(e).__name__)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _result(rid: Any, content: list, is_error: bool) -> dict:
|
|
88
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {"content": content, "isError": is_error}}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""A small JSON Schema checker for tool arguments: type, enum, const, bounds, items, properties, required, no extra keys."""
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
_TYPES = {
|
|
5
|
+
"string": lambda v: isinstance(v, str),
|
|
6
|
+
"integer": lambda v: isinstance(v, int) and not isinstance(v, bool),
|
|
7
|
+
"number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
|
|
8
|
+
"boolean": lambda v: isinstance(v, bool),
|
|
9
|
+
"array": lambda v: isinstance(v, list),
|
|
10
|
+
"object": lambda v: isinstance(v, dict),
|
|
11
|
+
"null": lambda v: v is None,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate(value: Any, schema: dict, path: str = "arguments") -> list[str]:
|
|
16
|
+
"""Every way `value` breaks `schema`, as messages that name the path. Empty when it is fine."""
|
|
17
|
+
errors: list[str] = []
|
|
18
|
+
if "anyOf" in schema and not any(not validate(value, alt, path) for alt in schema["anyOf"]):
|
|
19
|
+
return [f"{path}: does not match any allowed form ({'; '.join(_describe(a) for a in schema['anyOf'])})"]
|
|
20
|
+
want = schema.get("type")
|
|
21
|
+
if want is not None:
|
|
22
|
+
kinds = want if isinstance(want, list) else [want]
|
|
23
|
+
if not any(_TYPES[k](value) for k in kinds):
|
|
24
|
+
return [f"{path}: expected {' or '.join(kinds)}, got {_name(value)}"]
|
|
25
|
+
if "const" in schema and value != schema["const"]:
|
|
26
|
+
errors.append(f"{path}: must be {schema['const']!r}")
|
|
27
|
+
if "enum" in schema and value not in schema["enum"]:
|
|
28
|
+
errors.append(f"{path}: must be one of {', '.join(map(str, schema['enum']))}")
|
|
29
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
30
|
+
if "minimum" in schema and value < schema["minimum"]:
|
|
31
|
+
errors.append(f"{path}: must be at least {schema['minimum']}")
|
|
32
|
+
if "exclusiveMinimum" in schema and value <= schema["exclusiveMinimum"]:
|
|
33
|
+
errors.append(f"{path}: must be greater than {schema['exclusiveMinimum']}")
|
|
34
|
+
if "maximum" in schema and value > schema["maximum"]:
|
|
35
|
+
errors.append(f"{path}: must be at most {schema['maximum']}")
|
|
36
|
+
if isinstance(value, str):
|
|
37
|
+
if "minLength" in schema and len(value) < schema["minLength"]:
|
|
38
|
+
errors.append(f"{path}: must not be empty" if schema["minLength"] == 1 else f"{path}: too short")
|
|
39
|
+
if "maxLength" in schema and len(value) > schema["maxLength"]:
|
|
40
|
+
errors.append(f"{path}: longer than {schema['maxLength']} characters")
|
|
41
|
+
if isinstance(value, list):
|
|
42
|
+
if "minItems" in schema and len(value) < schema["minItems"]:
|
|
43
|
+
errors.append(f"{path}: needs at least {schema['minItems']} item(s)")
|
|
44
|
+
if "maxItems" in schema and len(value) > schema["maxItems"]:
|
|
45
|
+
errors.append(f"{path}: at most {schema['maxItems']} items")
|
|
46
|
+
for i, item in enumerate(value):
|
|
47
|
+
if "items" in schema:
|
|
48
|
+
errors += validate(item, schema["items"], f"{path}[{i}]")
|
|
49
|
+
if isinstance(value, dict):
|
|
50
|
+
props = schema.get("properties", {})
|
|
51
|
+
for key in schema.get("required", []):
|
|
52
|
+
if key not in value:
|
|
53
|
+
errors.append(f"{path}: missing required field {key!r}")
|
|
54
|
+
for key, item in value.items():
|
|
55
|
+
if key in props:
|
|
56
|
+
errors += validate(item, props[key], f"{path}.{key}")
|
|
57
|
+
elif schema.get("additionalProperties") is False:
|
|
58
|
+
errors.append(f"{path}: unknown field {key!r} (known: {', '.join(props) or 'none'})")
|
|
59
|
+
return errors
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _name(v: Any) -> str:
|
|
63
|
+
return "null" if v is None else type(v).__name__
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _describe(schema: dict) -> str:
|
|
67
|
+
kind = schema.get("type", "value")
|
|
68
|
+
return " or ".join(kind) if isinstance(kind, list) else kind
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The read-only gate for run_sql: exactly one SELECT, DESCRIBE, SHOW or EXPLAIN statement, checked with sqlglot."""
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
import sqlglot
|
|
5
|
+
from sqlglot import exp
|
|
6
|
+
from sqlglot.errors import SqlglotError
|
|
7
|
+
|
|
8
|
+
logging.getLogger("sqlglot").setLevel(logging.ERROR) # the Command fallback logs a warning for SHOW and EXPLAIN
|
|
9
|
+
|
|
10
|
+
QUERY_ROOTS = (exp.Select, exp.SetOperation) # WITH ... SELECT is a Select that carries a `with`
|
|
11
|
+
COMMAND_WORDS = ("SHOW", "EXPLAIN", "DESCRIBE", "DESC") # sqlglot reads these as a Command when it cannot model them
|
|
12
|
+
# Anything of these kinds anywhere in the tree is refused, so DML or DDL cannot hide in a CTE or a subquery.
|
|
13
|
+
_FORBIDDEN_NAMES = ("Insert", "Update", "Delete", "Merge", "Create", "Drop", "Alter", "AlterTable", "TruncateTable", "Copy",
|
|
14
|
+
"Command", "Use", "Set", "SetItem", "Into", "Grant", "Revoke", "Analyze", "Attach", "Detach", "Refresh",
|
|
15
|
+
"Cache", "Uncache", "LoadData", "Kill", "Transaction", "Commit", "Rollback", "Pragma")
|
|
16
|
+
FORBIDDEN = tuple(getattr(exp, n) for n in _FORBIDDEN_NAMES if hasattr(exp, n))
|
|
17
|
+
# Functions that run code or call out of the warehouse, so a SELECT is not enough to be read-only.
|
|
18
|
+
FORBIDDEN_FUNCTIONS = {"reflect", "java_method", "try_reflect", "http_request"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotReadOnly(ValueError):
|
|
22
|
+
"""The statement is not one run_sql accepts. str(e) says why."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_read_only(sql: str) -> str:
|
|
26
|
+
"""Return the one statement to run (as the caller wrote it, without a trailing `;`), or raise NotReadOnly."""
|
|
27
|
+
if not isinstance(sql, str) or not sql.strip():
|
|
28
|
+
raise NotReadOnly("statement is empty")
|
|
29
|
+
try:
|
|
30
|
+
parsed = sqlglot.parse(sql, read="databricks")
|
|
31
|
+
except SqlglotError as e:
|
|
32
|
+
raise NotReadOnly(f"could not parse the statement ({_first_line(e)}); only a plain SELECT, DESCRIBE, SHOW or EXPLAIN is accepted") from e
|
|
33
|
+
statements = [s for s in parsed if s is not None]
|
|
34
|
+
if not statements:
|
|
35
|
+
raise NotReadOnly("statement is empty")
|
|
36
|
+
if len(statements) > 1:
|
|
37
|
+
raise NotReadOnly(f"{len(statements)} statements found; send exactly one")
|
|
38
|
+
root = statements[0]
|
|
39
|
+
while isinstance(root, (exp.Subquery, exp.Paren)) and root.this is not None:
|
|
40
|
+
root = root.this # (SELECT 1) is still a query
|
|
41
|
+
if isinstance(root, exp.Command):
|
|
42
|
+
word = str(root.this).upper()
|
|
43
|
+
if word not in COMMAND_WORDS:
|
|
44
|
+
raise NotReadOnly(f"{word} is not allowed; run_sql accepts SELECT, DESCRIBE, SHOW and EXPLAIN")
|
|
45
|
+
return sql.strip().rstrip(";").rstrip()
|
|
46
|
+
if not isinstance(root, QUERY_ROOTS + (exp.Describe,)):
|
|
47
|
+
raise NotReadOnly(f"{type(root).__name__.upper()} statements are not allowed; run_sql accepts SELECT, DESCRIBE, SHOW and EXPLAIN")
|
|
48
|
+
for node in root.walk():
|
|
49
|
+
if isinstance(node, FORBIDDEN):
|
|
50
|
+
raise NotReadOnly(f"{type(node).__name__.upper()} is not allowed inside a query; run_sql is read-only")
|
|
51
|
+
if isinstance(node, (exp.Anonymous, exp.Func)):
|
|
52
|
+
name = node.name.lower() if isinstance(node, exp.Anonymous) else type(node).__name__.lower()
|
|
53
|
+
if name in FORBIDDEN_FUNCTIONS:
|
|
54
|
+
raise NotReadOnly(f"{name}() is not allowed; it can run code or call outside the warehouse")
|
|
55
|
+
return sql.strip().rstrip(";").rstrip()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _first_line(e: Exception) -> str:
|
|
59
|
+
return str(e).strip().splitlines()[0][:120] if str(e).strip() else type(e).__name__
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""What a tool is: the registry, the decorator that adds one, the context handlers get, and the error they raise."""
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any, Callable, Optional
|
|
4
|
+
|
|
5
|
+
REGISTRY: dict[str, "Tool"] = {}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ToolError(Exception):
|
|
9
|
+
"""A tool could not do what was asked. The message goes back to the agent as an error result."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Context:
|
|
14
|
+
"""The app's connection state, as the tools see it. The tools never connect, and never choose a profile."""
|
|
15
|
+
explorer: Callable[[], Any] # the connected Explorer, or None
|
|
16
|
+
store: Callable[[], Any] # the relationship store of the connected profile (a context manager)
|
|
17
|
+
miner: Callable[[], Any] # the Miner of the connected Explorer
|
|
18
|
+
extra: dict = field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
def need_explorer(self):
|
|
21
|
+
"""The connected Explorer, or a ToolError that tells the agent to ask the person to connect."""
|
|
22
|
+
e = self.explorer()
|
|
23
|
+
if e is None:
|
|
24
|
+
raise ToolError("Not connected: connect a profile in the app first. The agent cannot connect or choose a profile.")
|
|
25
|
+
return e
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Tool:
|
|
30
|
+
name: str
|
|
31
|
+
description: str
|
|
32
|
+
schema: dict
|
|
33
|
+
handler: Callable[[Context, dict], Any]
|
|
34
|
+
read_only: bool = True
|
|
35
|
+
destructive: bool = False
|
|
36
|
+
|
|
37
|
+
def listing(self) -> dict:
|
|
38
|
+
"""The tools/list entry."""
|
|
39
|
+
return {"name": self.name, "description": self.description, "inputSchema": self.schema,
|
|
40
|
+
"annotations": {"readOnlyHint": self.read_only, "destructiveHint": self.destructive,
|
|
41
|
+
"idempotentHint": False, "openWorldHint": False}}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def obj(properties: Optional[dict] = None, required: tuple = ()) -> dict:
|
|
45
|
+
"""An input schema: an object with exactly these properties."""
|
|
46
|
+
return {"type": "object", "properties": properties or {}, "required": list(required), "additionalProperties": False}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
S = {"type": "string"}
|
|
50
|
+
B = {"type": "boolean"}
|
|
51
|
+
FORMAT = {"type": "string", "enum": ["text", "json"], "description": "text (default, compact) or json (everything)"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def integer(minimum: int, maximum: int, description: str = "") -> dict:
|
|
55
|
+
"""A bounded integer property."""
|
|
56
|
+
return {"type": "integer", "minimum": minimum, "maximum": maximum, **({"description": description} if description else {})}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def strings(description: str = "") -> dict:
|
|
60
|
+
"""A list of strings property."""
|
|
61
|
+
return {"type": "array", "items": S, **({"description": description} if description else {})}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def tool(name: str, description: str, schema: dict, read_only: bool = True, destructive: bool = False):
|
|
65
|
+
"""Register a handler `fn(ctx, args) -> str | list of MCP content blocks` as a tool."""
|
|
66
|
+
def add(fn):
|
|
67
|
+
REGISTRY[name] = Tool(name, description, schema, fn, read_only, destructive)
|
|
68
|
+
return fn
|
|
69
|
+
return add
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def text(s: str) -> list[dict]:
|
|
73
|
+
"""One MCP text block."""
|
|
74
|
+
return [{"type": "text", "text": s}]
|