Compare commits

...

14 Commits

Author SHA1 Message Date
朱潮
8bf2c7c691 Merge branch 'feature/mcp-ui' into bot_manager 2026-05-17 08:20:24 +08:00
朱潮
14b7ed7a55 Merge branch 'developing' into feature/mcp-ui 2026-05-17 08:20:07 +08:00
朱潮
c095c8f5ba add mcp-ui 2026-05-17 08:19:47 +08:00
朱潮
6d7bcd62cd Merge branch 'feature/mcp-ui' into bot_manager 2026-05-15 19:25:56 +08:00
朱潮
03a30537ab ask user question 2026-05-15 19:25:08 +08:00
朱潮
cacf31abbe Merge branch 'feature/mcp-ui' into bot_manager 2026-05-15 18:02:34 +08:00
朱潮
26582f7d39 add config.tool_response or is_ui_resource — UIResource 始终输出,其他 tool response 仍受 tool_response 参数控制 2026-05-15 18:02:26 +08:00
朱潮
11e2ce2c17 add mcp-ui 2026-05-15 17:57:09 +08:00
朱潮
eb307cb1de fix requirements 2026-05-15 17:54:42 +08:00
朱潮
b842778be0 add mcp-ui 2026-05-15 14:22:10 +08:00
朱潮
5dfe2eba28 feat: add bot_id and model to langfuse metadata
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-14 18:14:41 +08:00
朱潮
32508ae9d4 fix: langfuse CallbackHandler API and tarfile dereference parameter
- Update langfuse CallbackHandler to use trace_context instead of removed trace_id/session_id/user_id params
- Pass session_id/user_id via LangChain metadata with langfuse_ prefix
- Move dereference param from TarFile.add() to tarfile.open()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-14 17:14:37 +08:00
朱潮
0c84beaeb0 add langfuse 2026-05-14 16:45:21 +08:00
朱潮
3f895f8fe4 add langfuse 2026-05-14 12:14:21 +08:00
15 changed files with 1693 additions and 597 deletions

View File

@ -304,8 +304,34 @@ class AgentConfig:
def invoke_config(self):
"""Return the configuration dictionary required by LangChain."""
config = {}
callbacks = []
if self.logging_handler:
config["callbacks"] = [self.logging_handler]
callbacks.append(self.logging_handler)
from utils.settings import LANGFUSE_ENABLED
if LANGFUSE_ENABLED:
from langfuse.langchain import CallbackHandler as LangfuseCallbackHandler
trace_context = {"trace_id": self.trace_id} if self.trace_id else None
langfuse_handler = LangfuseCallbackHandler(
trace_context=trace_context,
)
callbacks.append(langfuse_handler)
langfuse_metadata = {}
if LANGFUSE_ENABLED:
if self.session_id:
langfuse_metadata["langfuse_session_id"] = self.session_id
if self.user_identifier:
langfuse_metadata["langfuse_user_id"] = self.user_identifier
if self.bot_id:
langfuse_metadata["bot_id"] = self.bot_id
if self.model_name:
langfuse_metadata["model"] = self.model_name
if callbacks:
config["callbacks"] = callbacks
if langfuse_metadata:
config["metadata"] = langfuse_metadata
if self.session_id:
config["configurable"] = {"thread_id": self.session_id}
return config

1250
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -46,6 +46,8 @@ dependencies = [
"pyyaml (>=6.0,<7.0)",
"daytona-sdk",
"langchain-daytona",
"langfuse (>=2.0.0,<4.0.0)",
"mcp-ui-server (>=1.0.0,<2.0.0)",
]
[tool.poetry.requires-plugins]

View File

