synapse-cli-agent 0.1.13__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 (131) hide show
  1. synapse/__init__.py +13 -0
  2. synapse/__main__.py +6 -0
  3. synapse/app/__init__.py +1 -0
  4. synapse/app/agent.py +492 -0
  5. synapse/app/agent_md.py +107 -0
  6. synapse/cli.py +750 -0
  7. synapse/commands/__init__.py +1 -0
  8. synapse/commands/compression.py +573 -0
  9. synapse/commands/helpers.py +22 -0
  10. synapse/commands/mcp.py +406 -0
  11. synapse/commands/model.py +173 -0
  12. synapse/commands/result.py +34 -0
  13. synapse/commands/sessions.py +443 -0
  14. synapse/commands/slash_cmds.py +521 -0
  15. synapse/commands/slash_complete.py +816 -0
  16. synapse/commands/theme.py +99 -0
  17. synapse/config.py +27 -0
  18. synapse/content/__init__.py +1 -0
  19. synapse/content/input_history.py +122 -0
  20. synapse/content/multimodal.py +733 -0
  21. synapse/content/prompts.py +249 -0
  22. synapse/content/skills_catalog.py +128 -0
  23. synapse/integrations/__init__.py +1 -0
  24. synapse/integrations/checkpoint_seed.py +281 -0
  25. synapse/integrations/codex_history.py +375 -0
  26. synapse/integrations/codex_import.py +393 -0
  27. synapse/integrations/codex_sessions.py +629 -0
  28. synapse/integrations/describe_image.py +370 -0
  29. synapse/integrations/http_clients.py +199 -0
  30. synapse/integrations/llm_openai_compat.py +90 -0
  31. synapse/integrations/llm_openai_websocket.py +187 -0
  32. synapse/integrations/mcp_client.py +646 -0
  33. synapse/integrations/vision_middleware.py +62 -0
  34. synapse/models/__init__.py +5 -0
  35. synapse/models/config.py +240 -0
  36. synapse/models/helpers.py +206 -0
  37. synapse/models/profile.py +59 -0
  38. synapse/models/registry.py +722 -0
  39. synapse/models_registry.py +7 -0
  40. synapse/observability/__init__.py +1 -0
  41. synapse/observability/startup_trace.py +127 -0
  42. synapse/runtime/__init__.py +1 -0
  43. synapse/runtime/async_runtime.py +176 -0
  44. synapse/runtime/backends.py +458 -0
  45. synapse/runtime/context_compact.py +249 -0
  46. synapse/runtime/execute_capture.py +48 -0
  47. synapse/runtime/fs_permissions.py +79 -0
  48. synapse/runtime/harness.py +57 -0
  49. synapse/runtime/hitl.py +197 -0
  50. synapse/runtime/interaction_ledger.py +82 -0
  51. synapse/runtime/middleware.py +802 -0
  52. synapse/runtime/model_request_compression_middleware.py +745 -0
  53. synapse/runtime/pathing.py +146 -0
  54. synapse/runtime/safety.py +184 -0
  55. synapse/runtime/steer.py +240 -0
  56. synapse/runtime/subagents.py +207 -0
  57. synapse/runtime/tool_ignore.py +221 -0
  58. synapse/runtime/tool_output_eval.py +118 -0
  59. synapse/runtime/tool_output_middleware.py +585 -0
  60. synapse/runtime/tool_output_usage_middleware.py +60 -0
  61. synapse/sessions/__init__.py +31 -0
  62. synapse/sessions/cancel_repair.py +208 -0
  63. synapse/sessions/session_recap.py +174 -0
  64. synapse/sessions/store.py +695 -0
  65. synapse/sessions/transcript.py +754 -0
  66. synapse/settings/__init__.py +5 -0
  67. synapse/settings/config_paths.py +184 -0
  68. synapse/settings/schema.py +464 -0
  69. synapse/tool_output/__init__.py +59 -0
  70. synapse/tool_output/detection.py +170 -0
  71. synapse/tool_output/metrics.py +32 -0
  72. synapse/tool_output/models.py +173 -0
  73. synapse/tool_output/pipeline.py +330 -0
  74. synapse/tool_output/repository.py +721 -0
  75. synapse/tool_output/transformers.py +648 -0
  76. synapse/tools/__init__.py +5 -0
  77. synapse/tools/session_tools.py +204 -0
  78. synapse/ui/__init__.py +10 -0
  79. synapse/ui/bottombar/__init__.py +73 -0
  80. synapse/ui/bottombar/components/__init__.py +143 -0
  81. synapse/ui/bottombar/components/key_hints.py +30 -0
  82. synapse/ui/bottombar/components/mcp.py +64 -0
  83. synapse/ui/bottombar/components/mode.py +24 -0
  84. synapse/ui/bottombar/components/model.py +28 -0
  85. synapse/ui/bottombar/components/thread.py +29 -0
  86. synapse/ui/bottombar/context.py +36 -0
  87. synapse/ui/bottombar/core.py +74 -0
  88. synapse/ui/dialogs/__init__.py +25 -0
  89. synapse/ui/dialogs/base.py +362 -0
  90. synapse/ui/dialogs/codex_session_list.py +84 -0
  91. synapse/ui/dialogs/compression_diagnostics.py +210 -0
  92. synapse/ui/dialogs/git_explore.py +702 -0
  93. synapse/ui/dialogs/mcp_panel.py +407 -0
  94. synapse/ui/dialogs/model_picker.py +128 -0
  95. synapse/ui/dialogs/safety_panel.py +63 -0
  96. synapse/ui/dialogs/session_list.py +98 -0
  97. synapse/ui/dialogs/theme_designer.py +863 -0
  98. synapse/ui/dialogs/theme_picker.py +113 -0
  99. synapse/ui/git_explore/__init__.py +31 -0
  100. synapse/ui/git_explore/engine.py +82 -0
  101. synapse/ui/git_explore/provider.py +242 -0
  102. synapse/ui/git_explore/unified.py +85 -0
  103. synapse/ui/rendering.py +350 -0
  104. synapse/ui/sink.py +70 -0
  105. synapse/ui/steer_widget.py +367 -0
  106. synapse/ui/stream.py +1207 -0
  107. synapse/ui/stream_events.py +421 -0
  108. synapse/ui/stream_runtime.py +252 -0
  109. synapse/ui/theme.py +1154 -0
  110. synapse/ui/timeline.py +621 -0
  111. synapse/ui/topbar/__init__.py +97 -0
  112. synapse/ui/topbar/components/__init__.py +150 -0
  113. synapse/ui/topbar/components/branch.py +41 -0
  114. synapse/ui/topbar/components/title.py +24 -0
  115. synapse/ui/topbar/components/tool_output.py +24 -0
  116. synapse/ui/topbar/components/usage.py +24 -0
  117. synapse/ui/topbar/components/workspace.py +32 -0
  118. synapse/ui/topbar/context.py +32 -0
  119. synapse/ui/topbar/core.py +979 -0
  120. synapse/ui/topbar/git_changes_popover.py +178 -0
  121. synapse/ui/topbar/git_chrome.py +475 -0
  122. synapse/ui/topbar/tool_output_popover.py +84 -0
  123. synapse/ui/topbar/widget.py +474 -0
  124. synapse/ui/tui.py +5717 -0
  125. synapse/ui/turn_rail.py +71 -0
  126. synapse/ui/user_turn.py +83 -0
  127. synapse/ui/welcome.py +261 -0
  128. synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
  129. synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
  130. synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
  131. synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,646 @@
