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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- 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)
|