utim-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
utim_cli/mcp_client.py ADDED
@@ -0,0 +1,198 @@
1
+ import os
2
+ import json
3
+ import asyncio
4
+ import threading
5
+ from typing import Dict, List, Any, Optional
6
+
7
+ try:
8
+ from mcp import ClientSession, StdioServerParameters
9
+ from mcp.client.stdio import stdio_client
10
+ except ImportError:
11
+ pass
12
+
13
+ class MCPManager:
14
+ def __init__(self):
15
+ self.sessions: Dict[str, ClientSession] = {}
16
+ self._exit_stacks = {}
17
+ self._tool_to_server: Dict[str, str] = {}
18
+ self._started = False
19
+ self.cached_tools: List[Dict[str, Any]] = []
20
+ self.server_tools: Dict[str, List[str]] = {} # server_name -> list of tool names
21
+ self._loop = None
22
+ self._thread = None
23
+
24
+ def start_loop(self):
25
+ if self._loop is None:
26
+ self._loop = asyncio.new_event_loop()
27
+ self._thread = threading.Thread(target=self._run_loop, daemon=True)
28
+ self._thread.start()
29
+
30
+ def _run_loop(self):
31
+ asyncio.set_event_loop(self._loop)
32
+ self._loop.run_forever()
33
+
34
+ def run_coro(self, coro):
35
+ self.start_loop()
36
+ future = asyncio.run_coroutine_threadsafe(coro, self._loop)
37
+ return future.result()
38
+
39
+ def start(self):
40
+ if self._started:
41
+ return
42
+
43
+ # Check if mcp library is available
44
+ try:
45
+ from mcp import ClientSession, StdioServerParameters
46
+ from mcp.client.stdio import stdio_client
47
+ except ImportError:
48
+ # Create config if it doesn't exist, just in case
49
+ config_path = os.path.abspath(os.path.join(".utim", "mcp.json"))
50
+ if not os.path.exists(config_path):
51
+ os.makedirs(os.path.dirname(config_path), exist_ok=True)
52
+ with open(config_path, "w") as f:
53
+ json.dump({"mcpServers": {}}, f, indent=2)
54
+ self._started = True
55
+ return
56
+
57
+ self.start_loop()
58
+ self.run_coro(self._start_async())
59
+
60
+ async def _start_async(self):
61
+ config_path = os.path.abspath(os.path.join(".utim", "mcp.json"))
62
+ if not os.path.exists(config_path):
63
+ # Create dummy config
64
+ os.makedirs(os.path.dirname(config_path), exist_ok=True)
65
+ with open(config_path, "w") as f:
66
+ json.dump({"mcpServers": {}}, f, indent=2)
67
+ self._started = True
68
+ return
69
+
70
+ try:
71
+ with open(config_path, "r") as f:
72
+ config = json.load(f)
73
+ except Exception:
74
+ self._started = True
75
+ return
76
+
77
+ servers = config.get("mcpServers", {})
78
+
79
+ for name, srv_config in servers.items():
80
+ command = srv_config.get("command")
81
+ args = srv_config.get("args", [])
82
+ env = srv_config.get("env", {})
83
+
84
+ if not command:
85
+ continue
86
+
87
+ # Strip outer quotes from argument strings if they exist (common config error for space-containing paths)
88
+ cleaned_args = []
89
+ for arg in args:
90
+ if isinstance(arg, str):
91
+ if (arg.startswith('"') and arg.endswith('"')) or (arg.startswith("'") and arg.endswith("'")):
92
+ arg = arg[1:-1]
93
+ cleaned_args.append(arg)
94
+
95
+ try:
96
+ # Merge current env
97
+ merged_env = os.environ.copy()
98
+ merged_env.update(env)
99
+
100
+ # Setup StdioServerParameters with stdout wrapper to prevent JSON-RPC pollution
101
+ import sys
102
+ import shutil
103
+ current_dir = os.path.dirname(os.path.abspath(__file__))
104
+ wrapper_path = os.path.join(current_dir, "mcp_clean_wrapper.py")
105
+ resolved_command = shutil.which(command) or command
106
+
107
+ server_params = StdioServerParameters(
108
+ command=sys.executable,
109
+ args=["-u", wrapper_path, resolved_command] + cleaned_args,
110
+ env=merged_env
111
+ )
112
+
113
+ from contextlib import AsyncExitStack
114
+ stack = AsyncExitStack()
115
+ self._exit_stacks[name] = stack
116
+
117
+ read, write = await stack.enter_async_context(stdio_client(server_params))
118
+ session = await stack.enter_async_context(ClientSession(read, write))
119
+ await session.initialize()
120
+
121
+ self.sessions[name] = session
122
+
123
+ # Retrieve tools from the server and cache them
124
+ try:
125
+ res = await session.list_tools()
126
+ self.server_tools[name] = []
127
+ for t in res.tools:
128
+ full_name = f"{name}__{t.name}"
129
+ self._tool_to_server[full_name] = name
130
+ self.server_tools[name].append(t.name)
131
+
132
+ # Convert MCP tool schema to OpenAI/OpenRouter format
133
+ self.cached_tools.append({
134
+ "type": "function",
135
+ "function": {
136
+ "name": full_name,
137
+ "description": f"[{name}] {t.description}",
138
+ "parameters": t.inputSchema
139
+ }
140
+ })
141
+ except Exception as e:
142
+ print(f"Error fetching tools from MCP server {name}: {e}")
143
+
144
+ except Exception as e:
145
+ import traceback
146
+ print(f"Error starting MCP server {name}: {e}")
147
+
148
+ self._started = True
149
+
150
+ def get_tools(self) -> List[Dict[str, Any]]:
151
+ """Synchronously returns the cached list of MCP tools."""
152
+ return self.cached_tools
153
+
154
+ def get_notification_context(self) -> str:
155
+ """Returns the context notification string for connected MCP servers."""
156
+ notifications = []
157
+ for server_name, tool_names in self.server_tools.items():
158
+ if tool_names:
159
+ tools_str = ", ".join(tool_names)
160
+ notifications.append(f"this mcp server {server_name} is connected and a new set of tools are available for u: {tools_str}")
161
+ return "\n".join(notifications)
162
+
163
+ def call_tool(self, server_name: str, tool_name: str, arguments: Dict[str, Any]) -> str:
164
+ """Synchronously routes tool call execution to the background thread."""
165
+ return self.run_coro(self._call_tool_async(server_name, tool_name, arguments))
166
+
167
+ async def _call_tool_async(self, server_name: str, tool_name: str, arguments: Dict[str, Any]) -> str:
168
+ if server_name not in self.sessions:
169
+ return f"Error: MCP server '{server_name}' not running."
170
+
171
+ session = self.sessions[server_name]
172
+ try:
173
+ res = await session.call_tool(tool_name, arguments)
174
+ if getattr(res, "isError", False):
175
+ return f"Error from {server_name}: " + "\n".join(c.text for c in res.content)
176
+ return "\n".join(c.text for c in res.content)
177
+ except Exception as e:
178
+ return f"Error calling {tool_name} on {server_name}: {str(e)}"
179
+
180
+ def restart(self):
181
+ """Synchronously restarts and reloads all MCP sessions."""
182
+ self.run_coro(self._restart_async())
183
+
184
+ async def _restart_async(self):
185
+ for name, stack in list(self._exit_stacks.items()):
186
+ try:
187
+ await stack.aclose()
188
+ except Exception:
189
+ pass
190
+ self.sessions.clear()
191
+ self._exit_stacks.clear()
192
+ self._tool_to_server.clear()
193
+ self.cached_tools.clear()
194
+ self.server_tools.clear()
195
+ self._started = False
196
+ await self._start_async()
197
+
198
+ mcp_manager = MCPManager()