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.
Files changed (50) hide show
  1. package/backend/pyproject.toml +2 -1
  2. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  3. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  4. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  5. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/agent.py +28 -7
  20. package/backend/src/flowent/main.py +118 -9
  21. package/backend/src/flowent/mcp.py +484 -0
  22. package/backend/src/flowent/mcp_import.py +217 -0
  23. package/backend/src/flowent/skills.py +157 -0
  24. package/backend/src/flowent/static/assets/index-BhHdc2d_.js +81 -0
  25. package/backend/src/flowent/static/assets/index-C89n9qe2.css +2 -0
  26. package/backend/src/flowent/static/index.html +2 -2
  27. package/backend/src/flowent/storage.py +240 -0
  28. package/backend/src/flowent/tools.py +6 -2
  29. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  30. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  31. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  32. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  33. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  34. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/test_mcp.py +722 -0
  41. package/backend/tests/test_skills.py +462 -0
  42. package/backend/uv.lock +160 -1
  43. package/dist/frontend/assets/index-BhHdc2d_.js +81 -0
  44. package/dist/frontend/assets/index-C89n9qe2.css +2 -0
  45. package/dist/frontend/index.html +2 -2
  46. package/package.json +1 -1
  47. package/backend/src/flowent/static/assets/index-CEZrWoDG.css +0 -2
  48. package/backend/src/flowent/static/assets/index-S5a0Rkj1.js +0 -81
  49. package/dist/frontend/assets/index-CEZrWoDG.css +0 -2
  50. 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