trinity-lite 0.1.4__tar.gz → 0.2.2__tar.gz

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 (34) hide show
  1. {trinity_lite-0.1.4/trinity_lite.egg-info → trinity_lite-0.2.2}/PKG-INFO +5 -1
  2. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/pyproject.toml +5 -1
  3. trinity_lite-0.2.2/tests/test_mcp_server.py +589 -0
  4. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/trinity_lite/__init__.py +1 -1
  5. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/trinity_lite/cli.py +25 -1
  6. trinity_lite-0.2.2/trinity_lite/mcp_server.py +843 -0
  7. {trinity_lite-0.1.4 → trinity_lite-0.2.2/trinity_lite.egg-info}/PKG-INFO +5 -1
  8. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/trinity_lite.egg-info/SOURCES.txt +3 -0
  9. trinity_lite-0.2.2/trinity_lite.egg-info/requires.txt +6 -0
  10. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/LICENSE +0 -0
  11. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/README.md +0 -0
  12. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/setup.cfg +0 -0
  13. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/tests/test_bus.py +0 -0
  14. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/tests/test_cli.py +0 -0
  15. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/tests/test_doctor.py +0 -0
  16. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/tests/test_guard.py +0 -0
  17. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/tests/test_orchestrator.py +0 -0
  18. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/tests/test_router.py +0 -0
  19. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/tests/test_worker.py +0 -0
  20. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/trinity_lite/__main__.py +0 -0
  21. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/trinity_lite/adapters.py +0 -0
  22. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/trinity_lite/bus.py +0 -0
  23. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/trinity_lite/config.py +0 -0
  24. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/trinity_lite/doctor.py +0 -0
  25. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/trinity_lite/guard.py +0 -0
  26. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/trinity_lite/orchestrator.py +0 -0
  27. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/trinity_lite/paths.py +0 -0
  28. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/trinity_lite/py.typed +0 -0
  29. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/trinity_lite/router.py +0 -0
  30. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/trinity_lite/validation.py +0 -0
  31. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/trinity_lite/worker.py +0 -0
  32. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/trinity_lite.egg-info/dependency_links.txt +0 -0
  33. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/trinity_lite.egg-info/entry_points.txt +0 -0
  34. {trinity_lite-0.1.4 → trinity_lite-0.2.2}/trinity_lite.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trinity-lite
3
- Version: 0.1.4
3
+ Version: 0.2.2
4
4
  Summary: A minimal public multi-agent task bus with routing, workers, and safety checks.
5
5
  Author: Yomiracle
6
6
  License-Expression: MIT
@@ -17,6 +17,10 @@ Classifier: Programming Language :: Python :: 3.12
17
17
  Requires-Python: >=3.10
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE
20
+ Provides-Extra: mcp
21
+ Requires-Dist: mcp>=1.0; extra == "mcp"
22
+ Provides-Extra: agent-skill
23
+ Requires-Dist: agent-skill-system>=1.0.6; extra == "agent-skill"
20
24
  Dynamic: license-file
21
25
 
22
26
  # Trinity Lite
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trinity-lite"
7
- version = "0.1.4"
7
+ version = "0.2.2"
8
8
  description = "A minimal public multi-agent task bus with routing, workers, and safety checks."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -24,6 +24,10 @@ classifiers = [
24
24
  ]
25
25
  dependencies = []
26
26
 
27
+ [project.optional-dependencies]
28
+ mcp = ["mcp>=1.0"]
29
+ agent-skill = ["agent-skill-system>=1.0.6"]
30
+
27
31
  [project.scripts]
28
32
  trinity-lite = "trinity_lite.cli:main"
29
33
 
