Skip to content

Commit 1577df8

Browse files
✨ run MCP on Databricks Apps (#25)
* add initial mcp server on apps impl * add readme * add docs * uncomment function tools * fix lint * update versions * switch to stremeable transport * Update README.md * Update README.md --------- Co-authored-by: Siddharth Murching <[email protected]>
1 parent 84304f1 commit 1577df8

File tree

8 files changed

+336
-12
lines changed

8 files changed

+336
-12
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,6 @@ gunicorn.conf.py
124124
# ignore version file
125125
src/databricks/labs/mcp/_version.py
126126

127-
.ruff_cache/
127+
.ruff_cache/
128+
.build/
129+
.databricks

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Table of Contents
1313
- [Usage](#usage)
1414
- [Supported tools](#supported-tools)
1515
- [Developer Tools Server](#developer-tools-server)
16+
- [Deploying MCP servers on Databricks Apps](#deploying-mcp-servers-on-databricks-apps)
1617
- [Support](#support)
1718
- [Contributing](#contributing)
1819

@@ -77,6 +78,27 @@ the following tools:
7778

7879
This server is currently under construction. It is not yet usable, but contributions are welcome!
7980

81+
## Deploying MCP servers on Databricks Apps
82+
83+
You can deploy the Unity Catalog MCP server as a Databricks app. To do so, follow the instructions below:
84+
85+
1. Move into the project directory:
86+
```bash
87+
cd /path/to/this/repo
88+
```
89+
90+
2. Push app code to Databricks:
91+
```bash
92+
databricks bundle deploy -p <name-of-your-profile>
93+
```
94+
95+
3. Deploy the app:
96+
```bash
97+
databricks bundle run mcp-on-apps -p <name-of-your-profile>
98+
```
99+
100+
If you are a developer iterating on the server implementation, you can repeat steps #2 and #3 to push your latest modifications to the server to your Databricks app.
101+
80102
## Support
81103
Please note that all projects in the `databrickslabs` GitHub organization are provided for your exploration only, and are not formally supported by Databricks with Service Level Agreements (SLAs). They are provided AS-IS and we do not make any guarantees of any kind. Please do not submit a support ticket relating to any issues arising from the use of these projects.
82104

@@ -86,3 +108,4 @@ Any issues discovered through the use of this project should be filed as GitHub
86108

87109
We welcome contributions :) - see [CONTRIBUTING.md](./CONTRIBUTING.md) for details. Please make sure to read this guide before
88110
submitting pull requests, to ensure your contribution has the best chance of being accepted.
111+

databricks.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
bundle:
2+
name: mcp-on-apps
3+
4+
sync:
5+
include:
6+
- .build
7+
8+
artifacts:
9+
default:
10+
type: whl
11+
path: .
12+
build: uv build --wheel
13+
14+
resources:
15+
apps:
16+
mcp-on-apps:
17+
name: "mcp-on-apps"
18+
description: "MCP Server on Databricks Apps"
19+
source_code_path: ./.build
20+
config:
21+
command: ["unitycatalog-mcp-app"]
22+
23+
targets:
24+
dev:
25+
mode: development
26+
default: true

hooks/apps_build.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from typing import Any
2+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
3+
from pathlib import Path
4+
import shutil
5+
6+
7+
class AppsBuildHook(BuildHookInterface):
8+
"""Hook to create a Databricks Apps-compatible build.
9+
10+
This hook is used to create a Databricks Apps-compatible build of the project.
11+
12+
The following steps are performed:
13+
- Remove the ./.build folder if it exists.
14+
- Copy the artifact_path to the ./.build folder.
15+
- Write the name of the artifact to a requirements.txt file in the ./.build folder.
16+
- The resulting build directory is printed to the console.
17+
18+
"""
19+
20+
def finalize(
21+
self, version: str, build_data: dict[str, Any], artifact_path: str
22+
) -> None:
23+
self.app.display_info(
24+
f"Running Databricks Apps build hook for project {self.metadata.name} in directory {Path.cwd()}"
25+
)
26+
# remove the ./.build folder if it exists
27+
build_dir = Path(".build")
28+
self.app.display_info(f"Resulting build directory: {build_dir.absolute()}")
29+
30+
if build_dir.exists():
31+
self.app.display_info(f"Removing {build_dir}")
32+
shutil.rmtree(build_dir)
33+
self.app.display_info(f"Removed {build_dir}")
34+
else:
35+
self.app.display_info(f"{build_dir} does not exist, skipping removal")
36+
37+
# copy the artifact_path to the ./.build folder
38+
build_dir.mkdir(exist_ok=True)
39+
self.app.display_info(f"Copying {artifact_path} to {build_dir}")
40+
shutil.copy(artifact_path, build_dir)
41+
42+
# write the name of the artifact to a requirements.txt file in the ./.build folder
43+
requirements_file = build_dir / "requirements.txt"
44+
45+
requirements_file.write_text(Path(artifact_path).name, encoding="utf-8")
46+
47+
self.app.display_info(
48+
f"Apps-compatible build written to {build_dir.absolute()}"
49+
)

pyproject.toml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ requires-python = ">=3.10"
88
keywords = ["databricks", "unity catalog", "mcp", "agents", "llm", "automation", "genie"]
99

1010
dependencies = [
11-
"mcp>=1.2.1",
11+
"mcp>=1.8.1",
1212
"pydantic>=2.10.6",
1313
"pydantic-settings>=2.7.1",
1414
"unitycatalog-ai>=0.1.0",
15-
"databricks-sdk>=0.49.0",
15+
"databricks-sdk>=0.53.0",
1616
"databricks-openai>=0.3.1",
1717
]
1818
license-files = ["LICENSE", "NOTICE"]
@@ -24,10 +24,15 @@ dev-dependencies = [
2424
"ruff>=0.9.4",
2525
"pytest>=8.3.4",
2626
"isort>=6.0.1",
27+
"hatchling>=1.27.0",
2728
]
2829

30+
[tool.hatch.build.hooks.custom]
31+
path = "hooks/apps_build.py"
32+
2933
[project.scripts]
3034
unitycatalog-mcp = "databricks.labs.mcp.servers.unity_catalog:main"
35+
unitycatalog-mcp-app = "databricks.labs.mcp.servers.unity_catalog.app:start_app"
3136

3237
[build-system]
3338
requires = ["hatchling", "hatch-fancy-pypi-readme", "hatch-vcs"]

src/databricks/labs/mcp/base.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""
2+
Collection of base utils for MCP servers.
3+
"""
4+
5+
import contextlib
6+
import logging
7+
from collections import deque
8+
from dataclasses import dataclass
9+
from typing import AsyncIterator
10+
from uuid import uuid4
11+
from starlette.applications import Starlette
12+
from starlette.routing import Mount
13+
from starlette.types import Receive, Scope, Send
14+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
15+
16+
from mcp.server import Server
17+
18+
from mcp.server.streamable_http import (
19+
EventCallback,
20+
EventId,
21+
EventMessage,
22+
EventStore,
23+
StreamId,
24+
)
25+
from mcp.types import JSONRPCMessage
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
@dataclass
31+
class EventEntry:
32+
"""
33+
Represents an event entry in the event store.
34+
"""
35+
36+
event_id: EventId
37+
stream_id: StreamId
38+
message: JSONRPCMessage
39+
40+
41+
class InMemoryEventStore(EventStore):
42+
"""
43+
Simple in-memory implementation of the EventStore interface for resumability.
44+
This is primarily intended for examples and testing, not for production use
45+
where a persistent storage solution would be more appropriate.
46+
47+
This implementation keeps only the last N events per stream for memory efficiency.
48+
"""
49+
50+
def __init__(self, max_events_per_stream: int = 100):
51+
"""Initialize the event store.
52+
53+
Args:
54+
max_events_per_stream: Maximum number of events to keep per stream
55+
"""
56+
self.max_events_per_stream = max_events_per_stream
57+
# for maintaining last N events per stream
58+
self.streams: dict[StreamId, deque[EventEntry]] = {}
59+
# event_id -> EventEntry for quick lookup
60+
self.event_index: dict[EventId, EventEntry] = {}
61+
62+
async def store_event(
63+
self, stream_id: StreamId, message: JSONRPCMessage
64+
) -> EventId:
65+
"""Stores an event with a generated event ID."""
66+
event_id = str(uuid4())
67+
event_entry = EventEntry(
68+
event_id=event_id, stream_id=stream_id, message=message
69+
)
70+
71+
# Get or create deque for this stream
72+
if stream_id not in self.streams:
73+
self.streams[stream_id] = deque(maxlen=self.max_events_per_stream)
74+
75+
# If deque is full, the oldest event will be automatically removed
76+
# We need to remove it from the event_index as well
77+
if len(self.streams[stream_id]) == self.max_events_per_stream:
78+
oldest_event = self.streams[stream_id][0]
79+
self.event_index.pop(oldest_event.event_id, None)
80+
81+
# Add new event
82+
self.streams[stream_id].append(event_entry)
83+
self.event_index[event_id] = event_entry
84+
85+
return event_id
86+
87+
async def replay_events_after(
88+
self,
89+
last_event_id: EventId,
90+
send_callback: EventCallback,
91+
) -> StreamId | None:
92+
"""Replays events that occurred after the specified event ID."""
93+
if last_event_id not in self.event_index:
94+
logger.warning(f"Event ID {last_event_id} not found in store")
95+
return None
96+
97+
# Get the stream and find events after the last one
98+
last_event = self.event_index[last_event_id]
99+
stream_id = last_event.stream_id
100+
stream_events = self.streams.get(last_event.stream_id, deque())
101+
102+
# Events in deque are already in chronological order
103+
found_last = False
104+
for event in stream_events:
105+
if found_last:
106+
await send_callback(EventMessage(event.message, event.event_id))
107+
elif event.event_id == last_event_id:
108+
found_last = True
109+
110+
return stream_id
111+
112+
113+
async def get_serveable_app(app: Server, json_response: bool = True) -> Starlette:
114+
115+
event_store = InMemoryEventStore()
116+
117+
# Create the session manager with our app and event store
118+
session_manager = StreamableHTTPSessionManager(
119+
app=app,
120+
event_store=event_store, # Enable resumability
121+
json_response=json_response,
122+
)
123+
124+
# ASGI handler for streamable HTTP connections
125+
async def handle_streamable_http(
126+
scope: Scope, receive: Receive, send: Send
127+
) -> None:
128+
await session_manager.handle_request(scope, receive, send)
129+
130+
@contextlib.asynccontextmanager
131+
async def lifespan(app: Starlette) -> AsyncIterator[None]:
132+
"""Context manager for managing session manager lifecycle."""
133+
async with session_manager.run():
134+
logger.info("Application started with StreamableHTTP session manager!")
135+
try:
136+
yield
137+
finally:
138+
logger.info("Application shutting down...")
139+
140+
# Create an ASGI application using the transport
141+
return Starlette(
142+
debug=True,
143+
routes=[
144+
Mount("/mcp", app=handle_streamable_http),
145+
],
146+
lifespan=lifespan,
147+
)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from mcp.server import Server
2+
from mcp.types import Tool as ToolSpec
3+
import uvicorn
4+
from databricks.labs.mcp.base import get_serveable_app
5+
from databricks.labs.mcp.servers.unity_catalog.tools import (
6+
Content,
7+
)
8+
from databricks.labs.mcp.servers.unity_catalog.cli import get_settings
9+
10+
from databricks.labs.mcp._version import __version__ as VERSION
11+
from databricks.labs.mcp.servers.unity_catalog.server import get_tools_dict
12+
13+
14+
app = Server(name="mcp-unitycatalog", version=VERSION)
15+
tools_dict = get_tools_dict(settings=get_settings())
16+
17+
18+
@app.list_tools()
19+
async def list_tools() -> list[ToolSpec]:
20+
return [tool.tool_spec for tool in tools_dict.values()]
21+
22+
23+
@app.call_tool()
24+
async def call_tool(name: str, arguments: dict) -> list[Content]:
25+
tool = tools_dict[name]
26+
return tool.execute(**arguments)
27+
28+
29+
def start_app():
30+
serveable = get_serveable_app(app)
31+
uvicorn.run(serveable, host="0.0.0.0", port=8000)
32+
33+
34+
if __name__ == "__main__":
35+
start_app()

0 commit comments

Comments
 (0)