feat: MCP initial support for sse servers (Part 2)
Rafael Uzarowski committed
Mar 13, 2025 at 11:25 UTC
732b462865808e35c4d1f2b3329e8e18a446ea2f
3 files changed
+98
-53
docker/run/fs/ins/pre_install.sh
+15
-1
@@ -20,7 +20,21 @@ apt-get update && apt-get upgrade -y && apt-get install -y \
20
ffmpeg \
21
nginx\
22
supervisor \
23
- cron
23
+ cron \
24
+ libmagic-dev \
25
+ poppler-utils \
26
+ tesseract-ocr \
27
+ qpdf \
28
+ libreoffice \
29
+ pandoc \
30
+ libgtk-3-0 \
31
+ libnss3 \
32
+ libatk1.0-0 \
33
+ libatk-bridge2.0-0 \
34
+ libcups2 \
35
+ libasound2 \
36
+ libasound2-data \
37
+ cargo
38
39
echo "=====MID UPDATE====="
40
python/helpers/mcp.py
+80
-52
@@ -1,28 +1,25 @@
1
-from pydantic import BaseModel, Field, Discriminator, Tag, PrivateAttr
2
-from typing import List, Dict, Optional, Any, Union, Literal, Annotated
3
-from typing import (
4
- List, Dict, Optional, Any,
5
- Union, Literal, Annotated, ClassVar,
6
-)
1
+from abc import ABC, abstractmethod
2
+from typing import List, Dict, Optional, Any, Union, Literal, Annotated, ClassVar, cast
3
import threading
4
import asyncio
5
from contextlib import AsyncExitStack
6
from shutil import which
7
+from datetime import timedelta
8
+import dirtyjson
9
+import json
10
from mcp import ClientSession, StdioServerParameters
11
from mcp.client.stdio import stdio_client
12
+from mcp.client.sse import sse_client
13
from mcp.types import CallToolResult, ListToolsResult, JSONRPCMessage
14
from anyio.streams.memory import (
15
MemoryObjectReceiveStream,
16
MemoryObjectSendStream,
17
)
18
+
19
+from pydantic import BaseModel, Field, Discriminator, Tag, PrivateAttr
20
from python.helpers.dirty_json import DirtyJson
21
from python.helpers.print_style import PrintStyle
20
-import dirtyjson
21
-
22
from python.helpers.tool import Tool, Response
23
-from datetime import timedelta
24
-
25
-from abc import ABC, abstractmethod
23
24
25
class MCPTool(Tool):
@@ -59,7 +56,7 @@ class MCPTool(Tool):
56
self.log = self.get_log_object()
57
58
for key, value in self.args.items():
62
- PrintStyle(font_color="#85C1E9", bold=True).stream(self.nice_key(key)+": ")
59
+ PrintStyle(font_color="#85C1E9", bold=True).stream(self.nice_key(key) + ": ")
60
PrintStyle(font_color="#85C1E9", padding=isinstance(value, str) and "\n" in value).stream(value)
61
PrintStyle().print()
62
@@ -85,37 +82,46 @@ class MCPServerRemote(BaseModel):
82
description: Optional[str] = Field(default="Remote SSE Server")
83
url: str = Field(default_factory=str)
84
headers: dict[str, Any] | None = Field(default_factory=dict[str, Any])
88
- timeout: float = 5.0
89
- sse_read_timeout: float = 60.0 * 5.0
90
- disabled: bool = False
85
+ timeout: float = Field(default=5.0)
86
+ sse_read_timeout: float = Field(default=60.0 * 5.0)
87
+ disabled: bool = Field(default=False)
88
89
__lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock())
90
+ __client: Optional["MCPClientRemote"] = PrivateAttr(default=None)
91
92
def __init__(self, config: dict[str, Any]):
93
super().__init__()
94
+ self.__client = MCPClientRemote(self)
95
self.update(config)
96
97
def get_tools(self) -> List[dict[str, Any]]:
98
"""Get all tools from the server"""
100
- return []
99
+ with self.__lock:
100
+ return self.__client.tools # type: ignore
101
102
def has_tool(self, tool_name: str) -> bool:
103
"""Check if a tool is available"""
104
- return False
104
+ with self.__lock:
105
+ return self.__client.has_tool(tool_name) # type: ignore
106
107
async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult:
108
"""Call a tool with the given input data"""
108
- raise NotImplementedError("MCPServerRemote does not support calling tools")
109
+ with self.__lock:
110
+ # We already run in an event loop, dont believe Pylance
111
+ return await self.__client.call_tool(tool_name, input_data) # type: ignore
112
113
def update(self, config: dict[str, Any]) -> "MCPServerRemote":
114
with self.__lock:
115
for key, value in config.items():
116
if key in ["name", "description", "url", "headers", "timeout", "sse_read_timeout", "disabled"]:
117
+ if key == "name":
118
+ value = value.strip().lower().replace(" ", "_").replace("-", "_").replace(".", "_")
119
setattr(self, key, value)
115
- # We already run in an event loop, dont believe Pylance
116
- return asyncio.run(self.__on_update())
120
+ # We already run in an event loop, dont believe Pylance
121
+ return asyncio.run(self.__on_update())
122
123
async def __on_update(self) -> "MCPServerRemote":
124
+ await self.__client.update_tools() # type: ignore
125
return self
126
127
@@ -125,9 +131,9 @@ class MCPServerLocal(BaseModel):
131
command: str = Field(default_factory=str)
132
args: list[str] = Field(default_factory=list)
133
env: dict[str, str] | None = Field(default_factory=dict[str, str])
128
- encoding: str = "utf-8"
129
- encoding_error_handler: Literal["strict", "ignore", "replace"] = "strict"
130
- disabled: bool = False
134
+ encoding: str = Field(default="utf-8")
135
+ encoding_error_handler: Literal["strict", "ignore", "replace"] = Field(default="strict")
136
+ disabled: bool = Field(default=False)
137
138
__lock: ClassVar[threading.Lock] = PrivateAttr(default=threading.Lock())
139
__client: Optional["MCPClientLocal"] = PrivateAttr(default=None)
@@ -140,18 +146,18 @@ class MCPServerLocal(BaseModel):
146
def get_tools(self) -> List[dict[str, Any]]:
147
"""Get all tools from the server"""
148
with self.__lock:
143
- return self.__client.tools
149
+ return self.__client.tools # type: ignore
150
151
def has_tool(self, tool_name: str) -> bool:
152
"""Check if a tool is available"""
153
with self.__lock:
148
- return self.__client.has_tool(tool_name)
154
+ return self.__client.has_tool(tool_name) # type: ignore
155
156
async def call_tool(self, tool_name: str, input_data: Dict[str, Any]) -> CallToolResult:
157
"""Call a tool with the given input data"""
158
with self.__lock:
159
# We already run in an event loop, dont believe Pylance
154
- return await self.__client.call_tool(tool_name, input_data)
160
+ return await self.__client.call_tool(tool_name, input_data) # type: ignore
161
162
def update(self, config: dict[str, Any]) -> "MCPServerLocal":
163
with self.__lock:
@@ -164,7 +170,7 @@ class MCPServerLocal(BaseModel):
170
return asyncio.run(self.__on_update())
171
172
async def __on_update(self) -> "MCPServerLocal":
167
- await self.__client.update_tools()
173
+ await self.__client.update_tools() # type: ignore
174
return self
175
176
@@ -202,7 +208,7 @@ class MCPConfig(BaseModel):
208
try:
209
servers = DirtyJson.parse_string(config_str)
210
except Exception as e:
205
- raise ValueError(f"Failed to parse MCP config: {e}")
211
+ raise ValueError(f"Failed to parse MCP config: {e}") from e
212
cls.get_instance().__init__(servers_list=servers)
213
cls.__initialized = True
214
return cls.get_instance()
@@ -351,43 +357,30 @@ class MCPConfig(BaseModel):
357
raise ValueError(f"Tool {tool_name} not found")
358
359
354
-class MCPClientLocal:
360
+class MCPClientBase(ABC):
361
session: Optional[ClientSession] = None
362
exit_stack: AsyncExitStack = AsyncExitStack()
363
stdio: Optional[MemoryObjectReceiveStream[JSONRPCMessage | Exception]] = None
364
write: Optional[MemoryObjectSendStream[JSONRPCMessage]] = None
365
366
tools: List[dict[str, Any]] = []
361
- server: Optional[MCPServerLocal] = None
367
+ server: Optional[Union[MCPServerLocal, MCPServerRemote]] = None
368
369
__lock: ClassVar[threading.Lock] = threading.Lock()
370
365
- def __init__(self, server: MCPServerLocal):
371
+ def __init__(self, server: Union[MCPServerLocal, MCPServerRemote]):
372
self.server = server
373
374
+ # Protected method
375
+ @abstractmethod
376
+ async def _connect_client(self) -> tuple[MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage]]:
377
+ """Connect to an MCP server, init client and save stdio/write streams"""
378
+ ...
379
+
380
async def __connect_to_server(self) -> Any:
381
"""Connect to an MCP server"""
370
-
371
- if not which(self.server.command):
372
- raise ValueError(f"Command {self.server.command} not found")
373
-
374
- which_args = 0
375
- for arg in self.server.args:
376
- if which(arg):
377
- which_args = which_args + 1
378
- if which_args == 0:
379
- raise ValueError(f"None of the arguments {self.server.args} is a file")
380
-
382
with self.__lock:
382
- server_params = StdioServerParameters(
383
- command=self.server.command,
384
- args=self.server.args,
385
- env=self.server.env,
386
- encoding=self.server.encoding,
387
- encoding_error_handler=self.server.encoding_error_handler
388
- )
389
- stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
390
- self.stdio, self.write = stdio_transport
383
+ self.stdio, self.write = await self._connect_client()
384
385
self.session = (
386
await self.exit_stack.enter_async_context(
@@ -407,7 +400,6 @@ class MCPClientLocal:
400
"""List available tools from the server"""
401
try:
402
await self.__connect_to_server()
410
-
403
with self.__lock:
404
response: ListToolsResult = await self.session.list_tools()
405
available_tools = [{
@@ -453,3 +445,39 @@ class MCPClientLocal:
445
await self.exit_stack.aclose()
446
return response
447
raise ValueError(f"Tool {tool_name} not found")
448
+
449
+
450
+class MCPClientLocal(MCPClientBase):
451
+ async def _connect_client(self) -> tuple[MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage]]:
452
+ """Connect to an MCP server, init client and save stdio/write streams"""
453
+ server: MCPServerLocal = cast(MCPServerLocal, self.server)
454
+
455
+ if not which(server.command):
456
+ raise ValueError(f"Command {server.command} not found")
457
+
458
+ which_args = 0
459
+ for arg in server.args:
460
+ if which(arg):
461
+ which_args = which_args + 1
462
+ if which_args == 0:
463
+ raise ValueError(f"None of the arguments {server.args} is a file")
464
+
465
+ server_params = StdioServerParameters(
466
+ command=server.command,
467
+ args=server.args,
468
+ env=server.env,
469
+ encoding=server.encoding,
470
+ encoding_error_handler=server.encoding_error_handler
471
+ )
472
+ stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
473
+ return stdio_transport
474
+
475
+
476
+class MCPClientRemote(MCPClientBase):
477
+ async def _connect_client(self) -> tuple[MemoryObjectReceiveStream[JSONRPCMessage | Exception], MemoryObjectSendStream[JSONRPCMessage]]:
478
+ """Connect to an MCP server, init client and save stdio/write streams"""
479
+ server: MCPServerRemote = cast(MCPServerRemote, self.server)
480
+ stdio_transport = await self.exit_stack.enter_async_context(
481
+ sse_client(url=server.url, headers=server.headers, timeout=server.timeout, sse_read_timeout=server.sse_read_timeout)
482
+ )
483
+ return stdio_transport
requirements.txt
+3
@@ -33,3 +33,6 @@ mcp==1.3.0
33
nest-asyncio==1.6.0
34
pdf2image==1.17.0
35
crontab==1.0.1
36
+uv==0.6.6
37
+uvenv==3.6.5
38
+uvx==2.5.1