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,570 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from dataclasses import dataclass
6
+ from typing import cast
7
+
8
+ from mcp.types import (
9
+ ListPromptsResult,
10
+ ListResourcesResult,
11
+ ListResourceTemplatesResult,
12
+ ListToolsResult,
13
+ Prompt,
14
+ Resource,
15
+ ResourceTemplate,
16
+ TextResourceContents,
17
+ Tool,
18
+ )
19
+ from pydantic import BaseModel, ConfigDict, Field
20
+
21
+ from windcode.domain.messages import SourcedContextMessage
22
+ from windcode.domain.tools import ToolContext, ToolEffect, ToolResult
23
+ from windcode.extensions.mcp.adapter import McpToolAdapter
24
+ from windcode.extensions.mcp.catalog import (
25
+ McpCatalog,
26
+ McpPromptDefinition,
27
+ McpResourceDefinition,
28
+ McpToolDefinition,
29
+ build_catalog,
30
+ )
31
+ from windcode.extensions.mcp.runtime import McpRuntime
32
+ from windcode.extensions.models import CapabilityKind, CapabilityRecord
33
+ from windcode.sessions.artifacts import ArtifactStore
34
+ from windcode.tools.registry import ToolRegistry
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class McpToolSearchResult:
39
+ stable_id: str
40
+ server_id: str
41
+ name: str
42
+ description: str
43
+
44
+
45
+ class McpToolView:
46
+ def __init__(
47
+ self,
48
+ catalogs: tuple[McpCatalog, ...],
49
+ adapters: dict[str, McpToolAdapter],
50
+ *,
51
+ direct_tool_limit: int,
52
+ ) -> None:
53
+ self._definitions = {tool.stable_id: tool for catalog in catalogs for tool in catalog.tools}
54
+ self._adapters = dict(adapters)
55
+ self.direct_tool_limit = direct_tool_limit
56
+ self._selected: set[str] = set()
57
+
58
+ def search(self, query: str = "") -> tuple[McpToolSearchResult, ...]:
59
+ needle = query.casefold().strip()
60
+ return tuple(
61
+ McpToolSearchResult(item.stable_id, item.server_id, item.name, item.description)
62
+ for item in sorted(self._definitions.values(), key=lambda value: value.stable_id)
63
+ if not needle or needle in item.name.casefold() or needle in item.description.casefold()
64
+ )
65
+
66
+ def select(self, stable_id: str) -> McpToolDefinition:
67
+ try:
68
+ definition = self._definitions[stable_id.removeprefix("select:")]
69
+ except KeyError as exc:
70
+ raise KeyError(f"unknown MCP tool: {stable_id}") from exc
71
+ self._selected.add(definition.stable_id)
72
+ return definition
73
+
74
+ def register_visible(self, registry: ToolRegistry) -> None:
75
+ visible = (
76
+ set(self._definitions)
77
+ if len(self._definitions) <= self.direct_tool_limit
78
+ else self._selected
79
+ )
80
+ for stable_id in sorted(visible):
81
+ registry.register(self._adapters[stable_id], replace=True)
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class SourcedMcpContent:
86
+ server_id: str
87
+ identity: str
88
+ content: str
89
+ artifact_ref: str | None = None
90
+
91
+
92
+ class McpCapabilityService:
93
+ def __init__(
94
+ self,
95
+ runtime: McpRuntime,
96
+ *,
97
+ artifact_store: ArtifactStore | None = None,
98
+ content_limit: int = 20_000,
99
+ tool_catalogs: dict[str, tuple[McpToolDefinition, ...]] | None = None,
100
+ ) -> None:
101
+ self.runtime = runtime
102
+ self.artifact_store = artifact_store
103
+ self.content_limit = content_limit
104
+ self._catalogs: dict[str, McpCatalog] = {}
105
+ self._tool_catalogs = tool_catalogs if tool_catalogs is not None else {}
106
+ self._tool_catalog_locks: dict[str, asyncio.Lock] = {}
107
+ self._instructions_emitted: set[str] = set()
108
+ self._pending_context: list[SourcedContextMessage] = []
109
+
110
+ def drain_context(self) -> tuple[SourcedContextMessage, ...]:
111
+ messages = tuple(self._pending_context)
112
+ self._pending_context.clear()
113
+ return messages
114
+
115
+ def _emit_instructions(self, server_id: str, instructions: str | None) -> None:
116
+ if not instructions or server_id in self._instructions_emitted:
117
+ return
118
+ self._instructions_emitted.add(server_id)
119
+ self._pending_context.append(
120
+ SourcedContextMessage(f"mcp:{server_id}/instructions", instructions)
121
+ )
122
+
123
+ async def catalog(self, server_id: str) -> McpCatalog:
124
+ cached = self._catalogs.get(server_id)
125
+ if cached is not None:
126
+ return cached
127
+ client = await self.runtime.activate(server_id)
128
+ initialize = client.initialize_result
129
+ if initialize is None:
130
+ raise RuntimeError("MCP Server did not initialize")
131
+ tool_values: list[Tool] = []
132
+ resource_values: list[Resource] = []
133
+ template_values: list[ResourceTemplate] = []
134
+ prompt_values: list[Prompt] = []
135
+ cursor: str | None = None
136
+ while True:
137
+ page = await client.list_tools(cursor)
138
+ tool_values.extend(page.tools)
139
+ cursor = page.nextCursor
140
+ if cursor is None:
141
+ break
142
+ cursor = None
143
+ while True:
144
+ resource_page = await client.list_resources(cursor)
145
+ resource_values.extend(resource_page.resources)
146
+ cursor = resource_page.nextCursor
147
+ if cursor is None:
148
+ break
149
+ cursor = None
150
+ while True:
151
+ template_page = await client.list_resource_templates(cursor)
152
+ template_values.extend(template_page.resourceTemplates)
153
+ cursor = template_page.nextCursor
154
+ if cursor is None:
155
+ break
156
+ cursor = None
157
+ while True:
158
+ prompt_page = await client.list_prompts(cursor)
159
+ prompt_values.extend(prompt_page.prompts)
160
+ cursor = prompt_page.nextCursor
161
+ if cursor is None:
162
+ break
163
+ catalog = build_catalog(
164
+ server_id,
165
+ initialize,
166
+ ListToolsResult(tools=tool_values),
167
+ ListResourcesResult(resources=resource_values),
168
+ ListPromptsResult(prompts=prompt_values),
169
+ ListResourceTemplatesResult(resourceTemplates=template_values),
170
+ )
171
+ self._catalogs[server_id] = catalog
172
+ self._tool_catalogs[server_id] = catalog.tools
173
+ self._emit_instructions(server_id, catalog.instructions)
174
+ return catalog
175
+
176
+ async def tool_catalog(self, server_id: str) -> tuple[McpToolDefinition, ...]:
177
+ """List a server's tools without fetching resources/templates/prompts.
178
+
179
+ Tool discovery (search_mcp_tools) and direct registration only need
180
+ tools, so avoid the extra network round-trips a full catalog build makes.
181
+ """
182
+
183
+ full = self._catalogs.get(server_id)
184
+ if full is not None:
185
+ return full.tools
186
+ cached = self._tool_catalogs.get(server_id)
187
+ if cached is not None:
188
+ return cached
189
+ lock = self._tool_catalog_locks.setdefault(server_id, asyncio.Lock())
190
+ async with lock:
191
+ cached = self._tool_catalogs.get(server_id)
192
+ if cached is not None:
193
+ return cached
194
+ client = await self.runtime.activate(server_id)
195
+ initialize = client.initialize_result
196
+ if initialize is None:
197
+ raise RuntimeError("MCP Server did not initialize")
198
+ tool_values: list[Tool] = []
199
+ cursor: str | None = None
200
+ while True:
201
+ page = await client.list_tools(cursor)
202
+ tool_values.extend(page.tools)
203
+ cursor = page.nextCursor
204
+ if cursor is None:
205
+ break
206
+ catalog = build_catalog(
207
+ server_id,
208
+ initialize,
209
+ ListToolsResult(tools=tool_values),
210
+ ListResourcesResult(resources=[]),
211
+ ListPromptsResult(prompts=[]),
212
+ )
213
+ self._tool_catalogs[server_id] = catalog.tools
214
+ self._emit_instructions(server_id, catalog.instructions)
215
+ return catalog.tools
216
+
217
+ async def search_tools(self, query: str = "") -> tuple[McpToolDefinition, ...]:
218
+ tool_lists = [await self.tool_catalog(server_id) for server_id in self.runtime.server_ids]
219
+ needle = query.casefold().strip()
220
+ return tuple(
221
+ tool
222
+ for tools in tool_lists
223
+ for tool in tools
224
+ if not needle or needle in tool.name.casefold() or needle in tool.description.casefold()
225
+ )
226
+
227
+ async def tool(self, stable_id: str) -> McpToolDefinition:
228
+ for definition in await self.search_tools():
229
+ if definition.stable_id == stable_id:
230
+ return definition
231
+ raise KeyError(f"unknown MCP tool: {stable_id}")
232
+
233
+ async def adapter(self, stable_id: str) -> McpToolAdapter:
234
+ definition = await self.tool(stable_id)
235
+ return McpToolAdapter(
236
+ definition,
237
+ self.runtime,
238
+ artifact_store=self.artifact_store,
239
+ output_limit=self.content_limit,
240
+ )
241
+
242
+ async def register_selected_tools(
243
+ self, registry: ToolRegistry, stable_ids: set[str]
244
+ ) -> tuple[str, ...]:
245
+ registered: list[str] = []
246
+ stale: list[str] = []
247
+ for stable_id in sorted(stable_ids):
248
+ try:
249
+ adapter = await self.adapter(stable_id)
250
+ except KeyError:
251
+ stale.append(stable_id)
252
+ continue
253
+ registry.register(adapter, replace=True)
254
+ registered.append(adapter.name)
255
+ stable_ids.difference_update(stale)
256
+ return tuple(registered)
257
+
258
+ async def register_direct_tools(
259
+ self, registry: ToolRegistry, *, direct_tool_limit: int
260
+ ) -> tuple[str, ...]:
261
+ tool_lists = [
262
+ await self.tool_catalog(server_id) for server_id in self.runtime.required_server_ids
263
+ ]
264
+ definitions = tuple(tool for tools in tool_lists for tool in tools)
265
+ if len(definitions) > direct_tool_limit:
266
+ return ()
267
+ registered: list[str] = []
268
+ for definition in sorted(definitions, key=lambda item: item.stable_id):
269
+ adapter = McpToolAdapter(
270
+ definition,
271
+ self.runtime,
272
+ artifact_store=self.artifact_store,
273
+ output_limit=self.content_limit,
274
+ )
275
+ registry.register(adapter, replace=True)
276
+ registered.append(adapter.name)
277
+ return tuple(registered)
278
+
279
+ async def resources(self, server_id: str) -> tuple[McpResourceDefinition, ...]:
280
+ return (await self.catalog(server_id)).resources
281
+
282
+ async def prompts(self, server_id: str) -> tuple[McpPromptDefinition, ...]:
283
+ return (await self.catalog(server_id)).prompts
284
+
285
+ async def read_resource(self, server_id: str, uri: str) -> SourcedMcpContent:
286
+ result = await (await self.runtime.activate(server_id)).read_resource(uri)
287
+ content = "\n".join(
288
+ item.text
289
+ if isinstance(item, TextResourceContents)
290
+ else json.dumps(item.model_dump(mode="json"), sort_keys=True)
291
+ for item in result.contents
292
+ )
293
+ return self._bounded(server_id, uri, content)
294
+
295
+ async def get_prompt(
296
+ self, server_id: str, name: str, arguments: dict[str, str] | None = None
297
+ ) -> SourcedMcpContent:
298
+ result = await (await self.runtime.activate(server_id)).get_prompt(name, arguments)
299
+ content = "\n".join(
300
+ json.dumps(message.model_dump(mode="json"), sort_keys=True)
301
+ for message in result.messages
302
+ )
303
+ return self._bounded(server_id, name, content)
304
+
305
+ async def activate_prompt(self, name: str) -> SourcedMcpContent:
306
+ matches: list[tuple[str, McpPromptDefinition]] = []
307
+ for server_id in self.runtime.server_ids:
308
+ matches.extend(
309
+ (server_id, prompt)
310
+ for prompt in await self.prompts(server_id)
311
+ if prompt.name == name or prompt.stable_id == name
312
+ )
313
+ if not matches:
314
+ raise KeyError(f"unknown MCP prompt: {name}")
315
+ if len(matches) > 1:
316
+ raise ValueError(f"ambiguous MCP prompt: {name}")
317
+ server_id, prompt = matches[0]
318
+ content = await self.get_prompt(server_id, prompt.name)
319
+ self._pending_context.append(
320
+ SourcedContextMessage(f"mcp:{server_id}/prompt/{prompt.name}", content.content)
321
+ )
322
+ return content
323
+
324
+ async def instructions(self, server_id: str) -> SourcedMcpContent | None:
325
+ instructions = (await self.catalog(server_id)).instructions
326
+ return (
327
+ None if instructions is None else self._bounded(server_id, "instructions", instructions)
328
+ )
329
+
330
+ def _bounded(self, server_id: str, identity: str, content: str) -> SourcedMcpContent:
331
+ if self.artifact_store is None:
332
+ return SourcedMcpContent(server_id, identity, content[: self.content_limit])
333
+ summary, reference = self.artifact_store.externalize(content, threshold=self.content_limit)
334
+ return SourcedMcpContent(
335
+ server_id,
336
+ identity,
337
+ summary,
338
+ None if reference is None else reference.relative_path,
339
+ )
340
+
341
+
342
+ class _StrictInput(BaseModel):
343
+ model_config = ConfigDict(extra="forbid")
344
+
345
+
346
+ class SearchMcpToolsInput(_StrictInput):
347
+ query: str = ""
348
+
349
+
350
+ class ListMcpServersInput(_StrictInput):
351
+ include_disabled: bool = False
352
+
353
+
354
+ class ListMcpServersTool:
355
+ name = "list_mcp_servers"
356
+ description = (
357
+ "List enabled MCP servers available to this configuration. Disabled servers are omitted "
358
+ "by default and must not be counted as current MCP servers. Set `include_disabled` to "
359
+ "true only when the user explicitly asks to inspect disabled MCP configuration."
360
+ )
361
+ input_model = ListMcpServersInput
362
+ effects = frozenset[ToolEffect]()
363
+
364
+ def __init__(
365
+ self,
366
+ records: tuple[CapabilityRecord, ...],
367
+ tool_catalogs: dict[str, tuple[McpToolDefinition, ...]],
368
+ selected_tools: set[str],
369
+ ) -> None:
370
+ self.records = tuple(
371
+ record for record in records if record.kind is CapabilityKind.MCP_SERVER
372
+ )
373
+ self.tool_catalogs = tool_catalogs
374
+ self.selected_tools = selected_tools
375
+
376
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
377
+ del context
378
+ parsed = cast(ListMcpServersInput, arguments)
379
+ visible_records = tuple(
380
+ record for record in self.records if parsed.include_disabled or record.enabled
381
+ )
382
+ return ToolResult(
383
+ json.dumps(
384
+ {
385
+ "server_count": len(visible_records),
386
+ "includes_disabled": parsed.include_disabled,
387
+ "servers": [
388
+ {
389
+ "id": record.public_name,
390
+ "enabled": record.enabled,
391
+ "trusted": record.trusted,
392
+ "required": record.required,
393
+ "activation": record.activation.value,
394
+ "scope": record.source.scope.value,
395
+ "available_this_run": record.enabled and record.trusted,
396
+ "tool_catalog_cached": record.public_name in self.tool_catalogs,
397
+ "cached_tool_count": len(
398
+ self.tool_catalogs.get(record.public_name, ())
399
+ ),
400
+ "selected_tool_count": sum(
401
+ tool_id.startswith(f"mcp:{record.public_name}/tool/")
402
+ for tool_id in self.selected_tools
403
+ ),
404
+ }
405
+ for record in visible_records
406
+ ],
407
+ },
408
+ sort_keys=True,
409
+ )
410
+ )
411
+
412
+
413
+ class ReadMcpResourceInput(_StrictInput):
414
+ server_id: str = Field(min_length=1)
415
+ uri: str = Field(min_length=1)
416
+
417
+
418
+ class GetMcpPromptInput(_StrictInput):
419
+ server_id: str = Field(min_length=1)
420
+ name: str = Field(min_length=1)
421
+ arguments: dict[str, str] = Field(default_factory=dict[str, str])
422
+
423
+
424
+ class SearchMcpToolsTool:
425
+ name = "search_mcp_tools"
426
+ description = (
427
+ "Discover and enable an MCP tool that is not already available. Search with a specific "
428
+ "keyword: one match is enabled immediately and returns its `call_name`; multiple matches "
429
+ "require one follow-up query using `select:<id>`. Call the returned tool directly and do "
430
+ "not repeat discovery. Enabled tools remain available in later runs."
431
+ )
432
+ input_model = SearchMcpToolsInput
433
+ effects = frozenset({ToolEffect.PROCESS, ToolEffect.NETWORK})
434
+
435
+ def __init__(
436
+ self,
437
+ service: McpCapabilityService,
438
+ registry: ToolRegistry,
439
+ selected_tools: set[str],
440
+ ) -> None:
441
+ self.service = service
442
+ self.registry = registry
443
+ self.selected_tools = selected_tools
444
+
445
+ async def _select(self, stable_id: str) -> tuple[McpToolAdapter, bool]:
446
+ already_selected = stable_id in self.selected_tools
447
+ adapter = await self.service.adapter(stable_id)
448
+ self.registry.register(adapter, replace=True)
449
+ self.selected_tools.add(stable_id)
450
+ return adapter, already_selected
451
+
452
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
453
+ del context
454
+ parsed = cast(SearchMcpToolsInput, arguments)
455
+ if parsed.query.startswith("select:"):
456
+ stable_id = parsed.query.removeprefix("select:")
457
+ adapter, already_selected = await self._select(stable_id)
458
+ return ToolResult(
459
+ json.dumps(
460
+ {
461
+ "selected": stable_id,
462
+ "already_selected": already_selected,
463
+ "call_name": adapter.name,
464
+ "source": adapter.definition.server_id,
465
+ "next_step": (
466
+ f"call the tool named {adapter.name} directly with its arguments"
467
+ ),
468
+ },
469
+ sort_keys=True,
470
+ )
471
+ )
472
+ tools = await self.service.search_tools(parsed.query)
473
+ if len(tools) == 1:
474
+ tool = tools[0]
475
+ adapter, already_selected = await self._select(tool.stable_id)
476
+ return ToolResult(
477
+ json.dumps(
478
+ {
479
+ "selected": tool.stable_id,
480
+ "already_selected": already_selected,
481
+ "call_name": adapter.name,
482
+ "source": tool.server_id,
483
+ "next_step": (
484
+ f"call the tool named {adapter.name} directly; do not call "
485
+ "search_mcp_tools again"
486
+ ),
487
+ },
488
+ sort_keys=True,
489
+ )
490
+ )
491
+ return ToolResult(
492
+ json.dumps(
493
+ {
494
+ "tools": [
495
+ {
496
+ "id": tool.stable_id,
497
+ "name": tool.name,
498
+ "description": tool.description,
499
+ "source": tool.server_id,
500
+ }
501
+ for tool in tools
502
+ ],
503
+ "hint": (
504
+ "Multiple tools matched. Enable the intended one with "
505
+ "query='select:<id>' exactly once, then call the returned call_name."
506
+ ),
507
+ },
508
+ sort_keys=True,
509
+ )
510
+ )
511
+
512
+
513
+ class ReadMcpResourceTool:
514
+ name = "read_mcp_resource"
515
+ description = "Read a resource from an activated MCP Server with source attribution."
516
+ input_model = ReadMcpResourceInput
517
+ effects = frozenset({ToolEffect.PROCESS, ToolEffect.NETWORK})
518
+
519
+ def __init__(self, service: McpCapabilityService) -> None:
520
+ self.service = service
521
+
522
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
523
+ del context
524
+ parsed = cast(ReadMcpResourceInput, arguments)
525
+ content = await self.service.read_resource(parsed.server_id, parsed.uri)
526
+ return ToolResult(
527
+ content.content,
528
+ artifact_ref=content.artifact_ref,
529
+ data={"source": content.server_id, "identity": content.identity},
530
+ )
531
+
532
+
533
+ class GetMcpPromptTool:
534
+ name = "get_mcp_prompt"
535
+ description = "Get a prompt from an activated MCP Server with source attribution."
536
+ input_model = GetMcpPromptInput
537
+ effects = frozenset({ToolEffect.PROCESS, ToolEffect.NETWORK})
538
+
539
+ def __init__(self, service: McpCapabilityService) -> None:
540
+ self.service = service
541
+
542
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
543
+ del context
544
+ parsed = cast(GetMcpPromptInput, arguments)
545
+ content = await self.service.get_prompt(parsed.server_id, parsed.name, parsed.arguments)
546
+ return ToolResult(
547
+ content.content,
548
+ artifact_ref=content.artifact_ref,
549
+ data={"source": content.server_id, "identity": content.identity},
550
+ )
551
+
552
+
553
+ def register_mcp_management_tools(
554
+ registry: ToolRegistry, service: McpCapabilityService, selected_tools: set[str]
555
+ ) -> None:
556
+ for tool in (
557
+ SearchMcpToolsTool(service, registry, selected_tools),
558
+ ReadMcpResourceTool(service),
559
+ GetMcpPromptTool(service),
560
+ ):
561
+ registry.register(tool)
562
+
563
+
564
+ def register_mcp_status_tool(
565
+ registry: ToolRegistry,
566
+ records: tuple[CapabilityRecord, ...],
567
+ tool_catalogs: dict[str, tuple[McpToolDefinition, ...]],
568
+ selected_tools: set[str],
569
+ ) -> None:
570
+ registry.register(ListMcpServersTool(records, tool_catalogs, selected_tools))