tylor-mcp 1.0.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 (101) hide show
  1. package/.aws-setup.sh +25 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/.mcp.json +12 -0
  4. package/AGENTS.md +93 -0
  5. package/CLAUDE.md +99 -0
  6. package/CLAUDE_PLATFORM_AWS_SETUP.md +105 -0
  7. package/LICENSE +21 -0
  8. package/README.md +146 -0
  9. package/assets/tylor_logo.png +0 -0
  10. package/assets/tylor_threads_concept.png +0 -0
  11. package/bin/tylor.js +23 -0
  12. package/hooks/kill-thread-trigger.sh +7 -0
  13. package/hooks/post-tool-use-code-index.sh +7 -0
  14. package/hooks/session-checkpoint.sh +7 -0
  15. package/hooks/session-start.sh +7 -0
  16. package/install.py +401 -0
  17. package/install.sh +260 -0
  18. package/package.json +24 -0
  19. package/pytest.ini +2 -0
  20. package/registry.json +26 -0
  21. package/server/.env.example +24 -0
  22. package/server/__init__.py +0 -0
  23. package/server/config.py +89 -0
  24. package/server/main.py +93 -0
  25. package/server/personas/analyst.md +15 -0
  26. package/server/personas/ceo.md +14 -0
  27. package/server/personas/code_agent.md +15 -0
  28. package/server/personas/cto.md +14 -0
  29. package/server/provision.py +260 -0
  30. package/server/provision_opensearch.py +154 -0
  31. package/server/requirements.txt +26 -0
  32. package/server/storage/__init__.py +0 -0
  33. package/server/storage/dynamo.py +399 -0
  34. package/server/storage/json_store.py +359 -0
  35. package/server/storage/opensearch.py +194 -0
  36. package/server/storage/s3.py +96 -0
  37. package/server/storage/tests/__init__.py +0 -0
  38. package/server/storage/tests/test_dynamo.py +452 -0
  39. package/server/storage/tests/test_json_store.py +226 -0
  40. package/server/storage/tests/test_opensearch.py +270 -0
  41. package/server/storage/tests/test_s3.py +125 -0
  42. package/server/tests/__init__.py +0 -0
  43. package/server/tests/test_install.py +606 -0
  44. package/server/tests/test_isolation.py +90 -0
  45. package/server/tests/test_ui_server.py +385 -0
  46. package/server/tests/test_ui_shader_background.py +52 -0
  47. package/server/tests/test_ui_story_6_3.py +105 -0
  48. package/server/tools/__init__.py +0 -0
  49. package/server/tools/_mcp.py +4 -0
  50. package/server/tools/agents.py +160 -0
  51. package/server/tools/ecc/__init__.py +1 -0
  52. package/server/tools/ecc/data.py +35 -0
  53. package/server/tools/ecc/diagrams.py +23 -0
  54. package/server/tools/ecc/pipeline.py +24 -0
  55. package/server/tools/ecc/presentation.py +24 -0
  56. package/server/tools/ecc/web.py +23 -0
  57. package/server/tools/executor.py +880 -0
  58. package/server/tools/harness.py +330 -0
  59. package/server/tools/help.py +162 -0
  60. package/server/tools/hooks.py +357 -0
  61. package/server/tools/personas.py +110 -0
  62. package/server/tools/registry.py +195 -0
  63. package/server/tools/router.py +117 -0
  64. package/server/tools/skill_installer.py +230 -0
  65. package/server/tools/summarizer.py +168 -0
  66. package/server/tools/tests/__init__.py +0 -0
  67. package/server/tools/tests/test_agents.py +246 -0
  68. package/server/tools/tests/test_code_index.py +108 -0
  69. package/server/tools/tests/test_ecc_tools.py +51 -0
  70. package/server/tools/tests/test_executor.py +584 -0
  71. package/server/tools/tests/test_help_agent101.py +149 -0
  72. package/server/tools/tests/test_hooks.py +124 -0
  73. package/server/tools/tests/test_kill_thread.py +125 -0
  74. package/server/tools/tests/test_new_thread_list_threads.py +293 -0
  75. package/server/tools/tests/test_personas.py +52 -0
  76. package/server/tools/tests/test_recall_memory.py +55 -0
  77. package/server/tools/tests/test_registry_client.py +308 -0
  78. package/server/tools/tests/test_router.py +263 -0
  79. package/server/tools/tests/test_skill_installer.py +174 -0
  80. package/server/tools/tests/test_switch_thread.py +163 -0
  81. package/server/tools/tests/test_thread_command_skills.py +54 -0
  82. package/server/tools/tests/test_thread_resolver.py +165 -0
  83. package/server/tools/tests/test_tier1_schema.py +296 -0
  84. package/server/tools/thread_resolver.py +75 -0
  85. package/server/tools/tylor.py +374 -0
  86. package/server/tools/ui.py +38 -0
  87. package/server/ui_server.py +292 -0
  88. package/server/validate.py +237 -0
  89. package/skills/add-skill/SKILL.md +37 -0
  90. package/skills/afk-status/SKILL.md +20 -0
  91. package/skills/bmad/SKILL.md +14 -0
  92. package/skills/help-agent101/SKILL.md +48 -0
  93. package/skills/kill-thread/SKILL.md +35 -0
  94. package/skills/list-threads/SKILL.md +35 -0
  95. package/skills/new-thread/SKILL.md +35 -0
  96. package/skills/recall/SKILL.md +39 -0
  97. package/skills/run/SKILL.md +33 -0
  98. package/skills/set-sandbox/SKILL.md +38 -0
  99. package/skills/switch-thread/SKILL.md +38 -0
  100. package/ui/claude-logo.png +0 -0
  101. package/ui/index.html +1314 -0
