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.
- package/.aws-setup.sh +25 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.mcp.json +12 -0
- package/AGENTS.md +93 -0
- package/CLAUDE.md +99 -0
- package/CLAUDE_PLATFORM_AWS_SETUP.md +105 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/assets/tylor_logo.png +0 -0
- package/assets/tylor_threads_concept.png +0 -0
- package/bin/tylor.js +23 -0
- package/hooks/kill-thread-trigger.sh +7 -0
- package/hooks/post-tool-use-code-index.sh +7 -0
- package/hooks/session-checkpoint.sh +7 -0
- package/hooks/session-start.sh +7 -0
- package/install.py +401 -0
- package/install.sh +260 -0
- package/package.json +24 -0
- package/pytest.ini +2 -0
- package/registry.json +26 -0
- package/server/.env.example +24 -0
- package/server/__init__.py +0 -0
- package/server/config.py +89 -0
- package/server/main.py +93 -0
- package/server/personas/analyst.md +15 -0
- package/server/personas/ceo.md +14 -0
- package/server/personas/code_agent.md +15 -0
- package/server/personas/cto.md +14 -0
- package/server/provision.py +260 -0
- package/server/provision_opensearch.py +154 -0
- package/server/requirements.txt +26 -0
- package/server/storage/__init__.py +0 -0
- package/server/storage/dynamo.py +399 -0
- package/server/storage/json_store.py +359 -0
- package/server/storage/opensearch.py +194 -0
- package/server/storage/s3.py +96 -0
- package/server/storage/tests/__init__.py +0 -0
- package/server/storage/tests/test_dynamo.py +452 -0
- package/server/storage/tests/test_json_store.py +226 -0
- package/server/storage/tests/test_opensearch.py +270 -0
- package/server/storage/tests/test_s3.py +125 -0
- package/server/tests/__init__.py +0 -0
- package/server/tests/test_install.py +606 -0
- package/server/tests/test_isolation.py +90 -0
- package/server/tests/test_ui_server.py +385 -0
- package/server/tests/test_ui_shader_background.py +52 -0
- package/server/tests/test_ui_story_6_3.py +105 -0
- package/server/tools/__init__.py +0 -0
- package/server/tools/_mcp.py +4 -0
- package/server/tools/agents.py +160 -0
- package/server/tools/ecc/__init__.py +1 -0
- package/server/tools/ecc/data.py +35 -0
- package/server/tools/ecc/diagrams.py +23 -0
- package/server/tools/ecc/pipeline.py +24 -0
- package/server/tools/ecc/presentation.py +24 -0
- package/server/tools/ecc/web.py +23 -0
- package/server/tools/executor.py +880 -0
- package/server/tools/harness.py +330 -0
- package/server/tools/help.py +162 -0
- package/server/tools/hooks.py +357 -0
- package/server/tools/personas.py +110 -0
- package/server/tools/registry.py +195 -0
- package/server/tools/router.py +117 -0
- package/server/tools/skill_installer.py +230 -0
- package/server/tools/summarizer.py +168 -0
- package/server/tools/tests/__init__.py +0 -0
- package/server/tools/tests/test_agents.py +246 -0
- package/server/tools/tests/test_code_index.py +108 -0
- package/server/tools/tests/test_ecc_tools.py +51 -0
- package/server/tools/tests/test_executor.py +584 -0
- package/server/tools/tests/test_help_agent101.py +149 -0
- package/server/tools/tests/test_hooks.py +124 -0
- package/server/tools/tests/test_kill_thread.py +125 -0
- package/server/tools/tests/test_new_thread_list_threads.py +293 -0
- package/server/tools/tests/test_personas.py +52 -0
- package/server/tools/tests/test_recall_memory.py +55 -0
- package/server/tools/tests/test_registry_client.py +308 -0
- package/server/tools/tests/test_router.py +263 -0
- package/server/tools/tests/test_skill_installer.py +174 -0
- package/server/tools/tests/test_switch_thread.py +163 -0
- package/server/tools/tests/test_thread_command_skills.py +54 -0
- package/server/tools/tests/test_thread_resolver.py +165 -0
- package/server/tools/tests/test_tier1_schema.py +296 -0
- package/server/tools/thread_resolver.py +75 -0
- package/server/tools/tylor.py +374 -0
- package/server/tools/ui.py +38 -0
- package/server/ui_server.py +292 -0
- package/server/validate.py +237 -0
- package/skills/add-skill/SKILL.md +37 -0
- package/skills/afk-status/SKILL.md +20 -0
- package/skills/bmad/SKILL.md +14 -0
- package/skills/help-agent101/SKILL.md +48 -0
- package/skills/kill-thread/SKILL.md +35 -0
- package/skills/list-threads/SKILL.md +35 -0
- package/skills/new-thread/SKILL.md +35 -0
- package/skills/recall/SKILL.md +39 -0
- package/skills/run/SKILL.md +33 -0
- package/skills/set-sandbox/SKILL.md +38 -0
- package/skills/switch-thread/SKILL.md +38 -0
- package/ui/claude-logo.png +0 -0
- package/ui/index.html +1314 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""
|
|
2
|
+
server/tools/tylor.py — Tier 1 thread lifecycle MCP tools.
|
|
3
|
+
FR1-FR8: new_thread, switch_thread, kill_thread, recall_memory, list_threads.
|
|
4
|
+
|
|
5
|
+
Stories 2.3–2.5 implement storage. switch_thread (2.4), kill_thread (2.7),
|
|
6
|
+
recall_memory (2.5) remain stubs until their stories are implemented.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import asyncio
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
|
|
15
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
16
|
+
|
|
17
|
+
from ._mcp import mcp
|
|
18
|
+
from .summarizer import summarize_thread
|
|
19
|
+
from .thread_resolver import resolve_thread_name
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Broadcast helper
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
_broadcast_tasks: set = set() # holds task refs to prevent GC before completion
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _broadcast_thread_update() -> None:
|
|
30
|
+
"""Fire-and-forget WebSocket broadcast after any thread state change."""
|
|
31
|
+
try:
|
|
32
|
+
from ..ui_server import ws_manager, thread_update_payload, ui_available
|
|
33
|
+
if not ui_available or ws_manager.count == 0:
|
|
34
|
+
return
|
|
35
|
+
loop = asyncio.get_running_loop()
|
|
36
|
+
task = loop.create_task(_do_broadcast())
|
|
37
|
+
_broadcast_tasks.add(task)
|
|
38
|
+
task.add_done_callback(_broadcast_tasks.discard)
|
|
39
|
+
except RuntimeError:
|
|
40
|
+
pass # no running loop — broadcast not possible from this context
|
|
41
|
+
except Exception:
|
|
42
|
+
pass # broadcast is best-effort; never break MCP tool execution
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _do_broadcast() -> None:
|
|
46
|
+
from ..ui_server import ws_manager, thread_update_payload
|
|
47
|
+
payload = await thread_update_payload()
|
|
48
|
+
await ws_manager.broadcast(payload)
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Constants
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
NAME_MIN = 3
|
|
55
|
+
NAME_MAX = 64
|
|
56
|
+
_NAME_RE = re.compile(r"^[a-zA-Z0-9 _-]+$")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Helpers
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def _now_iso() -> str:
|
|
64
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _validate_name(name: str) -> None:
|
|
68
|
+
"""Raise ToolError if name fails length / character / whitespace rules."""
|
|
69
|
+
if not (NAME_MIN <= len(name) <= NAME_MAX):
|
|
70
|
+
raise ToolError("Thread name must be 3–64 characters")
|
|
71
|
+
if not name.strip():
|
|
72
|
+
raise ToolError("Thread name contains invalid characters")
|
|
73
|
+
if not _NAME_RE.match(name):
|
|
74
|
+
raise ToolError("Thread name contains invalid characters")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _get_db():
|
|
78
|
+
"""
|
|
79
|
+
Return the configured storage backend.
|
|
80
|
+
|
|
81
|
+
Default: JsonStore (local ~/.tylor/threads.json, zero setup).
|
|
82
|
+
Optional: DynamoClient when AWS credentials + DYNAMO_TABLE are configured.
|
|
83
|
+
"""
|
|
84
|
+
from server.config import config
|
|
85
|
+
|
|
86
|
+
# Use DynamoDB ONLY when user has explicitly set DYNAMO_TABLE env var
|
|
87
|
+
# (not just because AWS credentials happen to exist on the machine)
|
|
88
|
+
import os
|
|
89
|
+
use_dynamo = bool(os.environ.get("DYNAMO_TABLE") or os.environ.get("TYLOR_USE_DYNAMO"))
|
|
90
|
+
|
|
91
|
+
if use_dynamo:
|
|
92
|
+
from server.storage.dynamo import DynamoClient
|
|
93
|
+
return DynamoClient(
|
|
94
|
+
table_name=os.environ.get("DYNAMO_TABLE", "agent101"),
|
|
95
|
+
user_id=config.get("user_id", "default"),
|
|
96
|
+
profile=config.get("aws_profile"),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Default: local JSON storage — no AWS required
|
|
100
|
+
from pathlib import Path
|
|
101
|
+
from server.storage.json_store import JsonStore
|
|
102
|
+
store_path = Path(config.get("storage_path") or Path.home() / ".tylor" / "threads.json")
|
|
103
|
+
return JsonStore(path=store_path)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _get_memory_client():
|
|
107
|
+
"""Return an OpenSearchClient configured from server.config."""
|
|
108
|
+
from server.config import config
|
|
109
|
+
from server.storage.opensearch import OpenSearchClient
|
|
110
|
+
|
|
111
|
+
host = config.get("opensearch_host")
|
|
112
|
+
if not host:
|
|
113
|
+
raise ToolError("OPENSEARCH_HOST not configured")
|
|
114
|
+
|
|
115
|
+
port = int(config.get("opensearch_port", "9200"))
|
|
116
|
+
return OpenSearchClient(
|
|
117
|
+
host=host,
|
|
118
|
+
port=port,
|
|
119
|
+
bedrock_region=config.get("bedrock_region", "us-east-1"),
|
|
120
|
+
profile=config.get("aws_profile"),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _code_index_header_for_thread(thread_id: str, thread_name: str = "") -> str:
|
|
125
|
+
try:
|
|
126
|
+
from server.tools.hooks import build_code_index_header
|
|
127
|
+
|
|
128
|
+
return build_code_index_header(thread_id, thread_name)
|
|
129
|
+
except Exception:
|
|
130
|
+
return ""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# Tools
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
@mcp.tool()
|
|
138
|
+
def new_thread(name: str) -> dict:
|
|
139
|
+
"""
|
|
140
|
+
Create a new named thread.
|
|
141
|
+
Returns the new thread's ID and metadata.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
name: Human-readable name for the thread (e.g. "project-alpha", "weekly-review").
|
|
145
|
+
Must be 3–64 characters; alphanumeric, spaces, hyphens, underscores only.
|
|
146
|
+
"""
|
|
147
|
+
_validate_name(name)
|
|
148
|
+
|
|
149
|
+
db = _get_db()
|
|
150
|
+
|
|
151
|
+
# Uniqueness check — query all META items before writing
|
|
152
|
+
existing = db.query_all("THREAD#")
|
|
153
|
+
for item in existing:
|
|
154
|
+
if item.get("SK", "").endswith("#META") and item.get("Name") == name:
|
|
155
|
+
raise ToolError("Thread name already exists")
|
|
156
|
+
|
|
157
|
+
thread_id = uuid.uuid4().hex
|
|
158
|
+
sk = f"THREAD#{thread_id}#META"
|
|
159
|
+
now = _now_iso()
|
|
160
|
+
|
|
161
|
+
# Capture current project folder name from Claude Code's PWD env var
|
|
162
|
+
try:
|
|
163
|
+
from pathlib import Path as _Path
|
|
164
|
+
pwd = os.environ.get("PWD") or os.environ.get("CLAUDE_PROJECT_DIR", "")
|
|
165
|
+
project_name = _Path(pwd).name if pwd else ""
|
|
166
|
+
except Exception:
|
|
167
|
+
project_name = ""
|
|
168
|
+
|
|
169
|
+
written = db.put_item(sk, {
|
|
170
|
+
"Name": name,
|
|
171
|
+
"Status": "active",
|
|
172
|
+
"LastActivity": now,
|
|
173
|
+
"MessageCount": 0,
|
|
174
|
+
"Project": project_name,
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
result = {
|
|
178
|
+
"thread_id": thread_id,
|
|
179
|
+
"name": name,
|
|
180
|
+
"created_at": written["CreatedAt"],
|
|
181
|
+
}
|
|
182
|
+
_broadcast_thread_update()
|
|
183
|
+
|
|
184
|
+
# Auto-open Thread Visualizer on first thread creation if not already open
|
|
185
|
+
try:
|
|
186
|
+
from .ui import _open_ui_browser
|
|
187
|
+
_open_ui_browser()
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@mcp.tool()
|
|
195
|
+
def switch_thread(thread_id: str) -> dict:
|
|
196
|
+
"""
|
|
197
|
+
Atomically switch to an existing thread, making it the active context.
|
|
198
|
+
Uses DynamoDB TransactWriteItems — partial writes fail explicitly.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
thread_id: The unique ID of the thread to switch to.
|
|
202
|
+
|
|
203
|
+
Returns a dict. If the field ``code_index_header`` is present, prepend its
|
|
204
|
+
value verbatim to your context before continuing — it is the compact code
|
|
205
|
+
index for the thread you just switched into.
|
|
206
|
+
"""
|
|
207
|
+
db = _get_db()
|
|
208
|
+
try:
|
|
209
|
+
result = db.switch_thread(thread_id)
|
|
210
|
+
except ToolError:
|
|
211
|
+
raise
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
raise ToolError(
|
|
214
|
+
f"SwThread failed — both threads unchanged: {exc}"
|
|
215
|
+
) from exc
|
|
216
|
+
meta = db.get_thread_meta(thread_id) or {}
|
|
217
|
+
header = _code_index_header_for_thread(thread_id, meta.get("Name", result.get("name", "")))
|
|
218
|
+
if header:
|
|
219
|
+
result = {**result, "code_index_header": header}
|
|
220
|
+
_broadcast_thread_update()
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@mcp.tool()
|
|
225
|
+
def switch_thread_by_name(query: str) -> dict:
|
|
226
|
+
"""
|
|
227
|
+
Resolve a fuzzy thread-name query and switch to the matched thread.
|
|
228
|
+
Ambiguous matches are returned for Claude to present to the user.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
query: Partial or approximate thread name.
|
|
232
|
+
"""
|
|
233
|
+
db = _get_db()
|
|
234
|
+
all_items = db.query_all("THREAD#")
|
|
235
|
+
meta_items = [i for i in all_items if i.get("SK", "").endswith("#META")]
|
|
236
|
+
threads = []
|
|
237
|
+
for item in meta_items:
|
|
238
|
+
sk = item.get("SK", "")
|
|
239
|
+
threads.append({
|
|
240
|
+
"thread_id": sk.removeprefix("THREAD#").removesuffix("#META"),
|
|
241
|
+
"name": item.get("Name", ""),
|
|
242
|
+
"status": item.get("Status", "active"),
|
|
243
|
+
"last_activity": item.get("LastActivity", item.get("UpdatedAt", "")),
|
|
244
|
+
"message_count": int(item.get("MessageCount", 0)),
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
resolved = resolve_thread_name(query, threads)
|
|
249
|
+
except ToolError:
|
|
250
|
+
raise
|
|
251
|
+
except Exception as exc:
|
|
252
|
+
# Let McpError propagate with its original error code intact
|
|
253
|
+
raise
|
|
254
|
+
if resolved["status"] == "ambiguous":
|
|
255
|
+
return resolved
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
switched = db.switch_thread(resolved["thread_id"])
|
|
259
|
+
except ToolError:
|
|
260
|
+
raise
|
|
261
|
+
except Exception as exc:
|
|
262
|
+
raise ToolError(
|
|
263
|
+
f"SwThread failed — both threads unchanged: {exc}"
|
|
264
|
+
) from exc
|
|
265
|
+
header = _code_index_header_for_thread(resolved["thread_id"], resolved.get("name", ""))
|
|
266
|
+
return {
|
|
267
|
+
**switched,
|
|
268
|
+
"name": resolved["name"],
|
|
269
|
+
"message": resolved["message"],
|
|
270
|
+
**({"code_index_header": header} if header else {}),
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@mcp.tool()
|
|
275
|
+
def kill_thread(thread_id: str) -> dict:
|
|
276
|
+
"""
|
|
277
|
+
Close a thread and trigger async Bedrock Opus summarization.
|
|
278
|
+
Returns immediately; summarization is dispatched via the PostToolUse hook.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
thread_id: The unique ID of the thread to kill.
|
|
282
|
+
"""
|
|
283
|
+
db = _get_db()
|
|
284
|
+
if not db.get_thread_meta(thread_id):
|
|
285
|
+
raise ToolError(f"Thread not found: {thread_id}")
|
|
286
|
+
|
|
287
|
+
# Mark as killed in storage so the UI reflects it immediately.
|
|
288
|
+
# Fetch existing meta first so put_item (full-doc replace on DynamoDB)
|
|
289
|
+
# doesn't wipe Name, MessageCount, Project, CreatedAt, etc.
|
|
290
|
+
sk = f"THREAD#{thread_id}#META"
|
|
291
|
+
meta = db.get_thread_meta(thread_id) or {}
|
|
292
|
+
db.put_item(sk, {**meta, "Status": "killed", "LastActivity": _now_iso()})
|
|
293
|
+
|
|
294
|
+
# Summarization is dispatched by the kill-thread-trigger PostToolUse hook
|
|
295
|
+
# (hooks/kill-thread-trigger.sh → hooks.dispatch_kill_thread_summary).
|
|
296
|
+
# Do NOT also create_task here — that would cause double-summarization.
|
|
297
|
+
_broadcast_thread_update()
|
|
298
|
+
return {
|
|
299
|
+
"status": "killing",
|
|
300
|
+
"thread_id": thread_id,
|
|
301
|
+
"message": "Summarization in progress",
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@mcp.tool()
|
|
306
|
+
def save_memory(thread_id: str, fact: str, fact_type: str | None = None) -> dict:
|
|
307
|
+
"""
|
|
308
|
+
Save a memory fact for a thread, optionally tagged by category.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
thread_id: Thread this fact belongs to.
|
|
312
|
+
fact: Compact fact to persist.
|
|
313
|
+
fact_type: Optional category such as "code_index".
|
|
314
|
+
"""
|
|
315
|
+
if not fact or not fact.strip():
|
|
316
|
+
raise ToolError("Fact must not be empty")
|
|
317
|
+
metadata = {"type": fact_type} if fact_type else None
|
|
318
|
+
memory_id = _get_memory_client().index_memory(
|
|
319
|
+
thread_id=thread_id,
|
|
320
|
+
fact=fact,
|
|
321
|
+
metadata=metadata,
|
|
322
|
+
)
|
|
323
|
+
return {"status": "saved", "thread_id": thread_id, "memory_id": memory_id, "type": fact_type}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@mcp.tool()
|
|
327
|
+
def recall_memory(thread_id: str, query: str, top_k: int = 5, fact_type: str | None = None) -> dict:
|
|
328
|
+
"""
|
|
329
|
+
Semantically search thread memory using OpenSearch vector similarity.
|
|
330
|
+
Returns the top-k most relevant memory facts for the given query.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
thread_id: The thread to search within.
|
|
334
|
+
query: Natural-language search query.
|
|
335
|
+
top_k: Maximum number of results to return (default: 5).
|
|
336
|
+
fact_type: Optional category filter, e.g. "code_index" to retrieve only
|
|
337
|
+
structural code facts instead of conversation memory.
|
|
338
|
+
"""
|
|
339
|
+
if not query or not query.strip():
|
|
340
|
+
raise ToolError("Query must not be empty")
|
|
341
|
+
if top_k <= 0:
|
|
342
|
+
raise ToolError("top_k must be a positive integer")
|
|
343
|
+
|
|
344
|
+
client = _get_memory_client()
|
|
345
|
+
results = client.search_memory(thread_id=thread_id, query=query, k=top_k, type=fact_type)
|
|
346
|
+
return {"results": results}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@mcp.tool()
|
|
350
|
+
def list_threads() -> dict:
|
|
351
|
+
"""
|
|
352
|
+
List all threads with their status, name, and last-activity timestamp.
|
|
353
|
+
Returns threads sorted by last activity descending.
|
|
354
|
+
"""
|
|
355
|
+
db = _get_db()
|
|
356
|
+
|
|
357
|
+
all_items = db.query_all("THREAD#")
|
|
358
|
+
meta_items = [i for i in all_items if i.get("SK", "").endswith("#META")]
|
|
359
|
+
|
|
360
|
+
threads = []
|
|
361
|
+
for item in meta_items:
|
|
362
|
+
sk = item.get("SK", "")
|
|
363
|
+
thread_id = sk.removeprefix("THREAD#").removesuffix("#META")
|
|
364
|
+
threads.append({
|
|
365
|
+
"thread_id": thread_id,
|
|
366
|
+
"name": item.get("Name", ""),
|
|
367
|
+
"status": item.get("Status", "active").lower(),
|
|
368
|
+
"last_activity": item.get("LastActivity", item.get("UpdatedAt", "")),
|
|
369
|
+
"message_count": int(item.get("MessageCount", 0)),
|
|
370
|
+
"project": item.get("Project", ""),
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
threads.sort(key=lambda t: t["last_activity"], reverse=True)
|
|
374
|
+
return {"threads": threads}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
server/tools/ui.py — MCP tool for opening the Thread Visualizer UI.
|
|
3
|
+
FR43: /open-threads-ui skill calls this tool to open localhost:8765.
|
|
4
|
+
"""
|
|
5
|
+
import webbrowser
|
|
6
|
+
|
|
7
|
+
from ._mcp import mcp
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _ui_url() -> str:
|
|
11
|
+
from ..ui_server import PORT
|
|
12
|
+
return f"http://localhost:{PORT}"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _open_ui_browser() -> str:
|
|
16
|
+
"""
|
|
17
|
+
Open the Thread Visualizer in the default browser.
|
|
18
|
+
Always opens if ui_available; returns a status string.
|
|
19
|
+
"""
|
|
20
|
+
from ..ui_server import ui_available, PORT, PORT_RANGE
|
|
21
|
+
url = f"http://localhost:{PORT}"
|
|
22
|
+
if not ui_available:
|
|
23
|
+
return (
|
|
24
|
+
f"Thread Visualizer could not start — all ports "
|
|
25
|
+
f"{PORT_RANGE[0]}–{PORT_RANGE[1]-1} were in use. "
|
|
26
|
+
"MCP tools are unaffected. Free a port and restart the plugin."
|
|
27
|
+
)
|
|
28
|
+
webbrowser.open(url)
|
|
29
|
+
return f"Thread Visualizer open at {url}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@mcp.tool()
|
|
33
|
+
def open_threads_ui() -> str:
|
|
34
|
+
"""
|
|
35
|
+
Open the TYLOR Thread Visualizer in the system default browser.
|
|
36
|
+
Returns a confirmation message, or a warning if the UI server is unavailable.
|
|
37
|
+
"""
|
|
38
|
+
return _open_ui_browser()
|