ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
+99
@@ -0,0 +1,99 @@
|
||||
FROM python:3.13-slim-bookworm as base
|
||||
|
||||
# Avoid prompts from apt
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Configure mirror
|
||||
ARG ENABLE_MIRROR=false
|
||||
ENV PIP_INDEX_URL=${ENABLE_MIRROR:+https://mirrors.aliyun.com/pypi/simple}
|
||||
RUN if [ "$ENABLE_MIRROR" = "true" ]; then \
|
||||
rm -rfv /etc/apt/sources.list.d/* && \
|
||||
echo "deb http://ftp.cn.debian.org/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
|
||||
echo "deb http://ftp.cn.debian.org/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb http://ftp.cn.debian.org/debian bookworm-backports main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb http://ftp.cn.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
# Install dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
nodejs \
|
||||
npm \
|
||||
git \
|
||||
xvfb \
|
||||
x11vnc \
|
||||
openbox \
|
||||
supervisor \
|
||||
novnc \
|
||||
websockify \
|
||||
procps \
|
||||
xdg-utils \
|
||||
python3-xdg \
|
||||
x11-xserver-utils \
|
||||
curl \
|
||||
--no-install-recommends
|
||||
|
||||
# Install noVNC
|
||||
RUN git clone --depth 1 --branch v1.6.0 https://github.com/novnc/noVNC.git /usr/local/novnc \
|
||||
&& git clone --depth 1 --branch v0.13.0 https://github.com/novnc/websockify /usr/local/novnc/utils/websockify
|
||||
|
||||
# Set up working directory
|
||||
WORKDIR /app/view_server
|
||||
|
||||
# Install Playwright and browsers with dependencies
|
||||
RUN npm install playwright@1.54
|
||||
RUN npx playwright install chromium --with-deps
|
||||
|
||||
# Set up supervisord configuration
|
||||
COPY docker/resources/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
|
||||
# Copy scripts
|
||||
COPY docker/resources/start.sh start.sh
|
||||
COPY docker/resources/playwright-server.js playwright-server.js
|
||||
COPY docker/resources/x11-setup.sh x11-setup.sh
|
||||
|
||||
# Make scripts executable
|
||||
RUN chmod +x start.sh x11-setup.sh
|
||||
|
||||
# Create a simple openbox configuration to only show the browser window
|
||||
RUN mkdir -p /root/.config/openbox
|
||||
COPY docker/resources/openbox-rc.xml /root/.config/openbox/rc.xml
|
||||
|
||||
ENV PLAYWRIGHT_WS_PATH="default"
|
||||
ENV PLAYWRIGHT_PORT=37367
|
||||
ENV NO_VNC_PORT=5901
|
||||
|
||||
# Set the display environment variable
|
||||
ENV DISPLAY=:99
|
||||
|
||||
COPY docker/resources/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
# MCP Server Container
|
||||
FROM base
|
||||
|
||||
RUN pip install -U --break-system-packages uv
|
||||
|
||||
COPY mcp_server_proxy /app/mcp_server_proxy
|
||||
|
||||
RUN cd /app/mcp_server_proxy && uv sync --python-preference=only-system
|
||||
|
||||
RUN apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
unzip \
|
||||
libterm-readline-perl-perl \
|
||||
libmupdf-dev \
|
||||
vim \
|
||||
libmagic1
|
||||
|
||||
WORKDIR /root/workspace
|
||||
|
||||
EXPOSE 4242
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=10s --start-period=10s --retries=12 \
|
||||
CMD curl -f http://localhost:4242/health || exit 1
|
||||
|
||||
CMD ["sh", "-c", "/app/view_server/start.sh && cd /app/mcp_server_proxy && uv run --no-sync -m mcp_server_proxy.main"]
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
docker build -t mcp-server-base -f Dockerfile_mcp_server . && \
|
||||
|
||||
echo "✅ Build image success: mcp-server-base"
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
umask 000
|
||||
|
||||
exec "$@"
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<openbox_config xmlns="http://openbox.org/3.4/rc" xmlns:xi="http://www.w3.org/2001/XInclude">
|
||||
<desktops>
|
||||
<number>1</number>
|
||||
</desktops>
|
||||
<margins>
|
||||
<top>0</top>
|
||||
<bottom>0</bottom>
|
||||
<left>0</left>
|
||||
<right>0</right>
|
||||
</margins>
|
||||
<applications>
|
||||
<application class="*">
|
||||
<decor>no</decor>
|
||||
<maximized>yes</maximized>
|
||||
<fullscreen>yes</fullscreen>
|
||||
<position>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
</position>
|
||||
<size>
|
||||
<width>100%</width>
|
||||
<height>100%</height>
|
||||
</size>
|
||||
<focus>yes</focus>
|
||||
<desktop>1</desktop>
|
||||
<layer>normal</layer>
|
||||
</application>
|
||||
</applications>
|
||||
</openbox_config>
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "playwright-remote",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.51.1"
|
||||
}
|
||||
}
|
||||
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
const { chromium } = require("playwright");
|
||||
// Read ws path from environment variable
|
||||
const wsPath = process.env.WS_PATH || "default";
|
||||
const port = process.env.PLAYWRIGHT_PORT || 37367;
|
||||
|
||||
(async () => {
|
||||
console.log("Starting Playwright server...");
|
||||
|
||||
// Start the remote debugging server
|
||||
const browserServer = await chromium.launchServer({
|
||||
headless: false,
|
||||
port: port,
|
||||
wsPath: wsPath,
|
||||
args: [
|
||||
"--start-fullscreen",
|
||||
"--start-maximized",
|
||||
"--window-size=1280,1280",
|
||||
"--window-position=0,0",
|
||||
"--disable-infobars",
|
||||
"--no-default-browser-check",
|
||||
"--kiosk",
|
||||
"--disable-session-crashed-bubble",
|
||||
"--noerrdialogs",
|
||||
"--force-device-scale-factor=1.0",
|
||||
"--disable-features=DefaultViewportMetaTag",
|
||||
"--force-device-width=1280",
|
||||
],
|
||||
});
|
||||
|
||||
console.log(`Playwright server running: ${browserServer.wsEndpoint()}`);
|
||||
|
||||
// Keep the process running
|
||||
process.on("SIGINT", async () => {
|
||||
console.log("Shutting down Playwright server...");
|
||||
// await browser.close();
|
||||
await browserServer.close();
|
||||
process.exit(0);
|
||||
});
|
||||
})();
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Starting services..."
|
||||
echo "DISPLAY=$DISPLAY"
|
||||
|
||||
# Start supervisord to manage all processes
|
||||
exec supervisord -c /etc/supervisor/supervisord.conf
|
||||
Vendored
+64
@@ -0,0 +1,64 @@
|
||||
[supervisord]
|
||||
logfile=/var/log/supervisord.log
|
||||
logfile_maxbytes=50MB
|
||||
loglevel=info
|
||||
|
||||
[include]
|
||||
files = /etc/supervisor/conf.d/*.conf
|
||||
|
||||
[program:xvfb]
|
||||
command=Xvfb :99 -screen 0 2560x2560x24 -dpi 192 -ac -nolisten tcp
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:openbox]
|
||||
command=openbox-session
|
||||
environment=DISPLAY=:99
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:x11setup]
|
||||
command=/app/view_server/x11-setup.sh
|
||||
environment=DISPLAY=:99
|
||||
autorestart=false
|
||||
startsecs=0
|
||||
startretries=0
|
||||
priority=10
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:x11vnc]
|
||||
command=x11vnc -display :99 -forever -shared -nopw -geometry 1280x1280 -scale 1:1 -nomodtweak -noxdamage
|
||||
autorestart=true
|
||||
priority=20
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:novnc]
|
||||
command=/usr/local/novnc/utils/novnc_proxy --vnc localhost:5900 --listen %(ENV_NO_VNC_PORT)s
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:playwright-server]
|
||||
command=node /app/view_server/playwright-server.js
|
||||
environment=DISPLAY=:99,WS_PATH=%(ENV_PLAYWRIGHT_WS_PATH)s,PLAYWRIGHT_PORT=%(ENV_PLAYWRIGHT_PORT)s
|
||||
autorestart=true
|
||||
priority=30
|
||||
startsecs=1
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Make sure DISPLAY is set
|
||||
if [ -z "$DISPLAY" ]; then
|
||||
export DISPLAY=:99
|
||||
fi
|
||||
|
||||
# Set background to black to make black bars less obvious
|
||||
xsetroot -solid "#000000"
|
||||
|
||||
# Force X11 to use the exact screen dimensions without any offsets
|
||||
xrandr --output default --mode 1280x1280 --pos 0x0
|
||||
|
||||
# Set proper DPI settings for the display
|
||||
echo "Xft.dpi: 96" | xrdb -merge
|
||||
echo "Xft.antialias: 1" | xrdb -merge
|
||||
echo "Xft.hinting: 1" | xrdb -merge
|
||||
echo "Xft.hintstyle: hintfull" | xrdb -merge
|
||||
echo "Xft.rgba: rgb" | xrdb -merge
|
||||
|
||||
# Disable any screen savers or power management
|
||||
xset s off
|
||||
xset -dpms
|
||||
xset s noblank
|
||||
|
||||
# Ensure consistent scaling
|
||||
xrandr --dpi 96
|
||||
|
||||
echo "X11 environment configured for optimal display"
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
[project]
|
||||
name = "mcp-proxy"
|
||||
version = "0.1.0"
|
||||
description = "MCP Proxy"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"docker",
|
||||
"aiohttp",
|
||||
"playwright==1.52",
|
||||
"pydantic",
|
||||
"pydantic-settings",
|
||||
"fastapi[standard]",
|
||||
"typer",
|
||||
"aiofiles",
|
||||
"python-dotenv",
|
||||
"websockets",
|
||||
"pyyaml",
|
||||
"mcp==1.12.4",
|
||||
"httpx[http2]",
|
||||
"requests>=2.32.5",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
mcp_servers_path = os.getenv(
|
||||
"MCP_SERVERS_PATH",
|
||||
str((Path(__file__).parent.parent.parent.parent / "mcp_servers").resolve()),
|
||||
)
|
||||
|
||||
mcp_servers_config_path = os.getenv(
|
||||
"MCP_SERVERS_CONFIG_PATH", str((Path(mcp_servers_path) / "mcp_config.py").resolve())
|
||||
)
|
||||
|
||||
mcp_tool_schema_path = os.getenv(
|
||||
"MCP_TOOL_SCHEMA_PATH",
|
||||
str((Path(mcp_servers_path) / "mcp_tool_schema.json").resolve()),
|
||||
)
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .mcp_server_proxy import MCPServerProxy
|
||||
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
mcp = MCPServerProxy(
|
||||
name="MCP Server",
|
||||
stateless_http=False,
|
||||
host="0.0.0.0",
|
||||
port=4242,
|
||||
log_level="DEBUG",
|
||||
)
|
||||
|
||||
|
||||
@mcp.custom_route("/health", methods=["GET"])
|
||||
async def health(request: Request) -> Response:
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "MCP Server is healthy",
|
||||
"last_active": datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S.%f"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info("Starting MCP Server Proxy...")
|
||||
await mcp.initialize()
|
||||
await mcp.run_streamable_http_async()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
from contextlib import AsyncExitStack
|
||||
from datetime import timedelta
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import traceback
|
||||
from typing import Any
|
||||
import asyncio
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.server.fastmcp import Context
|
||||
from mcp.server.session import ServerSessionT
|
||||
from mcp.shared.context import LifespanContextT, RequestT
|
||||
import logging
|
||||
from mcp.client.stdio import stdio_client
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
|
||||
from mcp.types import LoggingMessageNotificationParams
|
||||
from .configs import mcp_servers_path
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MCPServerExecutor:
|
||||
def __init__(self, name: str, config: dict):
|
||||
self._name = name
|
||||
self._config = config
|
||||
self._session = None
|
||||
self._exit_stack = None
|
||||
self._lock = asyncio.Lock()
|
||||
self._init_event = asyncio.Event()
|
||||
self._terminate_event = asyncio.Event()
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
name: str,
|
||||
arguments: dict[str, Any],
|
||||
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
|
||||
convert_result: bool = False,
|
||||
) -> Any:
|
||||
"""Call a tool by name with arguments."""
|
||||
|
||||
await self._ensure_server_ready()
|
||||
|
||||
async def progress_callback_adapter(
|
||||
progress: float, total: float | None, message: str | None
|
||||
):
|
||||
logger.info(
|
||||
f"progress_callback: tool={name}, {progress}, {total}, {message}"
|
||||
)
|
||||
await self.progress_callback(
|
||||
progress=progress,
|
||||
total=total,
|
||||
message=message,
|
||||
context=context,
|
||||
)
|
||||
|
||||
result = await self._session.call_tool(
|
||||
name, arguments, progress_callback=progress_callback_adapter
|
||||
)
|
||||
return result.content, result.structuredContent
|
||||
|
||||
async def _ensure_server_ready(self):
|
||||
if not self._session:
|
||||
asyncio.create_task(self._start_tool_server())
|
||||
await self._init_event.wait()
|
||||
|
||||
async def _start_tool_server(
|
||||
self,
|
||||
):
|
||||
if self._session is not None:
|
||||
return
|
||||
async with self._lock:
|
||||
if self._session is not None:
|
||||
return
|
||||
|
||||
name: str = self._name
|
||||
config: dict = self._config
|
||||
try:
|
||||
logger.info(f"Starting tool server {name} with config {config}")
|
||||
exit_stack = AsyncExitStack()
|
||||
await exit_stack.__aenter__()
|
||||
|
||||
# Create client context and enter it
|
||||
if config.get("type") == "sse":
|
||||
read_stream, write_stream = await exit_stack.enter_async_context(
|
||||
sse_client(
|
||||
url=config.get("url", ""),
|
||||
headers=config.get("headers", {}),
|
||||
timeout=config.get("timeout", 60 * 10),
|
||||
sse_read_timeout=config.get("sse_read_timeout", 60 * 10),
|
||||
auth=config.get("auth", None),
|
||||
)
|
||||
)
|
||||
|
||||
elif config.get("type") == "streamable_http":
|
||||
read_stream, write_stream, _ = await exit_stack.enter_async_context(
|
||||
streamablehttp_client(
|
||||
url=config.get("url", ""),
|
||||
headers=config.get("headers", {}),
|
||||
timeout=config.get("timeout", 60 * 10),
|
||||
sse_read_timeout=config.get("sse_read_timeout", 60 * 10),
|
||||
auth=config.get("auth", None),
|
||||
)
|
||||
)
|
||||
|
||||
else: # stdio
|
||||
env = config.get("env", {})
|
||||
env.update(os.environ)
|
||||
server_params = StdioServerParameters(
|
||||
command=config.get("command", ""),
|
||||
args=config.get("args", []),
|
||||
env=env,
|
||||
cwd=str(Path(mcp_servers_path) / config.get("cwd", "")),
|
||||
)
|
||||
read_stream, write_stream = await exit_stack.enter_async_context(
|
||||
stdio_client(server=server_params)
|
||||
)
|
||||
|
||||
async def log_callback(params: LoggingMessageNotificationParams):
|
||||
logger.info(f"MCP Server {name} >>> {params}")
|
||||
|
||||
# Create session and tool manager
|
||||
session = await exit_stack.enter_async_context(
|
||||
ClientSession(
|
||||
read_stream,
|
||||
write_stream,
|
||||
logging_callback=log_callback,
|
||||
read_timeout_seconds=timedelta(
|
||||
seconds=config.get("read_timeout", 60 * 10)
|
||||
),
|
||||
)
|
||||
)
|
||||
await session.initialize()
|
||||
self._session = session
|
||||
self._exit_stack = exit_stack
|
||||
logger.info(f"Starting tool server success! {name}: {config}")
|
||||
self._init_event.set()
|
||||
await self._terminate_event.wait()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error starting tool server {name}: {config}\n{traceback.format_exc()}"
|
||||
)
|
||||
self._init_event.set()
|
||||
try:
|
||||
await self._exit_stack.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
raise e
|
||||
|
||||
async def cleanup(self):
|
||||
self._terminate_event.set()
|
||||
self._session = None
|
||||
self._exit_stack = None
|
||||
|
||||
async def progress_callback(
|
||||
self,
|
||||
progress: float,
|
||||
total: float | None,
|
||||
message: str | None,
|
||||
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
|
||||
):
|
||||
|
||||
def get_session_id() -> str:
|
||||
try:
|
||||
return context.request_context.request.headers.get("Mcp-Session-Id")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting session id: {e}")
|
||||
return None
|
||||
|
||||
if "tool_call_card_novnc_window" == message:
|
||||
"""Show the VNC window"""
|
||||
vnc_tool_card = {
|
||||
"type": "tool_call_card_novnc_window",
|
||||
"card_data": {
|
||||
"title": "VNC Window",
|
||||
"url": f"/novnc/{get_session_id()}/vnc.html?autoconnect=true&reconnect=true&quality=9&compression=9&show_dot=0&resize=local",
|
||||
"token": get_session_id(),
|
||||
},
|
||||
}
|
||||
message = f"""\
|
||||
\n\n
|
||||
```tool_card
|
||||
{json.dumps(vnc_tool_card, indent=2, ensure_ascii=False)}
|
||||
```
|
||||
\n\n
|
||||
"""
|
||||
if context:
|
||||
await context.report_progress(
|
||||
progress=progress, total=total, message=message
|
||||
)
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import json
|
||||
from importlib.util import spec_from_file_location, module_from_spec
|
||||
from pathlib import Path
|
||||
from .configs import mcp_servers_config_path, mcp_tool_schema_path
|
||||
|
||||
|
||||
class MCPServerLoader:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def load_mcp_servers_config(self):
|
||||
mcp_config = self._load_mcp_config()
|
||||
return mcp_config.get("mcpServers", {})
|
||||
|
||||
def load_mcp_tool_schema(self):
|
||||
with open(mcp_tool_schema_path, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
def _load_mcp_config(self):
|
||||
path = Path(mcp_servers_config_path).resolve()
|
||||
assert path.exists(), f"MCP servers config file not found: {path}"
|
||||
|
||||
spec = spec_from_file_location("mcp_servers_config", path)
|
||||
module = module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
mcp_config = getattr(module, "mcp_config")
|
||||
return mcp_config
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import traceback
|
||||
from typing import Any, Callable, Sequence
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
from mcp.server.fastmcp.resources import Resource
|
||||
from mcp.server.fastmcp.tools import Tool, ToolManager
|
||||
from mcp.server.session import ServerSessionT
|
||||
from mcp.shared.context import LifespanContextT, RequestT
|
||||
from mcp.types import ContentBlock, ToolAnnotations
|
||||
from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase, FuncMetadata
|
||||
import logging
|
||||
from mcp.server.fastmcp.tools.base import Tool as ServerTool
|
||||
from mcp.types import Tool as ClientTool
|
||||
|
||||
from .mcp_server_executor import MCPServerExecutor
|
||||
from .mcp_server_loader import MCPServerLoader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MCPServerProxy(FastMCP):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._mcp_tool_schema: dict[str, list[dict[str, Any]]] = {}
|
||||
self._mcp_server_executors: dict[str, MCPServerExecutor] = {}
|
||||
self._mcp_server_loader = MCPServerLoader()
|
||||
|
||||
async def initialize(self):
|
||||
self._load_tool_schema()
|
||||
self._load_mcp_servers()
|
||||
|
||||
async def call_tool(
|
||||
self, name: str, arguments: dict[str, Any]
|
||||
) -> Sequence[ContentBlock] | dict[str, Any]:
|
||||
"""Call a tool by name with arguments."""
|
||||
try:
|
||||
context = self.get_context()
|
||||
request_mcp_server_executor = self._get_request_mcp_server_executor(
|
||||
tool_name=name
|
||||
)
|
||||
|
||||
return await request_mcp_server_executor.call_tool(
|
||||
name, arguments, context=context, convert_result=True
|
||||
)
|
||||
except:
|
||||
logger.error(f"Error calling tool {name}: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
async def list_tools(self) -> list[ClientTool]:
|
||||
"""List all available tools."""
|
||||
try:
|
||||
request_mcp_servers = self._get_request_mcp_servers()
|
||||
|
||||
request_tools = [
|
||||
tool
|
||||
for server_name, server_tools in self._mcp_tool_schema.items()
|
||||
if server_name in request_mcp_servers
|
||||
for tool in server_tools
|
||||
]
|
||||
|
||||
return [
|
||||
ClientTool(
|
||||
name=tool.get("name", ""),
|
||||
title=tool.get("title", ""),
|
||||
description=tool.get("description", ""),
|
||||
inputSchema=tool.get("inputSchema", {}),
|
||||
outputSchema=tool.get("outputSchema", {}),
|
||||
annotations=tool.get("annotations", {}),
|
||||
_meta=tool.get("_meta", {}),
|
||||
)
|
||||
for tool in request_tools
|
||||
]
|
||||
except:
|
||||
logger.error(f"Error listing tools: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
def _load_tool_schema(self):
|
||||
self._mcp_tool_schema = self._mcp_server_loader.load_mcp_tool_schema()
|
||||
mcp_tool_schema = ""
|
||||
for server_name, server_tools in self._mcp_tool_schema.items():
|
||||
mcp_tool_schema += f" {server_name}:\n"
|
||||
for tool in server_tools:
|
||||
mcp_tool_schema += f" - {tool.get('name', '')}\n"
|
||||
logger.info(f"Loaded MCP tool schema: mcp_tool_schema={mcp_tool_schema}")
|
||||
|
||||
def _load_mcp_servers(self):
|
||||
for name, config in self._mcp_server_loader.load_mcp_servers_config().items():
|
||||
self._mcp_server_executors[name] = MCPServerExecutor(name, config)
|
||||
logger.info(f"Added MCP server executor: {name}")
|
||||
|
||||
def _get_request_mcp_servers(self) -> list[str]:
|
||||
context = self.get_context()
|
||||
request_servers = context.request_context.request.headers.get("MCP_SERVERS")
|
||||
if request_servers:
|
||||
return [server.strip() for server in request_servers.split(",")]
|
||||
return []
|
||||
|
||||
def _get_request_mcp_server_executor(self, tool_name: str) -> MCPServerExecutor:
|
||||
request_mcp_servers = self._get_request_mcp_servers()
|
||||
|
||||
request_tools = {
|
||||
server_name: tool
|
||||
for server_name, server_tools in self._mcp_tool_schema.items()
|
||||
if server_name in request_mcp_servers
|
||||
for tool in server_tools
|
||||
if tool.get("name", "") == tool_name
|
||||
}
|
||||
|
||||
if not request_tools:
|
||||
raise ToolError(f"Tool {tool_name} not found")
|
||||
else:
|
||||
if len(request_tools) > 1:
|
||||
logger.warning(
|
||||
f"Tool {tool_name} found in multiple MCP servers: {request_tools}"
|
||||
)
|
||||
server_name = list(request_tools.keys())[0]
|
||||
return self._mcp_server_executors[server_name]
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncGenerator, List
|
||||
|
||||
from mcp import ClientSession
|
||||
from mcp.types import (
|
||||
LoggingMessageNotificationParams,
|
||||
ElicitResult,
|
||||
ElicitRequestParams,
|
||||
)
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp.shared.context import RequestContext
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def mcp_client(
|
||||
url: str,
|
||||
token: str,
|
||||
session_id: str = None,
|
||||
mcp_servers: List[str] = [
|
||||
"readweb-server",
|
||||
"browser-server",
|
||||
"browseruse-server",
|
||||
"documents-csv-server",
|
||||
"documents-docx-server",
|
||||
"documents-pptx-server",
|
||||
"documents-pdf-server",
|
||||
"documents-txt-server",
|
||||
"download-server",
|
||||
"intelligence-code-server",
|
||||
"intelligence-think-server",
|
||||
"intelligence-guard-server",
|
||||
"media-audio-server",
|
||||
"media-image-server",
|
||||
"media-video-server",
|
||||
"parxiv-server",
|
||||
"terminal-server",
|
||||
"wayback-server",
|
||||
"wiki-server",
|
||||
"googlesearch-server",
|
||||
],
|
||||
) -> AsyncGenerator[ClientSession, None]:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"MCP_SERVERS": ",".join(mcp_servers),
|
||||
}
|
||||
if session_id:
|
||||
headers["SESSION_ID"] = session_id
|
||||
|
||||
async with streamablehttp_client(
|
||||
url=url,
|
||||
headers=headers,
|
||||
) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
get_session_id,
|
||||
):
|
||||
|
||||
async def logging_callback(params: LoggingMessageNotificationParams):
|
||||
logger.info(f"Receive logging callback: {params}")
|
||||
|
||||
async def elicitation_callback(
|
||||
context: RequestContext["ClientSession", Any],
|
||||
params: ElicitRequestParams,
|
||||
) -> ElicitResult:
|
||||
logger.info(f"Receive elicitation callback: {params}")
|
||||
return ElicitResult(action="accept", content={"user_name": "John"})
|
||||
|
||||
async with ClientSession(
|
||||
read_stream=read_stream,
|
||||
write_stream=write_stream,
|
||||
logging_callback=logging_callback,
|
||||
elicitation_callback=elicitation_callback,
|
||||
) as session:
|
||||
logger.info(f"MCP client connected: url={url}")
|
||||
await session.initialize()
|
||||
logger.info(
|
||||
f"MCP client session initialized: url={url}, session_id={get_session_id()}"
|
||||
)
|
||||
|
||||
yield session
|
||||
|
||||
|
||||
async def progress_callback(progress: float, total: float | None, message: str | None):
|
||||
logger.info(
|
||||
f"Receive progress callback: progress={progress}, total={total}, message={message}"
|
||||
)
|
||||
|
||||
if "```tool_card" in message:
|
||||
data = json.loads(message.split("```tool_card")[1].split("```")[0])
|
||||
vnc_url = f"{base_url}{data.get('card_data').get('url')}"
|
||||
logger.info(f"VNC URL: {vnc_url}")
|
||||
subprocess.run(["open", vnc_url])
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import asyncio
|
||||
from contextlib import AsyncExitStack
|
||||
from datetime import timedelta
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from mcp import ClientSession, StdioServerParameters, stdio_client
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
config = {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "~/workspace"],
|
||||
}
|
||||
|
||||
|
||||
async def server_session():
|
||||
async with AsyncExitStack() as exit_stack:
|
||||
# Create client context and enter it
|
||||
if config.get("type") == "sse":
|
||||
read_stream, write_stream = await exit_stack.enter_async_context(
|
||||
sse_client(
|
||||
url=config.get("url", ""),
|
||||
headers=config.get("headers", {}),
|
||||
timeout=config.get("timeout", 60),
|
||||
sse_read_timeout=config.get("sse_read_timeout", 60 * 5),
|
||||
auth=config.get("auth", None),
|
||||
)
|
||||
)
|
||||
|
||||
elif config.get("type") == "streamable_http":
|
||||
read_stream, write_stream, _ = await exit_stack.enter_async_context(
|
||||
streamablehttp_client(
|
||||
url=config.get("url", ""),
|
||||
headers=config.get("headers", {}),
|
||||
timeout=config.get("timeout", 120),
|
||||
sse_read_timeout=config.get("sse_read_timeout", 60 * 5),
|
||||
auth=config.get("auth", None),
|
||||
)
|
||||
)
|
||||
|
||||
else: # stdio
|
||||
base_folder = Path(__file__).parent
|
||||
server_params = StdioServerParameters(
|
||||
command=config.get("command", ""),
|
||||
args=config.get("args", []),
|
||||
env=config.get("env", {}),
|
||||
cwd=str(base_folder / config.get("cwd", "")),
|
||||
)
|
||||
read_stream, write_stream = await exit_stack.enter_async_context(
|
||||
stdio_client(server=server_params)
|
||||
)
|
||||
|
||||
# Create session and tool manager
|
||||
session = await exit_stack.enter_async_context(
|
||||
ClientSession(
|
||||
read_stream,
|
||||
write_stream,
|
||||
read_timeout_seconds=timedelta(seconds=config.get("read_timeout", 120)),
|
||||
)
|
||||
)
|
||||
await session.initialize()
|
||||
|
||||
yield session
|
||||
|
||||
async def test():
|
||||
async for session in server_session():
|
||||
ls = await session.list_tools()
|
||||
assert ls and ls.tools, "list_tools return null"
|
||||
tools = ls.tools
|
||||
logger.info(f"list_tools return:\n - {'\n - '.join([t.name for t in tools])}")
|
||||
print(tools[0])
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test())
|
||||
+1
@@ -0,0 +1 @@
|
||||
tool_test_cases = [{"tool_name": "read_url", "args": {"url": "https://www.baidu.com"}}]
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import subprocess
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
from mcp import ClientSession
|
||||
from mcp.types import (
|
||||
LoggingMessageNotificationParams,
|
||||
ElicitResult,
|
||||
ElicitRequestParams,
|
||||
)
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp.shared.context import RequestContext
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LOCAL_MCP_TOKEN_SECRET = "123321"
|
||||
|
||||
|
||||
def _jwt_part(value: dict) -> str:
|
||||
raw = json.dumps(value, separators=(",", ":")).encode()
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def gen_local_mcp_token(app: str = "mcp-gateway-debug") -> str:
|
||||
secret = os.getenv("MCP_GATEWAY_TOKEN_SECRET", LOCAL_MCP_TOKEN_SECRET)
|
||||
header = {"alg": "HS256", "typ": "JWT"}
|
||||
payload = {"app": app, "version": 1, "time": time.time()}
|
||||
signing_input = f"{_jwt_part(header)}.{_jwt_part(payload)}"
|
||||
signature = hmac.new(
|
||||
secret.encode(),
|
||||
signing_input.encode(),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
encoded_signature = base64.urlsafe_b64encode(signature).rstrip(b"=").decode()
|
||||
return f"{signing_input}.{encoded_signature}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
base_url, token = (
|
||||
"http://localhost:8000",
|
||||
gen_local_mcp_token(),
|
||||
)
|
||||
|
||||
asyncio.run(McpClient.mcp_test_client(base_url, token))
|
||||
|
||||
|
||||
class McpClient:
|
||||
|
||||
async def mcp_test_client(
|
||||
base_url: str, token: str
|
||||
) -> AsyncGenerator[ClientSession, None]:
|
||||
url = f"{base_url}/mcp"
|
||||
async with streamablehttp_client(
|
||||
url=url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"MCP_SERVERS": "readweb-server,browser-server,browseruse-server,documents-csv-server,documents-docx-server,documents-pptx-server,documents-pdf-server,documents-txt-server,download-server,intelligence-code-server,intelligence-think-server,intelligence-guard-server,media-audio-server,media-image-server,media-video-server,parxiv-server,terminal-server,wayback-server,wiki-server,googlesearch-server",
|
||||
# "SESSION_ID": "CHAT_WLDEV",
|
||||
},
|
||||
) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
get_session_id,
|
||||
):
|
||||
|
||||
async def logging_callback(params: LoggingMessageNotificationParams):
|
||||
logger.info(f"Receive logging callback: {params}")
|
||||
|
||||
async def elicitation_callback(
|
||||
context: RequestContext["ClientSession", Any],
|
||||
params: ElicitRequestParams,
|
||||
) -> ElicitResult:
|
||||
logger.info(f"Receive elicitation callback: {params}")
|
||||
return ElicitResult(action="accept", content={"user_name": "John"})
|
||||
|
||||
async with ClientSession(
|
||||
read_stream=read_stream,
|
||||
write_stream=write_stream,
|
||||
logging_callback=logging_callback,
|
||||
elicitation_callback=elicitation_callback,
|
||||
) as session:
|
||||
logger.info(f"MCP client connected: url={url}")
|
||||
await session.initialize()
|
||||
logger.info(
|
||||
f"MCP client session initialized: url={url}, session_id={get_session_id()}"
|
||||
)
|
||||
|
||||
yield session
|
||||
|
||||
|
||||
async def progress_callback(progress: float, total: float | None, message: str | None):
|
||||
logger.info(
|
||||
f"Receive progress callback: progress={progress}, total={total}, message={message}"
|
||||
)
|
||||
if "```tool_card" in message:
|
||||
data = json.loads(message.split("```tool_card")[1].split("```")[0])
|
||||
vnc_url = f"{base_url}{data.get('card_data').get('url')}"
|
||||
logger.info(f"VNC URL: {vnc_url}")
|
||||
subprocess.run(["open", vnc_url])
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Add the project root to Python path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from core.mcp_client import mcp_client, progress_callback
|
||||
from core.test_data import tool_test_cases
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
read_arg = lambda e: (os.getenv(f"URL_{e}"), os.getenv(f"TOKEN_{e}"))
|
||||
|
||||
url, token = read_arg("REMOTE")
|
||||
# url, token = read_arg("GW_DEBUG")
|
||||
# url, token = read_arg("MCP_DEBUG")
|
||||
|
||||
async def main():
|
||||
async for session in mcp_client(url, token):
|
||||
ls = await session.list_tools()
|
||||
assert ls and ls.tools, "list_tools return null"
|
||||
tools = ls.tools
|
||||
logger.info(f"list_tools return:\n - {'\n - '.join([t.name for t in tools])}")
|
||||
t = tools[0]
|
||||
assert t.name, "tool.name is null"
|
||||
assert t.inputSchema, "tool.inputSchema is null"
|
||||
assert t.outputSchema, "tool.outputSchema is null"
|
||||
|
||||
for t in tool_test_cases:
|
||||
tool_name = t["tool_name"]
|
||||
args = t["args"]
|
||||
logger.info(f"call tool: {tool_name}")
|
||||
result = await session.call_tool(tool_name, args, progress_callback=progress_callback)
|
||||
logger.info(f"tool result: {result.content[0].text[:300]}")
|
||||
|
||||
input("Press Enter to continue...")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Generated
Vendored
+1439
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user