forge-cpp-mcp 0.2.1__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 (83) hide show
  1. forge_cpp_mcp-0.2.1.dist-info/METADATA +436 -0
  2. forge_cpp_mcp-0.2.1.dist-info/RECORD +83 -0
  3. forge_cpp_mcp-0.2.1.dist-info/WHEEL +4 -0
  4. forge_cpp_mcp-0.2.1.dist-info/entry_points.txt +2 -0
  5. forge_cpp_mcp-0.2.1.dist-info/licenses/LICENSE +21 -0
  6. forgemcp/__init__.py +5 -0
  7. forgemcp/assets/clangd-result.html +119 -0
  8. forgemcp/assets/cmake-build.html +111 -0
  9. forgemcp/assets/cmake-configure.html +111 -0
  10. forgemcp/assets/cmake-profiles.html +111 -0
  11. forgemcp/assets/cmake-test.html +111 -0
  12. forgemcp/assets/process-details.html +113 -0
  13. forgemcp/assets/process-overview.html +111 -0
  14. forgemcp/assets/toolsets.html +111 -0
  15. forgemcp/assets/workspace-file.html +119 -0
  16. forgemcp/assets/workspace-result.html +119 -0
  17. forgemcp/assets/workspace-search.html +119 -0
  18. forgemcp/assets/workspace-tree.html +119 -0
  19. forgemcp/assets.py +62 -0
  20. forgemcp/clangd/__init__.py +1 -0
  21. forgemcp/clangd/errors.py +29 -0
  22. forgemcp/clangd/models.py +94 -0
  23. forgemcp/clangd/service.py +1279 -0
  24. forgemcp/clangd/session.py +520 -0
  25. forgemcp/cmake/__init__.py +1 -0
  26. forgemcp/cmake/errors.py +5 -0
  27. forgemcp/cmake/profiles.py +95 -0
  28. forgemcp/cmake/service.py +895 -0
  29. forgemcp/completion.py +49 -0
  30. forgemcp/icons/clangd.LICENSE +219 -0
  31. forgemcp/icons/clangd.svg +25 -0
  32. forgemcp/icons/cmake.svg +9 -0
  33. forgemcp/icons/process.svg +4 -0
  34. forgemcp/icons/toolchain.svg +1 -0
  35. forgemcp/icons/workspace-edit.svg +1 -0
  36. forgemcp/icons/workspace-file.svg +1 -0
  37. forgemcp/icons/workspace-search.svg +1 -0
  38. forgemcp/icons/workspace.svg +4 -0
  39. forgemcp/markdown.py +308 -0
  40. forgemcp/process/__init__.py +21 -0
  41. forgemcp/process/errors.py +21 -0
  42. forgemcp/process/models.py +171 -0
  43. forgemcp/process/service.py +830 -0
  44. forgemcp/process/transcript.py +116 -0
  45. forgemcp/progress.py +40 -0
  46. forgemcp/server.py +196 -0
  47. forgemcp/toolchain/__init__.py +1 -0
  48. forgemcp/toolchain/discovery.py +25 -0
  49. forgemcp/toolchain/errors.py +41 -0
  50. forgemcp/toolchain/loader.py +48 -0
  51. forgemcp/toolchain/providers/__init__.py +1 -0
  52. forgemcp/toolchain/providers/system.py +52 -0
  53. forgemcp/toolchain/providers/user.py +93 -0
  54. forgemcp/toolchain/providers/visual_studio.py +202 -0
  55. forgemcp/toolchain/service.py +226 -0
  56. forgemcp/toolchain/spec.py +73 -0
  57. forgemcp/toolchain/tools/__init__.py +1 -0
  58. forgemcp/toolchain/tools/clang.py +61 -0
  59. forgemcp/toolchain/tools/clang_cl.py +61 -0
  60. forgemcp/toolchain/tools/clangd.py +280 -0
  61. forgemcp/toolchain/tools/clangxx.py +61 -0
  62. forgemcp/toolchain/tools/cmake.py +280 -0
  63. forgemcp/toolchain/tools/cppvsdbg.py +27 -0
  64. forgemcp/toolchain/tools/ctest.py +202 -0
  65. forgemcp/toolchain/tools/gcc.py +61 -0
  66. forgemcp/toolchain/tools/gdb.py +61 -0
  67. forgemcp/toolchain/tools/git.py +61 -0
  68. forgemcp/toolchain/tools/gxx.py +61 -0
  69. forgemcp/toolchain/tools/link.py +27 -0
  70. forgemcp/toolchain/tools/lld.py +61 -0
  71. forgemcp/toolchain/tools/lld_link.py +61 -0
  72. forgemcp/toolchain/tools/lldb_dap.py +27 -0
  73. forgemcp/toolchain/tools/make.py +61 -0
  74. forgemcp/toolchain/tools/msbuild.py +61 -0
  75. forgemcp/toolchain/tools/msvc.py +27 -0
  76. forgemcp/toolchain/tools/ninja.py +61 -0
  77. forgemcp/workspace/__init__.py +5 -0
  78. forgemcp/workspace/diff.py +181 -0
  79. forgemcp/workspace/errors.py +5 -0
  80. forgemcp/workspace/metadata.py +87 -0
  81. forgemcp/workspace/path.py +71 -0
  82. forgemcp/workspace/providers.py +25 -0
  83. forgemcp/workspace/service.py +1766 -0
