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.
@@ -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())
@@ -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 {})
@@ -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
+ )