flowent 0.0.13 → 0.1.1
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.
- package/backend/pyproject.toml +2 -1
- package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
- package/backend/src/flowent/agent.py +28 -7
- package/backend/src/flowent/main.py +118 -9
- package/backend/src/flowent/mcp.py +484 -0
- package/backend/src/flowent/mcp_import.py +217 -0
- package/backend/src/flowent/skills.py +157 -0
- package/backend/src/flowent/static/assets/index-BhHdc2d_.js +81 -0
- package/backend/src/flowent/static/assets/index-C89n9qe2.css +2 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +240 -0
- package/backend/src/flowent/tools.py +6 -2
- package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/test_mcp.py +722 -0
- package/backend/tests/test_skills.py +462 -0
- package/backend/uv.lock +160 -1
- package/dist/frontend/assets/index-BhHdc2d_.js +81 -0
- package/dist/frontend/assets/index-C89n9qe2.css +2 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-CEZrWoDG.css +0 -2
- package/backend/src/flowent/static/assets/index-S5a0Rkj1.js +0 -81
- package/dist/frontend/assets/index-CEZrWoDG.css +0 -2
- package/dist/frontend/assets/index-S5a0Rkj1.js +0 -81
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from contextlib import AsyncExitStack
|
|
10
|
+
from importlib import import_module
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
|
|
13
|
+
from flowent.storage import StateStore, StoredMcpServer, StoredMcpTool
|
|
14
|
+
from flowent.tools import ToolResult
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("flowent.mcp")
|
|
17
|
+
MCP_CONNECT_TIMEOUT_SECONDS = 10
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class McpTransport(Protocol):
|
|
21
|
+
async def connect(self, server: StoredMcpServer) -> list[dict[str, object]]: ...
|
|
22
|
+
|
|
23
|
+
async def disconnect(self, server_id: str) -> None: ...
|
|
24
|
+
|
|
25
|
+
async def call_tool(
|
|
26
|
+
self,
|
|
27
|
+
server_id: str,
|
|
28
|
+
tool_name: str,
|
|
29
|
+
arguments: dict[str, object],
|
|
30
|
+
) -> dict[str, object]: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def mcp_tool_name(server_id: str, tool_name: str) -> str:
|
|
34
|
+
return f"mcp__{server_id}__{tool_name}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_mcp_tool_name(name: str) -> tuple[str, str] | None:
|
|
38
|
+
if not name.startswith("mcp__"):
|
|
39
|
+
return None
|
|
40
|
+
parts = name.removeprefix("mcp__").split("__", 1)
|
|
41
|
+
if len(parts) != 2 or not parts[0] or not parts[1]:
|
|
42
|
+
return None
|
|
43
|
+
return parts[0], parts[1]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def stable_mcp_server_id(name: str) -> str:
|
|
47
|
+
normalized = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
|
48
|
+
return f"mcp-{normalized or 'server'}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def mcp_tools_from_result(raw_tools: list[dict[str, object]]) -> list[StoredMcpTool]:
|
|
52
|
+
tools: list[StoredMcpTool] = []
|
|
53
|
+
for raw_tool in raw_tools:
|
|
54
|
+
name = raw_tool.get("name")
|
|
55
|
+
if not isinstance(name, str) or not name:
|
|
56
|
+
continue
|
|
57
|
+
input_schema = raw_tool.get("inputSchema", raw_tool.get("input_schema", {}))
|
|
58
|
+
output_schema = raw_tool.get("outputSchema", raw_tool.get("output_schema"))
|
|
59
|
+
tools.append(
|
|
60
|
+
StoredMcpTool(
|
|
61
|
+
description=str(raw_tool.get("description") or ""),
|
|
62
|
+
input_schema=input_schema if isinstance(input_schema, dict) else {},
|
|
63
|
+
name=name,
|
|
64
|
+
output_schema=output_schema
|
|
65
|
+
if isinstance(output_schema, dict)
|
|
66
|
+
else None,
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
return tools
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def mcp_result_content(result: dict[str, object]) -> str:
|
|
73
|
+
content = result.get("content")
|
|
74
|
+
if isinstance(content, list):
|
|
75
|
+
parts: list[str] = []
|
|
76
|
+
for item in content:
|
|
77
|
+
if isinstance(item, dict) and isinstance(item.get("text"), str):
|
|
78
|
+
parts.append(item["text"])
|
|
79
|
+
elif hasattr(item, "text") and isinstance(item.text, str):
|
|
80
|
+
parts.append(item.text)
|
|
81
|
+
if parts:
|
|
82
|
+
return "\n".join(parts)
|
|
83
|
+
structured_content = result.get("structuredContent")
|
|
84
|
+
if structured_content is not None:
|
|
85
|
+
return json.dumps(structured_content, ensure_ascii=False)
|
|
86
|
+
return json.dumps(result, ensure_ascii=False)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def mcp_result_is_error(result: dict[str, object]) -> bool:
|
|
90
|
+
return bool(result.get("isError") or result.get("is_error"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_template_pattern = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-(.*?))?\}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def expand_mcp_template(value: str, *, env: dict[str, str] | None = None) -> str:
|
|
97
|
+
lookup = env or os.environ
|
|
98
|
+
|
|
99
|
+
def replace(match: re.Match[str]) -> str:
|
|
100
|
+
name = match.group(1)
|
|
101
|
+
default = match.group(2)
|
|
102
|
+
return lookup.get(name, default or "")
|
|
103
|
+
|
|
104
|
+
return _template_pattern.sub(replace, value)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def expand_mcp_value(value: Any, *, env: dict[str, str] | None = None) -> Any:
|
|
108
|
+
if isinstance(value, str):
|
|
109
|
+
return expand_mcp_template(value, env=env)
|
|
110
|
+
if isinstance(value, list):
|
|
111
|
+
return [expand_mcp_value(item, env=env) for item in value]
|
|
112
|
+
if isinstance(value, dict):
|
|
113
|
+
return {key: expand_mcp_value(item, env=env) for key, item in value.items()}
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def expand_mcp_config(config: dict[str, object]) -> dict[str, object]:
|
|
118
|
+
expanded = expand_mcp_value(config) if config else {}
|
|
119
|
+
return expanded if isinstance(expanded, dict) else {}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class DefaultMcpTransport:
|
|
123
|
+
def __init__(self) -> None:
|
|
124
|
+
self._sessions: dict[str, Any] = {}
|
|
125
|
+
self._stacks: dict[str, AsyncExitStack] = {}
|
|
126
|
+
|
|
127
|
+
async def connect(self, server: StoredMcpServer) -> list[dict[str, object]]:
|
|
128
|
+
from mcp import ClientSession
|
|
129
|
+
from mcp.client.stdio import stdio_client
|
|
130
|
+
|
|
131
|
+
await self.disconnect(server.id)
|
|
132
|
+
stack = AsyncExitStack()
|
|
133
|
+
config = expand_mcp_config(server.config)
|
|
134
|
+
if server.type == "url":
|
|
135
|
+
http_module = import_module("mcp.client.streamable_http")
|
|
136
|
+
http_headers = self._streamable_http_headers(config) or None
|
|
137
|
+
if hasattr(http_module, "streamablehttp_client"):
|
|
138
|
+
read_stream, write_stream, _ = await stack.enter_async_context(
|
|
139
|
+
http_module.streamablehttp_client(
|
|
140
|
+
server.url or str(config.get("url") or ""),
|
|
141
|
+
headers=http_headers,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
import httpx
|
|
146
|
+
|
|
147
|
+
http_client = await stack.enter_async_context(
|
|
148
|
+
httpx.AsyncClient(headers=http_headers)
|
|
149
|
+
)
|
|
150
|
+
read_stream, write_stream, _ = await stack.enter_async_context(
|
|
151
|
+
http_module.streamable_http_client(
|
|
152
|
+
server.url or str(config.get("url") or ""),
|
|
153
|
+
http_client=http_client,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
read_stream, write_stream = await stack.enter_async_context(
|
|
158
|
+
stdio_client(
|
|
159
|
+
self._stdio_parameters(server, config),
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
session = await stack.enter_async_context(
|
|
163
|
+
ClientSession(read_stream, write_stream)
|
|
164
|
+
)
|
|
165
|
+
await session.initialize()
|
|
166
|
+
result = await session.list_tools()
|
|
167
|
+
self._sessions[server.id] = session
|
|
168
|
+
self._stacks[server.id] = stack
|
|
169
|
+
return [self._model_dump(tool) for tool in result.tools]
|
|
170
|
+
|
|
171
|
+
def _streamable_http_headers(self, config: dict[str, object]) -> dict[str, str]:
|
|
172
|
+
headers: dict[str, str] = {}
|
|
173
|
+
for key in ("http_headers", "headers"):
|
|
174
|
+
raw_headers = config.get(key)
|
|
175
|
+
if isinstance(raw_headers, dict):
|
|
176
|
+
headers.update(
|
|
177
|
+
{
|
|
178
|
+
str(header_name): str(header_value)
|
|
179
|
+
for header_name, header_value in raw_headers.items()
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
break
|
|
183
|
+
|
|
184
|
+
raw_env_headers = config.get("env_http_headers") or config.get("envHeaders")
|
|
185
|
+
if isinstance(raw_env_headers, dict):
|
|
186
|
+
for header_name, env_name in raw_env_headers.items():
|
|
187
|
+
if isinstance(env_name, str):
|
|
188
|
+
env_value = os.environ.get(env_name)
|
|
189
|
+
if env_value is not None:
|
|
190
|
+
headers[str(header_name)] = env_value
|
|
191
|
+
|
|
192
|
+
bearer_token_env_var = config.get("bearer_token_env_var") or config.get(
|
|
193
|
+
"bearerTokenEnvVar"
|
|
194
|
+
)
|
|
195
|
+
if isinstance(bearer_token_env_var, str):
|
|
196
|
+
env_bearer_token = os.environ.get(bearer_token_env_var)
|
|
197
|
+
if env_bearer_token and "Authorization" not in headers:
|
|
198
|
+
headers["Authorization"] = f"Bearer {env_bearer_token}"
|
|
199
|
+
|
|
200
|
+
bearer_token: object = config.get("bearer_token") or config.get("bearerToken")
|
|
201
|
+
if (
|
|
202
|
+
isinstance(bearer_token, str)
|
|
203
|
+
and bearer_token
|
|
204
|
+
and "Authorization" not in headers
|
|
205
|
+
):
|
|
206
|
+
headers["Authorization"] = f"Bearer {bearer_token}"
|
|
207
|
+
return headers
|
|
208
|
+
|
|
209
|
+
def _stdio_parameters(
|
|
210
|
+
self,
|
|
211
|
+
server: StoredMcpServer,
|
|
212
|
+
config: dict[str, object],
|
|
213
|
+
) -> Any:
|
|
214
|
+
from mcp import StdioServerParameters
|
|
215
|
+
|
|
216
|
+
env: dict[str, str] = {}
|
|
217
|
+
raw_env = config.get("env")
|
|
218
|
+
if isinstance(raw_env, dict):
|
|
219
|
+
for key, value in raw_env.items():
|
|
220
|
+
if isinstance(key, str) and isinstance(value, str):
|
|
221
|
+
env[key] = value
|
|
222
|
+
raw_env_vars = config.get("env_vars")
|
|
223
|
+
if isinstance(raw_env_vars, list):
|
|
224
|
+
for key in raw_env_vars:
|
|
225
|
+
if isinstance(key, str) and key not in env:
|
|
226
|
+
value = os.environ.get(key)
|
|
227
|
+
if value is not None:
|
|
228
|
+
env[key] = value
|
|
229
|
+
cwd = config.get("cwd")
|
|
230
|
+
raw_args = config.get("args")
|
|
231
|
+
config_args = raw_args if isinstance(raw_args, list) else []
|
|
232
|
+
return StdioServerParameters(
|
|
233
|
+
command=server.command or str(config.get("command") or ""),
|
|
234
|
+
args=server.args
|
|
235
|
+
or [str(argument) for argument in config_args if isinstance(argument, str)],
|
|
236
|
+
cwd=cwd if isinstance(cwd, str) else None,
|
|
237
|
+
env=env or None,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
async def disconnect(self, server_id: str) -> None:
|
|
241
|
+
stack = self._stacks.pop(server_id, None)
|
|
242
|
+
self._sessions.pop(server_id, None)
|
|
243
|
+
if stack is not None:
|
|
244
|
+
await stack.aclose()
|
|
245
|
+
|
|
246
|
+
async def call_tool(
|
|
247
|
+
self,
|
|
248
|
+
server_id: str,
|
|
249
|
+
tool_name: str,
|
|
250
|
+
arguments: dict[str, object],
|
|
251
|
+
) -> dict[str, object]:
|
|
252
|
+
session = self._sessions.get(server_id)
|
|
253
|
+
if session is None:
|
|
254
|
+
raise RuntimeError("Server is not connected.")
|
|
255
|
+
result = await session.call_tool(tool_name, arguments=arguments)
|
|
256
|
+
return self._model_dump(result)
|
|
257
|
+
|
|
258
|
+
def _model_dump(self, value: Any) -> dict[str, object]:
|
|
259
|
+
if hasattr(value, "model_dump"):
|
|
260
|
+
dumped = value.model_dump(by_alias=True)
|
|
261
|
+
return dumped if isinstance(dumped, dict) else {}
|
|
262
|
+
if isinstance(value, dict):
|
|
263
|
+
return value
|
|
264
|
+
return {}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class McpManager:
|
|
268
|
+
def __init__(
|
|
269
|
+
self,
|
|
270
|
+
*,
|
|
271
|
+
store: StateStore,
|
|
272
|
+
transport: McpTransport | None = None,
|
|
273
|
+
) -> None:
|
|
274
|
+
self.store = store
|
|
275
|
+
self.transport = transport or DefaultMcpTransport()
|
|
276
|
+
self._status_by_server: dict[str, str] = {}
|
|
277
|
+
self._error_by_server: dict[str, str] = {}
|
|
278
|
+
self._tools_by_server: dict[str, list[StoredMcpTool]] = {}
|
|
279
|
+
self._server_names: dict[str, str] = {}
|
|
280
|
+
self._connect_tasks: dict[str, asyncio.Task[None]] = {}
|
|
281
|
+
|
|
282
|
+
async def start_enabled(self) -> None:
|
|
283
|
+
for server in self.store.read_mcp_servers():
|
|
284
|
+
if server.enabled:
|
|
285
|
+
self.schedule_connect_server(server)
|
|
286
|
+
else:
|
|
287
|
+
await self.disconnect_server(server.id)
|
|
288
|
+
|
|
289
|
+
async def stop_all(self) -> None:
|
|
290
|
+
tasks = list(self._connect_tasks.values())
|
|
291
|
+
self._connect_tasks.clear()
|
|
292
|
+
for task in tasks:
|
|
293
|
+
task.cancel()
|
|
294
|
+
if tasks:
|
|
295
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
296
|
+
for server_id in list(self._status_by_server):
|
|
297
|
+
await self.transport.disconnect(server_id)
|
|
298
|
+
self._status_by_server.clear()
|
|
299
|
+
self._error_by_server.clear()
|
|
300
|
+
self._tools_by_server.clear()
|
|
301
|
+
self._server_names.clear()
|
|
302
|
+
|
|
303
|
+
async def sync_server(self, server: StoredMcpServer) -> StoredMcpServer:
|
|
304
|
+
await self.cancel_connect_server(server.id)
|
|
305
|
+
if not server.enabled:
|
|
306
|
+
await self.disconnect_server(server.id)
|
|
307
|
+
return self.server_with_status(server)
|
|
308
|
+
await self.connect_server(server)
|
|
309
|
+
return self.server_with_status(server)
|
|
310
|
+
|
|
311
|
+
def schedule_connect_server(self, server: StoredMcpServer) -> None:
|
|
312
|
+
task = self._connect_tasks.pop(server.id, None)
|
|
313
|
+
if task is not None and not task.done():
|
|
314
|
+
task.cancel()
|
|
315
|
+
self._server_names[server.id] = server.name
|
|
316
|
+
self._status_by_server[server.id] = "starting"
|
|
317
|
+
self._error_by_server[server.id] = ""
|
|
318
|
+
connect_task = asyncio.create_task(self.connect_server(server))
|
|
319
|
+
self._connect_tasks[server.id] = connect_task
|
|
320
|
+
connect_task.add_done_callback(self._connect_task_callback(server.id))
|
|
321
|
+
|
|
322
|
+
def _connect_task_callback(
|
|
323
|
+
self,
|
|
324
|
+
server_id: str,
|
|
325
|
+
) -> Callable[[asyncio.Task[None]], None]:
|
|
326
|
+
def finish(completed_task: asyncio.Task[None]) -> None:
|
|
327
|
+
self._finish_connect_task(server_id, completed_task)
|
|
328
|
+
|
|
329
|
+
return finish
|
|
330
|
+
|
|
331
|
+
def _finish_connect_task(
|
|
332
|
+
self,
|
|
333
|
+
server_id: str,
|
|
334
|
+
task: asyncio.Task[None],
|
|
335
|
+
) -> None:
|
|
336
|
+
if self._connect_tasks.get(server_id) is task:
|
|
337
|
+
self._connect_tasks.pop(server_id, None)
|
|
338
|
+
if task.cancelled():
|
|
339
|
+
return
|
|
340
|
+
try:
|
|
341
|
+
task.result()
|
|
342
|
+
except Exception:
|
|
343
|
+
logger.exception("MCP server background connect failed")
|
|
344
|
+
|
|
345
|
+
async def cancel_connect_server(self, server_id: str) -> None:
|
|
346
|
+
task = self._connect_tasks.pop(server_id, None)
|
|
347
|
+
if task is None or task.done():
|
|
348
|
+
return
|
|
349
|
+
task.cancel()
|
|
350
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
351
|
+
|
|
352
|
+
async def reconnect_server(self, server_id: str) -> StoredMcpServer:
|
|
353
|
+
server = self.find_server(server_id)
|
|
354
|
+
await self.cancel_connect_server(server_id)
|
|
355
|
+
await self.transport.disconnect(server_id)
|
|
356
|
+
if server.enabled:
|
|
357
|
+
await self.connect_server(server)
|
|
358
|
+
else:
|
|
359
|
+
await self.disconnect_server(server_id)
|
|
360
|
+
return self.server_with_status(server)
|
|
361
|
+
|
|
362
|
+
async def delete_server(self, server_id: str) -> None:
|
|
363
|
+
await self.cancel_connect_server(server_id)
|
|
364
|
+
self.store.delete_mcp_server(server_id)
|
|
365
|
+
try:
|
|
366
|
+
await self.transport.disconnect(server_id)
|
|
367
|
+
except Exception:
|
|
368
|
+
logger.exception("MCP server disconnect failed during delete")
|
|
369
|
+
self._status_by_server.pop(server_id, None)
|
|
370
|
+
self._error_by_server.pop(server_id, None)
|
|
371
|
+
self._tools_by_server.pop(server_id, None)
|
|
372
|
+
self._server_names.pop(server_id, None)
|
|
373
|
+
|
|
374
|
+
async def reload(self) -> list[StoredMcpServer]:
|
|
375
|
+
await self.stop_all()
|
|
376
|
+
await self.start_enabled()
|
|
377
|
+
return self.servers_with_status(self.store.read_mcp_servers())
|
|
378
|
+
|
|
379
|
+
def find_server(self, server_id: str) -> StoredMcpServer:
|
|
380
|
+
for server in self.store.read_mcp_servers():
|
|
381
|
+
if server.id == server_id:
|
|
382
|
+
return server
|
|
383
|
+
raise KeyError(server_id)
|
|
384
|
+
|
|
385
|
+
async def connect_server(self, server: StoredMcpServer) -> None:
|
|
386
|
+
self._server_names[server.id] = server.name
|
|
387
|
+
self._status_by_server[server.id] = "starting"
|
|
388
|
+
self._error_by_server[server.id] = ""
|
|
389
|
+
try:
|
|
390
|
+
raw_tools = await asyncio.wait_for(
|
|
391
|
+
self.transport.connect(server),
|
|
392
|
+
timeout=MCP_CONNECT_TIMEOUT_SECONDS,
|
|
393
|
+
)
|
|
394
|
+
except TimeoutError:
|
|
395
|
+
self._status_by_server[server.id] = "error"
|
|
396
|
+
self._error_by_server[server.id] = "Connection timed out."
|
|
397
|
+
self._tools_by_server[server.id] = []
|
|
398
|
+
return
|
|
399
|
+
except Exception as error:
|
|
400
|
+
self._status_by_server[server.id] = "error"
|
|
401
|
+
self._error_by_server[server.id] = str(error)
|
|
402
|
+
self._tools_by_server[server.id] = []
|
|
403
|
+
return
|
|
404
|
+
tools = mcp_tools_from_result(raw_tools)
|
|
405
|
+
self.store.save_mcp_tools(server.id, tools)
|
|
406
|
+
self._tools_by_server[server.id] = tools
|
|
407
|
+
self._status_by_server[server.id] = "ready"
|
|
408
|
+
self._error_by_server[server.id] = ""
|
|
409
|
+
|
|
410
|
+
async def disconnect_server(self, server_id: str) -> None:
|
|
411
|
+
await self.transport.disconnect(server_id)
|
|
412
|
+
self._status_by_server[server_id] = "disabled"
|
|
413
|
+
self._error_by_server[server_id] = ""
|
|
414
|
+
self._tools_by_server[server_id] = []
|
|
415
|
+
|
|
416
|
+
def server_with_status(self, server: StoredMcpServer) -> StoredMcpServer:
|
|
417
|
+
if not server.enabled:
|
|
418
|
+
status = "disabled"
|
|
419
|
+
error = ""
|
|
420
|
+
tools: list[StoredMcpTool] = []
|
|
421
|
+
else:
|
|
422
|
+
status = self._status_by_server.get(server.id, server.status)
|
|
423
|
+
error = self._error_by_server.get(server.id, server.error)
|
|
424
|
+
tools = self._tools_by_server.get(server.id, server.tools)
|
|
425
|
+
self._server_names[server.id] = server.name
|
|
426
|
+
return server.model_copy(
|
|
427
|
+
update={
|
|
428
|
+
"error": error,
|
|
429
|
+
"status": status,
|
|
430
|
+
"tools": tools,
|
|
431
|
+
}
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
def servers_with_status(
|
|
435
|
+
self, servers: list[StoredMcpServer]
|
|
436
|
+
) -> list[StoredMcpServer]:
|
|
437
|
+
return [self.server_with_status(server) for server in servers]
|
|
438
|
+
|
|
439
|
+
def tool_specs(self) -> list[dict[str, object]]:
|
|
440
|
+
specs: list[dict[str, object]] = []
|
|
441
|
+
for server in self.servers_with_status(self.store.read_mcp_servers()):
|
|
442
|
+
if server.status != "ready":
|
|
443
|
+
continue
|
|
444
|
+
for tool in server.tools:
|
|
445
|
+
specs.append(
|
|
446
|
+
{
|
|
447
|
+
"type": "function",
|
|
448
|
+
"function": {
|
|
449
|
+
"name": mcp_tool_name(server.id, tool.name),
|
|
450
|
+
"description": tool.description
|
|
451
|
+
or f"Call {server.name}.{tool.name}.",
|
|
452
|
+
"parameters": tool.input_schema or {"type": "object"},
|
|
453
|
+
},
|
|
454
|
+
}
|
|
455
|
+
)
|
|
456
|
+
return specs
|
|
457
|
+
|
|
458
|
+
def tool_title(self, name: str) -> str | None:
|
|
459
|
+
parsed = parse_mcp_tool_name(name)
|
|
460
|
+
if parsed is None:
|
|
461
|
+
return None
|
|
462
|
+
server_id, tool_name = parsed
|
|
463
|
+
return f"Calling {self._server_names.get(server_id, server_id)}.{tool_name}"
|
|
464
|
+
|
|
465
|
+
async def run_tool(
|
|
466
|
+
self, name: str, arguments: dict[str, object]
|
|
467
|
+
) -> ToolResult | None:
|
|
468
|
+
parsed = parse_mcp_tool_name(name)
|
|
469
|
+
if parsed is None:
|
|
470
|
+
return None
|
|
471
|
+
server_id, tool_name = parsed
|
|
472
|
+
result = await self.transport.call_tool(server_id, tool_name, arguments)
|
|
473
|
+
content = mcp_result_content(result)
|
|
474
|
+
server_name = self._server_names.get(server_id, server_id)
|
|
475
|
+
return ToolResult(
|
|
476
|
+
content=content,
|
|
477
|
+
data={
|
|
478
|
+
"server": server_name,
|
|
479
|
+
"tool": tool_name,
|
|
480
|
+
"result": result,
|
|
481
|
+
},
|
|
482
|
+
ok=not mcp_result_is_error(result),
|
|
483
|
+
title=f"Calling {server_name}.{tool_name}",
|
|
484
|
+
)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tomllib
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
from flowent.mcp import stable_mcp_server_id
|
|
12
|
+
from flowent.storage import StoredMcpServer
|
|
13
|
+
|
|
14
|
+
McpImportSource = Literal["claude_code", "codex"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class McpImportSourceResult(BaseModel):
|
|
18
|
+
model_config = ConfigDict(extra="forbid")
|
|
19
|
+
|
|
20
|
+
error: str = ""
|
|
21
|
+
path: str
|
|
22
|
+
servers: list[StoredMcpServer] = Field(default_factory=list)
|
|
23
|
+
source: McpImportSource
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class McpImportDiscovery(BaseModel):
|
|
27
|
+
model_config = ConfigDict(extra="forbid")
|
|
28
|
+
|
|
29
|
+
servers: list[StoredMcpServer] = Field(default_factory=list)
|
|
30
|
+
sources: list[McpImportSourceResult] = Field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def discover_imported_mcp_servers(
|
|
34
|
+
cwd: Path | None = None,
|
|
35
|
+
home: Path | None = None,
|
|
36
|
+
source: McpImportSource | None = None,
|
|
37
|
+
) -> McpImportDiscovery:
|
|
38
|
+
workspace = (cwd or Path.cwd()).resolve(strict=False)
|
|
39
|
+
user_home = (home or Path.home()).resolve(strict=False)
|
|
40
|
+
sources: list[McpImportSourceResult] = []
|
|
41
|
+
|
|
42
|
+
for path, config_source in candidate_mcp_config_files(
|
|
43
|
+
workspace,
|
|
44
|
+
user_home,
|
|
45
|
+
source,
|
|
46
|
+
):
|
|
47
|
+
if not path.is_file():
|
|
48
|
+
continue
|
|
49
|
+
try:
|
|
50
|
+
servers = parse_mcp_config_file(path, config_source, workspace)
|
|
51
|
+
sources.append(
|
|
52
|
+
McpImportSourceResult(
|
|
53
|
+
path=str(path.resolve(strict=False)),
|
|
54
|
+
servers=servers,
|
|
55
|
+
source=config_source,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
except Exception as error:
|
|
59
|
+
sources.append(
|
|
60
|
+
McpImportSourceResult(
|
|
61
|
+
error=str(error),
|
|
62
|
+
path=str(path.resolve(strict=False)),
|
|
63
|
+
source=config_source,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return McpImportDiscovery(
|
|
68
|
+
servers=dedupe_mcp_servers(
|
|
69
|
+
server for source_result in sources for server in source_result.servers
|
|
70
|
+
),
|
|
71
|
+
sources=sources,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def candidate_mcp_config_files(
|
|
76
|
+
cwd: Path,
|
|
77
|
+
home: Path,
|
|
78
|
+
source: McpImportSource | None = None,
|
|
79
|
+
) -> list[tuple[Path, McpImportSource]]:
|
|
80
|
+
candidates: list[tuple[Path, McpImportSource]] = []
|
|
81
|
+
if source in (None, "claude_code"):
|
|
82
|
+
candidates.extend(
|
|
83
|
+
[
|
|
84
|
+
(cwd / ".mcp.json", "claude_code"),
|
|
85
|
+
(cwd / ".claude" / "settings.local.json", "claude_code"),
|
|
86
|
+
(cwd / ".claude" / "settings.json", "claude_code"),
|
|
87
|
+
(home / ".claude.json", "claude_code"),
|
|
88
|
+
(home / ".claude" / "settings.json", "claude_code"),
|
|
89
|
+
]
|
|
90
|
+
)
|
|
91
|
+
if source in (None, "codex"):
|
|
92
|
+
candidates.extend(
|
|
93
|
+
[
|
|
94
|
+
(cwd / ".codex" / "config.toml", "codex"),
|
|
95
|
+
(home / ".codex" / "config.toml", "codex"),
|
|
96
|
+
]
|
|
97
|
+
)
|
|
98
|
+
seen: set[tuple[Path, McpImportSource]] = set()
|
|
99
|
+
unique_candidates: list[tuple[Path, McpImportSource]] = []
|
|
100
|
+
for path, source in candidates:
|
|
101
|
+
key = (path.resolve(strict=False), source)
|
|
102
|
+
if key in seen:
|
|
103
|
+
continue
|
|
104
|
+
seen.add(key)
|
|
105
|
+
unique_candidates.append((path, source))
|
|
106
|
+
return unique_candidates
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_mcp_config_file(
|
|
110
|
+
path: Path,
|
|
111
|
+
source: McpImportSource,
|
|
112
|
+
cwd: Path,
|
|
113
|
+
) -> list[StoredMcpServer]:
|
|
114
|
+
if source == "codex":
|
|
115
|
+
payload = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
116
|
+
return parse_codex_mcp_servers(payload)
|
|
117
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
118
|
+
return parse_claude_code_mcp_servers(payload, cwd)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_codex_mcp_servers(payload: object) -> list[StoredMcpServer]:
|
|
122
|
+
if not isinstance(payload, dict):
|
|
123
|
+
return []
|
|
124
|
+
raw_servers = payload.get("mcp_servers")
|
|
125
|
+
if not isinstance(raw_servers, dict):
|
|
126
|
+
return []
|
|
127
|
+
return servers_from_map(raw_servers)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def parse_claude_code_mcp_servers(payload: object, cwd: Path) -> list[StoredMcpServer]:
|
|
131
|
+
if not isinstance(payload, dict):
|
|
132
|
+
return []
|
|
133
|
+
server_maps: list[dict[object, object]] = []
|
|
134
|
+
projects = payload.get("projects")
|
|
135
|
+
if isinstance(projects, dict):
|
|
136
|
+
workspace_keys = [str(cwd.resolve(strict=False)), str(cwd)]
|
|
137
|
+
for workspace_key in dict.fromkeys(workspace_keys):
|
|
138
|
+
project_config = projects.get(workspace_key)
|
|
139
|
+
if isinstance(project_config, dict):
|
|
140
|
+
project_servers = project_config.get("mcpServers")
|
|
141
|
+
if isinstance(project_servers, dict):
|
|
142
|
+
server_maps.append(project_servers)
|
|
143
|
+
top_level_servers = payload.get("mcpServers")
|
|
144
|
+
if isinstance(top_level_servers, dict):
|
|
145
|
+
server_maps.append(top_level_servers)
|
|
146
|
+
|
|
147
|
+
return dedupe_mcp_servers(
|
|
148
|
+
server for server_map in server_maps for server in servers_from_map(server_map)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def servers_from_map(raw_servers: dict[object, object]) -> list[StoredMcpServer]:
|
|
153
|
+
servers: list[StoredMcpServer] = []
|
|
154
|
+
for raw_name, raw_config in raw_servers.items():
|
|
155
|
+
if not isinstance(raw_config, dict):
|
|
156
|
+
continue
|
|
157
|
+
server = server_from_config(str(raw_name), raw_config)
|
|
158
|
+
if server is not None:
|
|
159
|
+
servers.append(server)
|
|
160
|
+
return dedupe_mcp_servers(servers)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def server_from_config(
|
|
164
|
+
name: str,
|
|
165
|
+
raw_config: dict[object, object],
|
|
166
|
+
) -> StoredMcpServer | None:
|
|
167
|
+
config = {str(key): value for key, value in raw_config.items()}
|
|
168
|
+
url = string_config(config, "url")
|
|
169
|
+
command = string_config(config, "command")
|
|
170
|
+
args = string_list_config(config, "args")
|
|
171
|
+
server_type = "url" if url else "command"
|
|
172
|
+
if server_type == "command" and not command:
|
|
173
|
+
return None
|
|
174
|
+
enabled = enabled_config(config)
|
|
175
|
+
return StoredMcpServer(
|
|
176
|
+
args=args if server_type == "command" else [],
|
|
177
|
+
command=command if server_type == "command" else "",
|
|
178
|
+
config=config,
|
|
179
|
+
enabled=enabled,
|
|
180
|
+
id=stable_mcp_server_id(name),
|
|
181
|
+
name=name,
|
|
182
|
+
type=server_type,
|
|
183
|
+
url=url if server_type == "url" else "",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def string_config(config: dict[str, object], key: str) -> str:
|
|
188
|
+
value = config.get(key)
|
|
189
|
+
return value if isinstance(value, str) else ""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def string_list_config(config: dict[str, object], key: str) -> list[str]:
|
|
193
|
+
value = config.get(key)
|
|
194
|
+
if not isinstance(value, list):
|
|
195
|
+
return []
|
|
196
|
+
return [item for item in value if isinstance(item, str)]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def enabled_config(config: dict[str, object]) -> bool:
|
|
200
|
+
enabled = config.get("enabled")
|
|
201
|
+
if isinstance(enabled, bool):
|
|
202
|
+
return enabled
|
|
203
|
+
disabled = config.get("disabled")
|
|
204
|
+
if isinstance(disabled, bool):
|
|
205
|
+
return not disabled
|
|
206
|
+
return True
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def dedupe_mcp_servers(servers: Iterable[StoredMcpServer]) -> list[StoredMcpServer]:
|
|
210
|
+
unique_servers: list[StoredMcpServer] = []
|
|
211
|
+
seen_ids: set[str] = set()
|
|
212
|
+
for server in servers:
|
|
213
|
+
if server.id in seen_ids:
|
|
214
|
+
continue
|
|
215
|
+
seen_ids.add(server.id)
|
|
216
|
+
unique_servers.append(server)
|
|
217
|
+
return unique_servers
|