flowent 0.0.12 → 0.1.0

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 (54) 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/channels.py +296 -0
  21. package/backend/src/flowent/main.py +226 -3
  22. package/backend/src/flowent/mcp.py +484 -0
  23. package/backend/src/flowent/mcp_import.py +202 -0
  24. package/backend/src/flowent/skills.py +157 -0
  25. package/backend/src/flowent/static/assets/index-DqTHSMBo.js +81 -0
  26. package/backend/src/flowent/static/assets/index-d3FBbOXX.css +2 -0
  27. package/backend/src/flowent/static/index.html +2 -2
  28. package/backend/src/flowent/storage.py +419 -0
  29. package/backend/src/flowent/tools.py +34 -7
  30. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  31. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  32. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  33. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  34. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/test_agent_tools.py +54 -0
  42. package/backend/tests/test_channels.py +360 -0
  43. package/backend/tests/test_mcp.py +710 -0
  44. package/backend/tests/test_persistence.py +30 -0
  45. package/backend/tests/test_skills.py +462 -0
  46. package/backend/uv.lock +160 -1
  47. package/dist/frontend/assets/index-DqTHSMBo.js +81 -0
  48. package/dist/frontend/assets/index-d3FBbOXX.css +2 -0
  49. package/dist/frontend/index.html +2 -2
  50. package/package.json +1 -1
  51. package/backend/src/flowent/static/assets/index-BwQOML_0.css +0 -2
  52. package/backend/src/flowent/static/assets/index-DXQ_smj0.js +0 -81
  53. package/dist/frontend/assets/index-BwQOML_0.css +0 -2
  54. package/dist/frontend/assets/index-DXQ_smj0.js +0 -81
