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.
Files changed (143) hide show
  1. windcode/__init__.py +6 -0
  2. windcode/__main__.py +4 -0
  3. windcode/auth/__init__.py +11 -0
  4. windcode/auth/store.py +90 -0
  5. windcode/cli.py +181 -0
  6. windcode/config/__init__.py +41 -0
  7. windcode/config/loader.py +117 -0
  8. windcode/config/models.py +203 -0
  9. windcode/config/writer.py +74 -0
  10. windcode/context/__init__.py +18 -0
  11. windcode/context/compactor.py +72 -0
  12. windcode/context/estimator.py +89 -0
  13. windcode/context/truncation.py +87 -0
  14. windcode/domain/__init__.py +1 -0
  15. windcode/domain/errors.py +33 -0
  16. windcode/domain/events.py +597 -0
  17. windcode/domain/messages.py +226 -0
  18. windcode/domain/models.py +73 -0
  19. windcode/domain/subagents.py +263 -0
  20. windcode/domain/tools.py +55 -0
  21. windcode/extensions/__init__.py +27 -0
  22. windcode/extensions/commands.py +25 -0
  23. windcode/extensions/discovery.py +139 -0
  24. windcode/extensions/events.py +58 -0
  25. windcode/extensions/hooks/__init__.py +4 -0
  26. windcode/extensions/hooks/dispatcher.py +107 -0
  27. windcode/extensions/hooks/executor.py +49 -0
  28. windcode/extensions/hooks/loader.py +101 -0
  29. windcode/extensions/hooks/models.py +109 -0
  30. windcode/extensions/mcp/__init__.py +10 -0
  31. windcode/extensions/mcp/adapter.py +101 -0
  32. windcode/extensions/mcp/catalog.py +153 -0
  33. windcode/extensions/mcp/client.py +273 -0
  34. windcode/extensions/mcp/runtime.py +144 -0
  35. windcode/extensions/mcp/tools.py +570 -0
  36. windcode/extensions/models.py +168 -0
  37. windcode/extensions/paths.py +71 -0
  38. windcode/extensions/plugins/__init__.py +3 -0
  39. windcode/extensions/plugins/installer.py +93 -0
  40. windcode/extensions/plugins/manifest.py +166 -0
  41. windcode/extensions/runtime.py +366 -0
  42. windcode/extensions/service.py +437 -0
  43. windcode/extensions/skills/__init__.py +3 -0
  44. windcode/extensions/skills/loader.py +67 -0
  45. windcode/extensions/skills/parser.py +57 -0
  46. windcode/extensions/skills/tools.py +213 -0
  47. windcode/extensions/snapshot.py +57 -0
  48. windcode/extensions/state.py +158 -0
  49. windcode/instructions/__init__.py +8 -0
  50. windcode/instructions/loader.py +50 -0
  51. windcode/memory/__init__.py +57 -0
  52. windcode/memory/extraction.py +83 -0
  53. windcode/memory/models.py +218 -0
  54. windcode/memory/refiner.py +189 -0
  55. windcode/memory/security.py +23 -0
  56. windcode/memory/service.py +179 -0
  57. windcode/memory/store.py +362 -0
  58. windcode/observability/__init__.py +4 -0
  59. windcode/observability/redaction.py +95 -0
  60. windcode/observability/trace.py +115 -0
  61. windcode/policy/__init__.py +22 -0
  62. windcode/policy/engine.py +137 -0
  63. windcode/policy/models.py +69 -0
  64. windcode/providers/__init__.py +29 -0
  65. windcode/providers/_utils.py +19 -0
  66. windcode/providers/anthropic.py +215 -0
  67. windcode/providers/base.py +46 -0
  68. windcode/providers/catalog.py +119 -0
  69. windcode/providers/errors.py +96 -0
  70. windcode/providers/openai_compat.py +231 -0
  71. windcode/providers/openai_responses.py +189 -0
  72. windcode/providers/registry.py +105 -0
  73. windcode/runtime/__init__.py +15 -0
  74. windcode/runtime/control.py +89 -0
  75. windcode/runtime/event_bus.py +67 -0
  76. windcode/runtime/loop.py +510 -0
  77. windcode/runtime/prompts.py +132 -0
  78. windcode/runtime/report.py +64 -0
  79. windcode/runtime/retry.py +73 -0
  80. windcode/runtime/scheduler.py +194 -0
  81. windcode/runtime/subagents/__init__.py +28 -0
  82. windcode/runtime/subagents/approvals.py +88 -0
  83. windcode/runtime/subagents/budgets.py +79 -0
  84. windcode/runtime/subagents/coordinator.py +714 -0
  85. windcode/runtime/subagents/factory.py +311 -0
  86. windcode/runtime/subagents/roles.py +74 -0
  87. windcode/runtime/subagents/verification.py +53 -0
  88. windcode/sandbox/__init__.py +3 -0
  89. windcode/sandbox/bwrap.py +79 -0
  90. windcode/sdk.py +1259 -0
  91. windcode/sessions/__init__.py +23 -0
  92. windcode/sessions/artifacts.py +69 -0
  93. windcode/sessions/models.py +104 -0
  94. windcode/sessions/store.py +167 -0
  95. windcode/sessions/tree.py +35 -0
  96. windcode/tools/__init__.py +10 -0
  97. windcode/tools/apply_patch.py +191 -0
  98. windcode/tools/ask_user.py +58 -0
  99. windcode/tools/builtins.py +44 -0
  100. windcode/tools/edit_file.py +54 -0
  101. windcode/tools/filesystem.py +77 -0
  102. windcode/tools/glob.py +35 -0
  103. windcode/tools/grep.py +66 -0
  104. windcode/tools/memory.py +400 -0
  105. windcode/tools/read_file.py +54 -0
  106. windcode/tools/registry.py +120 -0
  107. windcode/tools/shell.py +159 -0
  108. windcode/tools/subagents/__init__.py +31 -0
  109. windcode/tools/subagents/cancel.py +35 -0
  110. windcode/tools/subagents/integrate.py +54 -0
  111. windcode/tools/subagents/list.py +46 -0
  112. windcode/tools/subagents/spawn.py +86 -0
  113. windcode/tools/subagents/wait.py +74 -0
  114. windcode/tools/write_file.py +61 -0
  115. windcode/tui/__init__.py +3 -0
  116. windcode/tui/app.py +1015 -0
  117. windcode/tui/commands.py +90 -0
  118. windcode/tui/permission_display.py +24 -0
  119. windcode/tui/styles.tcss +591 -0
  120. windcode/tui/widgets/__init__.py +31 -0
  121. windcode/tui/widgets/approval.py +98 -0
  122. windcode/tui/widgets/command_menu.py +90 -0
  123. windcode/tui/widgets/extensions.py +48 -0
  124. windcode/tui/widgets/input.py +110 -0
  125. windcode/tui/widgets/memory.py +143 -0
  126. windcode/tui/widgets/messages.py +299 -0
  127. windcode/tui/widgets/models.py +565 -0
  128. windcode/tui/widgets/question.py +46 -0
  129. windcode/tui/widgets/sessions.py +68 -0
  130. windcode/tui/widgets/status.py +46 -0
  131. windcode/tui/widgets/subagents.py +195 -0
  132. windcode/tui/widgets/tools.py +66 -0
  133. windcode/tui/widgets/welcome.py +149 -0
  134. windcode/types.py +80 -0
  135. windcode/worktrees/__init__.py +24 -0
  136. windcode/worktrees/git.py +92 -0
  137. windcode/worktrees/manager.py +265 -0
  138. windcode/worktrees/models.py +62 -0
  139. windcode-0.1.0.dist-info/METADATA +207 -0
  140. windcode-0.1.0.dist-info/RECORD +143 -0
  141. windcode-0.1.0.dist-info/WHEEL +4 -0
  142. windcode-0.1.0.dist-info/entry_points.txt +2 -0
  143. 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")