caudate-cli 0.1.0__py3-none-any.whl

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 (153) hide show
  1. api/__init__.py +5 -0
  2. api/anthropic_compat.py +1518 -0
  3. api/artifact_viewer.py +366 -0
  4. api/caudate_middleware.py +618 -0
  5. api/forge_bootstrapper_routes.py +377 -0
  6. api/forge_routes.py +630 -0
  7. api/forge_system_routes.py +294 -0
  8. api/openai_compat.py +1993 -0
  9. api/server.py +667 -0
  10. api/storyboard_page.py +677 -0
  11. caudate_cli-0.1.0.dist-info/METADATA +354 -0
  12. caudate_cli-0.1.0.dist-info/RECORD +153 -0
  13. caudate_cli-0.1.0.dist-info/WHEEL +5 -0
  14. caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
  15. caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  16. caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
  17. cognos_mcp/__init__.py +4 -0
  18. cognos_mcp/bridge.py +41 -0
  19. cognos_mcp/client.py +70 -0
  20. cognos_mcp/config.py +49 -0
  21. cognos_mcp/server.py +66 -0
  22. config.py +82 -0
  23. core/__init__.py +0 -0
  24. core/agent.py +468 -0
  25. core/agentic_loop.py +731 -0
  26. core/anthropic_auth.py +91 -0
  27. core/background.py +113 -0
  28. core/banner.py +134 -0
  29. core/bootstrap.py +292 -0
  30. core/citations.py +131 -0
  31. core/compaction.py +109 -0
  32. core/constitution.py +198 -0
  33. core/diff_viewer.py +87 -0
  34. core/export.py +85 -0
  35. core/file_refs.py +119 -0
  36. core/files.py +199 -0
  37. core/hooks.py +209 -0
  38. core/image.py +599 -0
  39. core/input.py +91 -0
  40. core/loop.py +238 -0
  41. core/memory_md.py +147 -0
  42. core/notifications.py +99 -0
  43. core/ownership.py +181 -0
  44. core/paste.py +81 -0
  45. core/permissions.py +210 -0
  46. core/plan_mode.py +215 -0
  47. core/sandbox_prompt.py +185 -0
  48. core/scheduler.py +195 -0
  49. core/schemas.py +202 -0
  50. core/session.py +90 -0
  51. core/settings.py +132 -0
  52. core/skills.py +398 -0
  53. core/slash_commands.py +977 -0
  54. core/statusline.py +61 -0
  55. core/subagent.py +300 -0
  56. core/thinking.py +50 -0
  57. core/updater.py +122 -0
  58. core/usage.py +109 -0
  59. core/worktree.py +93 -0
  60. execution/__init__.py +0 -0
  61. execution/executor.py +329 -0
  62. execution/plugins.py +108 -0
  63. execution/tools/__init__.py +0 -0
  64. execution/tools/agent_tool.py +107 -0
  65. execution/tools/agentic_tool.py +297 -0
  66. execution/tools/artifact_tool.py +191 -0
  67. execution/tools/ask_user_question_tool.py +137 -0
  68. execution/tools/base.py +81 -0
  69. execution/tools/calculator_tool.py +137 -0
  70. execution/tools/cognos_card_tool.py +124 -0
  71. execution/tools/cron_tool.py +215 -0
  72. execution/tools/datetime_tool.py +215 -0
  73. execution/tools/describe_image_tool.py +161 -0
  74. execution/tools/draw_tool.py +164 -0
  75. execution/tools/edit_image_tool.py +262 -0
  76. execution/tools/edit_tool.py +245 -0
  77. execution/tools/file_tool.py +90 -0
  78. execution/tools/find_anywhere_tool.py +255 -0
  79. execution/tools/forge_feature_tools.py +377 -0
  80. execution/tools/glob_tool.py +59 -0
  81. execution/tools/grep_tool.py +89 -0
  82. execution/tools/http_request_tool.py +224 -0
  83. execution/tools/load_skill_tool.py +104 -0
  84. execution/tools/longcat_avatar_tool.py +384 -0
  85. execution/tools/mcp_tool.py +100 -0
  86. execution/tools/notebook_tool.py +279 -0
  87. execution/tools/openapi_tool.py +440 -0
  88. execution/tools/plan_mode_tool.py +95 -0
  89. execution/tools/push_notification_tool.py +157 -0
  90. execution/tools/python_tool.py +61 -0
  91. execution/tools/respond_tool.py +40 -0
  92. execution/tools/sandbox_tool.py +378 -0
  93. execution/tools/search_tool.py +153 -0
  94. execution/tools/semantic_search_tool.py +106 -0
  95. execution/tools/shell_tool.py +283 -0
  96. execution/tools/speak_tool.py +134 -0
  97. execution/tools/storyboard_tool.py +727 -0
  98. execution/tools/system_info_tool.py +212 -0
  99. execution/tools/task_tool.py +323 -0
  100. execution/tools/think_tool.py +49 -0
  101. execution/tools/transcribe_audio_tool.py +86 -0
  102. execution/tools/update_memory_tool.py +92 -0
  103. execution/tools/web_fetch_tool.py +82 -0
  104. execution/tools/worktree_tool.py +174 -0
  105. llm/__init__.py +0 -0
  106. llm/fallback.py +116 -0
  107. llm/models.py +320 -0
  108. llm/provider.py +1356 -0
  109. llm/router.py +373 -0
  110. main.py +1889 -0
  111. memory/__init__.py +0 -0
  112. memory/episodic.py +99 -0
  113. memory/procedural.py +145 -0
  114. memory/semantic.py +71 -0
  115. memory/working.py +64 -0
  116. nn/__init__.py +43 -0
  117. nn/auto_evolve.py +245 -0
  118. nn/caudate.py +136 -0
  119. nn/config.py +141 -0
  120. nn/consolidator.py +81 -0
  121. nn/data.py +1635 -0
  122. nn/encoder.py +258 -0
  123. nn/forge_advisor.py +303 -0
  124. nn/format.py +235 -0
  125. nn/heads.py +432 -0
  126. nn/observer.py +994 -0
  127. nn/policy.py +214 -0
  128. nn/runtime.py +343 -0
  129. nn/scorer.py +175 -0
  130. nn/trainer.py +515 -0
  131. nn/vision.py +352 -0
  132. personality/__init__.py +23 -0
  133. personality/engine.py +129 -0
  134. personality/identity.py +144 -0
  135. personality/inner_voice.py +100 -0
  136. personality/mood.py +205 -0
  137. planning/__init__.py +0 -0
  138. planning/dev_server.py +221 -0
  139. planning/forge_models.py +718 -0
  140. planning/orchestrator.py +1363 -0
  141. planning/planner.py +451 -0
  142. planning/task_graph.py +61 -0
  143. reflection/__init__.py +0 -0
  144. reflection/meta_learner.py +156 -0
  145. reflection/reflector.py +127 -0
  146. ui/__init__.py +5 -0
  147. ui/display.py +88 -0
  148. voice/__init__.py +0 -0
  149. voice/conversation.py +125 -0
  150. voice/listener.py +111 -0
  151. voice/speaker.py +59 -0
  152. voice/stt.py +126 -0
  153. voice/tts.py +214 -0
