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,61 @@
1
+ """Typed methods for msbuild."""
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', '-nologo'),
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
+ '^([0-9]+\\.[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='msbuild', kind=ToolKind.BUILD_RUNNER, create_spec=create_spec)
@@ -0,0 +1,27 @@
1
+ """Typed methods for cl."""
2
+
3
+ from collections.abc import Mapping
4
+ from pathlib import Path
5
+ from typing import TypedDict
6
+
7
+ from forgemcp.process.service import ProcessService
8
+
9
+ from ..spec import ToolInfo, ToolKind, ToolSpec
10
+
11
+
12
+ class Methods(TypedDict):
13
+ pass
14
+
15
+
16
+ def create_spec(
17
+ path: Path,
18
+ processes: ProcessService,
19
+ environment: Mapping[str, str] | None = None,
20
+ inherit_environment: bool = True,
21
+ ) -> ToolSpec:
22
+ path = path.resolve()
23
+ methods: Methods = {}
24
+ return ToolSpec(INFO.name, INFO.kind, path, methods)
25
+
26
+
27
+ INFO = ToolInfo(name='cl', kind=ToolKind.COMPILER, create_spec=create_spec)
@@ -0,0 +1,61 @@
1
+ """Typed methods for ninja."""
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
+ '^([0-9]+\\.[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='ninja', kind=ToolKind.BUILD_RUNNER, create_spec=create_spec)
@@ -0,0 +1,5 @@
1
+ """Workspace filesystem feature."""
2
+
3
+ from .service import WorkspaceService
4
+
5
+ __all__ = ["WorkspaceService"]
@@ -0,0 +1,181 @@
1
+ """Typed, immutable line diffs for workspace mutation results."""
2
+
3
+ import difflib
4
+ from typing import Literal
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from .path import WorkspacePath
9
+ from .service import (
10
+ FileEditResult,
11
+ FileWriteResult,
12
+ ResultProvider,
13
+ WorkspaceService,
14
+ read_text,
15
+ )
16
+
17
+
18
+ class DiffLine(BaseModel):
19
+ kind: Literal["context", "added", "removed"]
20
+ before_line: int | None = Field(ge=1)
21
+ after_line: int | None = Field(ge=1)
22
+ text: str
23
+ spans: list[tuple[int, int]] = Field(default_factory=list)
24
+
25
+
26
+ class DiffRange(BaseModel):
27
+ kind: Literal["replace", "insert", "delete"]
28
+ before_start: int = Field(ge=1)
29
+ before_count: int = Field(ge=0)
30
+ after_start: int = Field(ge=1)
31
+ after_count: int = Field(ge=0)
32
+
33
+
34
+ class DiffHunk(BaseModel):
35
+ before_start: int = Field(ge=1)
36
+ before_count: int = Field(ge=0)
37
+ after_start: int = Field(ge=1)
38
+ after_count: int = Field(ge=0)
39
+ lines: list[DiffLine]
40
+
41
+
42
+ class FileDiff(BaseModel):
43
+ path: WorkspacePath
44
+ changes: list[DiffRange]
45
+ hunks: list[DiffHunk]
46
+
47
+
48
+ def create_diff(path: WorkspacePath, before_text: str, after_text: str) -> FileDiff:
49
+ """Preserve exact line text, including line endings and a missing final newline."""
50
+ before = before_text.splitlines(keepends=True)
51
+ after = after_text.splitlines(keepends=True)
52
+ matcher = difflib.SequenceMatcher(
53
+ None,
54
+ before,
55
+ after,
56
+ autojunk=False,
57
+ )
58
+ changes = [
59
+ DiffRange(
60
+ kind=kind,
61
+ before_start=i + 1,
62
+ before_count=j - i,
63
+ after_start=k + 1,
64
+ after_count=l - k,
65
+ )
66
+ for kind, i, j, k, l in matcher.get_opcodes()
67
+ if kind != "equal"
68
+ ]
69
+ hunks = []
70
+ for group in matcher.get_grouped_opcodes(3):
71
+ lines = []
72
+ for kind, i, j, k, l in group:
73
+ if kind == "equal":
74
+ lines.extend(
75
+ DiffLine(
76
+ kind="context",
77
+ before_line=number + 1,
78
+ after_line=k + number - i + 1,
79
+ text=before[number],
80
+ )
81
+ for number in range(i, j)
82
+ )
83
+ else:
84
+ before_spans = {}
85
+ after_spans = {}
86
+ if kind == "replace":
87
+ for old, new in zip(range(i, j), range(k, l)):
88
+ character_diff = difflib.SequenceMatcher(
89
+ None,
90
+ before[old],
91
+ after[new],
92
+ autojunk=False,
93
+ )
94
+ before_spans[old] = []
95
+ after_spans[new] = []
96
+ for operation, a, b, c, d in character_diff.get_opcodes():
97
+ if operation != "equal":
98
+ before_spans[old].append((a, b))
99
+ after_spans[new].append((c, d))
100
+ lines.extend(
101
+ DiffLine(
102
+ kind="removed",
103
+ before_line=number + 1,
104
+ after_line=None,
105
+ text=before[number],
106
+ spans=before_spans.get(number, [(0, len(before[number]))]),
107
+ )
108
+ for number in range(i, j)
109
+ )
110
+ lines.extend(
111
+ DiffLine(
112
+ kind="added",
113
+ before_line=None,
114
+ after_line=number + 1,
115
+ text=after[number],
116
+ spans=after_spans.get(number, [(0, len(after[number]))]),
117
+ )
118
+ for number in range(k, l)
119
+ )
120
+ hunks.append(
121
+ DiffHunk(
122
+ before_start=group[0][1] + 1,
123
+ before_count=group[-1][2] - group[0][1],
124
+ after_start=group[0][3] + 1,
125
+ after_count=group[-1][4] - group[0][3],
126
+ lines=lines,
127
+ )
128
+ )
129
+ return FileDiff(path=path, changes=changes, hunks=hunks)
130
+
131
+
132
+ class DiffProvider(ResultProvider[str]):
133
+ """Capture the old text before a write/edit and publish its immutable diff."""
134
+
135
+ def __init__(self, workspace: WorkspaceService) -> None:
136
+ self.workspace = workspace
137
+
138
+ async def before_workspace_write_file(
139
+ self,
140
+ call_id: str,
141
+ path: WorkspacePath,
142
+ text: str,
143
+ ) -> str:
144
+ candidate = self.workspace.resolve_workspace_path(path)
145
+ return read_text(candidate, path) if candidate.exists() else ""
146
+
147
+ async def after_workspace_write_file(
148
+ self,
149
+ call_id: str,
150
+ context: str,
151
+ result: FileWriteResult,
152
+ ) -> str:
153
+ return self.save_diff(call_id, result.path, context)
154
+
155
+ async def before_workspace_edit_file(
156
+ self,
157
+ call_id: str,
158
+ path: WorkspacePath,
159
+ old_text: str,
160
+ new_text: str,
161
+ replace_all: bool,
162
+ ) -> str:
163
+ return read_text(self.workspace.resolve_workspace_path(path), path)
164
+
165
+ async def after_workspace_edit_file(
166
+ self,
167
+ call_id: str,
168
+ context: str,
169
+ result: FileEditResult,
170
+ ) -> str:
171
+ return self.save_diff(call_id, result.path, context)
172
+
173
+ def save_diff(self, call_id: str, path: WorkspacePath, before: str) -> str:
174
+ after = read_text(self.workspace.resolve_workspace_path(path), path)
175
+ diff = create_diff(path, before, after)
176
+ return self.workspace.save_result_resource(
177
+ call_id,
178
+ "diff",
179
+ "application/json",
180
+ diff.model_dump_json(),
181
+ )
@@ -0,0 +1,5 @@
1
+ """Workspace feature errors."""
2
+
3
+
4
+ class WorkspaceError(Exception):
5
+ """Base class for expected workspace failures."""
@@ -0,0 +1,87 @@
1
+ """Platform-specific file ownership; unavailable accounts are represented by None."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def file_owner(path: Path) -> str | None:
8
+ if os.name != "nt":
9
+ try:
10
+ return path.owner()
11
+ except (KeyError, OSError, NotImplementedError):
12
+ return None
13
+
14
+ import ctypes
15
+ from ctypes import wintypes
16
+
17
+ advapi = ctypes.WinDLL("advapi32", use_last_error=True)
18
+ kernel = ctypes.WinDLL("kernel32", use_last_error=True)
19
+ owner, descriptor = ctypes.c_void_p(), ctypes.c_void_p()
20
+ get_security = advapi.GetNamedSecurityInfoW
21
+ get_security.argtypes = [
22
+ wintypes.LPWSTR,
23
+ wintypes.DWORD,
24
+ wintypes.DWORD,
25
+ ctypes.POINTER(ctypes.c_void_p),
26
+ ctypes.c_void_p,
27
+ ctypes.c_void_p,
28
+ ctypes.c_void_p,
29
+ ctypes.POINTER(ctypes.c_void_p),
30
+ ]
31
+ get_security.restype = wintypes.DWORD
32
+ lookup = advapi.LookupAccountSidW
33
+ lookup.argtypes = [
34
+ wintypes.LPCWSTR,
35
+ ctypes.c_void_p,
36
+ wintypes.LPWSTR,
37
+ ctypes.POINTER(wintypes.DWORD),
38
+ wintypes.LPWSTR,
39
+ ctypes.POINTER(wintypes.DWORD),
40
+ ctypes.POINTER(wintypes.DWORD),
41
+ ]
42
+ lookup.restype = wintypes.BOOL
43
+ kernel.LocalFree.argtypes = [ctypes.c_void_p]
44
+ kernel.LocalFree.restype = ctypes.c_void_p
45
+ if get_security(
46
+ str(path),
47
+ 1,
48
+ 1,
49
+ ctypes.byref(owner),
50
+ None,
51
+ None,
52
+ None,
53
+ ctypes.byref(descriptor),
54
+ ):
55
+ return None
56
+ try:
57
+ name_size, domain_size, kind = (
58
+ wintypes.DWORD(),
59
+ wintypes.DWORD(),
60
+ wintypes.DWORD(),
61
+ )
62
+ lookup(
63
+ None,
64
+ owner,
65
+ None,
66
+ ctypes.byref(name_size),
67
+ None,
68
+ ctypes.byref(domain_size),
69
+ ctypes.byref(kind),
70
+ )
71
+ if not name_size.value:
72
+ return None
73
+ name = ctypes.create_unicode_buffer(name_size.value)
74
+ domain = ctypes.create_unicode_buffer(max(1, domain_size.value))
75
+ if not lookup(
76
+ None,
77
+ owner,
78
+ name,
79
+ ctypes.byref(name_size),
80
+ domain,
81
+ ctypes.byref(domain_size),
82
+ ctypes.byref(kind),
83
+ ):
84
+ return None
85
+ return f"{domain.value}\\{name.value}" if domain.value else name.value
86
+ finally:
87
+ kernel.LocalFree(descriptor)
@@ -0,0 +1,71 @@
1
+ """Qualified managed paths and explicit external locations shared by modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path, PurePosixPath, PureWindowsPath
6
+ from typing import Literal, cast
7
+
8
+ from pydantic import GetCoreSchemaHandler
9
+ from pydantic_core import CoreSchema, core_schema
10
+
11
+
12
+ class WorkspacePath(str):
13
+ """A project/storage path, or root/ followed by an absolute native path."""
14
+
15
+ def __new__(cls, value: str) -> WorkspacePath:
16
+ instance = super().__new__(cls, value)
17
+ instance.validate_path()
18
+ return instance
19
+
20
+ @classmethod
21
+ def __get_pydantic_core_schema__(
22
+ cls,
23
+ source_type: object,
24
+ handler: GetCoreSchemaHandler,
25
+ ) -> CoreSchema:
26
+ return core_schema.no_info_after_validator_function(
27
+ cls,
28
+ core_schema.str_schema(),
29
+ serialization=core_schema.to_string_ser_schema(),
30
+ )
31
+
32
+ def validate_path(self) -> WorkspacePath:
33
+ area, separator, relative = self.partition("/")
34
+ if area not in ("project", "storage", "root") or not separator:
35
+ raise ValueError("Workspace path must start with project/, storage/, or root/.")
36
+ if "\\" in relative or "\0" in relative:
37
+ raise ValueError("Workspace paths use '/' and cannot contain NUL.")
38
+ windows = PureWindowsPath(relative)
39
+ if area == "root":
40
+ native = windows if windows.drive else PurePosixPath(relative)
41
+ if (
42
+ not native.is_absolute()
43
+ or native.as_posix() != relative
44
+ or any(part in (".", "..") for part in relative.split("/"))
45
+ or relative.startswith(("//?/", "//./"))
46
+ ):
47
+ raise ValueError("root/ must be followed by a canonical absolute path.")
48
+ return self
49
+ if windows.drive or windows.root or any(
50
+ part in ("", ".", "..") for part in relative.split("/") if relative
51
+ ):
52
+ raise ValueError("Workspace path must contain a canonical relative path.")
53
+ return self
54
+
55
+ @property
56
+ def area(self) -> Literal["project", "storage", "root"]:
57
+ return cast(Literal["project", "storage", "root"], self.partition("/")[0])
58
+
59
+ @property
60
+ def relative(self) -> str:
61
+ # The root/ suffix stays absolute and is rejected by managed-root APIs.
62
+ return self.partition("/")[2] or "."
63
+
64
+ @property
65
+ def absolute(self) -> Path:
66
+ if self.area != "root":
67
+ raise ValueError("Only root/ paths contain a native absolute path.")
68
+ path = Path(self.relative)
69
+ if not path.is_absolute():
70
+ raise ValueError("The external path is not absolute on this host.")
71
+ return path
@@ -0,0 +1,25 @@
1
+ """Invocation contexts and links to immutable Workspace provider resources."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+
10
+ class ResultResource(BaseModel):
11
+ model_config = ConfigDict(extra="forbid", strict=True)
12
+
13
+ uri: str
14
+ mime_type: Literal["application/json", "text/markdown"]
15
+
16
+
17
+ class ResultResources(BaseModel):
18
+ resources: dict[str, ResultResource] = Field(default_factory=dict)
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ProviderCall:
23
+ id: str
24
+ tool_name: str
25
+ contexts: Mapping[str, object]