Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions databricks_mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,28 @@ pip install git+https://git@github.com/databricks/databricks-ai-bridge.git#subdi

- **OAuth Provider**: Enables authentication across Databricks Notebooks, Model Serving, and local environments using the Databricks CLI.

## URL elicitation

An MCP server may ask the user to open a URL mid tool call (for example, to complete an auth flow). `DatabricksMCPClient` supports this, but **elicitation is off by default** — safe for agents embedded in headless or async runtimes (Model Serving, notebooks), where a stdin prompt or browser launch is meaningless and would block the event loop.

To opt in, pass an `elicitation_callback`. Agents should supply their own callback that surfaces the URL through their own channel (UI, response stream, approval card):

```python
from databricks_mcp import DatabricksMCPClient

client = DatabricksMCPClient(server_url, elicitation_callback=my_callback)
```

For local or CLI use, a ready-made interactive handler is provided. It prints a security warning, asks for confirmation on stdin, and opens the approved URL in a browser (running the blocking prompt off the event loop so it is safe from async code). Only URL-mode elicitation is supported; form-mode requests are rejected:

```python
from databricks_mcp import DatabricksMCPClient, interactive_url_elicitation_callback

client = DatabricksMCPClient(
server_url, elicitation_callback=interactive_url_elicitation_callback
)
```

---

