flowent 0.2.0 → 0.2.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 (67) hide show
  1. package/backend/pyproject.toml +31 -5
  2. package/backend/src/flowent/agent.py +13 -4
  3. package/backend/src/flowent/compact.py +35 -14
  4. package/backend/src/flowent/llm.py +73 -7
  5. package/backend/src/flowent/main.py +260 -59
  6. package/backend/src/flowent/static/assets/index-CRSV2xu1.css +2 -0
  7. package/backend/src/flowent/static/assets/index-DUYj6rgD.js +82 -0
  8. package/backend/src/flowent/static/index.html +2 -2
  9. package/backend/src/flowent/storage.py +135 -3
  10. package/backend/src/flowent/usage.py +315 -0
  11. package/backend/uv.lock +971 -3
  12. package/dist/frontend/assets/index-CRSV2xu1.css +2 -0
  13. package/dist/frontend/assets/index-DUYj6rgD.js +82 -0
  14. package/dist/frontend/index.html +2 -2
  15. package/package.json +24 -3
  16. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/approval.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  22. package/backend/src/flowent/__pycache__/compact.cpython-313.pyc +0 -0
  23. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  24. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  25. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  26. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  27. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  28. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  29. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  30. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  31. package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
  32. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  33. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  34. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  35. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  36. package/backend/src/flowent/static/assets/index-BlaCigkZ.js +0 -82
  37. package/backend/src/flowent/static/assets/index-CRvbsH4K.css +0 -2
  38. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_approval.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  42. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  43. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  44. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  45. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  46. package/backend/tests/__pycache__/test_patch.cpython-313-pytest-9.0.3.pyc +0 -0
  47. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  48. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  49. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  50. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  51. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  52. package/backend/tests/conftest.py +0 -60
  53. package/backend/tests/test_agent_tools.py +0 -1124
  54. package/backend/tests/test_approval.py +0 -283
  55. package/backend/tests/test_channels.py +0 -360
  56. package/backend/tests/test_health.py +0 -12
  57. package/backend/tests/test_llm_providers.py +0 -548
  58. package/backend/tests/test_logging.py +0 -212
  59. package/backend/tests/test_mcp.py +0 -788
  60. package/backend/tests/test_patch.py +0 -112
  61. package/backend/tests/test_permissions.py +0 -588
  62. package/backend/tests/test_persistence.py +0 -249
  63. package/backend/tests/test_skills.py +0 -462
  64. package/backend/tests/test_startup_requirements.py +0 -144
  65. package/backend/tests/test_workspace_chat.py +0 -2174
  66. package/dist/frontend/assets/index-BlaCigkZ.js +0 -82
  67. package/dist/frontend/assets/index-CRvbsH4K.css +0 -2
