Compare commits

...

2 Commits

Author SHA1 Message Date
朱潮
0bff09b61d Merge branch 'feature/mcp-ui' into bot_manager 2026-05-17 14:07:33 +08:00
朱潮
02085b789a support mcpUiUrl 2026-05-17 14:07:25 +08:00
6 changed files with 159 additions and 42 deletions

View File

@ -145,7 +145,7 @@ async def enhanced_generate_stream_response(
msg.text
and msg.text.lstrip().startswith('{"')
and (
('"ui://' in msg.text and '"text/html' in msg.text)
('"ui://' in msg.text and ('"text/html' in msg.text or '"text/uri-list' in msg.text))
or '"__ask_user__"' in msg.text
)
)

View File

@ -1,6 +1,14 @@
{
"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",

View File

@ -0,0 +1,57 @@
## ask_user Tool Usage Guide
When using the `ask_user` tool, follow these rules:
### 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.

View File

@ -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())

View File

@ -1,7 +1,7 @@
[
{
"name": "render_ui",
"description": "Render an interactive HTML UI widget in the chat. Use this tool when the user asks for interactive content, visualizations, forms, or dynamic displays that benefit from rich HTML rendering rather than plain text.",
"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": {
@ -11,7 +11,11 @@
},
"html_content": {
"type": "string",
"description": "Complete HTML content to render. Can include inline CSS and JavaScript within <style> and <script> tags."
"description": "Complete HTML content to render. Can include inline CSS and JavaScript within <style> and <script> tags. Use this OR url, not both."
},
"url": {
"type": "string",
"description": "External URL to embed in an iframe. Use this OR html_content, not both."
},
"width": {
"type": "string",
@ -29,28 +33,39 @@
},
{
"name": "ask_user",
"description": "MANDATORY: You MUST call this tool whenever you need the user to make a choice or provide input. Do NOT present options as plain text in your response — always use this tool to render interactive option buttons. This step is required and cannot be skipped or replaced by listing options in text. Use this tool when: (1) the user asks to choose between multiple alternatives, (2) you need to clarify ambiguous requirements before proceeding, (3) you need confirmation before taking an action, (4) there are multiple valid approaches and the user must decide. The question will be displayed at the end of your response with clickable option buttons. If options is empty, a free text input will be shown instead.",
"description": "Present questions with selectable options to the user. CRITICAL: Every question MUST have at least 2 options in the options array — generate reasonable suggestions based on context. Do NOT call this tool with empty options arrays. See the ask_user usage guide in system prompt for detailed rules.",
"inputSchema": {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "The question to ask the user"
},
"options": {
"questions": {
"type": "array",
"description": "Array of questions to ask the user. Each question is an object with its own question text, options, and multi_select setting.",
"items": {
"type": "string"
},
"description": "List of options for the user to choose from. If empty, a free text input will be shown instead."
},
"multi_select": {
"type": "boolean",
"description": "If true, the user can select multiple options. If false (default), only one option can be selected.",
"default": false
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "The question to ask the user"
},
"options": {
"type": "array",
"minItems": 2,
"items": {
"type": "string"
},
"description": "REQUIRED array with at least 2 options. Put choices here, NOT in the question text."
},
"multi_select": {
"type": "boolean",
"description": "If true, the user can select multiple options for this question. Default: false.",
"default": false
}
},
"required": ["question", "options"]
}
}
},
"required": ["question"]
"required": ["questions"]
}
}
]

View File

@ -24,19 +24,25 @@ from mcp_common import (
ASK_USER_MARKER = "__ask_user__"
def ask_user(
question: str, options: list = None, multi_select: bool = False
) -> Dict[str, Any]:
def ask_user(questions: list) -> Dict[str, Any]:
"""Create an ask_user response.
Args:
questions: List of dicts, each with "question", "options", and "multi_select".
Returns a JSON structure with a marker so the backend can detect it
and emit it as a special delta.ask_user event at the end of the stream.
"""
normalized = []
for q in questions:
normalized.append({
"question": q.get("question", ""),
"options": q.get("options", []),
"multi_select": q.get("multi_select", False),
})
payload = {
"__type__": ASK_USER_MARKER,
"question": question,
"options": options or [],
"multi_select": multi_select,
"questions": normalized,
}
return {
"content": [
@ -46,21 +52,32 @@ def ask_user(
def render_ui(
title: str, html_content: str, width: str = "100%", height: str = "400px"
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.
This is necessary because langchain_mcp_adapters strips metadata (uri, mimeType)
from EmbeddedResource objects. By wrapping it as text, the full resource JSON
passes through to the frontend for detection and rendering.
"""
try:
uri_slug = title.replace(" ", "-").lower()[:50]
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": {"type": "rawHtml", "htmlString": html_content},
"content": content,
"encoding": "text",
"uiMetadata": {
UIMetadataKey.PREFERRED_FRAME_SIZE: [width, height],
@ -68,7 +85,6 @@ def render_ui(
}
)
# Serialize the full UIResource as JSON string for passthrough
resource_json = json.dumps(
ui_resource.model_dump(mode="json"), ensure_ascii=False
)
@ -102,7 +118,7 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
tools = [
{
"name": "render_ui",
"description": "Render an interactive HTML UI widget in the chat.",
"description": "Render an interactive UI widget in the chat.",
"inputSchema": {
"type": "object",
"properties": {
@ -112,10 +128,14 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
},
"html_content": {
"type": "string",
"description": "Complete HTML content to render.",
"description": "Complete HTML content to render. Use this OR url.",
},
"url": {
"type": "string",
"description": "External URL to embed. Use this OR html_content.",
},
},
"required": ["title", "html_content"],
"required": ["title"],
},
}
]
@ -128,28 +148,27 @@ async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
if tool_name == "render_ui":
title = arguments.get("title", "UI Widget")
html_content = arguments.get("html_content", "")
url = arguments.get("url", "")
width = arguments.get("width", "100%")
height = arguments.get("height", "400px")
if not html_content:
if not html_content and not url:
return create_error_response(
request_id, -32602, "Missing required parameter: html_content"
request_id, -32602, "Missing required parameter: html_content or url"
)
result = render_ui(title, html_content, width, height)
result = render_ui(title, html_content, url, width, height)
return {"jsonrpc": "2.0", "id": request_id, "result": result}
elif tool_name == "ask_user":
question = arguments.get("question", "")
options = arguments.get("options", [])
multi_select = arguments.get("multi_select", False)
questions = arguments.get("questions", [])
if not question:
if not questions:
return create_error_response(
request_id, -32602, "Missing required parameter: question"
request_id, -32602, "Missing required parameter: questions"
)
result = ask_user(question, options, multi_select)
result = ask_user(questions)
return {"jsonrpc": "2.0", "id": request_id, "result": result}
else: