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
7 changes: 7 additions & 0 deletions dash/_callback.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import collections
import hashlib
import inspect
from datetime import datetime, timezone
from functools import wraps

from typing import Callable, Optional, Any, List, Tuple, Union, Dict
Expand Down Expand Up @@ -425,6 +426,12 @@ def _setup_background_callback(
ctx_value,
)

callback_manager.handle.set(
f"{cache_key}-created_at",
datetime.now(timezone.utc).isoformat(),
expire=callback_manager.expire,
)

data = {
"cacheKey": cache_key,
"job": job,
Expand Down
8 changes: 7 additions & 1 deletion dash/mcp/_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
list_tools,
read_resource,
)
from dash.mcp.tasks import get_task, get_task_result, cancel_task
from dash.mcp.primitives.tools.callback_adapter_collection import (
CallbackAdapterCollection,
)
Expand Down Expand Up @@ -163,11 +164,16 @@ def _process_mcp_message(data: dict[str, Any]) -> dict[str, Any] | None:
"initialize": _handle_initialize,
"tools/list": list_tools,
"tools/call": lambda: call_tool(
params.get("name", ""), params.get("arguments", {})
tool_name=params.get("name", ""),
arguments=params.get("arguments", {}),
task=params.get("task"),
),
"resources/list": list_resources,
"resources/templates/list": list_resource_templates,
"resources/read": lambda: read_resource(params.get("uri", "")),
"tasks/get": lambda: get_task(task_id=params.get("taskId", "")),
"tasks/result": lambda: get_task_result(task_id=params.get("taskId", "")),
"tasks/cancel": lambda: cancel_task(task_id=params.get("taskId", "")),
}

try:
Expand Down
16 changes: 12 additions & 4 deletions dash/mcp/primitives/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@

from typing import Any

from mcp.types import CallToolResult, ListToolsResult
from mcp.types import CallToolResult, CreateTaskResult, ListToolsResult

from dash.mcp.types import ToolNotFoundError

from .base import MCPToolProvider
from .tool_background_tasks import BackgroundTaskTools
from .tool_decorated_mcp_functions import DecoratedFunctionTools
from .tool_get_dash_component import GetDashComponentTool
from .tools_callbacks import CallbackTools

_TOOL_PROVIDERS: list[type[MCPToolProvider]] = [
CallbackTools,
BackgroundTaskTools,
GetDashComponentTool,
DecoratedFunctionTools,
]
Expand All @@ -28,11 +30,17 @@ def list_tools() -> ListToolsResult:
return ListToolsResult(tools=tools)


def call_tool(tool_name: str, arguments: dict[str, Any]) -> CallToolResult:
"""Route a tools/call request by tool name."""
def call_tool(
tool_name: str, arguments: dict[str, Any], task: dict | None = None
) -> CallToolResult | CreateTaskResult:
"""Route a tools/call request by tool name.

The optional ``task`` parameter (per MCP Tasks protocol) is passed
through to providers that support background callbacks.
"""
for provider in _TOOL_PROVIDERS:
if tool_name in provider.get_tool_names():
return provider.call_tool(tool_name, arguments)
return provider.call_tool(tool_name, arguments, task=task)
raise ToolNotFoundError(
f"Tool not found: {tool_name}."
" The app's callbacks may have changed."
Expand Down
6 changes: 4 additions & 2 deletions dash/mcp/primitives/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from typing import Any

from mcp.types import CallToolResult, Tool
from mcp.types import CallToolResult, CreateTaskResult, Tool


class MCPToolProvider:
Expand All @@ -24,5 +24,7 @@ def list_tools(cls) -> list[Tool]:
raise NotImplementedError

@classmethod
def call_tool(cls, tool_name: str, arguments: dict[str, Any]) -> CallToolResult:
def call_tool(
cls, tool_name: str, arguments: dict[str, Any], task: dict | None = None
) -> CallToolResult | CreateTaskResult:
raise NotImplementedError
2 changes: 2 additions & 0 deletions dash/mcp/primitives/tools/descriptions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from typing import TYPE_CHECKING

from .base import ToolDescriptionSource
from .description_background_callbacks import BackgroundCallbackDescription
from .description_docstring import DocstringDescription
from .description_outputs import OutputSummaryDescription

Expand All @@ -22,6 +23,7 @@
_SOURCES: list[type[ToolDescriptionSource]] = [
OutputSummaryDescription,
DocstringDescription,
BackgroundCallbackDescription,
]


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Description for background (long-running) callbacks.

Informs the LLM that the tool returns a taskId immediately
and must be polled via the background task result tool.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from ..tool_background_tasks import GET_RESULT_TOOL_NAME
from .base import ToolDescriptionSource

if TYPE_CHECKING:
from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter


class BackgroundCallbackDescription(ToolDescriptionSource):
"""Add async polling instructions for background callbacks."""

@classmethod
def describe(cls, callback: CallbackAdapter) -> list[str]:
# pylint: disable-next=protected-access
if not callback._cb_info.get("background"):
return []

return [
"",
"This is a long-running background operation. "
"It returns a taskId immediately. "
f"Call tool `{GET_RESULT_TOOL_NAME}` with the taskId to poll for the result.",
]
37 changes: 34 additions & 3 deletions dash/mcp/primitives/tools/results/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,19 @@
from __future__ import annotations

import json
from typing import Any
from typing import TYPE_CHECKING, Any

from mcp.types import CallToolResult, TextContent
from mcp.types import CallToolResult, CreateTaskResult, TextContent

from dash.types import CallbackExecutionResponse
from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter

from .base import ResultFormatter
from .result_dataframe import DataFrameResult
from .result_plotly_figure import PlotlyFigureResult

if TYPE_CHECKING:
from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter

_RESULT_FORMATTERS: list[type[ResultFormatter]] = [
PlotlyFigureResult,
DataFrameResult,
Expand Down Expand Up @@ -50,3 +52,32 @@ def format_callback_response(
content=content,
structuredContent=dict(response),
)


def task_result_to_tool_result(create_task_result: CreateTaskResult) -> CallToolResult:
"""Wrap a CreateTaskResult as a CallToolResult with polling instructions.

MCP Tasks are not yet supported by LLM clients, so this converts the
task metadata into a tool response that guides the LLM to poll via
the get_background_task_result tool.
"""
task = create_task_result.task
return CallToolResult(
content=[
TextContent(
type="text",
text=json.dumps(
{
"taskId": task.taskId,
"status": task.status,
"pollInterval": task.pollInterval,
"message": (
"This is a long-running background callback. "
"Call the get_background_task_result tool with this taskId "
"to poll for the result."
),
}
),
)
],
)
102 changes: 102 additions & 0 deletions dash/mcp/primitives/tools/tool_background_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Built-in tools for background callback task lifecycle.

Thin wrappers around the spec-aligned core in dash.mcp.tasks.
Only registered when the app has background callbacks.
"""

from __future__ import annotations

from typing import Any

from mcp.types import CallToolResult, TextContent, Tool

from dash import get_app
from dash.mcp.tasks import get_task, get_task_result, cancel_task

from .base import MCPToolProvider


GET_RESULT_TOOL_NAME = "get_background_task_result"
CANCEL_TOOL_NAME = "cancel_background_task"


def _has_background_callbacks() -> bool:
return any(cb_info.get("background") for cb_info in get_app().callback_map.values())


class BackgroundTaskTools(MCPToolProvider):
"""Built-in tools for polling and cancelling background callback tasks.

Only registered when the app has background callbacks.
"""

@classmethod
def get_tool_names(cls) -> set[str]:
if not _has_background_callbacks():
return set()
return {GET_RESULT_TOOL_NAME, CANCEL_TOOL_NAME}

@classmethod
def list_tools(cls) -> list[Tool]:
if not _has_background_callbacks():
return []
return [
Tool(
name=GET_RESULT_TOOL_NAME,
description=(
"Poll for the result of a long-running background callback. "
"Pass the taskId returned by the original tool call. "
"If the task is still running, call this tool again. "
"If complete, returns the callback result."
),
inputSchema={
"type": "object",
"properties": {
"taskId": {
"type": "string",
"description": "The taskId returned by the background callback tool.",
},
},
"required": ["taskId"],
},
),
Tool(
name=CANCEL_TOOL_NAME,
description="Cancel a running background callback.",
inputSchema={
"type": "object",
"properties": {
"taskId": {
"type": "string",
"description": "The taskId of the background task to cancel.",
},
},
"required": ["taskId"],
},
),
]

@classmethod
def call_tool(
cls,
tool_name: str,
arguments: dict[str, Any],
task: dict | None = None,
) -> CallToolResult:
task_id = arguments.get("taskId", "")

if tool_name == GET_RESULT_TOOL_NAME:
task_status = get_task(task_id)
if task_status.status == "completed":
return get_task_result(task_id)
return CallToolResult(
content=[TextContent(type="text", text=task_status.model_dump_json())],
)

if tool_name == CANCEL_TOOL_NAME:
result = cancel_task(task_id)
return CallToolResult(
content=[TextContent(type="text", text=result.model_dump_json())],
)

raise ValueError(f"Unknown tool: {tool_name}")
4 changes: 3 additions & 1 deletion dash/mcp/primitives/tools/tool_decorated_mcp_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ def list_tools(cls) -> list[Tool]:
return [_build_tool(name, reg) for name, reg in cls._registry().items()]

@classmethod
def call_tool(cls, tool_name: str, arguments: dict[str, Any]) -> CallToolResult:
def call_tool(
cls, tool_name: str, arguments: dict[str, Any], task: dict | None = None
) -> CallToolResult:
reg = cls._registry().get(tool_name)
if reg is None:
return CallToolResult(
Expand Down
7 changes: 6 additions & 1 deletion dash/mcp/primitives/tools/tool_get_dash_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,12 @@ def list_tools(cls) -> list[Tool]:
]

@classmethod
def call_tool(cls, tool_name: str, arguments: dict[str, Any]) -> CallToolResult:
def call_tool(
cls,
tool_name: str,
arguments: dict[str, Any],
task: dict | None = None,
) -> CallToolResult:
comp_id = arguments.get("component_id", "")
if not comp_id:
raise ValueError("component_id is required")
Expand Down
22 changes: 19 additions & 3 deletions dash/mcp/primitives/tools/tools_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@

from typing import Any

from mcp.types import CallToolResult, TextContent, Tool
from mcp.types import CallToolResult, CreateTaskResult, TextContent, Tool

from dash import get_app
from dash.mcp.tasks import create_task
from dash.mcp.types import CallbackExecutionError, ToolNotFoundError

from .base import MCPToolProvider
from .callback_utils import run_callback
from .results import format_callback_response
from .results import format_callback_response, task_result_to_tool_result


class CallbackTools(MCPToolProvider):
Expand All @@ -30,7 +31,12 @@ def list_tools(cls) -> list[Tool]:
return get_app().mcp_callback_map.as_mcp_tools()

@classmethod
def call_tool(cls, tool_name: str, arguments: dict[str, Any]) -> CallToolResult:
def call_tool(
cls,
tool_name: str,
arguments: dict[str, Any],
task: dict | None = None,
) -> CallToolResult | CreateTaskResult:
"""Execute a callback tool by name."""
callback_map = get_app().mcp_callback_map
cb = callback_map.find_by_tool_name(tool_name)
Expand All @@ -41,11 +47,21 @@ def call_tool(cls, tool_name: str, arguments: dict[str, Any]) -> CallToolResult:
" Please call tools/list to refresh your tool list."
)

# pylint: disable-next=protected-access
is_background = bool(cb._cb_info.get("background"))

try:
callback_response = run_callback(cb, arguments)
except CallbackExecutionError as e:
return CallToolResult(
content=[TextContent(type="text", text=str(e))],
isError=True,
)

if is_background:
task_result = create_task(callback_response, cb)
if task is not None:
return task_result
return task_result_to_tool_result(task_result)

return format_callback_response(callback_response, cb)
5 changes: 5 additions & 0 deletions dash/mcp/tasks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""MCP Tasks — lifecycle management for background callback execution."""

from .tasks import create_task, get_task, get_task_result, cancel_task

__all__ = ["create_task", "get_task", "get_task_result", "cancel_task"]
Loading
Loading