1
+ """MCP client adapter: discover remote tools and inject as LangChain tools.
2
+
3
+ deepagents has no MCP support. Extension path is create_deep_agent(tools=[...]).
4
+ Failures are non-fatal: missing servers degrade to an empty tool list + warnings.
5
+
6
+ Connections are kept alive on a dedicated asyncio loop thread so tool calls
7
+ do not re-spawn stdio/HTTP sessions every time.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import atexit
14
+ import json
15
+ import logging
16
+ import threading
17
+ from concurrent.futures import Future
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ @dataclass
26
+ class McpServerConfig:
27
+ name: str
28
+ transport: str = "stdio" # stdio | sse | streamable_http
29
+ command: str | None = None
30
+ args: list[str] = field(default_factory=list)
31
+ env: dict[str, str] = field(default_factory=dict)
32
+ url: str | None = None
33
+ headers: dict[str, str] = field(default_factory=dict)
34
+ enabled: bool = True
35
+ tool_prefix: str | None = None
36
+ # Per-tool filtering: if include_tools is set, only those tools are loaded.
37
+ # exclude_tools removes tools from the final list (applied after include).
38
+ include_tools: list[str] | None = None
39
+ exclude_tools: list[str] | None = None
40
+
41
+
42
+ @dataclass
43
+ class McpLoadResult:
44
+ tools: list[Any]
45
+ warnings: list[str] = field(default_factory=list)
46
+ servers: list[str] = field(default_factory=list)
47
+ tool_names: list[str] = field(default_factory=list)
48
+ pool: Any | None = None
49
+
50
+
51
+ def _expand_env(value: Any) -> Any:
52
+ """Expand ${VAR} or $VAR in strings (headers/url/command/env values)."""
53
+ import os
54
+ import re
55
+
56
+ if not isinstance(value, str):
57
+ return value
58
+ pattern = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)")
59
+
60
+ def repl(match: re.Match[str]) -> str:
61
+ key = match.group(1) or match.group(2)
62
+ return os.environ.get(key, "")
63
+
64
+ return pattern.sub(repl, value)
65
+
66
+
67
+ def _expand_mapping(raw: dict[str, Any] | None) -> dict[str, str]:
68
+ out: dict[str, str] = {}
69
+ for k, v in dict(raw or {}).items():
70
+ out[str(k)] = str(_expand_env(v))
71
+ return out
72
+
73
+
74
+ def _parse_server(raw: dict[str, Any]) -> McpServerConfig:
75
+ name = str(raw.get("name") or "").strip()
76
+ if not name:
77
+ raise ValueError("mcp server missing name")
78
+ command = raw.get("command")
79
+ url = raw.get("url")
80
+ include_tools = raw.get("include_tools")
81
+ exclude_tools = raw.get("exclude_tools")
82
+ return McpServerConfig(
83
+ name=name,
84
+ transport=str(raw.get("transport") or "stdio").strip().lower(),
85
+ command=_expand_env(command) if command is not None else None,
86
+ args=[str(_expand_env(a)) for a in list(raw.get("args") or [])],
87
+ env=_expand_mapping(raw.get("env")),
88
+ url=_expand_env(url) if url is not None else None,
89
+ headers=_expand_mapping(raw.get("headers")),
90
+ enabled=bool(raw.get("enabled", True)),
91
+ tool_prefix=raw.get("tool_prefix"),
92
+ include_tools=list(include_tools) if isinstance(include_tools, list) else None,
93
+ exclude_tools=list(exclude_tools) if isinstance(exclude_tools, list) else None,
94
+ )
95
+
96
+
97
+ def load_mcp_server_configs(
98
+ *,
99
+ path: Path | str | None = None,
100
+ paths: list[Path | str] | None = None,
101
+ json_blob: str | None = None,
102
+ workspace: Path | str | None = None,
103
+ ) -> list[McpServerConfig]:
104
+ """Load MCP servers from one or more JSON files (later overrides by name).
105
+
106
+ When ``path``/``paths`` are unset, loads layered:
107
+ ``~/.coding-agent/mcp.json`` then ``<workspace>/.coding-agent/mcp.json``.
108
+ """
109
+ file_paths: list[Path] = []
110
+ if paths:
111
+ file_paths.extend(Path(p).expanduser() for p in paths)
112
+ if path is not None:
113
+ file_paths.append(Path(path).expanduser())
114
+ if not file_paths:
115
+ from synapse.settings.config_paths import mcp_config_paths
116
+
117
+ file_paths = list(mcp_config_paths(workspace))
118
+
119
+ by_name: dict[str, McpServerConfig] = {}
120
+ for p in file_paths:
121
+ if not p.is_file():
122
+ continue
123
+ try:
124
+ data = json.loads(p.read_text(encoding="utf-8"))
125
+ except json.JSONDecodeError as exc:
126
+ logger.warning("mcp config %s: invalid JSON: %s", p, exc)
127
+ continue
128
+ for raw in _iter_server_dicts(data):
129
+ cfg = _parse_server(raw)
130
+ by_name[cfg.name] = cfg
131
+
132
+ if json_blob and json_blob.strip():
133
+ try:
134
+ data = json.loads(json_blob)
135
+ except json.JSONDecodeError as exc:
136
+ logger.warning("mcp config from json_blob: invalid JSON: %s", exc)
137
+ data = None
138
+ if data is not None:
139
+ for raw in _iter_server_dicts(data):
140
+ cfg = _parse_server(raw)
141
+ by_name[cfg.name] = cfg
142
+
143
+ return list(by_name.values())
144
+
145
+
146
+ def _iter_server_dicts(data: Any) -> list[dict[str, Any]]:
147
+ if data is None:
148
+ return []
149
+ if isinstance(data, dict):
150
+ servers = data.get("servers")
151
+ if isinstance(servers, list):
152
+ return [s for s in servers if isinstance(s, dict)]
153
+ # single server object
154
+ if "name" in data:
155
+ return [data]
156
+ return []
157
+ if isinstance(data, list):
158
+ return [s for s in data if isinstance(s, dict)]
159
+ return []
160
+
161
+
162
+ def _json_schema_to_args(schema: dict[str, Any] | None) -> dict[str, Any]:
163
+ """Return a JSON schema object suitable for StructuredTool args."""
164
+ if not schema:
165
+ return {"type": "object", "properties": {}}
166
+ if schema.get("type") == "object" or "properties" in schema:
167
+ # Ensure type=object even when only properties/required are present.
168
+ out = dict(schema)
169
+ out.setdefault("type", "object")
170
+ out.setdefault("properties", {})
171
+ return out
172
+ return {"type": "object", "properties": {"input": schema}}
173
+
174
+
175
+ def _annotation_for_prop(prop: Any) -> Any:
176
+ """Map a JSON-schema property fragment to a loose Python annotation."""
177
+ if not isinstance(prop, dict):
178
+ return Any
179
+ t = prop.get("type")
180
+ if isinstance(t, list):
181
+ # e.g. ["string", "null"]
182
+ non_null = [x for x in t if x != "null"]
183
+ base = _annotation_for_prop({**prop, "type": non_null[0] if non_null else "string"})
184
+ return base | None if "null" in t else base
185
+ if t == "string":
186
+ return str
187
+ if t == "integer":
188
+ return int
189
+ if t == "number":
190
+ return float
191
+ if t == "boolean":
192
+ return bool
193
+ if t == "array":
194
+ items = prop.get("items")
195
+ return list[_annotation_for_prop(items)] if isinstance(items, dict) else list[Any]
196
+ if t == "object" or "properties" in prop or prop.get("additionalProperties") is not None:
197
+ return dict[str, Any]
198
+ # anyOf / oneOf / $ref / missing type → keep open
199
+ return Any
200
+
201
+
202
+ def json_schema_to_pydantic_model(
203
+ name: str,
204
+ schema: dict[str, Any] | None,
205
+ ) -> type[Any]:
206
+ """Build a flat pydantic model from MCP JSON Schema.
207
+
208
+ Passing a raw dict to ``StructuredTool.from_function(args_schema=...)`` can
209
+ collapse into a single ``root: anyOf[...]`` field (RootModel-like). Models
210
+ then invent wrong arguments and MCP calls fail repeatedly.
211
+ """
212
+ from pydantic import ConfigDict, Field, create_model
213
+
214
+ schema_obj = _json_schema_to_args(schema)
215
+ props = schema_obj.get("properties") or {}
216
+ required = set(schema_obj.get("required") or [])
217
+ if not isinstance(props, dict):
218
+ props = {}
219
+
220
+ field_defs: dict[str, Any] = {}
221
+ for key, prop in props.items():
222
+ key_s = str(key)
223
+ annotation = _annotation_for_prop(prop)
224
+ desc = ""
225
+ if isinstance(prop, dict):
226
+ desc = str(prop.get("description") or "")
227
+ if key_s in required:
228
+ field_defs[key_s] = (annotation, Field(description=desc or key_s))
229
+ else:
230
+ # Optional: default None so model may omit the field.
231
+ field_defs[key_s] = (
232
+ annotation | None,
233
+ Field(default=None, description=desc or key_s),
234
+ )
235
+
236
+ safe = "".join(ch if ch.isalnum() or ch == "_" else "_" for ch in name) or "mcp_tool"
237
+ model_name = f"{safe}_input"
238
+ # extra=allow keeps forward-compatible MCP args / free-form objects.
239
+ return create_model(
240
+ model_name,
241
+ __config__=ConfigDict(extra="allow"),
242
+ **field_defs,
243
+ )
244
+
245
+
246
+ def _content_to_text(result: Any) -> str:
247
+ parts: list[str] = []
248
+ for block in getattr(result, "content", None) or []:
249
+ text = getattr(block, "text", None)
250
+ if text:
251
+ parts.append(text)
252
+ else:
253
+ parts.append(str(block))
254
+ if getattr(result, "isError", False):
255
+ return "MCP error: " + ("\n".join(parts) or "unknown")
256
+ return "\n".join(parts) if parts else "(empty MCP result)"
257
+
258
+
259
+ def _make_tool(
260
+ *,
261
+ server: McpServerConfig,
262
+ tool_name: str,
263
+ description: str,
264
+ input_schema: dict[str, Any] | None,
265
+ call_fn,
266
+ ):
267
+ from langchain_core.tools import StructuredTool
268
+
269
+ prefix = server.tool_prefix if server.tool_prefix is not None else f"{server.name}__"
270
+ full_name = f"{prefix}{tool_name}" if prefix else tool_name
271
+ safe_name = "".join(ch if ch.isalnum() or ch in {"_", "-"} else "_" for ch in full_name)
272
+ args_model = json_schema_to_pydantic_model(safe_name, input_schema)
273
+
274
+ def _invoke(**kwargs: Any) -> str:
275
+ # Drop explicit Nones so optional MCP fields stay omitted.
276
+ arguments = {k: v for k, v in kwargs.items() if v is not None}
277
+ return call_fn(tool_name, arguments)
278
+
279
+ return StructuredTool.from_function(
280
+ func=_invoke,
281
+ name=safe_name,
282
+ description=description or f"MCP tool {tool_name} from {server.name}",
283
+ args_schema=args_model,
284
+ )
285
+
286
+
287
+ class _LoopThread:
288
+ """Background event loop for long-lived async MCP sessions."""
289
+
290
+ def __init__(self) -> None:
291
+ self._loop = asyncio.new_event_loop()
292
+ self._thread = threading.Thread(
293
+ target=self._run,
294
+ name="coding-agent-mcp-loop",
295
+ daemon=True,
296
+ )
297
+ self._started = threading.Event()
298
+ self._thread.start()
299
+ self._started.wait(timeout=5)
300
+
301
+ def _run(self) -> None:
302
+ asyncio.set_event_loop(self._loop)
303
+ self._started.set()
304
+ self._loop.run_forever()
305
+
306
+ def submit(self, coro) -> Future:
307
+ if not self._thread.is_alive():
308
+ raise RuntimeError("MCP event loop thread is not running")
309
+ return asyncio.run_coroutine_threadsafe(coro, self._loop)
310
+
311
+ def run(self, coro, timeout: float | None = 120.0) -> Any:
312
+ fut = self.submit(coro)
313
+ try:
314
+ return fut.result(timeout=timeout)
315
+ except TimeoutError:
316
+ # Attempt to cancel the still-running coroutine on the event loop
317
+ # so we don't leak subprocesses / connections.
318
+ fut.cancel()
319
+ raise
320
+
321
+ def stop(self) -> None:
322
+ if not self._thread.is_alive():
323
+ return
324
+
325
+ def _stop() -> None:
326
+ self._loop.stop()
327
+
328
+ self._loop.call_soon_threadsafe(_stop)
329
+ self._thread.join(timeout=5)
330
+ try:
331
+ self._loop.close()
332
+ except Exception: # noqa: BLE001
333
+ pass
334
+
335
+
336
+ @dataclass
337
+ class _LiveServer:
338
+ config: McpServerConfig
339
+ session: Any
340
+ # Keep transport context managers open for process lifetime.
341
+ transport_cm: Any
342
+ session_cm: Any
343
+ streams: Any = None
344
+
345
+
346
+ class McpSessionPool:
347
+ """Process-local pool of live MCP ClientSessions."""
348
+
349
+ def __init__(self) -> None:
350
+ self._loop = _LoopThread()
351
+ self._servers: dict[str, _LiveServer] = {}
352
+ self._closed = False
353
+ self.warnings: list[str] = []
354
+ self.tool_names: list[str] = []
355
+ self.tools: list[Any] = []
356
+ # server_name → list of raw tool names (before prefix / filtering)
357
+ self.discovered_tools: dict[str, list[str]] = {}
358
+
359
+ @property
360
+ def server_names(self) -> list[str]:
361
+ return sorted(self._servers)
362
+
363
+ def close(self) -> None:
364
+ if self._closed:
365
+ return
366
+ self._closed = True
367
+ # Drop tool references first so any agent still holding them fails
368
+ # cleanly instead of hitting a stopped event loop.
369
+ self.tools = []
370
+ self.tool_names = []
371
+ try:
372
+ self._loop.run(self._aclose_all(), timeout=30)
373
+ except Exception as exc: # noqa: BLE001
374
+ logger.warning("MCP pool close failed: %s", exc)
375
+ finally:
376
+ self._loop.stop()
377
+
378
+ async def _aclose_all(self) -> None:
379
+ for live in list(self._servers.values()):
380
+ try:
381
+ await live.session_cm.__aexit__(None, None, None)
382
+ except Exception: # noqa: BLE001
383
+ pass
384
+ try:
385
+ await live.transport_cm.__aexit__(None, None, None)
386
+ except Exception: # noqa: BLE001
387
+ pass
388
+ self._servers.clear()
389
+
390
+ async def _open_stdio(self, server: McpServerConfig) -> _LiveServer:
391
+ from mcp import ClientSession, StdioServerParameters
392
+ from mcp.client.stdio import stdio_client
393
+
394
+ if not server.command:
395
+ raise ValueError(f"mcp server {server.name}: stdio requires command")
396
+ params = StdioServerParameters(
397
+ command=server.command,
398
+ args=server.args,
399
+ env=server.env or None,
400
+ )
401
+ transport_cm = stdio_client(params)
402
+ read, write = await transport_cm.__aenter__()
403
+ session_cm = ClientSession(read, write)
404
+ session = await session_cm.__aenter__()
405
+ await session.initialize()
406
+ return _LiveServer(
407
+ config=server,
408
+ session=session,
409
+ transport_cm=transport_cm,
410
+ session_cm=session_cm,
411
+ )
412
+
413
+ async def _open_http(self, server: McpServerConfig) -> _LiveServer:
414
+ if not server.url:
415
+ raise ValueError(
416
+ f"mcp server {server.name}: url required for {server.transport}"
417
+ )
418
+ if server.transport in {"streamable_http", "http"}:
419
+ from mcp.client.streamable_http import streamablehttp_client
420
+
421
+ transport_cm = streamablehttp_client(
422
+ server.url, headers=server.headers or None
423
+ )
424
+ else:
425
+ from mcp.client.sse import sse_client
426
+
427
+ transport_cm = sse_client(server.url, headers=server.headers or None)
428
+
429
+ streams = await transport_cm.__aenter__()
430
+ read, write = streams[0], streams[1]
431
+ from mcp import ClientSession
432
+
433
+ session_cm = ClientSession(read, write)
434
+ session = await session_cm.__aenter__()
435
+ await session.initialize()
436
+ return _LiveServer(
437
+ config=server,
438
+ session=session,
439
+ transport_cm=transport_cm,
440
+ session_cm=session_cm,
441
+ streams=streams,
442
+ )
443
+
444
+ async def _open_one(self, server: McpServerConfig) -> tuple[_LiveServer | None, str | None]:
445
+ if not server.enabled:
446
+ return None, None
447
+ try:
448
+ if server.transport == "stdio":
449
+ live = await self._open_stdio(server)
450
+ elif server.transport in {"sse", "streamable_http", "http"}:
451
+ live = await self._open_http(server)
452
+ else:
453
+ return None, f"mcp server {server.name}: unsupported transport {server.transport}"
454
+ self._servers[server.name] = live
455
+ return live, None
456
+ except Exception as exc: # noqa: BLE001
457
+ logger.warning("MCP server %s open failed: %s", server.name, exc)
458
+ return None, f"mcp server {server.name}: {exc}"
459
+
460
+ async def _call(self, server_name: str, tool_name: str, arguments: dict[str, Any]) -> str:
461
+ live = self._servers.get(server_name)
462
+ if live is None:
463
+ return f"MCP error: server {server_name} is not connected"
464
+ try:
465
+ result = await live.session.call_tool(tool_name, arguments=arguments)
466
+ return _content_to_text(result)
467
+ except Exception as exc:
468
+ # Connection broken (e.g. stdio process exited, HTTP stream closed,
469
+ # anyio.ClosedResourceError). Drop the dead session so follow-up
470
+ # calls fail fast with "not connected" instead of reusing a stale
471
+ # transport that will raise again.
472
+ logger.warning(
473
+ "MCP call_tool %s/%s failed: %s (removing session)",
474
+ server_name,
475
+ tool_name,
476
+ exc,
477
+ )
478
+ self._servers.pop(server_name, None)
479
+ return f"MCP error: {server_name}/{tool_name}: {exc}"
480
+
481
+ def call_tool(self, server_name: str, tool_name: str, arguments: dict[str, Any]) -> str:
482
+ try:
483
+ return self._loop.run(self._call(server_name, tool_name, arguments))
484
+ except Exception as exc:
485
+ logger.warning("MCP call_tool %s/%s loop error: %s", server_name, tool_name, exc)
486
+ return f"MCP error: {server_name}/{tool_name}: {exc}"
487
+
488
+ async def _discover(self, servers: list[McpServerConfig]) -> McpLoadResult:
489
+ tools: list[Any] = []
490
+ warnings: list[str] = []
491
+ ok_servers: list[str] = []
492
+ tool_names: list[str] = []
493
+
494
+ for server in servers:
495
+ if not server.enabled:
496
+ continue
497
+ live, err = await self._open_one(server)
498
+ if err:
499
+ warnings.append(err)
500
+ continue
501
+ if live is None:
502
+ continue
503
+ try:
504
+ listed = await live.session.list_tools()
505
+ except Exception as exc: # noqa: BLE001
506
+ warnings.append(f"mcp server {server.name}: list_tools failed: {exc}")
507
+ self._servers.pop(server.name, None)
508
+ continue
509
+
510
+ # Record all discovered tool names (pre-filtering) for UI introspection.
511
+ all_raw_names = [getattr(item, "name", "") for item in listed.tools]
512
+ self.discovered_tools[server.name] = all_raw_names
513
+
514
+ def make_call(server_name: str = server.name):
515
+ def _call(name: str, arguments: dict[str, Any]) -> str:
516
+ return self.call_tool(server_name, name, arguments)
517
+
518
+ return _call
519
+
520
+ call_fn = make_call()
521
+ include_set: set[str] | None = None
522
+ if server.include_tools:
523
+ include_set = set(server.include_tools)
524
+ exclude_set: set[str] | None = None
525
+ if server.exclude_tools:
526
+ exclude_set = set(server.exclude_tools)
527
+
528
+ server_tool_count = 0
529
+ for item in listed.tools:
530
+ tool_name_raw = getattr(item, "name", "")
531
+ # Per-tool filtering: include → exclude → load.
532
+ if include_set is not None and tool_name_raw not in include_set:
533
+ continue
534
+ if exclude_set is not None and tool_name_raw in exclude_set:
535
+ continue
536
+ server_tool_count += 1
537
+ tool = _make_tool(
538
+ server=server,
539
+ tool_name=tool_name_raw,
540
+ description=getattr(item, "description", "") or "",
541
+ input_schema=getattr(item, "inputSchema", None),
542
+ call_fn=call_fn,
543
+ )
544
+ tools.append(tool)
545
+ tool_names.append(getattr(tool, "name", tool_name_raw))
546
+
547
+ total_available = len(listed.tools)
548
+ if include_set is not None or exclude_set is not None:
549
+ if server_tool_count == 0:
550
+ include_names = sorted(include_set) if include_set else None
551
+ exclude_names = sorted(exclude_set) if exclude_set else None
552
+ warnings.append(
553
+ f"mcp server {server.name}: {total_available} tools discovered "
554
+ f"but all filtered out (include={include_names}, exclude={exclude_names})"
555
+ )
556
+ elif server_tool_count < total_available:
557
+ logger.info(
558
+ "mcp server %s: %d/%d tools loaded (filtered)",
559
+ server.name, server_tool_count, total_available,
560
+ )
561
+ elif server_tool_count == 0:
562
+ warnings.append(f"mcp server {server.name}: no tools discovered")
563
+ ok_servers.append(server.name)
564
+
565
+ self.warnings = warnings
566
+ self.tool_names = tool_names
567
+ # Keep tool objects so rebuilds can reuse them without reconnecting.
568
+ self.tools = tools
569
+ return McpLoadResult(
570
+ tools=tools,
571
+ warnings=warnings,
572
+ servers=ok_servers,
573
+ tool_names=tool_names,
574
+ pool=self,
575
+ )
576
+
577
+ def load(self, servers: list[McpServerConfig]) -> McpLoadResult:
578
+ return self._loop.run(self._discover(servers))
579
+
580
+
581
+ # Process-level active pool (replaced on reload).
582
+ _ACTIVE_POOL: McpSessionPool | None = None
583
+ _POOL_LOCK = threading.Lock()
584
+
585
+
586
+ def close_active_mcp_pool() -> None:
587
+ global _ACTIVE_POOL
588
+ with _POOL_LOCK:
589
+ pool = _ACTIVE_POOL
590
+ _ACTIVE_POOL = None
591
+ if pool is not None:
592
+ pool.close()
593
+
594
+
595
+ def get_active_mcp_pool() -> McpSessionPool | None:
596
+ with _POOL_LOCK:
597
+ return _ACTIVE_POOL
598
+
599
+
600
+ def load_mcp_tools(
601
+ servers: list[McpServerConfig],
602
+ *,
603
+ enabled: bool = True,
604
+ reuse_pool: bool = True,
605
+ ) -> McpLoadResult:
606
+ """Synchronously load tools from configured MCP servers.
607
+
608
+ Opens long-lived sessions on a background loop. Subsequent tool calls
609
+ reuse those sessions instead of spawning a new process/connection.
610
+ """
611
+ global _ACTIVE_POOL
612
+ if not enabled or not servers:
613
+ return McpLoadResult(tools=[], warnings=[], servers=[], tool_names=[])
614
+
615
+ with _POOL_LOCK:
616
+ if reuse_pool and _ACTIVE_POOL is not None:
617
+ # Replace pool on reload so config changes take effect.
618
+ old = _ACTIVE_POOL
619
+ _ACTIVE_POOL = None
620
+ else:
621
+ old = None
622
+
623
+ if old is not None:
624
+ try:
625
+ old.close()
626
+ except Exception: # noqa: BLE001
627
+ pass
628
+
629
+ pool = McpSessionPool()
630
+ try:
631
+ result = pool.load(servers)
632
+ except Exception as exc: # noqa: BLE001
633
+ pool.close()
634
+ return McpLoadResult(
635
+ tools=[],
636
+ warnings=[f"mcp pool failed: {exc}"],
637
+ servers=[],
638
+ tool_names=[],
639
+ )
640
+
641
+ with _POOL_LOCK:
642
+ _ACTIVE_POOL = pool
643
+ return result
644
+
645
+
646
+ atexit.register(close_active_mcp_pool)
@@ -0,0 +1,62 @@
1
+ """Middleware that adapts image messages for text-only primary models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from typing import Any
7
+
8
+ from langchain.agents.middleware import AgentMiddleware, AgentState
9
+ from langchain.agents.middleware.types import ModelRequest, ModelResponse
10
+
11
+ from synapse.integrations.describe_image import (
12
+ VisionModelClient,
13
+ VisionModelConfig,
14
+ rewrite_messages,
15
+ rewrite_messages_sync,
16
+ )
17
+
18
+
19
+ class DescribeImageMiddleware(AgentMiddleware):
20
+ """Convert image content to text unless the primary model supports images."""
21
+
22
+ state_schema = AgentState
23
+ tools: list[Any] = []
24
+
25
+ def __init__(self, *, image_input: bool, config: VisionModelConfig | None):
26
+ self.image_input = bool(image_input)
27
+ self.client = VisionModelClient(config) if config is not None else None
28
+
29
+ @property
30
+ def name(self) -> str:
31
+ return "describe_image_for_text_model"
32
+
33
+ def wrap_model_call(
34
+ self,
35
+ request: ModelRequest,
36
+ handler: Callable[[ModelRequest], ModelResponse],
37
+ ) -> ModelResponse:
38
+ if self.image_input:
39
+ return handler(request)
40
+ messages = rewrite_messages_sync(request.messages, self.client)
41
+ if messages == request.messages:
42
+ return handler(request)
43
+ return handler(request.override(messages=messages))
44
+
45
+ async def awrap_model_call(
46
+ self,
47
+ request: ModelRequest,
48
+ handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
49
+ ) -> ModelResponse:
50
+ if self.image_input:
51
+ return await handler(request)
52
+ messages = await rewrite_messages(request.messages, self.client)
53
+ if messages == request.messages:
54
+ return await handler(request)
55
+ return await handler(request.override(messages=messages))
56
+
57
+
58
+ def build_describe_image_middleware(
59
+ *, image_input: bool, config: VisionModelConfig | None
60
+ ) -> DescribeImageMiddleware:
61
+ """Build the primary-model image adaptation middleware."""
62
+ return DescribeImageMiddleware(image_input=image_input, config=config)