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