@@ -0,0 +1,384 @@
1
+ """LongCat Avatar tool — animate a portrait with speech audio.
2
+
3
+ The tool drives a local ComfyUI server running the WAN-S2V (Speech-to-Video)
4
+ inference graph at ``cognos_mcp/workflows/wan_s2v_longcat_avatar.json``.
5
+ The underlying model is ``LongCat-Avatar-Single`` (WAN 2.2 S2V fine-tune
6
+ by vantagewithai), loaded from a Q5_1 GGUF.
7
+
8
+ Flow:
9
+ 1. Resolve ref_image + audio (FileStore id or absolute path)
10
+ 2. Stage them into ComfyUI's input/ dir
11
+ 3. Substitute placeholders + tunables in the workflow JSON
12
+ 4. POST to ComfyUI /prompt, poll /history/<id> until done
13
+ 5. Pull the output video, persist via FileStore, return a markdown link
14
+
15
+ ComfyUI must be running. The tool does NOT auto-start it — if /system_stats
16
+ is unreachable, it returns an actionable error pointing at the start command.
17
+ Subprocess auto-start can be layered on later (see CONTEXT.md /
18
+ "Caudate" → tool gap log).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import json
25
+ import logging
26
+ import os
27
+ import re
28
+ import shutil
29
+ import time
30
+ import uuid
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ import httpx
35
+
36
+ from core.schemas import ToolResult
37
+ from execution.tools.base import BaseTool
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ COMFYUI_ROOT = Path("/home/raveuk/comfy/ComfyUI")
43
+ WORKFLOW_PATH = (
44
+ Path(__file__).resolve().parent.parent.parent
45
+ / "cognos_mcp" / "workflows" / "wan_s2v_longcat_avatar.json"
46
+ )
47
+
48
+
49
+ def _public_base_url() -> str:
50
+ return os.environ.get("COGNOS_PUBLIC_URL", "http://127.0.0.1:8000").rstrip("/")
51
+
52
+
53
+ class LongCatAvatarTool(BaseTool):
54
+ mutates = True
55
+ name = "LongCatAvatar"
56
+ description = (
57
+ "Animate a portrait image with a speech audio clip to produce a "
58
+ "talking-head video. Provide a `ref_image` (FileStore id from a "
59
+ "prior [file:...] marker, or an absolute path to a portrait), an "
60
+ "`audio` clip (wav/mp3/flac of the speech to lip-sync), and a "
61
+ "`prompt` describing the avatar's appearance / setting. Returns "
62
+ "a `markdown` field — paste it VERBATIM into your reply so the "
63
+ "UI renders the video inline. Generation takes ~30-90 seconds "
64
+ "on a 3090 at default settings. Requires a local ComfyUI server "
65
+ "running on 127.0.0.1:8188 with the LongCat-Avatar GGUF + WAN "
66
+ "companions installed (see project memory `longcat-to-integrate`)."
67
+ )
68
+
69
+ def __init__(
70
+ self,
71
+ file_store: Any | None = None,
72
+ comfyui_url: str = "http://127.0.0.1:8188",
73
+ timeout_seconds: float = 600.0,
74
+ ):
75
+ self._file_store = file_store
76
+ self._url = comfyui_url.rstrip("/")
77
+ self._timeout = timeout_seconds
78
+ # When set, the tool can synthesize speech from a `text` arg
79
+ # instead of requiring a pre-recorded `audio` file. Wired by
80
+ # CognosAgent at startup (agent.py). Keeping it injected
81
+ # rather than imported avoids a hard dep on the Speak tool here.
82
+ self._speak_tool: Any | None = None
83
+
84
+ @property
85
+ def input_schema(self) -> dict[str, Any]:
86
+ return {
87
+ "type": "object",
88
+ "properties": {
89
+ "ref_image": {
90
+ "type": "string",
91
+ "description": "Avatar portrait. Either a FileStore id (uuid from a [file:...] marker) or an absolute path.",
92
+ },
93
+ "audio": {
94
+ "type": "string",
95
+ "description": "Speech clip to lip-sync. FileStore id or absolute path. WAV/MP3/FLAC. Either `audio` OR `text` must be provided.",
96
+ },
97
+ "text": {
98
+ "type": "string",
99
+ "description": "Text to synthesize into speech (Kokoro TTS) and use as the audio driver. Use this instead of `audio` when you want the avatar to say something the user typed. Either `audio` OR `text` must be provided; if both, `audio` wins.",
100
+ },
101
+ "voice": {
102
+ "type": "string",
103
+ "description": "TTS voice id (Kokoro). Only honoured when `text` is supplied. Optional.",
104
+ },
105
+ "prompt": {
106
+ "type": "string",
107
+ "description": "Positive prompt describing the avatar and scene (e.g. 'a young woman speaking expressively, soft studio lighting').",
108
+ },
109
+ "negative_prompt": {
110
+ "type": "string",
111
+ "description": "Negative prompt. Default: 'blurry, distorted, low quality, watermark, deformed face'.",
112
+ },
113
+ "width": {"type": "integer", "description": "Output width, multiple of 16. Default 832 (horizontal). Use 480 for vertical/portrait."},
114
+ "height": {"type": "integer", "description": "Output height, multiple of 16. Default 480."},
115
+ "length": {"type": "integer", "description": "Frame count. MUST satisfy (length-1) %% 4 == 0. Default 77 (~5s at 16fps); 121 ≈ 7.5s; 161 ≈ 10s."},
116
+ "seed": {"type": "integer", "description": "Random seed. Default 42; randomize for variety."},
117
+ "steps": {"type": "integer", "description": "Sampler steps. 20-30 typical. Default 20."},
118
+ "cfg": {"type": "number", "description": "Classifier-free guidance scale. Default 6.0."},
119
+ "sampler": {"type": "string", "description": "KSampler sampler_name. Default 'euler'. Try 'dpmpp_2m' or 'uni_pc' for sharper."},
120
+ "scheduler": {"type": "string", "description": "Sampler scheduler. Default 'simple'. Try 'beta' for cleaner motion."},
121
+ "fps": {"type": "number", "description": "Output framerate. Default 16 (matches WAN-S2V training)."},
122
+ },
123
+ "required": ["ref_image", "prompt"],
124
+ }
125
+
126
+ def set_file_store(self, file_store: Any) -> None:
127
+ self._file_store = file_store
128
+
129
+ def set_speak_tool(self, speak_tool: Any) -> None:
130
+ """Inject a SpeakTool reference so the `text` parameter can
131
+ drive Kokoro TTS in-process. Optional — without it, callers
132
+ must pass `audio` (FileStore id or path)."""
133
+ self._speak_tool = speak_tool
134
+
135
+ # ------------------------------------------------------------------
136
+
137
+ def _resolve_input(self, ref: str, *, expected_kind: str | None = None) -> Path:
138
+ """Resolve a FileStore id or filesystem path to an absolute Path.
139
+
140
+ Raises FileNotFoundError on bad ids / missing files.
141
+ """
142
+ ref = (ref or "").strip()
143
+ if not ref:
144
+ raise ValueError("empty file reference")
145
+ # FileStore ids are UUIDs (no '/' or '.' typical for paths)
146
+ if "/" not in ref and "." not in ref and self._file_store is not None:
147
+ rec = self._file_store.get(ref)
148
+ if rec is not None:
149
+ return Path(rec.path)
150
+ # Otherwise treat as a filesystem path
151
+ p = Path(ref).expanduser().resolve()
152
+ if not p.exists():
153
+ raise FileNotFoundError(f"Not a FileStore id and not a file on disk: {ref}")
154
+ return p
155
+
156
+ def _build_workflow(self, kwargs: dict[str, Any], ref_image_name: str, audio_name: str) -> dict[str, Any]:
157
+ """Load the template workflow + substitute placeholders + apply overrides."""
158
+ wf = json.loads(WORKFLOW_PATH.read_text())
159
+
160
+ # Placeholder substitutions (the 4 __*__ tokens from the template)
161
+ wf["5"]["inputs"]["image"] = ref_image_name
162
+ wf["6"]["inputs"]["audio"] = audio_name
163
+ wf["7"]["inputs"]["text"] = kwargs["prompt"]
164
+ wf["8"]["inputs"]["text"] = (
165
+ kwargs.get("negative_prompt")
166
+ or "blurry, distorted, low quality, watermark, deformed face"
167
+ )
168
+
169
+ # Tunable overrides — only apply when the caller passed a value.
170
+ def _maybe(node_id: str, field: str, key: str, cast):
171
+ if kwargs.get(key) is not None:
172
+ wf[node_id]["inputs"][field] = cast(kwargs[key])
173
+
174
+ _maybe("10", "width", "width", int)
175
+ _maybe("10", "height", "height", int)
176
+ _maybe("10", "length", "length", int)
177
+ _maybe("11", "seed", "seed", int)
178
+ _maybe("11", "steps", "steps", int)
179
+ _maybe("11", "cfg", "cfg", float)
180
+ _maybe("11", "sampler_name","sampler", str)
181
+ _maybe("11", "scheduler", "scheduler", str)
182
+ _maybe("13", "fps", "fps", float)
183
+
184
+ return wf
185
+
186
+ # ------------------------------------------------------------------
187
+
188
+ async def _ensure_comfyui_up(self, client: httpx.AsyncClient) -> str | None:
189
+ """Probe ComfyUI's /system_stats. Returns None if up, or an error string."""
190
+ try:
191
+ r = await client.get(f"{self._url}/system_stats", timeout=5.0)
192
+ r.raise_for_status()
193
+ return None
194
+ except Exception as e:
195
+ return (
196
+ f"ComfyUI not reachable at {self._url} ({type(e).__name__}: {e}). "
197
+ f"Start it with:\n"
198
+ f" cd {COMFYUI_ROOT} && python3 main.py --listen 127.0.0.1 --port 8188\n"
199
+ f"Then re-run this tool."
200
+ )
201
+
202
+ async def _submit_and_wait(self, client: httpx.AsyncClient, workflow: dict) -> tuple[str | None, Path | None, str | None]:
203
+ """POST the workflow and poll /history until done. Returns (prompt_id, output_path, error_str)."""
204
+ client_id = uuid.uuid4().hex
205
+ try:
206
+ r = await client.post(
207
+ f"{self._url}/prompt",
208
+ json={"prompt": workflow, "client_id": client_id},
209
+ timeout=30.0,
210
+ )
211
+ r.raise_for_status()
212
+ data = r.json()
213
+ except httpx.HTTPStatusError as e:
214
+ # ComfyUI returns 400 with a JSON error body when the workflow is invalid
215
+ body = e.response.text if e.response is not None else "<no body>"
216
+ return None, None, f"ComfyUI rejected the prompt ({e.response.status_code}): {body[:500]}"
217
+ except Exception as e:
218
+ return None, None, f"ComfyUI /prompt POST failed: {type(e).__name__}: {e}"
219
+
220
+ prompt_id = data.get("prompt_id")
221
+ if not prompt_id:
222
+ return None, None, f"ComfyUI did not return a prompt_id: {data}"
223
+
224
+ # Poll /history/<id>
225
+ deadline = time.time() + self._timeout
226
+ last_status = None
227
+ while time.time() < deadline:
228
+ await asyncio.sleep(2.0)
229
+ try:
230
+ h = await client.get(f"{self._url}/history/{prompt_id}", timeout=10.0)
231
+ h.raise_for_status()
232
+ hist = h.json().get(prompt_id)
233
+ except Exception as e:
234
+ logger.debug(f"history poll failed (will retry): {e}")
235
+ continue
236
+ if not hist:
237
+ continue
238
+ status = hist.get("status", {})
239
+ last_status = status
240
+ status_str = status.get("status_str")
241
+ if status_str == "error":
242
+ msgs = status.get("messages", [])
243
+ return prompt_id, None, f"ComfyUI execution error: {msgs}"
244
+ if not status.get("completed"):
245
+ continue
246
+ # Done — find the SaveVideo output (node 14)
247
+ outputs = hist.get("outputs", {}) or {}
248
+ node_out = outputs.get("14", {}) or {}
249
+ for key in ("videos", "images", "gifs"):
250
+ items = node_out.get(key) or []
251
+ if items:
252
+ item = items[0]
253
+ out_path = (
254
+ COMFYUI_ROOT / "output"
255
+ / (item.get("subfolder") or "")
256
+ / item["filename"]
257
+ )
258
+ return prompt_id, out_path, None
259
+ return prompt_id, None, f"ComfyUI completed but no video output found (node 14 outputs: {node_out})"
260
+
261
+ return prompt_id, None, f"timed out after {self._timeout}s (last status: {last_status})"
262
+
263
+ # ------------------------------------------------------------------
264
+
265
+ async def execute(self, **kwargs: Any) -> ToolResult:
266
+ prompt = (kwargs.get("prompt") or "").strip()
267
+ if not prompt:
268
+ return self._error("No prompt provided")
269
+ if self._file_store is None:
270
+ return self._error("LongCatAvatar is not wired to a FileStore. Configure one on the tool.")
271
+
272
+ # 1. Resolve inputs. `audio` (FileStore id or path) wins over
273
+ # `text` if both supplied; if neither, error out.
274
+ audio_ref = (kwargs.get("audio") or "").strip()
275
+ text_to_speak = (kwargs.get("text") or "").strip()
276
+ try:
277
+ ref_image_src = self._resolve_input(kwargs["ref_image"])
278
+ except (FileNotFoundError, ValueError) as e:
279
+ return self._error(str(e))
280
+
281
+ if audio_ref:
282
+ try:
283
+ audio_src = self._resolve_input(audio_ref)
284
+ except (FileNotFoundError, ValueError) as e:
285
+ return self._error(str(e))
286
+ elif text_to_speak:
287
+ if self._speak_tool is None:
288
+ return self._error(
289
+ "`text` was supplied but the Speak tool is not wired "
290
+ "to this LongCatAvatar. Pass `audio` instead, or "
291
+ "ensure CognosAgent wired Speak into the tool."
292
+ )
293
+ speak_kwargs: dict[str, Any] = {"text": text_to_speak}
294
+ if kwargs.get("voice"):
295
+ speak_kwargs["voice"] = kwargs["voice"]
296
+ speak_result = await self._speak_tool.execute(**speak_kwargs)
297
+ status_val = getattr(speak_result, "status", None)
298
+ # ToolResult.status is an enum; compare by value
299
+ ok = status_val == "success" or (
300
+ hasattr(status_val, "value") and status_val.value == "success"
301
+ )
302
+ if not ok:
303
+ return self._error(
304
+ f"TTS step (Speak) failed: "
305
+ f"{getattr(speak_result, 'error', 'unknown error')}"
306
+ )
307
+ # Speak puts the URL into the output string; extract the
308
+ # file_id from the `.../files/<uuid>/content` segment.
309
+ # (ToolResult drops metadata kwargs silently in pydantic, so
310
+ # we can't rely on speak_result.metadata.)
311
+ output_text = str(getattr(speak_result, "output", "") or "")
312
+ m = re.search(r"/files/([0-9a-f-]{32,36})/content", output_text)
313
+ if not m:
314
+ return self._error(
315
+ "TTS step succeeded but could not find the audio "
316
+ "FileStore id in Speak's output; cannot proceed."
317
+ )
318
+ audio_file_id = m.group(1)
319
+ try:
320
+ audio_src = self._resolve_input(audio_file_id)
321
+ except (FileNotFoundError, ValueError) as e:
322
+ return self._error(f"Could not resolve TTS output: {e}")
323
+ else:
324
+ return self._error(
325
+ "Provide either `audio` (FileStore id or path) or "
326
+ "`text` (to synthesize speech). Neither was supplied."
327
+ )
328
+
329
+ # 2. Validate length
330
+ length = int(kwargs.get("length") or 77)
331
+ if (length - 1) % 4 != 0:
332
+ return self._error(
333
+ f"length must satisfy (length - 1) %% 4 == 0 (the temporal VAE requires it). "
334
+ f"Got {length}. Try 77, 81, 85, …, 121, …, 161."
335
+ )
336
+
337
+ # 3. Stage inputs into ComfyUI/input/
338
+ input_dir = COMFYUI_ROOT / "input"
339
+ try:
340
+ input_dir.mkdir(parents=True, exist_ok=True)
341
+ except Exception as e:
342
+ return self._error(f"Cannot create ComfyUI input dir {input_dir}: {e}")
343
+ tag = uuid.uuid4().hex[:8]
344
+ ref_image_name = f"cognos_longcat_ref_{tag}{ref_image_src.suffix or '.png'}"
345
+ audio_name = f"cognos_longcat_audio_{tag}{audio_src.suffix or '.wav'}"
346
+ try:
347
+ shutil.copy2(ref_image_src, input_dir / ref_image_name)
348
+ shutil.copy2(audio_src, input_dir / audio_name)
349
+ except Exception as e:
350
+ return self._error(f"Failed to stage inputs into ComfyUI: {e}")
351
+
352
+ # 4. Build workflow
353
+ try:
354
+ workflow = self._build_workflow(kwargs, ref_image_name, audio_name)
355
+ except Exception as e:
356
+ return self._error(f"Failed to build workflow from template: {e}")
357
+
358
+ # 5. Run
359
+ async with httpx.AsyncClient() as client:
360
+ up_err = await self._ensure_comfyui_up(client)
361
+ if up_err is not None:
362
+ return self._error(up_err)
363
+ prompt_id, out_path, err = await self._submit_and_wait(client, workflow)
364
+
365
+ if err is not None:
366
+ return self._error(err)
367
+ if out_path is None or not out_path.exists():
368
+ return self._error(f"output file missing on disk: {out_path}")
369
+
370
+ # 6. Persist + return markdown link
371
+ try:
372
+ record = self._file_store.upload(out_path)
373
+ except Exception as e:
374
+ return self._error(f"FileStore upload of {out_path} failed: {e}")
375
+ url = f"{_public_base_url()}/files/{record.id}/content"
376
+ markdown = f"[{record.filename}]({url})" # video, not image — no leading !
377
+ return self._success({
378
+ "file_id": record.id,
379
+ "filename": record.filename,
380
+ "size_bytes": record.size_bytes,
381
+ "path": str(out_path),
382
+ "markdown": markdown,
383
+ "prompt_id": prompt_id,
384
+ })
@@ -0,0 +1,100 @@
1
+ """MCP visibility tool — list connected Model Context Protocol servers.
2
+
3
+ Cognos already supports MCP at runtime: each `CognosAgent` keeps a
4
+ `_mcp_clients` list of connected `MCPClient`s, and the tools they
5
+ expose are merged into the executor's tool palette. This tool just
6
+ makes that surface visible from chat — the agent can ask "which MCP
7
+ servers are connected and what do they expose?" and get a clear
8
+ answer instead of having to dig through config files.
9
+
10
+ Configuration lives at `data/mcp_servers.json` (see the example in
11
+ the same dir for shape).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from typing import Any
18
+
19
+ from core.schemas import ToolResult
20
+ from execution.tools.base import BaseTool
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class MCPListServersTool(BaseTool):
26
+ name = "MCPListServers"
27
+ description = (
28
+ "List all Model Context Protocol (MCP) servers currently "
29
+ "connected to this Cognos agent, plus the tools each one "
30
+ "exposes. Useful when the user asks 'what MCP servers do I "
31
+ "have' or 'why isn't tool X working' — this surfaces what's "
32
+ "actually wired vs. only configured. No arguments. Configure "
33
+ "MCP servers in `data/mcp_servers.json`."
34
+ )
35
+
36
+ def __init__(self, agent: Any | None = None):
37
+ self._agent = agent
38
+
39
+ def set_agent(self, agent: Any) -> None:
40
+ self._agent = agent
41
+
42
+ @property
43
+ def input_schema(self) -> dict[str, Any]:
44
+ return {"type": "object", "properties": {}}
45
+
46
+ async def execute(self, **_: Any) -> ToolResult:
47
+ if self._agent is None:
48
+ return ToolResult(
49
+ tool_name=self.name, status="error",
50
+ error=(
51
+ "MCPListServers is not wired to a CognosAgent. "
52
+ "The agent must call set_agent() at startup."
53
+ ),
54
+ )
55
+ clients = getattr(self._agent, "_mcp_clients", None)
56
+ if clients is None:
57
+ return ToolResult(
58
+ tool_name=self.name, status="success",
59
+ output="MCP support is not active in this agent.",
60
+ metadata={"clients": []},
61
+ )
62
+ if not clients:
63
+ return ToolResult(
64
+ tool_name=self.name, status="success",
65
+ output=(
66
+ "No MCP servers connected.\n"
67
+ "Configure one at `data/mcp_servers.json` (see "
68
+ "`data/mcp_servers.json.example` for the shape) "
69
+ "and restart Cognos."
70
+ ),
71
+ metadata={"clients": []},
72
+ )
73
+
74
+ lines = [f"{len(clients)} MCP server(s) connected:", ""]
75
+ client_meta: list[dict[str, Any]] = []
76
+ for c in clients:
77
+ name = getattr(c, "name", None) or repr(c)
78
+ tools = []
79
+ try:
80
+ tools = c.tools() or []
81
+ except Exception as e:
82
+ logger.debug(f"MCP {name}: tools() failed: {e}")
83
+ tool_names = [
84
+ getattr(t, "name", None) or t.get("name", "?")
85
+ if isinstance(t, dict) else str(t)
86
+ for t in tools
87
+ ]
88
+ lines.append(f" {name} ({len(tool_names)} tool(s))")
89
+ for tn in tool_names[:12]:
90
+ lines.append(f" - {tn}")
91
+ if len(tool_names) > 12:
92
+ lines.append(f" ... and {len(tool_names) - 12} more")
93
+ client_meta.append({"name": name, "tool_count": len(tool_names),
94
+ "tools": tool_names})
95
+
96
+ return ToolResult(
97
+ tool_name=self.name, status="success",
98
+ output="\n".join(lines),
99
+ metadata={"clients": client_meta},
100
+ )