diff --git a/fastapi_app.py b/fastapi_app.py
index e727d83..35d4b5e 100644
--- a/fastapi_app.py
+++ b/fastapi_app.py
@@ -186,6 +186,9 @@ init_with_fastapi(app)
# Mount the public directory as static files
app.mount("/public", StaticFiles(directory="public"), name="static")
+# Mount robot projects directory as static files (supports HTML/CSS/JS/images)
+app.mount("/robots", StaticFiles(directory="projects/robot", html=True), name="robots")
+
# Add CORS middleware for frontend pages
app.add_middleware(
CORSMiddleware,
diff --git a/poetry.lock b/poetry.lock
index a6920a7..b9bba52 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -2765,6 +2765,26 @@ cli = ["python-dotenv (>=1.0.0)", "typer (>=0.16.0)"]
rich = ["rich (>=13.9.4)"]
ws = ["websockets (>=15.0.1)"]
+[[package]]
+name = "mcp-ui-server"
+version = "1.0.0"
+description = "mcp-ui Server SDK for Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "mcp_ui_server-1.0.0-py3-none-any.whl", hash = "sha256:85f53b2e4300fbd175f1fbb7c40f2566b1f4a4ad03a1f33647867c82a3159dcc"},
+ {file = "mcp_ui_server-1.0.0.tar.gz", hash = "sha256:5ab8f17b93bf794966af7c35e9a575e4f21a9ba2bab3d316cfc107a15f88a3c9"},
+]
+
+[package.dependencies]
+mcp = ">=1.0.0"
+pydantic = ">=2.0.0"
+typing-extensions = ">=4.0.0"
+
+[package.extras]
+dev = ["pyright (>=1.1.0)", "pytest (>=7.0.0)", "ruff (>=0.1.0)"]
+
[[package]]
name = "mdit-py-plugins"
version = "0.5.0"
@@ -7132,4 +7152,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
[metadata]
lock-version = "2.1"
python-versions = ">=3.12,<3.15"
-content-hash = "dc130664802ad1344adc341931036a343f9892934a41bbc15c48663d0146696b"
+content-hash = "9c949ca9f49b62502571dadab242919fad5e90621f187998e6abdcbcbd448fe4"
diff --git a/pyproject.toml b/pyproject.toml
index 3b30d87..3c6250f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,6 +42,7 @@ dependencies = [
"daytona-sdk",
"langchain-daytona",
"langfuse (>=2.0.0,<4.0.0)",
+ "mcp-ui-server (>=1.0.0,<2.0.0)",
]
[tool.poetry.requires-plugins]
diff --git a/requirements.txt b/requirements.txt
index 0b6933b..01994d9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -95,6 +95,7 @@ linkify-it-py==2.1.0 ; python_version >= "3.12" and python_version < "3.15"
markdown-it-py==4.0.0 ; python_version >= "3.12" and python_version < "3.15"
markdownify==1.2.2 ; python_version >= "3.12" and python_version < "3.15"
markupsafe==3.0.3 ; python_version >= "3.12" and python_version < "3.15"
+mcp-ui-server==1.0.0 ; python_version >= "3.12" and python_version < "3.15"
mcp==1.12.4 ; python_version >= "3.12" and python_version < "3.15"
mdit-py-plugins==0.5.0 ; python_version >= "3.12" and python_version < "3.15"
mdurl==0.1.2 ; python_version >= "3.12" and python_version < "3.15"
diff --git a/routes/chat.py b/routes/chat.py
index fd5dc50..61b3682 100644
--- a/routes/chat.py
+++ b/routes/chat.py
@@ -92,6 +92,7 @@ async def enhanced_generate_stream_response(
logger.info(f"Starting agent stream response")
chunk_id = 0
message_tag = ""
+ current_tool_name = ""
last_answer_first_char_duration_ms = None
waiting_for_answer_first_char = False
agent, checkpointer, sandbox = await init_agent(config)
@@ -108,10 +109,13 @@ async def enhanced_generate_stream_response(
if msg.tool_call_chunks:
message_tag = "TOOL_CALL"
waiting_for_answer_first_char = False
- if config.tool_response:
- for tool_call_chunk in msg.tool_call_chunks:
- chunk_name = tool_call_chunk.get("name") if isinstance(tool_call_chunk, dict) else getattr(tool_call_chunk, "name", None)
- chunk_args = tool_call_chunk.get("args") if isinstance(tool_call_chunk, dict) else getattr(tool_call_chunk, "args", None)
+ for tool_call_chunk in msg.tool_call_chunks:
+ chunk_name = tool_call_chunk.get("name") if isinstance(tool_call_chunk, dict) else getattr(tool_call_chunk, "name", None)
+ chunk_args = tool_call_chunk.get("args") if isinstance(tool_call_chunk, dict) else getattr(tool_call_chunk, "args", None)
+ if chunk_name:
+ current_tool_name = chunk_name
+ # Always output ask_user and render_ui tool calls even when tool_response is disabled
+ if config.tool_response or current_tool_name in ('ask_user', 'render_ui'):
if chunk_name:
new_content = f"[{message_tag}] {chunk_name}\n"
if chunk_args:
@@ -140,7 +144,13 @@ async def enhanced_generate_stream_response(
elif isinstance(msg, ToolMessage) and msg.content:
message_tag = "TOOL_RESPONSE"
waiting_for_answer_first_char = False
- if config.tool_response:
+ # Always output UIResource responses even when tool_response is disabled
+ is_ui_resource = (
+ msg.text
+ and msg.text.lstrip().startswith('{"')
+ and '"ui://' in msg.text
+ )
+ if config.tool_response or is_ui_resource:
new_content = f"[{message_tag}] {msg.name}\n{msg.text}\n"
# Collect full content
diff --git a/skills/common/data-dashboard/.claude-plugin/plugin.json b/skills/common/data-dashboard/.claude-plugin/plugin.json
new file mode 100644
index 0000000..a7c0b8a
--- /dev/null
+++ b/skills/common/data-dashboard/.claude-plugin/plugin.json
@@ -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}"]
+ }
+ }
+}
diff --git a/skills/common/data-dashboard/dashboard_server.py b/skills/common/data-dashboard/dashboard_server.py
new file mode 100644
index 0000000..c125813
--- /dev/null
+++ b/skills/common/data-dashboard/dashboard_server.py
@@ -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'{arrow} {_esc(change)}'
+
+ cards_html += f"""
+
+
{label}
+
{value}
+ {f'
{change_html}
' if change_html else ''}
+
"""
+
+ return f"""
+
+
+
+
+{_esc(title)}
+
+
+
+ {_esc(title)}
+ {cards_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())
diff --git a/skills/common/data-dashboard/dashboard_tools.json b/skills/common/data-dashboard/dashboard_tools.json
new file mode 100644
index 0000000..36e6f87
--- /dev/null
+++ b/skills/common/data-dashboard/dashboard_tools.json
@@ -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"]
+ }
+ }
+]
diff --git a/skills/common/data-dashboard/hooks/dashboard_guide.md b/skills/common/data-dashboard/hooks/dashboard_guide.md
new file mode 100644
index 0000000..3cf2920
--- /dev/null
+++ b/skills/common/data-dashboard/hooks/dashboard_guide.md
@@ -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"}
+ ])
diff --git a/skills/common/data-dashboard/hooks/pre_prompt.py b/skills/common/data-dashboard/hooks/pre_prompt.py
new file mode 100644
index 0000000..5382e71
--- /dev/null
+++ b/skills/common/data-dashboard/hooks/pre_prompt.py
@@ -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())
diff --git a/skills/common/data-dashboard/mcp_common.py b/skills/common/data-dashboard/mcp_common.py
new file mode 100644
index 0000000..0baeb01
--- /dev/null
+++ b/skills/common/data-dashboard/mcp_common.py
@@ -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
diff --git a/skills/common/mcp-ui/.claude-plugin/plugin.json b/skills/common/mcp-ui/.claude-plugin/plugin.json
new file mode 100644
index 0000000..bb8ca06
--- /dev/null
+++ b/skills/common/mcp-ui/.claude-plugin/plugin.json
@@ -0,0 +1,19 @@
+{
+ "name": "mcp-ui",
+ "description": "Provides interactive UI components through MCP tool responses.",
+ "hooks": {
+ "PrePrompt": [
+ {
+ "type": "command",
+ "command": "python hooks/pre_prompt.py"
+ }
+ ]
+ },
+ "mcpServers": {
+ "mcp_ui": {
+ "transport": "stdio",
+ "command": "python",
+ "args": ["./ui_render_server.py", "{bot_id}"]
+ }
+ }
+}
diff --git a/skills/common/mcp-ui/hooks/ask_user_guide.md b/skills/common/mcp-ui/hooks/ask_user_guide.md
new file mode 100644
index 0000000..5a2d145
--- /dev/null
+++ b/skills/common/mcp-ui/hooks/ask_user_guide.md
@@ -0,0 +1,55 @@
+## ask_user Tool Usage Guide
+
+### When to call ask_user
+You MUST call this tool in these cases:
+1. When you need single-select or multi-select choices from the user.
+2. When you have multiple questions to ask at once — you MUST batch them into a single ask_user call. Do NOT list multiple questions as plain text in your response.
+
+The ONLY case where you do NOT need this tool is when there is exactly 1 open-ended question with no options to suggest — in that case, just ask directly in your response text.
+
+### CRITICAL: Every question MUST have options
+When calling ask_user, you MUST generate at least 2-3 suggested options for EVERY question — even for questions that seem open-ended. You should infer reasonable options based on context. The user can always type a custom answer if none of the suggestions fit.
+
+Example: If the question is "What is the topic of the PPT?", do NOT leave options empty. Instead, suggest options like:
+```json
+{"question": "What is the topic of the PPT?", "options": ["Work report", "Product introduction", "Academic presentation", "Other"]}
+```
+
+Do NOT call ask_user with empty options arrays.
+
+### How to format options
+- Options MUST be placed in the `options` array field, NOT embedded in the question text.
+- Keep the question text short and clean — just the question itself.
+- Each question MUST have at least 2 options in the options array.
+
+CORRECT example:
+```json
+{
+ "questions": [
+ {
+ "question": "Who is the audience?",
+ "options": ["Leadership", "Team", "Client"]
+ },
+ {
+ "question": "How long is the presentation?",
+ "options": ["5-10 minutes", "15-20 minutes", "30+ minutes"]
+ }
+ ]
+}
+```
+
+WRONG example (DO NOT do this):
+```json
+{
+ "questions": [
+ {
+ "question": "Who is the audience? A. Leadership B. Team C. Client",
+ "options": []
+ }
+ ]
+}
+```
+
+### Other rules
+- Each question MUST be a separate item in the questions array.
+- NEVER combine multiple questions into a single question string.
diff --git a/skills/common/mcp-ui/hooks/pre_prompt.py b/skills/common/mcp-ui/hooks/pre_prompt.py
new file mode 100644
index 0000000..6c14bf2
--- /dev/null
+++ b/skills/common/mcp-ui/hooks/pre_prompt.py
@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+"""
+PrePrompt Hook - ask_user tool usage guide loader.
+
+Outputs the ask_user usage guide to be injected into the system prompt.
+"""
+import sys
+from pathlib import Path
+
+
+def main():
+ guide_file = Path(__file__).parent / "ask_user_guide.md"
+ print(guide_file.read_text(encoding="utf-8"))
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/skills/common/mcp-ui/mcp_common.py b/skills/common/mcp-ui/mcp_common.py
new file mode 100644
index 0000000..0baeb01
--- /dev/null
+++ b/skills/common/mcp-ui/mcp_common.py
@@ -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
diff --git a/skills/common/mcp-ui/mcp_ui_tools.json b/skills/common/mcp-ui/mcp_ui_tools.json
new file mode 100644
index 0000000..90f2ea8
--- /dev/null
+++ b/skills/common/mcp-ui/mcp_ui_tools.json
@@ -0,0 +1,71 @@
+[
+ {
+ "name": "render_ui",
+ "description": "Render an interactive UI widget in the chat. Supports two modes: (1) raw HTML — provide html_content to render custom HTML/CSS/JS, (2) external URL — provide url to embed an external webpage in an iframe. Use html_content OR url, not both.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string",
+ "description": "A descriptive title for the UI widget"
+ },
+ "html_content": {
+ "type": "string",
+ "description": "Complete HTML content to render. Can include inline CSS and JavaScript within