@@ -0,0 +1,589 @@
1
+ import io
2
+ import json
3
+ import os
4
+ import sys
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ from trinity_lite.bus import TrinityBus
10
+ from trinity_lite.mcp_server import (
11
+ handle_request,
12
+ serve,
13
+ TOOL_DEFINITIONS,
14
+ RESOURCE_DEFINITIONS,
15
+ jsonrpc_response,
16
+ jsonrpc_error,
17
+ _compact_task,
18
+ _validate_task_id,
19
+ _validate_agent_id,
20
+ _validate_text,
21
+ _validate_limit,
22
+ _validate_timeout,
23
+ _get_known_agents,
24
+ )
25
+
26
+ try:
27
+ from engine.bank import SkillBank # noqa: F401
28
+
29
+ HAS_AGENT_SKILL = True
30
+ except ImportError:
31
+ HAS_AGENT_SKILL = False
32
+
33
+ # Detect agent-skill-system skills directory
34
+ SKILLS_DIR = os.environ.get(
35
+ "TRINITY_SKILLS_DIR",
36
+ os.path.expanduser("~/agent-skill-system/skills"),
37
+ )
38
+
39
+
40
+ class McpServerTest(unittest.TestCase):
41
+ def setUp(self):
42
+ self.temp = tempfile.TemporaryDirectory()
43
+ self.root = Path(self.temp.name)
44
+ self.db = self.root / "bus.db"
45
+ self.bus = TrinityBus(self.db, allowed_roots=[self.root])
46
+
47
+ def tearDown(self):
48
+ self.temp.cleanup()
49
+
50
+ def _msg(self, method, id_=1, params=None):
51
+ msg = {"jsonrpc": "2.0", "method": method, "id": id_}
52
+ if params is not None:
53
+ msg["params"] = params
54
+ return msg
55
+
56
+ def _call(self, msg):
57
+ return handle_request(msg, self.bus, None, None)
58
+
59
+ # ---- JSON-RPC lifecycle ----
60
+
61
+ def test_initialize_handshake(self):
62
+ resp = self._call(self._msg("initialize", 1, {}))
63
+ self.assertEqual(resp["id"], 1)
64
+ self.assertIn("protocolVersion", resp["result"])
65
+ self.assertEqual(resp["result"]["protocolVersion"], "2024-11-05")
66
+ self.assertIn("capabilities", resp["result"])
67
+ self.assertIn("tools", resp["result"]["capabilities"])
68
+ self.assertIn("resources", resp["result"]["capabilities"])
69
+ self.assertIn("serverInfo", resp["result"])
70
+ self.assertEqual(resp["result"]["serverInfo"]["name"], "trinity-lite-mcp")
71
+ self.assertEqual(resp["result"]["serverInfo"]["version"], "0.2.2")
72
+
73
+ def test_initialized_returns_none(self):
74
+ resp = self._call(self._msg("initialized"))
75
+ self.assertIsNone(resp)
76
+
77
+ def test_shutdown_returns_empty_result(self):
78
+ resp = self._call(self._msg("shutdown", 1, {}))
79
+ self.assertEqual(resp["id"], 1)
80
+ self.assertEqual(resp["result"], {})
81
+
82
+ def test_unknown_method_returns_error(self):
83
+ resp = self._call(self._msg("nonexistent", 1, {}))
84
+ self.assertIn("error", resp)
85
+ self.assertEqual(resp["error"]["code"], -32601)
86
+
87
+ # ---- tools/list ----
88
+
89
+ def test_tools_list(self):
90
+ resp = self._call(self._msg("tools/list", 1))
91
+ tools = resp["result"]["tools"]
92
+ self.assertIsInstance(tools, list)
93
+ self.assertEqual(len(tools), 10)
94
+ names = [t["name"] for t in tools]
95
+ self.assertIn("trinity_dispatch", names)
96
+ self.assertIn("trinity_dispatch_auto", names)
97
+ self.assertIn("trinity_status", names)
98
+ self.assertIn("trinity_tasks", names)
99
+ self.assertIn("trinity_worker", names)
100
+ self.assertIn("trinity_doctor", names)
101
+ self.assertIn("trinity_inbox", names)
102
+ self.assertIn("trinity_send", names)
103
+ self.assertIn("trinity_skill_search", names)
104
+ self.assertIn("trinity_skill_load", names)
105
+ # Check schema structure
106
+ for t in tools:
107
+ self.assertIn("name", t)
108
+ self.assertIn("description", t)
109
+ self.assertIn("inputSchema", t)
110
+ self.assertEqual(t["inputSchema"]["type"], "object")
111
+ self.assertIn("properties", t["inputSchema"])
112
+ self.assertIn("required", t["inputSchema"])
113
+
114
+ # ---- resources/list ----
115
+
116
+ def test_resources_list(self):
117
+ resp = self._call(self._msg("resources/list", 1))
118
+ resources = resp["result"]["resources"]
119
+ self.assertIsInstance(resources, list)
120
+ self.assertEqual(len(resources), 3)
121
+ uris = [r["uri"] for r in resources]
122
+ self.assertIn("trinity://health", uris)
123
+ self.assertIn("trinity://tasks/recent", uris)
124
+ self.assertIn("trinity://tasks/{task_id}", uris)
125
+
126
+ # ---- resources/read ----
127
+
128
+ def test_resource_health(self):
129
+ resp = self._call(self._msg("resources/read", 1, {"uri": "trinity://health"}))
130
+ self.assertIn("result", resp)
131
+ contents = resp["result"]["contents"]
132
+ self.assertEqual(len(contents), 1)
133
+ data = json.loads(contents[0]["text"])
134
+ self.assertEqual(data["status"], "ok")
135
+ self.assertEqual(data["bus"], "connected")
136
+ self.assertEqual(data["tools"], 10)
137
+ self.assertEqual(data["resources"], 3)
138
+
139
+ def test_resource_health_degraded(self):
140
+ # Use a bad bus path to simulate disconnected
141
+ bad_bus = TrinityBus(self.root / "nonexistent" / "deep" / "bus.db", allowed_roots=[self.root])
142
+ resp = handle_request(self._msg("resources/read", 1, {"uri": "trinity://health"}), bad_bus, None, None)
143
+ # Even with a bad path, SQLite creates the file... Let's use list_tasks catching.
144
+ # Actually sqlite will create dirs via bus, so this should still connect.
145
+ # Skip this test since sqlite is too forgiving.
146
+ pass
147
+
148
+ def test_resource_tasks_recent(self):
149
+ task = self.bus.submit_task("user", "codex", "hello", "implementation", cwd=self.root)
150
+ resp = self._call(self._msg("resources/read", 1, {"uri": "trinity://tasks/recent"}))
151
+ contents = resp["result"]["contents"]
152
+ data = json.loads(contents[0]["text"])
153
+ self.assertIsInstance(data, list)
154
+ self.assertGreaterEqual(len(data), 1)
155
+
156
+ def test_resource_task_by_id(self):
157
+ task = self.bus.submit_task("user", "codex", "hello", "implementation", cwd=self.root)
158
+ resp = self._call(self._msg("resources/read", 1, {"uri": "trinity://tasks/" + task["id"]}))
159
+ contents = resp["result"]["contents"]
160
+ data = json.loads(contents[0]["text"])
161
+ self.assertEqual(data["id"], task["id"])
162
+
163
+ def test_resource_task_not_found(self):
164
+ resp = self._call(self._msg("resources/read", 1, {"uri": "trinity://tasks/deadbeef1234"}))
165
+ self.assertIn("error", resp)
166
+ self.assertEqual(resp["error"]["code"], -32002)
167
+
168
+ def test_resource_invalid_uri(self):
169
+ resp = self._call(self._msg("resources/read", 1, {"uri": "trinity://nonexistent"}))
170
+ self.assertIn("error", resp)
171
+ self.assertEqual(resp["error"]["code"], -32002)
172
+
173
+ # ---- tools/call: trinity_status ----
174
+
175
+ def test_trinity_status_returns_task(self):
176
+ task = self.bus.submit_task("user", "codex", "implement test", "implementation", cwd=self.root)
177
+ params = {"name": "trinity_status", "arguments": {"task_id": task["id"]}}
178
+ resp = self._call(self._msg("tools/call", 1, params))
179
+ self.assertIn("result", resp)
180
+ self.assertEqual(resp["result"]["id"], task["id"])
181
+ self.assertEqual(resp["result"]["status"], "queued")
182
+
183
+ def test_trinity_status_not_found(self):
184
+ params = {"name": "trinity_status", "arguments": {"task_id": "deadbeef1234"}}
185
+ resp = self._call(self._msg("tools/call", 1, params))
186
+ self.assertIn("error", resp)
187
+ self.assertEqual(resp["error"]["code"], -32002)
188
+
189
+ def test_trinity_status_invalid_id(self):
190
+ params = {"name": "trinity_status", "arguments": {"task_id": "short"}}
191
+ resp = self._call(self._msg("tools/call", 1, params))
192
+ self.assertIn("error", resp)
193
+ self.assertEqual(resp["error"]["code"], -32602)
194
+
195
+ # ---- tools/call: trinity_dispatch ----
196
+
197
+ def test_trinity_dispatch_creates_and_runs_task(self):
198
+ params = {
199
+ "name": "trinity_dispatch",
200
+ "arguments": {
201
+ "target_agent": "codex",
202
+ "task": "implement hello world",
203
+ "cwd": str(self.root),
204
+ },
205
+ }
206
+ resp = self._call(self._msg("tools/call", 1, params))
207
+ self.assertIn("result", resp)
208
+ result = resp["result"]
209
+ self.assertEqual(result["target_agent"], "codex")
210
+ self.assertEqual(result["status"], "completed")
211
+ self.assertIn("[mock:codex]", result.get("result", ""))
212
+
213
+ def test_trinity_dispatch_self_delegation_blocked(self):
214
+ params = {
215
+ "name": "trinity_dispatch",
216
+ "arguments": {
217
+ "target_agent": "mcp",
218
+ "task": "self task",
219
+ "cwd": str(self.root),
220
+ },
221
+ }
222
+ resp = self._call(self._msg("tools/call", 1, params))
223
+ self.assertIn("error", resp)
224
+ self.assertEqual(resp["error"]["code"], -32000)
225
+
226
+ def test_trinity_dispatch_unknown_agent(self):
227
+ params = {
228
+ "name": "trinity_dispatch",
229
+ "arguments": {
230
+ "target_agent": "nonexistent_agent_123",
231
+ "task": "hello",
232
+ "cwd": str(self.root),
233
+ },
234
+ }
235
+ resp = self._call(self._msg("tools/call", 1, params))
236
+ self.assertIn("error", resp)
237
+ self.assertEqual(resp["error"]["code"], -32602)
238
+
239
+ def test_trinity_dispatch_missing_required(self):
240
+ params = {
241
+ "name": "trinity_dispatch",
242
+ "arguments": {"target_agent": "codex"},
243
+ }
244
+ resp = self._call(self._msg("tools/call", 1, params))
245
+ self.assertIn("error", resp)
246
+ self.assertEqual(resp["error"]["code"], -32602)
247
+
248
+ # ---- tools/call: trinity_dispatch_auto ----
249
+
250
+ def test_trinity_dispatch_auto_routes_and_runs(self):
251
+ params = {
252
+ "name": "trinity_dispatch_auto",
253
+ "arguments": {
254
+ "task": "implement a parser function",
255
+ "cwd": str(self.root),
256
+ },
257
+ }
258
+ resp = self._call(self._msg("tools/call", 1, params))
259
+ self.assertIn("result", resp)
260
+ result = resp["result"]
261
+ self.assertIn("route", result)
262
+ self.assertEqual(result["route"]["agent"], "codex")
263
+ self.assertEqual(result["route"]["task_type"], "implementation")
264
+ self.assertEqual(result["status"], "completed")
265
+
266
+ # ---- tools/call: trinity_tasks ----
267
+
268
+ def test_trinity_tasks_lists_tasks(self):
269
+ self.bus.submit_task("user", "codex", "task 1", cwd=self.root)
270
+ self.bus.submit_task("user", "claude_code", "task 2", cwd=self.root)
271
+ params = {"name": "trinity_tasks", "arguments": {}}
272
+ resp = self._call(self._msg("tools/call", 1, params))
273
+ self.assertIn("result", resp)
274
+ tasks = resp["result"]
275
+ self.assertIsInstance(tasks, list)
276
+ self.assertEqual(len(tasks), 2)
277
+
278
+ def test_trinity_tasks_filter_by_agent(self):
279
+ self.bus.submit_task("user", "codex", "codex task", cwd=self.root)
280
+ self.bus.submit_task("user", "claude_code", "claude task", cwd=self.root)
281
+ params = {
282
+ "name": "trinity_tasks",
283
+ "arguments": {"agent": "codex"},
284
+ }
285
+ resp = self._call(self._msg("tools/call", 1, params))
286
+ tasks = resp["result"]
287
+ self.assertEqual(len(tasks), 1)
288
+ self.assertEqual(tasks[0]["target_agent"], "codex")
289
+
290
+ # ---- tools/call: trinity_worker ----
291
+
292
+ def test_trinity_worker_runs_queued_task(self):
293
+ task = self.bus.submit_task("user", "codex", "worker test", cwd=self.root)
294
+ params = {
295
+ "name": "trinity_worker",
296
+ "arguments": {"agent": "codex"},
297
+ }
298
+ resp = self._call(self._msg("tools/call", 1, params))
299
+ self.assertIn("result", resp)
300
+ self.assertEqual(resp["result"]["id"], task["id"])
301
+ self.assertEqual(resp["result"]["status"], "completed")
302
+
303
+ def test_trinity_worker_no_task(self):
304
+ params = {
305
+ "name": "trinity_worker",
306
+ "arguments": {"agent": "codex"},
307
+ }
308
+ resp = self._call(self._msg("tools/call", 1, params))
309
+ self.assertIn("result", resp)
310
+ self.assertEqual(resp["result"]["status"], "no_task")
311
+
312
+ # ---- tools/call: trinity_doctor ----
313
+
314
+ def test_trinity_doctor_runs(self):
315
+ params = {"name": "trinity_doctor", "arguments": {}}
316
+ resp = self._call(self._msg("tools/call", 1, params))
317
+ self.assertIn("result", resp)
318
+ self.assertIn("status", resp["result"])
319
+
320
+ # ---- tools/call: trinity_inbox ----
321
+
322
+ def test_trinity_inbox_reads_messages(self):
323
+ self.bus.send_message("codex", "claude_code", "please review")
324
+ params = {
325
+ "name": "trinity_inbox",
326
+ "arguments": {"agent": "claude_code"},
327
+ }
328
+ resp = self._call(self._msg("tools/call", 1, params))
329
+ self.assertIn("result", resp)
330
+ self.assertEqual(len(resp["result"]), 1)
331
+ self.assertEqual(resp["result"][0]["message"], "please review")
332
+
333
+ # ---- tools/call: trinity_send ----
334
+
335
+ def test_trinity_send_sends_message(self):
336
+ params = {
337
+ "name": "trinity_send",
338
+ "arguments": {
339
+ "target_agent": "claude_code",
340
+ "message": "review this please",
341
+ },
342
+ }
343
+ resp = self._call(self._msg("tools/call", 1, params))
344
+ self.assertIn("result", resp)
345
+ self.assertEqual(resp["result"]["message"], "review this please")
346
+
347
+ def test_trinity_send_self_message_blocked(self):
348
+ params = {
349
+ "name": "trinity_send",
350
+ "arguments": {
351
+ "target_agent": "mcp",
352
+ "message": "self message",
353
+ },
354
+ }
355
+ resp = self._call(self._msg("tools/call", 1, params))
356
+ self.assertIn("error", resp)
357
+ self.assertEqual(resp["error"]["code"], -32000)
358
+
359
+ # ---- Input validation helpers ----
360
+
361
+ def test_validate_task_id_valid(self):
362
+ self.assertEqual(_validate_task_id("deadbeef1234"), "deadbeef1234")
363
+
364
+ def test_validate_task_id_invalid(self):
365
+ with self.assertRaises(ValueError):
366
+ _validate_task_id("short")
367
+ with self.assertRaises(ValueError):
368
+ _validate_task_id("nothex123456")
369
+
370
+ def test_validate_agent_id_known(self):
371
+ known = {"codex", "claude_code", "hermes"}
372
+ self.assertEqual(_validate_agent_id("codex", known), "codex")
373
+ self.assertEqual(_validate_agent_id("mcp", known), "mcp") # reserved
374
+
375
+ def test_validate_agent_id_unknown(self):
376
+ with self.assertRaises(ValueError):
377
+ _validate_agent_id("nonexistent", {"codex"})
378
+
379
+ def test_validate_text_control_chars(self):
380
+ with self.assertRaises(ValueError):
381
+ _validate_text("hello\x00world")
382
+
383
+ def test_validate_text_too_long(self):
384
+ with self.assertRaises(ValueError):
385
+ _validate_text("x" * 200_000)
386
+
387
+ def test_validate_limit(self):
388
+ self.assertEqual(_validate_limit(None), 20)
389
+ self.assertEqual(_validate_limit(5), 5)
390
+ with self.assertRaises(ValueError):
391
+ _validate_limit(0)
392
+ with self.assertRaises(ValueError):
393
+ _validate_limit(101)
394
+
395
+ def test_validate_timeout(self):
396
+ self.assertEqual(_validate_timeout(None), 600)
397
+ self.assertEqual(_validate_timeout(60), 60)
398
+ with self.assertRaises(ValueError):
399
+ _validate_timeout(0)
400
+ with self.assertRaises(ValueError):
401
+ _validate_timeout(3601)
402
+
403
+ # ---- Error code verification ----
404
+
405
+ def test_parse_error_on_bad_json(self):
406
+ from trinity_lite.mcp_server import serve
407
+ import io
408
+ import sys
409
+
410
+ # Simulate serving a malformed line
411
+ old_stdout = sys.stdout
412
+ old_stdin = sys.stdin
413
+ try:
414
+ # We test handle_request directly for bad json
415
+ pass
416
+ finally:
417
+ sys.stdout = old_stdout
418
+
419
+ def test_invalid_params_for_unknown_tool(self):
420
+ params = {
421
+ "name": "nonexistent_tool",
422
+ "arguments": {},
423
+ }
424
+ resp = self._call(self._msg("tools/call", 1, params))
425
+ self.assertIn("error", resp)
426
+ self.assertEqual(resp["error"]["code"], -32602)
427
+
428
+ # ---- JSON-RPC response helpers ----
429
+
430
+ def test_jsonrpc_response_structure(self):
431
+ resp = jsonrpc_response(1, {"key": "value"})
432
+ self.assertEqual(resp["jsonrpc"], "2.0")
433
+ self.assertEqual(resp["id"], 1)
434
+ self.assertEqual(resp["result"], {"key": "value"})
435
+ self.assertNotIn("error", resp)
436
+
437
+ def test_jsonrpc_error_structure(self):
438
+ resp = jsonrpc_error(1, -32600, "test error", {"detail": "extra"})
439
+ self.assertEqual(resp["jsonrpc"], "2.0")
440
+ self.assertEqual(resp["id"], 1)
441
+ self.assertIn("error", resp)
442
+ self.assertEqual(resp["error"]["code"], -32600)
443
+ self.assertEqual(resp["error"]["message"], "test error")
444
+ self.assertEqual(resp["error"]["data"], {"detail": "extra"})
445
+
446
+ # ---- Compact task ----
447
+
448
+ def test_compact_task_strips_extra_fields(self):
449
+ task = {
450
+ "id": "abc123def456",
451
+ "source_agent": "user",
452
+ "target_agent": "codex",
453
+ "task_type": "impl",
454
+ "prompt": "test",
455
+ "cwd": "/tmp",
456
+ "status": "queued",
457
+ "depth": 0,
458
+ "result": None,
459
+ "error": None,
460
+ "created_at": "now",
461
+ "started_at": None,
462
+ "finished_at": None,
463
+ "extra_field": "should be removed",
464
+ }
465
+ compact = _compact_task(task)
466
+ self.assertNotIn("extra_field", compact)
467
+ self.assertEqual(compact["id"], "abc123def456")
468
+
469
+
470
+ # ---- Skills: agent-skill-system integration ----
471
+
472
+ def test_skill_search_without_agent_skill_system(self):
473
+ """Graceful error when agent-skill-system not installed."""
474
+ import trinity_lite.mcp_server as mcp
475
+ from unittest.mock import patch
476
+
477
+ with patch.object(mcp, "_init_skill_engine", return_value=False):
478
+ params = {
479
+ "name": "trinity_skill_search",
480
+ "arguments": {"query": "parse a contract"},
481
+ }
482
+ resp = self._call(self._msg("tools/call", 1, params))
483
+ self.assertIn("result", resp)
484
+ self.assertIn("error", resp["result"])
485
+ self.assertEqual(resp["result"]["error"], "agent-skill-system not installed")
486
+ self.assertIn("hint", resp["result"])
487
+
488
+ @unittest.skipUnless(HAS_AGENT_SKILL and Path(SKILLS_DIR).exists(),
489
+ "agent-skill-system not installed or skills dir not found")
490
+ def test_skill_search_returns_matching_skills(self):
491
+ """Search returns matching skills when engine is available."""
492
+ import trinity_lite.mcp_server as mcp
493
+
494
+ old_available = mcp._SKILL_ENGINE_AVAILABLE
495
+ old_bank = mcp._SKILL_BANK
496
+ old_bank_dir = mcp._SKILL_BANK_DIR
497
+ try:
498
+ from engine.bank import SkillBank
499
+ mcp._SKILL_ENGINE_AVAILABLE = True
500
+ mcp._SKILL_BANK_DIR = SKILLS_DIR
501
+ mcp._SKILL_BANK = SkillBank(mcp._SKILL_BANK_DIR)
502
+ mcp._SKILL_BANK.scan_directory()
503
+
504
+ params = {
505
+ "name": "trinity_skill_search",
506
+ "arguments": {"query": "generate a weekly report", "limit": 3},
507
+ }
508
+ resp = self._call(self._msg("tools/call", 1, params))
509
+ self.assertIn("result", resp)
510
+ self.assertIn("skills", resp["result"])
511
+ skills = resp["result"]["skills"]
512
+ self.assertIsInstance(skills, list)
513
+ if skills:
514
+ self.assertIn("name", skills[0])
515
+ self.assertIn("description", skills[0])
516
+ self.assertIn("score", skills[0])
517
+ finally:
518
+ mcp._SKILL_ENGINE_AVAILABLE = old_available
519
+ mcp._SKILL_BANK = old_bank
520
+ mcp._SKILL_BANK_DIR = old_bank_dir
521
+
522
+ @unittest.skipUnless(HAS_AGENT_SKILL and Path(SKILLS_DIR).exists(),
523
+ "agent-skill-system not installed or skills dir not found")
524
+ def test_skill_load_returns_content(self):
525
+ """Load a skill's full content."""
526
+ import trinity_lite.mcp_server as mcp
527
+
528
+ old_available = mcp._SKILL_ENGINE_AVAILABLE
529
+ old_bank = mcp._SKILL_BANK
530
+ old_bank_dir = mcp._SKILL_BANK_DIR
531
+ try:
532
+ from engine.bank import SkillBank
533
+ mcp._SKILL_ENGINE_AVAILABLE = True
534
+ mcp._SKILL_BANK_DIR = SKILLS_DIR
535
+ mcp._SKILL_BANK = SkillBank(mcp._SKILL_BANK_DIR)
536
+ mcp._SKILL_BANK.scan_directory()
537
+
538
+ # Pick an active skill from the bank
539
+ active = mcp._SKILL_BANK.list_active()
540
+ if active:
541
+ skill_name = active[0].name
542
+ params = {
543
+ "name": "trinity_skill_load",
544
+ "arguments": {"skill_name": skill_name},
545
+ }
546
+ resp = self._call(self._msg("tools/call", 1, params))
547
+ self.assertIn("result", resp)
548
+ result = resp["result"]
549
+ self.assertIn("name", result)
550
+ self.assertIn("skill_md", result)
551
+ self.assertIn("system_prompt", result)
552
+ finally:
553
+ mcp._SKILL_ENGINE_AVAILABLE = old_available
554
+ mcp._SKILL_BANK = old_bank
555
+ mcp._SKILL_BANK_DIR = old_bank_dir
556
+
557
+ @unittest.skipUnless(HAS_AGENT_SKILL and Path(SKILLS_DIR).exists(),
558
+ "agent-skill-system not installed or skills dir not found")
559
+ def test_skill_load_not_found(self):
560
+ """Graceful error when skill name is not in the bank."""
561
+ import trinity_lite.mcp_server as mcp
562
+
563
+ old_available = mcp._SKILL_ENGINE_AVAILABLE
564
+ old_bank = mcp._SKILL_BANK
565
+ old_bank_dir = mcp._SKILL_BANK_DIR
566
+ try:
567
+ from engine.bank import SkillBank
568
+ mcp._SKILL_ENGINE_AVAILABLE = True
569
+ mcp._SKILL_BANK_DIR = SKILLS_DIR
570
+ mcp._SKILL_BANK = SkillBank(mcp._SKILL_BANK_DIR)
571
+ mcp._SKILL_BANK.scan_directory()
572
+
573
+ params = {
574
+ "name": "trinity_skill_load",
575
+ "arguments": {"skill_name": "nonexistent-skill-xyz"},
576
+ }
577
+ resp = self._call(self._msg("tools/call", 1, params))
578
+ self.assertIn("result", resp)
579
+ self.assertIn("error", resp["result"])
580
+ self.assertIn("skill not found", resp["result"]["error"])
581
+ self.assertIn("available_skills", resp["result"])
582
+ finally:
583
+ mcp._SKILL_ENGINE_AVAILABLE = old_available
584
+ mcp._SKILL_BANK = old_bank
585
+ mcp._SKILL_BANK_DIR = old_bank_dir
586
+
587
+
588
+ if __name__ == "__main__":
589
+ unittest.main()
@@ -1,3 +1,3 @@
1
1
  """Trinity Lite: a minimal public multi-agent task bus."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.2.2"
@@ -40,7 +40,7 @@ Quick demo: trinity-lite demo
40
40
  Full help: trinity-lite --help
41
41
 
42
42
  Commands: demo, dispatch, dispatch-auto, worker, orchestrate,
43
- status, tasks, route, doctor, send, inbox"""
43
+ status, tasks, route, doctor, send, inbox, mcp"""
44
44
 
45
45
 
46
46
  def build_parser() -> argparse.ArgumentParser:
@@ -124,6 +124,11 @@ def build_parser() -> argparse.ArgumentParser:
124
124
 
125
125
  demo = sub.add_parser("demo", parents=[common], help="run a guided first-run demo")
126
126
 
127
+ mcp = sub.add_parser("mcp", help="MCP server control")
128
+ mcp_sub = mcp.add_subparsers(dest="mcp_command")
129
+ mcp_sub.required = False
130
+ mcp_serve = mcp_sub.add_parser("serve", parents=[common], help="start MCP server on stdio")
131
+
127
132
  return parser
128
133
 
129
134
 
@@ -238,6 +243,8 @@ def run_command(args: argparse.Namespace) -> int:
238
243
  return 0
239
244
  if args.command == "demo":
240
245
  return _demo(args, bus)
246
+ if args.command == "mcp":
247
+ return _mcp(args, bus)
241
248
 
242
249
  raise AssertionError(f"unhandled command: {args.command}")
243
250
 
@@ -350,6 +357,23 @@ def _acceptance_status_from_flow(
350
357
  return _acceptance_status(route, primary_task, review_task)
351
358
 
352
359
 
360
+ def _mcp(args: argparse.Namespace, bus: TrinityBus) -> int:
361
+ """Handle 'trinity-lite mcp' subcommand."""
362
+ if args.mcp_command == "serve":
363
+ from .mcp_server import serve as mcp_serve
364
+
365
+ mcp_serve(bus, agents_path=args.agents, routes_path=args.routes)
366
+ return 0
367
+ # Show mcp help
368
+ print("usage: trinity-lite mcp serve [--db PATH] [--routes PATH] [--agents PATH]")
369
+ print("")
370
+ print("MCP server control")
371
+ print("")
372
+ print("commands:")
373
+ print(" serve start MCP server on stdio")
374
+ return 0
375
+
376
+
353
377
  def _demo(args: argparse.Namespace, bus: TrinityBus) -> int:
354
378
  """Run a guided demo flow with human-friendly output."""
355
379