@ -1,4 +1,4 @@
agent-client-protocol==0.9.0 ; python_version >= "3.12" and python_version < "3.15"
agent-client-protocol==0.10.0 ; python_version >= "3.12" and python_version < "3.15"
aiofiles==24.1.0 ; python_version >= "3.12" and python_version < "3.15"
aiohappyeyeballs==2.6.1 ; python_version >= "3.12" and python_version < "3.15"
aiohttp-retry==2.9.1 ; python_version >= "3.12" and python_version < "3.15"
@ -6,7 +6,7 @@ aiohttp==3.13.5 ; python_version >= "3.12" and python_version < "3.15"
aiosignal==1.4.0 ; python_version >= "3.12" and python_version < "3.15"
aiosqlite==0.22.1 ; python_version >= "3.12" and python_version < "3.15"
annotated-types==0.7.0 ; python_version >= "3.12" and python_version < "3.15"
anthropic==0.100.0 ; python_version >= "3.12" and python_version < "3.15"
anthropic==0.102.0 ; python_version >= "3.12" and python_version < "3.15"
anyio==4.13.0 ; python_version >= "3.12" and python_version < "3.15"
attrs==26.1.0 ; python_version >= "3.12" and python_version < "3.15"
backoff==2.2.1 ; python_version >= "3.12" and python_version < "3.15"
@ -31,8 +31,8 @@ daytona-toolbox-api-client-async==0.169.0 ; python_version >= "3.12" and python_
daytona-toolbox-api-client==0.169.0 ; python_version >= "3.12" and python_version < "3.15"
daytona==0.169.0 ; python_version >= "3.12" and python_version < "3.15"
deepagents-acp==0.0.6 ; python_version >= "3.12" and python_version < "3.15"
deepagents-cli==0.0.51 ; python_version >= "3.12" and python_version < "3.15"
deepagents==0.5.7 ; python_version >= "3.12" and python_version < "3.15"
deepagents-cli==0.0.57 ; python_version >= "3.12" and python_version < "3.15"
deepagents==0.5.9 ; python_version >= "3.12" and python_version < "3.15"
defusedxml==0.7.1 ; python_version >= "3.12" and python_version < "3.15"
deprecated==1.3.1 ; python_version >= "3.12" and python_version < "3.15"
distro==1.9.0 ; python_version >= "3.12" and python_version < "3.15"
@ -44,7 +44,7 @@ filetype==1.2.0 ; python_version >= "3.12" and python_version < "3.15"
forbiddenfruit==0.1.4 ; python_version >= "3.12" and python_version < "3.15" and implementation_name == "cpython"
frozenlist==1.8.0 ; python_version >= "3.12" and python_version < "3.15"
fsspec==2026.4.0 ; python_version >= "3.12" and python_version < "3.15"
google-auth==2.51.0 ; python_version >= "3.12" and python_version < "3.15"
google-auth==2.52.0 ; python_version >= "3.12" and python_version < "3.15"
google-genai==1.75.0 ; python_version >= "3.12" and python_version < "3.15"
googleapis-common-protos==1.75.0 ; python_version >= "3.12" and python_version < "3.15"
greenlet==3.5.0 ; python_version >= "3.12" and python_version < "3.15" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32")
@ -61,7 +61,7 @@ httpx==0.28.1 ; python_version >= "3.12" and python_version < "3.15"
huey==2.6.0 ; python_version >= "3.12" and python_version < "3.15"
huggingface-hub==0.36.2 ; python_version >= "3.12" and python_version < "3.15"
hyperframe==6.1.0 ; python_version >= "3.12" and python_version < "3.15"
idna==3.13 ; python_version >= "3.12" and python_version < "3.15"
idna==3.15 ; python_version >= "3.12" and python_version < "3.15"
importlib-metadata==8.7.1 ; python_version >= "3.12" and python_version < "3.15"
jinja2==3.1.6 ; python_version >= "3.12" and python_version < "3.15"
jiter==0.14.0 ; python_version >= "3.12" and python_version < "3.15"
@ -74,29 +74,31 @@ jsonschema-rs==0.29.1 ; python_version >= "3.12" and python_version < "3.15"
jsonschema-specifications==2025.9.1 ; python_version >= "3.12" and python_version < "3.15"
jsonschema==4.26.0 ; python_version >= "3.12" and python_version < "3.15"
langchain-anthropic==1.4.3 ; python_version >= "3.12" and python_version < "3.15"
langchain-core==1.3.3 ; python_version >= "3.12" and python_version < "3.15"
langchain-core==1.4.0 ; python_version >= "3.12" and python_version < "3.15"
langchain-daytona==0.0.5 ; python_version >= "3.12" and python_version < "3.15"
langchain-google-genai==4.2.2 ; python_version >= "3.12" and python_version < "3.15"
langchain-mcp-adapters==0.2.2 ; python_version >= "3.12" and python_version < "3.15"
langchain-openai==1.2.1 ; python_version >= "3.12" and python_version < "3.15"
langchain-protocol==0.0.15 ; python_version >= "3.12" and python_version < "3.15"
langchain==1.2.17 ; python_version >= "3.12" and python_version < "3.15"
langchain==1.3.0 ; python_version >= "3.12" and python_version < "3.15"
langfuse==3.14.6 ; python_version >= "3.12" and python_version < "3.15"
langgraph-api==0.7.28 ; python_version >= "3.12" and python_version < "3.15"
langgraph-checkpoint-postgres==3.0.5 ; python_version >= "3.12" and python_version < "3.15"
langgraph-checkpoint-sqlite==3.0.3 ; python_version >= "3.12" and python_version < "3.15"
langgraph-checkpoint==4.0.3 ; python_version >= "3.12" and python_version < "3.15"
langgraph-cli==0.4.24 ; python_version >= "3.12" and python_version < "3.15"
langgraph-prebuilt==1.0.13 ; python_version >= "3.12" and python_version < "3.15"
langgraph-checkpoint-postgres==3.1.0 ; python_version >= "3.12" and python_version < "3.15"
langgraph-checkpoint-sqlite==3.1.0 ; python_version >= "3.12" and python_version < "3.15"
langgraph-checkpoint==4.1.0 ; python_version >= "3.12" and python_version < "3.15"
langgraph-cli==0.4.26 ; python_version >= "3.12" and python_version < "3.15"
langgraph-prebuilt==1.1.0 ; python_version >= "3.12" and python_version < "3.15"
langgraph-runtime-inmem==0.24.1 ; python_version >= "3.12" and python_version < "3.15"
langgraph-sdk==0.3.14 ; python_version >= "3.12" and python_version < "3.15"
langgraph==1.1.10 ; python_version >= "3.12" and python_version < "3.15"
langsmith==0.8.2 ; python_version >= "3.12" and python_version < "3.15"
langgraph==1.2.0 ; python_version >= "3.12" and python_version < "3.15"
langsmith==0.8.4 ; python_version >= "3.12" and python_version < "3.15"
linkify-it-py==2.1.0 ; python_version >= "3.12" and python_version < "3.15"
markdown-it-py==4.1.0 ; python_version >= "3.12" and python_version < "3.15"
markdown-it-py==4.2.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"
mdit-py-plugins==0.6.1 ; python_version >= "3.12" and python_version < "3.15"
mdurl==0.1.2 ; python_version >= "3.12" and python_version < "3.15"
mem0ai==0.1.115 ; python_version >= "3.12" and python_version < "3.15"
mpmath==1.3.0 ; python_version >= "3.12" and python_version < "3.15"
@ -117,7 +119,7 @@ nvidia-nccl-cu12==2.19.3 ; python_version >= "3.12" and python_version < "3.15"
nvidia-nvjitlink-cu12==12.9.86 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64"
nvidia-nvtx-cu12==12.1.105 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64"
obstore==0.8.2 ; python_version >= "3.12" and python_version < "3.15"
openai==2.35.1 ; python_version >= "3.12" and python_version < "3.15"
openai==2.36.0 ; python_version >= "3.12" and python_version < "3.15"
openpyxl==3.1.5 ; python_version >= "3.12" and python_version < "3.15"
opentelemetry-api==1.41.1 ; python_version >= "3.12" and python_version < "3.15"
opentelemetry-exporter-otlp-proto-common==1.41.1 ; python_version >= "3.12" and python_version < "3.15"
@ -130,17 +132,17 @@ opentelemetry-semantic-conventions==0.62b1 ; python_version >= "3.12" and python
opentelemetry-util-http==0.62b1 ; python_version >= "3.12" and python_version < "3.15"
orjson==3.11.9 ; python_version >= "3.12" and python_version < "3.15"
ormsgpack==1.12.2 ; python_version >= "3.12" and python_version < "3.15"
packaging==26.2 ; python_version >= "3.12" and python_version < "3.15"
packaging==25.0 ; python_version >= "3.12" and python_version < "3.15"
pandas==2.3.3 ; python_version == "3.14"
pandas==3.0.2 ; python_version >= "3.12" and python_version < "3.14"
pandas==3.0.3 ; python_version >= "3.12" and python_version < "3.14"
pathspec==1.1.1 ; python_version >= "3.12" and python_version < "3.15"
pillow==12.2.0 ; python_version >= "3.12" and python_version < "3.15"
platformdirs==4.9.6 ; python_version >= "3.12" and python_version < "3.15"
portalocker==2.10.1 ; python_version >= "3.13" and python_version < "3.15"
portalocker==3.2.0 ; python_version == "3.12"
posthog==7.14.0 ; python_version >= "3.12" and python_version < "3.15"
posthog==7.14.2 ; python_version >= "3.12" and python_version < "3.15"
prompt-toolkit==3.0.52 ; python_version >= "3.12" and python_version < "3.15"
propcache==0.4.1 ; python_version >= "3.12" and python_version < "3.15"
propcache==0.5.2 ; python_version >= "3.12" and python_version < "3.15"
protobuf==6.33.6 ; python_version >= "3.12" and python_version < "3.15"
psutil==7.2.2 ; python_version >= "3.12" and python_version < "3.15"
psycopg-pool==3.3.1 ; python_version >= "3.12" and python_version < "3.15"
@ -150,22 +152,22 @@ pyasn1-modules==0.4.2 ; python_version >= "3.12" and python_version < "3.15"
pyasn1==0.6.3 ; python_version >= "3.12" and python_version < "3.15"
pycparser==3.0 ; python_version >= "3.12" and python_version < "3.15" and platform_python_implementation != "PyPy" and implementation_name != "PyPy"
pydantic-core==2.27.2 ; python_version >= "3.12" and python_version < "3.15"
pydantic-settings==2.14.0 ; python_version >= "3.12" and python_version < "3.15"
pydantic-settings==2.14.1 ; python_version >= "3.12" and python_version < "3.15"
pydantic==2.10.5 ; python_version >= "3.12" and python_version < "3.15"
pygments==2.20.0 ; python_version >= "3.12" and python_version < "3.15"
pyjwt==2.12.1 ; python_version >= "3.12" and python_version < "3.15"
pyperclip==1.11.0 ; python_version >= "3.12" and python_version < "3.15"
python-dateutil==2.8.2 ; python_version >= "3.12" and python_version < "3.15"
python-dotenv==1.2.2 ; python_version >= "3.12" and python_version < "3.15"
python-multipart==0.0.27 ; python_version >= "3.12" and python_version < "3.15"
python-multipart==0.0.28 ; python_version >= "3.12" and python_version < "3.15"
pytz==2026.2 ; python_version >= "3.12" and python_version < "3.15"
pywin32==311 ; python_version >= "3.12" and python_version < "3.15" and (sys_platform == "win32" or platform_system == "Windows")
pyyaml==6.0.3 ; python_version >= "3.12" and python_version < "3.15"
qdrant-client==1.12.1 ; python_version >= "3.13" and python_version < "3.15"
qdrant-client==1.17.1 ; python_version == "3.12"
qdrant-client==1.18.0 ; python_version == "3.12"
ragflow-sdk==0.23.1 ; python_version >= "3.12" and python_version < "3.15"
referencing==0.37.0 ; python_version >= "3.12" and python_version < "3.15"
regex==2026.4.4 ; python_version >= "3.12" and python_version < "3.15"
regex==2026.5.9 ; python_version >= "3.12" and python_version < "3.15"
requests-toolbelt==1.0.0 ; python_version >= "3.12" and python_version < "3.15"
requests==2.32.5 ; python_version >= "3.12" and python_version < "3.15"
rich==15.0.0 ; python_version >= "3.12" and python_version < "3.15"
@ -188,9 +190,9 @@ tavily-python==0.7.24 ; python_version >= "3.12" and python_version < "3.15"
tenacity==9.1.4 ; python_version >= "3.12" and python_version < "3.15"
textual-autocomplete==4.0.6 ; python_version >= "3.12" and python_version < "3.15"
textual-speedups==0.2.1 ; python_version >= "3.12" and python_version < "3.15"
textual==8.2.5 ; python_version >= "3.12" and python_version < "3.15"
textual==8.2.6 ; python_version >= "3.12" and python_version < "3.15"
threadpoolctl==3.6.0 ; python_version >= "3.12" and python_version < "3.15"
tiktoken==0.12.0 ; python_version >= "3.12" and python_version < "3.15"
tiktoken==0.13.0 ; python_version >= "3.12" and python_version < "3.15"
tokenizers==0.22.2 ; python_version >= "3.12" and python_version < "3.15"
toml==0.10.2 ; python_version >= "3.12" and python_version < "3.15"
tomli-w==1.2.0 ; python_version >= "3.12" and python_version < "3.15"
@ -203,8 +205,8 @@ typing-extensions==4.15.0 ; python_version >= "3.12" and python_version < "3.15"
typing-inspection==0.4.2 ; python_version >= "3.12" and python_version < "3.15"
tzdata==2026.2 ; python_version >= "3.12" and python_version < "3.15" and (sys_platform == "win32" or sys_platform == "emscripten") or python_version == "3.14"
uc-micro-py==2.0.0 ; python_version >= "3.12" and python_version < "3.15"
urllib3==2.6.3 ; python_version >= "3.12" and python_version < "3.15"
uuid-utils==0.14.1 ; python_version >= "3.12" and python_version < "3.15"
urllib3==2.7.0 ; python_version >= "3.12" and python_version < "3.15"
uuid-utils==0.15.0 ; python_version >= "3.12" and python_version < "3.15"
uvicorn==0.35.0 ; python_version >= "3.12" and python_version < "3.15"
uvloop==0.22.1 ; python_version >= "3.12" and python_version < "3.15"
watchfiles==1.1.1 ; python_version >= "3.12" and python_version < "3.15"
@ -212,7 +214,7 @@ wcmatch==10.1 ; python_version >= "3.12" and python_version < "3.15"
wcwidth==0.7.0 ; python_version >= "3.12" and python_version < "3.15"
webrtcvad==2.0.10 ; python_version >= "3.12" and python_version < "3.15"
websockets==15.0.1 ; python_version >= "3.12" and python_version < "3.15"
wrapt==2.1.2 ; python_version >= "3.12" and python_version < "3.15"
wrapt==1.17.3 ; python_version >= "3.12" and python_version < "3.15"
wsgidav==4.3.3 ; python_version >= "3.12" and python_version < "3.15"
xlrd==2.0.2 ; python_version >= "3.12" and python_version < "3.15"
xxhash==3.7.0 ; python_version >= "3.12" and python_version < "3.15"

