xg-cli 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.
- xg/__init__.py +3 -0
- xg/__main__.py +3 -0
- xg/adaptive/__init__.py +27 -0
- xg/adaptive/calibrate.py +191 -0
- xg/adaptive/feedback.py +170 -0
- xg/adaptive/learned_rules.py +284 -0
- xg/adaptive/signals.py +125 -0
- xg/adaptive/store.py +164 -0
- xg/agent/__init__.py +0 -0
- xg/agent/plan.py +631 -0
- xg/agent/react.py +268 -0
- xg/agent/team.py +1793 -0
- xg/assets/router.lgb +0 -0
- xg/assets/router_semantics.json +21278 -0
- xg/assets/router_semantics.onnx +0 -0
- xg/cli/__init__.py +0 -0
- xg/cli/app.py +1372 -0
- xg/cli/commands.py +921 -0
- xg/cli/completion.py +740 -0
- xg/cli/help.py +185 -0
- xg/cli/train.py +200 -0
- xg/config/__init__.py +0 -0
- xg/config/env_writer.py +121 -0
- xg/config/manager.py +446 -0
- xg/config/mcp.py +229 -0
- xg/config/provider_service.py +267 -0
- xg/config/providers.py +46 -0
- xg/config/settings.py +258 -0
- xg/config/skills.py +100 -0
- xg/config/smart_router_service.py +121 -0
- xg/config/web.py +140 -0
- xg/input_history/__init__.py +7 -0
- xg/input_history/models.py +28 -0
- xg/input_history/persistence.py +126 -0
- xg/input_history/policy.py +39 -0
- xg/input_history/prompt_toolkit.py +36 -0
- xg/input_history/store.py +118 -0
- xg/llm/__init__.py +0 -0
- xg/llm/client.py +49 -0
- xg/llm/factory.py +40 -0
- xg/llm/openai_compat.py +275 -0
- xg/llm/types.py +98 -0
- xg/mcp/__init__.py +4 -0
- xg/mcp/http.py +192 -0
- xg/mcp/manager.py +726 -0
- xg/mcp/models.py +86 -0
- xg/mcp/protocol.py +62 -0
- xg/mcp/resources.py +72 -0
- xg/mcp/schema.py +137 -0
- xg/mcp/stdio.py +210 -0
- xg/mcp/transport.py +66 -0
- xg/memory/__init__.py +15 -0
- xg/memory/context.py +327 -0
- xg/memory/manager.py +111 -0
- xg/memory/models.py +41 -0
- xg/memory/project.py +187 -0
- xg/memory/store.py +144 -0
- xg/router/__init__.py +124 -0
- xg/router/features.py +66 -0
- xg/router/keywords.py +50 -0
- xg/router/ml_router.py +178 -0
- xg/router/model_tiers.py +73 -0
- xg/router/postprocess.py +167 -0
- xg/router/rule_router.py +77 -0
- xg/router/semantic.py +138 -0
- xg/safety/__init__.py +0 -0
- xg/safety/audit.py +96 -0
- xg/safety/guards.py +106 -0
- xg/safety/hitl.py +73 -0
- xg/skill/__init__.py +9 -0
- xg/skill/errors.py +45 -0
- xg/skill/loader.py +42 -0
- xg/skill/models.py +57 -0
- xg/skill/parser.py +93 -0
- xg/skill/policy.py +40 -0
- xg/skill/prompt.py +45 -0
- xg/skill/registry.py +169 -0
- xg/tool/__init__.py +0 -0
- xg/tool/builtin.py +356 -0
- xg/tool/registry.py +228 -0
- xg/tui/__init__.py +34 -0
- xg/tui/app.py +612 -0
- xg/tui/controller.py +1296 -0
- xg/tui/diagrams/__init__.py +22 -0
- xg/tui/diagrams/layout.py +110 -0
- xg/tui/diagrams/markdown.py +39 -0
- xg/tui/diagrams/model.py +36 -0
- xg/tui/diagrams/parser.py +119 -0
- xg/tui/diagrams/renderer.py +551 -0
- xg/tui/i18n.py +169 -0
- xg/tui/messages.py +45 -0
- xg/tui/plan_renderables.py +147 -0
- xg/tui/reducer.py +1029 -0
- xg/tui/renderables.py +252 -0
- xg/tui/state.py +240 -0
- xg/tui/theme.tcss +208 -0
- xg/tui/widgets/__init__.py +1 -0
- xg/tui/widgets/action_card.py +94 -0
- xg/tui/widgets/agent_group_card.py +39 -0
- xg/tui/widgets/approval_modal.py +59 -0
- xg/tui/widgets/collapsible_card.py +40 -0
- xg/tui/widgets/command_suggestions.py +128 -0
- xg/tui/widgets/composer.py +151 -0
- xg/tui/widgets/config_panel.py +198 -0
- xg/tui/widgets/confirm_modal.py +31 -0
- xg/tui/widgets/footer.py +9 -0
- xg/tui/widgets/header.py +118 -0
- xg/tui/widgets/inspector.py +378 -0
- xg/tui/widgets/plan_modal.py +53 -0
- xg/tui/widgets/provider_form.py +141 -0
- xg/tui/widgets/queue_status.py +30 -0
- xg/tui/widgets/smart_router_form.py +95 -0
- xg/tui/widgets/transcript.py +354 -0
- xg/tui/workers.py +14 -0
- xg/web/__init__.py +21 -0
- xg/web/errors.py +57 -0
- xg/web/extract.py +127 -0
- xg/web/fetch.py +106 -0
- xg/web/markdown.py +77 -0
- xg/web/models.py +91 -0
- xg/web/providers.py +79 -0
- xg/web/search.py +118 -0
- xg/web/searxng.py +27 -0
- xg/web/serpapi.py +29 -0
- xg/web/url_policy.py +110 -0
- xg/web/zhipu.py +27 -0
- xg_cli-1.0.dist-info/METADATA +284 -0
- xg_cli-1.0.dist-info/RECORD +130 -0
- xg_cli-1.0.dist-info/WHEEL +4 -0
- xg_cli-1.0.dist-info/entry_points.txt +2 -0
xg/mcp/manager.py
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
"""MCP server lifecycle, discovery, dynamic tools and resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Awaitable, Callable
|
|
11
|
+
|
|
12
|
+
from xg.config.mcp import McpConfigManager
|
|
13
|
+
from xg.llm.types import ToolResult
|
|
14
|
+
from xg.mcp.http import StreamableHttpTransport
|
|
15
|
+
from xg.mcp.models import McpEvent, McpResource, McpServerConfig, McpServerSnapshot, McpToolSpec
|
|
16
|
+
from xg.mcp.protocol import DEFAULT_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, McpError, McpProtocolError, McpUnavailableError
|
|
17
|
+
from xg.mcp.resources import decode_resource_contents, find_resource_references, redact_uri
|
|
18
|
+
from xg.mcp.schema import exposed_tool_name, sanitize_description, sanitize_schema
|
|
19
|
+
from xg.mcp.stdio import StdioTransport
|
|
20
|
+
from xg.mcp.transport import McpTransport
|
|
21
|
+
from xg.tool.registry import Tool, ToolRegistry
|
|
22
|
+
from xg.safety.audit import redact_text
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
EventListener = Callable[[McpEvent], Awaitable[None] | None]
|
|
26
|
+
TransportFactory = Callable[[McpServerConfig], McpTransport]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class _ServerRuntime:
|
|
31
|
+
config: McpServerConfig
|
|
32
|
+
status: str = "disabled"
|
|
33
|
+
transport: McpTransport | None = None
|
|
34
|
+
protocol_version: str | None = None
|
|
35
|
+
capabilities: dict = field(default_factory=dict)
|
|
36
|
+
tools: dict[str, McpToolSpec] = field(default_factory=dict)
|
|
37
|
+
resources: dict[str, McpResource] = field(default_factory=dict)
|
|
38
|
+
last_error: str = ""
|
|
39
|
+
started_at: datetime | None = None
|
|
40
|
+
last_refresh_at: datetime | None = None
|
|
41
|
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class McpManager:
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
registry: ToolRegistry,
|
|
48
|
+
config_manager: McpConfigManager,
|
|
49
|
+
*,
|
|
50
|
+
approval_policy=None,
|
|
51
|
+
audit=None,
|
|
52
|
+
enabled: bool = True,
|
|
53
|
+
max_servers: int = 32,
|
|
54
|
+
resource_total_chars: int = 64_000,
|
|
55
|
+
transport_factory: TransportFactory | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.registry = registry
|
|
58
|
+
self.config_manager = config_manager
|
|
59
|
+
self.approval_policy = approval_policy
|
|
60
|
+
self.audit = audit
|
|
61
|
+
self.enabled = enabled
|
|
62
|
+
self.max_servers = max(1, max_servers)
|
|
63
|
+
self.resource_total_chars = max(1, resource_total_chars)
|
|
64
|
+
self.transport_factory = transport_factory or self._default_transport
|
|
65
|
+
self._servers: dict[str, _ServerRuntime] = {}
|
|
66
|
+
self._listeners: list[EventListener] = []
|
|
67
|
+
self._started = False
|
|
68
|
+
self._start_lock = asyncio.Lock()
|
|
69
|
+
self._background: set[asyncio.Task] = set()
|
|
70
|
+
self._config_errors: list[str] = []
|
|
71
|
+
self._hitl_names: dict[str, set[str]] = {}
|
|
72
|
+
# A small, intentional adapter hook used by Agent, Plan and commands.
|
|
73
|
+
setattr(self.registry, "mcp_manager", self)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _default_transport(config: McpServerConfig) -> McpTransport:
|
|
77
|
+
if config.transport == "stdio":
|
|
78
|
+
return StdioTransport(config)
|
|
79
|
+
return StreamableHttpTransport(config)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def started(self) -> bool:
|
|
83
|
+
return self._started
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def config_errors(self) -> tuple[str, ...]:
|
|
87
|
+
return tuple(self._config_errors)
|
|
88
|
+
|
|
89
|
+
def add_listener(self, listener: EventListener) -> None:
|
|
90
|
+
self._listeners.append(listener)
|
|
91
|
+
|
|
92
|
+
async def _emit(self, event: McpEvent) -> None:
|
|
93
|
+
for listener in tuple(self._listeners):
|
|
94
|
+
try:
|
|
95
|
+
result = listener(event)
|
|
96
|
+
if inspect.isawaitable(result):
|
|
97
|
+
await result
|
|
98
|
+
except Exception:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
def _audit(self, action: str, **fields) -> None:
|
|
102
|
+
if self.audit is not None:
|
|
103
|
+
self.audit.record(action, **fields)
|
|
104
|
+
|
|
105
|
+
async def ensure_started(self) -> None:
|
|
106
|
+
if not self._started:
|
|
107
|
+
await self.start_all()
|
|
108
|
+
|
|
109
|
+
async def start_all(self) -> None:
|
|
110
|
+
async with self._start_lock:
|
|
111
|
+
if self._started:
|
|
112
|
+
return
|
|
113
|
+
self._started = True
|
|
114
|
+
if not self.enabled:
|
|
115
|
+
return
|
|
116
|
+
loaded = self.config_manager.load()
|
|
117
|
+
self._config_errors = list(loaded.errors)
|
|
118
|
+
configs = list(loaded.servers.values())
|
|
119
|
+
if len(configs) > self.max_servers:
|
|
120
|
+
self._config_errors.append(
|
|
121
|
+
f"MCP Server 数量 {len(configs)} 超过上限 {self.max_servers},仅加载前 {self.max_servers} 个"
|
|
122
|
+
)
|
|
123
|
+
configs = configs[: self.max_servers]
|
|
124
|
+
for config in configs:
|
|
125
|
+
self._servers[config.name] = _ServerRuntime(
|
|
126
|
+
config=config,
|
|
127
|
+
status="disabled" if not config.enabled else "starting",
|
|
128
|
+
)
|
|
129
|
+
self._audit(
|
|
130
|
+
"mcp_server_config_loaded",
|
|
131
|
+
server=config.name,
|
|
132
|
+
transport=config.transport,
|
|
133
|
+
enabled=config.enabled,
|
|
134
|
+
)
|
|
135
|
+
await asyncio.gather(
|
|
136
|
+
*(self.start_server(config.name) for config in configs if config.enabled),
|
|
137
|
+
return_exceptions=True,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
async def start_server(self, name: str) -> bool:
|
|
141
|
+
runtime = self._servers.get(name)
|
|
142
|
+
if runtime is None or not self.enabled:
|
|
143
|
+
return False
|
|
144
|
+
async with runtime.lock:
|
|
145
|
+
if not runtime.config.enabled:
|
|
146
|
+
runtime.status = "disabled"
|
|
147
|
+
return False
|
|
148
|
+
if runtime.status == "ready" and runtime.transport is not None:
|
|
149
|
+
return True
|
|
150
|
+
runtime.status = "starting"
|
|
151
|
+
runtime.last_error = ""
|
|
152
|
+
await self._emit(McpEvent("mcp_server_starting", name))
|
|
153
|
+
self._audit("mcp_server_start", server=name, transport=runtime.config.transport)
|
|
154
|
+
transport = self.transport_factory(runtime.config)
|
|
155
|
+
runtime.transport = transport
|
|
156
|
+
transport.set_notification_handler(
|
|
157
|
+
lambda method, params, server=name: self._on_notification(server, method, params)
|
|
158
|
+
)
|
|
159
|
+
transport.set_disconnect_handler(
|
|
160
|
+
lambda error, server=name: self._on_disconnect(server, error)
|
|
161
|
+
)
|
|
162
|
+
try:
|
|
163
|
+
await asyncio.wait_for(
|
|
164
|
+
self._initialize(runtime), timeout=runtime.config.startup_timeout
|
|
165
|
+
)
|
|
166
|
+
except asyncio.CancelledError:
|
|
167
|
+
runtime.status = "unavailable"
|
|
168
|
+
runtime.last_error = "MCP Server 启动已取消"
|
|
169
|
+
try:
|
|
170
|
+
await transport.close()
|
|
171
|
+
finally:
|
|
172
|
+
runtime.transport = None
|
|
173
|
+
raise
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
# A transport can report the same failure while initialize()
|
|
176
|
+
# is waiting. Route this path through the shared handler so
|
|
177
|
+
# the disconnect callback and startup exception are deduped.
|
|
178
|
+
await self._mark_unavailable(runtime, exc)
|
|
179
|
+
return False
|
|
180
|
+
runtime.status = "ready"
|
|
181
|
+
runtime.started_at = datetime.now()
|
|
182
|
+
runtime.last_refresh_at = runtime.started_at
|
|
183
|
+
self._audit(
|
|
184
|
+
"mcp_server_ready",
|
|
185
|
+
server=name,
|
|
186
|
+
transport=runtime.config.transport,
|
|
187
|
+
protocol_version=runtime.protocol_version,
|
|
188
|
+
tool_count=len(runtime.tools),
|
|
189
|
+
resource_count=len(runtime.resources),
|
|
190
|
+
)
|
|
191
|
+
await self._emit(
|
|
192
|
+
McpEvent(
|
|
193
|
+
"mcp_server_ready",
|
|
194
|
+
name,
|
|
195
|
+
tool_count=len(runtime.tools),
|
|
196
|
+
resource_count=len(runtime.resources),
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
async def _initialize(self, runtime: _ServerRuntime) -> None:
|
|
202
|
+
assert runtime.transport is not None
|
|
203
|
+
await runtime.transport.connect()
|
|
204
|
+
result = await runtime.transport.request(
|
|
205
|
+
"initialize",
|
|
206
|
+
{
|
|
207
|
+
"protocolVersion": DEFAULT_PROTOCOL_VERSION,
|
|
208
|
+
"capabilities": {},
|
|
209
|
+
"clientInfo": {"name": "xg-cli", "version": "0.1.0"},
|
|
210
|
+
},
|
|
211
|
+
)
|
|
212
|
+
version = str(result.get("protocolVersion", ""))
|
|
213
|
+
if version not in SUPPORTED_PROTOCOL_VERSIONS:
|
|
214
|
+
raise McpProtocolError(f"不支持的 MCP protocolVersion: {version or '(empty)'}")
|
|
215
|
+
runtime.protocol_version = version
|
|
216
|
+
runtime.transport.set_protocol_version(version)
|
|
217
|
+
capabilities = result.get("capabilities", {})
|
|
218
|
+
runtime.capabilities = capabilities if isinstance(capabilities, dict) else {}
|
|
219
|
+
await runtime.transport.notify("notifications/initialized")
|
|
220
|
+
if "tools" in runtime.capabilities:
|
|
221
|
+
await self._refresh_tools_locked(runtime)
|
|
222
|
+
else:
|
|
223
|
+
self.registry.replace_source(self._source(runtime.config.name), [])
|
|
224
|
+
if "resources" in runtime.capabilities:
|
|
225
|
+
await self._refresh_resources_locked(runtime, update_registry=False)
|
|
226
|
+
await self._register_tools(runtime)
|
|
227
|
+
await runtime.transport.start_notifications()
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _source(server: str) -> str:
|
|
231
|
+
return f"mcp:{server}"
|
|
232
|
+
|
|
233
|
+
async def _paged(self, runtime: _ServerRuntime, method: str, key: str, limit: int) -> list[dict]:
|
|
234
|
+
assert runtime.transport is not None
|
|
235
|
+
values: list[dict] = []
|
|
236
|
+
cursor: str | None = None
|
|
237
|
+
seen: set[str] = set()
|
|
238
|
+
while len(values) < limit:
|
|
239
|
+
params = {"cursor": cursor} if cursor else None
|
|
240
|
+
result = await runtime.transport.request(method, params)
|
|
241
|
+
page = result.get(key, [])
|
|
242
|
+
if not isinstance(page, list):
|
|
243
|
+
raise McpProtocolError(f"{method} 的 {key} 必须是数组")
|
|
244
|
+
values.extend(item for item in page if isinstance(item, dict))
|
|
245
|
+
next_cursor = result.get("nextCursor")
|
|
246
|
+
if not next_cursor:
|
|
247
|
+
break
|
|
248
|
+
cursor = str(next_cursor)
|
|
249
|
+
if cursor in seen:
|
|
250
|
+
raise McpProtocolError(f"{method} 返回重复 cursor")
|
|
251
|
+
seen.add(cursor)
|
|
252
|
+
return values[:limit]
|
|
253
|
+
|
|
254
|
+
async def refresh_tools(self, name: str) -> bool:
|
|
255
|
+
runtime = self._servers.get(name)
|
|
256
|
+
if runtime is None or runtime.transport is None:
|
|
257
|
+
return False
|
|
258
|
+
async with runtime.lock:
|
|
259
|
+
if runtime.transport is None or runtime.status in {"disabled", "stopping"}:
|
|
260
|
+
return False
|
|
261
|
+
runtime.status = "refreshing"
|
|
262
|
+
try:
|
|
263
|
+
await self._refresh_tools_locked(runtime)
|
|
264
|
+
except Exception as exc:
|
|
265
|
+
runtime.last_error = self._safe_error(exc)
|
|
266
|
+
runtime.status = "ready"
|
|
267
|
+
await self._emit(McpEvent("mcp_warning", name, runtime.last_error))
|
|
268
|
+
return False
|
|
269
|
+
runtime.status = "ready"
|
|
270
|
+
runtime.last_refresh_at = datetime.now()
|
|
271
|
+
return True
|
|
272
|
+
|
|
273
|
+
async def _refresh_tools_locked(self, runtime: _ServerRuntime) -> None:
|
|
274
|
+
raw_tools = await self._paged(
|
|
275
|
+
runtime, "tools/list", "tools", runtime.config.max_tools
|
|
276
|
+
)
|
|
277
|
+
specs: dict[str, McpToolSpec] = {}
|
|
278
|
+
used_exposed: set[str] = set()
|
|
279
|
+
for raw in raw_tools:
|
|
280
|
+
remote_name = str(raw.get("name", "")).strip()
|
|
281
|
+
if not remote_name or len(remote_name) > 128:
|
|
282
|
+
continue
|
|
283
|
+
exposed = exposed_tool_name(runtime.config.name, remote_name)
|
|
284
|
+
if exposed in {
|
|
285
|
+
f"mcp__{runtime.config.name}__resources_list",
|
|
286
|
+
f"mcp__{runtime.config.name}__resources_read",
|
|
287
|
+
}:
|
|
288
|
+
if runtime.transport:
|
|
289
|
+
runtime.transport.log(f"tool name reserved for resources: {remote_name}")
|
|
290
|
+
continue
|
|
291
|
+
if exposed in used_exposed:
|
|
292
|
+
# Sanitization can collapse distinct remote names. Keep the
|
|
293
|
+
# first stable mapping and expose the conflict in logs.
|
|
294
|
+
if runtime.transport:
|
|
295
|
+
runtime.transport.log(f"tool name collision after sanitizing: {remote_name}")
|
|
296
|
+
continue
|
|
297
|
+
schema, warnings = sanitize_schema(raw.get("inputSchema", {}))
|
|
298
|
+
if warnings and runtime.transport:
|
|
299
|
+
runtime.transport.log(f"schema warning {remote_name}: {'; '.join(warnings)}")
|
|
300
|
+
specs[exposed] = McpToolSpec(
|
|
301
|
+
server=runtime.config.name,
|
|
302
|
+
remote_name=remote_name,
|
|
303
|
+
exposed_name=exposed,
|
|
304
|
+
description=sanitize_description(raw.get("description", "")),
|
|
305
|
+
input_schema=schema,
|
|
306
|
+
annotations=raw.get("annotations", {}) if isinstance(raw.get("annotations"), dict) else {},
|
|
307
|
+
)
|
|
308
|
+
used_exposed.add(exposed)
|
|
309
|
+
runtime.tools = specs
|
|
310
|
+
await self._register_tools(runtime)
|
|
311
|
+
self._audit(
|
|
312
|
+
"mcp_tools_refreshed", server=runtime.config.name, tool_count=len(specs)
|
|
313
|
+
)
|
|
314
|
+
await self._emit(
|
|
315
|
+
McpEvent("mcp_tools_refreshed", runtime.config.name, tool_count=len(specs))
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
async def _register_tools(self, runtime: _ServerRuntime) -> None:
|
|
319
|
+
tools: list[Tool] = []
|
|
320
|
+
levels: dict[str, str] = {}
|
|
321
|
+
for spec in runtime.tools.values():
|
|
322
|
+
async def handler(args: dict, server=runtime.config.name, remote=spec.remote_name, exposed=spec.exposed_name) -> ToolResult:
|
|
323
|
+
return await self.call_tool(server, remote, exposed, args)
|
|
324
|
+
|
|
325
|
+
tools.append(
|
|
326
|
+
Tool(
|
|
327
|
+
name=spec.exposed_name,
|
|
328
|
+
description=spec.description or f"MCP tool {spec.remote_name} from {runtime.config.name}",
|
|
329
|
+
parameters=spec.input_schema,
|
|
330
|
+
async_handler=handler,
|
|
331
|
+
source=self._source(runtime.config.name),
|
|
332
|
+
metadata={"server": runtime.config.name, "remote_name": spec.remote_name},
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
levels[spec.exposed_name] = self._approval_level(runtime.config, spec.remote_name)
|
|
336
|
+
if "resources" in runtime.capabilities:
|
|
337
|
+
list_name = f"mcp__{runtime.config.name}__resources_list"
|
|
338
|
+
read_name = f"mcp__{runtime.config.name}__resources_read"
|
|
339
|
+
|
|
340
|
+
async def list_handler(args: dict, server=runtime.config.name, name=list_name) -> ToolResult:
|
|
341
|
+
return ToolResult("", name, True, output=self.format_resources(server))
|
|
342
|
+
|
|
343
|
+
async def read_handler(args: dict, server=runtime.config.name, name=read_name) -> ToolResult:
|
|
344
|
+
uri = str(args.get("uri", "")).strip()
|
|
345
|
+
if not uri:
|
|
346
|
+
return ToolResult("", name, False, error="缺少 uri")
|
|
347
|
+
try:
|
|
348
|
+
text = await self.read_resource(server, uri)
|
|
349
|
+
return ToolResult("", name, True, output=text)
|
|
350
|
+
except McpError as exc:
|
|
351
|
+
return ToolResult("", name, False, error=str(exc))
|
|
352
|
+
|
|
353
|
+
tools.extend([
|
|
354
|
+
Tool(
|
|
355
|
+
list_name,
|
|
356
|
+
f"列出 MCP Server {runtime.config.name} 提供的 resources。",
|
|
357
|
+
{"type": "object", "properties": {}},
|
|
358
|
+
async_handler=list_handler,
|
|
359
|
+
source=self._source(runtime.config.name),
|
|
360
|
+
metadata={"server": runtime.config.name, "virtual": "resources_list"},
|
|
361
|
+
),
|
|
362
|
+
Tool(
|
|
363
|
+
read_name,
|
|
364
|
+
f"读取 MCP Server {runtime.config.name} 的一个 resource。",
|
|
365
|
+
{
|
|
366
|
+
"type": "object",
|
|
367
|
+
"properties": {"uri": {"type": "string", "description": "资源 URI"}},
|
|
368
|
+
"required": ["uri"],
|
|
369
|
+
},
|
|
370
|
+
async_handler=read_handler,
|
|
371
|
+
source=self._source(runtime.config.name),
|
|
372
|
+
metadata={"server": runtime.config.name, "virtual": "resources_read"},
|
|
373
|
+
),
|
|
374
|
+
])
|
|
375
|
+
levels[list_name] = "never"
|
|
376
|
+
levels[read_name] = "never"
|
|
377
|
+
self.registry.replace_source(self._source(runtime.config.name), tools)
|
|
378
|
+
self._set_hitl(runtime.config.name, levels)
|
|
379
|
+
|
|
380
|
+
def _approval_level(self, config: McpServerConfig, remote_name: str) -> str:
|
|
381
|
+
override = config.tool_overrides.get(remote_name)
|
|
382
|
+
if override:
|
|
383
|
+
return override
|
|
384
|
+
return "confirm" if config.hitl == "default" else config.hitl
|
|
385
|
+
|
|
386
|
+
def _set_hitl(self, server: str, levels: dict[str, str]) -> None:
|
|
387
|
+
self._clear_hitl(server)
|
|
388
|
+
if self.approval_policy is None:
|
|
389
|
+
return
|
|
390
|
+
for name, level in levels.items():
|
|
391
|
+
self.approval_policy.levels[name] = level
|
|
392
|
+
self._hitl_names[server] = set(levels)
|
|
393
|
+
|
|
394
|
+
def _clear_hitl(self, server: str) -> None:
|
|
395
|
+
names = self._hitl_names.pop(server, set())
|
|
396
|
+
if self.approval_policy is not None:
|
|
397
|
+
for name in names:
|
|
398
|
+
self.approval_policy.levels.pop(name, None)
|
|
399
|
+
|
|
400
|
+
async def call_tool(
|
|
401
|
+
self, server: str, remote_name: str, exposed_name: str, args: dict
|
|
402
|
+
) -> ToolResult:
|
|
403
|
+
runtime = self._servers.get(server)
|
|
404
|
+
if runtime is None or runtime.status not in {"ready", "refreshing"} or runtime.transport is None:
|
|
405
|
+
return ToolResult("", exposed_name, False, error=f"MCP Server {server} 不可用")
|
|
406
|
+
if exposed_name not in runtime.tools:
|
|
407
|
+
return ToolResult("", exposed_name, False, error=f"MCP 工具已不可用: {remote_name}")
|
|
408
|
+
try:
|
|
409
|
+
result = await runtime.transport.request(
|
|
410
|
+
"tools/call", {"name": remote_name, "arguments": args}
|
|
411
|
+
)
|
|
412
|
+
except McpError as exc:
|
|
413
|
+
if isinstance(exc, (McpUnavailableError, McpProtocolError)):
|
|
414
|
+
await self._mark_unavailable(runtime, exc)
|
|
415
|
+
return ToolResult("", exposed_name, False, error=str(exc))
|
|
416
|
+
output_parts: list[str] = []
|
|
417
|
+
content = result.get("content", [])
|
|
418
|
+
if isinstance(content, list):
|
|
419
|
+
for block in content:
|
|
420
|
+
if not isinstance(block, dict):
|
|
421
|
+
continue
|
|
422
|
+
if isinstance(block.get("text"), str):
|
|
423
|
+
output_parts.append(block["text"])
|
|
424
|
+
elif block.get("type") == "resource" and isinstance(block.get("resource"), dict):
|
|
425
|
+
resource_text, _ = decode_resource_contents(
|
|
426
|
+
{"contents": [block["resource"]]}, runtime.config.max_output_chars
|
|
427
|
+
)
|
|
428
|
+
output_parts.append(resource_text)
|
|
429
|
+
else:
|
|
430
|
+
output_parts.append(json.dumps(block, ensure_ascii=False))
|
|
431
|
+
structured = result.get("structuredContent")
|
|
432
|
+
if structured is not None:
|
|
433
|
+
output_parts.append(json.dumps(structured, ensure_ascii=False, indent=2))
|
|
434
|
+
output = "\n\n".join(part for part in output_parts if part)
|
|
435
|
+
is_error = bool(result.get("isError", False))
|
|
436
|
+
if is_error:
|
|
437
|
+
return ToolResult("", exposed_name, False, error=output or "MCP tool returned an error")
|
|
438
|
+
return ToolResult("", exposed_name, True, output=output or "(no output)")
|
|
439
|
+
|
|
440
|
+
async def refresh_resources(self, name: str) -> bool:
|
|
441
|
+
runtime = self._servers.get(name)
|
|
442
|
+
if runtime is None or runtime.transport is None:
|
|
443
|
+
return False
|
|
444
|
+
async with runtime.lock:
|
|
445
|
+
if runtime.transport is None or runtime.status in {"disabled", "stopping"}:
|
|
446
|
+
return False
|
|
447
|
+
previous = dict(runtime.resources)
|
|
448
|
+
runtime.status = "refreshing"
|
|
449
|
+
try:
|
|
450
|
+
await self._refresh_resources_locked(runtime)
|
|
451
|
+
except Exception as exc:
|
|
452
|
+
runtime.resources = previous
|
|
453
|
+
runtime.last_error = self._safe_error(exc)
|
|
454
|
+
runtime.status = "ready"
|
|
455
|
+
await self._emit(McpEvent("mcp_warning", name, runtime.last_error))
|
|
456
|
+
return False
|
|
457
|
+
runtime.status = "ready"
|
|
458
|
+
runtime.last_refresh_at = datetime.now()
|
|
459
|
+
return True
|
|
460
|
+
|
|
461
|
+
async def _refresh_resources_locked(
|
|
462
|
+
self, runtime: _ServerRuntime, *, update_registry: bool = True
|
|
463
|
+
) -> None:
|
|
464
|
+
raw_resources = await self._paged(
|
|
465
|
+
runtime, "resources/list", "resources", runtime.config.max_resources
|
|
466
|
+
)
|
|
467
|
+
resources: dict[str, McpResource] = {}
|
|
468
|
+
for raw in raw_resources:
|
|
469
|
+
uri = str(raw.get("uri", "")).strip()
|
|
470
|
+
if not uri:
|
|
471
|
+
continue
|
|
472
|
+
size = raw.get("size")
|
|
473
|
+
resources[uri] = McpResource(
|
|
474
|
+
server=runtime.config.name,
|
|
475
|
+
uri=uri,
|
|
476
|
+
name=str(raw.get("name") or uri),
|
|
477
|
+
description=sanitize_description(raw.get("description", "")),
|
|
478
|
+
mime_type=str(raw.get("mimeType")) if raw.get("mimeType") else None,
|
|
479
|
+
size=int(size) if isinstance(size, (int, float)) else None,
|
|
480
|
+
metadata={key: value for key, value in raw.items() if key not in {"uri", "name", "description", "mimeType", "size"}},
|
|
481
|
+
)
|
|
482
|
+
runtime.resources = resources
|
|
483
|
+
if update_registry:
|
|
484
|
+
await self._register_tools(runtime)
|
|
485
|
+
self._audit(
|
|
486
|
+
"mcp_resources_refreshed",
|
|
487
|
+
server=runtime.config.name,
|
|
488
|
+
resource_count=len(resources),
|
|
489
|
+
)
|
|
490
|
+
await self._emit(
|
|
491
|
+
McpEvent(
|
|
492
|
+
"mcp_resources_refreshed",
|
|
493
|
+
runtime.config.name,
|
|
494
|
+
resource_count=len(resources),
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
async def read_resource(self, server: str, uri: str) -> str:
|
|
499
|
+
runtime = self._servers.get(server)
|
|
500
|
+
if runtime is None or runtime.status not in {"ready", "refreshing"} or runtime.transport is None:
|
|
501
|
+
raise McpUnavailableError(f"MCP Server {server} 不可用")
|
|
502
|
+
if uri not in runtime.resources:
|
|
503
|
+
raise McpUnavailableError(f"MCP Server {server} 未声明 resource: {uri}")
|
|
504
|
+
try:
|
|
505
|
+
result = await runtime.transport.request("resources/read", {"uri": uri})
|
|
506
|
+
except McpError as exc:
|
|
507
|
+
self._audit(
|
|
508
|
+
"mcp_resource_read",
|
|
509
|
+
server=server,
|
|
510
|
+
uri=redact_uri(uri),
|
|
511
|
+
ok=False,
|
|
512
|
+
error=self._safe_error(exc),
|
|
513
|
+
)
|
|
514
|
+
if isinstance(exc, (McpUnavailableError, McpProtocolError)):
|
|
515
|
+
await self._mark_unavailable(runtime, exc)
|
|
516
|
+
raise
|
|
517
|
+
text, _ = decode_resource_contents(result, runtime.config.resource_max_chars)
|
|
518
|
+
if not text:
|
|
519
|
+
text = "(empty resource)"
|
|
520
|
+
self._audit(
|
|
521
|
+
"mcp_resource_read",
|
|
522
|
+
server=server,
|
|
523
|
+
uri=redact_uri(uri),
|
|
524
|
+
chars=len(text),
|
|
525
|
+
ok=True,
|
|
526
|
+
)
|
|
527
|
+
await self._emit(McpEvent("mcp_resource_read", server, redact_uri(uri)))
|
|
528
|
+
return text
|
|
529
|
+
|
|
530
|
+
async def expand_references(self, user_input: str) -> str:
|
|
531
|
+
references = find_resource_references(user_input)
|
|
532
|
+
if not references:
|
|
533
|
+
return user_input
|
|
534
|
+
blocks: list[str] = []
|
|
535
|
+
total = 0
|
|
536
|
+
seen: set[tuple[str, str]] = set()
|
|
537
|
+
for reference in references:
|
|
538
|
+
key = (reference.server, reference.uri)
|
|
539
|
+
if key in seen:
|
|
540
|
+
continue
|
|
541
|
+
seen.add(key)
|
|
542
|
+
text = await self.read_resource(reference.server, reference.uri)
|
|
543
|
+
remaining = self.resource_total_chars - total
|
|
544
|
+
if remaining <= 0:
|
|
545
|
+
raise McpProtocolError("当前输入引用的 MCP resources 总量超过上限")
|
|
546
|
+
if len(text) > remaining:
|
|
547
|
+
text = text[:remaining] + "\n... (资源总量达到上限,已截断)"
|
|
548
|
+
total += len(text)
|
|
549
|
+
blocks.append(
|
|
550
|
+
f"[外部 MCP resource: server={reference.server}, uri={redact_uri(reference.uri)}]\n"
|
|
551
|
+
f"--- begin resource content ---\n{text}\n--- end resource content ---"
|
|
552
|
+
)
|
|
553
|
+
return user_input + "\n\n" + "\n\n".join(blocks)
|
|
554
|
+
|
|
555
|
+
async def _on_notification(self, server: str, method: str, params: dict) -> None:
|
|
556
|
+
if method in {"notifications/tools/list_changed", "tools/list_changed"}:
|
|
557
|
+
self._schedule(self.refresh_tools(server))
|
|
558
|
+
elif method in {"notifications/resources/list_changed", "resources/list_changed"}:
|
|
559
|
+
self._schedule(self.refresh_resources(server))
|
|
560
|
+
|
|
561
|
+
async def _on_disconnect(self, server: str, error: Exception) -> None:
|
|
562
|
+
runtime = self._servers.get(server)
|
|
563
|
+
if runtime is not None:
|
|
564
|
+
await self._mark_unavailable(runtime, error)
|
|
565
|
+
|
|
566
|
+
async def _mark_unavailable(self, runtime: _ServerRuntime, error: Exception) -> None:
|
|
567
|
+
if runtime.status in {"disabled", "stopping", "unavailable"}:
|
|
568
|
+
return
|
|
569
|
+
runtime.status = "unavailable"
|
|
570
|
+
runtime.last_error = self._safe_error(error)
|
|
571
|
+
transport = runtime.transport
|
|
572
|
+
runtime.transport = None
|
|
573
|
+
self.registry.unregister_source(self._source(runtime.config.name))
|
|
574
|
+
self._clear_hitl(runtime.config.name)
|
|
575
|
+
self._audit(
|
|
576
|
+
"mcp_server_unavailable",
|
|
577
|
+
server=runtime.config.name,
|
|
578
|
+
transport=runtime.config.transport,
|
|
579
|
+
error=runtime.last_error,
|
|
580
|
+
)
|
|
581
|
+
await self._emit(McpEvent(
|
|
582
|
+
"mcp_server_unavailable", runtime.config.name, runtime.last_error
|
|
583
|
+
))
|
|
584
|
+
if transport is not None:
|
|
585
|
+
try:
|
|
586
|
+
await transport.close()
|
|
587
|
+
except Exception:
|
|
588
|
+
pass
|
|
589
|
+
|
|
590
|
+
def _schedule(self, awaitable) -> None:
|
|
591
|
+
task = asyncio.create_task(awaitable)
|
|
592
|
+
self._background.add(task)
|
|
593
|
+
task.add_done_callback(self._background.discard)
|
|
594
|
+
|
|
595
|
+
def snapshots(self) -> list[McpServerSnapshot]:
|
|
596
|
+
return [
|
|
597
|
+
McpServerSnapshot(
|
|
598
|
+
name=name,
|
|
599
|
+
transport=runtime.config.transport,
|
|
600
|
+
status=runtime.status, # type: ignore[arg-type]
|
|
601
|
+
protocol_version=runtime.protocol_version,
|
|
602
|
+
tool_count=len(runtime.tools),
|
|
603
|
+
resource_count=len(runtime.resources),
|
|
604
|
+
last_error=runtime.last_error,
|
|
605
|
+
started_at=runtime.started_at,
|
|
606
|
+
last_refresh_at=runtime.last_refresh_at,
|
|
607
|
+
)
|
|
608
|
+
for name, runtime in sorted(self._servers.items())
|
|
609
|
+
]
|
|
610
|
+
|
|
611
|
+
def format_status(self) -> str:
|
|
612
|
+
lines: list[str] = []
|
|
613
|
+
if not self.enabled:
|
|
614
|
+
return "MCP 已通过 XG_MCP_ENABLED 关闭。"
|
|
615
|
+
for error in self._config_errors:
|
|
616
|
+
lines.append(f"配置错误: {error}")
|
|
617
|
+
for snapshot in self.snapshots():
|
|
618
|
+
detail = (
|
|
619
|
+
f"{snapshot.name}: {snapshot.status} [{snapshot.transport}] "
|
|
620
|
+
f"tools={snapshot.tool_count} resources={snapshot.resource_count}"
|
|
621
|
+
)
|
|
622
|
+
if snapshot.last_error:
|
|
623
|
+
detail += f" error={snapshot.last_error}"
|
|
624
|
+
lines.append(detail)
|
|
625
|
+
return "\n".join(lines) if lines else "未配置 MCP Server。"
|
|
626
|
+
|
|
627
|
+
def format_resources(self, server: str | None = None) -> str:
|
|
628
|
+
lines: list[str] = []
|
|
629
|
+
runtimes = [self._servers[server]] if server in self._servers else self._servers.values() if server is None else []
|
|
630
|
+
for runtime in runtimes:
|
|
631
|
+
for resource in runtime.resources.values():
|
|
632
|
+
size = f" {resource.size}B" if resource.size is not None else ""
|
|
633
|
+
mime = f" {resource.mime_type}" if resource.mime_type else ""
|
|
634
|
+
lines.append(f"{runtime.config.name}: {resource.uri} {resource.name}{mime}{size}")
|
|
635
|
+
if server is not None and server not in self._servers:
|
|
636
|
+
return f"未知 MCP Server: {server}"
|
|
637
|
+
return "\n".join(lines) if lines else "没有可用的 MCP resources。"
|
|
638
|
+
|
|
639
|
+
def logs(self, name: str, limit: int = 50) -> str:
|
|
640
|
+
runtime = self._servers.get(name)
|
|
641
|
+
if runtime is None:
|
|
642
|
+
return f"未知 MCP Server: {name}"
|
|
643
|
+
if runtime.transport is None:
|
|
644
|
+
return runtime.last_error or f"MCP Server {name} 当前没有日志"
|
|
645
|
+
lines = runtime.transport.recent_logs(limit)
|
|
646
|
+
return "\n".join(redact_text(line) for line in lines) if lines else f"MCP Server {name} 当前没有日志"
|
|
647
|
+
|
|
648
|
+
async def restart(self, name: str) -> bool:
|
|
649
|
+
if name not in self._servers or not self.enabled:
|
|
650
|
+
return False
|
|
651
|
+
await self.stop_server(name, restarting=True)
|
|
652
|
+
loaded = self.config_manager.load()
|
|
653
|
+
config = loaded.servers.get(name)
|
|
654
|
+
if config is None:
|
|
655
|
+
return False
|
|
656
|
+
runtime = self._servers[name]
|
|
657
|
+
runtime.config = config
|
|
658
|
+
runtime.status = "restarting"
|
|
659
|
+
self._audit("mcp_server_restart", server=name)
|
|
660
|
+
return await self.start_server(name)
|
|
661
|
+
|
|
662
|
+
async def set_enabled(self, name: str, enabled: bool) -> bool:
|
|
663
|
+
if enabled and not self.enabled:
|
|
664
|
+
return False
|
|
665
|
+
loaded = self.config_manager.load()
|
|
666
|
+
if name not in loaded.servers and name not in self._servers:
|
|
667
|
+
return False
|
|
668
|
+
self.config_manager.set_enabled(name, enabled)
|
|
669
|
+
refreshed = self.config_manager.load()
|
|
670
|
+
config = refreshed.servers.get(name)
|
|
671
|
+
if config is None:
|
|
672
|
+
return False
|
|
673
|
+
if not enabled:
|
|
674
|
+
if name not in self._servers:
|
|
675
|
+
self._servers[name] = _ServerRuntime(config=config, status="disabled")
|
|
676
|
+
else:
|
|
677
|
+
self._servers[name].config = config
|
|
678
|
+
await self.stop_server(name)
|
|
679
|
+
self._servers[name].status = "disabled"
|
|
680
|
+
return True
|
|
681
|
+
runtime = self._servers.get(name)
|
|
682
|
+
if runtime is None:
|
|
683
|
+
runtime = _ServerRuntime(config=config, status="starting")
|
|
684
|
+
self._servers[name] = runtime
|
|
685
|
+
else:
|
|
686
|
+
runtime.config = config
|
|
687
|
+
return await self.start_server(name)
|
|
688
|
+
|
|
689
|
+
async def stop_server(self, name: str, *, restarting: bool = False) -> bool:
|
|
690
|
+
runtime = self._servers.get(name)
|
|
691
|
+
if runtime is None:
|
|
692
|
+
return False
|
|
693
|
+
async with runtime.lock:
|
|
694
|
+
runtime.status = "restarting" if restarting else "stopping"
|
|
695
|
+
transport = runtime.transport
|
|
696
|
+
runtime.transport = None
|
|
697
|
+
self.registry.unregister_source(self._source(name))
|
|
698
|
+
self._clear_hitl(name)
|
|
699
|
+
if transport is not None:
|
|
700
|
+
try:
|
|
701
|
+
await transport.close()
|
|
702
|
+
except Exception as exc:
|
|
703
|
+
runtime.last_error = self._safe_error(exc)
|
|
704
|
+
runtime.tools.clear()
|
|
705
|
+
runtime.resources.clear()
|
|
706
|
+
if not restarting:
|
|
707
|
+
runtime.status = "disabled" if not runtime.config.enabled else "unavailable"
|
|
708
|
+
self._audit("mcp_server_stop", server=name)
|
|
709
|
+
await self._emit(McpEvent("mcp_server_stopping", name))
|
|
710
|
+
return True
|
|
711
|
+
|
|
712
|
+
async def close(self) -> None:
|
|
713
|
+
for task in tuple(self._background):
|
|
714
|
+
task.cancel()
|
|
715
|
+
if self._background:
|
|
716
|
+
await asyncio.gather(*self._background, return_exceptions=True)
|
|
717
|
+
await asyncio.gather(
|
|
718
|
+
*(self.stop_server(name) for name in list(self._servers)),
|
|
719
|
+
return_exceptions=True,
|
|
720
|
+
)
|
|
721
|
+
self._servers.clear()
|
|
722
|
+
self._started = False
|
|
723
|
+
|
|
724
|
+
@staticmethod
|
|
725
|
+
def _safe_error(exc: Exception) -> str:
|
|
726
|
+
return redact_text(f"{type(exc).__name__}: {exc}"[:1000])
|