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.
Files changed (130) hide show
  1. xg/__init__.py +3 -0
  2. xg/__main__.py +3 -0
  3. xg/adaptive/__init__.py +27 -0
  4. xg/adaptive/calibrate.py +191 -0
  5. xg/adaptive/feedback.py +170 -0
  6. xg/adaptive/learned_rules.py +284 -0
  7. xg/adaptive/signals.py +125 -0
  8. xg/adaptive/store.py +164 -0
  9. xg/agent/__init__.py +0 -0
  10. xg/agent/plan.py +631 -0
  11. xg/agent/react.py +268 -0
  12. xg/agent/team.py +1793 -0
  13. xg/assets/router.lgb +0 -0
  14. xg/assets/router_semantics.json +21278 -0
  15. xg/assets/router_semantics.onnx +0 -0
  16. xg/cli/__init__.py +0 -0
  17. xg/cli/app.py +1372 -0
  18. xg/cli/commands.py +921 -0
  19. xg/cli/completion.py +740 -0
  20. xg/cli/help.py +185 -0
  21. xg/cli/train.py +200 -0
  22. xg/config/__init__.py +0 -0
  23. xg/config/env_writer.py +121 -0
  24. xg/config/manager.py +446 -0
  25. xg/config/mcp.py +229 -0
  26. xg/config/provider_service.py +267 -0
  27. xg/config/providers.py +46 -0
  28. xg/config/settings.py +258 -0
  29. xg/config/skills.py +100 -0
  30. xg/config/smart_router_service.py +121 -0
  31. xg/config/web.py +140 -0
  32. xg/input_history/__init__.py +7 -0
  33. xg/input_history/models.py +28 -0
  34. xg/input_history/persistence.py +126 -0
  35. xg/input_history/policy.py +39 -0
  36. xg/input_history/prompt_toolkit.py +36 -0
  37. xg/input_history/store.py +118 -0
  38. xg/llm/__init__.py +0 -0
  39. xg/llm/client.py +49 -0
  40. xg/llm/factory.py +40 -0
  41. xg/llm/openai_compat.py +275 -0
  42. xg/llm/types.py +98 -0
  43. xg/mcp/__init__.py +4 -0
  44. xg/mcp/http.py +192 -0
  45. xg/mcp/manager.py +726 -0
  46. xg/mcp/models.py +86 -0
  47. xg/mcp/protocol.py +62 -0
  48. xg/mcp/resources.py +72 -0
  49. xg/mcp/schema.py +137 -0
  50. xg/mcp/stdio.py +210 -0
  51. xg/mcp/transport.py +66 -0
  52. xg/memory/__init__.py +15 -0
  53. xg/memory/context.py +327 -0
  54. xg/memory/manager.py +111 -0
  55. xg/memory/models.py +41 -0
  56. xg/memory/project.py +187 -0
  57. xg/memory/store.py +144 -0
  58. xg/router/__init__.py +124 -0
  59. xg/router/features.py +66 -0
  60. xg/router/keywords.py +50 -0
  61. xg/router/ml_router.py +178 -0
  62. xg/router/model_tiers.py +73 -0
  63. xg/router/postprocess.py +167 -0
  64. xg/router/rule_router.py +77 -0
  65. xg/router/semantic.py +138 -0
  66. xg/safety/__init__.py +0 -0
  67. xg/safety/audit.py +96 -0
  68. xg/safety/guards.py +106 -0
  69. xg/safety/hitl.py +73 -0
  70. xg/skill/__init__.py +9 -0
  71. xg/skill/errors.py +45 -0
  72. xg/skill/loader.py +42 -0
  73. xg/skill/models.py +57 -0
  74. xg/skill/parser.py +93 -0
  75. xg/skill/policy.py +40 -0
  76. xg/skill/prompt.py +45 -0
  77. xg/skill/registry.py +169 -0
  78. xg/tool/__init__.py +0 -0
  79. xg/tool/builtin.py +356 -0
  80. xg/tool/registry.py +228 -0
  81. xg/tui/__init__.py +34 -0
  82. xg/tui/app.py +612 -0
  83. xg/tui/controller.py +1296 -0
  84. xg/tui/diagrams/__init__.py +22 -0
  85. xg/tui/diagrams/layout.py +110 -0
  86. xg/tui/diagrams/markdown.py +39 -0
  87. xg/tui/diagrams/model.py +36 -0
  88. xg/tui/diagrams/parser.py +119 -0
  89. xg/tui/diagrams/renderer.py +551 -0
  90. xg/tui/i18n.py +169 -0
  91. xg/tui/messages.py +45 -0
  92. xg/tui/plan_renderables.py +147 -0
  93. xg/tui/reducer.py +1029 -0
  94. xg/tui/renderables.py +252 -0
  95. xg/tui/state.py +240 -0
  96. xg/tui/theme.tcss +208 -0
  97. xg/tui/widgets/__init__.py +1 -0
  98. xg/tui/widgets/action_card.py +94 -0
  99. xg/tui/widgets/agent_group_card.py +39 -0
  100. xg/tui/widgets/approval_modal.py +59 -0
  101. xg/tui/widgets/collapsible_card.py +40 -0
  102. xg/tui/widgets/command_suggestions.py +128 -0
  103. xg/tui/widgets/composer.py +151 -0
  104. xg/tui/widgets/config_panel.py +198 -0
  105. xg/tui/widgets/confirm_modal.py +31 -0
  106. xg/tui/widgets/footer.py +9 -0
  107. xg/tui/widgets/header.py +118 -0
  108. xg/tui/widgets/inspector.py +378 -0
  109. xg/tui/widgets/plan_modal.py +53 -0
  110. xg/tui/widgets/provider_form.py +141 -0
  111. xg/tui/widgets/queue_status.py +30 -0
  112. xg/tui/widgets/smart_router_form.py +95 -0
  113. xg/tui/widgets/transcript.py +354 -0
  114. xg/tui/workers.py +14 -0
  115. xg/web/__init__.py +21 -0
  116. xg/web/errors.py +57 -0
  117. xg/web/extract.py +127 -0
  118. xg/web/fetch.py +106 -0
  119. xg/web/markdown.py +77 -0
  120. xg/web/models.py +91 -0
  121. xg/web/providers.py +79 -0
  122. xg/web/search.py +118 -0
  123. xg/web/searxng.py +27 -0
  124. xg/web/serpapi.py +29 -0
  125. xg/web/url_policy.py +110 -0
  126. xg/web/zhipu.py +27 -0
  127. xg_cli-1.0.dist-info/METADATA +284 -0
  128. xg_cli-1.0.dist-info/RECORD +130 -0
  129. xg_cli-1.0.dist-info/WHEEL +4 -0
  130. 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])