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,296 @@
1
+ """
2
+ Tests for Story 1.4: FastMCP Server Skeleton with Tier 1 Tools
3
+ Run: pytest server/tools/tests/test_tier1_schema.py -v
4
+ """
5
+ import importlib
6
+ import inspect
7
+ import sys
8
+ import logging
9
+ import asyncio
10
+ from pathlib import Path
11
+ import os
12
+
13
+ import pytest
14
+
15
+ # Ensure project root is on path
16
+ PLUGIN_DIR = Path(__file__).parent.parent.parent.parent
17
+ sys.path.insert(0, str(PLUGIN_DIR))
18
+
19
+ TIER_1_TOOLS = [
20
+ "new_thread",
21
+ "switch_thread",
22
+ "switch_thread_by_name",
23
+ "kill_thread",
24
+ "save_memory",
25
+ "recall_memory",
26
+ "list_threads",
27
+ "list_personas",
28
+ "spawn_agent",
29
+ "load_skill_tools",
30
+ "list_registry",
31
+ "help_agent101",
32
+ "add_skill",
33
+ "set_sandbox",
34
+ "execute_in_sandbox",
35
+ "execute_with_recovery",
36
+ "start_afk",
37
+ "afk_status",
38
+ "pause_afk",
39
+ ]
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # AC1: Server imports without error
44
+ # ---------------------------------------------------------------------------
45
+
46
+ def test_main_module_imports_cleanly():
47
+ """server.main must be importable without side effects or crashes."""
48
+ if "server.main" in sys.modules:
49
+ del sys.modules["server.main"]
50
+ mod = importlib.import_module("server.main")
51
+ assert mod is not None
52
+
53
+
54
+ def test_mcp_singleton_name():
55
+ """The FastMCP instance must be named 'agent101'."""
56
+ from server.tools._mcp import mcp
57
+ assert mcp.name == "agent101"
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # AC2: All 8 Tier 1 tools are registered
62
+ # ---------------------------------------------------------------------------
63
+
64
+ def test_all_tier1_tools_registered():
65
+ """All Tier 1 tools must be registered on the mcp instance."""
66
+ import asyncio
67
+ # Ensure startup registration imports every Tier 1 tool module.
68
+ import server.main
69
+ from server.tools._mcp import mcp
70
+
71
+ tools = asyncio.run(mcp.list_tools())
72
+ registered = {t.name for t in tools}
73
+
74
+ missing = [t for t in TIER_1_TOOLS if t not in registered]
75
+ assert not missing, f"Missing Tier 1 tools: {missing}"
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # AC2: Tool parameter signatures are correct
80
+ # ---------------------------------------------------------------------------
81
+
82
+ def test_new_thread_signature():
83
+ from server.tools.tylor import new_thread
84
+ sig = inspect.signature(new_thread)
85
+ assert "name" in sig.parameters
86
+ assert sig.parameters["name"].annotation in (str, "str")
87
+
88
+
89
+ def test_switch_thread_signature():
90
+ from server.tools.tylor import switch_thread
91
+ sig = inspect.signature(switch_thread)
92
+ assert "thread_id" in sig.parameters
93
+ assert sig.parameters["thread_id"].annotation in (str, "str")
94
+
95
+
96
+ def test_switch_thread_by_name_signature():
97
+ from server.tools.tylor import switch_thread_by_name
98
+ sig = inspect.signature(switch_thread_by_name)
99
+ assert "query" in sig.parameters
100
+ assert sig.parameters["query"].annotation in (str, "str")
101
+
102
+
103
+ def test_kill_thread_is_sync():
104
+ # kill_thread is now sync — summarization dispatched by PostToolUse hook
105
+ from server.tools.tylor import kill_thread
106
+ assert not inspect.iscoroutinefunction(kill_thread)
107
+
108
+
109
+ def test_recall_memory_signature():
110
+ from server.tools.tylor import recall_memory
111
+ sig = inspect.signature(recall_memory)
112
+ assert "thread_id" in sig.parameters
113
+ assert "query" in sig.parameters
114
+ assert "top_k" in sig.parameters
115
+ assert "fact_type" in sig.parameters # renamed from `type` to avoid shadowing builtin
116
+ # top_k has default of 5
117
+ assert sig.parameters["top_k"].default == 5
118
+
119
+
120
+ def test_save_memory_signature():
121
+ from server.tools.tylor import save_memory
122
+ sig = inspect.signature(save_memory)
123
+ assert "thread_id" in sig.parameters
124
+ assert "fact" in sig.parameters
125
+ assert "fact_type" in sig.parameters # renamed from `type` to avoid shadowing builtin
126
+
127
+
128
+ def test_spawn_agent_signature():
129
+ from server.tools.agents import spawn_agent
130
+ sig = inspect.signature(spawn_agent)
131
+ assert "thread_id" in sig.parameters
132
+ assert "persona" in sig.parameters
133
+ assert "task" in sig.parameters
134
+
135
+
136
+ def test_list_personas_signature():
137
+ from server.tools.agents import list_personas
138
+ sig = inspect.signature(list_personas)
139
+ assert not sig.parameters
140
+
141
+
142
+ def test_load_skill_tools_signature():
143
+ from server.tools.registry import load_skill_tools
144
+ sig = inspect.signature(load_skill_tools)
145
+ assert "tool_group" in sig.parameters
146
+
147
+
148
+ def test_add_skill_signature():
149
+ from server.tools.skill_installer import add_skill
150
+ sig = inspect.signature(add_skill)
151
+ assert "source_path" in sig.parameters
152
+ assert "overwrite" in sig.parameters
153
+
154
+
155
+ def test_set_sandbox_signature():
156
+ from server.tools.executor import set_sandbox
157
+ sig = inspect.signature(set_sandbox)
158
+ assert "path" in sig.parameters
159
+ assert "thread_id" in sig.parameters
160
+ assert sig.parameters["thread_id"].default is None
161
+
162
+
163
+ def test_execute_in_sandbox_signature():
164
+ from server.tools.executor import execute_in_sandbox
165
+ sig = inspect.signature(execute_in_sandbox)
166
+ assert "command" in sig.parameters
167
+ assert "thread_id" in sig.parameters
168
+ assert "cwd" in sig.parameters
169
+ assert sig.parameters["timeout_seconds"].default == 120
170
+
171
+
172
+ def test_execute_with_recovery_signature():
173
+ from server.tools.executor import execute_with_recovery
174
+ sig = inspect.signature(execute_with_recovery)
175
+ assert "command" in sig.parameters
176
+ assert "thread_id" in sig.parameters
177
+ assert "cwd" in sig.parameters
178
+ assert "timeout_seconds" in sig.parameters
179
+ assert "recovery_attempts_used" in sig.parameters
180
+
181
+
182
+ def test_afk_tool_signatures():
183
+ from server.tools.executor import afk_status, pause_afk, start_afk
184
+
185
+ start_sig = inspect.signature(start_afk)
186
+ assert "task" in start_sig.parameters
187
+ assert "steps" in start_sig.parameters
188
+ assert "thread_id" in start_sig.parameters
189
+ assert "cwd" in start_sig.parameters
190
+ assert "background" in start_sig.parameters
191
+
192
+ status_sig = inspect.signature(afk_status)
193
+ assert "thread_id" in status_sig.parameters
194
+
195
+ pause_sig = inspect.signature(pause_afk)
196
+ assert "thread_id" in pause_sig.parameters
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # AC2: Tools raise McpError (not crash) when called without storage
201
+ # ---------------------------------------------------------------------------
202
+
203
+ def test_kill_thread_raises_tool_error():
204
+ import asyncio
205
+ from unittest.mock import MagicMock, patch
206
+ from mcp.server.fastmcp.exceptions import ToolError
207
+ from server.tools.tylor import kill_thread
208
+ mock_db = MagicMock()
209
+ mock_db.get_thread_meta.return_value = None
210
+ with pytest.raises(ToolError):
211
+ with patch("server.tools.tylor._get_db", return_value=mock_db):
212
+ asyncio.run(kill_thread(thread_id="t123"))
213
+
214
+
215
+ def test_new_thread_validates_short_name():
216
+ # Story 2.3: new_thread is implemented — validates input before any DB write
217
+ from mcp.server.fastmcp.exceptions import ToolError
218
+ from server.tools.tylor import new_thread
219
+ with pytest.raises(ToolError, match="3\u201364 characters"):
220
+ new_thread(name="ab") # too short
221
+
222
+
223
+ def test_list_threads_returns_dict():
224
+ # Story 2.3: list_threads is implemented — returns {threads: []} when DB is mocked empty
225
+ import server.tools.tylor as tylor_mod
226
+ from unittest.mock import MagicMock, patch
227
+ mock_db = MagicMock()
228
+ mock_db.query_all.return_value = []
229
+ with patch.object(tylor_mod, "_get_db", return_value=mock_db):
230
+ from server.tools.tylor import list_threads
231
+ result = list_threads()
232
+ assert "threads" in result
233
+
234
+
235
+ def test_list_registry_returns_dict():
236
+ from server.tools.registry import list_registry
237
+ assert "skills" in list_registry()
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # AC3: config.py warns on missing optional keys without crashing
242
+ # ---------------------------------------------------------------------------
243
+
244
+ def test_config_loads_without_crash():
245
+ """config.py must be importable without crashing regardless of env state."""
246
+ if "server.config" in sys.modules:
247
+ del sys.modules["server.config"]
248
+ mod = importlib.import_module("server.config")
249
+ assert hasattr(mod, "config")
250
+ assert isinstance(mod.config, dict)
251
+
252
+
253
+ def test_config_warns_on_missing_platform_key(caplog):
254
+ """Missing Platform on AWS API key must emit a warning."""
255
+ env_backup = os.environ.pop("ANTHROPIC_PLATFORM_AWS_API_KEY", None)
256
+ aws_env_backup = os.environ.pop("ANTHROPIC_AWS_API_KEY", None)
257
+ try:
258
+ os.environ["ANTHROPIC_PLATFORM_AWS_API_KEY"] = ""
259
+ os.environ["ANTHROPIC_AWS_API_KEY"] = ""
260
+ if "server.config" in sys.modules:
261
+ del sys.modules["server.config"]
262
+ with caplog.at_level(logging.WARNING, logger="server.config"):
263
+ importlib.import_module("server.config")
264
+ assert any("ANTHROPIC_PLATFORM_AWS_API_KEY" in r.message for r in caplog.records)
265
+ finally:
266
+ os.environ.pop("ANTHROPIC_PLATFORM_AWS_API_KEY", None)
267
+ os.environ.pop("ANTHROPIC_AWS_API_KEY", None)
268
+ if env_backup is not None:
269
+ os.environ["ANTHROPIC_PLATFORM_AWS_API_KEY"] = env_backup
270
+ if aws_env_backup is not None:
271
+ os.environ["ANTHROPIC_AWS_API_KEY"] = aws_env_backup
272
+
273
+
274
+ def test_config_bedrock_region_defaults_to_us_east_1():
275
+ """Bedrock region must default to us-east-1 when not set."""
276
+ env_backup = os.environ.pop("BEDROCK_REGION", None)
277
+ try:
278
+ if "server.config" in sys.modules:
279
+ del sys.modules["server.config"]
280
+ mod = importlib.import_module("server.config")
281
+ assert mod.config["bedrock_region"] == "us-east-1"
282
+ finally:
283
+ if env_backup is not None:
284
+ os.environ["BEDROCK_REGION"] = env_backup
285
+
286
+
287
+ def test_config_reads_aws_profile_from_env():
288
+ """AWS_PROFILE env var must be reflected in config."""
289
+ os.environ["AWS_PROFILE"] = "agent101-test"
290
+ try:
291
+ if "server.config" in sys.modules:
292
+ del sys.modules["server.config"]
293
+ mod = importlib.import_module("server.config")
294
+ assert mod.config["aws_profile"] == "agent101-test"
295
+ finally:
296
+ del os.environ["AWS_PROFILE"]
@@ -0,0 +1,75 @@
1
+ """
2
+ server/tools/thread_resolver.py — fuzzy thread-name resolution.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ from mcp.shared.exceptions import McpError
9
+ from mcp.types import ErrorData, INVALID_REQUEST
10
+ from rapidfuzz import process
11
+
12
+ SCORE_CUTOFF = 70
13
+
14
+
15
+ def _invalid_request(message: str) -> McpError:
16
+ return McpError(ErrorData(code=INVALID_REQUEST, message=message))
17
+
18
+
19
+ def _thread_choices(threads: list[dict]) -> dict[str, dict]:
20
+ return {
21
+ thread.get("name", ""): thread
22
+ for thread in threads
23
+ if thread.get("name") and thread.get("thread_id")
24
+ }
25
+
26
+
27
+ def _format_ambiguous(matches: list[dict]) -> str:
28
+ choices = ", ".join(
29
+ f"[{idx}] {match['name']}"
30
+ for idx, match in enumerate(matches, start=1)
31
+ )
32
+ return f"Did you mean: {choices}?"
33
+
34
+
35
+ def resolve_thread_name(
36
+ query: str,
37
+ threads: list[dict[str, Any]],
38
+ score_cutoff: int = SCORE_CUTOFF,
39
+ ) -> dict:
40
+ """Resolve a fuzzy thread-name query to one thread or ambiguous choices."""
41
+ cleaned = query.strip() if query else ""
42
+ if not cleaned:
43
+ raise _invalid_request("No thread found matching '' - run list_threads to see available threads")
44
+
45
+ choices = _thread_choices(threads)
46
+ all_matches = process.extract(
47
+ cleaned,
48
+ choices.keys(),
49
+ score_cutoff=score_cutoff,
50
+ limit=None,
51
+ )
52
+ if not all_matches:
53
+ raise _invalid_request(
54
+ f"No thread found matching '{cleaned}' - run list_threads to see available threads"
55
+ )
56
+
57
+ matches = [
58
+ {**choices[name], "score": score}
59
+ for name, score, _index in all_matches
60
+ ]
61
+
62
+ if len(matches) > 1:
63
+ return {
64
+ "status": "ambiguous",
65
+ "matches": matches,
66
+ "message": _format_ambiguous(matches),
67
+ }
68
+
69
+ thread = choices[all_matches[0][0]]
70
+ return {
71
+ "status": "resolved",
72
+ "thread_id": thread["thread_id"],
73
+ "name": thread["name"],
74
+ "message": f"Switching to thread: {thread['name']}",
75
+ }