@@ -0,0 +1,710 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import pytest
9
+ from fastapi.testclient import TestClient
10
+
11
+ from flowent.main import create_app
12
+ from flowent.mcp import McpTransport, mcp_tool_name
13
+ from flowent.storage import StateStore, StoredMcpServer
14
+
15
+
16
+ class FakeMcpTransport(McpTransport):
17
+ def __init__(self) -> None:
18
+ self.connect_calls: list[StoredMcpServer] = []
19
+ self.disconnect_calls: list[str] = []
20
+ self.disconnect_errors: dict[str, str] = {}
21
+ self.tool_calls: list[tuple[str, str, dict[str, object]]] = []
22
+ self.errors: dict[str, str] = {}
23
+ self.tools_by_server: dict[str, list[dict[str, object]]] = {}
24
+ self.results: dict[tuple[str, str], dict[str, object]] = {}
25
+ self.sleep_on_connect: set[str] = set()
26
+
27
+ async def connect(self, server: StoredMcpServer) -> list[dict[str, object]]:
28
+ self.connect_calls.append(server)
29
+ if server.id in self.sleep_on_connect:
30
+ await asyncio.sleep(60)
31
+ if server.id in self.errors:
32
+ raise RuntimeError(self.errors[server.id])
33
+ return self.tools_by_server.get(server.id, [])
34
+
35
+ async def disconnect(self, server_id: str) -> None:
36
+ self.disconnect_calls.append(server_id)
37
+ if server_id in self.disconnect_errors:
38
+ raise RuntimeError(self.disconnect_errors[server_id])
39
+
40
+ async def call_tool(
41
+ self,
42
+ server_id: str,
43
+ tool_name: str,
44
+ arguments: dict[str, object],
45
+ ) -> dict[str, object]:
46
+ self.tool_calls.append((server_id, tool_name, arguments))
47
+ result = self.results.get((server_id, tool_name))
48
+ if result is None:
49
+ return {
50
+ "content": [{"type": "text", "text": "Tool result"}],
51
+ "isError": False,
52
+ }
53
+ return result
54
+
55
+
56
+ def configure_provider(client: TestClient) -> None:
57
+ client.post(
58
+ "/api/providers",
59
+ json={
60
+ "api_key": "sk-local",
61
+ "base_url": "",
62
+ "id": "provider-openai",
63
+ "models": ["gpt-5.1"],
64
+ "name": "OpenAI",
65
+ "type": "openai",
66
+ },
67
+ )
68
+ client.put(
69
+ "/api/settings",
70
+ json={
71
+ "reasoning_effort": "default",
72
+ "selected_model": "gpt-5.1",
73
+ "selected_provider_id": "provider-openai",
74
+ },
75
+ )
76
+
77
+
78
+ def command_server_payload(**updates: object) -> dict[str, object]:
79
+ payload: dict[str, object] = {
80
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/project"],
81
+ "command": "npx",
82
+ "enabled": True,
83
+ "id": "mcp-files",
84
+ "name": "Files",
85
+ "status": "disabled",
86
+ "tools": [],
87
+ "type": "command",
88
+ "url": "",
89
+ }
90
+ payload.update(updates)
91
+ return payload
92
+
93
+
94
+ def url_server_payload(**updates: object) -> dict[str, object]:
95
+ payload = command_server_payload(
96
+ args=[],
97
+ command="",
98
+ id="mcp-docs",
99
+ name="Docs",
100
+ type="url",
101
+ url="https://example.com/mcp",
102
+ )
103
+ payload.update(updates)
104
+ return payload
105
+
106
+
107
+ def codex_import_content(name: str = "docs", command: str = "npx") -> str:
108
+ return f"""
109
+ [mcp_servers.{name}]
110
+ command = "{command}"
111
+ args = ["-y", "@modelcontextprotocol/server-filesystem", "/project"]
112
+ enabled = false
113
+
114
+ [mcp_servers.{name}.env]
115
+ DOCS_TOKEN = "${{DOCS_TOKEN}}"
116
+ """
117
+
118
+
119
+ def claude_code_import_content() -> str:
120
+ return json.dumps(
121
+ {
122
+ "mcpServers": {
123
+ "Linear": {
124
+ "headers": {"X-Team": "${TEAM_ID:-local}"},
125
+ "type": "http",
126
+ "url": "https://linear.example.com/mcp",
127
+ }
128
+ }
129
+ }
130
+ )
131
+
132
+
133
+ def isolated_mcp_import_environment(tmp_path, monkeypatch) -> tuple[Path, Path]:
134
+ data_dir = tmp_path / "data"
135
+ home = tmp_path / "home"
136
+ workspace = tmp_path / "workspace"
137
+ home.mkdir()
138
+ workspace.mkdir()
139
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(data_dir))
140
+ monkeypatch.setenv("HOME", str(home))
141
+ monkeypatch.chdir(workspace)
142
+ return home, workspace
143
+
144
+
145
+ def write_config(path: Path, content: str) -> None:
146
+ path.parent.mkdir(parents=True, exist_ok=True)
147
+ path.write_text(content, encoding="utf-8")
148
+
149
+
150
+ async def wait_for_status(
151
+ manager,
152
+ server: StoredMcpServer,
153
+ status: str,
154
+ *,
155
+ attempts: int = 20,
156
+ ) -> StoredMcpServer:
157
+ current = manager.server_with_status(server)
158
+ for _ in range(attempts):
159
+ current = manager.server_with_status(server)
160
+ if current.status == status:
161
+ return current
162
+ await asyncio.sleep(0.01)
163
+ return current
164
+
165
+
166
+ def tool_call_chunk(
167
+ name: str,
168
+ arguments: dict[str, object],
169
+ call_id: str = "call-1",
170
+ ) -> dict[str, object]:
171
+ return {
172
+ "choices": [
173
+ {
174
+ "delta": {
175
+ "tool_calls": [
176
+ {
177
+ "function": {
178
+ "arguments": json.dumps(arguments),
179
+ "name": name,
180
+ },
181
+ "id": call_id,
182
+ "index": 0,
183
+ "type": "function",
184
+ }
185
+ ]
186
+ }
187
+ }
188
+ ]
189
+ }
190
+
191
+
192
+ def text_chunk(content: str) -> dict[str, object]:
193
+ return {"choices": [{"delta": {"content": content}}]}
194
+
195
+
196
+ def stream_events(content: str) -> list[dict[str, Any]]:
197
+ events: list[dict[str, Any]] = []
198
+ for raw_event in content.strip().split("\n\n"):
199
+ event_type = ""
200
+ data = ""
201
+ for line in raw_event.splitlines():
202
+ if line.startswith("event: "):
203
+ event_type = line.removeprefix("event: ")
204
+ if line.startswith("data: "):
205
+ data = line.removeprefix("data: ")
206
+ events.append({"event": event_type, "data": json.loads(data)})
207
+ return events
208
+
209
+
210
+ def test_mcp_state_defaults_to_empty_servers(tmp_path, monkeypatch) -> None:
211
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
212
+ client = TestClient(create_app(serve_frontend=False))
213
+
214
+ state = client.get("/api/state").json()
215
+
216
+ assert state["mcp_servers"] == []
217
+
218
+
219
+ def test_mcp_command_server_is_saved_and_persisted(tmp_path, monkeypatch) -> None:
220
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
221
+ transport = FakeMcpTransport()
222
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
223
+
224
+ response = client.put("/api/mcp/servers", json=command_server_payload())
225
+
226
+ assert response.status_code == 200
227
+ restarted = TestClient(create_app(serve_frontend=False))
228
+ state = restarted.get("/api/state").json()
229
+ assert state["mcp_servers"][0]["command"] == "npx"
230
+ assert state["mcp_servers"][0]["args"] == [
231
+ "-y",
232
+ "@modelcontextprotocol/server-filesystem",
233
+ "/project",
234
+ ]
235
+
236
+
237
+ def test_mcp_url_server_is_saved_and_persisted(tmp_path, monkeypatch) -> None:
238
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
239
+ transport = FakeMcpTransport()
240
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
241
+
242
+ response = client.put("/api/mcp/servers", json=url_server_payload())
243
+
244
+ assert response.status_code == 200
245
+ restarted = TestClient(create_app(serve_frontend=False))
246
+ state = restarted.get("/api/state").json()
247
+ assert state["mcp_servers"][0]["type"] == "url"
248
+ assert state["mcp_servers"][0]["url"] == "https://example.com/mcp"
249
+
250
+
251
+ def test_mcp_server_config_is_saved_and_persisted(tmp_path, monkeypatch) -> None:
252
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
253
+ transport = FakeMcpTransport()
254
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
255
+
256
+ response = client.put(
257
+ "/api/mcp/servers",
258
+ json=command_server_payload(
259
+ config={
260
+ "cwd": "/workspace",
261
+ "env": {"DOCS_TOKEN": "${DOCS_TOKEN}"},
262
+ },
263
+ ),
264
+ )
265
+
266
+ assert response.status_code == 200
267
+ restarted = TestClient(create_app(serve_frontend=False))
268
+ state = restarted.get("/api/state").json()
269
+ assert state["mcp_servers"][0]["config"] == {
270
+ "cwd": "/workspace",
271
+ "env": {"DOCS_TOKEN": "${DOCS_TOKEN}"},
272
+ }
273
+
274
+
275
+ def test_mcp_import_preview_reads_codex_config(tmp_path, monkeypatch) -> None:
276
+ home, _ = isolated_mcp_import_environment(tmp_path, monkeypatch)
277
+ write_config(home / ".codex" / "config.toml", codex_import_content())
278
+ client = TestClient(create_app(serve_frontend=False))
279
+
280
+ response = client.get("/api/mcp/import/preview")
281
+
282
+ assert response.status_code == 200
283
+ result = response.json()
284
+ assert result["sources"][0]["source"] == "codex"
285
+ assert result["sources"][0]["path"].endswith(".codex/config.toml")
286
+ server = result["servers"][0]
287
+ assert server["id"] == "mcp-docs"
288
+ assert server["name"] == "docs"
289
+ assert server["type"] == "command"
290
+ assert server["command"] == "npx"
291
+ assert server["enabled"] is False
292
+ assert server["config"]["env"] == {"DOCS_TOKEN": "${DOCS_TOKEN}"}
293
+
294
+
295
+ def test_mcp_import_preview_reads_claude_code_config(tmp_path, monkeypatch) -> None:
296
+ home, _ = isolated_mcp_import_environment(tmp_path, monkeypatch)
297
+ write_config(home / ".claude.json", claude_code_import_content())
298
+ client = TestClient(create_app(serve_frontend=False))
299
+
300
+ response = client.get("/api/mcp/import/preview")
301
+
302
+ assert response.status_code == 200
303
+ result = response.json()
304
+ assert result["sources"][0]["source"] == "claude_code"
305
+ assert result["sources"][0]["path"].endswith(".claude.json")
306
+ server = result["servers"][0]
307
+ assert server["id"] == "mcp-linear"
308
+ assert server["name"] == "Linear"
309
+ assert server["type"] == "url"
310
+ assert server["url"] == "https://linear.example.com/mcp"
311
+ assert server["config"]["headers"] == {"X-Team": "${TEAM_ID:-local}"}
312
+
313
+
314
+ def test_mcp_import_skip_keeps_existing_server(tmp_path, monkeypatch) -> None:
315
+ home, _ = isolated_mcp_import_environment(tmp_path, monkeypatch)
316
+ write_config(home / ".codex" / "config.toml", codex_import_content())
317
+ transport = FakeMcpTransport()
318
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
319
+ client.put("/api/mcp/servers", json=command_server_payload(id="mcp-docs"))
320
+
321
+ response = client.post(
322
+ "/api/mcp/import",
323
+ json={"duplicate_action": "skip"},
324
+ )
325
+
326
+ assert response.status_code == 200
327
+ state = client.get("/api/state").json()
328
+ assert state["mcp_servers"][0]["name"] == "Files"
329
+ assert state["mcp_servers"][0]["command"] == "npx"
330
+
331
+
332
+ def test_mcp_import_replace_updates_existing_server(tmp_path, monkeypatch) -> None:
333
+ home, _ = isolated_mcp_import_environment(tmp_path, monkeypatch)
334
+ write_config(
335
+ home / ".codex" / "config.toml",
336
+ codex_import_content(command="docs-server"),
337
+ )
338
+ transport = FakeMcpTransport()
339
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
340
+ client.put("/api/mcp/servers", json=command_server_payload(id="mcp-docs"))
341
+
342
+ response = client.post(
343
+ "/api/mcp/import",
344
+ json={"duplicate_action": "replace"},
345
+ )
346
+
347
+ assert response.status_code == 200
348
+ state = client.get("/api/state").json()
349
+ assert state["mcp_servers"][0]["name"] == "docs"
350
+ assert state["mcp_servers"][0]["command"] == "docs-server"
351
+
352
+
353
+ def test_mcp_import_preview_reports_empty_scan(tmp_path, monkeypatch) -> None:
354
+ isolated_mcp_import_environment(tmp_path, monkeypatch)
355
+ client = TestClient(create_app(serve_frontend=False))
356
+
357
+ response = client.get("/api/mcp/import/preview")
358
+
359
+ assert response.status_code == 200
360
+ assert response.json() == {"servers": [], "sources": []}
361
+
362
+
363
+ def test_mcp_import_preview_dedupes_discovered_servers(tmp_path, monkeypatch) -> None:
364
+ home, workspace = isolated_mcp_import_environment(tmp_path, monkeypatch)
365
+ write_config(workspace / ".codex" / "config.toml", codex_import_content())
366
+ write_config(
367
+ home / ".codex" / "config.toml",
368
+ codex_import_content(command="different-docs-server"),
369
+ )
370
+ write_config(home / ".claude.json", claude_code_import_content())
371
+ client = TestClient(create_app(serve_frontend=False))
372
+
373
+ response = client.get("/api/mcp/import/preview")
374
+
375
+ assert response.status_code == 200
376
+ result = response.json()
377
+ assert [server["id"] for server in result["servers"]] == [
378
+ "mcp-linear",
379
+ "mcp-docs",
380
+ ]
381
+ assert result["servers"][1]["command"] == "npx"
382
+ assert len(result["sources"]) == 3
383
+
384
+
385
+ def test_disabled_mcp_server_does_not_connect_or_expose_tools(
386
+ tmp_path, monkeypatch
387
+ ) -> None:
388
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
389
+ transport = FakeMcpTransport()
390
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
391
+
392
+ response = client.put(
393
+ "/api/mcp/servers",
394
+ json=command_server_payload(enabled=False),
395
+ )
396
+
397
+ assert response.status_code == 200
398
+ assert transport.connect_calls == []
399
+ assert response.json()["status"] == "disabled"
400
+
401
+
402
+ def test_enabled_mcp_server_connects_and_lists_tools(tmp_path, monkeypatch) -> None:
403
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
404
+ transport = FakeMcpTransport()
405
+ transport.tools_by_server["mcp-files"] = [
406
+ {
407
+ "description": "Read a file",
408
+ "inputSchema": {"type": "object"},
409
+ "name": "read_file",
410
+ }
411
+ ]
412
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
413
+
414
+ response = client.put("/api/mcp/servers", json=command_server_payload())
415
+
416
+ assert response.status_code == 200
417
+ assert response.json()["status"] == "ready"
418
+ assert response.json()["tools"][0]["name"] == "read_file"
419
+
420
+
421
+ def test_mcp_connection_error_is_reported_in_state(tmp_path, monkeypatch) -> None:
422
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
423
+ transport = FakeMcpTransport()
424
+ transport.errors["mcp-files"] = "Command failed"
425
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
426
+
427
+ response = client.put("/api/mcp/servers", json=command_server_payload())
428
+
429
+ assert response.status_code == 200
430
+ assert response.json()["status"] == "error"
431
+ assert response.json()["error"] == "Command failed"
432
+
433
+
434
+ def test_mcp_server_can_be_reconnected(tmp_path, monkeypatch) -> None:
435
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
436
+ transport = FakeMcpTransport()
437
+ transport.tools_by_server["mcp-files"] = [
438
+ {"inputSchema": {"type": "object"}, "name": "read_file"}
439
+ ]
440
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
441
+ client.put("/api/mcp/servers", json=command_server_payload())
442
+ transport.tools_by_server["mcp-files"] = [
443
+ {"inputSchema": {"type": "object"}, "name": "read_file"},
444
+ {"inputSchema": {"type": "object"}, "name": "write_file"},
445
+ ]
446
+
447
+ response = client.post("/api/mcp/servers/mcp-files/reconnect")
448
+
449
+ assert response.status_code == 200
450
+ assert [tool["name"] for tool in response.json()["tools"]] == [
451
+ "read_file",
452
+ "write_file",
453
+ ]
454
+ assert [server.id for server in transport.connect_calls] == [
455
+ "mcp-files",
456
+ "mcp-files",
457
+ ]
458
+
459
+
460
+ @pytest.mark.anyio
461
+ async def test_enabled_mcp_start_does_not_block_app_state(
462
+ tmp_path, monkeypatch
463
+ ) -> None:
464
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
465
+ store = StateStore(tmp_path)
466
+ server = store.save_mcp_server(
467
+ StoredMcpServer(
468
+ args=[],
469
+ command="slow-server",
470
+ id="mcp-slow",
471
+ name="Slow",
472
+ type="command",
473
+ )
474
+ )
475
+ transport = FakeMcpTransport()
476
+ transport.sleep_on_connect.add("mcp-slow")
477
+ from flowent.mcp import McpManager
478
+
479
+ manager = McpManager(store=store, transport=transport)
480
+
481
+ await manager.start_enabled()
482
+ state_server = manager.server_with_status(server)
483
+
484
+ assert state_server.status == "starting"
485
+ assert transport.connect_calls == []
486
+
487
+
488
+ @pytest.mark.anyio
489
+ async def test_mcp_connection_timeout_reports_error(tmp_path, monkeypatch) -> None:
490
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
491
+ server = StoredMcpServer(
492
+ args=[],
493
+ command="slow-server",
494
+ id="mcp-slow",
495
+ name="Slow",
496
+ type="command",
497
+ )
498
+ transport = FakeMcpTransport()
499
+ transport.sleep_on_connect.add("mcp-slow")
500
+ from flowent import mcp as mcp_module
501
+
502
+ monkeypatch.setattr(mcp_module, "MCP_CONNECT_TIMEOUT_SECONDS", 0.01)
503
+ manager = mcp_module.McpManager(store=StateStore(tmp_path), transport=transport)
504
+
505
+ await manager.connect_server(server)
506
+ state_server = manager.server_with_status(server)
507
+
508
+ assert state_server.status == "error"
509
+ assert state_server.error == "Connection timed out."
510
+
511
+
512
+ def test_mcp_server_can_be_deleted(tmp_path, monkeypatch) -> None:
513
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
514
+ transport = FakeMcpTransport()
515
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
516
+ client.put("/api/mcp/servers", json=command_server_payload())
517
+
518
+ response = client.delete("/api/mcp/servers/mcp-files")
519
+ state = client.get("/api/state").json()
520
+
521
+ assert response.status_code == 200
522
+ assert state["mcp_servers"] == []
523
+ assert transport.disconnect_calls == ["mcp-files"]
524
+
525
+
526
+ def test_mcp_server_delete_removes_saved_server_when_disconnect_fails(
527
+ tmp_path, monkeypatch
528
+ ) -> None:
529
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
530
+ transport = FakeMcpTransport()
531
+ transport.disconnect_errors["mcp-files"] = "Disconnect failed"
532
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
533
+ client.put("/api/mcp/servers", json=command_server_payload())
534
+
535
+ response = client.delete("/api/mcp/servers/mcp-files")
536
+ state = client.get("/api/state").json()
537
+
538
+ assert response.status_code == 200
539
+ assert state["mcp_servers"] == []
540
+ assert transport.disconnect_calls == ["mcp-files"]
541
+
542
+
543
+ def test_ready_mcp_tools_are_included_in_workspace_request(
544
+ tmp_path, monkeypatch
545
+ ) -> None:
546
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
547
+ captured_requests: list[dict[str, object]] = []
548
+ transport = FakeMcpTransport()
549
+ transport.tools_by_server["mcp-files"] = [
550
+ {
551
+ "description": "Read a file",
552
+ "inputSchema": {"type": "object"},
553
+ "name": "read_file",
554
+ }
555
+ ]
556
+
557
+ async def fake_completion(**request: object) -> object:
558
+ captured_requests.append(request)
559
+
560
+ async def chunks() -> object:
561
+ yield text_chunk("Done.")
562
+
563
+ return chunks()
564
+
565
+ client = TestClient(
566
+ create_app(
567
+ serve_frontend=False,
568
+ chat_completion=fake_completion,
569
+ mcp_transport=transport,
570
+ )
571
+ )
572
+ configure_provider(client)
573
+ client.put("/api/mcp/servers", json=command_server_payload())
574
+
575
+ response = client.post("/api/workspace/respond", json={"content": "Read file"})
576
+
577
+ assert response.status_code == 200
578
+ tool_names = [
579
+ tool["function"]["name"]
580
+ for tool in captured_requests[0]["tools"]
581
+ if isinstance(tool, dict) and isinstance(tool.get("function"), dict)
582
+ ]
583
+ assert mcp_tool_name("mcp-files", "read_file") in tool_names
584
+
585
+
586
+ def test_mcp_tool_call_is_forwarded_and_result_returns_to_agent(
587
+ tmp_path, monkeypatch
588
+ ) -> None:
589
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
590
+ captured_requests: list[dict[str, object]] = []
591
+ transport = FakeMcpTransport()
592
+ transport.tools_by_server["mcp-files"] = [
593
+ {"inputSchema": {"type": "object"}, "name": "read_file"}
594
+ ]
595
+ transport.results[("mcp-files", "read_file")] = {
596
+ "content": [{"type": "text", "text": "MCP file content"}],
597
+ "isError": False,
598
+ }
599
+
600
+ async def fake_completion(**request: object) -> object:
601
+ captured_requests.append(request)
602
+
603
+ async def chunks() -> object:
604
+ if len(captured_requests) == 1:
605
+ yield tool_call_chunk(
606
+ mcp_tool_name("mcp-files", "read_file"),
607
+ {"path": "README.md"},
608
+ )
609
+ else:
610
+ yield text_chunk("Used MCP.")
611
+
612
+ return chunks()
613
+
614
+ client = TestClient(
615
+ create_app(
616
+ serve_frontend=False,
617
+ chat_completion=fake_completion,
618
+ mcp_transport=transport,
619
+ )
620
+ )
621
+ configure_provider(client)
622
+ client.put("/api/mcp/servers", json=command_server_payload())
623
+
624
+ response = client.post("/api/workspace/respond", json={"content": "Use MCP"})
625
+
626
+ assert response.status_code == 200
627
+ assert transport.tool_calls == [("mcp-files", "read_file", {"path": "README.md"})]
628
+ second_messages = captured_requests[1]["messages"]
629
+ assert second_messages[-1] == {
630
+ "content": "MCP file content",
631
+ "role": "tool",
632
+ "tool_call_id": "call-1",
633
+ }
634
+ events = stream_events(response.text)
635
+ assert events[2]["event"] == "tool_start"
636
+ assert events[2]["data"]["tool"]["title"] == "Calling Files.read_file"
637
+ assert events[3]["event"] == "tool_done"
638
+ assert events[3]["data"]["data"]["server"] == "Files"
639
+ assert events[3]["data"]["data"]["tool"] == "read_file"
640
+
641
+
642
+ def test_mcp_tool_call_failure_is_reported_in_workspace(tmp_path, monkeypatch) -> None:
643
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
644
+ captured_requests: list[dict[str, object]] = []
645
+ transport = FakeMcpTransport()
646
+ transport.tools_by_server["mcp-files"] = [
647
+ {"inputSchema": {"type": "object"}, "name": "read_file"}
648
+ ]
649
+ transport.results[("mcp-files", "read_file")] = {
650
+ "content": [{"type": "text", "text": "Permission denied"}],
651
+ "isError": True,
652
+ }
653
+
654
+ async def fake_completion(**request: object) -> object:
655
+ captured_requests.append(request)
656
+
657
+ async def chunks() -> object:
658
+ if len(captured_requests) == 1:
659
+ yield tool_call_chunk(
660
+ mcp_tool_name("mcp-files", "read_file"),
661
+ {"path": "secret.txt"},
662
+ )
663
+ else:
664
+ yield text_chunk("Could not use MCP.")
665
+
666
+ return chunks()
667
+
668
+ client = TestClient(
669
+ create_app(
670
+ serve_frontend=False,
671
+ chat_completion=fake_completion,
672
+ mcp_transport=transport,
673
+ )
674
+ )
675
+ configure_provider(client)
676
+ client.put("/api/mcp/servers", json=command_server_payload())
677
+
678
+ response = client.post("/api/workspace/respond", json={"content": "Use MCP"})
679
+
680
+ assert response.status_code == 200
681
+ events = stream_events(response.text)
682
+ assert events[3]["event"] == "tool_error"
683
+ assert events[3]["data"]["status"] == "failed"
684
+ assert events[3]["data"]["content"] == "Permission denied"
685
+
686
+
687
+ @pytest.mark.anyio
688
+ async def test_mcp_server_reload_reconnects_saved_enabled_servers(
689
+ tmp_path, monkeypatch
690
+ ) -> None:
691
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
692
+ store = StateStore(tmp_path)
693
+ server = store.save_mcp_server(
694
+ StoredMcpServer.model_validate(command_server_payload())
695
+ )
696
+ transport = FakeMcpTransport()
697
+ transport.tools_by_server["mcp-files"] = [
698
+ {"inputSchema": {"type": "object"}, "name": "read_file"}
699
+ ]
700
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
701
+
702
+ response = client.post("/api/mcp/reload")
703
+
704
+ assert response.status_code == 200
705
+ assert response.json()[0]["status"] == "starting"
706
+ manager = client.app.state.mcp_manager
707
+ connected = await wait_for_status(manager, server, "ready")
708
+ assert connected.status == "ready"
709
+ assert connected.tools[0].name == "read_file"
710
+ assert transport.connect_calls[0].id == "mcp-files"