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/llm/factory.py ADDED
@@ -0,0 +1,40 @@
1
+ """LlmClient 工厂:按解析后的 api_base / key / model 组装客户端。
2
+
3
+ URL 与 Key 的解析(环境变量、配置合并)由 ConfigManager 负责,
4
+ 工厂只负责组装客户端,是后续 provider 差异化(能力、协议)的扩展点。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from xg.llm.client import LlmClient
10
+ from xg.llm.openai_compat import OpenAICompatClient
11
+
12
+ DEFAULT_TIMEOUT = 120.0
13
+
14
+
15
+ def create_client(
16
+ api_base: str,
17
+ api_key: str,
18
+ model: str,
19
+ timeout: float = DEFAULT_TIMEOUT,
20
+ retry_enabled: bool = True,
21
+ max_retries: int = 2,
22
+ retry_base_delay: float = 1.0,
23
+ retry_max_delay: float = 8.0,
24
+ retry_jitter: float = 0.25,
25
+ retry_total_timeout: float = 30.0,
26
+ respect_retry_after: bool = True,
27
+ ) -> LlmClient:
28
+ return OpenAICompatClient(
29
+ api_base=api_base,
30
+ api_key=api_key,
31
+ model=model,
32
+ timeout=timeout,
33
+ retry_enabled=retry_enabled,
34
+ max_retries=max_retries,
35
+ retry_base_delay=retry_base_delay,
36
+ retry_max_delay=retry_max_delay,
37
+ retry_jitter=retry_jitter,
38
+ retry_total_timeout=retry_total_timeout,
39
+ respect_retry_after=respect_retry_after,
40
+ )
@@ -0,0 +1,275 @@
1
+ """OpenAI-compatible Chat Completions 实现(httpx SSE 流式 + tool calling 增量聚合)。
2
+
3
+ 所有 provider(GLM / DeepSeek / Kimi / OpenAI 兼容)共享此模板。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+ import random
11
+ import time
12
+ from email.utils import parsedate_to_datetime
13
+ from typing import Any, AsyncIterator
14
+
15
+ import httpx
16
+
17
+ from xg.llm.client import LlmClient, LlmError
18
+ from xg.llm.types import Message, StreamEvent, ToolCall, Usage
19
+
20
+ DEFAULT_TIMEOUT = 120.0
21
+
22
+
23
+ def _looks_like_html_error(content_type: str = "", body: str = "") -> bool:
24
+ """Identify gateway/proxy error pages, which often use HTTP 400 generically."""
25
+ media_type = content_type.split(";", 1)[0].strip().lower()
26
+ prefix = body.lstrip()[:512].lower()
27
+ return media_type == "text/html" or prefix.startswith(("<!doctype html", "<html", "<head", "<body"))
28
+
29
+
30
+ def _status_retry_policy(
31
+ status_code: int,
32
+ *,
33
+ content_type: str = "",
34
+ body: str = "",
35
+ ) -> tuple[bool, str]:
36
+ if status_code == 408:
37
+ return True, "transient_api_error"
38
+ if status_code == 429:
39
+ return True, "rate_limited"
40
+ if status_code == 503:
41
+ return True, "service_unavailable"
42
+ if status_code == 504:
43
+ return True, "gateway_timeout"
44
+ if 500 <= status_code < 600:
45
+ return True, "transient_api_error"
46
+ if status_code in {400, 422}:
47
+ if _looks_like_html_error(content_type, body):
48
+ return True, "proxy_bad_request"
49
+ return False, "invalid_request"
50
+ if status_code == 401:
51
+ return False, "authentication_error"
52
+ if status_code == 403:
53
+ return False, "permission_error"
54
+ if status_code == 404:
55
+ return False, "endpoint_not_found"
56
+ if status_code == 413:
57
+ return False, "request_too_large"
58
+ return False, "api_error"
59
+
60
+
61
+ def _parse_retry_after(value: str | None) -> float | None:
62
+ if not value:
63
+ return None
64
+ try:
65
+ return max(0.0, float(value.strip()))
66
+ except ValueError:
67
+ try:
68
+ parsed = parsedate_to_datetime(value)
69
+ return max(0.0, parsed.timestamp() - time.time())
70
+ except (TypeError, ValueError, OverflowError):
71
+ return None
72
+
73
+
74
+ class OpenAICompatClient(LlmClient):
75
+ def __init__(
76
+ self,
77
+ api_base: str,
78
+ api_key: str,
79
+ model: str,
80
+ timeout: float = DEFAULT_TIMEOUT,
81
+ retry_enabled: bool = True,
82
+ max_retries: int = 2,
83
+ retry_base_delay: float = 1.0,
84
+ retry_max_delay: float = 8.0,
85
+ retry_jitter: float = 0.25,
86
+ retry_total_timeout: float = 30.0,
87
+ respect_retry_after: bool = True,
88
+ ) -> None:
89
+ # F5:允许以空配置构造(启动不阻断),真正调用时再拦截并给出引导。
90
+ self.api_base = (api_base or "").rstrip("/")
91
+ self.api_key = api_key
92
+ self.model = model
93
+ self.timeout = timeout
94
+ self.retry_enabled = retry_enabled
95
+ self.max_retries = max(0, max_retries)
96
+ self.retry_base_delay = max(0.0, retry_base_delay)
97
+ self.retry_max_delay = max(0.0, retry_max_delay)
98
+ self.retry_jitter = max(0.0, min(1.0, retry_jitter))
99
+ self.retry_total_timeout = max(0.0, retry_total_timeout)
100
+ self.respect_retry_after = respect_retry_after
101
+
102
+ def stream_chat(
103
+ self, messages: list[Message], tools: list[dict] | None = None
104
+ ) -> AsyncIterator[StreamEvent]:
105
+ return self._stream_with_retry(messages, tools)
106
+
107
+ async def _stream_with_retry(
108
+ self, messages: list[Message], tools: list[dict] | None
109
+ ) -> AsyncIterator[StreamEvent]:
110
+ started_at = time.monotonic()
111
+ max_attempts = 1 + (self.max_retries if self.retry_enabled else 0)
112
+ for attempt in range(1, max_attempts + 1):
113
+ semantic_event_seen = False
114
+ try:
115
+ async for event in self._stream_once(messages, tools):
116
+ if event.kind in {"content", "thinking", "tool_call"}:
117
+ semantic_event_seen = True
118
+ yield event
119
+ return
120
+ except LlmError as error:
121
+ error.response_started = error.response_started or semantic_event_seen
122
+ error.attempt = attempt
123
+ error.max_attempts = max_attempts
124
+ if (
125
+ not self.retry_enabled
126
+ or attempt >= max_attempts
127
+ or not error.retryable
128
+ or error.response_started
129
+ ):
130
+ raise
131
+ delay = self._retry_delay(error, attempt)
132
+ elapsed = time.monotonic() - started_at
133
+ if elapsed + delay > self.retry_total_timeout:
134
+ error.category = f"{error.category}_retry_timeout"
135
+ raise
136
+ yield StreamEvent(
137
+ kind="retrying",
138
+ text="API 临时故障,正在重试",
139
+ attempt=attempt + 1,
140
+ max_attempts=max_attempts,
141
+ retry_after=delay,
142
+ )
143
+ await asyncio.sleep(delay)
144
+
145
+ def _retry_delay(self, error: LlmError, attempt: int) -> float:
146
+ delay = min(self.retry_max_delay, self.retry_base_delay * (2 ** (attempt - 1)))
147
+ if self.respect_retry_after and error.retry_after is not None:
148
+ delay = max(delay, error.retry_after)
149
+ if self.retry_jitter:
150
+ delay *= random.uniform(1.0 - self.retry_jitter, 1.0 + self.retry_jitter)
151
+ return max(0.0, min(delay, self.retry_max_delay))
152
+
153
+ async def _stream_once(
154
+ self, messages: list[Message], tools: list[dict] | None
155
+ ) -> AsyncIterator[StreamEvent]:
156
+ if not self.api_base or not self.api_key or not self.model:
157
+ # F5:仅调用 LLM 时拦截,提示在会话内配置即可用,无需重启。
158
+ raise LlmError(
159
+ "缺少可用的 base provider / API Key。请在会话内用 /provider 配置,"
160
+ "例如 /provider add <name> <api_base> --model M --key K --set-base。"
161
+ )
162
+ payload: dict[str, Any] = {
163
+ "model": self.model,
164
+ "messages": [m.to_api() for m in messages],
165
+ "stream": True,
166
+ "stream_options": {"include_usage": True},
167
+ }
168
+ if tools:
169
+ payload["tools"] = [
170
+ {"type": "function", "function": t} for t in tools
171
+ ]
172
+
173
+ url = f"{self.api_base}/chat/completions"
174
+ headers = {
175
+ "Authorization": f"Bearer {self.api_key}",
176
+ "Content-Type": "application/json",
177
+ "Accept": "text/event-stream",
178
+ }
179
+
180
+ try:
181
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
182
+ async with client.stream("POST", url, json=payload, headers=headers) as resp:
183
+ if resp.status_code != 200:
184
+ body = (await resp.aread()).decode("utf-8", errors="replace")
185
+ status = resp.status_code
186
+ retryable, category = _status_retry_policy(
187
+ status,
188
+ content_type=resp.headers.get("Content-Type", ""),
189
+ body=body,
190
+ )
191
+ raise LlmError(
192
+ f"API 返回 {status}: {body[:500]}",
193
+ status_code=status,
194
+ category=category,
195
+ retryable=retryable,
196
+ retry_after=_parse_retry_after(resp.headers.get("Retry-After")),
197
+ request_id=resp.headers.get("x-request-id", ""),
198
+ )
199
+ async for event in self._parse_sse(resp):
200
+ yield event
201
+ except httpx.TimeoutException as e:
202
+ raise LlmError(
203
+ f"请求超时({self.timeout}s): {e}",
204
+ category="network_timeout", retryable=True,
205
+ ) from e
206
+ except httpx.HTTPError as e:
207
+ raise LlmError(
208
+ f"网络错误: {e}", category="network_error", retryable=True,
209
+ ) from e
210
+
211
+ async def _parse_sse(self, resp: httpx.Response) -> AsyncIterator[StreamEvent]:
212
+ """解析 SSE 流,聚合 content 与 tool_call 增量分片。"""
213
+ # 按 index 聚合中的 tool_call 分片
214
+ pending: dict[int, dict[str, str]] = {}
215
+ finish_reason = ""
216
+ usage: Usage | None = None
217
+
218
+ async for line in resp.aiter_lines():
219
+ line = line.strip()
220
+ if not line.startswith("data:"):
221
+ continue
222
+ data = line[len("data:") :].strip()
223
+ if data == "[DONE]":
224
+ break
225
+ try:
226
+ chunk = json.loads(data)
227
+ except json.JSONDecodeError:
228
+ continue
229
+
230
+ if chunk.get("usage"):
231
+ u = chunk["usage"]
232
+ usage = Usage(
233
+ prompt_tokens=u.get("prompt_tokens", 0),
234
+ completion_tokens=u.get("completion_tokens", 0),
235
+ total_tokens=u.get("total_tokens", 0),
236
+ )
237
+
238
+ for choice in chunk.get("choices", []):
239
+ delta = choice.get("delta") or {}
240
+ # 不同 OpenAI-compatible provider 对显式 reasoning 的字段名
241
+ # 不统一;在 LLM 边界归一化,UI 不读取 provider 私有字段。
242
+ reasoning = delta.get("reasoning_content") or delta.get("reasoning")
243
+ if reasoning:
244
+ yield StreamEvent(kind="thinking", text=str(reasoning))
245
+ if delta.get("content"):
246
+ yield StreamEvent(kind="content", text=delta["content"])
247
+
248
+ for tc in delta.get("tool_calls") or []:
249
+ idx = tc.get("index", 0)
250
+ frag = pending.setdefault(idx, {"id": "", "name": "", "arguments": ""})
251
+ if tc.get("id"):
252
+ frag["id"] = tc["id"]
253
+ fn = tc.get("function") or {}
254
+ if fn.get("name"):
255
+ frag["name"] = frag["name"] + fn["name"]
256
+ if fn.get("arguments"):
257
+ frag["arguments"] = frag["arguments"] + fn["arguments"]
258
+
259
+ if choice.get("finish_reason"):
260
+ finish_reason = choice["finish_reason"]
261
+
262
+ # 流结束:统一发出聚合后的 tool_call,再发 done
263
+ for idx in sorted(pending):
264
+ frag = pending[idx]
265
+ if not frag["name"] and not frag["arguments"]:
266
+ continue
267
+ yield StreamEvent(
268
+ kind="tool_call",
269
+ tool_call=ToolCall(
270
+ id=frag["id"] or f"call_{idx}",
271
+ name=frag["name"],
272
+ arguments=frag["arguments"],
273
+ ),
274
+ )
275
+ yield StreamEvent(kind="done", finish_reason=finish_reason, usage=usage)
xg/llm/types.py ADDED
@@ -0,0 +1,98 @@
1
+ """LLM 层通用类型:消息、工具调用、流式事件。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal
7
+
8
+
9
+ @dataclass
10
+ class ToolCall:
11
+ """一次工具调用请求(模型侧发起)。"""
12
+
13
+ id: str
14
+ name: str
15
+ arguments: str # JSON 字符串
16
+
17
+ def parsed_arguments(self) -> dict[str, Any]:
18
+ import json
19
+
20
+ if not self.arguments:
21
+ return {}
22
+ try:
23
+ return json.loads(self.arguments)
24
+ except json.JSONDecodeError:
25
+ return {"_raw": self.arguments}
26
+
27
+
28
+ @dataclass
29
+ class ToolResult:
30
+ """一次工具执行结果(工具侧返回,回灌给模型)。"""
31
+
32
+ tool_call_id: str
33
+ name: str
34
+ ok: bool
35
+ output: str = ""
36
+ error: str = ""
37
+
38
+ def to_message_content(self) -> str:
39
+ if self.ok:
40
+ return self.output or "(no output)"
41
+ return f"ERROR: {self.error or 'unknown error'}"
42
+
43
+
44
+ @dataclass
45
+ class Message:
46
+ """对话消息。role ∈ system / user / assistant / tool。"""
47
+
48
+ role: Literal["system", "user", "assistant", "tool"]
49
+ content: str = ""
50
+ tool_calls: list[ToolCall] = field(default_factory=list)
51
+ tool_call_id: str = "" # role=tool 时对应的调用 id
52
+
53
+ def to_api(self) -> dict[str, Any]:
54
+ """转为 OpenAI Chat Completions API 格式。"""
55
+ msg: dict[str, Any] = {"role": self.role}
56
+ if self.role == "assistant" and self.tool_calls:
57
+ msg["content"] = self.content or None
58
+ msg["tool_calls"] = [
59
+ {
60
+ "id": tc.id,
61
+ "type": "function",
62
+ "function": {"name": tc.name, "arguments": tc.arguments},
63
+ }
64
+ for tc in self.tool_calls
65
+ ]
66
+ elif self.role == "tool":
67
+ msg["content"] = self.content
68
+ msg["tool_call_id"] = self.tool_call_id
69
+ else:
70
+ msg["content"] = self.content
71
+ return msg
72
+
73
+
74
+ @dataclass
75
+ class Usage:
76
+ prompt_tokens: int = 0
77
+ completion_tokens: int = 0
78
+ total_tokens: int = 0
79
+
80
+
81
+ @dataclass
82
+ class StreamEvent:
83
+ """流式事件。
84
+
85
+ - kind="content": text 为普通/最终回答增量文本
86
+ - kind="thinking": text 为 Provider 明确返回的思考增量文本
87
+ - kind="tool_call": tool_call 为本轮聚合完成的 ToolCall(一次性发出,不再分片)
88
+ - kind="done": 结束,含 finish_reason 与 usage
89
+ """
90
+
91
+ kind: Literal["content", "thinking", "tool_call", "done", "retrying"]
92
+ text: str = ""
93
+ tool_call: ToolCall | None = None
94
+ finish_reason: str = ""
95
+ usage: Usage | None = None
96
+ attempt: int = 0
97
+ max_attempts: int = 0
98
+ retry_after: float | None = None
xg/mcp/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """MCP client runtime: transports, discovery, tools and resources."""
2
+
3
+ # Keep package initialization dependency-free. The configuration layer uses
4
+ # xg.mcp.models and must not recursively import the runtime manager.
xg/mcp/http.py ADDED
@@ -0,0 +1,192 @@
1
+ """MCP Streamable HTTP transport using httpx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from contextlib import suppress
8
+ from typing import AsyncIterator
9
+
10
+ import httpx
11
+
12
+ from xg.mcp.models import McpServerConfig
13
+ from xg.mcp.protocol import McpProtocolError, McpRequestError, McpUnavailableError, notification_message, request_message
14
+ from xg.mcp.transport import McpTransport
15
+ from xg.safety.audit import redact_text
16
+
17
+
18
+ class StreamableHttpTransport(McpTransport):
19
+ def __init__(self, config: McpServerConfig, client: httpx.AsyncClient | None = None) -> None:
20
+ super().__init__(log_lines=config.log_lines)
21
+ self.config = config
22
+ self._external_client = client is not None
23
+ self.client = client
24
+ self.session_id: str | None = None
25
+ self._next_id = 1
26
+ self._notification_task: asyncio.Task | None = None
27
+ self._closed = False
28
+
29
+ async def connect(self) -> None:
30
+ if self.client is None:
31
+ self.client = httpx.AsyncClient(
32
+ timeout=httpx.Timeout(self.config.request_timeout),
33
+ follow_redirects=True,
34
+ max_redirects=5,
35
+ )
36
+ self._closed = False
37
+
38
+ def _headers(self) -> dict[str, str]:
39
+ headers = {
40
+ "Accept": "application/json, text/event-stream",
41
+ "Content-Type": "application/json",
42
+ **dict(self.config.headers),
43
+ }
44
+ if self.session_id:
45
+ headers["Mcp-Session-Id"] = self.session_id
46
+ if self.protocol_version:
47
+ headers["MCP-Protocol-Version"] = self.protocol_version
48
+ return headers
49
+
50
+ async def request(self, method: str, params: dict | None = None) -> dict:
51
+ if self.client is None or self._closed or not self.config.url:
52
+ raise McpUnavailableError("MCP HTTP Server 未连接")
53
+ request_id = self._next_id
54
+ self._next_id += 1
55
+ response = await self._post(request_message(request_id, method, params))
56
+ messages = await self._messages(response)
57
+ for message in messages:
58
+ if message.get("id") == request_id:
59
+ error = message.get("error")
60
+ if isinstance(error, dict):
61
+ raise McpRequestError(error.get("code", -32000), str(error.get("message", "unknown error")), error.get("data"))
62
+ result = message.get("result", {})
63
+ if result is None:
64
+ return {}
65
+ if not isinstance(result, dict):
66
+ raise McpProtocolError("JSON-RPC result 必须是对象")
67
+ return result
68
+ await self._dispatch_unsolicited(message)
69
+ raise McpProtocolError(f"MCP HTTP 响应缺少 request id {request_id}")
70
+
71
+ async def notify(self, method: str, params: dict | None = None) -> None:
72
+ response = await self._post(notification_message(method, params))
73
+ if response.status_code not in (200, 202, 204):
74
+ raise McpUnavailableError(f"MCP HTTP notification 失败: HTTP {response.status_code}")
75
+ if response.content:
76
+ for message in await self._messages(response):
77
+ await self._dispatch_unsolicited(message)
78
+
79
+ async def _post(self, payload: dict) -> httpx.Response:
80
+ assert self.client is not None and self.config.url
81
+ encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
82
+ if len(encoded) > self.config.max_message_bytes:
83
+ raise McpProtocolError("JSON-RPC 请求超过消息上限")
84
+ try:
85
+ response = await self.client.post(self.config.url, headers=self._headers(), content=encoded)
86
+ except httpx.TimeoutException as exc:
87
+ raise McpUnavailableError("MCP HTTP 请求超时") from exc
88
+ except httpx.HTTPError as exc:
89
+ raise McpUnavailableError(f"MCP HTTP 连接失败: {exc}") from exc
90
+ if response.status_code >= 400:
91
+ preview = redact_text(response.text[:300].replace("\n", " "))
92
+ raise McpUnavailableError(f"MCP HTTP {response.status_code}: {preview}")
93
+ session_id = response.headers.get("mcp-session-id")
94
+ if session_id:
95
+ self.session_id = session_id
96
+ if len(response.content) > self.config.max_message_bytes:
97
+ raise McpProtocolError("MCP HTTP 响应超过消息上限")
98
+ return response
99
+
100
+ async def _messages(self, response: httpx.Response) -> list[dict]:
101
+ if not response.content:
102
+ return []
103
+ content_type = response.headers.get("content-type", "").lower()
104
+ raw_messages: list[object]
105
+ if "text/event-stream" in content_type:
106
+ raw_messages = []
107
+ data_lines: list[str] = []
108
+ for line in response.text.splitlines():
109
+ if line.startswith("data:"):
110
+ data_lines.append(line[5:].lstrip())
111
+ elif not line and data_lines:
112
+ raw_messages.append(json.loads("\n".join(data_lines)))
113
+ data_lines = []
114
+ if data_lines:
115
+ raw_messages.append(json.loads("\n".join(data_lines)))
116
+ else:
117
+ try:
118
+ parsed = response.json()
119
+ except (ValueError, json.JSONDecodeError) as exc:
120
+ raise McpProtocolError(f"MCP HTTP 返回非法 JSON: {exc}") from exc
121
+ raw_messages = parsed if isinstance(parsed, list) else [parsed]
122
+ messages: list[dict] = []
123
+ for item in raw_messages:
124
+ if not isinstance(item, dict) or item.get("jsonrpc") != "2.0":
125
+ raise McpProtocolError("MCP HTTP 返回非法 JSON-RPC envelope")
126
+ messages.append(item)
127
+ return messages
128
+
129
+ async def _dispatch_unsolicited(self, message: dict) -> None:
130
+ method = message.get("method")
131
+ if isinstance(method, str) and "id" not in message:
132
+ await self.dispatch_notification(method, message.get("params"))
133
+ elif isinstance(method, str) and "id" in message:
134
+ response = (
135
+ {"jsonrpc": "2.0", "id": message["id"], "result": {}}
136
+ if method == "ping"
137
+ else {
138
+ "jsonrpc": "2.0",
139
+ "id": message["id"],
140
+ "error": {"code": -32601, "message": "Method not found"},
141
+ }
142
+ )
143
+ await self._post(response)
144
+
145
+ async def start_notifications(self) -> None:
146
+ if self._notification_task is None and self.client is not None and self.session_id:
147
+ self._notification_task = asyncio.create_task(
148
+ self._listen_notifications(), name=f"mcp-{self.config.name}-http-events"
149
+ )
150
+
151
+ async def _listen_notifications(self) -> None:
152
+ assert self.client is not None and self.config.url
153
+ try:
154
+ async with self.client.stream("GET", self.config.url, headers=self._headers()) as response:
155
+ if response.status_code in (405, 404):
156
+ return
157
+ response.raise_for_status()
158
+ data_lines: list[str] = []
159
+ async for line in response.aiter_lines():
160
+ if line.startswith("data:"):
161
+ data_lines.append(line[5:].lstrip())
162
+ elif not line and data_lines:
163
+ raw = "\n".join(data_lines)
164
+ data_lines = []
165
+ if len(raw.encode("utf-8")) > self.config.max_message_bytes:
166
+ self.log("notification message exceeds limit")
167
+ continue
168
+ message = json.loads(raw)
169
+ if isinstance(message, dict):
170
+ await self._dispatch_unsolicited(message)
171
+ except asyncio.CancelledError:
172
+ raise
173
+ except Exception as exc:
174
+ if not self._closed:
175
+ self.log(f"notification stream closed: {type(exc).__name__}: {exc}")
176
+
177
+ async def close(self) -> None:
178
+ if self._closed:
179
+ return
180
+ self._closed = True
181
+ task = self._notification_task
182
+ if task is not None and not task.done():
183
+ task.cancel()
184
+ with suppress(asyncio.CancelledError):
185
+ await task
186
+ if self.client is not None and self.config.url and self.session_id:
187
+ with suppress(httpx.HTTPError):
188
+ await self.client.delete(self.config.url, headers=self._headers())
189
+ self.session_id = None
190
+ if self.client is not None and not self._external_client:
191
+ await self.client.aclose()
192
+ self.client = None