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,52 @@
1
+ """
2
+ Tests for Story 3.1: persona definition files
3
+ Run: pytest server/tools/tests/test_personas.py -v
4
+ """
5
+ from pathlib import Path
6
+
7
+ PLUGIN_DIR = Path(__file__).parent.parent.parent.parent
8
+ PERSONAS_DIR = PLUGIN_DIR / "server" / "personas"
9
+
10
+ EXPECTED_PERSONAS = {
11
+ "code_agent.md": {"ecc/web", "ecc/data", "ecc/pipeline"},
12
+ "analyst.md": {"ecc/web", "ecc/data", "ecc/diagrams"},
13
+ "ceo.md": {"ecc/presentation", "ecc/web"},
14
+ "cto.md": {"ecc/diagrams", "ecc/pipeline"},
15
+ }
16
+
17
+
18
+ def _read_persona(filename: str) -> str:
19
+ return (PERSONAS_DIR / filename).read_text()
20
+
21
+
22
+ def test_required_persona_files_exist():
23
+ for filename in EXPECTED_PERSONAS:
24
+ assert (PERSONAS_DIR / filename).exists(), f"Missing persona {filename}"
25
+
26
+
27
+ def test_each_persona_has_required_sections():
28
+ for filename in EXPECTED_PERSONAS:
29
+ text = _read_persona(filename)
30
+ assert "# " in text
31
+ assert "## Role Description" in text
32
+ assert "## Communication Style" in text
33
+ assert "## ECC Tool Categories" in text
34
+
35
+
36
+ def test_each_persona_declares_expected_ecc_categories():
37
+ for filename, expected_categories in EXPECTED_PERSONAS.items():
38
+ text = _read_persona(filename)
39
+ for category in expected_categories:
40
+ assert f"- `{category}`" in text, f"{filename} missing {category}"
41
+
42
+
43
+ def test_each_persona_has_no_unexpected_ecc_categories():
44
+ allowed = set().union(*EXPECTED_PERSONAS.values())
45
+ for filename in EXPECTED_PERSONAS:
46
+ text = _read_persona(filename)
47
+ declared = {
48
+ line.strip().removeprefix("- `").removesuffix("`")
49
+ for line in text.splitlines()
50
+ if line.strip().startswith("- `ecc/")
51
+ }
52
+ assert declared <= allowed
@@ -0,0 +1,55 @@
1
+ """
2
+ Tests for Story 2.5: recall_memory tool.
3
+ Run: pytest server/tools/tests/test_recall_memory.py -v
4
+ """
5
+ import sys
6
+ from pathlib import Path
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ import pytest
10
+
11
+ PLUGIN_DIR = Path(__file__).parent.parent.parent.parent
12
+ sys.path.insert(0, str(PLUGIN_DIR))
13
+
14
+ from mcp.server.fastmcp.exceptions import ToolError
15
+ from server.tools.tylor import recall_memory
16
+
17
+
18
+ def test_recall_memory_calls_search_memory_with_top_k():
19
+ mock_client = MagicMock()
20
+ mock_client.search_memory.return_value = [{"id": "doc1"}]
21
+
22
+ with patch("server.tools.tylor._get_memory_client", return_value=mock_client):
23
+ result = recall_memory(thread_id="t1", query="find relevant facts", top_k=3)
24
+
25
+ mock_client.search_memory.assert_called_once_with(
26
+ thread_id="t1",
27
+ query="find relevant facts",
28
+ k=3,
29
+ type=None,
30
+ )
31
+ assert result == {"results": [{"id": "doc1"}]}
32
+
33
+
34
+ def test_recall_memory_returns_empty_list_when_no_results():
35
+ mock_client = MagicMock()
36
+ mock_client.search_memory.return_value = []
37
+
38
+ with patch("server.tools.tylor._get_memory_client", return_value=mock_client):
39
+ result = recall_memory(thread_id="t1", query="missing facts")
40
+
41
+ assert result == {"results": []}
42
+
43
+
44
+ def test_recall_memory_raises_on_empty_query():
45
+ with pytest.raises(ToolError, match="Query must not be empty"):
46
+ recall_memory(thread_id="t1", query=" ")
47
+
48
+
49
+ def test_recall_memory_propagates_opensearch_errors():
50
+ mock_client = MagicMock()
51
+ mock_client.search_memory.side_effect = ToolError("search failed")
52
+
53
+ with patch("server.tools.tylor._get_memory_client", return_value=mock_client):
54
+ with pytest.raises(ToolError, match="search failed"):
55
+ recall_memory(thread_id="t1", query="search")
@@ -0,0 +1,308 @@
1
+ """
2
+ Tests for Story 4.2: two-tier manifest and skill registry client.
3
+ Run: pytest server/tools/tests/test_registry_client.py -v
4
+ """
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from unittest.mock import patch
10
+
11
+ PLUGIN_DIR = Path(__file__).parent.parent.parent.parent
12
+
13
+
14
+ def _write_registry(path: Path, skills: list[dict]) -> None:
15
+ path.write_text(
16
+ json.dumps({"version": "1.0", "skills": skills}, indent=2),
17
+ encoding="utf-8",
18
+ )
19
+
20
+
21
+ def test_list_registry_returns_lightweight_skill_index(tmp_path):
22
+ from server.tools import registry as registry_mod
23
+
24
+ registry_path = tmp_path / "registry.json"
25
+ _write_registry(
26
+ registry_path,
27
+ [
28
+ {
29
+ "name": "bmad",
30
+ "trigger_description": "BMad story workflows",
31
+ "keywords": ["story", "review"],
32
+ "tool_count": 2,
33
+ "installed_date": "2026-05-13",
34
+ "module": "server.tools.ecc.web",
35
+ "tools": ["web_fetch", "web_scrape"],
36
+ "schemas": [{"name": "too-heavy"}],
37
+ }
38
+ ],
39
+ )
40
+
41
+ with patch.object(registry_mod, "REGISTRY_PATH", registry_path):
42
+ result = registry_mod.list_registry()
43
+
44
+ assert result == {
45
+ "skills": [
46
+ {
47
+ "name": "bmad",
48
+ "trigger_description": "BMad story workflows",
49
+ "keywords": ["story", "review"],
50
+ "tool_count": 2,
51
+ }
52
+ ]
53
+ }
54
+ assert "schemas" not in result["skills"][0]
55
+ assert "module" not in result["skills"][0]
56
+ assert "tools" not in result["skills"][0]
57
+
58
+
59
+ def test_load_skill_tools_loads_registry_backed_group_without_restart(tmp_path):
60
+ from server.tools import registry as registry_mod
61
+ from server.tools._mcp import mcp
62
+
63
+ registry_path = tmp_path / "registry.json"
64
+ _write_registry(
65
+ registry_path,
66
+ [
67
+ {
68
+ "name": "bmad",
69
+ "trigger_description": "BMad story workflows",
70
+ "keywords": ["story"],
71
+ "tool_count": 2,
72
+ "module": "server.tools.ecc.web",
73
+ "tools": ["web_fetch", "web_scrape"],
74
+ }
75
+ ],
76
+ )
77
+
78
+ with patch.object(registry_mod, "REGISTRY_PATH", registry_path):
79
+ result = registry_mod.load_skill_tools("bmad")
80
+
81
+ assert result == {
82
+ "tool_group": "bmad",
83
+ "status": "loaded",
84
+ "tools": ["web_fetch", "web_scrape"],
85
+ }
86
+ tool_names = {tool.name for tool in __import__("asyncio").run(mcp.list_tools())}
87
+ assert {"web_fetch", "web_scrape"} <= tool_names
88
+
89
+
90
+ def test_startup_manifest_is_tier1_only_and_lists_under_100ms():
91
+ code = """
92
+ import asyncio, json, time
93
+ import server.main
94
+ from server.tools._mcp import mcp
95
+ start = time.perf_counter()
96
+ tools = asyncio.run(mcp.list_tools())
97
+ elapsed_ms = (time.perf_counter() - start) * 1000
98
+ print(json.dumps({"elapsed_ms": elapsed_ms, "tools": sorted(t.name for t in tools)}))
99
+ """
100
+ completed = subprocess.run(
101
+ [sys.executable, "-c", code],
102
+ cwd=PLUGIN_DIR,
103
+ check=True,
104
+ capture_output=True,
105
+ text=True,
106
+ )
107
+ payload = json.loads(completed.stdout)
108
+
109
+ tier2_tools = {
110
+ "web_scrape",
111
+ "web_fetch",
112
+ "dataset_manager",
113
+ "data_clean",
114
+ "data_transform",
115
+ "build_pptx",
116
+ "build_doc",
117
+ "diagram_gen",
118
+ "flowchart_gen",
119
+ "pipeline_builder",
120
+ "pipeline_run",
121
+ }
122
+ assert payload["elapsed_ms"] < 100
123
+ assert tier2_tools.isdisjoint(payload["tools"])
124
+
125
+
126
+ def test_detect_registry_skill_match_surfaces_agent101_suggestion(tmp_path):
127
+ from server.tools import registry as registry_mod
128
+
129
+ registry_path = tmp_path / "registry.json"
130
+ _write_registry(
131
+ registry_path,
132
+ [
133
+ {
134
+ "name": "bmad",
135
+ "trigger_description": "Use when user wants to create a PRD or draft requirements.",
136
+ "keywords": ["prd", "requirements"],
137
+ "tool_count": 2,
138
+ "module": "server.tools.ecc.web",
139
+ "tools": ["web_fetch", "web_scrape"],
140
+ }
141
+ ],
142
+ )
143
+
144
+ with patch.object(registry_mod, "REGISTRY_PATH", registry_path):
145
+ result = registry_mod.detect_registry_skill("let's draft a PRD for this idea")
146
+
147
+ assert result == {
148
+ "matched": True,
149
+ "skill": "bmad",
150
+ "action": "suggest",
151
+ "message": "You have BMAD in agent101 — want me to use it?",
152
+ "thread_persistence": True,
153
+ }
154
+
155
+
156
+ def test_auto_load_matching_registry_skill_loads_tools(tmp_path):
157
+ from server.tools import registry as registry_mod
158
+
159
+ registry_path = tmp_path / "registry.json"
160
+ _write_registry(
161
+ registry_path,
162
+ [
163
+ {
164
+ "name": "bmad",
165
+ "trigger_description": "Use when user wants to create a PRD.",
166
+ "keywords": ["prd"],
167
+ "tool_count": 2,
168
+ "module": "server.tools.ecc.web",
169
+ "tools": ["web_fetch", "web_scrape"],
170
+ }
171
+ ],
172
+ )
173
+
174
+ with patch.object(registry_mod, "REGISTRY_PATH", registry_path):
175
+ result = registry_mod.detect_registry_skill(
176
+ "let's draft a PRD for this idea",
177
+ auto_load=True,
178
+ )
179
+
180
+ assert result["matched"] is True
181
+ assert result["action"] == "loaded"
182
+ assert result["skill"] == "bmad"
183
+ assert result["loaded"]["tools"] == ["web_fetch", "web_scrape"]
184
+ assert result["thread_persistence"] is True
185
+
186
+
187
+ def test_auto_load_matching_registry_skill_without_tools_falls_back_to_suggestion(tmp_path):
188
+ from server.tools import registry as registry_mod
189
+
190
+ registry_path = tmp_path / "registry.json"
191
+ _write_registry(
192
+ registry_path,
193
+ [
194
+ {
195
+ "name": "bmad",
196
+ "trigger_description": "Use when user wants to create a PRD.",
197
+ "keywords": ["prd"],
198
+ "tool_count": 1,
199
+ "source_path": "/local/skills/bmad",
200
+ }
201
+ ],
202
+ )
203
+
204
+ with patch.object(registry_mod, "REGISTRY_PATH", registry_path), patch(
205
+ "server.tools.registry.importlib.import_module"
206
+ ) as import_module:
207
+ result = registry_mod.detect_registry_skill(
208
+ "let's draft a PRD for this idea",
209
+ auto_load=True,
210
+ )
211
+
212
+ assert result == {
213
+ "matched": True,
214
+ "skill": "bmad",
215
+ "action": "suggest",
216
+ "message": "You have BMAD in agent101 — want me to use it?",
217
+ "thread_persistence": True,
218
+ }
219
+ import_module.assert_not_called()
220
+
221
+
222
+ def test_non_matching_registry_skill_does_not_load_schemas(tmp_path):
223
+ from server.tools import registry as registry_mod
224
+
225
+ registry_path = tmp_path / "registry.json"
226
+ _write_registry(
227
+ registry_path,
228
+ [
229
+ {
230
+ "name": "bmad",
231
+ "trigger_description": "Use when user wants to create a PRD.",
232
+ "keywords": ["prd"],
233
+ "tool_count": 2,
234
+ "module": "server.tools.ecc.web",
235
+ "tools": ["web_fetch", "web_scrape"],
236
+ }
237
+ ],
238
+ )
239
+
240
+ with patch.object(registry_mod, "REGISTRY_PATH", registry_path), patch(
241
+ "server.tools.registry.importlib.import_module"
242
+ ) as import_module:
243
+ result = registry_mod.detect_registry_skill("show my active threads", auto_load=True)
244
+
245
+ assert result == {"matched": False, "action": "none"}
246
+ import_module.assert_not_called()
247
+
248
+
249
+ def test_generic_trigger_words_do_not_match_registry_skill(tmp_path):
250
+ from server.tools import registry as registry_mod
251
+
252
+ registry_path = tmp_path / "registry.json"
253
+ _write_registry(
254
+ registry_path,
255
+ [
256
+ {
257
+ "name": "bmad",
258
+ "trigger_description": "Use when user wants to create a PRD.",
259
+ "keywords": ["prd"],
260
+ "tool_count": 2,
261
+ "module": "server.tools.ecc.web",
262
+ "tools": ["web_fetch", "web_scrape"],
263
+ }
264
+ ],
265
+ )
266
+
267
+ with patch.object(registry_mod, "REGISTRY_PATH", registry_path), patch(
268
+ "server.tools.registry.importlib.import_module"
269
+ ) as import_module:
270
+ result = registry_mod.detect_registry_skill(
271
+ "create a new thread for billing",
272
+ auto_load=True,
273
+ )
274
+
275
+ assert result == {"matched": False, "action": "none"}
276
+ import_module.assert_not_called()
277
+
278
+
279
+ def test_explicit_native_slash_skill_is_not_thread_persistent(tmp_path):
280
+ from server.tools import registry as registry_mod
281
+
282
+ registry_path = tmp_path / "registry.json"
283
+ _write_registry(
284
+ registry_path,
285
+ [
286
+ {
287
+ "name": "bmad",
288
+ "trigger_description": "Use when user wants to create a PRD.",
289
+ "keywords": ["prd"],
290
+ "tool_count": 2,
291
+ "module": "server.tools.ecc.web",
292
+ "tools": ["web_fetch", "web_scrape"],
293
+ }
294
+ ],
295
+ )
296
+
297
+ with patch.object(registry_mod, "REGISTRY_PATH", registry_path), patch(
298
+ "server.tools.registry.importlib.import_module"
299
+ ) as import_module:
300
+ result = registry_mod.detect_registry_skill("/bmad create a PRD")
301
+
302
+ assert result == {
303
+ "matched": True,
304
+ "skill": "bmad",
305
+ "action": "claude_native",
306
+ "thread_persistence": False,
307
+ }
308
+ import_module.assert_not_called()
@@ -0,0 +1,263 @@
1
+ """
2
+ Tests for Story 2.6: Model Router 3-Tier Fallback
3
+ Run: pytest server/tools/tests/test_router.py -v
4
+ """
5
+ from pathlib import Path
6
+ import sys
7
+ from unittest.mock import patch
8
+
9
+ import pytest
10
+ from mcp.shared.exceptions import McpError
11
+
12
+ PLUGIN_DIR = Path(__file__).parent.parent.parent.parent
13
+ sys.path.insert(0, str(PLUGIN_DIR))
14
+
15
+
16
+ class FakeMessages:
17
+ def __init__(self, result=None, error=None):
18
+ self.result = result
19
+ self.error = error
20
+ self.calls = []
21
+
22
+ def create(self, **kwargs):
23
+ self.calls.append(kwargs)
24
+ if self.error:
25
+ raise self.error
26
+ return self.result
27
+
28
+
29
+ class FakeClient:
30
+ def __init__(self, result=None, error=None):
31
+ self.messages = FakeMessages(result=result, error=error)
32
+
33
+
34
+ class AnthropicStyleError(Exception):
35
+ def __init__(self, error_type: str, message: str = "boom"):
36
+ super().__init__(message)
37
+ self.type = error_type
38
+
39
+
40
+ def test_primary_success_returns_response_as_is_without_platform_route():
41
+ from server.tools.router import ModelRouter
42
+
43
+ response = object()
44
+ primary = FakeClient(result=response)
45
+ platform = FakeClient(result={"unused": True})
46
+ router = ModelRouter(primary_client=primary, platform_client=platform)
47
+
48
+ request = {
49
+ "model": "claude-sonnet-4-6",
50
+ "max_tokens": 128,
51
+ "messages": [{"role": "user", "content": "hello"}],
52
+ }
53
+
54
+ assert router.create_message(**request) is response
55
+ assert primary.messages.calls == [request]
56
+ assert platform.messages.calls == []
57
+
58
+
59
+ def test_rate_limit_error_retries_identical_request_via_platform_route():
60
+ from server.tools.router import ModelRouter
61
+
62
+ primary_error = AnthropicStyleError("rate_limit_error", "primary overflow")
63
+ primary = FakeClient(error=primary_error)
64
+ platform_response = {"content": "fallback response"}
65
+ platform = FakeClient(result=platform_response)
66
+ router = ModelRouter(primary_client=primary, platform_client=platform)
67
+
68
+ request = {
69
+ "model": "claude-sonnet-4-6",
70
+ "max_tokens": 128,
71
+ "messages": [{"role": "user", "content": "same request"}],
72
+ "temperature": 0,
73
+ }
74
+
75
+ assert router.create_message(**request) == platform_response
76
+ assert primary.messages.calls == [request]
77
+ assert platform.messages.calls == [request]
78
+
79
+
80
+ def test_context_length_exceeded_does_not_use_platform_route():
81
+ from server.tools.router import ModelRouter
82
+
83
+ primary_error = AnthropicStyleError("context_length_exceeded", "too long")
84
+ primary = FakeClient(error=primary_error)
85
+ platform = FakeClient(result={"unused": True})
86
+ router = ModelRouter(primary_client=primary, platform_client=platform)
87
+
88
+ with pytest.raises(AnthropicStyleError, match="too long"):
89
+ router.create_message(model="claude-sonnet-4-6", max_tokens=128, messages=[])
90
+
91
+ assert platform.messages.calls == []
92
+
93
+
94
+ def test_non_overflow_primary_error_does_not_use_platform_route():
95
+ from server.tools.router import ModelRouter
96
+
97
+ primary_error = AnthropicStyleError("authentication_error", "bad key")
98
+ primary = FakeClient(error=primary_error)
99
+ platform = FakeClient(result={"unused": True})
100
+ router = ModelRouter(primary_client=primary, platform_client=platform)
101
+
102
+ with pytest.raises(AnthropicStyleError, match="bad key"):
103
+ router.create_message(model="claude-sonnet-4-6", max_tokens=128, messages=[])
104
+
105
+ assert platform.messages.calls == []
106
+
107
+
108
+ def test_exhausted_routes_raise_mcp_internal_error():
109
+ from server.tools.router import ModelRouter
110
+
111
+ primary = FakeClient(error=AnthropicStyleError("rate_limit_error", "primary limit"))
112
+ platform = FakeClient(error=RuntimeError("platform unavailable"))
113
+ router = ModelRouter(primary_client=primary, platform_client=platform)
114
+
115
+ with pytest.raises(McpError) as exc_info:
116
+ router.create_message(model="claude-sonnet-4-6", max_tokens=128, messages=[])
117
+
118
+ error = exc_info.value.error
119
+ assert error.code == -32603
120
+ assert error.message == "All model routes exhausted"
121
+
122
+
123
+ def test_overflow_with_null_platform_key_raises_mcp_error_immediately(monkeypatch):
124
+ import server.config as server_config
125
+ from server.tools.router import ModelRouter
126
+
127
+ monkeypatch.setattr(server_config, "config", {"platform_key": None})
128
+
129
+ primary = FakeClient(error=AnthropicStyleError("rate_limit_error", "rate limited"))
130
+ router = ModelRouter(primary_client=primary, platform_client=None)
131
+
132
+ with pytest.raises(McpError) as exc_info:
133
+ router.create_message(model="claude-sonnet-4-6", max_tokens=128, messages=[])
134
+
135
+ error = exc_info.value.error
136
+ assert error.code == -32603
137
+ assert error.message == "All model routes exhausted"
138
+
139
+
140
+ def test_platform_client_cached_after_first_overflow(monkeypatch):
141
+ import server.config as server_config
142
+ from server.tools.router import ModelRouter
143
+
144
+ monkeypatch.setattr(
145
+ server_config,
146
+ "config",
147
+ {
148
+ "platform_key": "test-key",
149
+ "platform_base_url": "https://aws-external-anthropic.us-east-1.api.aws",
150
+ "platform_workspace_id": None,
151
+ "bedrock_region": "us-east-1",
152
+ },
153
+ )
154
+
155
+ platform_response = {"content": "fallback"}
156
+ primary = FakeClient(error=AnthropicStyleError("rate_limit_error", "rate limited"))
157
+ router = ModelRouter(primary_client=primary, platform_client=None)
158
+
159
+ with patch("anthropic.Anthropic") as anthropic_cls:
160
+ mock_client = anthropic_cls.return_value
161
+ mock_client.messages.create.return_value = platform_response
162
+ router.create_message(model="claude-sonnet-4-6", max_tokens=128, messages=[])
163
+
164
+ assert router.platform_client is mock_client
165
+ assert anthropic_cls.call_count == 1
166
+
167
+
168
+ def test_platform_base_url_uses_regional_aws_endpoint():
169
+ from server.tools.router import build_platform_base_url
170
+
171
+ assert (
172
+ build_platform_base_url("us-east-1")
173
+ == "https://aws-external-anthropic.us-east-1.api.aws"
174
+ )
175
+
176
+
177
+ def test_build_platform_client_uses_key_base_url_and_workspace_header(monkeypatch):
178
+ import server.config as server_config
179
+ from server.tools.router import build_platform_client
180
+
181
+ monkeypatch.setattr(
182
+ server_config,
183
+ "config",
184
+ {
185
+ "platform_key": "test-key",
186
+ "platform_base_url": "https://aws-external-anthropic.us-east-1.api.aws",
187
+ "platform_workspace_id": "wrkspc_test",
188
+ "bedrock_region": "us-east-1",
189
+ },
190
+ )
191
+
192
+ with patch("anthropic.Anthropic") as anthropic_cls:
193
+ build_platform_client()
194
+
195
+ anthropic_cls.assert_called_once_with(
196
+ api_key="test-key",
197
+ base_url="https://aws-external-anthropic.us-east-1.api.aws",
198
+ default_headers={"anthropic-workspace-id": "wrkspc_test"},
199
+ )
200
+
201
+
202
+ def test_build_platform_client_no_workspace_id_sends_no_header(monkeypatch):
203
+ import server.config as server_config
204
+ from server.tools.router import build_platform_client
205
+
206
+ monkeypatch.setattr(
207
+ server_config,
208
+ "config",
209
+ {
210
+ "platform_key": "test-key",
211
+ "platform_base_url": "https://aws-external-anthropic.us-east-1.api.aws",
212
+ "platform_workspace_id": None,
213
+ "bedrock_region": "us-east-1",
214
+ },
215
+ )
216
+
217
+ with patch("anthropic.Anthropic") as anthropic_cls:
218
+ build_platform_client()
219
+
220
+ anthropic_cls.assert_called_once_with(
221
+ api_key="test-key",
222
+ base_url="https://aws-external-anthropic.us-east-1.api.aws",
223
+ default_headers=None,
224
+ )
225
+
226
+
227
+ def test_build_platform_client_raises_when_platform_key_missing(monkeypatch):
228
+ import server.config as server_config
229
+ from server.tools.router import build_platform_client
230
+
231
+ monkeypatch.setattr(server_config, "config", {"platform_key": None})
232
+
233
+ with pytest.raises(RuntimeError, match="ANTHROPIC_PLATFORM_AWS_API_KEY not configured"):
234
+ build_platform_client()
235
+
236
+
237
+ def test_error_type_string_matching_fallback():
238
+ from server.tools.router import _error_type
239
+
240
+ class PlainError(Exception):
241
+ pass
242
+
243
+ assert _error_type(PlainError("rate_limit_error in response")) == "rate_limit_error"
244
+
245
+
246
+ def test_error_type_returns_empty_string_for_unknown_error():
247
+ from server.tools.router import _error_type
248
+
249
+ class PlainError(Exception):
250
+ pass
251
+
252
+ assert _error_type(PlainError("something completely different")) == ""
253
+
254
+
255
+ def test_create_message_convenience_wrapper_success():
256
+ from server.tools.router import create_message
257
+
258
+ response = object()
259
+ primary = FakeClient(result=response)
260
+ request = {"model": "claude-sonnet-4-6", "max_tokens": 128, "messages": []}
261
+
262
+ assert create_message(primary, **request) is response
263
+ assert primary.messages.calls == [request]