windcode 0.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.
- windcode/__init__.py +6 -0
- windcode/__main__.py +4 -0
- windcode/auth/__init__.py +11 -0
- windcode/auth/store.py +90 -0
- windcode/cli.py +181 -0
- windcode/config/__init__.py +41 -0
- windcode/config/loader.py +117 -0
- windcode/config/models.py +203 -0
- windcode/config/writer.py +74 -0
- windcode/context/__init__.py +18 -0
- windcode/context/compactor.py +72 -0
- windcode/context/estimator.py +89 -0
- windcode/context/truncation.py +87 -0
- windcode/domain/__init__.py +1 -0
- windcode/domain/errors.py +33 -0
- windcode/domain/events.py +597 -0
- windcode/domain/messages.py +226 -0
- windcode/domain/models.py +73 -0
- windcode/domain/subagents.py +263 -0
- windcode/domain/tools.py +55 -0
- windcode/extensions/__init__.py +27 -0
- windcode/extensions/commands.py +25 -0
- windcode/extensions/discovery.py +139 -0
- windcode/extensions/events.py +58 -0
- windcode/extensions/hooks/__init__.py +4 -0
- windcode/extensions/hooks/dispatcher.py +107 -0
- windcode/extensions/hooks/executor.py +49 -0
- windcode/extensions/hooks/loader.py +101 -0
- windcode/extensions/hooks/models.py +109 -0
- windcode/extensions/mcp/__init__.py +10 -0
- windcode/extensions/mcp/adapter.py +101 -0
- windcode/extensions/mcp/catalog.py +153 -0
- windcode/extensions/mcp/client.py +273 -0
- windcode/extensions/mcp/runtime.py +144 -0
- windcode/extensions/mcp/tools.py +570 -0
- windcode/extensions/models.py +168 -0
- windcode/extensions/paths.py +71 -0
- windcode/extensions/plugins/__init__.py +3 -0
- windcode/extensions/plugins/installer.py +93 -0
- windcode/extensions/plugins/manifest.py +166 -0
- windcode/extensions/runtime.py +366 -0
- windcode/extensions/service.py +437 -0
- windcode/extensions/skills/__init__.py +3 -0
- windcode/extensions/skills/loader.py +67 -0
- windcode/extensions/skills/parser.py +57 -0
- windcode/extensions/skills/tools.py +213 -0
- windcode/extensions/snapshot.py +57 -0
- windcode/extensions/state.py +158 -0
- windcode/instructions/__init__.py +8 -0
- windcode/instructions/loader.py +50 -0
- windcode/memory/__init__.py +57 -0
- windcode/memory/extraction.py +83 -0
- windcode/memory/models.py +218 -0
- windcode/memory/refiner.py +189 -0
- windcode/memory/security.py +23 -0
- windcode/memory/service.py +179 -0
- windcode/memory/store.py +362 -0
- windcode/observability/__init__.py +4 -0
- windcode/observability/redaction.py +95 -0
- windcode/observability/trace.py +115 -0
- windcode/policy/__init__.py +22 -0
- windcode/policy/engine.py +137 -0
- windcode/policy/models.py +69 -0
- windcode/providers/__init__.py +29 -0
- windcode/providers/_utils.py +19 -0
- windcode/providers/anthropic.py +215 -0
- windcode/providers/base.py +46 -0
- windcode/providers/catalog.py +119 -0
- windcode/providers/errors.py +96 -0
- windcode/providers/openai_compat.py +231 -0
- windcode/providers/openai_responses.py +189 -0
- windcode/providers/registry.py +105 -0
- windcode/runtime/__init__.py +15 -0
- windcode/runtime/control.py +89 -0
- windcode/runtime/event_bus.py +67 -0
- windcode/runtime/loop.py +510 -0
- windcode/runtime/prompts.py +132 -0
- windcode/runtime/report.py +64 -0
- windcode/runtime/retry.py +73 -0
- windcode/runtime/scheduler.py +194 -0
- windcode/runtime/subagents/__init__.py +28 -0
- windcode/runtime/subagents/approvals.py +88 -0
- windcode/runtime/subagents/budgets.py +79 -0
- windcode/runtime/subagents/coordinator.py +714 -0
- windcode/runtime/subagents/factory.py +311 -0
- windcode/runtime/subagents/roles.py +74 -0
- windcode/runtime/subagents/verification.py +53 -0
- windcode/sandbox/__init__.py +3 -0
- windcode/sandbox/bwrap.py +79 -0
- windcode/sdk.py +1259 -0
- windcode/sessions/__init__.py +23 -0
- windcode/sessions/artifacts.py +69 -0
- windcode/sessions/models.py +104 -0
- windcode/sessions/store.py +167 -0
- windcode/sessions/tree.py +35 -0
- windcode/tools/__init__.py +10 -0
- windcode/tools/apply_patch.py +191 -0
- windcode/tools/ask_user.py +58 -0
- windcode/tools/builtins.py +44 -0
- windcode/tools/edit_file.py +54 -0
- windcode/tools/filesystem.py +77 -0
- windcode/tools/glob.py +35 -0
- windcode/tools/grep.py +66 -0
- windcode/tools/memory.py +400 -0
- windcode/tools/read_file.py +54 -0
- windcode/tools/registry.py +120 -0
- windcode/tools/shell.py +159 -0
- windcode/tools/subagents/__init__.py +31 -0
- windcode/tools/subagents/cancel.py +35 -0
- windcode/tools/subagents/integrate.py +54 -0
- windcode/tools/subagents/list.py +46 -0
- windcode/tools/subagents/spawn.py +86 -0
- windcode/tools/subagents/wait.py +74 -0
- windcode/tools/write_file.py +61 -0
- windcode/tui/__init__.py +3 -0
- windcode/tui/app.py +1015 -0
- windcode/tui/commands.py +90 -0
- windcode/tui/permission_display.py +24 -0
- windcode/tui/styles.tcss +591 -0
- windcode/tui/widgets/__init__.py +31 -0
- windcode/tui/widgets/approval.py +98 -0
- windcode/tui/widgets/command_menu.py +90 -0
- windcode/tui/widgets/extensions.py +48 -0
- windcode/tui/widgets/input.py +110 -0
- windcode/tui/widgets/memory.py +143 -0
- windcode/tui/widgets/messages.py +299 -0
- windcode/tui/widgets/models.py +565 -0
- windcode/tui/widgets/question.py +46 -0
- windcode/tui/widgets/sessions.py +68 -0
- windcode/tui/widgets/status.py +46 -0
- windcode/tui/widgets/subagents.py +195 -0
- windcode/tui/widgets/tools.py +66 -0
- windcode/tui/widgets/welcome.py +149 -0
- windcode/types.py +80 -0
- windcode/worktrees/__init__.py +24 -0
- windcode/worktrees/git.py +92 -0
- windcode/worktrees/manager.py +265 -0
- windcode/worktrees/models.py +62 -0
- windcode-0.1.0.dist-info/METADATA +207 -0
- windcode-0.1.0.dist-info/RECORD +143 -0
- windcode-0.1.0.dist-info/WHEEL +4 -0
- windcode-0.1.0.dist-info/entry_points.txt +2 -0
- windcode-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from mcp.types import (
|
|
9
|
+
InitializeResult,
|
|
10
|
+
ListPromptsResult,
|
|
11
|
+
ListResourcesResult,
|
|
12
|
+
ListResourceTemplatesResult,
|
|
13
|
+
ListToolsResult,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from windcode.extensions.models import normalize_id
|
|
17
|
+
|
|
18
|
+
# Provider function-calling names allow only these characters, capped at 64.
|
|
19
|
+
# MCP stable_ids ("mcp:{server}/tool/{name}") contain ":" and "/", and tool
|
|
20
|
+
# names may contain ".", so they must be sanitized before reaching a provider.
|
|
21
|
+
_WIRE_UNSAFE = re.compile(r"[^A-Za-z0-9_-]")
|
|
22
|
+
_WIRE_MAX = 64
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def mcp_tool_wire_name(server_id: str, tool_name: str) -> str:
|
|
26
|
+
"""Derive a provider-safe function name for an MCP tool.
|
|
27
|
+
|
|
28
|
+
Keeps the ``{server}__{tool}`` shape when it fits the provider charset
|
|
29
|
+
and length limit, falling back to a deterministic hash suffix otherwise so
|
|
30
|
+
distinct tools never collide on the wire.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
base = _WIRE_UNSAFE.sub("_", f"{server_id}__{tool_name}")
|
|
34
|
+
if len(base) <= _WIRE_MAX:
|
|
35
|
+
return base
|
|
36
|
+
digest = hashlib.sha256(f"{server_id}\0{tool_name}".encode()).hexdigest()[:8]
|
|
37
|
+
return f"{base[: _WIRE_MAX - len(digest) - 1]}_{digest}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _suffix(value: str) -> str:
|
|
41
|
+
normalized = value.strip().lower()
|
|
42
|
+
try:
|
|
43
|
+
return normalize_id(normalized)
|
|
44
|
+
except ValueError:
|
|
45
|
+
return hashlib.sha256(value.encode()).hexdigest()[:16]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class McpToolDefinition:
|
|
50
|
+
stable_id: str
|
|
51
|
+
server_id: str
|
|
52
|
+
name: str
|
|
53
|
+
description: str
|
|
54
|
+
input_schema: dict[str, Any]
|
|
55
|
+
annotations: dict[str, Any]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class McpResourceDefinition:
|
|
60
|
+
stable_id: str
|
|
61
|
+
server_id: str
|
|
62
|
+
uri: str
|
|
63
|
+
name: str
|
|
64
|
+
description: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class McpPromptDefinition:
|
|
69
|
+
stable_id: str
|
|
70
|
+
server_id: str
|
|
71
|
+
name: str
|
|
72
|
+
description: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class McpCatalog:
|
|
77
|
+
server_id: str
|
|
78
|
+
instructions: str | None
|
|
79
|
+
tools: tuple[McpToolDefinition, ...]
|
|
80
|
+
resources: tuple[McpResourceDefinition, ...]
|
|
81
|
+
prompts: tuple[McpPromptDefinition, ...]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_catalog(
|
|
85
|
+
server_id: str,
|
|
86
|
+
initialize: InitializeResult,
|
|
87
|
+
tools: ListToolsResult,
|
|
88
|
+
resources: ListResourcesResult,
|
|
89
|
+
prompts: ListPromptsResult,
|
|
90
|
+
resource_templates: ListResourceTemplatesResult | None = None,
|
|
91
|
+
*,
|
|
92
|
+
max_metadata_chars: int = 65_536,
|
|
93
|
+
) -> McpCatalog:
|
|
94
|
+
server = normalize_id(server_id)
|
|
95
|
+
instructions = initialize.instructions
|
|
96
|
+
if instructions is not None and len(instructions) > max_metadata_chars:
|
|
97
|
+
raise ValueError("MCP instructions exceed metadata limit")
|
|
98
|
+
tool_items: list[McpToolDefinition] = []
|
|
99
|
+
seen_tools: set[str] = set()
|
|
100
|
+
for tool in tools.tools:
|
|
101
|
+
name = normalize_id(tool.name)
|
|
102
|
+
if name in seen_tools:
|
|
103
|
+
raise ValueError(f"duplicate MCP tool: {name}")
|
|
104
|
+
seen_tools.add(name)
|
|
105
|
+
schema = tool.inputSchema
|
|
106
|
+
annotations = {} if tool.annotations is None else tool.annotations.model_dump(mode="json")
|
|
107
|
+
tool_items.append(
|
|
108
|
+
McpToolDefinition(
|
|
109
|
+
f"mcp:{server}/tool/{name}",
|
|
110
|
+
server,
|
|
111
|
+
tool.name,
|
|
112
|
+
tool.description or "",
|
|
113
|
+
schema,
|
|
114
|
+
annotations,
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
resource_items = [
|
|
118
|
+
McpResourceDefinition(
|
|
119
|
+
f"mcp:{server}/resource/{_suffix(str(resource.uri))}",
|
|
120
|
+
server,
|
|
121
|
+
str(resource.uri),
|
|
122
|
+
resource.name,
|
|
123
|
+
resource.description or "",
|
|
124
|
+
)
|
|
125
|
+
for resource in resources.resources
|
|
126
|
+
]
|
|
127
|
+
if resource_templates is not None:
|
|
128
|
+
resource_items.extend(
|
|
129
|
+
McpResourceDefinition(
|
|
130
|
+
f"mcp:{server}/resource/{_suffix(template.uriTemplate)}",
|
|
131
|
+
server,
|
|
132
|
+
template.uriTemplate,
|
|
133
|
+
template.name,
|
|
134
|
+
template.description or "",
|
|
135
|
+
)
|
|
136
|
+
for template in resource_templates.resourceTemplates
|
|
137
|
+
)
|
|
138
|
+
prompt_items = tuple(
|
|
139
|
+
McpPromptDefinition(
|
|
140
|
+
f"mcp:{server}/prompt/{normalize_id(prompt.name)}",
|
|
141
|
+
server,
|
|
142
|
+
prompt.name,
|
|
143
|
+
prompt.description or "",
|
|
144
|
+
)
|
|
145
|
+
for prompt in prompts.prompts
|
|
146
|
+
)
|
|
147
|
+
return McpCatalog(
|
|
148
|
+
server,
|
|
149
|
+
instructions,
|
|
150
|
+
tuple(sorted(tool_items, key=lambda item: item.stable_id)),
|
|
151
|
+
tuple(sorted(resource_items, key=lambda item: item.stable_id)),
|
|
152
|
+
tuple(sorted(prompt_items, key=lambda item: item.stable_id)),
|
|
153
|
+
)
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from contextlib import AsyncExitStack
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import Self, TextIO
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from mcp import ClientSession, StdioServerParameters
|
|
15
|
+
from mcp.client.stdio import stdio_client
|
|
16
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
17
|
+
from mcp.types import (
|
|
18
|
+
CallToolResult,
|
|
19
|
+
GetPromptResult,
|
|
20
|
+
InitializeResult,
|
|
21
|
+
ListPromptsResult,
|
|
22
|
+
ListResourcesResult,
|
|
23
|
+
ListResourceTemplatesResult,
|
|
24
|
+
ListToolsResult,
|
|
25
|
+
PaginatedRequestParams,
|
|
26
|
+
ReadResourceResult,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class ResolvedStdioServer:
|
|
32
|
+
command: str
|
|
33
|
+
args: tuple[str, ...] = ()
|
|
34
|
+
cwd: Path | None = None
|
|
35
|
+
env: dict[str, str] | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class ResolvedHttpServer:
|
|
40
|
+
url: str
|
|
41
|
+
headers: dict[str, str] | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class McpClient:
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
definition: ResolvedStdioServer | ResolvedHttpServer,
|
|
48
|
+
*,
|
|
49
|
+
connect_timeout: float = 10.0,
|
|
50
|
+
call_timeout: float = 60.0,
|
|
51
|
+
stderr_limit: int = 16_384,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.definition = definition
|
|
54
|
+
self.connect_timeout = connect_timeout
|
|
55
|
+
self.call_timeout = call_timeout
|
|
56
|
+
self._stack: AsyncExitStack | None = None
|
|
57
|
+
self._session: ClientSession | None = None
|
|
58
|
+
self._stderr_limit = stderr_limit
|
|
59
|
+
self._stderr_file: TextIO | None = None
|
|
60
|
+
self._stderr_value = ""
|
|
61
|
+
self.initialize_result: InitializeResult | None = None
|
|
62
|
+
self._owner_task: asyncio.Task[None] | None = None
|
|
63
|
+
self._ready: asyncio.Future[InitializeResult] | None = None
|
|
64
|
+
self._close_requested: asyncio.Event | None = None
|
|
65
|
+
self._close_error: BaseException | None = None
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def stderr(self) -> str:
|
|
69
|
+
stream = self._stderr_file
|
|
70
|
+
if stream is None:
|
|
71
|
+
return self._stderr_value
|
|
72
|
+
stream.flush()
|
|
73
|
+
position = stream.tell()
|
|
74
|
+
stream.seek(0)
|
|
75
|
+
value = stream.read(self._stderr_limit)
|
|
76
|
+
stream.seek(position)
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def connected(self) -> bool:
|
|
81
|
+
return self._session is not None
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def close_error(self) -> BaseException | None:
|
|
85
|
+
return self._close_error
|
|
86
|
+
|
|
87
|
+
async def __aenter__(self) -> Self:
|
|
88
|
+
await self.connect()
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
async def __aexit__(
|
|
92
|
+
self,
|
|
93
|
+
exc_type: type[BaseException] | None,
|
|
94
|
+
exc: BaseException | None,
|
|
95
|
+
traceback: TracebackType | None,
|
|
96
|
+
) -> None:
|
|
97
|
+
del exc_type, exc, traceback
|
|
98
|
+
await self.aclose()
|
|
99
|
+
|
|
100
|
+
async def connect(self) -> InitializeResult:
|
|
101
|
+
if self.initialize_result is not None:
|
|
102
|
+
return self.initialize_result
|
|
103
|
+
if self._owner_task is None:
|
|
104
|
+
loop = asyncio.get_running_loop()
|
|
105
|
+
self._ready = loop.create_future()
|
|
106
|
+
self._close_requested = asyncio.Event()
|
|
107
|
+
self._owner_task = asyncio.create_task(self._own_connection())
|
|
108
|
+
ready = self._ready
|
|
109
|
+
if ready is None:
|
|
110
|
+
raise RuntimeError("MCP client connection owner was not initialized")
|
|
111
|
+
async with asyncio.timeout(self.connect_timeout):
|
|
112
|
+
return await asyncio.shield(ready)
|
|
113
|
+
|
|
114
|
+
async def _own_connection(self) -> None:
|
|
115
|
+
stack = AsyncExitStack()
|
|
116
|
+
try:
|
|
117
|
+
async with asyncio.timeout(self.connect_timeout):
|
|
118
|
+
if isinstance(self.definition, ResolvedStdioServer):
|
|
119
|
+
self._stderr_file = tempfile.TemporaryFile(
|
|
120
|
+
mode="w+", encoding="utf-8", errors="replace"
|
|
121
|
+
)
|
|
122
|
+
minimum_env = {
|
|
123
|
+
key: value
|
|
124
|
+
for key in ("PATH", "HOME", "USER", "LANG", "LC_ALL", "TMPDIR")
|
|
125
|
+
if (value := os.environ.get(key)) is not None
|
|
126
|
+
}
|
|
127
|
+
minimum_env.update(self.definition.env or {})
|
|
128
|
+
streams = await stack.enter_async_context(
|
|
129
|
+
stdio_client(
|
|
130
|
+
StdioServerParameters(
|
|
131
|
+
command=self.definition.command,
|
|
132
|
+
args=list(self.definition.args),
|
|
133
|
+
cwd=self.definition.cwd,
|
|
134
|
+
env=minimum_env,
|
|
135
|
+
),
|
|
136
|
+
errlog=self._stderr_file,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
read_stream, write_stream = streams
|
|
140
|
+
else:
|
|
141
|
+
http_client = await stack.enter_async_context(
|
|
142
|
+
httpx.AsyncClient(
|
|
143
|
+
headers=self.definition.headers,
|
|
144
|
+
timeout=httpx.Timeout(self.call_timeout, connect=self.connect_timeout),
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
streams = await stack.enter_async_context(
|
|
148
|
+
streamable_http_client(
|
|
149
|
+
self.definition.url,
|
|
150
|
+
http_client=http_client,
|
|
151
|
+
terminate_on_close=False,
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
read_stream, write_stream, _ = streams
|
|
155
|
+
session = await stack.enter_async_context(
|
|
156
|
+
ClientSession(
|
|
157
|
+
read_stream,
|
|
158
|
+
write_stream,
|
|
159
|
+
read_timeout_seconds=timedelta(seconds=self.call_timeout),
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
result = await session.initialize()
|
|
163
|
+
self._stack = stack
|
|
164
|
+
self._session = session
|
|
165
|
+
self.initialize_result = result
|
|
166
|
+
ready = self._ready
|
|
167
|
+
if ready is not None and not ready.done():
|
|
168
|
+
ready.set_result(result)
|
|
169
|
+
close_requested = self._close_requested
|
|
170
|
+
if close_requested is not None:
|
|
171
|
+
await close_requested.wait()
|
|
172
|
+
except BaseException as exc:
|
|
173
|
+
ready = self._ready
|
|
174
|
+
if ready is not None and not ready.done():
|
|
175
|
+
if isinstance(exc, asyncio.CancelledError):
|
|
176
|
+
ready.cancel()
|
|
177
|
+
else:
|
|
178
|
+
ready.set_exception(exc)
|
|
179
|
+
elif not isinstance(exc, asyncio.CancelledError):
|
|
180
|
+
raise
|
|
181
|
+
finally:
|
|
182
|
+
self._session = None
|
|
183
|
+
self.initialize_result = None
|
|
184
|
+
await stack.aclose()
|
|
185
|
+
self._stack = None
|
|
186
|
+
self._close_stderr()
|
|
187
|
+
|
|
188
|
+
def _close_stderr(self) -> None:
|
|
189
|
+
if self._stderr_file is None:
|
|
190
|
+
return
|
|
191
|
+
self._stderr_value = self.stderr
|
|
192
|
+
self._stderr_file.close()
|
|
193
|
+
self._stderr_file = None
|
|
194
|
+
|
|
195
|
+
def _require_session(self) -> ClientSession:
|
|
196
|
+
if self._session is None:
|
|
197
|
+
raise RuntimeError("MCP client is not connected")
|
|
198
|
+
return self._session
|
|
199
|
+
|
|
200
|
+
async def list_tools(self, cursor: str | None = None) -> ListToolsResult:
|
|
201
|
+
async with asyncio.timeout(self.call_timeout):
|
|
202
|
+
return await self._require_session().list_tools(
|
|
203
|
+
params=PaginatedRequestParams(cursor=cursor)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
async def list_resources(self, cursor: str | None = None) -> ListResourcesResult:
|
|
207
|
+
async with asyncio.timeout(self.call_timeout):
|
|
208
|
+
return await self._require_session().list_resources(
|
|
209
|
+
params=PaginatedRequestParams(cursor=cursor)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
async def list_resource_templates(
|
|
213
|
+
self, cursor: str | None = None
|
|
214
|
+
) -> ListResourceTemplatesResult:
|
|
215
|
+
async with asyncio.timeout(self.call_timeout):
|
|
216
|
+
return await self._require_session().list_resource_templates(
|
|
217
|
+
params=PaginatedRequestParams(cursor=cursor)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
async def list_prompts(self, cursor: str | None = None) -> ListPromptsResult:
|
|
221
|
+
async with asyncio.timeout(self.call_timeout):
|
|
222
|
+
return await self._require_session().list_prompts(
|
|
223
|
+
params=PaginatedRequestParams(cursor=cursor)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
async def call_tool(self, name: str, arguments: dict[str, object]) -> CallToolResult:
|
|
227
|
+
async with asyncio.timeout(self.call_timeout):
|
|
228
|
+
return await self._require_session().call_tool(name, arguments)
|
|
229
|
+
|
|
230
|
+
async def read_resource(self, uri: str) -> ReadResourceResult:
|
|
231
|
+
async with asyncio.timeout(self.call_timeout):
|
|
232
|
+
return await self._require_session().read_resource(uri) # pyright: ignore[reportArgumentType]
|
|
233
|
+
|
|
234
|
+
async def get_prompt(
|
|
235
|
+
self, name: str, arguments: dict[str, str] | None = None
|
|
236
|
+
) -> GetPromptResult:
|
|
237
|
+
async with asyncio.timeout(self.call_timeout):
|
|
238
|
+
return await self._require_session().get_prompt(name, arguments)
|
|
239
|
+
|
|
240
|
+
async def aclose(self) -> None:
|
|
241
|
+
task = self._owner_task
|
|
242
|
+
if task is None:
|
|
243
|
+
self._settle_ready()
|
|
244
|
+
self._close_stderr()
|
|
245
|
+
return
|
|
246
|
+
close_requested = self._close_requested
|
|
247
|
+
if close_requested is not None:
|
|
248
|
+
close_requested.set()
|
|
249
|
+
try:
|
|
250
|
+
async with asyncio.timeout(self.connect_timeout):
|
|
251
|
+
result = await asyncio.gather(task, return_exceptions=True)
|
|
252
|
+
if result and isinstance(result[0], BaseException):
|
|
253
|
+
self._close_error = result[0]
|
|
254
|
+
except TimeoutError:
|
|
255
|
+
task.cancel()
|
|
256
|
+
result = await asyncio.gather(task, return_exceptions=True)
|
|
257
|
+
self._close_error = TimeoutError("MCP connection close timed out")
|
|
258
|
+
if result and isinstance(result[0], BaseException):
|
|
259
|
+
self._close_error = result[0]
|
|
260
|
+
finally:
|
|
261
|
+
self._settle_ready()
|
|
262
|
+
self._owner_task = None
|
|
263
|
+
self._ready = None
|
|
264
|
+
self._close_requested = None
|
|
265
|
+
|
|
266
|
+
def _settle_ready(self) -> None:
|
|
267
|
+
ready = self._ready
|
|
268
|
+
if ready is None:
|
|
269
|
+
return
|
|
270
|
+
if not ready.done():
|
|
271
|
+
ready.cancel()
|
|
272
|
+
elif not ready.cancelled():
|
|
273
|
+
ready.exception()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
from windcode.extensions.mcp.client import McpClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class McpServerState(StrEnum):
|
|
12
|
+
DISCOVERED = "discovered"
|
|
13
|
+
CONNECTING = "connecting"
|
|
14
|
+
READY = "ready"
|
|
15
|
+
FAILED = "failed"
|
|
16
|
+
CLOSING = "closing"
|
|
17
|
+
CLOSED = "closed"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
ClientFactory = Callable[[], McpClient]
|
|
21
|
+
McpObserver = Callable[[str, str, str], Awaitable[None]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class _ServerSlot:
|
|
26
|
+
factory: ClientFactory
|
|
27
|
+
required: bool
|
|
28
|
+
state: McpServerState = McpServerState.DISCOVERED
|
|
29
|
+
client: McpClient | None = None
|
|
30
|
+
error: BaseException | None = None
|
|
31
|
+
reconnects: int = 0
|
|
32
|
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class McpRuntime:
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
servers: dict[str, tuple[ClientFactory, bool]],
|
|
39
|
+
observer: McpObserver | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self._servers = {
|
|
42
|
+
server_id: _ServerSlot(factory, required)
|
|
43
|
+
for server_id, (factory, required) in sorted(servers.items())
|
|
44
|
+
}
|
|
45
|
+
self._closed = False
|
|
46
|
+
self.observer = observer
|
|
47
|
+
|
|
48
|
+
async def _observe(self, action: str, server_id: str, status: str) -> None:
|
|
49
|
+
if self.observer is not None:
|
|
50
|
+
await self.observer(action, server_id, status)
|
|
51
|
+
|
|
52
|
+
def state(self, server_id: str) -> McpServerState:
|
|
53
|
+
return self._servers[server_id].state
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def server_ids(self) -> tuple[str, ...]:
|
|
57
|
+
return tuple(self._servers)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def required_server_ids(self) -> tuple[str, ...]:
|
|
61
|
+
return tuple(server_id for server_id, slot in self._servers.items() if slot.required)
|
|
62
|
+
|
|
63
|
+
async def activate(self, server_id: str) -> McpClient:
|
|
64
|
+
slot = self._servers[server_id]
|
|
65
|
+
async with slot.lock:
|
|
66
|
+
if self._closed:
|
|
67
|
+
raise RuntimeError("MCP runtime is closed")
|
|
68
|
+
if slot.state is McpServerState.READY and slot.client is not None:
|
|
69
|
+
return slot.client
|
|
70
|
+
slot.state = McpServerState.CONNECTING
|
|
71
|
+
await self._observe("mcp_connecting", server_id, "connecting")
|
|
72
|
+
client: McpClient | None = None
|
|
73
|
+
try:
|
|
74
|
+
client = slot.factory()
|
|
75
|
+
await client.connect()
|
|
76
|
+
except BaseException as exc:
|
|
77
|
+
slot.state = McpServerState.FAILED
|
|
78
|
+
slot.error = exc
|
|
79
|
+
await self._observe("diagnostic", server_id, "failed")
|
|
80
|
+
if client is not None:
|
|
81
|
+
await client.aclose()
|
|
82
|
+
raise
|
|
83
|
+
slot.client = client
|
|
84
|
+
slot.error = None
|
|
85
|
+
slot.state = McpServerState.READY
|
|
86
|
+
await self._observe("mcp_connected", server_id, "ready")
|
|
87
|
+
return client
|
|
88
|
+
|
|
89
|
+
async def call(
|
|
90
|
+
self, server_id: str, operation: Callable[[McpClient], Awaitable[object]]
|
|
91
|
+
) -> object:
|
|
92
|
+
client = await self.activate(server_id)
|
|
93
|
+
try:
|
|
94
|
+
result = await operation(client)
|
|
95
|
+
await self._observe("mcp_called", server_id, "success")
|
|
96
|
+
return result
|
|
97
|
+
except (ConnectionError, EOFError, BrokenPipeError):
|
|
98
|
+
slot = self._servers[server_id]
|
|
99
|
+
async with slot.lock:
|
|
100
|
+
if slot.reconnects >= 1:
|
|
101
|
+
raise
|
|
102
|
+
slot.reconnects += 1
|
|
103
|
+
if slot.client is not None:
|
|
104
|
+
await slot.client.aclose()
|
|
105
|
+
slot.client = None
|
|
106
|
+
slot.state = McpServerState.DISCOVERED
|
|
107
|
+
client = await self.activate(server_id)
|
|
108
|
+
result = await operation(client)
|
|
109
|
+
await self._observe("mcp_called", server_id, "success")
|
|
110
|
+
return result
|
|
111
|
+
except BaseException:
|
|
112
|
+
await self._observe("diagnostic", server_id, "call_failed")
|
|
113
|
+
raise
|
|
114
|
+
|
|
115
|
+
async def activate_required(self, *, concurrency: int = 4) -> None:
|
|
116
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
117
|
+
|
|
118
|
+
async def activate_one(server_id: str) -> None:
|
|
119
|
+
async with semaphore:
|
|
120
|
+
await self.activate(server_id)
|
|
121
|
+
|
|
122
|
+
await asyncio.gather(
|
|
123
|
+
*(activate_one(server_id) for server_id, slot in self._servers.items() if slot.required)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
async def aclose(self) -> None:
|
|
127
|
+
if self._closed:
|
|
128
|
+
return
|
|
129
|
+
self._closed = True
|
|
130
|
+
for server_id, slot in reversed(tuple(self._servers.items())):
|
|
131
|
+
async with slot.lock:
|
|
132
|
+
slot.state = McpServerState.CLOSING
|
|
133
|
+
client = slot.client
|
|
134
|
+
try:
|
|
135
|
+
if client is not None:
|
|
136
|
+
await client.aclose()
|
|
137
|
+
if client.close_error is not None:
|
|
138
|
+
await self._observe("diagnostic", server_id, "close_failed")
|
|
139
|
+
except Exception:
|
|
140
|
+
await self._observe("diagnostic", server_id, "close_failed")
|
|
141
|
+
finally:
|
|
142
|
+
slot.client = None
|
|
143
|
+
slot.state = McpServerState.CLOSED
|
|
144
|
+
await self._observe("mcp_closed", server_id, "closed")
|