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/models.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Data models shared by the MCP configuration and runtime layers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Literal, Mapping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
McpServerStatus = Literal[
|
|
11
|
+
"disabled",
|
|
12
|
+
"starting",
|
|
13
|
+
"ready",
|
|
14
|
+
"refreshing",
|
|
15
|
+
"unavailable",
|
|
16
|
+
"restarting",
|
|
17
|
+
"stopping",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class McpServerConfig:
|
|
23
|
+
name: str
|
|
24
|
+
transport: Literal["stdio", "streamable_http"]
|
|
25
|
+
enabled: bool = True
|
|
26
|
+
command: str | None = None
|
|
27
|
+
args: tuple[str, ...] = ()
|
|
28
|
+
cwd: str | None = None
|
|
29
|
+
env: Mapping[str, str] = field(default_factory=dict)
|
|
30
|
+
url: str | None = None
|
|
31
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
32
|
+
startup_timeout: float = 15.0
|
|
33
|
+
request_timeout: float = 120.0
|
|
34
|
+
shutdown_timeout: float = 5.0
|
|
35
|
+
max_output_chars: int = 20_000
|
|
36
|
+
hitl: Literal["default", "never", "confirm", "always"] = "default"
|
|
37
|
+
tool_overrides: Mapping[str, str] = field(default_factory=dict)
|
|
38
|
+
max_tools: int = 256
|
|
39
|
+
max_resources: int = 512
|
|
40
|
+
max_message_bytes: int = 2_097_152
|
|
41
|
+
resource_max_chars: int = 32_000
|
|
42
|
+
log_lines: int = 200
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class McpToolSpec:
|
|
47
|
+
server: str
|
|
48
|
+
remote_name: str
|
|
49
|
+
exposed_name: str
|
|
50
|
+
description: str
|
|
51
|
+
input_schema: dict
|
|
52
|
+
annotations: dict = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class McpResource:
|
|
57
|
+
server: str
|
|
58
|
+
uri: str
|
|
59
|
+
name: str
|
|
60
|
+
description: str = ""
|
|
61
|
+
mime_type: str | None = None
|
|
62
|
+
size: int | None = None
|
|
63
|
+
metadata: dict = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class McpServerSnapshot:
|
|
68
|
+
name: str
|
|
69
|
+
transport: str
|
|
70
|
+
status: McpServerStatus
|
|
71
|
+
protocol_version: str | None = None
|
|
72
|
+
tool_count: int = 0
|
|
73
|
+
resource_count: int = 0
|
|
74
|
+
last_error: str = ""
|
|
75
|
+
started_at: datetime | None = None
|
|
76
|
+
last_refresh_at: datetime | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class McpEvent:
|
|
81
|
+
kind: str
|
|
82
|
+
server: str = ""
|
|
83
|
+
text: str = ""
|
|
84
|
+
tool_count: int | None = None
|
|
85
|
+
resource_count: int | None = None
|
|
86
|
+
|
xg/mcp/protocol.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""MCP JSON-RPC constants, validation and public exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
SUPPORTED_PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
|
|
9
|
+
DEFAULT_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class McpError(Exception):
|
|
13
|
+
"""Base MCP error with a user-safe message."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class McpConfigError(McpError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class McpProtocolError(McpError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class McpUnavailableError(McpError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class McpRequestError(McpError):
|
|
29
|
+
def __init__(self, code: int | str, message: str, data: Any = None) -> None:
|
|
30
|
+
self.code = code
|
|
31
|
+
self.data = data
|
|
32
|
+
super().__init__(f"MCP error {code}: {message}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def request_message(request_id: int, method: str, params: dict | None = None) -> dict:
|
|
36
|
+
message: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id, "method": method}
|
|
37
|
+
if params is not None:
|
|
38
|
+
message["params"] = params
|
|
39
|
+
return message
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def notification_message(method: str, params: dict | None = None) -> dict:
|
|
43
|
+
message: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
|
44
|
+
if params is not None:
|
|
45
|
+
message["params"] = params
|
|
46
|
+
return message
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_result(message: dict, expected_id: int | str | None = None) -> dict:
|
|
50
|
+
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
|
|
51
|
+
raise McpProtocolError("非法 JSON-RPC envelope")
|
|
52
|
+
if expected_id is not None and message.get("id") != expected_id:
|
|
53
|
+
raise McpProtocolError("JSON-RPC response id 不匹配")
|
|
54
|
+
error = message.get("error")
|
|
55
|
+
if isinstance(error, dict):
|
|
56
|
+
raise McpRequestError(error.get("code", -32000), str(error.get("message", "unknown error")), error.get("data"))
|
|
57
|
+
result = message.get("result", {})
|
|
58
|
+
if result is None:
|
|
59
|
+
return {}
|
|
60
|
+
if not isinstance(result, dict):
|
|
61
|
+
raise McpProtocolError("JSON-RPC result 必须是对象")
|
|
62
|
+
return result
|
xg/mcp/resources.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""MCP resource decoding and explicit @server:uri reference parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_REFERENCE_RE = re.compile(
|
|
12
|
+
r"@(?P<server>[A-Za-z0-9_-]{1,32}):(?P<uri>[^\s,。;!?,;!?\)\]\}]+)"
|
|
13
|
+
)
|
|
14
|
+
_TRAILING = ".,;!?,。;!?)]}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ResourceReference:
|
|
19
|
+
server: str
|
|
20
|
+
uri: str
|
|
21
|
+
token: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def find_resource_references(text: str, limit: int = 8) -> list[ResourceReference]:
|
|
25
|
+
found: list[ResourceReference] = []
|
|
26
|
+
for match in _REFERENCE_RE.finditer(text):
|
|
27
|
+
uri = match.group("uri").rstrip(_TRAILING)
|
|
28
|
+
if not uri or ":" not in uri:
|
|
29
|
+
continue
|
|
30
|
+
token = f"@{match.group('server')}:{uri}"
|
|
31
|
+
found.append(ResourceReference(match.group("server"), uri, token))
|
|
32
|
+
if len(found) >= limit:
|
|
33
|
+
break
|
|
34
|
+
return found
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def decode_resource_contents(result: dict, max_chars: int) -> tuple[str, bool]:
|
|
38
|
+
contents = result.get("contents", [])
|
|
39
|
+
if not isinstance(contents, list):
|
|
40
|
+
return "", False
|
|
41
|
+
parts: list[str] = []
|
|
42
|
+
binary_seen = False
|
|
43
|
+
for item in contents:
|
|
44
|
+
if not isinstance(item, dict):
|
|
45
|
+
continue
|
|
46
|
+
if isinstance(item.get("text"), str):
|
|
47
|
+
parts.append(item["text"])
|
|
48
|
+
elif isinstance(item.get("blob"), str):
|
|
49
|
+
binary_seen = True
|
|
50
|
+
try:
|
|
51
|
+
size = len(base64.b64decode(item["blob"], validate=False))
|
|
52
|
+
except Exception:
|
|
53
|
+
size = len(item["blob"])
|
|
54
|
+
parts.append(f"[二进制 MCP resource,约 {size} bytes,未内联]")
|
|
55
|
+
text = "\n\n".join(parts)
|
|
56
|
+
truncated = len(text) > max_chars
|
|
57
|
+
if truncated:
|
|
58
|
+
text = text[:max_chars] + f"\n... (资源已截断,原始长度 {len(text)} 字符)"
|
|
59
|
+
return text, truncated or binary_seen
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def redact_uri(uri: str) -> str:
|
|
63
|
+
try:
|
|
64
|
+
parsed = urlsplit(uri)
|
|
65
|
+
except ValueError:
|
|
66
|
+
return uri.split("?", 1)[0]
|
|
67
|
+
if not parsed.scheme:
|
|
68
|
+
return uri.split("?", 1)[0]
|
|
69
|
+
host = parsed.hostname or ""
|
|
70
|
+
if parsed.port:
|
|
71
|
+
host = f"{host}:{parsed.port}"
|
|
72
|
+
return urlunsplit((parsed.scheme, host, parsed.path, "", ""))
|
xg/mcp/schema.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Sanitize MCP input schemas for OpenAI-compatible tool definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_NAME_RE = re.compile(r"[^A-Za-z0-9_-]+")
|
|
11
|
+
_ALLOWED = {
|
|
12
|
+
"type", "properties", "required", "items", "description", "enum", "default",
|
|
13
|
+
"minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems",
|
|
14
|
+
"additionalProperties", "format",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def exposed_tool_name(server: str, remote_name: str, max_length: int = 128) -> str:
|
|
19
|
+
clean = _NAME_RE.sub("_", remote_name).strip("_") or "tool"
|
|
20
|
+
return f"mcp__{server}__{clean}"[:max_length]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sanitize_description(value: Any, max_chars: int = 4000) -> str:
|
|
24
|
+
text = " ".join(str(value or "").split())
|
|
25
|
+
if len(text) <= max_chars:
|
|
26
|
+
return text
|
|
27
|
+
return text[: max(0, max_chars - 8)] + "…[截断]"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sanitize_schema(schema: Any, *, max_depth: int = 8, max_properties: int = 128) -> tuple[dict, list[str]]:
|
|
31
|
+
warnings: list[str] = []
|
|
32
|
+
root = copy.deepcopy(schema) if isinstance(schema, dict) else {}
|
|
33
|
+
definitions = root.get("$defs") or root.get("definitions") or {}
|
|
34
|
+
resolving: set[str] = set()
|
|
35
|
+
|
|
36
|
+
def resolve_ref(ref: str) -> dict | None:
|
|
37
|
+
if not ref.startswith("#/") or ref in resolving:
|
|
38
|
+
return None
|
|
39
|
+
node: Any = root
|
|
40
|
+
try:
|
|
41
|
+
for part in ref[2:].split("/"):
|
|
42
|
+
part = part.replace("~1", "/").replace("~0", "~")
|
|
43
|
+
node = node[part]
|
|
44
|
+
except (KeyError, TypeError):
|
|
45
|
+
return None
|
|
46
|
+
if not isinstance(node, dict):
|
|
47
|
+
return None
|
|
48
|
+
resolving.add(ref)
|
|
49
|
+
try:
|
|
50
|
+
return clean(node, 1)
|
|
51
|
+
finally:
|
|
52
|
+
resolving.discard(ref)
|
|
53
|
+
|
|
54
|
+
def merge_all_of(items: list, depth: int) -> dict:
|
|
55
|
+
merged: dict[str, Any] = {"type": "object", "properties": {}, "required": []}
|
|
56
|
+
for item in items:
|
|
57
|
+
part = clean(item, depth + 1)
|
|
58
|
+
if part.get("type") != "object":
|
|
59
|
+
warnings.append("allOf 含非 object,已降级")
|
|
60
|
+
continue
|
|
61
|
+
merged["properties"].update(part.get("properties", {}))
|
|
62
|
+
merged["required"].extend(part.get("required", []))
|
|
63
|
+
merged["required"] = list(dict.fromkeys(merged["required"]))
|
|
64
|
+
return merged
|
|
65
|
+
|
|
66
|
+
def clean(node: Any, depth: int) -> dict:
|
|
67
|
+
if depth > max_depth or not isinstance(node, dict):
|
|
68
|
+
warnings.append("schema 深度超限,已降级为 string")
|
|
69
|
+
return {"type": "string"}
|
|
70
|
+
if isinstance(node.get("$ref"), str):
|
|
71
|
+
resolved = resolve_ref(node["$ref"])
|
|
72
|
+
if resolved is None:
|
|
73
|
+
warnings.append(f"无法解析 $ref: {node['$ref']}")
|
|
74
|
+
return {"type": "string", "description": "原 schema reference 无法展开"}
|
|
75
|
+
return resolved
|
|
76
|
+
if isinstance(node.get("allOf"), list):
|
|
77
|
+
merged = merge_all_of(node["allOf"], depth)
|
|
78
|
+
if node.get("description"):
|
|
79
|
+
merged["description"] = sanitize_description(node["description"])
|
|
80
|
+
return merged
|
|
81
|
+
union = node.get("anyOf") or node.get("oneOf")
|
|
82
|
+
if isinstance(union, list) and union:
|
|
83
|
+
options = [clean(item, depth + 1) for item in union if isinstance(item, dict)]
|
|
84
|
+
non_null = [item for item in options if item.get("type") != "null"]
|
|
85
|
+
if len(non_null) == 1:
|
|
86
|
+
chosen = non_null[0]
|
|
87
|
+
if node.get("description"):
|
|
88
|
+
chosen["description"] = sanitize_description(node["description"])
|
|
89
|
+
return chosen
|
|
90
|
+
types = {str(item.get("type")) for item in non_null if item.get("type")}
|
|
91
|
+
if len(types) == 1 and non_null:
|
|
92
|
+
return non_null[0]
|
|
93
|
+
warnings.append("anyOf/oneOf 无法无损表达,已降级为 string")
|
|
94
|
+
return {"type": "string", "description": sanitize_description(node.get("description") or "联合类型参数")}
|
|
95
|
+
|
|
96
|
+
inferred = node.get("type")
|
|
97
|
+
if isinstance(inferred, list):
|
|
98
|
+
values = [value for value in inferred if value != "null"]
|
|
99
|
+
inferred = values[0] if len(values) == 1 else "string"
|
|
100
|
+
if not isinstance(inferred, str):
|
|
101
|
+
inferred = "object" if isinstance(node.get("properties"), dict) else "string"
|
|
102
|
+
if inferred not in {"object", "array", "string", "integer", "number", "boolean", "null"}:
|
|
103
|
+
inferred = "string"
|
|
104
|
+
out: dict[str, Any] = {"type": inferred}
|
|
105
|
+
for key in _ALLOWED - {"type", "properties", "required", "items", "description", "enum"}:
|
|
106
|
+
if key in node and isinstance(node[key], (str, int, float, bool)):
|
|
107
|
+
out[key] = node[key]
|
|
108
|
+
if node.get("description"):
|
|
109
|
+
out["description"] = sanitize_description(node["description"])
|
|
110
|
+
if isinstance(node.get("enum"), list):
|
|
111
|
+
out["enum"] = node["enum"][:100]
|
|
112
|
+
if len(node["enum"]) > 100:
|
|
113
|
+
warnings.append("enum 超过 100 项,已截断")
|
|
114
|
+
if inferred == "object":
|
|
115
|
+
props = node.get("properties", {})
|
|
116
|
+
if not isinstance(props, dict):
|
|
117
|
+
props = {}
|
|
118
|
+
selected = list(props.items())[:max_properties]
|
|
119
|
+
if len(props) > max_properties:
|
|
120
|
+
warnings.append("properties 超过上限,已截断")
|
|
121
|
+
out["properties"] = {str(name): clean(value, depth + 1) for name, value in selected}
|
|
122
|
+
required = node.get("required", [])
|
|
123
|
+
if isinstance(required, list):
|
|
124
|
+
out["required"] = [str(name) for name in required if str(name) in out["properties"]]
|
|
125
|
+
if "additionalProperties" in node and isinstance(node["additionalProperties"], bool):
|
|
126
|
+
out["additionalProperties"] = node["additionalProperties"]
|
|
127
|
+
elif inferred == "array":
|
|
128
|
+
out["items"] = clean(node.get("items", {}), depth + 1)
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
cleaned = clean(root, 0)
|
|
132
|
+
if cleaned.get("type") != "object":
|
|
133
|
+
warnings.append("工具 inputSchema 顶层不是 object,已包装为空 object")
|
|
134
|
+
cleaned = {"type": "object", "properties": {}}
|
|
135
|
+
cleaned.setdefault("properties", {})
|
|
136
|
+
return cleaned, warnings
|
|
137
|
+
|
xg/mcp/stdio.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""MCP stdio transport implemented with asyncio subprocesses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from xg.mcp.models import McpServerConfig
|
|
12
|
+
from xg.mcp.protocol import McpProtocolError, McpRequestError, McpUnavailableError, notification_message, request_message
|
|
13
|
+
from xg.mcp.transport import McpTransport
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StdioTransport(McpTransport):
|
|
17
|
+
def __init__(self, config: McpServerConfig) -> None:
|
|
18
|
+
super().__init__(log_lines=config.log_lines)
|
|
19
|
+
self.config = config
|
|
20
|
+
self.process: asyncio.subprocess.Process | None = None
|
|
21
|
+
self._reader_task: asyncio.Task | None = None
|
|
22
|
+
self._stderr_task: asyncio.Task | None = None
|
|
23
|
+
self._wait_task: asyncio.Task | None = None
|
|
24
|
+
self._pending: dict[int, asyncio.Future[dict]] = {}
|
|
25
|
+
self._next_id = 1
|
|
26
|
+
self._write_lock = asyncio.Lock()
|
|
27
|
+
self._closing = False
|
|
28
|
+
self._disconnect_notified = False
|
|
29
|
+
|
|
30
|
+
async def connect(self) -> None:
|
|
31
|
+
if self.process is not None and self.process.returncode is None:
|
|
32
|
+
return
|
|
33
|
+
if not self.config.command:
|
|
34
|
+
raise McpUnavailableError("stdio Server 缺少 command")
|
|
35
|
+
child_env = os.environ.copy()
|
|
36
|
+
child_env.update(self.config.env)
|
|
37
|
+
try:
|
|
38
|
+
self.process = await asyncio.create_subprocess_exec(
|
|
39
|
+
self.config.command,
|
|
40
|
+
*self.config.args,
|
|
41
|
+
cwd=self.config.cwd,
|
|
42
|
+
env=child_env,
|
|
43
|
+
stdin=asyncio.subprocess.PIPE,
|
|
44
|
+
stdout=asyncio.subprocess.PIPE,
|
|
45
|
+
stderr=asyncio.subprocess.PIPE,
|
|
46
|
+
limit=self.config.max_message_bytes + 1,
|
|
47
|
+
)
|
|
48
|
+
except (OSError, ValueError) as exc:
|
|
49
|
+
raise McpUnavailableError(f"无法启动 stdio Server: {exc}") from exc
|
|
50
|
+
self._closing = False
|
|
51
|
+
self._disconnect_notified = False
|
|
52
|
+
self._reader_task = asyncio.create_task(self._read_stdout(), name=f"mcp-{self.config.name}-stdout")
|
|
53
|
+
self._stderr_task = asyncio.create_task(self._read_stderr(), name=f"mcp-{self.config.name}-stderr")
|
|
54
|
+
self._wait_task = asyncio.create_task(self._watch_process(), name=f"mcp-{self.config.name}-wait")
|
|
55
|
+
|
|
56
|
+
async def _send(self, message: dict) -> None:
|
|
57
|
+
process = self.process
|
|
58
|
+
if process is None or process.returncode is not None or process.stdin is None:
|
|
59
|
+
raise McpUnavailableError("MCP stdio Server 未连接")
|
|
60
|
+
payload = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + b"\n"
|
|
61
|
+
if len(payload) > self.config.max_message_bytes:
|
|
62
|
+
raise McpProtocolError("JSON-RPC 请求超过消息上限")
|
|
63
|
+
async with self._write_lock:
|
|
64
|
+
process.stdin.write(payload)
|
|
65
|
+
try:
|
|
66
|
+
await process.stdin.drain()
|
|
67
|
+
except (BrokenPipeError, ConnectionResetError) as exc:
|
|
68
|
+
raise McpUnavailableError("MCP stdio 连接已断开") from exc
|
|
69
|
+
|
|
70
|
+
async def request(self, method: str, params: dict | None = None) -> dict:
|
|
71
|
+
request_id = self._next_id
|
|
72
|
+
self._next_id += 1
|
|
73
|
+
future = asyncio.get_running_loop().create_future()
|
|
74
|
+
self._pending[request_id] = future
|
|
75
|
+
try:
|
|
76
|
+
await self._send(request_message(request_id, method, params))
|
|
77
|
+
message = await asyncio.wait_for(future, timeout=self.config.request_timeout)
|
|
78
|
+
except asyncio.TimeoutError as exc:
|
|
79
|
+
raise McpUnavailableError(f"MCP 请求超时: {method}") from exc
|
|
80
|
+
finally:
|
|
81
|
+
self._pending.pop(request_id, None)
|
|
82
|
+
error = message.get("error")
|
|
83
|
+
if isinstance(error, dict):
|
|
84
|
+
raise McpRequestError(error.get("code", -32000), str(error.get("message", "unknown error")), error.get("data"))
|
|
85
|
+
result = message.get("result", {})
|
|
86
|
+
if result is None:
|
|
87
|
+
return {}
|
|
88
|
+
if not isinstance(result, dict):
|
|
89
|
+
raise McpProtocolError("JSON-RPC result 必须是对象")
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
async def notify(self, method: str, params: dict | None = None) -> None:
|
|
93
|
+
await self._send(notification_message(method, params))
|
|
94
|
+
|
|
95
|
+
async def _read_stdout(self) -> None:
|
|
96
|
+
process = self.process
|
|
97
|
+
if process is None or process.stdout is None:
|
|
98
|
+
return
|
|
99
|
+
try:
|
|
100
|
+
while not self._closing:
|
|
101
|
+
line = await process.stdout.readline()
|
|
102
|
+
if not line:
|
|
103
|
+
break
|
|
104
|
+
if len(line) > self.config.max_message_bytes:
|
|
105
|
+
raise McpProtocolError("MCP stdio 消息超过上限")
|
|
106
|
+
try:
|
|
107
|
+
message = json.loads(line.decode("utf-8"))
|
|
108
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
109
|
+
self.log(f"protocol error: invalid JSON ({exc})")
|
|
110
|
+
continue
|
|
111
|
+
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
|
|
112
|
+
self.log("protocol error: invalid JSON-RPC envelope")
|
|
113
|
+
continue
|
|
114
|
+
if "id" in message and ("result" in message or "error" in message):
|
|
115
|
+
future = self._pending.get(message["id"])
|
|
116
|
+
if future is not None and not future.done():
|
|
117
|
+
future.set_result(message)
|
|
118
|
+
continue
|
|
119
|
+
method = message.get("method")
|
|
120
|
+
if isinstance(method, str) and "id" not in message:
|
|
121
|
+
await self.dispatch_notification(method, message.get("params"))
|
|
122
|
+
elif isinstance(method, str) and "id" in message:
|
|
123
|
+
if method == "ping":
|
|
124
|
+
await self._send({"jsonrpc": "2.0", "id": message["id"], "result": {}})
|
|
125
|
+
else:
|
|
126
|
+
await self._send({
|
|
127
|
+
"jsonrpc": "2.0",
|
|
128
|
+
"id": message["id"],
|
|
129
|
+
"error": {"code": -32601, "message": "Method not found"},
|
|
130
|
+
})
|
|
131
|
+
except asyncio.CancelledError:
|
|
132
|
+
raise
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
self.log(f"protocol error: {type(exc).__name__}: {exc}")
|
|
135
|
+
finally:
|
|
136
|
+
if not self._closing:
|
|
137
|
+
error = McpUnavailableError("MCP stdio 连接已关闭")
|
|
138
|
+
await self._notify_disconnect(error)
|
|
139
|
+
|
|
140
|
+
async def _read_stderr(self) -> None:
|
|
141
|
+
process = self.process
|
|
142
|
+
if process is None or process.stderr is None:
|
|
143
|
+
return
|
|
144
|
+
try:
|
|
145
|
+
while not self._closing:
|
|
146
|
+
line = await process.stderr.readline()
|
|
147
|
+
if not line:
|
|
148
|
+
break
|
|
149
|
+
self.log(line.decode("utf-8", errors="replace"))
|
|
150
|
+
except asyncio.CancelledError:
|
|
151
|
+
raise
|
|
152
|
+
|
|
153
|
+
async def _watch_process(self) -> None:
|
|
154
|
+
process = self.process
|
|
155
|
+
if process is None:
|
|
156
|
+
return
|
|
157
|
+
code = await process.wait()
|
|
158
|
+
if not self._closing:
|
|
159
|
+
self.log(f"process exited with code {code}")
|
|
160
|
+
error = McpUnavailableError(f"MCP Server 已退出(code={code})")
|
|
161
|
+
await self._notify_disconnect(error)
|
|
162
|
+
|
|
163
|
+
async def _notify_disconnect(self, error: Exception) -> None:
|
|
164
|
+
"""Notify the owner once when the child connection is lost.
|
|
165
|
+
|
|
166
|
+
Both stdout EOF and process.wait() observe the same child exit. The
|
|
167
|
+
first observer owns failure propagation; the other observer must not
|
|
168
|
+
emit a duplicate lifecycle event.
|
|
169
|
+
"""
|
|
170
|
+
if self._closing or self._disconnect_notified:
|
|
171
|
+
return
|
|
172
|
+
self._disconnect_notified = True
|
|
173
|
+
self._fail_pending(error)
|
|
174
|
+
await self.dispatch_disconnect(error)
|
|
175
|
+
|
|
176
|
+
def _fail_pending(self, error: Exception) -> None:
|
|
177
|
+
for future in list(self._pending.values()):
|
|
178
|
+
if not future.done():
|
|
179
|
+
future.set_exception(error)
|
|
180
|
+
self._pending.clear()
|
|
181
|
+
|
|
182
|
+
async def close(self) -> None:
|
|
183
|
+
if self._closing:
|
|
184
|
+
return
|
|
185
|
+
self._closing = True
|
|
186
|
+
self._fail_pending(McpUnavailableError("MCP Server 正在关闭"))
|
|
187
|
+
process = self.process
|
|
188
|
+
if process is not None and process.stdin is not None:
|
|
189
|
+
process.stdin.close()
|
|
190
|
+
with suppress(Exception):
|
|
191
|
+
await process.stdin.wait_closed()
|
|
192
|
+
if process is not None and process.returncode is None:
|
|
193
|
+
try:
|
|
194
|
+
await asyncio.wait_for(process.wait(), timeout=self.config.shutdown_timeout)
|
|
195
|
+
except asyncio.TimeoutError:
|
|
196
|
+
process.terminate()
|
|
197
|
+
try:
|
|
198
|
+
await asyncio.wait_for(process.wait(), timeout=self.config.shutdown_timeout)
|
|
199
|
+
except asyncio.TimeoutError:
|
|
200
|
+
process.kill()
|
|
201
|
+
await process.wait()
|
|
202
|
+
current = asyncio.current_task()
|
|
203
|
+
for task in (self._reader_task, self._stderr_task, self._wait_task):
|
|
204
|
+
if task is not None and task is not current and not task.done():
|
|
205
|
+
task.cancel()
|
|
206
|
+
for task in (self._reader_task, self._stderr_task, self._wait_task):
|
|
207
|
+
if task is not None and task is not current:
|
|
208
|
+
with suppress(asyncio.CancelledError, Exception):
|
|
209
|
+
await task
|
|
210
|
+
self.process = None
|
xg/mcp/transport.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Transport abstraction and common logging helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections import deque
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
NotificationHandler = Callable[[str, dict], Awaitable[None] | None]
|
|
12
|
+
DisconnectHandler = Callable[[Exception], Awaitable[None] | None]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class McpTransport(ABC):
|
|
16
|
+
def __init__(self, *, log_lines: int = 200) -> None:
|
|
17
|
+
self._logs: deque[str] = deque(maxlen=max(1, log_lines))
|
|
18
|
+
self.notification_handler: NotificationHandler | None = None
|
|
19
|
+
self.disconnect_handler: DisconnectHandler | None = None
|
|
20
|
+
self.protocol_version: str | None = None
|
|
21
|
+
|
|
22
|
+
def set_protocol_version(self, version: str) -> None:
|
|
23
|
+
self.protocol_version = version
|
|
24
|
+
|
|
25
|
+
def set_notification_handler(self, handler: NotificationHandler | None) -> None:
|
|
26
|
+
self.notification_handler = handler
|
|
27
|
+
|
|
28
|
+
def set_disconnect_handler(self, handler: DisconnectHandler | None) -> None:
|
|
29
|
+
self.disconnect_handler = handler
|
|
30
|
+
|
|
31
|
+
async def dispatch_disconnect(self, error: Exception) -> None:
|
|
32
|
+
handler = self.disconnect_handler
|
|
33
|
+
if handler is None:
|
|
34
|
+
return
|
|
35
|
+
result = handler(error)
|
|
36
|
+
if result is not None:
|
|
37
|
+
await result
|
|
38
|
+
|
|
39
|
+
async def dispatch_notification(self, method: str, params: dict | None) -> None:
|
|
40
|
+
handler = self.notification_handler
|
|
41
|
+
if handler is None:
|
|
42
|
+
return
|
|
43
|
+
result = handler(method, params or {})
|
|
44
|
+
if result is not None:
|
|
45
|
+
await result
|
|
46
|
+
|
|
47
|
+
def log(self, message: str) -> None:
|
|
48
|
+
self._logs.append(str(message).replace("\r", "").rstrip("\n"))
|
|
49
|
+
|
|
50
|
+
def recent_logs(self, limit: int = 50) -> list[str]:
|
|
51
|
+
return list(self._logs)[-max(1, limit):]
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
async def connect(self) -> None: ...
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
async def request(self, method: str, params: dict | None = None) -> dict: ...
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
async def notify(self, method: str, params: dict | None = None) -> None: ...
|
|
61
|
+
|
|
62
|
+
async def start_notifications(self) -> None:
|
|
63
|
+
"""Start an optional server-initiated notification stream."""
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
async def close(self) -> None: ...
|
xg/memory/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""第五期:项目记忆、长期记忆与上下文压缩。"""
|
|
2
|
+
|
|
3
|
+
from xg.memory.context import CompressionResult, ConversationContext
|
|
4
|
+
from xg.memory.manager import MemoryManager, SharedSection
|
|
5
|
+
from xg.memory.models import MemoryEntry
|
|
6
|
+
from xg.memory.store import SQLiteMemoryStore
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"CompressionResult",
|
|
10
|
+
"ConversationContext",
|
|
11
|
+
"MemoryEntry",
|
|
12
|
+
"MemoryManager",
|
|
13
|
+
"SharedSection",
|
|
14
|
+
"SQLiteMemoryStore",
|
|
15
|
+
]
|