@@ -0,0 +1,895 @@
1
+ """CMake profile selection, build-tree ownership, and MCP operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import shutil
8
+ import tempfile
9
+ from collections.abc import AsyncGenerator, Sequence
10
+ from pathlib import Path
11
+ from typing import Annotated, Literal, cast
12
+ from urllib.parse import quote
13
+
14
+ from mcp.server import MCPServer
15
+ from mcp.server.apps import Apps
16
+ from mcp.server.mcpserver import Context
17
+ from mcp.server.mcpserver.exceptions import ToolError
18
+ from mcp.types import ToolAnnotations
19
+ from pydantic import BaseModel, Field
20
+
21
+ from forgemcp.assets import IconFile, Widget
22
+ from forgemcp.completion import Complete
23
+ from forgemcp.process.errors import ProcessError
24
+ from forgemcp.process.models import ProcessTimeout
25
+ from forgemcp.progress import progress
26
+ from forgemcp.toolchain.errors import ToolchainError
27
+ from forgemcp.toolchain.service import ToolchainService
28
+ from forgemcp.toolchain.spec import Toolset
29
+ from forgemcp.toolchain.tools import cmake, ctest
30
+ from forgemcp.workspace.path import WorkspacePath
31
+
32
+ from .errors import CMakeError
33
+ from .profiles import ProfileDefinition
34
+
35
+
36
+ class CMakeProfile(BaseModel):
37
+ name: str
38
+ toolset_id: str
39
+ mode: Literal["plain", "presets"] = "plain"
40
+ build_directory: WorkspacePath | None = None
41
+ configure_presets: list[str] = Field(default_factory=list)
42
+ build_presets: list[str] = Field(default_factory=list)
43
+ test_presets: list[str] = Field(default_factory=list)
44
+ configuration: str | None = None
45
+ generator: str | None = None
46
+
47
+
48
+ class CompilationContext(BaseModel):
49
+ id: str
50
+ toolset_id: str
51
+ build_directory: WorkspacePath
52
+ compilation_database: WorkspacePath
53
+
54
+
55
+ class CMakeConfigureResult(BaseModel):
56
+ profile: str
57
+ preset: str | None = None
58
+ build_directory: WorkspacePath | None = None
59
+ compilation_database: WorkspacePath | None = None
60
+ error: str | None = Field(
61
+ default=None,
62
+ description="Null on success; failure explanation otherwise.",
63
+ )
64
+ process_id: int | None = None
65
+
66
+
67
+ class CMakeBuildResult(BaseModel):
68
+ profile: str
69
+ preset: str | None = None
70
+ configuration: str | None = None
71
+ completed_steps: int | None = None
72
+ total_steps: int | None = None
73
+ error: str | None = Field(
74
+ default=None,
75
+ description="Null on success; failure explanation otherwise.",
76
+ )
77
+ process_id: int | None = None
78
+
79
+
80
+ class CMakeTestResult(BaseModel):
81
+ profile: str
82
+ preset: str | None = None
83
+ configuration: str | None = None
84
+ tests: list[ctest.TestCase] = Field(default_factory=list)
85
+ error: str | None = Field(
86
+ default=None,
87
+ description="Null on success; failure explanation otherwise.",
88
+ )
89
+ process_id: int | None = None
90
+
91
+
92
+ class CMakeService:
93
+ """CMake: configuration, builds, and CTest runs through named operator profiles.
94
+
95
+ Each profile binds a toolset to manual settings (configuration, generator,
96
+ compilers, build directory, cache definitions) or native presets.
97
+ Configure/build/test presets are selected independently; no associations are
98
+ inferred. Profile settings belong to the operator, not tool arguments.
99
+
100
+ When the operator supplies no profiles, preset files yield one presets profile
101
+ running all available presets; without preset files, Debug and Release use
102
+ separate storage/build/cmake-<name> directories.
103
+ Omitting the profiles filter runs all profiles; a nonempty list selects a subset.
104
+ Configure before building; build before testing. Diagnose with a subset,
105
+ then verify all profiles. Each execution reports error (null means success)
106
+ and, when launched, process_id for its command log.
107
+ """
108
+
109
+ ICON = IconFile("icons/cmake.svg")
110
+ PROFILES_WIDGET = Widget("assets/cmake-profiles.html")
111
+ CONFIGURE_WIDGET = Widget("assets/cmake-configure.html")
112
+ BUILD_WIDGET = Widget("assets/cmake-build.html")
113
+ TEST_WIDGET = Widget("assets/cmake-test.html")
114
+
115
+ def __init__(
116
+ self,
117
+ toolchains: ToolchainService,
118
+ profiles: tuple[ProfileDefinition, ...] = (),
119
+ *,
120
+ project_root: Path,
121
+ storage_root: Path,
122
+ protected_paths: set[Path],
123
+ default_toolset: str = "system",
124
+ progress_interval: float = 1.0,
125
+ ) -> None:
126
+ self.project_root = project_root.resolve()
127
+ self.storage_root = storage_root.resolve()
128
+ self.protected_paths = protected_paths
129
+ self.toolchains = toolchains
130
+ self.definitions = profiles
131
+ self.default_toolset = default_toolset
132
+ self.progress_interval = progress_interval
133
+ self.configurations: dict[str, CompilationContext] = {}
134
+ self.configurations_loaded = False
135
+ self.configuration_subscribers: dict[
136
+ asyncio.Queue[list[CompilationContext]], tuple[str, ...]
137
+ ] = {}
138
+ self.configuration_lock = asyncio.Lock()
139
+
140
+ def toolset(self, selector: str) -> Toolset:
141
+ matches = [
142
+ item
143
+ for item in self.toolchains.list_toolsets()
144
+ if selector in (item.id, item.name)
145
+ ]
146
+ if len(matches) != 1:
147
+ raise CMakeError(f"Expected one toolset matching {selector!r}.")
148
+ return matches[0]
149
+
150
+ def cmake_methods(self, toolset_id: str) -> cmake.Methods:
151
+ tool = self.toolchains.get_tool(toolset_id, "cmake")
152
+ if tool is None:
153
+ raise CMakeError(f"Toolset {toolset_id!r} has no cmake executable.")
154
+ return cast(cmake.Methods, tool.methods)
155
+
156
+ def ctest_methods(self, toolset_id: str) -> ctest.Methods:
157
+ tool = self.toolchains.get_tool(toolset_id, "ctest")
158
+ if tool is None:
159
+ raise CMakeError(f"Toolset {toolset_id!r} has no ctest executable.")
160
+ return cast(ctest.Methods, tool.methods)
161
+
162
+ async def available_presets(self, toolset_id: str) -> cmake.PresetsResult:
163
+ methods = self.cmake_methods(toolset_id)
164
+ return await methods["presets"](self.project_root)
165
+
166
+ async def selection(
167
+ self,
168
+ names: list[str] | None,
169
+ ) -> tuple[list[ProfileDefinition], dict[str, cmake.PresetsResult | CMakeError]]:
170
+ catalogs = {}
171
+ if self.definitions:
172
+ definitions = list(self.definitions)
173
+ elif any(
174
+ (self.project_root / filename).is_file()
175
+ for filename in ("CMakePresets.json", "CMakeUserPresets.json")
176
+ ):
177
+ toolset = self.toolset(self.default_toolset)
178
+ available = await self.available_presets(toolset.id)
179
+ catalogs[toolset.id] = available
180
+ definitions = [
181
+ ProfileDefinition(
182
+ name="presets",
183
+ toolset=toolset.id,
184
+ configure_presets=available.configure,
185
+ build_presets=available.build,
186
+ test_presets=available.test,
187
+ )
188
+ ]
189
+ else:
190
+ definitions = [
191
+ ProfileDefinition(
192
+ name=configuration,
193
+ toolset=self.default_toolset,
194
+ configuration=configuration,
195
+ )
196
+ for configuration in ("Debug", "Release")
197
+ ]
198
+ if names is not None:
199
+ if not names or len(names) != len(set(names)):
200
+ raise CMakeError("Profiles must be a nonempty list without duplicates.")
201
+ unknown = set(names) - {definition.name for definition in definitions}
202
+ if unknown:
203
+ raise CMakeError(f"Unknown CMake profiles: {', '.join(sorted(unknown))}.")
204
+ definitions = [definition for definition in definitions if definition.name in names]
205
+ for definition in definitions:
206
+ try:
207
+ toolset = self.toolset(definition.toolset)
208
+ except CMakeError:
209
+ # Report a bad toolset on its own profile without stopping the others.
210
+ continue
211
+ if definition.uses_presets and toolset.id not in catalogs:
212
+ try:
213
+ catalogs[toolset.id] = await self.available_presets(toolset.id)
214
+ except (CMakeError, ToolchainError, ProcessError) as error:
215
+ catalogs[toolset.id] = CMakeError(str(error))
216
+ return definitions, catalogs
217
+
218
+ def resolve_profile(
219
+ self,
220
+ definition: ProfileDefinition,
221
+ catalogs: dict[str, cmake.PresetsResult | CMakeError],
222
+ ) -> CMakeProfile:
223
+ if not definition.uses_presets:
224
+ return self.plain_profile(definition)
225
+ toolset = self.toolset(definition.toolset)
226
+ available = catalogs[toolset.id]
227
+ if isinstance(available, CMakeError):
228
+ raise available
229
+ for kind in ("configure", "build", "test"):
230
+ chosen = getattr(definition, kind + "_presets") or []
231
+ if len(chosen) != len(set(chosen)):
232
+ raise CMakeError(f"Repeated {kind} preset in profile {definition.name!r}.")
233
+ unknown = set(chosen) - set(getattr(available, kind))
234
+ if unknown:
235
+ raise CMakeError(
236
+ f"Unavailable {kind} presets for {toolset.name}: {', '.join(sorted(unknown))}."
237
+ )
238
+ if definition.build_directory is not None:
239
+ self.build_path(definition.build_directory)
240
+ return CMakeProfile(
241
+ name=definition.name,
242
+ toolset_id=toolset.id,
243
+ mode="presets",
244
+ build_directory=definition.build_directory,
245
+ configure_presets=definition.configure_presets or [],
246
+ build_presets=definition.build_presets or [],
247
+ test_presets=definition.test_presets or [],
248
+ configuration=definition.configuration,
249
+ )
250
+
251
+ def operation_presets(self, profile: CMakeProfile, operation: str) -> list[str | None]:
252
+ if profile.mode == "plain":
253
+ return [None]
254
+ selected = getattr(profile, operation + "_presets")
255
+ if selected:
256
+ return selected
257
+ if operation != "configure" and profile.build_directory is not None:
258
+ return [None]
259
+ raise CMakeError(
260
+ f"Profile {profile.name!r} has no {operation} presets. "
261
+ "Select the required presets in --cmake-profile; for build/test without "
262
+ "presets, set build-directory to the existing preset build directory."
263
+ )
264
+
265
+ def plain_profile(self, definition: ProfileDefinition) -> CMakeProfile:
266
+ toolset = self.toolset(definition.toolset)
267
+ directory = definition.build_directory
268
+ if directory is None:
269
+ if (
270
+ not definition.name
271
+ or definition.name in (".", "..")
272
+ or any(char in definition.name for char in "/\\\0")
273
+ ):
274
+ raise CMakeError("Directory key must be one nonempty directory name.")
275
+ directory = WorkspacePath(f"storage/build/cmake-{definition.name}")
276
+ self.build_path(directory)
277
+ return CMakeProfile(
278
+ name=definition.name,
279
+ toolset_id=toolset.id,
280
+ build_directory=directory,
281
+ configuration=definition.configuration,
282
+ generator=definition.generator or "Ninja",
283
+ )
284
+
285
+ def build_path(self, directory: WorkspacePath) -> Path:
286
+ if directory.area == "root":
287
+ raise CMakeError("Build directory must be inside the project or storage root.")
288
+ base = self.project_root if directory.area == "project" else self.storage_root
289
+ path = base / directory.relative
290
+ try:
291
+ if not path.resolve().is_relative_to(base):
292
+ raise CMakeError(f"Build path resolves outside its root: {directory}.")
293
+ if path in (self.project_root, self.storage_root):
294
+ raise CMakeError("Cannot use a workspace root as a build directory.")
295
+ if os.name == "nt" and any(
296
+ os.path.isreserved(part) for part in Path(directory.relative).parts
297
+ ):
298
+ raise CMakeError(f"Build path contains a reserved name: {directory}.")
299
+ current = path
300
+ while True:
301
+ if current.is_symlink() or current.is_junction():
302
+ raise CMakeError("Build path cannot traverse symbolic links or junctions.")
303
+ if current == base:
304
+ break
305
+ current = current.parent
306
+ if any(path.is_relative_to(protected) for protected in self.protected_paths):
307
+ raise CMakeError(f"Build path is protected: {directory}.")
308
+ except OSError as error:
309
+ raise CMakeError(f"Cannot inspect build path {directory}.") from error
310
+ return path
311
+
312
+ def cache(self, directory: WorkspacePath) -> dict[str, str]:
313
+ path = self.build_path(directory) / "CMakeCache.txt"
314
+ base = self.project_root if directory.area == "project" else self.storage_root
315
+ try:
316
+ if not path.resolve().is_relative_to(base):
317
+ raise CMakeError("CMake cache resolves outside the build root.")
318
+ text = path.read_text(encoding="utf-8")
319
+ except (OSError, UnicodeError) as error:
320
+ raise CMakeError(f"Cannot read CMake cache in {directory}.") from error
321
+ if "\0" in text:
322
+ raise CMakeError(f"CMake cache in {directory} is not a text file.")
323
+ return cmake.parse_cache(text)
324
+
325
+ def require_configured(self, profile: CMakeProfile) -> dict[str, str]:
326
+ if profile.build_directory is None:
327
+ raise CMakeError("Set build-directory for operations without a preset.")
328
+ try:
329
+ cache = self.cache(profile.build_directory)
330
+ except CMakeError as error:
331
+ raise CMakeError(f"Configure profile {profile.name!r} first.") from error
332
+ source = cache.get("CMAKE_HOME_DIRECTORY")
333
+ if source is None or Path(source).resolve() != self.project_root:
334
+ raise CMakeError("Build directory belongs to another source project.")
335
+ return cache
336
+
337
+ def require_configuration(
338
+ self,
339
+ cache: dict[str, str],
340
+ configuration: str | None,
341
+ ) -> None:
342
+ choices = cache.get("CMAKE_CONFIGURATION_TYPES", "").split(";")
343
+ if choices != [""] and configuration not in choices:
344
+ raise CMakeError(
345
+ "Select an available multi-config configuration in the operator "
346
+ f"profile or preset: {', '.join(choices)}."
347
+ )
348
+ if (
349
+ choices == [""]
350
+ and configuration is not None
351
+ and cache.get("CMAKE_BUILD_TYPE") != configuration
352
+ ):
353
+ raise CMakeError(
354
+ "The build directory has a different configuration; configure this profile first."
355
+ )
356
+
357
+ def configure_definitions(self, definition: ProfileDefinition) -> dict[str, str]:
358
+ result = dict(definition.definitions)
359
+ reserved = {"CMAKE_HOME_DIRECTORY", "CMAKE_CACHEFILE_DIR"}
360
+ if reserved.intersection(key.partition(":")[0] for key in result):
361
+ raise CMakeError("CMake source/build directories cannot be cache overrides.")
362
+ toolset = self.toolset(definition.toolset)
363
+ for field, variable in (
364
+ (definition.c_compiler, "CMAKE_C_COMPILER"),
365
+ (definition.cxx_compiler, "CMAKE_CXX_COMPILER"),
366
+ ):
367
+ if field is not None:
368
+ tool = self.toolchains.get_tool(toolset.id, field)
369
+ if tool is None:
370
+ raise CMakeError(f"Toolset {toolset.name!r} has no {field!r}.")
371
+ if any(key.partition(":")[0] == variable for key in result):
372
+ raise CMakeError(f"Duplicate setting for {variable}.")
373
+ result[variable] = str(tool.path)
374
+ if definition.toolchain_file is not None:
375
+ if any(key.partition(":")[0] == "CMAKE_TOOLCHAIN_FILE" for key in result):
376
+ raise CMakeError("Duplicate setting for CMAKE_TOOLCHAIN_FILE.")
377
+ reference = definition.toolchain_file
378
+ if reference.area == "root":
379
+ raise CMakeError("Toolchain file must be inside the project or storage root.")
380
+ base = self.project_root if reference.area == "project" else self.storage_root
381
+ toolchain_file = (base / reference.relative).resolve()
382
+ if not toolchain_file.is_relative_to(base):
383
+ raise CMakeError(f"Toolchain file is outside its root: {reference}.")
384
+ if not toolchain_file.is_file():
385
+ raise CMakeError(f"Toolchain file does not exist: {reference}.")
386
+ result["CMAKE_TOOLCHAIN_FILE"] = str(toolchain_file)
387
+ if definition.configuration is not None:
388
+ configured = [
389
+ value
390
+ for key, value in result.items()
391
+ if key.partition(":")[0] == "CMAKE_BUILD_TYPE"
392
+ ]
393
+ if configured and any(value != definition.configuration for value in configured):
394
+ raise CMakeError("configuration conflicts with CMAKE_BUILD_TYPE.")
395
+ if not configured:
396
+ result["CMAKE_BUILD_TYPE"] = definition.configuration
397
+ if not any(key.partition(":")[0] == "CMAKE_EXPORT_COMPILE_COMMANDS" for key in result):
398
+ result["CMAKE_EXPORT_COMPILE_COMMANDS"] = "ON"
399
+ if (definition.generator or "Ninja") in ("Ninja", "Ninja Multi-Config"):
400
+ ninja = self.toolchains.get_tool(toolset.id, "ninja")
401
+ if ninja is None:
402
+ raise CMakeError(f"Toolset {toolset.name!r} has no ninja executable.")
403
+ if not any(key.partition(":")[0] == "CMAKE_MAKE_PROGRAM" for key in result):
404
+ result["CMAKE_MAKE_PROGRAM"] = str(ninja.path)
405
+ return result
406
+
407
+ def prepare_file_api(self, directory: WorkspacePath) -> None:
408
+ build_directory = self.build_path(directory)
409
+ reference = WorkspacePath(f"{directory}/.cmake/api/v1/query/client-forgemcp")
410
+ query = self.build_path(reference)
411
+ try:
412
+ build_directory.mkdir(parents=True, exist_ok=True)
413
+ query.mkdir(parents=True, exist_ok=True)
414
+ for name in ("codemodel-v2", "cache-v2", "toolchains-v1"):
415
+ target = WorkspacePath(f"{reference}/{name}")
416
+ file = self.build_path(target)
417
+ file.write_text("", encoding="utf-8")
418
+ except OSError as error:
419
+ raise CMakeError(f"Cannot prepare CMake File API in {directory}.") from error
420
+
421
+ def compilation_database(self, directory: WorkspacePath) -> WorkspacePath | None:
422
+ path = self.build_path(directory) / "compile_commands.json"
423
+ return WorkspacePath(f"{directory}/compile_commands.json") if path.is_file() else None
424
+
425
+ async def compilation_contexts(
426
+ self,
427
+ profiles: Sequence[str] = (),
428
+ ) -> list[CompilationContext]:
429
+ """Return successful configurations with databases that still exist."""
430
+ if not self.configurations_loaded:
431
+ try:
432
+ definitions, catalogs = await self.selection(None)
433
+ except (CMakeError, ToolchainError, ProcessError):
434
+ return []
435
+ for definition in definitions:
436
+ try:
437
+ profile = self.resolve_profile(definition, catalogs)
438
+ presets = self.operation_presets(profile, "configure")
439
+ except (CMakeError, ToolchainError):
440
+ continue
441
+ for preset in presets:
442
+ directory = profile.build_directory
443
+ if directory is None or (preset is not None and len(presets) != 1):
444
+ continue
445
+ try:
446
+ database = self.compilation_database(directory)
447
+ except (CMakeError, OSError):
448
+ continue
449
+ if database is None:
450
+ continue
451
+ identifier = quote(profile.name, safe="")
452
+ if preset is not None:
453
+ identifier += "/" + quote(preset, safe="")
454
+ self.configurations[identifier] = CompilationContext(
455
+ id=identifier,
456
+ toolset_id=profile.toolset_id,
457
+ build_directory=directory,
458
+ compilation_database=database,
459
+ )
460
+ self.configurations_loaded = True
461
+ if profiles:
462
+ definitions, _ = await self.selection(list(profiles))
463
+ selected = {quote(definition.name, safe="") for definition in definitions}
464
+ else:
465
+ selected = None
466
+ for identifier, context in tuple(self.configurations.items()):
467
+ try:
468
+ available = self.compilation_database(context.build_directory)
469
+ except (CMakeError, OSError):
470
+ available = None
471
+ if available != context.compilation_database:
472
+ del self.configurations[identifier]
473
+ return [
474
+ context.model_copy(deep=True)
475
+ for context in self.configurations.values()
476
+ if selected is None or context.id.split("/", 1)[0] in selected
477
+ ]
478
+
479
+ async def configuration_updates(self) -> AsyncGenerator[list[CompilationContext], None]:
480
+ """Yield the current configurations, then their latest changed snapshot."""
481
+ queue: asyncio.Queue[list[CompilationContext]] = asyncio.Queue(maxsize=1)
482
+ async with self.configuration_lock:
483
+ current = await self.compilation_contexts()
484
+ self.configuration_subscribers[queue] = tuple(
485
+ item.model_dump_json() for item in current
486
+ )
487
+ queue.put_nowait(current)
488
+ try:
489
+ while True:
490
+ yield await queue.get()
491
+ finally:
492
+ async with self.configuration_lock:
493
+ self.configuration_subscribers.pop(queue, None)
494
+
495
+ async def publish_configurations(self, *, refresh: bool = False) -> None:
496
+ """Publish changed snapshots, or refresh databases after successful configure."""
497
+ if not self.configuration_subscribers:
498
+ return
499
+ async with self.configuration_lock:
500
+ current = await self.compilation_contexts()
501
+ signature = tuple(item.model_dump_json() for item in current)
502
+ for queue, previous in tuple(self.configuration_subscribers.items()):
503
+ if signature == previous and not refresh:
504
+ continue
505
+ if queue.full():
506
+ queue.get_nowait()
507
+ snapshot = [item.model_copy(deep=True) for item in current]
508
+ queue.put_nowait(snapshot)
509
+ self.configuration_subscribers[queue] = signature
510
+
511
+ def register(self, mcp: MCPServer, apps: Apps, complete: Complete) -> None:
512
+ icon = self.ICON.icon
513
+ changes_files = ToolAnnotations(
514
+ read_only_hint=False,
515
+ destructive_hint=True,
516
+ idempotent_hint=False,
517
+ open_world_hint=True,
518
+ )
519
+
520
+ @apps.tool(
521
+ resource_uri=self.PROFILES_WIDGET.uri,
522
+ icons=[icon],
523
+ annotations=ToolAnnotations(read_only_hint=True),
524
+ )
525
+ async def cmake_profiles(ctx: Context) -> list[CMakeProfile]:
526
+ """List effective profiles with their toolsets, build settings, and preset selections."""
527
+ report = progress(ctx, interval=self.progress_interval)
528
+ await report(0, message="Reading CMake profiles")
529
+ try:
530
+ definitions, catalogs = await self.selection(None)
531
+ profiles = []
532
+ for definition in definitions:
533
+ profile = self.resolve_profile(definition, catalogs)
534
+ profiles.append(profile)
535
+ await report(len(profiles), message=f"Read {profile.name}")
536
+ return profiles
537
+ except (CMakeError, ToolchainError, ProcessError) as error:
538
+ raise ToolError(str(error)) from error
539
+
540
+ @apps.tool(
541
+ resource_uri=self.CONFIGURE_WIDGET.uri,
542
+ icons=[icon],
543
+ annotations=changes_files,
544
+ )
545
+ async def cmake_configure(
546
+ ctx: Context,
547
+ profiles: list[str] | None = None,
548
+ timeout: ProcessTimeout = ProcessTimeout(total=600),
549
+ ) -> list[CMakeConfigureResult]:
550
+ """Configure selected profiles; return build/database paths and per-execution errors.
551
+
552
+ Changing a manual profile's generator removes and recreates its build directory.
553
+ """
554
+ report = progress(ctx, interval=self.progress_interval)
555
+ await report(0, message="Preparing CMake configure")
556
+ try:
557
+ definitions, catalogs = await self.selection(profiles)
558
+ await self.compilation_contexts()
559
+ except (CMakeError, ToolchainError, ProcessError) as error:
560
+ raise ToolError(str(error)) from error
561
+ results: list[CMakeConfigureResult] = []
562
+ configured = {}
563
+ updates = 0
564
+
565
+ async def report_status(message: str) -> None:
566
+ nonlocal updates
567
+ updates += 1
568
+ label = profile.name if preset is None else f"{profile.name}/{preset}"
569
+ await report(updates, message=f"{label}: {message}")
570
+
571
+ for definition in definitions:
572
+ try:
573
+ profile = self.resolve_profile(definition, catalogs)
574
+ presets = self.operation_presets(profile, "configure")
575
+ methods = self.cmake_methods(profile.toolset_id)
576
+ except (CMakeError, ToolchainError) as error:
577
+ identifier = quote(definition.name, safe="")
578
+ for key in tuple(self.configurations):
579
+ if key == identifier or key.startswith(identifier + "/"):
580
+ del self.configurations[key]
581
+ await self.publish_configurations()
582
+ results.append(
583
+ CMakeConfigureResult(
584
+ profile=definition.name,
585
+ error=str(error),
586
+ )
587
+ )
588
+ continue
589
+ for preset in presets:
590
+ identifier = quote(profile.name, safe="")
591
+ if preset is not None:
592
+ identifier += "/" + quote(preset, safe="")
593
+ result = CMakeConfigureResult(
594
+ profile=profile.name,
595
+ preset=preset,
596
+ build_directory=profile.build_directory,
597
+ )
598
+ results.append(result)
599
+ try:
600
+ key = (profile.toolset_id, preset, profile.build_directory)
601
+ if preset is not None and key in configured:
602
+ prior = configured[key]
603
+ result.error = prior.error
604
+ result.process_id = prior.process_id
605
+ result.compilation_database = prior.compilation_database
606
+ if (
607
+ result.error is None
608
+ and result.build_directory is not None
609
+ and result.compilation_database is not None
610
+ and len(presets) == 1
611
+ ):
612
+ self.configurations[identifier] = CompilationContext(
613
+ id=identifier,
614
+ toolset_id=profile.toolset_id,
615
+ build_directory=result.build_directory,
616
+ compilation_database=result.compilation_database,
617
+ )
618
+ else:
619
+ self.configurations.pop(identifier, None)
620
+ await self.publish_configurations()
621
+ continue
622
+ build_directory = (
623
+ self.build_path(profile.build_directory)
624
+ if profile.build_directory is not None else None
625
+ )
626
+ settings = {}
627
+ if preset is None:
628
+ if profile.build_directory is None:
629
+ raise CMakeError("Plain configure requires a build directory.")
630
+ settings = self.configure_definitions(definition)
631
+ build_directory = self.build_path(profile.build_directory)
632
+ if (build_directory / "CMakeCache.txt").exists():
633
+ cache = self.require_configured(profile)
634
+ if cache.get("CMAKE_GENERATOR") != profile.generator:
635
+ await report_status("Clearing build directory for generator change")
636
+ if any(
637
+ protected.is_relative_to(build_directory)
638
+ for protected in self.protected_paths
639
+ ) or self.storage_root.is_relative_to(build_directory):
640
+ raise CMakeError("Build directory contains a protected path.")
641
+ if build_directory.exists():
642
+ def raise_walk_error(error: OSError) -> None:
643
+ raise error
644
+
645
+ try:
646
+ for parent, directories, files in os.walk(
647
+ build_directory,
648
+ followlinks=False,
649
+ onerror=raise_walk_error,
650
+ ):
651
+ for name in (*directories, *files):
652
+ item = Path(parent) / name
653
+ if item.is_symlink() or item.is_junction():
654
+ raise CMakeError(
655
+ "Build directory contains a link or junction."
656
+ )
657
+ base = (
658
+ self.project_root
659
+ if profile.build_directory.area == "project"
660
+ else self.storage_root
661
+ )
662
+ if not build_directory.resolve().is_relative_to(base):
663
+ raise CMakeError("Build directory escaped its root.")
664
+ shutil.rmtree(build_directory)
665
+ await self.publish_configurations()
666
+ except OSError as error:
667
+ raise CMakeError("Cannot clear build directory.") from error
668
+ self.prepare_file_api(profile.build_directory)
669
+ await report_status("Starting configure")
670
+ command = await methods["configure"](
671
+ self.project_root,
672
+ build_directory,
673
+ preset=preset,
674
+ generator=profile.generator,
675
+ definitions=settings,
676
+ timeout=timeout,
677
+ on_progress=report_status,
678
+ )
679
+ result.process_id = command.process_id
680
+ if command.return_code != 0:
681
+ result.error = f"CMake configure exited with {command.return_code}."
682
+ if command.return_code == 0 and result.build_directory is not None:
683
+ result.compilation_database = self.compilation_database(result.build_directory)
684
+ if preset is not None:
685
+ configured[key] = result
686
+ except (CMakeError, ToolchainError, ProcessError) as error:
687
+ result.error = str(error)
688
+ result.process_id = getattr(error, "process_id", result.process_id)
689
+ if (
690
+ result.error is None
691
+ and result.build_directory is not None
692
+ and result.compilation_database is not None
693
+ and (preset is None or len(presets) == 1)
694
+ ):
695
+ self.configurations[identifier] = CompilationContext(
696
+ id=identifier,
697
+ toolset_id=profile.toolset_id,
698
+ build_directory=result.build_directory,
699
+ compilation_database=result.compilation_database,
700
+ )
701
+ else:
702
+ self.configurations.pop(identifier, None)
703
+ await self.publish_configurations(refresh=result.error is None)
704
+ await report_status(result.error or "Completed successfully")
705
+ return results
706
+
707
+ @apps.tool(
708
+ resource_uri=self.BUILD_WIDGET.uri,
709
+ icons=[icon],
710
+ annotations=changes_files,
711
+ )
712
+ async def cmake_build(
713
+ ctx: Context,
714
+ profiles: list[str] | None = None,
715
+ targets: list[str] | None = None,
716
+ parallel: Annotated[int | None, Field(ge=1)] = None,
717
+ timeout: ProcessTimeout = ProcessTimeout(total=600),
718
+ ) -> list[CMakeBuildResult]:
719
+ """Build selected profiles and their build presets after configuration.
720
+
721
+ Omitted targets use CMake or preset defaults; a nonempty targets list
722
+ selects explicit build targets.
723
+ """
724
+ if targets is not None and (not targets or any(not name for name in targets)):
725
+ raise ToolError("Targets must be a nonempty list of nonempty names.")
726
+ report = progress(ctx, interval=self.progress_interval)
727
+ await report(0, message="Preparing CMake build")
728
+ try:
729
+ definitions, catalogs = await self.selection(profiles)
730
+ except (CMakeError, ToolchainError, ProcessError) as error:
731
+ raise ToolError(str(error)) from error
732
+ results: list[CMakeBuildResult] = []
733
+ updates = 0
734
+
735
+ async def report_status(message: str) -> None:
736
+ nonlocal updates
737
+ updates += 1
738
+ label = profile.name if preset is None else f"{profile.name}/{preset}"
739
+ await report(updates, message=f"{label}: {message}")
740
+
741
+ for definition in definitions:
742
+ try:
743
+ profile = self.resolve_profile(definition, catalogs)
744
+ methods = self.cmake_methods(profile.toolset_id)
745
+ presets = self.operation_presets(profile, "build")
746
+ except (CMakeError, ToolchainError) as error:
747
+ results.append(
748
+ CMakeBuildResult(
749
+ profile=definition.name,
750
+ error=str(error),
751
+ )
752
+ )
753
+ continue
754
+ for preset in presets:
755
+ result = CMakeBuildResult(
756
+ profile=profile.name,
757
+ preset=preset,
758
+ )
759
+ results.append(result)
760
+ try:
761
+ configuration = profile.configuration
762
+ result.configuration = configuration
763
+ build_directory = None
764
+ if preset is None:
765
+ cache = self.require_configured(profile)
766
+ self.require_configuration(cache, configuration)
767
+ build_directory = self.build_path(profile.build_directory)
768
+ await report_status("Starting build")
769
+ command = await methods["build"](
770
+ self.project_root,
771
+ build_directory,
772
+ preset=preset,
773
+ configuration=configuration,
774
+ targets=targets,
775
+ parallel=parallel,
776
+ timeout=timeout,
777
+ on_progress=report_status,
778
+ )
779
+ result.completed_steps = command.completed_steps
780
+ result.total_steps = command.total_steps
781
+ result.process_id = command.process_id
782
+ if command.return_code != 0:
783
+ result.error = f"CMake build exited with {command.return_code}."
784
+ except (CMakeError, ToolchainError, ProcessError) as error:
785
+ result.error = str(error)
786
+ result.process_id = getattr(error, "process_id", result.process_id)
787
+ await report_status(result.error or "Completed successfully")
788
+ return results
789
+
790
+ @apps.tool(
791
+ resource_uri=self.TEST_WIDGET.uri,
792
+ icons=[icon],
793
+ annotations=changes_files,
794
+ )
795
+ async def cmake_test(
796
+ ctx: Context,
797
+ profiles: list[str] | None = None,
798
+ names: list[str] | None = None,
799
+ parallel: Annotated[int | None, Field(ge=1)] = None,
800
+ timeout: ProcessTimeout = ProcessTimeout(total=600),
801
+ ) -> list[CMakeTestResult]:
802
+ """Run CTest for selected profiles and test presets after building; return test cases.
803
+
804
+ names selects exact test names, not regex patterns; omit it to use
805
+ CTest or preset defaults.
806
+ """
807
+ if names is not None and (not names or any(not name for name in names)):
808
+ raise ToolError("Test names must be a nonempty list of nonempty names.")
809
+ report = progress(ctx, interval=self.progress_interval)
810
+ await report(0, message="Preparing CTest")
811
+ try:
812
+ definitions, catalogs = await self.selection(profiles)
813
+ except (CMakeError, ToolchainError, ProcessError) as error:
814
+ raise ToolError(str(error)) from error
815
+ results: list[CMakeTestResult] = []
816
+ updates = 0
817
+
818
+ async def report_status(message: str) -> None:
819
+ nonlocal updates
820
+ updates += 1
821
+ label = profile.name if preset is None else f"{profile.name}/{preset}"
822
+ await report(updates, message=f"{label}: {message}")
823
+
824
+ for definition in definitions:
825
+ try:
826
+ profile = self.resolve_profile(definition, catalogs)
827
+ methods = self.ctest_methods(profile.toolset_id)
828
+ presets = self.operation_presets(profile, "test")
829
+ except (CMakeError, ToolchainError) as error:
830
+ results.append(
831
+ CMakeTestResult(
832
+ profile=definition.name,
833
+ error=str(error),
834
+ )
835
+ )
836
+ continue
837
+ for preset in presets:
838
+ result = CMakeTestResult(
839
+ profile=profile.name,
840
+ preset=preset,
841
+ )
842
+ results.append(result)
843
+ try:
844
+ configuration = profile.configuration
845
+ result.configuration = configuration
846
+ build_directory = None
847
+ if preset is None:
848
+ cache = self.require_configured(profile)
849
+ self.require_configuration(cache, configuration)
850
+ build_directory = self.build_path(profile.build_directory)
851
+ temporary_root = self.build_path(WorkspacePath("storage/tmp"))
852
+ try:
853
+ temporary_root.mkdir(parents=True, exist_ok=True)
854
+ except OSError as error:
855
+ raise CMakeError("Cannot create CTest temporary storage.") from error
856
+ try:
857
+ with tempfile.TemporaryDirectory(
858
+ prefix="ctest-",
859
+ dir=temporary_root,
860
+ ) as temporary:
861
+ report_path = Path(temporary) / "results.xml"
862
+ await report_status("Starting tests")
863
+ command = await methods["test"](
864
+ self.project_root,
865
+ build_directory,
866
+ report_path=report_path,
867
+ preset=preset,
868
+ configuration=configuration,
869
+ names=names,
870
+ parallel=parallel,
871
+ timeout=timeout,
872
+ on_progress=report_status,
873
+ )
874
+ result.process_id = command.process_id
875
+ result.tests = command.tests
876
+ except OSError as error:
877
+ raise CMakeError("Cannot use CTest temporary storage.") from error
878
+ failed = any(
879
+ test.status in ("failed", "not_run") for test in result.tests
880
+ )
881
+ if command.return_code != 0 or failed:
882
+ result.error = f"CTest failed (exit {command.return_code})."
883
+ except (CMakeError, ToolchainError, ProcessError) as error:
884
+ result.error = str(error)
885
+ result.process_id = getattr(error, "process_id", result.process_id)
886
+ await report_status(result.error or "Completed successfully")
887
+ return results
888
+
889
+ for widget in (
890
+ self.PROFILES_WIDGET,
891
+ self.CONFIGURE_WIDGET,
892
+ self.BUILD_WIDGET,
893
+ self.TEST_WIDGET,
894
+ ):
895
+ apps.add_html_resource(widget.uri, widget.content)