View File

@ -140,7 +140,16 @@ 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 and ask_user responses even when tool_response is disabled
is_ui_resource = (
msg.text
and msg.text.lstrip().startswith('{"')
and (
('"ui://' in msg.text and '"text/html' in msg.text)
or '"__ask_user__"' 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

View File

@ -0,0 +1,11 @@
{
"name": "mcp-ui",
"description": "Provides interactive UI components through MCP tool responses.",
"mcpServers": {
"mcp_ui": {
"transport": "stdio",
"command": "python",
"args": ["./ui_render_server.py", "{bot_id}"]
}
}
}

View 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

View File

@ -0,0 +1,30 @@
[
{
"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.",
"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 <style> and <script> tags."
},
"width": {
"type": "string",
"description": "CSS width for the iframe. Default: '100%'",
"default": "100%"
},
"height": {
"type": "string",
"description": "CSS height for the iframe. Default: '400px'",
"default": "400px"
}
},
"required": ["title", "html_content"]
}
}
]

View File

@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
MCP UI Server - provides interactive UI rendering tools.
Uses mcp-ui-server SDK to create standard UIResource objects.
"""
import asyncio
import json
import sys
from typing import Any, Dict
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 render_ui(
title: str, html_content: str, width: str = "100%", height: str = "400px"
) -> Dict[str, Any]:
"""Create a UI resource and serialize it as JSON text for passthrough.
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]
ui_resource = create_ui_resource(
{
"uri": f"ui://mcp-ui-skill/{uri_slug}",
"content": {"type": "rawHtml", "htmlString": html_content},
"encoding": "text",
"uiMetadata": {
UIMetadataKey.PREFERRED_FRAME_SIZE: [width, height],
},
}
)
# Serialize the full UIResource as JSON string for passthrough
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]:
"""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, "mcp-ui")
elif method == "ping":
return create_ping_response(request_id)
elif method == "tools/list":
tools = load_tools_from_json("mcp_ui_tools.json")
if not tools:
tools = [
{
"name": "render_ui",
"description": "Render an interactive HTML UI widget in the chat.",
"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.",
},
},
"required": ["title", "html_content"],
},
}
]
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_ui":
title = arguments.get("title", "UI Widget")
html_content = arguments.get("html_content", "")
width = arguments.get("width", "100%")
height = arguments.get("height", "400px")
if not html_content:
return create_error_response(
request_id, -32602, "Missing required parameter: html_content"
)
result = render_ui(title, html_content, width, height)
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())

View File

@ -0,0 +1,11 @@
{
"name": "mcp-ui",
"description": "Provides interactive UI components through MCP tool responses.",
"mcpServers": {
"mcp_ui": {
"transport": "stdio",
"command": "python",
"args": ["./ui_render_server.py", "{bot_id}"]
}
}
}

View 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

View File

@ -0,0 +1,51 @@
[
{
"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.",
"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 <style> and <script> tags."
},
"width": {
"type": "string",
"description": "CSS width for the iframe. Default: '100%'",
"default": "100%"
},
"height": {
"type": "string",
"description": "CSS height for the iframe. Default: '400px'",
"default": "400px"
}
},
"required": ["title", "html_content"]
}
},
{
"name": "ask_user",
"description": "Ask the user a question and present options for them to choose from. Use this tool when you need user input to proceed, such as clarifying requirements, choosing between alternatives, or confirming an action. The question will be displayed at the end of your response with clickable option buttons.",
"inputSchema": {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "The question to ask the user"
},
"options": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of options for the user to choose from. If empty, a free text input will be shown instead."
}
},
"required": ["question"]
}
}
]

View File

@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
MCP UI Server - provides interactive UI rendering tools.
Uses mcp-ui-server SDK to create standard UIResource objects.
"""
import asyncio
import json
import sys
from typing import Any, Dict
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,
)
ASK_USER_MARKER = "__ask_user__"
def ask_user(question: str, options: list = None) -> Dict[str, Any]:
"""Create an ask_user response.
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.
"""
payload = {
"__type__": ASK_USER_MARKER,
"question": question,
"options": options or [],
}
return {
"content": [
{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}
]
}
def render_ui(
title: str, html_content: str, width: str = "100%", height: str = "400px"
) -> Dict[str, Any]:
"""Create a UI resource and serialize it as JSON text for passthrough.
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]
ui_resource = create_ui_resource(
{
"uri": f"ui://mcp-ui-skill/{uri_slug}",
"content": {"type": "rawHtml", "htmlString": html_content},
"encoding": "text",
"uiMetadata": {
UIMetadataKey.PREFERRED_FRAME_SIZE: [width, height],
},
}
)
# Serialize the full UIResource as JSON string for passthrough
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]:
"""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, "mcp-ui")
elif method == "ping":
return create_ping_response(request_id)
elif method == "tools/list":
tools = load_tools_from_json("mcp_ui_tools.json")
if not tools:
tools = [
{
"name": "render_ui",
"description": "Render an interactive HTML UI widget in the chat.",
"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.",
},
},
"required": ["title", "html_content"],
},
}
]
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_ui":
title = arguments.get("title", "UI Widget")
html_content = arguments.get("html_content", "")
width = arguments.get("width", "100%")
height = arguments.get("height", "400px")
if not html_content:
return create_error_response(
request_id, -32602, "Missing required parameter: html_content"
)
result = render_ui(title, html_content, width, height)
return {"jsonrpc": "2.0", "id": request_id, "result": result}
elif tool_name == "ask_user":
question = arguments.get("question", "")
options = arguments.get("options", [])
if not question:
return create_error_response(
request_id, -32602, "Missing required parameter: question"
)
result = ask_user(question, options)
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())