@@ -0,0 +1,246 @@
1
+ """
2
+ Tests for Story 3.2: spawn_agent and list_personas tools
3
+ Run: pytest server/tools/tests/test_agents.py -v
4
+ """
5
+ from pathlib import Path
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ import pytest
9
+ from mcp.shared.exceptions import McpError
10
+ from mcp.types import INVALID_PARAMS
11
+
12
+ PLUGIN_DIR = Path(__file__).parent.parent.parent.parent
13
+
14
+
15
+ class FakeDb:
16
+ def __init__(self, meta: dict | None) -> None:
17
+ self.meta = meta
18
+ self.agent_outputs = []
19
+ self.agent_handoffs = []
20
+ self.agent_states = []
21
+
22
+ def get_thread_meta(self, thread_id: str) -> dict | None:
23
+ return self.meta
24
+
25
+ def put_agent_output(self, thread_id: str, agent_id: str, output: str, task: str | None = None) -> dict:
26
+ item = {
27
+ "SK": f"THREAD#{thread_id}#AGENT#{agent_id}#OUT#2026-05-12T00:00:00Z",
28
+ "ThreadId": thread_id,
29
+ "AgentId": agent_id,
30
+ "Output": output,
31
+ "Task": task,
32
+ }
33
+ self.agent_outputs.append(item)
34
+ return item
35
+
36
+ def put_agent_handoff(self, thread_id: str, agent_id: str, handoff_state: dict) -> dict:
37
+ item = {
38
+ "SK": f"THREAD#{thread_id}#AGENT#{agent_id}#HANDOFF#2026-05-12T00:00:00Z",
39
+ "ThreadId": thread_id,
40
+ "AgentId": agent_id,
41
+ "HandoffState": handoff_state,
42
+ }
43
+ self.agent_handoffs.append(item)
44
+ return item
45
+
46
+ def put_agent_state(self, thread_id: str, agent_id: str, state: dict) -> dict:
47
+ item = {
48
+ "SK": f"THREAD#{thread_id}#AGENT#{agent_id}#STATE",
49
+ "ThreadId": thread_id,
50
+ "AgentId": agent_id,
51
+ **state,
52
+ }
53
+ self.agent_states.append(item)
54
+ return item
55
+
56
+
57
+ def test_list_personas_returns_four_personas_with_summaries_and_categories():
58
+ from server.tools.agents import list_personas
59
+
60
+ result = list_personas()
61
+
62
+ assert set(result.keys()) == {"personas"}
63
+ personas = result["personas"]
64
+ assert {p["name"] for p in personas} == {"analyst", "ceo", "code_agent", "cto"}
65
+
66
+ for persona in personas:
67
+ assert persona["role_summary"]
68
+ assert persona["ecc_tool_categories"]
69
+ assert all(c.startswith("ecc/") for c in persona["ecc_tool_categories"])
70
+
71
+
72
+ def test_spawn_agent_initializes_known_persona_in_active_thread_with_scoped_tools():
73
+ from server.tools.agents import spawn_agent
74
+
75
+ db = FakeDb({"SK": "THREAD#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1#META", "Status": "active"})
76
+
77
+ with patch("server.tools.agents._get_db", return_value=db):
78
+ result = spawn_agent(
79
+ persona="code_agent",
80
+ thread_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1",
81
+ task="Implement the next story.",
82
+ )
83
+
84
+ assert result["agent_id"].startswith("agent_")
85
+ assert result["persona"] == "code_agent"
86
+ assert result["thread_id"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"
87
+ assert result["tools_loaded"] == ["ecc/web", "ecc/data", "ecc/pipeline"]
88
+ assert "Senior implementation engineer" in result["role_prompt"]
89
+ assert "Implement the next story." in result["task"]
90
+ assert result["state_sk"].startswith(f"THREAD#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1#AGENT#{result['agent_id']}#STATE")
91
+ assert db.agent_states[0]["ThreadId"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"
92
+ assert db.agent_states[0]["Status"] == "active"
93
+ assert db.agent_states[0]["Persona"] == "code_agent"
94
+ assert db.agent_states[0]["ToolsLoaded"] == ["ecc/web", "ecc/data", "ecc/pipeline"]
95
+
96
+
97
+ def test_spawn_agent_keeps_parallel_thread_agents_scoped_to_own_thread():
98
+ from server.tools.agents import spawn_agent
99
+
100
+ db_alpha = FakeDb({"SK": "THREAD#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2#META", "Status": "active"})
101
+ db_beta = FakeDb({"SK": "THREAD#bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb#META", "Status": "active"})
102
+
103
+ with patch("server.tools.agents._get_db", return_value=db_alpha):
104
+ alpha = spawn_agent(
105
+ persona="analyst",
106
+ thread_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2",
107
+ task="Analyze alpha.",
108
+ )
109
+
110
+ with patch("server.tools.agents._get_db", return_value=db_beta):
111
+ beta = spawn_agent(
112
+ persona="cto",
113
+ thread_id="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
114
+ task="Analyze beta.",
115
+ )
116
+
117
+ assert alpha["thread_id"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2"
118
+ assert beta["thread_id"] == "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
119
+ assert db_alpha.agent_states[0]["ThreadId"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2"
120
+ assert db_beta.agent_states[0]["ThreadId"] == "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
121
+ assert "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" not in db_alpha.agent_states[0]["SK"]
122
+ assert "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2" not in db_beta.agent_states[0]["SK"]
123
+
124
+
125
+ def test_spawn_agent_rejects_inactive_thread():
126
+ from server.tools.agents import spawn_agent
127
+
128
+ db = FakeDb({"SK": "THREAD#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1#META", "Status": "killed"})
129
+
130
+ with pytest.raises(McpError) as excinfo:
131
+ with patch("server.tools.agents._get_db", return_value=db):
132
+ spawn_agent(persona="analyst", thread_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1", task="Analyze market.")
133
+
134
+ assert excinfo.value.error.code == INVALID_PARAMS
135
+ assert "Thread is not active: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1" in excinfo.value.error.message
136
+
137
+
138
+ def test_spawn_agent_unknown_persona_raises_invalid_params_with_persona_list():
139
+ from server.tools.agents import spawn_agent
140
+
141
+ with pytest.raises(McpError) as excinfo:
142
+ spawn_agent(persona="designer", thread_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1", task="Make UI.")
143
+
144
+ assert excinfo.value.error.code == INVALID_PARAMS
145
+ message = excinfo.value.error.message
146
+ assert "Unknown persona: designer" in message
147
+ assert "Available personas:" in message
148
+ assert "code_agent" in message
149
+ assert "analyst" in message
150
+ assert "ceo" in message
151
+ assert "cto" in message
152
+
153
+
154
+ def test_spawn_agent_rejects_persona_file_outside_allowlist(tmp_path):
155
+ from server.tools import personas as personas_mod
156
+ from server.tools.agents import spawn_agent
157
+
158
+ persona_dir = tmp_path / "personas"
159
+ persona_dir.mkdir()
160
+ (persona_dir / "designer.md").write_text(
161
+ "# Designer\n\n"
162
+ "## Role Description\nDesign role.\n\n"
163
+ "## Communication Style\nDirect.\n\n"
164
+ "## ECC Tool Categories\n- `ecc/web`\n",
165
+ encoding="utf-8",
166
+ )
167
+
168
+ with patch.object(personas_mod, "PERSONAS_DIR", persona_dir):
169
+ with pytest.raises(McpError) as excinfo:
170
+ spawn_agent(persona="designer", thread_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1", task="Make UI.")
171
+
172
+ assert excinfo.value.error.code == INVALID_PARAMS
173
+ assert "Unknown persona: designer" in excinfo.value.error.message
174
+
175
+
176
+ def test_persist_agent_output_writes_thread_scoped_dynamo_item_and_indexes_memory():
177
+ from server.tools.agents import persist_agent_output
178
+
179
+ db = FakeDb({"SK": "THREAD#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1#META", "Status": "active"})
180
+ memory = MagicMock()
181
+ memory.index_memory.return_value = "memory-doc-1"
182
+
183
+ with patch("server.tools.agents._get_db", return_value=db), patch(
184
+ "server.tools.agents._get_memory_client", return_value=memory
185
+ ):
186
+ result = persist_agent_output(
187
+ thread_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1",
188
+ agent_id="agent_a",
189
+ output="Agent A found the launch risk.",
190
+ task="Assess launch risk.",
191
+ )
192
+
193
+ assert result["thread_id"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"
194
+ assert result["agent_id"] == "agent_a"
195
+ assert result["output_sk"].startswith("THREAD#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1#AGENT#agent_a#OUT#")
196
+ assert result["memory_id"] == "memory-doc-1"
197
+ assert db.agent_outputs[0]["ThreadId"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"
198
+ assert db.agent_outputs[0]["Output"] == "Agent A found the launch risk."
199
+ memory.index_memory.assert_called_once_with(
200
+ thread_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1",
201
+ fact="Agent A found the launch risk.",
202
+ metadata={
203
+ "source": "agent_output",
204
+ "agent_id": "agent_a",
205
+ "agent_output_sk": result["output_sk"],
206
+ },
207
+ )
208
+
209
+
210
+ def test_persist_agent_output_preserves_handoff_state_as_distinct_item():
211
+ from server.tools.agents import persist_agent_output
212
+
213
+ db = FakeDb({"SK": "THREAD#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1#META", "Status": "active"})
214
+ memory = MagicMock()
215
+ memory.index_memory.return_value = "memory-doc-1"
216
+ handoff = {"next_agent": "agent_b", "summary": "Use risk findings in plan."}
217
+
218
+ with patch("server.tools.agents._get_db", return_value=db), patch(
219
+ "server.tools.agents._get_memory_client", return_value=memory
220
+ ):
221
+ result = persist_agent_output(
222
+ thread_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1",
223
+ agent_id="agent_a",
224
+ output="Agent A found the launch risk.",
225
+ handoff_state=handoff,
226
+ )
227
+
228
+ assert result["handoff_sk"].startswith("THREAD#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1#AGENT#agent_a#HANDOFF#")
229
+ assert db.agent_outputs[0]["SK"] != db.agent_handoffs[0]["SK"]
230
+ assert db.agent_handoffs[0]["HandoffState"] == handoff
231
+
232
+
233
+ def test_persist_agent_output_rejects_cross_thread_agent_id():
234
+ from server.tools.agents import persist_agent_output
235
+
236
+ db = FakeDb({"SK": "THREAD#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1#META", "Status": "active"})
237
+ with pytest.raises(McpError) as excinfo:
238
+ with patch("server.tools.agents._get_db", return_value=db):
239
+ persist_agent_output(
240
+ thread_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1",
241
+ agent_id="THREAD#other#AGENT#agent_a",
242
+ output="bad",
243
+ )
244
+
245
+ assert excinfo.value.error.code == INVALID_PARAMS
246
+ assert "Invalid agent_id" in excinfo.value.error.message
@@ -0,0 +1,108 @@
1
+ """Tests for Story 2.11: thread-scoped code index."""
2
+ from pathlib import Path
3
+ from unittest.mock import MagicMock, patch
4
+
5
+
6
+ def test_save_memory_stores_typed_code_index_fact():
7
+ from server.tools.tylor import save_memory
8
+
9
+ memory = MagicMock()
10
+ memory.index_memory.return_value = "doc-1"
11
+
12
+ with patch("server.tools.tylor._get_memory_client", return_value=memory):
13
+ result = save_memory(
14
+ thread_id="t1",
15
+ fact="SignIn: ui/auth.tsx:42 — login form component",
16
+ fact_type="code_index",
17
+ )
18
+
19
+ memory.index_memory.assert_called_once_with(
20
+ thread_id="t1",
21
+ fact="SignIn: ui/auth.tsx:42 — login form component",
22
+ metadata={"type": "code_index"},
23
+ )
24
+ assert result == {"status": "saved", "thread_id": "t1", "memory_id": "doc-1", "type": "code_index"}
25
+
26
+
27
+ def test_recall_memory_filters_by_type_at_storage_layer():
28
+ from server.tools.tylor import recall_memory
29
+
30
+ memory = MagicMock()
31
+ memory.search_memory.return_value = [{"content": "SignIn: ui/auth.tsx:42"}]
32
+
33
+ with patch("server.tools.tylor._get_memory_client", return_value=memory):
34
+ result = recall_memory(thread_id="t1", query="SignIn", fact_type="code_index")
35
+
36
+ # storage layer receives `type=` (its own parameter name)
37
+ memory.search_memory.assert_called_once_with(
38
+ thread_id="t1",
39
+ query="SignIn",
40
+ k=5,
41
+ type="code_index",
42
+ )
43
+ assert result["results"][0]["content"].startswith("SignIn")
44
+
45
+
46
+ def test_code_index_header_injected_on_session_start_with_budget():
47
+ from server.tools.hooks import session_start_message
48
+
49
+ db = MagicMock()
50
+ db.get_current_thread_marker.return_value = {
51
+ "CurrentThreadId": "t1",
52
+ "ActiveAt": "2026-05-13T00:00:00Z",
53
+ }
54
+ db.get_thread_meta.return_value = {"Name": "Frontend", "Status": "active", "MessageCount": 3}
55
+ memory = MagicMock()
56
+ memory.search_memory.return_value = [
57
+ {"content": f"Symbol{i}: src/file{i}.tsx:{i} — component", "created_at": f"2026-05-13T00:00:{i:02d}Z"}
58
+ for i in range(30)
59
+ ]
60
+
61
+ def list_threads():
62
+ return {"threads": [{"thread_id": "t1", "name": "Frontend", "status": "active", "message_count": 3}]}
63
+
64
+ message = session_start_message(db=db, list_threads_fn=list_threads, memory_client=memory)
65
+
66
+ assert "[Frontend Thread — Code Index]" in message
67
+ header = message.split("agent101 active thread context:", 1)[0]
68
+ assert len(header.split()) <= 150
69
+ # storage layer receives `type=`
70
+ memory.search_memory.assert_called_once_with(
71
+ thread_id="t1",
72
+ query="code index",
73
+ k=30,
74
+ type="code_index",
75
+ )
76
+
77
+
78
+ def test_post_tool_use_indexes_read_file_symbol(tmp_path):
79
+ from server.tools.hooks import index_code_file_for_active_thread
80
+
81
+ source = tmp_path / "SignIn.tsx"
82
+ source.write_text("export function SignIn() {\n return <form />\n}\n", encoding="utf-8")
83
+ db = MagicMock()
84
+ db.get_current_thread_marker.return_value = {"CurrentThreadId": "t1"}
85
+ memory = MagicMock()
86
+ memory.index_memory.return_value = "doc-1"
87
+
88
+ result = index_code_file_for_active_thread(str(source), db=db, memory_client=memory,
89
+ project_root=tmp_path)
90
+
91
+ assert result["status"] == "indexed"
92
+ fact = memory.index_memory.call_args.kwargs["fact"]
93
+ assert fact.startswith("SignIn:")
94
+ # path should be relative (just the filename when project_root=tmp_path)
95
+ assert "SignIn.tsx:1" in fact
96
+ assert len(fact.split()) <= 30
97
+ assert memory.index_memory.call_args.kwargs["metadata"]["type"] == "code_index"
98
+
99
+
100
+ def test_post_tool_use_skips_config_files(tmp_path):
101
+ from server.tools.hooks import index_code_file_for_active_thread
102
+
103
+ config = tmp_path / "vite.config.ts"
104
+ config.write_text("export default {}\n", encoding="utf-8")
105
+
106
+ result = index_code_file_for_active_thread(str(config), db=MagicMock(), memory_client=MagicMock())
107
+
108
+ assert result == {"status": "skipped", "reason": "no_indexable_symbol"}
@@ -0,0 +1,51 @@
1
+ """
2
+ Tests for Story 4.1: ECC tool modules initial five categories.
3
+ Run: pytest server/tools/tests/test_ecc_tools.py -v
4
+ """
5
+ import asyncio
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+ from mcp.shared.exceptions import McpError
10
+ from mcp.types import INVALID_PARAMS
11
+
12
+ PLUGIN_DIR = Path(__file__).parent.parent.parent.parent
13
+
14
+ EXPECTED_GROUPS = {
15
+ "ecc/web": {"web_scrape", "web_fetch"},
16
+ "ecc/data": {"dataset_manager", "data_clean", "data_transform"},
17
+ "ecc/presentation": {"build_pptx", "build_doc"},
18
+ "ecc/diagrams": {"diagram_gen", "flowchart_gen"},
19
+ "ecc/pipeline": {"pipeline_builder", "pipeline_run"},
20
+ }
21
+
22
+
23
+ def _registered_tools() -> set[str]:
24
+ from server.tools._mcp import mcp
25
+
26
+ tools = asyncio.run(mcp.list_tools())
27
+ return {tool.name for tool in tools}
28
+
29
+
30
+ @pytest.mark.parametrize("tool_group,expected_tools", EXPECTED_GROUPS.items())
31
+ def test_load_skill_tools_registers_ecc_group_tools(tool_group, expected_tools):
32
+ from server.tools.registry import load_skill_tools
33
+
34
+ result = load_skill_tools(tool_group)
35
+
36
+ assert result == {
37
+ "tool_group": tool_group,
38
+ "status": "loaded",
39
+ "tools": sorted(expected_tools),
40
+ }
41
+ assert expected_tools <= _registered_tools()
42
+
43
+
44
+ def test_load_skill_tools_unknown_category_raises_invalid_params():
45
+ from server.tools.registry import load_skill_tools
46
+
47
+ with pytest.raises(McpError) as excinfo:
48
+ load_skill_tools("ecc/unknown")
49
+
50
+ assert excinfo.value.error.code == INVALID_PARAMS
51
+ assert excinfo.value.error.message == "Unknown skill category: ecc/unknown"