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.
- forge_cpp_mcp-0.2.1.dist-info/METADATA +436 -0
- forge_cpp_mcp-0.2.1.dist-info/RECORD +83 -0
- forge_cpp_mcp-0.2.1.dist-info/WHEEL +4 -0
- forge_cpp_mcp-0.2.1.dist-info/entry_points.txt +2 -0
- forge_cpp_mcp-0.2.1.dist-info/licenses/LICENSE +21 -0
- forgemcp/__init__.py +5 -0
- forgemcp/assets/clangd-result.html +119 -0
- forgemcp/assets/cmake-build.html +111 -0
- forgemcp/assets/cmake-configure.html +111 -0
- forgemcp/assets/cmake-profiles.html +111 -0
- forgemcp/assets/cmake-test.html +111 -0
- forgemcp/assets/process-details.html +113 -0
- forgemcp/assets/process-overview.html +111 -0
- forgemcp/assets/toolsets.html +111 -0
- forgemcp/assets/workspace-file.html +119 -0
- forgemcp/assets/workspace-result.html +119 -0
- forgemcp/assets/workspace-search.html +119 -0
- forgemcp/assets/workspace-tree.html +119 -0
- forgemcp/assets.py +62 -0
- forgemcp/clangd/__init__.py +1 -0
- forgemcp/clangd/errors.py +29 -0
- forgemcp/clangd/models.py +94 -0
- forgemcp/clangd/service.py +1279 -0
- forgemcp/clangd/session.py +520 -0
- forgemcp/cmake/__init__.py +1 -0
- forgemcp/cmake/errors.py +5 -0
- forgemcp/cmake/profiles.py +95 -0
- forgemcp/cmake/service.py +895 -0
- forgemcp/completion.py +49 -0
- forgemcp/icons/clangd.LICENSE +219 -0
- forgemcp/icons/clangd.svg +25 -0
- forgemcp/icons/cmake.svg +9 -0
- forgemcp/icons/process.svg +4 -0
- forgemcp/icons/toolchain.svg +1 -0
- forgemcp/icons/workspace-edit.svg +1 -0
- forgemcp/icons/workspace-file.svg +1 -0
- forgemcp/icons/workspace-search.svg +1 -0
- forgemcp/icons/workspace.svg +4 -0
- forgemcp/markdown.py +308 -0
- forgemcp/process/__init__.py +21 -0
- forgemcp/process/errors.py +21 -0
- forgemcp/process/models.py +171 -0
- forgemcp/process/service.py +830 -0
- forgemcp/process/transcript.py +116 -0
- forgemcp/progress.py +40 -0
- forgemcp/server.py +196 -0
- forgemcp/toolchain/__init__.py +1 -0
- forgemcp/toolchain/discovery.py +25 -0
- forgemcp/toolchain/errors.py +41 -0
- forgemcp/toolchain/loader.py +48 -0
- forgemcp/toolchain/providers/__init__.py +1 -0
- forgemcp/toolchain/providers/system.py +52 -0
- forgemcp/toolchain/providers/user.py +93 -0
- forgemcp/toolchain/providers/visual_studio.py +202 -0
- forgemcp/toolchain/service.py +226 -0
- forgemcp/toolchain/spec.py +73 -0
- forgemcp/toolchain/tools/__init__.py +1 -0
- forgemcp/toolchain/tools/clang.py +61 -0
- forgemcp/toolchain/tools/clang_cl.py +61 -0
- forgemcp/toolchain/tools/clangd.py +280 -0
- forgemcp/toolchain/tools/clangxx.py +61 -0
- forgemcp/toolchain/tools/cmake.py +280 -0
- forgemcp/toolchain/tools/cppvsdbg.py +27 -0
- forgemcp/toolchain/tools/ctest.py +202 -0
- forgemcp/toolchain/tools/gcc.py +61 -0
- forgemcp/toolchain/tools/gdb.py +61 -0
- forgemcp/toolchain/tools/git.py +61 -0
- forgemcp/toolchain/tools/gxx.py +61 -0
- forgemcp/toolchain/tools/link.py +27 -0
- forgemcp/toolchain/tools/lld.py +61 -0
- forgemcp/toolchain/tools/lld_link.py +61 -0
- forgemcp/toolchain/tools/lldb_dap.py +27 -0
- forgemcp/toolchain/tools/make.py +61 -0
- forgemcp/toolchain/tools/msbuild.py +61 -0
- forgemcp/toolchain/tools/msvc.py +27 -0
- forgemcp/toolchain/tools/ninja.py +61 -0
- forgemcp/workspace/__init__.py +5 -0
- forgemcp/workspace/diff.py +181 -0
- forgemcp/workspace/errors.py +5 -0
- forgemcp/workspace/metadata.py +87 -0
- forgemcp/workspace/path.py +71 -0
- forgemcp/workspace/providers.py +25 -0
- forgemcp/workspace/service.py +1766 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Windows Visual Studio instances, installation layouts, and VsDevCmd environments."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from itertools import islice
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from forgemcp.process.errors import ProcessError
|
|
10
|
+
from forgemcp.process.models import ProcessEncoding, ProcessTimeout
|
|
11
|
+
from forgemcp.process.service import ProcessService
|
|
12
|
+
|
|
13
|
+
from ..errors import ToolCommandError
|
|
14
|
+
from ..spec import ToolInfo, Toolset
|
|
15
|
+
|
|
16
|
+
MAX_INSTANCES = 128
|
|
17
|
+
MAX_LAYOUT_ENTRIES = 512
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def vswhere_path() -> Path | None:
|
|
21
|
+
folder = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
|
22
|
+
path = Path(folder) / "Microsoft Visual Studio/Installer/vswhere.exe"
|
|
23
|
+
return path.resolve() if path.is_file() else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def instance_environment(root: Path, processes: ProcessService) -> dict[str, str]:
|
|
27
|
+
batch = root / "Common7/Tools/VsDevCmd.bat"
|
|
28
|
+
if not batch.is_file():
|
|
29
|
+
raise ToolCommandError("Visual Studio developer environment is unavailable.")
|
|
30
|
+
# cmd is the explicit interpreter for the batch script. Shell operators here
|
|
31
|
+
# are provider-owned, not executable arguments supplied by project data.
|
|
32
|
+
comspec = os.environ.get("COMSPEC", r"C:\Windows\System32\cmd.exe")
|
|
33
|
+
session = await processes.launch(
|
|
34
|
+
comspec,
|
|
35
|
+
(
|
|
36
|
+
"/u",
|
|
37
|
+
"/d",
|
|
38
|
+
"/s",
|
|
39
|
+
"/c",
|
|
40
|
+
"call",
|
|
41
|
+
str(batch),
|
|
42
|
+
"-no_logo",
|
|
43
|
+
"-arch=x64",
|
|
44
|
+
"&&",
|
|
45
|
+
"set",
|
|
46
|
+
),
|
|
47
|
+
encoding=ProcessEncoding("utf-16-le"),
|
|
48
|
+
timeout=ProcessTimeout(total=60),
|
|
49
|
+
)
|
|
50
|
+
environment: dict[str, str] = {}
|
|
51
|
+
pending = ""
|
|
52
|
+
async with session:
|
|
53
|
+
await session.close_stdin()
|
|
54
|
+
async for output in session.output():
|
|
55
|
+
if output.stream != "stdout":
|
|
56
|
+
continue
|
|
57
|
+
pending += output.text
|
|
58
|
+
lines = pending.split("\n")
|
|
59
|
+
pending = lines.pop()
|
|
60
|
+
for line in lines:
|
|
61
|
+
name, separator, value = line.rstrip("\r").partition("=")
|
|
62
|
+
if separator and name:
|
|
63
|
+
environment[name] = value
|
|
64
|
+
if pending:
|
|
65
|
+
name, separator, value = pending.rstrip("\r").partition("=")
|
|
66
|
+
if separator and name:
|
|
67
|
+
environment[name] = value
|
|
68
|
+
result = await session.wait()
|
|
69
|
+
if result != 0 or not environment:
|
|
70
|
+
raise ToolCommandError("Visual Studio developer environment failed.")
|
|
71
|
+
return environment
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def directories(root: Path) -> list[Path]:
|
|
75
|
+
if not root.is_dir():
|
|
76
|
+
return []
|
|
77
|
+
# Only enumerate known shallow layout directories, never a recursive tree scan.
|
|
78
|
+
return sorted(
|
|
79
|
+
(path for path in islice(root.iterdir(), MAX_LAYOUT_ENTRIES) if path.is_dir()),
|
|
80
|
+
reverse=True,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def locate(root: Path, name: str) -> Path | None:
|
|
85
|
+
candidates: list[Path] = []
|
|
86
|
+
if name in {"cl", "link"}:
|
|
87
|
+
for version in directories(root / "VC/Tools/MSVC"):
|
|
88
|
+
for host in ("Hostx64/x64", "Hostarm64/arm64", "Hostx86/x86"):
|
|
89
|
+
candidates.append(version / "bin" / host / f"{name}.exe")
|
|
90
|
+
if name == "msbuild":
|
|
91
|
+
candidates += [
|
|
92
|
+
root / "MSBuild/Current/Bin/amd64/MSBuild.exe",
|
|
93
|
+
root / "MSBuild/Current/Bin/MSBuild.exe",
|
|
94
|
+
]
|
|
95
|
+
if name in {"cmake", "ctest", "ninja"}:
|
|
96
|
+
relative = "Ninja/ninja.exe" if name == "ninja" else f"CMake/bin/{name}.exe"
|
|
97
|
+
candidates.append(
|
|
98
|
+
root / "Common7/IDE/CommonExtensions/Microsoft/CMake" / relative
|
|
99
|
+
)
|
|
100
|
+
if name in {
|
|
101
|
+
"clang",
|
|
102
|
+
"clang++",
|
|
103
|
+
"clang-cl",
|
|
104
|
+
"lld",
|
|
105
|
+
"lld-link",
|
|
106
|
+
"clangd",
|
|
107
|
+
"lldb-dap",
|
|
108
|
+
}:
|
|
109
|
+
for folder in (
|
|
110
|
+
"VC/Tools/Llvm/x64/bin",
|
|
111
|
+
"VC/Tools/Llvm/ARM64/bin",
|
|
112
|
+
"VC/Tools/Llvm/bin",
|
|
113
|
+
):
|
|
114
|
+
candidates.append(root / folder / f"{name}.exe")
|
|
115
|
+
if name == "git":
|
|
116
|
+
candidates.append(
|
|
117
|
+
root
|
|
118
|
+
/ "Common7/IDE/CommonExtensions/Microsoft/TeamFoundation/Team Explorer/Git/cmd/git.exe"
|
|
119
|
+
)
|
|
120
|
+
if name == "cppvsdbg":
|
|
121
|
+
for relative in (
|
|
122
|
+
"Common7/IDE/Extensions/Microsoft/DebugAdapterHost/OpenDebugAD7.exe",
|
|
123
|
+
"Common7/IDE/CommonExtensions/Microsoft/VC/Debugger/OpenDebugAD7.exe",
|
|
124
|
+
):
|
|
125
|
+
candidates.append(root / relative)
|
|
126
|
+
return next((path.resolve() for path in candidates if path.is_file()), None)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def discover(
|
|
130
|
+
specs: tuple[ToolInfo, ...],
|
|
131
|
+
processes: ProcessService,
|
|
132
|
+
) -> tuple[Toolset, ...]:
|
|
133
|
+
if os.name != "nt":
|
|
134
|
+
return ()
|
|
135
|
+
executable = vswhere_path()
|
|
136
|
+
if executable is None:
|
|
137
|
+
return ()
|
|
138
|
+
try:
|
|
139
|
+
session = await processes.launch(
|
|
140
|
+
executable,
|
|
141
|
+
("-all", "-prerelease", "-products", "*", "-format", "json", "-utf8"),
|
|
142
|
+
encoding=ProcessEncoding("utf-8"),
|
|
143
|
+
timeout=ProcessTimeout(total=30),
|
|
144
|
+
)
|
|
145
|
+
async with session:
|
|
146
|
+
await session.close_stdin()
|
|
147
|
+
# JSON must be decoded as a document; cap only this discovery listing.
|
|
148
|
+
document = ""
|
|
149
|
+
async for output in session.output():
|
|
150
|
+
if output.stream == "stdout":
|
|
151
|
+
if len(document) + len(output.text) > 4 * 1024 * 1024:
|
|
152
|
+
raise ToolCommandError(
|
|
153
|
+
"Visual Studio instance listing is too large."
|
|
154
|
+
)
|
|
155
|
+
document += output.text
|
|
156
|
+
terminal = await session.wait()
|
|
157
|
+
if terminal != 0:
|
|
158
|
+
return ()
|
|
159
|
+
instances = json.loads(document)
|
|
160
|
+
if not isinstance(instances, list):
|
|
161
|
+
return ()
|
|
162
|
+
except (ProcessError, ToolCommandError, ValueError, OSError):
|
|
163
|
+
return ()
|
|
164
|
+
found = []
|
|
165
|
+
seen = set()
|
|
166
|
+
for instance in instances[:MAX_INSTANCES]:
|
|
167
|
+
if not isinstance(instance, dict):
|
|
168
|
+
continue
|
|
169
|
+
if not all(
|
|
170
|
+
isinstance(instance.get(key), str) and instance[key]
|
|
171
|
+
for key in ("instanceId", "installationPath", "displayName")
|
|
172
|
+
):
|
|
173
|
+
continue
|
|
174
|
+
identifier = "visual-studio-" + quote(instance["instanceId"], safe="")
|
|
175
|
+
if identifier in seen:
|
|
176
|
+
continue
|
|
177
|
+
seen.add(identifier)
|
|
178
|
+
root = Path(instance["installationPath"])
|
|
179
|
+
try:
|
|
180
|
+
environment = await instance_environment(root, processes)
|
|
181
|
+
except (ProcessError, ToolCommandError, OSError):
|
|
182
|
+
# Keep a failed instance visible, but don't bind commands to a wrong env.
|
|
183
|
+
found.append(Toolset(identifier, instance["displayName"], (), None, False))
|
|
184
|
+
continue
|
|
185
|
+
bound = []
|
|
186
|
+
for spec in specs:
|
|
187
|
+
try:
|
|
188
|
+
path = locate(root, spec.name)
|
|
189
|
+
if path is not None:
|
|
190
|
+
bound.append(spec.create_spec(path, processes, environment, False))
|
|
191
|
+
except OSError:
|
|
192
|
+
continue
|
|
193
|
+
found.append(
|
|
194
|
+
Toolset(
|
|
195
|
+
identifier,
|
|
196
|
+
instance["displayName"],
|
|
197
|
+
tuple(bound),
|
|
198
|
+
environment,
|
|
199
|
+
False,
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
return tuple(found)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Toolset containers, consumer API, and read-only MCP inspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from mcp.server import MCPServer
|
|
9
|
+
from mcp.server.apps import Apps
|
|
10
|
+
from mcp.server.mcpserver import Context
|
|
11
|
+
from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError
|
|
12
|
+
from mcp.types import (
|
|
13
|
+
Completion,
|
|
14
|
+
CompletionArgument,
|
|
15
|
+
CompletionContext,
|
|
16
|
+
PromptReference,
|
|
17
|
+
ResourceTemplateReference,
|
|
18
|
+
ToolAnnotations,
|
|
19
|
+
)
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
|
|
22
|
+
from forgemcp import markdown
|
|
23
|
+
from forgemcp.assets import IconFile, Widget
|
|
24
|
+
from forgemcp.completion import Complete
|
|
25
|
+
from forgemcp.progress import progress
|
|
26
|
+
from forgemcp.process.service import ProcessService
|
|
27
|
+
|
|
28
|
+
from . import discovery
|
|
29
|
+
from .errors import ToolCommandError, ToolParserError, ToolsetNotFoundError
|
|
30
|
+
from .spec import ToolKind, Toolset, ToolSpec
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ToolsetSummary(BaseModel):
|
|
34
|
+
id: str
|
|
35
|
+
name: str
|
|
36
|
+
tools: list[str]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ToolDetails(BaseModel):
|
|
40
|
+
name: str
|
|
41
|
+
kind: ToolKind
|
|
42
|
+
path: str
|
|
43
|
+
version: str | None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ToolsetDetails(BaseModel):
|
|
47
|
+
id: str
|
|
48
|
+
name: str
|
|
49
|
+
tools: list[ToolDetails]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ToolchainService:
|
|
53
|
+
"""Toolchain: independent sets of development executables and execution environments.
|
|
54
|
+
|
|
55
|
+
Toolsets are discovered once at startup and may be incomplete or empty.
|
|
56
|
+
No current or preferred toolset is selected; consumers bind an explicit ID.
|
|
57
|
+
Executable paths are absolute; available versions are queried on demand.
|
|
58
|
+
A missing version does not imply a missing executable. Toolset results omit
|
|
59
|
+
execution environments.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
WIDGET = Widget("assets/toolsets.html")
|
|
63
|
+
ICON = IconFile("icons/toolchain.svg")
|
|
64
|
+
LIST_URI = "forgemcp://toolsets"
|
|
65
|
+
DETAILS_URI = "forgemcp://toolsets/{toolset_id}"
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
processes: ProcessService,
|
|
70
|
+
definitions: Sequence[Sequence[str]] = (),
|
|
71
|
+
*,
|
|
72
|
+
progress_interval: float = 1.0,
|
|
73
|
+
) -> None:
|
|
74
|
+
self.progress_interval = progress_interval
|
|
75
|
+
self.processes = processes
|
|
76
|
+
self.definitions = tuple(tuple(group) for group in definitions)
|
|
77
|
+
self.toolsets: tuple[Toolset, ...] = ()
|
|
78
|
+
self.initialized = False
|
|
79
|
+
self.discovery_lock = asyncio.Lock()
|
|
80
|
+
|
|
81
|
+
async def initialize(self) -> None:
|
|
82
|
+
"""Discover once and publish the complete collection atomically."""
|
|
83
|
+
async with self.discovery_lock:
|
|
84
|
+
if not self.initialized:
|
|
85
|
+
toolsets = await discovery.discover(self.processes, self.definitions)
|
|
86
|
+
self.toolsets = toolsets
|
|
87
|
+
self.initialized = True
|
|
88
|
+
|
|
89
|
+
def list_toolsets(self) -> tuple[Toolset, ...]:
|
|
90
|
+
return self.toolsets
|
|
91
|
+
|
|
92
|
+
def get_toolset(self, toolset_id: str) -> Toolset:
|
|
93
|
+
for toolset in self.toolsets:
|
|
94
|
+
if toolset.id == toolset_id:
|
|
95
|
+
return toolset
|
|
96
|
+
raise ToolsetNotFoundError(f"Unknown toolset ID {toolset_id!r}.")
|
|
97
|
+
|
|
98
|
+
def get_tool(self, toolset_id: str, tool_name: str) -> ToolSpec | None:
|
|
99
|
+
return next(
|
|
100
|
+
(
|
|
101
|
+
tool
|
|
102
|
+
for tool in self.get_toolset(toolset_id).tools
|
|
103
|
+
if tool.name == tool_name
|
|
104
|
+
),
|
|
105
|
+
None,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def register(self, mcp: MCPServer, apps: Apps, complete: Complete) -> None:
|
|
109
|
+
icon = self.ICON.icon
|
|
110
|
+
annotations = ToolAnnotations(
|
|
111
|
+
read_only_hint=True,
|
|
112
|
+
destructive_hint=False,
|
|
113
|
+
idempotent_hint=True,
|
|
114
|
+
open_world_hint=False,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
async def read_tool(tool: ToolSpec) -> ToolDetails:
|
|
118
|
+
# Versions belong only to the current MCP response, never the spec.
|
|
119
|
+
version = None
|
|
120
|
+
if "version" in tool.methods:
|
|
121
|
+
try:
|
|
122
|
+
version = await tool.methods["version"]()
|
|
123
|
+
except (ToolCommandError, ToolParserError):
|
|
124
|
+
pass
|
|
125
|
+
return ToolDetails(
|
|
126
|
+
name=tool.name,
|
|
127
|
+
kind=tool.kind,
|
|
128
|
+
path=str(tool.path),
|
|
129
|
+
version=version,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@apps.tool(resource_uri=self.WIDGET.uri, icons=[icon], annotations=annotations)
|
|
133
|
+
async def toolsets_list(ctx: Context) -> list[ToolsetSummary]:
|
|
134
|
+
"""List all discovered toolsets and the tools available in each."""
|
|
135
|
+
report_progress = progress(ctx, interval=self.progress_interval)
|
|
136
|
+
await report_progress(0, total=1, message="Reading toolsets")
|
|
137
|
+
result = [
|
|
138
|
+
ToolsetSummary(
|
|
139
|
+
id=item.id,
|
|
140
|
+
name=item.name,
|
|
141
|
+
tools=[tool.name for tool in item.tools],
|
|
142
|
+
)
|
|
143
|
+
for item in self.list_toolsets()
|
|
144
|
+
]
|
|
145
|
+
await report_progress(1, total=1, message="Toolsets ready")
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
@apps.tool(resource_uri=self.WIDGET.uri, icons=[icon], annotations=annotations)
|
|
149
|
+
async def toolset_get(toolset_id: str, ctx: Context) -> ToolsetDetails:
|
|
150
|
+
"""Read tool paths and kinds, querying available versions for this request."""
|
|
151
|
+
report_progress = progress(ctx, interval=self.progress_interval)
|
|
152
|
+
try:
|
|
153
|
+
toolset = self.get_toolset(toolset_id)
|
|
154
|
+
except ToolsetNotFoundError as error:
|
|
155
|
+
raise ToolError(str(error)) from error
|
|
156
|
+
total = len(toolset.tools)
|
|
157
|
+
await report_progress(0, total=total, message="Reading tool versions")
|
|
158
|
+
details = []
|
|
159
|
+
for index, tool in enumerate(toolset.tools, start=1):
|
|
160
|
+
details.append(await read_tool(tool))
|
|
161
|
+
await report_progress(
|
|
162
|
+
index,
|
|
163
|
+
total=total,
|
|
164
|
+
message=f"Read {tool.name}",
|
|
165
|
+
)
|
|
166
|
+
return ToolsetDetails(id=toolset.id, name=toolset.name, tools=details)
|
|
167
|
+
|
|
168
|
+
apps.add_html_resource(self.WIDGET.uri, self.WIDGET.content)
|
|
169
|
+
|
|
170
|
+
@mcp.resource(self.LIST_URI, mime_type="text/markdown", icons=[icon])
|
|
171
|
+
def toolsets() -> str:
|
|
172
|
+
"""Read a markdown summary of all discovered toolsets."""
|
|
173
|
+
table = markdown.Table(["ID", "Name", "Tools"])
|
|
174
|
+
for item in self.list_toolsets():
|
|
175
|
+
table.add(
|
|
176
|
+
[item.id, item.name, ", ".join(tool.name for tool in item.tools)]
|
|
177
|
+
)
|
|
178
|
+
return markdown.Document([markdown.Heading("Toolsets"), table]).render()
|
|
179
|
+
|
|
180
|
+
@mcp.resource(self.DETAILS_URI, mime_type="text/markdown", icons=[icon])
|
|
181
|
+
async def toolset_details(toolset_id: str) -> str:
|
|
182
|
+
"""Read executable paths and query available versions of one toolset."""
|
|
183
|
+
try:
|
|
184
|
+
toolset = self.get_toolset(toolset_id)
|
|
185
|
+
except ToolsetNotFoundError as error:
|
|
186
|
+
raise ResourceNotFoundError(str(error)) from error
|
|
187
|
+
table = markdown.Table(["Tool", "Kind", "Absolute path", "Version"])
|
|
188
|
+
for tool in toolset.tools:
|
|
189
|
+
detail = await read_tool(tool)
|
|
190
|
+
table.add(
|
|
191
|
+
[
|
|
192
|
+
detail.name,
|
|
193
|
+
detail.kind.value,
|
|
194
|
+
detail.path,
|
|
195
|
+
detail.version or "Unknown",
|
|
196
|
+
]
|
|
197
|
+
)
|
|
198
|
+
return markdown.Document(
|
|
199
|
+
[
|
|
200
|
+
markdown.Heading(toolset.name),
|
|
201
|
+
markdown.Paragraph(f"ID: {toolset.id}"),
|
|
202
|
+
table,
|
|
203
|
+
]
|
|
204
|
+
).render()
|
|
205
|
+
|
|
206
|
+
async def toolset_completion(
|
|
207
|
+
ref: PromptReference | ResourceTemplateReference,
|
|
208
|
+
argument: CompletionArgument,
|
|
209
|
+
context: CompletionContext | None,
|
|
210
|
+
) -> Completion | None:
|
|
211
|
+
if (
|
|
212
|
+
not isinstance(ref, ResourceTemplateReference)
|
|
213
|
+
or ref.uri != self.DETAILS_URI
|
|
214
|
+
or argument.name != "toolset_id"
|
|
215
|
+
):
|
|
216
|
+
return None
|
|
217
|
+
values = [
|
|
218
|
+
item.id for item in self.toolsets if item.id.startswith(argument.value)
|
|
219
|
+
]
|
|
220
|
+
return Completion(
|
|
221
|
+
values=values[:100],
|
|
222
|
+
total=len(values),
|
|
223
|
+
has_more=len(values) > 100,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
complete.add_completion(toolset_completion)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Tool metadata, ready executable specs, and independent toolsets."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import NotRequired, ReadOnly, TypedDict
|
|
9
|
+
|
|
10
|
+
from forgemcp.process.service import ProcessService
|
|
11
|
+
|
|
12
|
+
from .errors import ToolCommandError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ToolKind(StrEnum):
|
|
16
|
+
BUILD_SYSTEM = "build_system"
|
|
17
|
+
BUILD_RUNNER = "build_runner"
|
|
18
|
+
TEST_RUNNER = "test_runner"
|
|
19
|
+
COMPILER = "compiler"
|
|
20
|
+
LINKER = "linker"
|
|
21
|
+
LANGUAGE_SERVER = "language_server"
|
|
22
|
+
VERSION_CONTROL = "version_control"
|
|
23
|
+
DEBUGGER = "debugger"
|
|
24
|
+
OTHER = "other"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ToolMethods(TypedDict):
|
|
28
|
+
"""The subset of tool methods understood by toolchain."""
|
|
29
|
+
|
|
30
|
+
version: ReadOnly[NotRequired[Callable[[], Awaitable[str]]]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ToolSpec:
|
|
35
|
+
name: str
|
|
36
|
+
kind: ToolKind
|
|
37
|
+
path: Path
|
|
38
|
+
methods: ToolMethods
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ToolInfo:
|
|
43
|
+
"""Discovery metadata and a factory for a ready executable spec."""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
kind: ToolKind
|
|
47
|
+
create_spec: Callable[
|
|
48
|
+
[Path, ProcessService, Mapping[str, str] | None, bool], ToolSpec
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Toolset:
|
|
54
|
+
id: str
|
|
55
|
+
name: str
|
|
56
|
+
tools: tuple[ToolSpec, ...]
|
|
57
|
+
environment: Mapping[str, str] | None
|
|
58
|
+
inherit_environment: bool
|
|
59
|
+
|
|
60
|
+
def __post_init__(self) -> None:
|
|
61
|
+
if len({tool.name for tool in self.tools}) != len(self.tools):
|
|
62
|
+
raise ToolCommandError("Toolset contains duplicate tool names.")
|
|
63
|
+
object.__setattr__(
|
|
64
|
+
self,
|
|
65
|
+
"tools",
|
|
66
|
+
tuple(sorted(self.tools, key=lambda tool: tool.name)),
|
|
67
|
+
)
|
|
68
|
+
if self.environment is not None:
|
|
69
|
+
object.__setattr__(
|
|
70
|
+
self,
|
|
71
|
+
"environment",
|
|
72
|
+
MappingProxyType(dict(self.environment)),
|
|
73
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Each public module exports INFO and its concrete Methods TypedDict."""
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Typed methods for clang."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TypedDict
|
|
7
|
+
|
|
8
|
+
from forgemcp.process.errors import ProcessError
|
|
9
|
+
from forgemcp.process.models import ProcessTimeout
|
|
10
|
+
from forgemcp.process.service import ProcessService
|
|
11
|
+
|
|
12
|
+
from ..errors import ToolCommandError, ToolParserError
|
|
13
|
+
from ..spec import ToolInfo, ToolKind, ToolSpec
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Methods(TypedDict):
|
|
17
|
+
version: Callable[[], Awaitable[str]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_spec(
|
|
21
|
+
path: Path,
|
|
22
|
+
processes: ProcessService,
|
|
23
|
+
environment: Mapping[str, str] | None = None,
|
|
24
|
+
inherit_environment: bool = True,
|
|
25
|
+
) -> ToolSpec:
|
|
26
|
+
path = path.resolve()
|
|
27
|
+
|
|
28
|
+
async def version() -> str:
|
|
29
|
+
output = {"stdout": "", "stderr": ""}
|
|
30
|
+
try:
|
|
31
|
+
async with await processes.launch(
|
|
32
|
+
path,
|
|
33
|
+
('--version',),
|
|
34
|
+
env=environment,
|
|
35
|
+
inherit_environment=inherit_environment,
|
|
36
|
+
timeout=ProcessTimeout(total=15),
|
|
37
|
+
) as session:
|
|
38
|
+
await session.close_stdin()
|
|
39
|
+
async for chunk in session.output():
|
|
40
|
+
# Only the short version banner is needed; drain the rest.
|
|
41
|
+
remaining = 4096 - len(output[chunk.stream])
|
|
42
|
+
output[chunk.stream] += chunk.text[:remaining]
|
|
43
|
+
code = await session.wait()
|
|
44
|
+
if code != 0:
|
|
45
|
+
raise ToolCommandError(f"{INFO.name}.version failed (exit {code}).")
|
|
46
|
+
except ProcessError as error:
|
|
47
|
+
raise ToolCommandError(f"Cannot read {INFO.name} version.") from error
|
|
48
|
+
for text in output.values():
|
|
49
|
+
if match := re.search(
|
|
50
|
+
'clang version ([0-9][^\\s]*)',
|
|
51
|
+
text,
|
|
52
|
+
re.IGNORECASE | re.MULTILINE,
|
|
53
|
+
):
|
|
54
|
+
return match.group(1)
|
|
55
|
+
raise ToolParserError(f"Cannot parse {INFO.name} version.")
|
|
56
|
+
|
|
57
|
+
methods: Methods = {"version": version}
|
|
58
|
+
return ToolSpec(INFO.name, INFO.kind, path, methods)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
INFO = ToolInfo(name='clang', kind=ToolKind.COMPILER, create_spec=create_spec)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Typed methods for clang-cl."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TypedDict
|
|
7
|
+
|
|
8
|
+
from forgemcp.process.errors import ProcessError
|
|
9
|
+
from forgemcp.process.models import ProcessTimeout
|
|
10
|
+
from forgemcp.process.service import ProcessService
|
|
11
|
+
|
|
12
|
+
from ..errors import ToolCommandError, ToolParserError
|
|
13
|
+
from ..spec import ToolInfo, ToolKind, ToolSpec
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Methods(TypedDict):
|
|
17
|
+
version: Callable[[], Awaitable[str]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_spec(
|
|
21
|
+
path: Path,
|
|
22
|
+
processes: ProcessService,
|
|
23
|
+
environment: Mapping[str, str] | None = None,
|
|
24
|
+
inherit_environment: bool = True,
|
|
25
|
+
) -> ToolSpec:
|
|
26
|
+
path = path.resolve()
|
|
27
|
+
|
|
28
|
+
async def version() -> str:
|
|
29
|
+
output = {"stdout": "", "stderr": ""}
|
|
30
|
+
try:
|
|
31
|
+
async with await processes.launch(
|
|
32
|
+
path,
|
|
33
|
+
('--version',),
|
|
34
|
+
env=environment,
|
|
35
|
+
inherit_environment=inherit_environment,
|
|
36
|
+
timeout=ProcessTimeout(total=15),
|
|
37
|
+
) as session:
|
|
38
|
+
await session.close_stdin()
|
|
39
|
+
async for chunk in session.output():
|
|
40
|
+
# Only the short version banner is needed; drain the rest.
|
|
41
|
+
remaining = 4096 - len(output[chunk.stream])
|
|
42
|
+
output[chunk.stream] += chunk.text[:remaining]
|
|
43
|
+
code = await session.wait()
|
|
44
|
+
if code != 0:
|
|
45
|
+
raise ToolCommandError(f"{INFO.name}.version failed (exit {code}).")
|
|
46
|
+
except ProcessError as error:
|
|
47
|
+
raise ToolCommandError(f"Cannot read {INFO.name} version.") from error
|
|
48
|
+
for text in output.values():
|
|
49
|
+
if match := re.search(
|
|
50
|
+
'clang version ([0-9][^\\s]*)',
|
|
51
|
+
text,
|
|
52
|
+
re.IGNORECASE | re.MULTILINE,
|
|
53
|
+
):
|
|
54
|
+
return match.group(1)
|
|
55
|
+
raise ToolParserError(f"Cannot parse {INFO.name} version.")
|
|
56
|
+
|
|
57
|
+
methods: Methods = {"version": version}
|
|
58
|
+
return ToolSpec(INFO.name, INFO.kind, path, methods)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
INFO = ToolInfo(name='clang-cl', kind=ToolKind.COMPILER, create_spec=create_spec)
|