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,722 @@
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.post(
281
+ "/api/mcp/import/preview",
282
+ json={"source": "codex"},
283
+ )
284
+
285
+ assert response.status_code == 200
286
+ result = response.json()
287
+ assert result["sources"][0]["source"] == "codex"
288
+ assert result["sources"][0]["path"].endswith(".codex/config.toml")
289
+ server = result["servers"][0]
290
+ assert server["id"] == "mcp-docs"
291
+ assert server["name"] == "docs"
292
+ assert server["type"] == "command"
293
+ assert server["command"] == "npx"
294
+ assert server["enabled"] is False
295
+ assert server["config"]["env"] == {"DOCS_TOKEN": "${DOCS_TOKEN}"}
296
+
297
+
298
+ def test_mcp_import_preview_reads_claude_code_config(tmp_path, monkeypatch) -> None:
299
+ home, _ = isolated_mcp_import_environment(tmp_path, monkeypatch)
300
+ write_config(home / ".claude.json", claude_code_import_content())
301
+ client = TestClient(create_app(serve_frontend=False))
302
+
303
+ response = client.post(
304
+ "/api/mcp/import/preview",
305
+ json={"source": "claude_code"},
306
+ )
307
+
308
+ assert response.status_code == 200
309
+ result = response.json()
310
+ assert result["sources"][0]["source"] == "claude_code"
311
+ assert result["sources"][0]["path"].endswith(".claude.json")
312
+ server = result["servers"][0]
313
+ assert server["id"] == "mcp-linear"
314
+ assert server["name"] == "Linear"
315
+ assert server["type"] == "url"
316
+ assert server["url"] == "https://linear.example.com/mcp"
317
+ assert server["config"]["headers"] == {"X-Team": "${TEAM_ID:-local}"}
318
+
319
+
320
+ def test_mcp_import_keeps_existing_server(tmp_path, monkeypatch) -> None:
321
+ home, _ = isolated_mcp_import_environment(tmp_path, monkeypatch)
322
+ write_config(home / ".codex" / "config.toml", codex_import_content())
323
+ transport = FakeMcpTransport()
324
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
325
+ client.put("/api/mcp/servers", json=command_server_payload(id="mcp-docs"))
326
+
327
+ response = client.post(
328
+ "/api/mcp/import",
329
+ json={
330
+ "server_id": "mcp-docs",
331
+ "source": "codex",
332
+ },
333
+ )
334
+
335
+ assert response.status_code == 200
336
+ state = client.get("/api/state").json()
337
+ assert state["mcp_servers"][0]["name"] == "Files"
338
+ assert state["mcp_servers"][0]["command"] == "npx"
339
+
340
+
341
+ def test_mcp_import_only_saves_requested_server(tmp_path, monkeypatch) -> None:
342
+ home, _ = isolated_mcp_import_environment(tmp_path, monkeypatch)
343
+ write_config(
344
+ home / ".codex" / "config.toml",
345
+ codex_import_content(name="docs") + codex_import_content(name="search"),
346
+ )
347
+ client = TestClient(create_app(serve_frontend=False))
348
+
349
+ response = client.post(
350
+ "/api/mcp/import",
351
+ json={
352
+ "server_id": "mcp-search",
353
+ "source": "codex",
354
+ },
355
+ )
356
+
357
+ assert response.status_code == 200
358
+ state = client.get("/api/state").json()
359
+ assert [server["id"] for server in state["mcp_servers"]] == ["mcp-search"]
360
+
361
+
362
+ def test_mcp_import_preview_reports_empty_scan(tmp_path, monkeypatch) -> None:
363
+ isolated_mcp_import_environment(tmp_path, monkeypatch)
364
+ client = TestClient(create_app(serve_frontend=False))
365
+
366
+ response = client.post(
367
+ "/api/mcp/import/preview",
368
+ json={"source": "codex"},
369
+ )
370
+
371
+ assert response.status_code == 200
372
+ assert response.json() == {"servers": [], "sources": []}
373
+
374
+
375
+ def test_mcp_import_preview_dedupes_discovered_servers(tmp_path, monkeypatch) -> None:
376
+ home, workspace = isolated_mcp_import_environment(tmp_path, monkeypatch)
377
+ write_config(workspace / ".codex" / "config.toml", codex_import_content())
378
+ write_config(
379
+ home / ".codex" / "config.toml",
380
+ codex_import_content(command="different-docs-server"),
381
+ )
382
+ write_config(home / ".claude.json", claude_code_import_content())
383
+ client = TestClient(create_app(serve_frontend=False))
384
+
385
+ response = client.post(
386
+ "/api/mcp/import/preview",
387
+ json={"source": "codex"},
388
+ )
389
+
390
+ assert response.status_code == 200
391
+ result = response.json()
392
+ assert [server["id"] for server in result["servers"]] == ["mcp-docs"]
393
+ assert result["servers"][0]["command"] == "npx"
394
+ assert len(result["sources"]) == 2
395
+
396
+
397
+ def test_disabled_mcp_server_does_not_connect_or_expose_tools(
398
+ tmp_path, monkeypatch
399
+ ) -> None:
400
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
401
+ transport = FakeMcpTransport()
402
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
403
+
404
+ response = client.put(
405
+ "/api/mcp/servers",
406
+ json=command_server_payload(enabled=False),
407
+ )
408
+
409
+ assert response.status_code == 200
410
+ assert transport.connect_calls == []
411
+ assert response.json()["status"] == "disabled"
412
+
413
+
414
+ def test_enabled_mcp_server_connects_and_lists_tools(tmp_path, monkeypatch) -> None:
415
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
416
+ transport = FakeMcpTransport()
417
+ transport.tools_by_server["mcp-files"] = [
418
+ {
419
+ "description": "Read a file",
420
+ "inputSchema": {"type": "object"},
421
+ "name": "read_file",
422
+ }
423
+ ]
424
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
425
+
426
+ response = client.put("/api/mcp/servers", json=command_server_payload())
427
+
428
+ assert response.status_code == 200
429
+ assert response.json()["status"] == "ready"
430
+ assert response.json()["tools"][0]["name"] == "read_file"
431
+
432
+
433
+ def test_mcp_connection_error_is_reported_in_state(tmp_path, monkeypatch) -> None:
434
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
435
+ transport = FakeMcpTransport()
436
+ transport.errors["mcp-files"] = "Command failed"
437
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
438
+
439
+ response = client.put("/api/mcp/servers", json=command_server_payload())
440
+
441
+ assert response.status_code == 200
442
+ assert response.json()["status"] == "error"
443
+ assert response.json()["error"] == "Command failed"
444
+
445
+
446
+ def test_mcp_server_can_be_reconnected(tmp_path, monkeypatch) -> None:
447
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
448
+ transport = FakeMcpTransport()
449
+ transport.tools_by_server["mcp-files"] = [
450
+ {"inputSchema": {"type": "object"}, "name": "read_file"}
451
+ ]
452
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
453
+ client.put("/api/mcp/servers", json=command_server_payload())
454
+ transport.tools_by_server["mcp-files"] = [
455
+ {"inputSchema": {"type": "object"}, "name": "read_file"},
456
+ {"inputSchema": {"type": "object"}, "name": "write_file"},
457
+ ]
458
+
459
+ response = client.post("/api/mcp/servers/mcp-files/reconnect")
460
+
461
+ assert response.status_code == 200
462
+ assert [tool["name"] for tool in response.json()["tools"]] == [
463
+ "read_file",
464
+ "write_file",
465
+ ]
466
+ assert [server.id for server in transport.connect_calls] == [
467
+ "mcp-files",
468
+ "mcp-files",
469
+ ]
470
+
471
+
472
+ @pytest.mark.anyio
473
+ async def test_enabled_mcp_start_does_not_block_app_state(
474
+ tmp_path, monkeypatch
475
+ ) -> None:
476
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
477
+ store = StateStore(tmp_path)
478
+ server = store.save_mcp_server(
479
+ StoredMcpServer(
480
+ args=[],
481
+ command="slow-server",
482
+ id="mcp-slow",
483
+ name="Slow",
484
+ type="command",
485
+ )
486
+ )
487
+ transport = FakeMcpTransport()
488
+ transport.sleep_on_connect.add("mcp-slow")
489
+ from flowent.mcp import McpManager
490
+
491
+ manager = McpManager(store=store, transport=transport)
492
+
493
+ await manager.start_enabled()
494
+ state_server = manager.server_with_status(server)
495
+
496
+ assert state_server.status == "starting"
497
+ assert transport.connect_calls == []
498
+
499
+
500
+ @pytest.mark.anyio
501
+ async def test_mcp_connection_timeout_reports_error(tmp_path, monkeypatch) -> None:
502
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
503
+ server = StoredMcpServer(
504
+ args=[],
505
+ command="slow-server",
506
+ id="mcp-slow",
507
+ name="Slow",
508
+ type="command",
509
+ )
510
+ transport = FakeMcpTransport()
511
+ transport.sleep_on_connect.add("mcp-slow")
512
+ from flowent import mcp as mcp_module
513
+
514
+ monkeypatch.setattr(mcp_module, "MCP_CONNECT_TIMEOUT_SECONDS", 0.01)
515
+ manager = mcp_module.McpManager(store=StateStore(tmp_path), transport=transport)
516
+
517
+ await manager.connect_server(server)
518
+ state_server = manager.server_with_status(server)
519
+
520
+ assert state_server.status == "error"
521
+ assert state_server.error == "Connection timed out."
522
+
523
+
524
+ def test_mcp_server_can_be_deleted(tmp_path, monkeypatch) -> None:
525
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
526
+ transport = FakeMcpTransport()
527
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
528
+ client.put("/api/mcp/servers", json=command_server_payload())
529
+
530
+ response = client.delete("/api/mcp/servers/mcp-files")
531
+ state = client.get("/api/state").json()
532
+
533
+ assert response.status_code == 200
534
+ assert state["mcp_servers"] == []
535
+ assert transport.disconnect_calls == ["mcp-files"]
536
+
537
+
538
+ def test_mcp_server_delete_removes_saved_server_when_disconnect_fails(
539
+ tmp_path, monkeypatch
540
+ ) -> None:
541
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
542
+ transport = FakeMcpTransport()
543
+ transport.disconnect_errors["mcp-files"] = "Disconnect failed"
544
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
545
+ client.put("/api/mcp/servers", json=command_server_payload())
546
+
547
+ response = client.delete("/api/mcp/servers/mcp-files")
548
+ state = client.get("/api/state").json()
549
+
550
+ assert response.status_code == 200
551
+ assert state["mcp_servers"] == []
552
+ assert transport.disconnect_calls == ["mcp-files"]
553
+
554
+
555
+ def test_ready_mcp_tools_are_included_in_workspace_request(
556
+ tmp_path, monkeypatch
557
+ ) -> None:
558
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
559
+ captured_requests: list[dict[str, object]] = []
560
+ transport = FakeMcpTransport()
561
+ transport.tools_by_server["mcp-files"] = [
562
+ {
563
+ "description": "Read a file",
564
+ "inputSchema": {"type": "object"},
565
+ "name": "read_file",
566
+ }
567
+ ]
568
+
569
+ async def fake_completion(**request: object) -> object:
570
+ captured_requests.append(request)
571
+
572
+ async def chunks() -> object:
573
+ yield text_chunk("Done.")
574
+
575
+ return chunks()
576
+
577
+ client = TestClient(
578
+ create_app(
579
+ serve_frontend=False,
580
+ chat_completion=fake_completion,
581
+ mcp_transport=transport,
582
+ )
583
+ )
584
+ configure_provider(client)
585
+ client.put("/api/mcp/servers", json=command_server_payload())
586
+
587
+ response = client.post("/api/workspace/respond", json={"content": "Read file"})
588
+
589
+ assert response.status_code == 200
590
+ tool_names = [
591
+ tool["function"]["name"]
592
+ for tool in captured_requests[0]["tools"]
593
+ if isinstance(tool, dict) and isinstance(tool.get("function"), dict)
594
+ ]
595
+ assert mcp_tool_name("mcp-files", "read_file") in tool_names
596
+
597
+
598
+ def test_mcp_tool_call_is_forwarded_and_result_returns_to_agent(
599
+ tmp_path, monkeypatch
600
+ ) -> None:
601
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
602
+ captured_requests: list[dict[str, object]] = []
603
+ transport = FakeMcpTransport()
604
+ transport.tools_by_server["mcp-files"] = [
605
+ {"inputSchema": {"type": "object"}, "name": "read_file"}
606
+ ]
607
+ transport.results[("mcp-files", "read_file")] = {
608
+ "content": [{"type": "text", "text": "MCP file content"}],
609
+ "isError": False,
610
+ }
611
+
612
+ async def fake_completion(**request: object) -> object:
613
+ captured_requests.append(request)
614
+
615
+ async def chunks() -> object:
616
+ if len(captured_requests) == 1:
617
+ yield tool_call_chunk(
618
+ mcp_tool_name("mcp-files", "read_file"),
619
+ {"path": "README.md"},
620
+ )
621
+ else:
622
+ yield text_chunk("Used MCP.")
623
+
624
+ return chunks()
625
+
626
+ client = TestClient(
627
+ create_app(
628
+ serve_frontend=False,
629
+ chat_completion=fake_completion,
630
+ mcp_transport=transport,
631
+ )
632
+ )
633
+ configure_provider(client)
634
+ client.put("/api/mcp/servers", json=command_server_payload())
635
+
636
+ response = client.post("/api/workspace/respond", json={"content": "Use MCP"})
637
+
638
+ assert response.status_code == 200
639
+ assert transport.tool_calls == [("mcp-files", "read_file", {"path": "README.md"})]
640
+ second_messages = captured_requests[1]["messages"]
641
+ assert second_messages[-1] == {
642
+ "content": "MCP file content",
643
+ "role": "tool",
644
+ "tool_call_id": "call-1",
645
+ }
646
+ events = stream_events(response.text)
647
+ assert events[2]["event"] == "tool_start"
648
+ assert events[2]["data"]["tool"]["title"] == "Calling Files.read_file"
649
+ assert events[3]["event"] == "tool_done"
650
+ assert events[3]["data"]["data"]["server"] == "Files"
651
+ assert events[3]["data"]["data"]["tool"] == "read_file"
652
+
653
+
654
+ def test_mcp_tool_call_failure_is_reported_in_workspace(tmp_path, monkeypatch) -> None:
655
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
656
+ captured_requests: list[dict[str, object]] = []
657
+ transport = FakeMcpTransport()
658
+ transport.tools_by_server["mcp-files"] = [
659
+ {"inputSchema": {"type": "object"}, "name": "read_file"}
660
+ ]
661
+ transport.results[("mcp-files", "read_file")] = {
662
+ "content": [{"type": "text", "text": "Permission denied"}],
663
+ "isError": True,
664
+ }
665
+
666
+ async def fake_completion(**request: object) -> object:
667
+ captured_requests.append(request)
668
+
669
+ async def chunks() -> object:
670
+ if len(captured_requests) == 1:
671
+ yield tool_call_chunk(
672
+ mcp_tool_name("mcp-files", "read_file"),
673
+ {"path": "secret.txt"},
674
+ )
675
+ else:
676
+ yield text_chunk("Could not use MCP.")
677
+
678
+ return chunks()
679
+
680
+ client = TestClient(
681
+ create_app(
682
+ serve_frontend=False,
683
+ chat_completion=fake_completion,
684
+ mcp_transport=transport,
685
+ )
686
+ )
687
+ configure_provider(client)
688
+ client.put("/api/mcp/servers", json=command_server_payload())
689
+
690
+ response = client.post("/api/workspace/respond", json={"content": "Use MCP"})
691
+
692
+ assert response.status_code == 200
693
+ events = stream_events(response.text)
694
+ assert events[3]["event"] == "tool_error"
695
+ assert events[3]["data"]["status"] == "failed"
696
+ assert events[3]["data"]["content"] == "Permission denied"
697
+
698
+
699
+ @pytest.mark.anyio
700
+ async def test_mcp_server_reload_reconnects_saved_enabled_servers(
701
+ tmp_path, monkeypatch
702
+ ) -> None:
703
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
704
+ store = StateStore(tmp_path)
705
+ server = store.save_mcp_server(
706
+ StoredMcpServer.model_validate(command_server_payload())
707
+ )
708
+ transport = FakeMcpTransport()
709
+ transport.tools_by_server["mcp-files"] = [
710
+ {"inputSchema": {"type": "object"}, "name": "read_file"}
711
+ ]
712
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
713
+
714
+ response = client.post("/api/mcp/reload")
715
+
716
+ assert response.status_code == 200
717
+ assert response.json()[0]["status"] == "starting"
718
+ manager = client.app.state.mcp_manager
719
+ connected = await wait_for_status(manager, server, "ready")
720
+ assert connected.status == "ready"
721
+ assert connected.tools[0].name == "read_file"
722
+ assert transport.connect_calls[0].id == "mcp-files"