View File

@ -61,12 +61,12 @@ def _list_local_changed_files(workspace_path: Path) -> tuple[bool, list[str]]:
def _tar_workspace_entries(workspace_path: Path, entries: list[Path]) -> bytes:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
with tarfile.open(fileobj=buf, mode="w:gz", dereference=True) as tar:
for entry in entries:
if entry.is_absolute():
tar.add(str(entry), arcname=entry.relative_to(workspace_path).as_posix(), dereference=True)
tar.add(str(entry), arcname=entry.relative_to(workspace_path).as_posix())
else:
tar.add(str(workspace_path / entry), arcname=entry.as_posix(), dereference=True)
tar.add(str(workspace_path / entry), arcname=entry.as_posix())
buf.seek(0)
return buf.read()

View File

@ -109,6 +109,7 @@ DAYTONA_ENABLED = os.getenv("DAYTONA_ENABLED", "false") == "true"
os.environ["OPENAI_API_KEY"] = "your_api_key"
# ============================================================
# RAGFlow Knowledge Base Configuration
# ============================================================
@ -163,3 +164,11 @@ VOICE_LITE_SILENCE_TIMEOUT = float(os.getenv("VOICE_LITE_SILENCE_TIMEOUT", "3.0"
SINGLE_AGENT_MODE = os.getenv("SINGLE_AGENT_MODE", "false") == "true"
TEMPLATE_BOT_ID = os.getenv("TEMPLATE_BOT_ID", "403a2b63-88e4-4db1-b712-8dcf31fc98ea")
TEMPLATE_BOT_NAME = os.getenv("TEMPLATE_BOT_NAME", "智能助手")
# Langfuse Observability Configuration
# ============================================================
LANGFUSE_ENABLED = os.getenv("LANGFUSE_ENABLED", "false") == "true"
LANGFUSE_SECRET_KEY = os.getenv("LANGFUSE_SECRET_KEY", "sk-lf-3d6db91e-ebe3-441d-b965-26ecb8e8df98")
LANGFUSE_PUBLIC_KEY = os.getenv("LANGFUSE_PUBLIC_KEY", "pk-lf-042583dc-2965-411a-be82-16c5050bdb53")
LANGFUSE_HOST = os.getenv("LANGFUSE_HOST", "https://langfuse.gbase.ai")