-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
MCP Server Part 9: Background callbacks #3766
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feature/mcp-server-integration
Are you sure you want to change the base?
Changes from all commits
9416dc5
08ee509
f35e935
dbfcb3f
24a6e46
7189b72
f4254e2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. " | ||||||||||
|
Comment on lines
+29
to
+30
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These get joined with newlines, so you could probably skip the ending spaces.
Suggested change
|
||||||||||
| f"Call tool `{GET_RESULT_TOOL_NAME}` with the taskId to poll for the result.", | ||||||||||
| ] | ||||||||||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||
|
Comment on lines
+20
to
+21
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why was this necessary? |
||||||||
|
|
||||||||
| _RESULT_FORMATTERS: list[type[ResultFormatter]] = [ | ||||||||
| PlotlyFigureResult, | ||||||||
| DataFrameResult, | ||||||||
|
|
@@ -50,3 +52,32 @@ def format_callback_response( | |||||||
| content=content, | ||||||||
| structuredContent=dict(response), | ||||||||
| ) | ||||||||
|
|
||||||||
|
|
||||||||
| def task_result_to_tool_result(create_task_result: CreateTaskResult) -> CallToolResult: | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is minor, but |
||||||||
| """Wrap a CreateTaskResult as a CallToolResult with polling instructions. | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ahem...
Suggested change
|
||||||||
|
|
||||||||
| 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." | ||||||||
| ), | ||||||||
| } | ||||||||
| ), | ||||||||
| ) | ||||||||
| ], | ||||||||
| ) | ||||||||
| 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) | ||
|
Comment on lines
+89
to
+91
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the task guaranteed to still be available between these two calls? For example, if you have two clients polling for the same task, could one of them grab the result before the other and cause an error? |
||
| 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())], | ||
| ) | ||
|
Comment on lines
+88
to
+100
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You could use a |
||
|
|
||
| raise ValueError(f"Unknown tool: {tool_name}") | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would
CreateTaskResultget returned? Intask_result_to_tool_resultthe result is wrapped inCallToolResult.