@@ -1,788 +0,0 @@
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
- @pytest.mark.anyio
415
- async def test_enabled_mcp_server_save_returns_starting_and_connects_in_background(
416
- tmp_path, monkeypatch
417
- ) -> None:
418
- monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
419
- transport = FakeMcpTransport()
420
- transport.tools_by_server["mcp-files"] = [
421
- {
422
- "description": "Read a file",
423
- "inputSchema": {"type": "object"},
424
- "name": "read_file",
425
- }
426
- ]
427
- client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
428
-
429
- response = client.put("/api/mcp/servers", json=command_server_payload())
430
-
431
- assert response.status_code == 200
432
- assert response.json()["status"] == "starting"
433
- assert response.json()["tools"] == []
434
- manager = client.app.state.mcp_manager
435
- connected = await wait_for_status(
436
- manager,
437
- StoredMcpServer.model_validate(response.json()),
438
- "ready",
439
- )
440
- assert connected.status == "ready"
441
- assert connected.tools[0].name == "read_file"
442
-
443
-
444
- @pytest.mark.anyio
445
- async def test_mcp_connection_error_is_reported_in_state(tmp_path, monkeypatch) -> None:
446
- monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
447
- transport = FakeMcpTransport()
448
- transport.errors["mcp-files"] = "Command failed"
449
- client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
450
-
451
- response = client.put("/api/mcp/servers", json=command_server_payload())
452
-
453
- assert response.status_code == 200
454
- assert response.json()["status"] == "starting"
455
- manager = client.app.state.mcp_manager
456
- errored = await wait_for_status(
457
- manager,
458
- StoredMcpServer.model_validate(response.json()),
459
- "error",
460
- )
461
- assert errored.status == "error"
462
- assert errored.error == "Command failed"
463
-
464
-
465
- @pytest.mark.anyio
466
- async def test_mcp_server_can_be_reconnected(tmp_path, monkeypatch) -> None:
467
- monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
468
- transport = FakeMcpTransport()
469
- transport.tools_by_server["mcp-files"] = [
470
- {"inputSchema": {"type": "object"}, "name": "read_file"}
471
- ]
472
- client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
473
- client.put("/api/mcp/servers", json=command_server_payload())
474
-
475
- connected = await wait_for_status(
476
- client.app.state.mcp_manager,
477
- StoredMcpServer.model_validate(command_server_payload()),
478
- "ready",
479
- )
480
- assert connected.status == "ready"
481
- transport.tools_by_server["mcp-files"] = [
482
- {"inputSchema": {"type": "object"}, "name": "read_file"},
483
- {"inputSchema": {"type": "object"}, "name": "write_file"},
484
- ]
485
-
486
- response = client.post("/api/mcp/servers/mcp-files/reconnect")
487
-
488
- assert response.status_code == 200
489
- assert [tool["name"] for tool in response.json()["tools"]] == [
490
- "read_file",
491
- "write_file",
492
- ]
493
- assert [server.id for server in transport.connect_calls] == [
494
- "mcp-files",
495
- "mcp-files",
496
- ]
497
-
498
-
499
- @pytest.mark.anyio
500
- async def test_enabled_mcp_start_does_not_block_app_state(
501
- tmp_path, monkeypatch
502
- ) -> None:
503
- monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
504
- store = StateStore(tmp_path)
505
- server = store.save_mcp_server(
506
- StoredMcpServer(
507
- args=[],
508
- command="slow-server",
509
- id="mcp-slow",
510
- name="Slow",
511
- type="command",
512
- )
513
- )
514
- transport = FakeMcpTransport()
515
- transport.sleep_on_connect.add("mcp-slow")
516
- from flowent.mcp import McpManager
517
-
518
- manager = McpManager(store=store, transport=transport)
519
-
520
- await manager.start_enabled()
521
- state_server = manager.server_with_status(server)
522
-
523
- assert state_server.status == "starting"
524
- assert transport.connect_calls == []
525
-
526
-
527
- @pytest.mark.anyio
528
- async def test_mcp_connection_timeout_reports_error(tmp_path, monkeypatch) -> None:
529
- monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
530
- server = StoredMcpServer(
531
- args=[],
532
- command="slow-server",
533
- id="mcp-slow",
534
- name="Slow",
535
- type="command",
536
- )
537
- transport = FakeMcpTransport()
538
- transport.sleep_on_connect.add("mcp-slow")
539
- from flowent import mcp as mcp_module
540
-
541
- monkeypatch.setattr(mcp_module, "MCP_CONNECT_TIMEOUT_SECONDS", 0.01)
542
- manager = mcp_module.McpManager(store=StateStore(tmp_path), transport=transport)
543
-
544
- await manager.connect_server(server)
545
- state_server = manager.server_with_status(server)
546
-
547
- assert state_server.status == "error"
548
- assert state_server.error == "Connection timed out."
549
-
550
-
551
- def test_mcp_server_can_be_deleted(tmp_path, monkeypatch) -> None:
552
- monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
553
- transport = FakeMcpTransport()
554
- client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
555
- client.put("/api/mcp/servers", json=command_server_payload())
556
-
557
- response = client.delete("/api/mcp/servers/mcp-files")
558
- state = client.get("/api/state").json()
559
-
560
- assert response.status_code == 200
561
- assert state["mcp_servers"] == []
562
- assert transport.disconnect_calls == ["mcp-files"]
563
-
564
-
565
- def test_mcp_server_delete_removes_saved_server_when_disconnect_fails(
566
- tmp_path, monkeypatch
567
- ) -> None:
568
- monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
569
- transport = FakeMcpTransport()
570
- transport.disconnect_errors["mcp-files"] = "Disconnect failed"
571
- client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
572
- client.put("/api/mcp/servers", json=command_server_payload())
573
-
574
- response = client.delete("/api/mcp/servers/mcp-files")
575
- state = client.get("/api/state").json()
576
-
577
- assert response.status_code == 200
578
- assert state["mcp_servers"] == []
579
- assert transport.disconnect_calls == ["mcp-files"]
580
-
581
-
582
- @pytest.mark.anyio
583
- async def test_ready_mcp_tools_are_included_in_workspace_request(
584
- tmp_path, monkeypatch
585
- ) -> None:
586
- monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
587
- captured_requests: list[dict[str, object]] = []
588
- transport = FakeMcpTransport()
589
- transport.tools_by_server["mcp-files"] = [
590
- {
591
- "description": "Read a file",
592
- "inputSchema": {"type": "object"},
593
- "name": "read_file",
594
- }
595
- ]
596
-
597
- async def fake_completion(**request: object) -> object:
598
- captured_requests.append(request)
599
-
600
- async def chunks() -> object:
601
- yield text_chunk("Done.")
602
-
603
- return chunks()
604
-
605
- client = TestClient(
606
- create_app(
607
- serve_frontend=False,
608
- chat_completion=fake_completion,
609
- mcp_transport=transport,
610
- )
611
- )
612
- configure_provider(client)
613
- client.put("/api/mcp/servers", json=command_server_payload())
614
- connected = await wait_for_status(
615
- client.app.state.mcp_manager,
616
- StoredMcpServer.model_validate(command_server_payload()),
617
- "ready",
618
- )
619
- assert connected.status == "ready"
620
-
621
- response = client.post("/api/workspace/respond", json={"content": "Read file"})
622
-
623
- assert response.status_code == 200
624
- tool_names = [
625
- tool["function"]["name"]
626
- for tool in captured_requests[0]["tools"]
627
- if isinstance(tool, dict) and isinstance(tool.get("function"), dict)
628
- ]
629
- assert mcp_tool_name("mcp-files", "read_file") in tool_names
630
-
631
-
632
- @pytest.mark.anyio
633
- async def test_mcp_tool_call_is_forwarded_and_result_returns_to_agent(
634
- tmp_path, monkeypatch
635
- ) -> None:
636
- monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
637
- captured_requests: list[dict[str, object]] = []
638
- transport = FakeMcpTransport()
639
- transport.tools_by_server["mcp-files"] = [
640
- {"inputSchema": {"type": "object"}, "name": "read_file"}
641
- ]
642
- transport.results[("mcp-files", "read_file")] = {
643
- "content": [{"type": "text", "text": "MCP file content"}],
644
- "isError": False,
645
- }
646
-
647
- async def fake_completion(**request: object) -> object:
648
- captured_requests.append(request)
649
-
650
- async def chunks() -> object:
651
- if len(captured_requests) == 1:
652
- yield tool_call_chunk(
653
- mcp_tool_name("mcp-files", "read_file"),
654
- {"path": "README.md"},
655
- )
656
- else:
657
- yield text_chunk("Used MCP.")
658
-
659
- return chunks()
660
-
661
- client = TestClient(
662
- create_app(
663
- serve_frontend=False,
664
- chat_completion=fake_completion,
665
- mcp_transport=transport,
666
- )
667
- )
668
- configure_provider(client)
669
- client.put("/api/mcp/servers", json=command_server_payload())
670
- connected = await wait_for_status(
671
- client.app.state.mcp_manager,
672
- StoredMcpServer.model_validate(command_server_payload()),
673
- "ready",
674
- )
675
- assert connected.status == "ready"
676
-
677
- response = client.post("/api/workspace/respond", json={"content": "Use MCP"})
678
-
679
- assert response.status_code == 200
680
- assert transport.tool_calls == [("mcp-files", "read_file", {"path": "README.md"})]
681
- second_messages = captured_requests[1]["messages"]
682
- assert second_messages[-1] == {
683
- "content": "MCP file content",
684
- "role": "tool",
685
- "tool_call_id": "call-1",
686
- }
687
- events = stream_events(response.text)
688
- assert events[2]["event"] == "tool_start"
689
- assert events[2]["data"]["tool"]["title"] == "Calling Files.read_file"
690
- assert events[3]["event"] == "tool_done"
691
- assert events[3]["data"]["data"]["server"] == "Files"
692
- assert events[3]["data"]["data"]["tool"] == "read_file"
693
-
694
-
695
- @pytest.mark.anyio
696
- async def test_mcp_tool_call_failure_is_reported_in_workspace(
697
- tmp_path, monkeypatch
698
- ) -> None:
699
- monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
700
- captured_requests: list[dict[str, object]] = []
701
- transport = FakeMcpTransport()
702
- transport.tools_by_server["mcp-files"] = [
703
- {"inputSchema": {"type": "object"}, "name": "read_file"}
704
- ]
705
- transport.results[("mcp-files", "read_file")] = {
706
- "content": [{"type": "text", "text": "Permission denied"}],
707
- "isError": True,
708
- }
709
-
710
- async def fake_completion(**request: object) -> object:
711
- captured_requests.append(request)
712
-
713
- async def chunks() -> object:
714
- if len(captured_requests) == 1:
715
- yield tool_call_chunk(
716
- mcp_tool_name("mcp-files", "read_file"),
717
- {"path": "secret.txt"},
718
- )
719
- else:
720
- yield text_chunk("Could not use MCP.")
721
-
722
- return chunks()
723
-
724
- client = TestClient(
725
- create_app(
726
- serve_frontend=False,
727
- chat_completion=fake_completion,
728
- mcp_transport=transport,
729
- )
730
- )
731
- configure_provider(client)
732
- client.put("/api/mcp/servers", json=command_server_payload())
733
- connected = await wait_for_status(
734
- client.app.state.mcp_manager,
735
- StoredMcpServer.model_validate(command_server_payload()),
736
- "ready",
737
- )
738
- assert connected.status == "ready"
739
-
740
- response = client.post("/api/workspace/respond", json={"content": "Use MCP"})
741
-
742
- assert response.status_code == 200
743
- events = stream_events(response.text)
744
- assert events[3]["event"] == "tool_error"
745
- assert events[3]["data"]["status"] == "failed"
746
- assert events[3]["data"]["content"] == "Permission denied"
747
-
748
-
749
- @pytest.mark.anyio
750
- async def test_mcp_server_reload_reconnects_saved_enabled_servers(
751
- tmp_path, monkeypatch
752
- ) -> None:
753
- monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
754
- store = StateStore(tmp_path)
755
- server = store.save_mcp_server(
756
- StoredMcpServer.model_validate(command_server_payload())
757
- )
758
- transport = FakeMcpTransport()
759
- transport.tools_by_server["mcp-files"] = [
760
- {"inputSchema": {"type": "object"}, "name": "read_file"}
761
- ]
762
- client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
763
-
764
- response = client.post("/api/mcp/reload")
765
-
766
- assert response.status_code == 200
767
- assert response.json()[0]["status"] == "starting"
768
- manager = client.app.state.mcp_manager
769
- connected = await wait_for_status(manager, server, "ready")
770
- assert connected.status == "ready"
771
- assert connected.tools[0].name == "read_file"
772
- assert transport.connect_calls[0].id == "mcp-files"
773
-
774
-
775
- @pytest.mark.anyio
776
- async def test_enabled_mcp_server_save_does_not_block_response(
777
- tmp_path, monkeypatch
778
- ) -> None:
779
- monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
780
- transport = FakeMcpTransport()
781
- transport.sleep_on_connect.add("mcp-files")
782
- client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
783
-
784
- response = client.put("/api/mcp/servers", json=command_server_payload())
785
-
786
- assert response.status_code == 200
787
- assert response.json()["status"] == "starting"
788
- assert response.json()["tools"] == []