databricks-mcp-toolkit 0.2.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_mcp/__init__.py +10 -0
- databricks_mcp/__main__.py +10 -0
- databricks_mcp/api/__init__.py +30 -0
- databricks_mcp/api/clusters.py +73 -0
- databricks_mcp/api/command_execution.py +92 -0
- databricks_mcp/api/dbfs.py +37 -0
- databricks_mcp/api/files.py +94 -0
- databricks_mcp/api/jobs.py +144 -0
- databricks_mcp/api/sql.py +99 -0
- databricks_mcp/api/unity_catalog.py +76 -0
- databricks_mcp/api/warehouses.py +32 -0
- databricks_mcp/api/workspace.py +67 -0
- databricks_mcp/cli.py +100 -0
- databricks_mcp/client.py +208 -0
- databricks_mcp/config.py +56 -0
- databricks_mcp/server.py +642 -0
- databricks_mcp_toolkit-0.2.0.dist-info/METADATA +282 -0
- databricks_mcp_toolkit-0.2.0.dist-info/RECORD +21 -0
- databricks_mcp_toolkit-0.2.0.dist-info/WHEEL +4 -0
- databricks_mcp_toolkit-0.2.0.dist-info/entry_points.txt +2 -0
- databricks_mcp_toolkit-0.2.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 Kush Patel
|
|
2
|
+
# Dual-licensed: AGPL-3.0-only OR commercial license.
|
|
3
|
+
# If you obtained this from GitHub or PyPI, you may use it under the AGPL-3.0 terms.
|
|
4
|
+
# For commercial terms, see the project README.
|
|
5
|
+
|
|
6
|
+
"""SQL Warehouses API 2.0."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from databricks_mcp.client import DatabricksClient
|
|
13
|
+
|
|
14
|
+
BASE = "/api/2.0/sql/warehouses"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def list_warehouses(client: DatabricksClient) -> dict[str, Any]:
|
|
18
|
+
return await client.get(BASE)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def get_warehouse(client: DatabricksClient, warehouse_id: str) -> dict[str, Any]:
|
|
22
|
+
return await client.get(f"{BASE}/{warehouse_id}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def start_warehouse(client: DatabricksClient, warehouse_id: str) -> dict[str, Any]:
|
|
26
|
+
await client.post(f"{BASE}/{warehouse_id}/start")
|
|
27
|
+
return {"warehouse_id": warehouse_id, "start_requested": True}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def stop_warehouse(client: DatabricksClient, warehouse_id: str) -> dict[str, Any]:
|
|
31
|
+
await client.post(f"{BASE}/{warehouse_id}/stop")
|
|
32
|
+
return {"warehouse_id": warehouse_id, "stop_requested": True}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 Kush Patel
|
|
2
|
+
# Dual-licensed: AGPL-3.0-only OR commercial license.
|
|
3
|
+
# If you obtained this from GitHub or PyPI, you may use it under the AGPL-3.0 terms.
|
|
4
|
+
# For commercial terms, see the project README.
|
|
5
|
+
|
|
6
|
+
"""Workspace API 2.0 (notebooks, folders, workspace files)."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from databricks_mcp.client import DatabricksClient
|
|
14
|
+
|
|
15
|
+
BASE = "/api/2.0/workspace"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def list_objects(client: DatabricksClient, path: str) -> dict[str, Any]:
|
|
19
|
+
return await client.get(f"{BASE}/list", {"path": path})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def get_status(client: DatabricksClient, path: str) -> dict[str, Any]:
|
|
23
|
+
return await client.get(f"{BASE}/get-status", {"path": path})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def export_notebook(client: DatabricksClient, path: str, format: str = "SOURCE") -> dict[str, Any]:
|
|
27
|
+
"""Export and decode. SOURCE/JUPYTER/HTML come back as text; DBC stays base64."""
|
|
28
|
+
result = await client.get(f"{BASE}/export", {"path": path, "format": format})
|
|
29
|
+
encoded = result.get("content")
|
|
30
|
+
if encoded and format != "DBC":
|
|
31
|
+
try:
|
|
32
|
+
result["content"] = base64.b64decode(encoded).decode("utf-8")
|
|
33
|
+
result["encoding"] = "utf-8"
|
|
34
|
+
except (ValueError, UnicodeDecodeError):
|
|
35
|
+
result["encoding"] = "base64"
|
|
36
|
+
elif encoded:
|
|
37
|
+
result["encoding"] = "base64"
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def import_notebook(
|
|
42
|
+
client: DatabricksClient,
|
|
43
|
+
path: str,
|
|
44
|
+
content: str,
|
|
45
|
+
language: str | None = None,
|
|
46
|
+
format: str = "SOURCE",
|
|
47
|
+
overwrite: bool = False,
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
body: dict[str, Any] = {
|
|
50
|
+
"path": path,
|
|
51
|
+
"format": format,
|
|
52
|
+
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
|
53
|
+
"overwrite": overwrite,
|
|
54
|
+
}
|
|
55
|
+
if language:
|
|
56
|
+
body["language"] = language
|
|
57
|
+
elif format == "SOURCE":
|
|
58
|
+
raise ValueError("language is required when format is SOURCE.")
|
|
59
|
+
return await client.post(f"{BASE}/import", body)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def mkdirs(client: DatabricksClient, path: str) -> dict[str, Any]:
|
|
63
|
+
return await client.post(f"{BASE}/mkdirs", {"path": path})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def delete(client: DatabricksClient, path: str, recursive: bool = False) -> dict[str, Any]:
|
|
67
|
+
return await client.post(f"{BASE}/delete", {"path": path, "recursive": recursive})
|
databricks_mcp/cli.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 Kush Patel
|
|
2
|
+
# Dual-licensed: AGPL-3.0-only OR commercial license.
|
|
3
|
+
# If you obtained this from GitHub or PyPI, you may use it under the AGPL-3.0 terms.
|
|
4
|
+
# For commercial terms, see the project README.
|
|
5
|
+
|
|
6
|
+
"""`databricks-mcp` command line entry point."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import replace
|
|
16
|
+
|
|
17
|
+
from databricks_mcp import __version__
|
|
18
|
+
from databricks_mcp.config import Settings
|
|
19
|
+
from databricks_mcp.server import TOOLSETS, build_server
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_toolsets(raw: str | None) -> list[str] | None:
|
|
23
|
+
if not raw:
|
|
24
|
+
return None
|
|
25
|
+
return [t.strip() for t in raw.split(",") if t.strip()]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parser() -> argparse.ArgumentParser:
|
|
29
|
+
parser = argparse.ArgumentParser(prog="databricks-mcp", description="MCP server for Databricks.")
|
|
30
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
31
|
+
sub = parser.add_subparsers(dest="command")
|
|
32
|
+
|
|
33
|
+
run = sub.add_parser("run", help="Run the MCP server (the default command).")
|
|
34
|
+
run.add_argument(
|
|
35
|
+
"--transport",
|
|
36
|
+
choices=["stdio", "streamable-http"],
|
|
37
|
+
default=os.environ.get("DATABRICKS_MCP_TRANSPORT", "stdio"),
|
|
38
|
+
help="stdio for desktop clients (default); streamable-http to serve over HTTP.",
|
|
39
|
+
)
|
|
40
|
+
run.add_argument("--host", default="127.0.0.1", help="Bind address for streamable-http.")
|
|
41
|
+
run.add_argument("--port", type=int, default=8000, help="Port for streamable-http.")
|
|
42
|
+
for p in (run, sub.add_parser("list-tools", help="Print the tools the server would expose.")):
|
|
43
|
+
p.add_argument("--read-only", action="store_true", help="Only expose tools that don't change anything.")
|
|
44
|
+
p.add_argument(
|
|
45
|
+
"--toolsets",
|
|
46
|
+
default=os.environ.get("DATABRICKS_MCP_TOOLSETS"),
|
|
47
|
+
help=f"Comma-separated subset of: {','.join(TOOLSETS)}. Default: all.",
|
|
48
|
+
)
|
|
49
|
+
run.add_argument(
|
|
50
|
+
"--log-level",
|
|
51
|
+
type=str.upper,
|
|
52
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
|
53
|
+
default=os.environ.get("DATABRICKS_MCP_LOG_LEVEL", "WARNING").upper(),
|
|
54
|
+
)
|
|
55
|
+
return parser
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _settings(args: argparse.Namespace) -> Settings:
|
|
59
|
+
settings = Settings.from_env()
|
|
60
|
+
if args.read_only:
|
|
61
|
+
settings = replace(settings, read_only=True)
|
|
62
|
+
return settings
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main(argv: list[str] | None = None) -> int:
|
|
66
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
67
|
+
# `run` is the default, so `databricks-mcp --read-only` works in client configs.
|
|
68
|
+
if not argv or argv[0] not in {"run", "list-tools", "-h", "--help", "--version"}:
|
|
69
|
+
argv = ["run", *argv]
|
|
70
|
+
args = _parser().parse_args(argv)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
server = build_server(
|
|
74
|
+
settings=_settings(args),
|
|
75
|
+
toolsets=_parse_toolsets(args.toolsets),
|
|
76
|
+
log_level=getattr(args, "log_level", "WARNING"),
|
|
77
|
+
)
|
|
78
|
+
except ValueError as exc:
|
|
79
|
+
print(f"databricks-mcp: {exc}", file=sys.stderr)
|
|
80
|
+
return 2
|
|
81
|
+
|
|
82
|
+
if args.command == "list-tools":
|
|
83
|
+
for tool in asyncio.run(server.list_tools()):
|
|
84
|
+
ro = " [read-only]" if tool.annotations and tool.annotations.read_only_hint else ""
|
|
85
|
+
print(f"{tool.name}{ro}\n {(tool.description or '').splitlines()[0]}")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
# stdout is the MCP channel under stdio, so logs must go to stderr.
|
|
89
|
+
logging.basicConfig(stream=sys.stderr, level=args.log_level, format="%(levelname)s %(name)s: %(message)s")
|
|
90
|
+
# httpx logs every request URL at INFO; keep that out unless asked for.
|
|
91
|
+
logging.getLogger("httpx").setLevel(max(logging.WARNING, logging.getLevelName(args.log_level)))
|
|
92
|
+
if args.transport == "stdio":
|
|
93
|
+
server.run("stdio")
|
|
94
|
+
else:
|
|
95
|
+
server.run("streamable-http", host=args.host, port=args.port)
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
sys.exit(main())
|
databricks_mcp/client.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 Kush Patel
|
|
2
|
+
# Dual-licensed: AGPL-3.0-only OR commercial license.
|
|
3
|
+
# If you obtained this from GitHub or PyPI, you may use it under the AGPL-3.0 terms.
|
|
4
|
+
# For commercial terms, see the project README.
|
|
5
|
+
|
|
6
|
+
"""Async HTTP client for the Databricks REST API."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import Callable, Mapping
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from databricks_mcp import __version__
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Returns (workspace host, auth headers). Called per request so OAuth tokens refresh.
|
|
22
|
+
AuthProvider = Callable[[], tuple[str, dict[str, str]]]
|
|
23
|
+
|
|
24
|
+
# Safe to resend after a 5xx or timeout. POSTs are not: a retried clusters/create
|
|
25
|
+
# can leave you with two clusters.
|
|
26
|
+
_IDEMPOTENT = {"GET", "HEAD", "PUT", "DELETE"}
|
|
27
|
+
_MAX_BACKOFF_SECONDS = 30.0
|
|
28
|
+
_USER_AGENT = f"databricks-mcp-toolkit/{__version__}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DatabricksAPIError(Exception):
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
message: str,
|
|
35
|
+
status_code: int | None = None,
|
|
36
|
+
error_code: str | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.message = message
|
|
40
|
+
self.status_code = status_code
|
|
41
|
+
self.error_code = error_code
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str:
|
|
44
|
+
parts = []
|
|
45
|
+
if self.status_code:
|
|
46
|
+
parts.append(f"HTTP {self.status_code}")
|
|
47
|
+
if self.error_code:
|
|
48
|
+
parts.append(self.error_code)
|
|
49
|
+
prefix = " ".join(parts)
|
|
50
|
+
return f"{prefix}: {self.message}" if prefix else self.message
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sdk_auth_provider() -> AuthProvider:
|
|
54
|
+
"""Auth via databricks-sdk, so every auth type the SDK supports works here too.
|
|
55
|
+
|
|
56
|
+
I build the Config lazily: `databricks-mcp list-tools` shouldn't need credentials.
|
|
57
|
+
"""
|
|
58
|
+
config = None
|
|
59
|
+
|
|
60
|
+
def provide() -> tuple[str, dict[str, str]]:
|
|
61
|
+
nonlocal config
|
|
62
|
+
if config is None:
|
|
63
|
+
from databricks.sdk.config import Config
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
config = Config()
|
|
67
|
+
except Exception as exc: # the SDK raises ValueError with a decent message
|
|
68
|
+
raise DatabricksAPIError(
|
|
69
|
+
"Databricks auth is not configured. Set DATABRICKS_HOST plus "
|
|
70
|
+
"DATABRICKS_TOKEN (or OAuth / a config profile). "
|
|
71
|
+
f"SDK said: {exc}"
|
|
72
|
+
) from exc
|
|
73
|
+
return config.host.rstrip("/"), config.authenticate()
|
|
74
|
+
|
|
75
|
+
return provide
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _clean_params(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
79
|
+
if not params:
|
|
80
|
+
return None
|
|
81
|
+
return {k: v for k, v in params.items() if v is not None}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _error_from_response(response: httpx.Response) -> DatabricksAPIError:
|
|
85
|
+
message = ""
|
|
86
|
+
error_code = None
|
|
87
|
+
try:
|
|
88
|
+
body = response.json()
|
|
89
|
+
if isinstance(body, dict):
|
|
90
|
+
error_code = body.get("error_code")
|
|
91
|
+
message = body.get("message") or body.get("error") or ""
|
|
92
|
+
except ValueError:
|
|
93
|
+
pass
|
|
94
|
+
if not message:
|
|
95
|
+
message = response.text[:500] or response.reason_phrase
|
|
96
|
+
return DatabricksAPIError(message, status_code=response.status_code, error_code=error_code)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class DatabricksClient:
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
auth: AuthProvider | None = None,
|
|
103
|
+
*,
|
|
104
|
+
timeout_seconds: float = 60.0,
|
|
105
|
+
max_retries: int = 3,
|
|
106
|
+
backoff_seconds: float = 1.0,
|
|
107
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
self._auth = auth or sdk_auth_provider()
|
|
110
|
+
self._timeout = httpx.Timeout(timeout_seconds, connect=10.0)
|
|
111
|
+
self._max_retries = max_retries
|
|
112
|
+
self._backoff = backoff_seconds
|
|
113
|
+
self._transport = transport
|
|
114
|
+
self._http: httpx.AsyncClient | None = None
|
|
115
|
+
|
|
116
|
+
def _client(self) -> httpx.AsyncClient:
|
|
117
|
+
if self._http is None:
|
|
118
|
+
self._http = httpx.AsyncClient(
|
|
119
|
+
timeout=self._timeout,
|
|
120
|
+
transport=self._transport,
|
|
121
|
+
headers={"User-Agent": _USER_AGENT},
|
|
122
|
+
)
|
|
123
|
+
return self._http
|
|
124
|
+
|
|
125
|
+
async def aclose(self) -> None:
|
|
126
|
+
if self._http is not None:
|
|
127
|
+
await self._http.aclose()
|
|
128
|
+
self._http = None
|
|
129
|
+
|
|
130
|
+
def _delay(self, attempt: int, response: httpx.Response | None = None) -> float:
|
|
131
|
+
if response is not None:
|
|
132
|
+
retry_after = response.headers.get("Retry-After")
|
|
133
|
+
if retry_after:
|
|
134
|
+
try:
|
|
135
|
+
return min(float(retry_after), _MAX_BACKOFF_SECONDS)
|
|
136
|
+
except ValueError:
|
|
137
|
+
pass
|
|
138
|
+
return min(self._backoff * (2**attempt), _MAX_BACKOFF_SECONDS)
|
|
139
|
+
|
|
140
|
+
async def request(
|
|
141
|
+
self,
|
|
142
|
+
method: str,
|
|
143
|
+
path: str,
|
|
144
|
+
*,
|
|
145
|
+
params: Mapping[str, Any] | None = None,
|
|
146
|
+
json: Any = None,
|
|
147
|
+
content: bytes | None = None,
|
|
148
|
+
headers: Mapping[str, str] | None = None,
|
|
149
|
+
raw: bool = False,
|
|
150
|
+
) -> Any:
|
|
151
|
+
"""Send a request. Returns parsed JSON ({} for an empty body), or the
|
|
152
|
+
httpx.Response itself when raw=True (used by the Files API)."""
|
|
153
|
+
method = method.upper()
|
|
154
|
+
host, auth_headers = self._auth()
|
|
155
|
+
url = f"{host}{path if path.startswith('/') else '/' + path}"
|
|
156
|
+
merged_headers = {**auth_headers, **(headers or {})}
|
|
157
|
+
clean = _clean_params(params)
|
|
158
|
+
|
|
159
|
+
attempt = 0
|
|
160
|
+
while True:
|
|
161
|
+
try:
|
|
162
|
+
response = await self._client().request(
|
|
163
|
+
method,
|
|
164
|
+
url,
|
|
165
|
+
params=clean,
|
|
166
|
+
json=json,
|
|
167
|
+
content=content,
|
|
168
|
+
headers=merged_headers,
|
|
169
|
+
)
|
|
170
|
+
except httpx.ConnectError as exc:
|
|
171
|
+
# Never reached the server, so any method is safe to resend.
|
|
172
|
+
if attempt < self._max_retries:
|
|
173
|
+
await asyncio.sleep(self._delay(attempt))
|
|
174
|
+
attempt += 1
|
|
175
|
+
continue
|
|
176
|
+
raise DatabricksAPIError(f"Could not connect to {host}: {exc}") from exc
|
|
177
|
+
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
|
178
|
+
if method in _IDEMPOTENT and attempt < self._max_retries:
|
|
179
|
+
await asyncio.sleep(self._delay(attempt))
|
|
180
|
+
attempt += 1
|
|
181
|
+
continue
|
|
182
|
+
raise DatabricksAPIError(f"{method} {path} failed: {exc!r}") from exc
|
|
183
|
+
|
|
184
|
+
status = response.status_code
|
|
185
|
+
retryable = status == 429 or (status >= 500 and method in _IDEMPOTENT)
|
|
186
|
+
if retryable and attempt < self._max_retries:
|
|
187
|
+
delay = self._delay(attempt, response)
|
|
188
|
+
logger.warning("%s %s -> %s, retrying in %.1fs", method, path, status, delay)
|
|
189
|
+
await asyncio.sleep(delay)
|
|
190
|
+
attempt += 1
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
if status >= 400:
|
|
194
|
+
raise _error_from_response(response)
|
|
195
|
+
if raw:
|
|
196
|
+
return response
|
|
197
|
+
if not response.content:
|
|
198
|
+
return {}
|
|
199
|
+
try:
|
|
200
|
+
return response.json()
|
|
201
|
+
except ValueError:
|
|
202
|
+
return {"raw": response.text}
|
|
203
|
+
|
|
204
|
+
async def get(self, path: str, params: Mapping[str, Any] | None = None) -> Any:
|
|
205
|
+
return await self.request("GET", path, params=params)
|
|
206
|
+
|
|
207
|
+
async def post(self, path: str, json: Any = None) -> Any:
|
|
208
|
+
return await self.request("POST", path, json=json if json is not None else {})
|
databricks_mcp/config.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 Kush Patel
|
|
2
|
+
# Dual-licensed: AGPL-3.0-only OR commercial license.
|
|
3
|
+
# If you obtained this from GitHub or PyPI, you may use it under the AGPL-3.0 terms.
|
|
4
|
+
# For commercial terms, see the project README.
|
|
5
|
+
|
|
6
|
+
"""Server settings.
|
|
7
|
+
|
|
8
|
+
Databricks auth is NOT configured here. I hand that to databricks-sdk's unified auth,
|
|
9
|
+
so anything the Databricks CLI accepts works: DATABRICKS_HOST + DATABRICKS_TOKEN,
|
|
10
|
+
OAuth M2M (DATABRICKS_CLIENT_ID / DATABRICKS_CLIENT_SECRET), a ~/.databrickscfg
|
|
11
|
+
profile via DATABRICKS_CONFIG_PROFILE, Azure CLI, and so on.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
_TRUE = {"1", "true", "yes", "on"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _env_bool(name: str, default: bool = False) -> bool:
|
|
23
|
+
raw = os.environ.get(name)
|
|
24
|
+
if raw is None or raw.strip() == "":
|
|
25
|
+
return default
|
|
26
|
+
return raw.strip().lower() in _TRUE
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _env_int(name: str, default: int) -> int:
|
|
30
|
+
raw = os.environ.get(name)
|
|
31
|
+
if raw is None or raw.strip() == "":
|
|
32
|
+
return default
|
|
33
|
+
try:
|
|
34
|
+
return int(raw)
|
|
35
|
+
except ValueError as exc:
|
|
36
|
+
raise ValueError(f"{name} must be an integer, got {raw!r}") from exc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Settings:
|
|
41
|
+
warehouse_id: str | None = None
|
|
42
|
+
read_only: bool = False
|
|
43
|
+
# Hard cap on what a single tool returns to the model. Big JSON blobs eat context fast.
|
|
44
|
+
max_response_chars: int = 50_000
|
|
45
|
+
http_timeout_seconds: int = 60
|
|
46
|
+
max_retries: int = 3
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_env(cls) -> Settings:
|
|
50
|
+
return cls(
|
|
51
|
+
warehouse_id=os.environ.get("DATABRICKS_WAREHOUSE_ID") or None,
|
|
52
|
+
read_only=_env_bool("DATABRICKS_MCP_READ_ONLY"),
|
|
53
|
+
max_response_chars=_env_int("DATABRICKS_MCP_MAX_RESPONSE_CHARS", 50_000),
|
|
54
|
+
http_timeout_seconds=_env_int("DATABRICKS_MCP_HTTP_TIMEOUT", 60),
|
|
55
|
+
max_retries=_env_int("DATABRICKS_MCP_MAX_RETRIES", 3),
|
|
56
|
+
)
|