Merge branch 'feature/mcp-ui' into bot_manager
This commit is contained in:
commit
4039bb0cb9
@ -114,8 +114,8 @@ async def enhanced_generate_stream_response(
|
|||||||
chunk_args = tool_call_chunk.get("args") if isinstance(tool_call_chunk, dict) else getattr(tool_call_chunk, "args", None)
|
chunk_args = tool_call_chunk.get("args") if isinstance(tool_call_chunk, dict) else getattr(tool_call_chunk, "args", None)
|
||||||
if chunk_name:
|
if chunk_name:
|
||||||
current_tool_name = chunk_name
|
current_tool_name = chunk_name
|
||||||
# Always output ask_user tool calls even when tool_response is disabled
|
# Always output ask_user and render_ui tool calls even when tool_response is disabled
|
||||||
if config.tool_response or current_tool_name == 'ask_user':
|
if config.tool_response or current_tool_name in ('ask_user', 'render_ui'):
|
||||||
if chunk_name:
|
if chunk_name:
|
||||||
new_content = f"[{message_tag}] {chunk_name}\n"
|
new_content = f"[{message_tag}] {chunk_name}\n"
|
||||||
if chunk_args:
|
if chunk_args:
|
||||||
@ -144,7 +144,7 @@ async def enhanced_generate_stream_response(
|
|||||||
elif isinstance(msg, ToolMessage) and msg.content:
|
elif isinstance(msg, ToolMessage) and msg.content:
|
||||||
message_tag = "TOOL_RESPONSE"
|
message_tag = "TOOL_RESPONSE"
|
||||||
waiting_for_answer_first_char = False
|
waiting_for_answer_first_char = False
|
||||||
# Always output UIResource and ask_user responses even when tool_response is disabled
|
# Always output UIResource, ask_user and render_ui responses even when tool_response is disabled
|
||||||
is_ui_resource = (
|
is_ui_resource = (
|
||||||
msg.text
|
msg.text
|
||||||
and msg.text.lstrip().startswith('{"')
|
and msg.text.lstrip().startswith('{"')
|
||||||
@ -152,7 +152,8 @@ async def enhanced_generate_stream_response(
|
|||||||
and ('"text/html' in msg.text or '"text/uri-list' in msg.text)
|
and ('"text/html' in msg.text or '"text/uri-list' in msg.text)
|
||||||
)
|
)
|
||||||
is_ask_user = msg.name == 'ask_user'
|
is_ask_user = msg.name == 'ask_user'
|
||||||
if config.tool_response or is_ui_resource or is_ask_user:
|
is_render_ui = msg.name == 'render_ui'
|
||||||
|
if config.tool_response or is_ui_resource or is_ask_user or is_render_ui:
|
||||||
new_content = f"[{message_tag}] {msg.name}\n{msg.text}\n"
|
new_content = f"[{message_tag}] {msg.name}\n{msg.text}\n"
|
||||||
|
|
||||||
# Collect full content
|
# Collect full content
|
||||||
|
|||||||
19
skills/common/data-dashboard/.claude-plugin/plugin.json
Normal file
19
skills/common/data-dashboard/.claude-plugin/plugin.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "data-dashboard",
|
||||||
|
"description": "Renders data as an interactive dashboard card UI using the mcp-ui protocol.",
|
||||||
|
"hooks": {
|
||||||
|
"PrePrompt": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python hooks/pre_prompt.py"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mcpServers": {
|
||||||
|
"data_dashboard": {
|
||||||
|
"transport": "stdio",
|
||||||
|
"command": "python",
|
||||||
|
"args": ["./dashboard_server.py", "{bot_id}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
187
skills/common/data-dashboard/dashboard_server.py
Normal file
187
skills/common/data-dashboard/dashboard_server.py
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Data Dashboard MCP Server - renders metric data as an interactive dashboard.
|
||||||
|
Returns UIResource via the mcp-ui protocol so the frontend renders it as an iframe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from mcp_ui_server import create_ui_resource, UIMetadataKey
|
||||||
|
|
||||||
|
from mcp_common import (
|
||||||
|
create_error_response,
|
||||||
|
create_initialize_response,
|
||||||
|
create_ping_response,
|
||||||
|
create_tools_list_response,
|
||||||
|
load_tools_from_json,
|
||||||
|
handle_mcp_streaming,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_dashboard_html(title: str, metrics: List[Dict[str, str]]) -> str:
|
||||||
|
"""Build a self-contained HTML dashboard from metrics data."""
|
||||||
|
cards_html = ""
|
||||||
|
for m in metrics:
|
||||||
|
label = _esc(m.get("label", ""))
|
||||||
|
value = _esc(m.get("value", ""))
|
||||||
|
change = m.get("change", "")
|
||||||
|
change_type = m.get("change_type", "neutral")
|
||||||
|
|
||||||
|
change_html = ""
|
||||||
|
if change:
|
||||||
|
color = "#16a34a" if change_type == "up" else "#dc2626" if change_type == "down" else "#6b7280"
|
||||||
|
arrow = "▲" if change_type == "up" else "▼" if change_type == "down" else "—"
|
||||||
|
change_html = f'<span class="change" style="color:{color}"><span class="arrow">{arrow}</span> {_esc(change)}</span>'
|
||||||
|
|
||||||
|
cards_html += f"""
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">{label}</div>
|
||||||
|
<div class="card-value">{value}</div>
|
||||||
|
{f'<div class="card-change">{change_html}</div>' if change_html else ''}
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{_esc(title)}</title>
|
||||||
|
<style>
|
||||||
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||||
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; padding: 24px; }}
|
||||||
|
h1 {{ font-size: 20px; font-weight: 600; color: #0f172a; margin-bottom: 20px; }}
|
||||||
|
.grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }}
|
||||||
|
.card {{ background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 20px; transition: box-shadow 0.15s; }}
|
||||||
|
.card:hover {{ box-shadow: 0 4px 12px rgba(0,0,0,0.06); }}
|
||||||
|
.card-label {{ font-size: 13px; color: #64748b; margin-bottom: 8px; }}
|
||||||
|
.card-value {{ font-size: 28px; font-weight: 700; color: #0f172a; }}
|
||||||
|
.card-change {{ margin-top: 8px; font-size: 13px; font-weight: 500; }}
|
||||||
|
.arrow {{ font-size: 10px; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{_esc(title)}</h1>
|
||||||
|
<div class="grid">{cards_html}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _esc(text: str) -> str:
|
||||||
|
"""Minimal HTML escaping."""
|
||||||
|
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||||
|
|
||||||
|
|
||||||
|
def render_dashboard(title: str, metrics: List[Dict[str, str]]) -> Dict[str, Any]:
|
||||||
|
"""Create a UIResource dashboard and serialize it as JSON for TOOL_RESPONSE."""
|
||||||
|
try:
|
||||||
|
html = _build_dashboard_html(title, metrics)
|
||||||
|
uri_slug = title.replace(" ", "-").lower()[:50]
|
||||||
|
|
||||||
|
ui_resource = create_ui_resource(
|
||||||
|
{
|
||||||
|
"uri": f"ui://data-dashboard/{uri_slug}",
|
||||||
|
"content": {"type": "rawHtml", "htmlString": html},
|
||||||
|
"encoding": "text",
|
||||||
|
"uiMetadata": {
|
||||||
|
UIMetadataKey.PREFERRED_FRAME_SIZE: ["100%", "auto"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
resource_json = json.dumps(
|
||||||
|
ui_resource.model_dump(mode="json"), ensure_ascii=False
|
||||||
|
)
|
||||||
|
return {"content": [{"type": "text", "text": resource_json}]}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": f"Error creating dashboard: {str(e)}"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Handle an MCP request."""
|
||||||
|
try:
|
||||||
|
method = request.get("method")
|
||||||
|
params = request.get("params", {})
|
||||||
|
request_id = request.get("id")
|
||||||
|
|
||||||
|
if method == "initialize":
|
||||||
|
return create_initialize_response(request_id, "data-dashboard")
|
||||||
|
|
||||||
|
elif method == "ping":
|
||||||
|
return create_ping_response(request_id)
|
||||||
|
|
||||||
|
elif method == "tools/list":
|
||||||
|
tools = load_tools_from_json("dashboard_tools.json")
|
||||||
|
if not tools:
|
||||||
|
tools = [
|
||||||
|
{
|
||||||
|
"name": "render_dashboard",
|
||||||
|
"description": "Render a data dashboard with metric cards.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string"},
|
||||||
|
"metrics": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": {"type": "string"},
|
||||||
|
"value": {"type": "string"},
|
||||||
|
"change": {"type": "string"},
|
||||||
|
"change_type": {"type": "string", "enum": ["up", "down", "neutral"]},
|
||||||
|
},
|
||||||
|
"required": ["label", "value"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["title", "metrics"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return create_tools_list_response(request_id, tools)
|
||||||
|
|
||||||
|
elif method == "tools/call":
|
||||||
|
tool_name = params.get("name")
|
||||||
|
arguments = params.get("arguments", {})
|
||||||
|
|
||||||
|
if tool_name == "render_dashboard":
|
||||||
|
title = arguments.get("title", "Dashboard")
|
||||||
|
metrics = arguments.get("metrics", [])
|
||||||
|
|
||||||
|
if not metrics:
|
||||||
|
return create_error_response(
|
||||||
|
request_id, -32602, "Missing required parameter: metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = render_dashboard(title, metrics)
|
||||||
|
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return create_error_response(
|
||||||
|
request_id, -32601, f"Unknown tool: {tool_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
return create_error_response(
|
||||||
|
request_id, -32601, f"Unknown method: {method}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return create_error_response(
|
||||||
|
request.get("id"), -32603, f"Internal error: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
await handle_mcp_streaming(handle_request)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
43
skills/common/data-dashboard/dashboard_tools.json
Normal file
43
skills/common/data-dashboard/dashboard_tools.json
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "render_dashboard",
|
||||||
|
"description": "Render an interactive data dashboard with metric cards. Pass an array of metrics, each with label, value, and optional change/change_type fields. The dashboard is rendered as an HTML card UI.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Dashboard title, e.g. 'Sales Overview'"
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Array of metric objects to display as cards",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Metric name, e.g. 'Revenue'"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Metric value, e.g. '$12,345'"
|
||||||
|
},
|
||||||
|
"change": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Change indicator, e.g. '+12.5%' or '-3.2%'"
|
||||||
|
},
|
||||||
|
"change_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["up", "down", "neutral"],
|
||||||
|
"description": "Direction of change"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["label", "value"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["title", "metrics"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
13
skills/common/data-dashboard/hooks/dashboard_guide.md
Normal file
13
skills/common/data-dashboard/hooks/dashboard_guide.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
## `render_dashboard` usage guide
|
||||||
|
|
||||||
|
### When to use render_dashboard:
|
||||||
|
- When the user wants to visualize data as metric cards (KPIs, stats, numbers)
|
||||||
|
- When summarizing numerical results (sales, traffic, performance metrics)
|
||||||
|
- When comparing multiple data points side by side
|
||||||
|
|
||||||
|
### How to call:
|
||||||
|
render_dashboard(title="Sales Overview", metrics=[
|
||||||
|
{"label": "Revenue", "value": "$12,345", "change": "+12.5%", "change_type": "up"},
|
||||||
|
{"label": "Users", "value": "1,234", "change": "-3.2%", "change_type": "down"},
|
||||||
|
{"label": "Conversion", "value": "4.5%", "change_type": "neutral"}
|
||||||
|
])
|
||||||
8
skills/common/data-dashboard/hooks/pre_prompt.py
Normal file
8
skills/common/data-dashboard/hooks/pre_prompt.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""PrePrompt hook - inject dashboard tool guide into system prompt."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
guide_path = os.path.join(os.path.dirname(__file__), "dashboard_guide.md")
|
||||||
|
if os.path.exists(guide_path):
|
||||||
|
with open(guide_path, "r", encoding="utf-8") as f:
|
||||||
|
print(f.read())
|
||||||
252
skills/common/data-dashboard/mcp_common.py
Normal file
252
skills/common/data-dashboard/mcp_common.py
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Shared utility functions for the MCP server.
|
||||||
|
Provides common functionality for path handling, file validation, and request processing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
import re
|
||||||
|
|
||||||
|
def get_allowed_directory():
|
||||||
|
"""Get the directory that is allowed to be accessed."""
|
||||||
|
# Prefer dataset_dir passed through command-line arguments.
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
dataset_dir = sys.argv[1]
|
||||||
|
return os.path.abspath(dataset_dir)
|
||||||
|
|
||||||
|
# Read the project data directory from the environment variable.
|
||||||
|
project_dir = os.getenv("PROJECT_DATA_DIR", "./projects/data")
|
||||||
|
return os.path.abspath(project_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_file_path(file_path: str, default_subfolder: str = "default") -> str:
|
||||||
|
"""
|
||||||
|
Resolve a file path, supporting both folder/document.txt and document.txt formats.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Input file path.
|
||||||
|
default_subfolder: Default subfolder name to use when only a filename is provided.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The resolved full file path.
|
||||||
|
"""
|
||||||
|
# If the path contains a folder separator, use it directly.
|
||||||
|
if '/' in file_path or '\\' in file_path:
|
||||||
|
clean_path = file_path.replace('\\', '/')
|
||||||
|
|
||||||
|
# Remove the projects/ prefix if it exists.
|
||||||
|
if clean_path.startswith('projects/'):
|
||||||
|
clean_path = clean_path[9:] # Remove the 'projects/' prefix.
|
||||||
|
elif clean_path.startswith('./projects/'):
|
||||||
|
clean_path = clean_path[11:] # Remove the './projects/' prefix.
|
||||||
|
else:
|
||||||
|
# If only a filename is provided, add the default subfolder.
|
||||||
|
clean_path = f"{default_subfolder}/{file_path}"
|
||||||
|
|
||||||
|
# Get the allowed directory.
|
||||||
|
project_data_dir = get_allowed_directory()
|
||||||
|
|
||||||
|
# Try to locate the file directly under the project directory.
|
||||||
|
full_path = os.path.join(project_data_dir, clean_path.lstrip('./'))
|
||||||
|
if os.path.exists(full_path):
|
||||||
|
return full_path
|
||||||
|
|
||||||
|
# If the direct path does not exist, try a recursive search.
|
||||||
|
found = find_file_in_project(clean_path, project_data_dir)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
|
||||||
|
# If this is a bare filename and it was not found under the default subfolder,
|
||||||
|
# try looking in the project root.
|
||||||
|
if '/' not in file_path and '\\' not in file_path:
|
||||||
|
root_path = os.path.join(project_data_dir, file_path)
|
||||||
|
if os.path.exists(root_path):
|
||||||
|
return root_path
|
||||||
|
|
||||||
|
raise FileNotFoundError(f"File not found: {file_path} (searched in {project_data_dir})")
|
||||||
|
|
||||||
|
|
||||||
|
def find_file_in_project(filename: str, project_dir: str) -> Optional[str]:
|
||||||
|
"""Recursively search for a file inside the project directory."""
|
||||||
|
# If filename includes a path, only search within the specified path.
|
||||||
|
if '/' in filename:
|
||||||
|
parts = filename.split('/')
|
||||||
|
target_file = parts[-1]
|
||||||
|
search_dir = os.path.join(project_dir, *parts[:-1])
|
||||||
|
|
||||||
|
if os.path.exists(search_dir):
|
||||||
|
target_path = os.path.join(search_dir, target_file)
|
||||||
|
if os.path.exists(target_path):
|
||||||
|
return target_path
|
||||||
|
else:
|
||||||
|
# For a bare filename, recursively search the whole project directory.
|
||||||
|
for root, dirs, files in os.walk(project_dir):
|
||||||
|
if filename in files:
|
||||||
|
return os.path.join(root, filename)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_tools_from_json(tools_file_name: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Load tool definitions from a JSON file."""
|
||||||
|
try:
|
||||||
|
tools_file = os.path.join(os.path.dirname(__file__), tools_file_name)
|
||||||
|
if os.path.exists(tools_file):
|
||||||
|
with open(tools_file, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
else:
|
||||||
|
# If the JSON file does not exist, use the default definitions.
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Unable to load tool definition JSON file: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def create_error_response(request_id: Any, code: int, message: str) -> Dict[str, Any]:
|
||||||
|
"""Create a standardized error response."""
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"error": {
|
||||||
|
"code": code,
|
||||||
|
"message": message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_success_response(request_id: Any, result: Any) -> Dict[str, Any]:
|
||||||
|
"""Create a standardized success response."""
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": result
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_initialize_response(request_id: Any, server_name: str, server_version: str = "1.0.0") -> Dict[str, Any]:
|
||||||
|
"""Create a standardized initialize response."""
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {
|
||||||
|
"tools": {}
|
||||||
|
},
|
||||||
|
"serverInfo": {
|
||||||
|
"name": server_name,
|
||||||
|
"version": server_version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_ping_response(request_id: Any) -> Dict[str, Any]:
|
||||||
|
"""Create a standardized ping response."""
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": {
|
||||||
|
"pong": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_tools_list_response(request_id: Any, tools: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""Create a standardized tools/list response."""
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": {
|
||||||
|
"tools": tools
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_regex_pattern(pattern: str) -> bool:
|
||||||
|
"""Check whether a string should be treated as a regular expression pattern."""
|
||||||
|
# Check the /pattern/ format.
|
||||||
|
if pattern.startswith('/') and pattern.endswith('/') and len(pattern) > 2:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check the r"pattern" or r'pattern' format.
|
||||||
|
if pattern.startswith(('r"', "r'")) and pattern.endswith(('"', "'")) and len(pattern) > 3:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check whether it contains regex metacharacters.
|
||||||
|
regex_chars = {'*', '+', '?', '|', '(', ')', '[', ']', '{', '}', '^', '$', '\\', '.'}
|
||||||
|
return any(char in pattern for char in regex_chars)
|
||||||
|
|
||||||
|
|
||||||
|
def compile_pattern(pattern: str) -> Union[re.Pattern, str, None]:
|
||||||
|
"""Compile a regex pattern, or return the original string if it is not regex."""
|
||||||
|
if not is_regex_pattern(pattern):
|
||||||
|
return pattern
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Handle the /pattern/ format.
|
||||||
|
if pattern.startswith('/') and pattern.endswith('/'):
|
||||||
|
regex_body = pattern[1:-1]
|
||||||
|
return re.compile(regex_body)
|
||||||
|
|
||||||
|
# Handle the r"pattern" or r'pattern' format.
|
||||||
|
if pattern.startswith(('r"', "r'")) and pattern.endswith(('"', "'")):
|
||||||
|
regex_body = pattern[2:-1]
|
||||||
|
return re.compile(regex_body)
|
||||||
|
|
||||||
|
# Directly compile strings that contain regex metacharacters.
|
||||||
|
return re.compile(pattern)
|
||||||
|
except re.error as e:
|
||||||
|
# If compilation fails, return None to indicate an invalid regex.
|
||||||
|
print(f"Warning: Regular expression '{pattern}' compilation failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_mcp_streaming(request_handler):
|
||||||
|
"""Handle the standard main loop for MCP requests."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Read from stdin
|
||||||
|
line = await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline)
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
request = json.loads(line)
|
||||||
|
response = await request_handler(request)
|
||||||
|
|
||||||
|
# Write to stdout
|
||||||
|
sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
error_response = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"error": {
|
||||||
|
"code": -32700,
|
||||||
|
"message": "Parse error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sys.stdout.write(json.dumps(error_response, ensure_ascii=False) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_response = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"error": {
|
||||||
|
"code": -32603,
|
||||||
|
"message": f"Internal error: {str(e)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sys.stdout.write(json.dumps(error_response, ensure_ascii=False) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
@ -24,8 +24,8 @@
|
|||||||
},
|
},
|
||||||
"height": {
|
"height": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "CSS height for the iframe. Default: '400px'",
|
"description": "CSS height for the iframe. Set to a fixed value (e.g. '300px', '600px') matching the HTML content height. Use 'auto' only when the HTML is responsive and has no fixed height. Default: 'auto'",
|
||||||
"default": "400px"
|
"default": "auto"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["title", "html_content"]
|
"required": ["title", "html_content"]
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
MCP UI Server - provides interactive UI rendering tools.
|
MCP UI Server - provides interactive UI rendering tools.
|
||||||
Uses mcp-ui-server SDK to create standard UIResource objects.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -9,8 +8,6 @@ import json
|
|||||||
import sys
|
import sys
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from mcp_ui_server import create_ui_resource, UIMetadataKey
|
|
||||||
|
|
||||||
from mcp_common import (
|
from mcp_common import (
|
||||||
create_error_response,
|
create_error_response,
|
||||||
create_initialize_response,
|
create_initialize_response,
|
||||||
@ -39,52 +36,22 @@ def ask_user() -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def render_ui(
|
RENDER_UI_RESPONSE = "UI rendered."
|
||||||
title: str,
|
|
||||||
html_content: str = "",
|
|
||||||
url: str = "",
|
|
||||||
width: str = "100%",
|
|
||||||
height: str = "400px",
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Create a UI resource and serialize it as JSON text for passthrough.
|
|
||||||
|
|
||||||
Supports two modes:
|
|
||||||
- rawHtml: provide html_content to render custom HTML.
|
|
||||||
- externalUrl: provide url to embed an external webpage.
|
|
||||||
|
|
||||||
The UIResource is serialized as a JSON string inside a type:"text" content block.
|
def render_ui() -> Dict[str, Any]:
|
||||||
|
"""Return a minimal fixed response for render_ui tool.
|
||||||
|
|
||||||
|
The actual html_content/url is already in the TOOL_CALL arguments,
|
||||||
|
so the frontend parses them directly from there. This response only
|
||||||
|
serves to acknowledge the tool call and minimize token usage in the
|
||||||
|
subsequent LLM inference round.
|
||||||
"""
|
"""
|
||||||
try:
|
return {
|
||||||
uri_slug = title.replace(" ", "-").lower()[:50]
|
"content": [
|
||||||
|
{"type": "text", "text": RENDER_UI_RESPONSE}
|
||||||
if url:
|
]
|
||||||
content = {"type": "externalUrl", "iframeUrl": url}
|
}
|
||||||
else:
|
|
||||||
content = {"type": "rawHtml", "htmlString": html_content}
|
|
||||||
|
|
||||||
ui_resource = create_ui_resource(
|
|
||||||
{
|
|
||||||
"uri": f"ui://mcp-ui-skill/{uri_slug}",
|
|
||||||
"content": content,
|
|
||||||
"encoding": "text",
|
|
||||||
"uiMetadata": {
|
|
||||||
UIMetadataKey.PREFERRED_FRAME_SIZE: [width, height],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
resource_json = json.dumps(
|
|
||||||
ui_resource.model_dump(mode="json"), ensure_ascii=False
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"content": [{"type": "text", "text": resource_json}]}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return {
|
|
||||||
"content": [
|
|
||||||
{"type": "text", "text": f"Error creating UI resource: {str(e)}"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
|
async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
@ -134,18 +101,15 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
arguments = params.get("arguments", {})
|
arguments = params.get("arguments", {})
|
||||||
|
|
||||||
if tool_name == "render_ui":
|
if tool_name == "render_ui":
|
||||||
title = arguments.get("title", "UI Widget")
|
|
||||||
html_content = arguments.get("html_content", "")
|
html_content = arguments.get("html_content", "")
|
||||||
url = arguments.get("url", "")
|
url = arguments.get("url", "")
|
||||||
width = arguments.get("width", "100%")
|
|
||||||
height = arguments.get("height", "400px")
|
|
||||||
|
|
||||||
if not html_content and not url:
|
if not html_content and not url:
|
||||||
return create_error_response(
|
return create_error_response(
|
||||||
request_id, -32602, "Missing required parameter: html_content or url"
|
request_id, -32602, "Missing required parameter: html_content or url"
|
||||||
)
|
)
|
||||||
|
|
||||||
result = render_ui(title, html_content, url, width, height)
|
result = render_ui()
|
||||||
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||||
|
|
||||||
elif tool_name == "ask_user":
|
elif tool_name == "ask_user":
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user