## Contribution Guide
Expand Down
2 changes: 1 addition & 1 deletion databricks_mcp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ readme = "README.md"
license = { text="Apache-2.0" }
requires-python = ">=3.10"
dependencies = [
"mcp>=1.13.0",
"mcp>=1.23.0",
"databricks-sdk>=0.49.0",
"databricks-ai-bridge>=0.4.2",
"mlflow>=3.1"
Expand Down
9 changes: 7 additions & 2 deletions databricks_mcp/src/databricks_mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
from databricks_mcp.connector import register_mcp_server_via_dcr
from databricks_mcp.mcp import DatabricksMCPClient
from databricks_mcp.mcp import DatabricksMCPClient, interactive_url_elicitation_callback
from databricks_mcp.oauth_provider import DatabricksOAuthClientProvider

__all__ = ["DatabricksOAuthClientProvider", "DatabricksMCPClient", "register_mcp_server_via_dcr"]
__all__ = [
"DatabricksOAuthClientProvider",
"DatabricksMCPClient",
"register_mcp_server_via_dcr",
"interactive_url_elicitation_callback",
]
132 changes: 127 additions & 5 deletions databricks_mcp/src/databricks_mcp/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,25 @@
import json
import logging
import re
import webbrowser
from functools import wraps
from typing import Any, Callable, List, Optional
from urllib.parse import urlparse

import requests
from databricks.sdk import WorkspaceClient
from databricks_ai_bridge.utils.annotations import experimental
from mcp.client.session import ClientSession
from mcp.client.session import ClientSession, ElicitationFnT
from mcp.client.streamable_http import streamablehttp_client
from mcp.types import CallToolResult, Tool
from mcp.shared.context import RequestContext
from mcp.types import (
INVALID_REQUEST,
CallToolResult,
ElicitRequestParams,
ElicitResult,
ErrorData,
Tool,
)
from mlflow.models.resources import (
DatabricksFunction,
DatabricksGenieSpace,
Expand Down Expand Up @@ -142,6 +151,104 @@ def sync_wrapper(self, *args, **kwargs):
return sync_wrapper


# Schemes we are willing to hand to the user's browser. URL elicitation lets the
# server choose the URL, so anything outside http(s) (e.g. file://, javascript:) is
# refused rather than opened.
_ALLOWED_URL_SCHEMES = {"http", "https"}


def _blocking_url_prompt(url: str, netloc: str, message: str, elicitation_id: Any) -> ElicitResult:
"""Synchronously prompt the user to open a URL and open it on confirmation.

Uses blocking ``input()`` / ``webbrowser.open()``, so callers must run this off the event
loop (see ``_handle_url_elicitation``). Returns ``accept`` once the URL is opened,
``decline`` if the user refuses, and ``cancel`` when no decision can be obtained (e.g. no
interactive stdin).
"""
# The server controls the URL, so the confirmation prompt doubles as a security warning.
# It is written to stdout via input()'s prompt (rather than print) so the human sees it
# exactly when they are being asked to act.
prompt = (
"\n" + "=" * 60 + "\n"
"SECURITY WARNING: the MCP server is requesting you open an external URL\n"
+ "="
* 60
+ "\n"
f"\n Domain: {netloc}\n"
f" Full URL: {url}\n"
f"\n Server's reason:\n {message}\n"
f"\n Elicitation ID: {elicitation_id}\n" + "-" * 60 + "\n"
"\nOpen this URL in your browser? (y/n): "
)
try:
response = input(prompt).strip().lower()
except EOFError:
# No interactive stdin (e.g. running inside a service); cancel rather than guess.
return ElicitResult(action="cancel")

if response in ("y", "yes"):
try:
webbrowser.open(url)
except Exception as e:
logger.warning("Failed to open browser (%s); open the URL manually: %s", e, url)
logger.info("Opened browser for URL elicitation; awaiting completion: %s", url)
return ElicitResult(action="accept")
if response in ("n", "no"):
return ElicitResult(action="decline")

logger.info("Unrecognized response %r to URL elicitation; cancelling.", response)
return ElicitResult(action="cancel")


async def _handle_url_elicitation(params: ElicitRequestParams) -> ElicitResult:
"""Handle a URL-mode elicitation request by confirming with the user and opening the URL.

The server supplies the URL, so we validate the scheme and then run the interactive
confirmation off the event loop (``run_in_executor``) so blocking ``input()`` never
freezes the caller's loop when used from ``acall_tool``/``alist_tools``. Returns
``decline`` if the scheme is disallowed or the URL is missing.
"""
url = getattr(params, "url", None)
if not url:
logger.error("URL elicitation request did not include a URL; declining.")
return ElicitResult(action="decline")

parsed = urlparse(str(url))
if parsed.scheme.lower() not in _ALLOWED_URL_SCHEMES:
logger.warning("Refusing URL elicitation with disallowed scheme %r: %s", parsed.scheme, url)
return ElicitResult(action="decline")

loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None,
_blocking_url_prompt,
str(url),
parsed.netloc,
params.message,
getattr(params, "elicitationId", None),
)


async def interactive_url_elicitation_callback(
context: RequestContext["ClientSession", Any],
params: ElicitRequestParams,
) -> ElicitResult | ErrorData:
"""Opt-in elicitation callback that supports URL-mode elicitation interactively.

Pass this as ``elicitation_callback`` to :class:`DatabricksMCPClient` for local/CLI use: it
prompts the user on stdin and opens the approved URL in a browser. It is not the default,
because agents embedding this client should surface the URL through their own channel by
supplying their own callback. Form-mode elicitation is rejected so the server can fall back
rather than hang waiting for a response it will never get.
"""
if params.mode == "url":
return await _handle_url_elicitation(params)
return ErrorData(
code=INVALID_REQUEST,
message=f"Only URL elicitation is supported; got mode={params.mode!r}",
)


@experimental
class DatabricksMCPClient:
"""
Expand All @@ -156,9 +263,20 @@ class DatabricksMCPClient:
client (databricks.sdk.WorkspaceClient): The Databricks workspace client used for authentication and requests.
"""

def __init__(self, server_url: str, workspace_client: Optional[WorkspaceClient] = None):
def __init__(
self,
server_url: str,
workspace_client: Optional[WorkspaceClient] = None,
elicitation_callback: Optional[ElicitationFnT] = None,
):
self.client = workspace_client or WorkspaceClient()
self.server_url = server_url
# Elicitation is off by default: with no callback the ClientSession advertises no
# elicitation capability, which is the safe behavior when this client is embedded in a
# headless/async agent (Model Serving, notebooks). Callers opt in by injecting their own
# callback that surfaces the request through the agent's own channel, or by passing the
# exported interactive_url_elicitation_callback for local/CLI use.
self._elicitation_callback = elicitation_callback

# Early detection: error if using non-OAuth auth with Databricks Apps
if _is_databricks_apps_url(server_url) and not _is_oauth_auth(self.client):
Expand All @@ -184,7 +302,9 @@ async def _get_tools_async(self) -> List[Tool]:
url=self.server_url,
auth=DatabricksOAuthClientProvider(self.client),
) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
async with ClientSession(
read_stream, write_stream, elicitation_callback=self._elicitation_callback
) as session:
await session.initialize()
return (await session.list_tools()).tools

Expand All @@ -198,7 +318,9 @@ async def _call_tools_async(
url=self.server_url,
auth=DatabricksOAuthClientProvider(self.client),
) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
async with ClientSession(
read_stream, write_stream, elicitation_callback=self._elicitation_callback
) as session:
await session.initialize()
return await session.call_tool(tool_name, arguments)

Expand Down
Loading
Loading