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,830 @@
|
|
|
1
|
+
"""Asynchronous subprocess lifecycle, transcript, and MCP inspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from asyncio.subprocess import Process
|
|
7
|
+
import codecs
|
|
8
|
+
import locale
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from collections.abc import AsyncGenerator, Mapping, Sequence
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Annotated, Any, Literal, cast
|
|
17
|
+
|
|
18
|
+
from pydantic import Field
|
|
19
|
+
|
|
20
|
+
from forgemcp import markdown
|
|
21
|
+
|
|
22
|
+
from mcp.server import MCPServer
|
|
23
|
+
from mcp.server.apps import Apps
|
|
24
|
+
from mcp.server.mcpserver import Context
|
|
25
|
+
from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError
|
|
26
|
+
from mcp.types import (
|
|
27
|
+
Completion,
|
|
28
|
+
CompletionArgument,
|
|
29
|
+
CompletionContext,
|
|
30
|
+
PromptReference,
|
|
31
|
+
ResourceTemplateReference,
|
|
32
|
+
ToolAnnotations,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from forgemcp.assets import IconFile, Widget
|
|
36
|
+
from forgemcp.completion import Complete
|
|
37
|
+
from forgemcp.progress import progress
|
|
38
|
+
|
|
39
|
+
from .errors import (
|
|
40
|
+
ProcessError,
|
|
41
|
+
ProcessExitedError,
|
|
42
|
+
ProcessStartError,
|
|
43
|
+
ProcessStreamError,
|
|
44
|
+
)
|
|
45
|
+
from .models import (
|
|
46
|
+
ProcessTimeout,
|
|
47
|
+
ProcessLogEntry,
|
|
48
|
+
ProcessOverview,
|
|
49
|
+
ProcessEncoding,
|
|
50
|
+
ProcessStatus,
|
|
51
|
+
ProcessOutput,
|
|
52
|
+
ProcessSummary,
|
|
53
|
+
ProcessInfo,
|
|
54
|
+
ProcessDetails,
|
|
55
|
+
ProcessStream,
|
|
56
|
+
FirstLines,
|
|
57
|
+
LastLines,
|
|
58
|
+
LineRange,
|
|
59
|
+
FirstSeconds,
|
|
60
|
+
LastSeconds,
|
|
61
|
+
TimeRange,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
from .transcript import select_transcript
|
|
65
|
+
|
|
66
|
+
logger = logging.getLogger(__name__)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ProcessRecord:
|
|
71
|
+
"""Mutable internal state owned by ProcessService."""
|
|
72
|
+
|
|
73
|
+
summary: ProcessSummary
|
|
74
|
+
status: ProcessStatus
|
|
75
|
+
start: float = field(init=False, default=0)
|
|
76
|
+
transcript: list[ProcessLogEntry] = field(default_factory=list)
|
|
77
|
+
next_line: int = 1
|
|
78
|
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
79
|
+
|
|
80
|
+
def append_log(self, stream: ProcessStream, text: str, time: float) -> None:
|
|
81
|
+
"""Append under the record lock; all streams share one line sequence."""
|
|
82
|
+
if not text:
|
|
83
|
+
return
|
|
84
|
+
newlines = text.count("\n")
|
|
85
|
+
self.transcript.append(
|
|
86
|
+
ProcessLogEntry(
|
|
87
|
+
time=time,
|
|
88
|
+
stream=stream,
|
|
89
|
+
text=text,
|
|
90
|
+
start_line=self.next_line,
|
|
91
|
+
end_line=self.next_line + newlines - int(text.endswith("\n")),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
self.next_line += newlines
|
|
95
|
+
|
|
96
|
+
def snapshot(self) -> ProcessInfo:
|
|
97
|
+
"""Copy the small process state while the caller holds the record lock."""
|
|
98
|
+
return ProcessInfo(
|
|
99
|
+
summary=self.summary,
|
|
100
|
+
status=self.status.model_copy(),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
async def terminate(self, process: Process) -> None:
|
|
104
|
+
try:
|
|
105
|
+
process.terminate()
|
|
106
|
+
try:
|
|
107
|
+
async with asyncio.timeout(2):
|
|
108
|
+
await process.wait()
|
|
109
|
+
except TimeoutError:
|
|
110
|
+
process.kill()
|
|
111
|
+
await process.wait()
|
|
112
|
+
except ProcessLookupError:
|
|
113
|
+
await process.wait()
|
|
114
|
+
|
|
115
|
+
async def monitor(self, process: Process, stopping: asyncio.Event) -> None:
|
|
116
|
+
loop = asyncio.get_running_loop()
|
|
117
|
+
|
|
118
|
+
summary = self.summary
|
|
119
|
+
timeout = summary.timeout
|
|
120
|
+
task = asyncio.create_task(process.wait())
|
|
121
|
+
try:
|
|
122
|
+
while True:
|
|
123
|
+
finished = False
|
|
124
|
+
timed_out = False
|
|
125
|
+
message: str
|
|
126
|
+
result: Literal["interrupted", "running", "stopped"] | int
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
result = await asyncio.wait_for(asyncio.shield(task), 0.250)
|
|
130
|
+
finished = True
|
|
131
|
+
message = f"Process {summary.process_id} finished with return code {result}"
|
|
132
|
+
except TimeoutError:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
now = loop.time()
|
|
136
|
+
duration = now - self.start
|
|
137
|
+
|
|
138
|
+
if not finished:
|
|
139
|
+
if stopping.is_set():
|
|
140
|
+
await self.terminate(process)
|
|
141
|
+
finished = True
|
|
142
|
+
result = "stopped"
|
|
143
|
+
message = f"Process {summary.process_id} stopped"
|
|
144
|
+
elif timeout.total is not None:
|
|
145
|
+
finished = duration > timeout.total
|
|
146
|
+
if finished:
|
|
147
|
+
timed_out = True
|
|
148
|
+
await self.terminate(process)
|
|
149
|
+
result = "interrupted"
|
|
150
|
+
message = f"Process {summary.process_id} interrupted by timeout {timeout.total}"
|
|
151
|
+
|
|
152
|
+
if not finished and timeout.idle is not None:
|
|
153
|
+
async with self.lock:
|
|
154
|
+
finished = (
|
|
155
|
+
now
|
|
156
|
+
- (
|
|
157
|
+
self.transcript[-1].time
|
|
158
|
+
if self.transcript
|
|
159
|
+
else self.start
|
|
160
|
+
)
|
|
161
|
+
> timeout.idle
|
|
162
|
+
)
|
|
163
|
+
if finished:
|
|
164
|
+
timed_out = True
|
|
165
|
+
await self.terminate(process)
|
|
166
|
+
result = "interrupted"
|
|
167
|
+
message = f"Process {summary.process_id} interrupted by idle {timeout.idle}"
|
|
168
|
+
|
|
169
|
+
if finished:
|
|
170
|
+
async with self.lock:
|
|
171
|
+
self.status.work_time = duration
|
|
172
|
+
self.status.current_status = result
|
|
173
|
+
logger.info(message)
|
|
174
|
+
if timed_out:
|
|
175
|
+
raise ProcessError(message)
|
|
176
|
+
break
|
|
177
|
+
else:
|
|
178
|
+
async with self.lock:
|
|
179
|
+
self.status.work_time = duration
|
|
180
|
+
finally:
|
|
181
|
+
if task.cancel():
|
|
182
|
+
try:
|
|
183
|
+
await task
|
|
184
|
+
except asyncio.CancelledError:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
async def read_stream(
|
|
188
|
+
self,
|
|
189
|
+
stream: Literal["stdout", "stderr"],
|
|
190
|
+
reader: asyncio.StreamReader,
|
|
191
|
+
decoder: ChunkDecoder,
|
|
192
|
+
queue: asyncio.Queue[ProcessOutput | None],
|
|
193
|
+
) -> None:
|
|
194
|
+
try:
|
|
195
|
+
loop = asyncio.get_running_loop()
|
|
196
|
+
while True:
|
|
197
|
+
chunk = await reader.read(4096)
|
|
198
|
+
if not chunk:
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
text = decoder.decode(chunk)
|
|
202
|
+
await queue.put(ProcessOutput(stream=stream, text=text))
|
|
203
|
+
async with self.lock:
|
|
204
|
+
now = loop.time()
|
|
205
|
+
self.append_log(stream, text, now)
|
|
206
|
+
|
|
207
|
+
if text := decoder.final():
|
|
208
|
+
async with self.lock:
|
|
209
|
+
now = loop.time()
|
|
210
|
+
self.append_log(stream, text, now)
|
|
211
|
+
await queue.put(ProcessOutput(stream=stream, text=text))
|
|
212
|
+
|
|
213
|
+
except (OSError, UnicodeError) as error:
|
|
214
|
+
raise ProcessStreamError(
|
|
215
|
+
f"Cannot read process {self.summary.process_id} {stream}."
|
|
216
|
+
) from error
|
|
217
|
+
else:
|
|
218
|
+
queue.put_nowait(None)
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
async def drain_stream(reader: asyncio.StreamReader) -> None:
|
|
222
|
+
"""Discard unread pipe data while cleaning up a failed session."""
|
|
223
|
+
while await reader.read(64 * 1024):
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
async def write_stream(
|
|
227
|
+
self,
|
|
228
|
+
writer: asyncio.StreamWriter,
|
|
229
|
+
encoder: ChunkEncoder,
|
|
230
|
+
queue: asyncio.Queue[str | None],
|
|
231
|
+
) -> None:
|
|
232
|
+
try:
|
|
233
|
+
loop = asyncio.get_running_loop()
|
|
234
|
+
while True:
|
|
235
|
+
text = await queue.get()
|
|
236
|
+
if text is None:
|
|
237
|
+
writer.write(encoder.final())
|
|
238
|
+
await writer.drain()
|
|
239
|
+
writer.close()
|
|
240
|
+
await writer.wait_closed()
|
|
241
|
+
break
|
|
242
|
+
|
|
243
|
+
writer.write(encoder.encode(text))
|
|
244
|
+
async with self.lock:
|
|
245
|
+
now = loop.time()
|
|
246
|
+
self.append_log("stdin", text, now)
|
|
247
|
+
await writer.drain()
|
|
248
|
+
except (OSError, UnicodeError) as error:
|
|
249
|
+
raise ProcessStreamError(
|
|
250
|
+
f"Cannot write process {self.summary.process_id} stdin."
|
|
251
|
+
) from error
|
|
252
|
+
finally:
|
|
253
|
+
# Normal EOF already flushed above; cancellation must not wait for
|
|
254
|
+
# a child that has stopped consuming buffered stdin. Unix transports
|
|
255
|
+
# cannot be aborted again after wait_closed() releases their loop.
|
|
256
|
+
if not writer.is_closing() or writer.transport.get_write_buffer_size():
|
|
257
|
+
writer.transport.abort()
|
|
258
|
+
with suppress(BrokenPipeError, ConnectionResetError):
|
|
259
|
+
await writer.wait_closed()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class ChunkDecoder:
|
|
263
|
+
def __init__(self, encoding: ProcessEncoding):
|
|
264
|
+
selected_encoding = (
|
|
265
|
+
locale.getencoding() if encoding.driver == "default" else encoding.driver
|
|
266
|
+
)
|
|
267
|
+
try:
|
|
268
|
+
codecs.lookup(selected_encoding)
|
|
269
|
+
except LookupError as error:
|
|
270
|
+
raise ProcessStartError(
|
|
271
|
+
f"Unknown process encoding: {selected_encoding}"
|
|
272
|
+
) from error
|
|
273
|
+
self.decoder = codecs.getincrementaldecoder(selected_encoding)(
|
|
274
|
+
errors=encoding.errors
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def decode(self, array: bytes) -> str:
|
|
278
|
+
return self.decoder.decode(array)
|
|
279
|
+
|
|
280
|
+
def final(self) -> str:
|
|
281
|
+
return self.decoder.decode(b"", final=True)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class ChunkEncoder:
|
|
285
|
+
def __init__(self, encoding: ProcessEncoding):
|
|
286
|
+
selected_encoding = (
|
|
287
|
+
locale.getencoding() if encoding.driver == "default" else encoding.driver
|
|
288
|
+
)
|
|
289
|
+
try:
|
|
290
|
+
codecs.lookup(selected_encoding)
|
|
291
|
+
except LookupError as error:
|
|
292
|
+
raise ProcessStartError(
|
|
293
|
+
f"Unknown process encoding: {selected_encoding}"
|
|
294
|
+
) from error
|
|
295
|
+
self.encoder = codecs.getincrementalencoder(selected_encoding)(
|
|
296
|
+
errors=encoding.errors
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def encode(self, string: str) -> bytes:
|
|
300
|
+
return self.encoder.encode(string)
|
|
301
|
+
|
|
302
|
+
def final(self) -> bytes:
|
|
303
|
+
return self.encoder.encode("", final=True)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class ProcessService:
|
|
307
|
+
"""Process: read-only inspection of development commands and their transcripts.
|
|
308
|
+
|
|
309
|
+
Feature results identify commands by process_id. Process state and ordered
|
|
310
|
+
stdin/stdout/stderr logs are retained in memory until the server stops.
|
|
311
|
+
Arbitrary command execution is unavailable through the public surface.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
WIDGET = Widget("assets/process-overview.html")
|
|
315
|
+
DETAILS_WIDGET = Widget("assets/process-details.html")
|
|
316
|
+
ICON = IconFile("icons/process.svg")
|
|
317
|
+
RESOURCE_URI = "forgemcp://processes/{process_id}"
|
|
318
|
+
OVERVIEW_URI = "forgemcp://processes"
|
|
319
|
+
|
|
320
|
+
@staticmethod
|
|
321
|
+
def format_timestamp(value: datetime) -> str:
|
|
322
|
+
"""Render a process timestamp to readable UTC seconds."""
|
|
323
|
+
return value.astimezone(timezone.utc).strftime("%d %b %Y, %H:%M:%S UTC")
|
|
324
|
+
|
|
325
|
+
@staticmethod
|
|
326
|
+
def describe_status(status: str | int) -> str:
|
|
327
|
+
"""Render terminal state in Markdown resources."""
|
|
328
|
+
if isinstance(status, int):
|
|
329
|
+
return (
|
|
330
|
+
"Completed successfully" if status == 0 else f"Exited with code {status}"
|
|
331
|
+
)
|
|
332
|
+
return {
|
|
333
|
+
"running": "Running",
|
|
334
|
+
"interrupted": "Interrupted by timeout",
|
|
335
|
+
"stopped": "Stopped",
|
|
336
|
+
"stream_failure": "Stream failure",
|
|
337
|
+
}[status]
|
|
338
|
+
|
|
339
|
+
def __init__(
|
|
340
|
+
self,
|
|
341
|
+
workspace_root: Path,
|
|
342
|
+
*,
|
|
343
|
+
allowed_roots: Sequence[Path] = (),
|
|
344
|
+
progress_interval: float = 1.0,
|
|
345
|
+
) -> None:
|
|
346
|
+
self.progress_interval = progress_interval
|
|
347
|
+
self.id_counter = 0
|
|
348
|
+
self.root = workspace_root.resolve()
|
|
349
|
+
self.allowed_roots = (self.root, *(path.resolve() for path in allowed_roots))
|
|
350
|
+
self.records: dict[int, ProcessRecord] = {}
|
|
351
|
+
self.sessions: dict[int, ProcessSession] = {}
|
|
352
|
+
|
|
353
|
+
async def launch(
|
|
354
|
+
self,
|
|
355
|
+
executable: str | Path,
|
|
356
|
+
arguments: Sequence[str] = (),
|
|
357
|
+
*,
|
|
358
|
+
cwd: Path | None = None,
|
|
359
|
+
env: Mapping[str, str] | None = None,
|
|
360
|
+
inherit_environment: bool = True,
|
|
361
|
+
encoding: ProcessEncoding = ProcessEncoding('default'),
|
|
362
|
+
timeout: ProcessTimeout = ProcessTimeout(),
|
|
363
|
+
) -> ProcessSession:
|
|
364
|
+
"""Start a process; manage its streams with async with await service.launch(...)."""
|
|
365
|
+
working_directory = (cwd or self.root).resolve()
|
|
366
|
+
if not working_directory.is_dir():
|
|
367
|
+
raise ProcessStartError(
|
|
368
|
+
f"Process working directory does not exist: {working_directory}"
|
|
369
|
+
)
|
|
370
|
+
if not any(
|
|
371
|
+
working_directory.is_relative_to(root) for root in self.allowed_roots
|
|
372
|
+
):
|
|
373
|
+
raise ProcessStartError(
|
|
374
|
+
f"Process working directory must stay inside the workspace or configured storage: {working_directory}"
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
child_environment = os.environ.copy() if inherit_environment else {}
|
|
378
|
+
child_environment.update(env or {})
|
|
379
|
+
|
|
380
|
+
summary = ProcessSummary(
|
|
381
|
+
process_id=self.id_counter,
|
|
382
|
+
executable=str(executable),
|
|
383
|
+
arguments=list(arguments),
|
|
384
|
+
cwd=str(working_directory),
|
|
385
|
+
encoding=encoding.driver,
|
|
386
|
+
timeout=timeout,
|
|
387
|
+
)
|
|
388
|
+
stdout_decoder = ChunkDecoder(encoding)
|
|
389
|
+
stderr_decoder = ChunkDecoder(encoding)
|
|
390
|
+
stdin_encoder = ChunkEncoder(encoding)
|
|
391
|
+
self.id_counter += 1
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
process = await asyncio.create_subprocess_exec(
|
|
395
|
+
executable,
|
|
396
|
+
*arguments,
|
|
397
|
+
cwd=working_directory,
|
|
398
|
+
env=child_environment,
|
|
399
|
+
stdin=asyncio.subprocess.PIPE,
|
|
400
|
+
stdout=asyncio.subprocess.PIPE,
|
|
401
|
+
stderr=asyncio.subprocess.PIPE,
|
|
402
|
+
)
|
|
403
|
+
except OSError as error:
|
|
404
|
+
raise ProcessStartError(f"Cannot start executable {executable}.") from error
|
|
405
|
+
|
|
406
|
+
status = ProcessStatus(pid=process.pid, current_status="running")
|
|
407
|
+
stdin = asyncio.Queue[str | None]()
|
|
408
|
+
output = asyncio.Queue[ProcessOutput | None]()
|
|
409
|
+
record = ProcessRecord(summary, status)
|
|
410
|
+
self.records[summary.process_id] = record
|
|
411
|
+
session = ProcessSession(
|
|
412
|
+
process,
|
|
413
|
+
record,
|
|
414
|
+
stdin,
|
|
415
|
+
output,
|
|
416
|
+
stdout_decoder,
|
|
417
|
+
stderr_decoder,
|
|
418
|
+
stdin_encoder,
|
|
419
|
+
)
|
|
420
|
+
session.task = asyncio.create_task(session.run())
|
|
421
|
+
self.sessions[summary.process_id] = session
|
|
422
|
+
session.task.add_done_callback(
|
|
423
|
+
lambda _: self.sessions.pop(summary.process_id, None)
|
|
424
|
+
)
|
|
425
|
+
return session
|
|
426
|
+
|
|
427
|
+
async def close(self) -> None:
|
|
428
|
+
"""Close all remaining sessions during server shutdown."""
|
|
429
|
+
async with asyncio.TaskGroup() as group:
|
|
430
|
+
for session in tuple(self.sessions.values()):
|
|
431
|
+
group.create_task(session.close())
|
|
432
|
+
|
|
433
|
+
def register(self, mcp: MCPServer, apps: Apps, complete: Complete) -> None:
|
|
434
|
+
"""Register read-only process inspection entrypoints."""
|
|
435
|
+
icon = self.ICON.icon
|
|
436
|
+
|
|
437
|
+
@apps.tool(
|
|
438
|
+
resource_uri=self.WIDGET.uri,
|
|
439
|
+
icons=[icon],
|
|
440
|
+
annotations=ToolAnnotations(
|
|
441
|
+
read_only_hint=True,
|
|
442
|
+
destructive_hint=False,
|
|
443
|
+
idempotent_hint=True,
|
|
444
|
+
open_world_hint=False,
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
async def processes_overview(
|
|
448
|
+
ctx: Context,
|
|
449
|
+
status: Literal["all", "running", "completed"] = "all",
|
|
450
|
+
) -> ProcessOverview:
|
|
451
|
+
"""Show current and completed external processes managed by ForgeMCP."""
|
|
452
|
+
report_progress = progress(ctx, interval=self.progress_interval)
|
|
453
|
+
records = tuple(self.records.values())
|
|
454
|
+
completed = 0
|
|
455
|
+
running = 0
|
|
456
|
+
processes: list[ProcessInfo] = []
|
|
457
|
+
for i, record in enumerate(records):
|
|
458
|
+
await report_progress(
|
|
459
|
+
i,
|
|
460
|
+
total=len(records),
|
|
461
|
+
message=f"Reading process state: {i}",
|
|
462
|
+
)
|
|
463
|
+
async with record.lock:
|
|
464
|
+
snapshot = record.snapshot()
|
|
465
|
+
if snapshot.status.current_status == "running":
|
|
466
|
+
running += 1
|
|
467
|
+
if status == "all" or status == "running":
|
|
468
|
+
processes.append(snapshot)
|
|
469
|
+
else:
|
|
470
|
+
completed += 1
|
|
471
|
+
if status == "all" or status == "completed":
|
|
472
|
+
processes.append(snapshot)
|
|
473
|
+
|
|
474
|
+
await report_progress(
|
|
475
|
+
len(records),
|
|
476
|
+
total=len(records),
|
|
477
|
+
message="Process state ready",
|
|
478
|
+
)
|
|
479
|
+
return ProcessOverview(
|
|
480
|
+
running=running,
|
|
481
|
+
completed=completed,
|
|
482
|
+
processes=processes,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
@apps.tool(
|
|
486
|
+
resource_uri=self.DETAILS_WIDGET.uri,
|
|
487
|
+
icons=[icon],
|
|
488
|
+
annotations=ToolAnnotations(
|
|
489
|
+
read_only_hint=True,
|
|
490
|
+
destructive_hint=False,
|
|
491
|
+
idempotent_hint=True,
|
|
492
|
+
open_world_hint=False,
|
|
493
|
+
),
|
|
494
|
+
)
|
|
495
|
+
async def process_get(
|
|
496
|
+
ctx: Context,
|
|
497
|
+
process_id: int,
|
|
498
|
+
lines: FirstLines | LastLines | LineRange | None = None,
|
|
499
|
+
time: FirstSeconds | LastSeconds | TimeRange | None = None,
|
|
500
|
+
max_bytes: Annotated[int, Field(ge=0)] = 65536,
|
|
501
|
+
) -> ProcessDetails:
|
|
502
|
+
"""Read a log slice: time, then lines, then UTF-8 text bytes.
|
|
503
|
+
|
|
504
|
+
With neither selector, return the last 100 lines. Line ranges are
|
|
505
|
+
inclusive; time ranges are [start, end) seconds from process start.
|
|
506
|
+
Last seconds end at now for running processes, at completion otherwise.
|
|
507
|
+
Returned line numbers remain absolute; a byte limit can cut a line.
|
|
508
|
+
"""
|
|
509
|
+
report_progress = progress(ctx, interval=self.progress_interval)
|
|
510
|
+
await report_progress(0, total=1, message="Reading process transcript")
|
|
511
|
+
record = self.records.get(process_id)
|
|
512
|
+
if record is None:
|
|
513
|
+
raise ToolError(f"Process {process_id} is not retained.")
|
|
514
|
+
async with record.lock:
|
|
515
|
+
end = (
|
|
516
|
+
asyncio.get_running_loop().time()
|
|
517
|
+
if record.status.current_status == "running"
|
|
518
|
+
else max(
|
|
519
|
+
record.start + record.status.work_time,
|
|
520
|
+
record.transcript[-1].time if record.transcript else record.start,
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
transcript = select_transcript(
|
|
524
|
+
record.transcript,
|
|
525
|
+
process_start=record.start,
|
|
526
|
+
snapshot_end=end,
|
|
527
|
+
lines=lines,
|
|
528
|
+
time=time,
|
|
529
|
+
max_bytes=max_bytes,
|
|
530
|
+
)
|
|
531
|
+
details = ProcessDetails(
|
|
532
|
+
process=record.snapshot(),
|
|
533
|
+
transcript=transcript,
|
|
534
|
+
lines=(
|
|
535
|
+
(transcript[0].start_line, transcript[-1].end_line)
|
|
536
|
+
if transcript
|
|
537
|
+
else None
|
|
538
|
+
),
|
|
539
|
+
)
|
|
540
|
+
await report_progress(1, total=1, message="Process transcript ready")
|
|
541
|
+
return details
|
|
542
|
+
|
|
543
|
+
apps.add_html_resource(self.WIDGET.uri, self.WIDGET.content)
|
|
544
|
+
apps.add_html_resource(self.DETAILS_WIDGET.uri, self.DETAILS_WIDGET.content)
|
|
545
|
+
|
|
546
|
+
@mcp.resource(self.OVERVIEW_URI, mime_type="text/markdown", icons=[icon])
|
|
547
|
+
async def processes_list() -> str:
|
|
548
|
+
"""Read the current retained process list as Markdown."""
|
|
549
|
+
document = markdown.Document([markdown.Heading("Processes")])
|
|
550
|
+
table = markdown.Table(
|
|
551
|
+
["ID", "Executable", "PID", "Started (UTC)", "Outcome"]
|
|
552
|
+
)
|
|
553
|
+
for record in tuple(self.records.values()):
|
|
554
|
+
async with record.lock:
|
|
555
|
+
item = record.snapshot()
|
|
556
|
+
status = item.status.current_status
|
|
557
|
+
outcome = self.describe_status(status)
|
|
558
|
+
table.add(
|
|
559
|
+
[
|
|
560
|
+
markdown.Link(
|
|
561
|
+
str(item.summary.process_id),
|
|
562
|
+
f"forgemcp://processes/{item.summary.process_id}",
|
|
563
|
+
),
|
|
564
|
+
item.summary.executable,
|
|
565
|
+
str(item.status.pid),
|
|
566
|
+
self.format_timestamp(item.status.started),
|
|
567
|
+
outcome,
|
|
568
|
+
]
|
|
569
|
+
)
|
|
570
|
+
document.add(table)
|
|
571
|
+
return document.render()
|
|
572
|
+
|
|
573
|
+
@mcp.resource(self.RESOURCE_URI, mime_type="text/markdown", icons=[icon])
|
|
574
|
+
async def process_details(process_id: int) -> str:
|
|
575
|
+
"""Read the retained state and transcript for one process."""
|
|
576
|
+
if process_id not in self.records:
|
|
577
|
+
raise ResourceNotFoundError()
|
|
578
|
+
|
|
579
|
+
record = self.records[process_id]
|
|
580
|
+
async with record.lock:
|
|
581
|
+
item = record.snapshot()
|
|
582
|
+
transcript = list(record.transcript)
|
|
583
|
+
document = markdown.Document(
|
|
584
|
+
[markdown.Heading(f"Process {item.summary.process_id}: {item.summary.executable}")]
|
|
585
|
+
)
|
|
586
|
+
table = markdown.Table(["Property", "Value"])
|
|
587
|
+
table.add(["Outcome", self.describe_status(item.status.current_status)])
|
|
588
|
+
table.add(["PID", str(item.status.pid)])
|
|
589
|
+
table.add(["Started (UTC)", self.format_timestamp(item.status.started)])
|
|
590
|
+
table.add(["Work time", f"{item.status.work_time:.2f} s"])
|
|
591
|
+
table.add(["Working directory", item.summary.cwd])
|
|
592
|
+
table.add(["Encoding", item.summary.encoding])
|
|
593
|
+
table.add(
|
|
594
|
+
[
|
|
595
|
+
"Total timeout",
|
|
596
|
+
f"{item.summary.timeout.total} s"
|
|
597
|
+
if item.summary.timeout.total is not None
|
|
598
|
+
else "None",
|
|
599
|
+
]
|
|
600
|
+
)
|
|
601
|
+
table.add(
|
|
602
|
+
[
|
|
603
|
+
"Idle timeout",
|
|
604
|
+
f"{item.summary.timeout.idle} s"
|
|
605
|
+
if item.summary.timeout.idle is not None
|
|
606
|
+
else "None",
|
|
607
|
+
]
|
|
608
|
+
)
|
|
609
|
+
document.add(table)
|
|
610
|
+
document.add(markdown.Heading("Arguments", level=2))
|
|
611
|
+
document.add(
|
|
612
|
+
markdown.OrderedList(item.summary.arguments)
|
|
613
|
+
if item.summary.arguments
|
|
614
|
+
else markdown.Paragraph("None")
|
|
615
|
+
)
|
|
616
|
+
document.add(markdown.Heading("Transcript", level=2))
|
|
617
|
+
if not transcript:
|
|
618
|
+
document.add(markdown.Paragraph("No stream chunks recorded."))
|
|
619
|
+
for entry in transcript:
|
|
620
|
+
elapsed = max(
|
|
621
|
+
0.0,
|
|
622
|
+
(entry.timestamp - item.status.started).total_seconds(),
|
|
623
|
+
)
|
|
624
|
+
document.add(
|
|
625
|
+
markdown.Heading(
|
|
626
|
+
f"{entry.stream} · {self.format_timestamp(entry.timestamp)} · +{elapsed:.3f} s",
|
|
627
|
+
level=3,
|
|
628
|
+
)
|
|
629
|
+
)
|
|
630
|
+
document.add(markdown.CodeBlock(entry.text))
|
|
631
|
+
return document.render()
|
|
632
|
+
|
|
633
|
+
async def process_completion(
|
|
634
|
+
ref: PromptReference | ResourceTemplateReference,
|
|
635
|
+
argument: CompletionArgument,
|
|
636
|
+
context: CompletionContext | None,
|
|
637
|
+
) -> Completion | None:
|
|
638
|
+
if (
|
|
639
|
+
not isinstance(ref, ResourceTemplateReference)
|
|
640
|
+
or ref.uri != self.RESOURCE_URI
|
|
641
|
+
or argument.name != "process_id"
|
|
642
|
+
):
|
|
643
|
+
return None
|
|
644
|
+
values = [
|
|
645
|
+
str(process_id)
|
|
646
|
+
for process_id in self.records
|
|
647
|
+
if str(process_id).startswith(argument.value)
|
|
648
|
+
]
|
|
649
|
+
return Completion(
|
|
650
|
+
values=values[:100],
|
|
651
|
+
total=len(values),
|
|
652
|
+
has_more=len(values) > 100,
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
complete.add_completion(process_completion)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
@dataclass
|
|
659
|
+
class ProcessSession:
|
|
660
|
+
"""Own one process and its stream tasks for an async context."""
|
|
661
|
+
|
|
662
|
+
process: Process
|
|
663
|
+
record: ProcessRecord
|
|
664
|
+
stdin: asyncio.Queue[str | None]
|
|
665
|
+
output_queue: asyncio.Queue[ProcessOutput | None]
|
|
666
|
+
stdout_decoder: ChunkDecoder
|
|
667
|
+
stderr_decoder: ChunkDecoder
|
|
668
|
+
stdin_encoder: ChunkEncoder
|
|
669
|
+
task: asyncio.Task[None] | None = field(init=False, default=None)
|
|
670
|
+
stopping: asyncio.Event = field(init=False, default_factory=asyncio.Event)
|
|
671
|
+
stdin_closed: bool = field(init=False, default=False)
|
|
672
|
+
output_claimed: bool = field(init=False, default=False)
|
|
673
|
+
entered: bool = field(init=False, default=False)
|
|
674
|
+
failure: Exception | None = field(init=False, default=None)
|
|
675
|
+
|
|
676
|
+
@property
|
|
677
|
+
def process_id(self) -> int:
|
|
678
|
+
return self.record.summary.process_id
|
|
679
|
+
|
|
680
|
+
@property
|
|
681
|
+
def returncode(self) -> int | None:
|
|
682
|
+
return self.process.returncode
|
|
683
|
+
|
|
684
|
+
async def __aenter__(self) -> ProcessSession:
|
|
685
|
+
if self.entered or self.stopping.is_set():
|
|
686
|
+
raise ProcessStreamError("A process session can only be entered once.")
|
|
687
|
+
self.entered = True
|
|
688
|
+
return self
|
|
689
|
+
|
|
690
|
+
async def __aexit__(
|
|
691
|
+
self,
|
|
692
|
+
exception_type: Any,
|
|
693
|
+
exception: Any,
|
|
694
|
+
traceback: Any,
|
|
695
|
+
) -> None:
|
|
696
|
+
await self.close()
|
|
697
|
+
if exception is None and self.failure is not None:
|
|
698
|
+
raise self.failure
|
|
699
|
+
|
|
700
|
+
async def run(self) -> None:
|
|
701
|
+
"""Own the workers and finish cleanup before publishing session failure."""
|
|
702
|
+
self.record.start = asyncio.get_running_loop().time()
|
|
703
|
+
stdout = cast(asyncio.StreamReader, self.process.stdout)
|
|
704
|
+
stderr = cast(asyncio.StreamReader, self.process.stderr)
|
|
705
|
+
try:
|
|
706
|
+
async with asyncio.TaskGroup() as group:
|
|
707
|
+
group.create_task(
|
|
708
|
+
self.record.read_stream(
|
|
709
|
+
"stdout",
|
|
710
|
+
stdout,
|
|
711
|
+
self.stdout_decoder,
|
|
712
|
+
self.output_queue,
|
|
713
|
+
)
|
|
714
|
+
)
|
|
715
|
+
group.create_task(
|
|
716
|
+
self.record.read_stream(
|
|
717
|
+
"stderr",
|
|
718
|
+
stderr,
|
|
719
|
+
self.stderr_decoder,
|
|
720
|
+
self.output_queue,
|
|
721
|
+
)
|
|
722
|
+
)
|
|
723
|
+
writer = group.create_task(
|
|
724
|
+
self.record.write_stream(
|
|
725
|
+
cast(asyncio.StreamWriter, self.process.stdin),
|
|
726
|
+
self.stdin_encoder,
|
|
727
|
+
self.stdin,
|
|
728
|
+
)
|
|
729
|
+
)
|
|
730
|
+
monitor = group.create_task(
|
|
731
|
+
self.record.monitor(self.process, self.stopping)
|
|
732
|
+
)
|
|
733
|
+
await monitor
|
|
734
|
+
writer.cancel()
|
|
735
|
+
except Exception as error:
|
|
736
|
+
while isinstance(error, ExceptionGroup) and len(error.exceptions) == 1:
|
|
737
|
+
error = error.exceptions[0]
|
|
738
|
+
self.failure = error
|
|
739
|
+
finally:
|
|
740
|
+
self.stdin_closed = True
|
|
741
|
+
# Workers have finished before cleanup reads the same pipes.
|
|
742
|
+
try:
|
|
743
|
+
if (
|
|
744
|
+
self.process.returncode is None
|
|
745
|
+
or not stdout.at_eof()
|
|
746
|
+
or not stderr.at_eof()
|
|
747
|
+
):
|
|
748
|
+
async with asyncio.TaskGroup() as cleanup:
|
|
749
|
+
cleanup.create_task(self.record.drain_stream(stdout))
|
|
750
|
+
cleanup.create_task(self.record.drain_stream(stderr))
|
|
751
|
+
await self.record.terminate(self.process)
|
|
752
|
+
except Exception as error:
|
|
753
|
+
if self.failure is None:
|
|
754
|
+
self.failure = error
|
|
755
|
+
finally:
|
|
756
|
+
async with self.record.lock:
|
|
757
|
+
if self.record.status.current_status == "running":
|
|
758
|
+
self.record.status.current_status = (
|
|
759
|
+
"stream_failure"
|
|
760
|
+
if self.failure is not None
|
|
761
|
+
else "stopped"
|
|
762
|
+
if self.stopping.is_set()
|
|
763
|
+
else cast(int, self.process.returncode)
|
|
764
|
+
)
|
|
765
|
+
self.record.status.work_time = (
|
|
766
|
+
asyncio.get_running_loop().time() - self.record.start
|
|
767
|
+
)
|
|
768
|
+
# Wake output() even when a reader failed without reaching EOF.
|
|
769
|
+
self.output_queue.put_nowait(None)
|
|
770
|
+
|
|
771
|
+
async def close(self) -> None:
|
|
772
|
+
"""Stop the process and wait for every worker, including during cancellation."""
|
|
773
|
+
self.stopping.set()
|
|
774
|
+
if self.task is None:
|
|
775
|
+
return
|
|
776
|
+
cancelled = False
|
|
777
|
+
while not self.task.done():
|
|
778
|
+
try:
|
|
779
|
+
await asyncio.shield(self.task)
|
|
780
|
+
except asyncio.CancelledError:
|
|
781
|
+
cancelled = True
|
|
782
|
+
if cancelled:
|
|
783
|
+
raise asyncio.CancelledError
|
|
784
|
+
|
|
785
|
+
async def output(self) -> AsyncGenerator[ProcessOutput]:
|
|
786
|
+
"""Consume tagged text once, ending after both pipes reach EOF."""
|
|
787
|
+
if self.output_claimed:
|
|
788
|
+
raise ProcessStreamError("Process output has already been claimed.")
|
|
789
|
+
self.output_claimed = True
|
|
790
|
+
finished = 0
|
|
791
|
+
while finished < 2:
|
|
792
|
+
item = await self.output_queue.get()
|
|
793
|
+
if self.failure is not None:
|
|
794
|
+
await self.wait()
|
|
795
|
+
if item is None:
|
|
796
|
+
finished += 1
|
|
797
|
+
if self.task is not None and self.task.done():
|
|
798
|
+
await self.wait()
|
|
799
|
+
else:
|
|
800
|
+
yield item
|
|
801
|
+
if self.failure is not None:
|
|
802
|
+
await self.wait()
|
|
803
|
+
|
|
804
|
+
async def write_stdin(self, text: str) -> None:
|
|
805
|
+
"""Queue text for the stdin writer without waiting for transport drain."""
|
|
806
|
+
if (
|
|
807
|
+
self.process.returncode is not None
|
|
808
|
+
or self.stopping.is_set()
|
|
809
|
+
or self.stdin_closed
|
|
810
|
+
):
|
|
811
|
+
raise ProcessExitedError("Process stdin is closed.")
|
|
812
|
+
await self.stdin.put(text)
|
|
813
|
+
|
|
814
|
+
async def close_stdin(self) -> None:
|
|
815
|
+
"""Queue EOF after prior writes; repeated calls are harmless."""
|
|
816
|
+
if self.stdin_closed:
|
|
817
|
+
return
|
|
818
|
+
self.stdin_closed = True
|
|
819
|
+
if self.process.returncode is not None or self.stopping.is_set():
|
|
820
|
+
return
|
|
821
|
+
await self.stdin.put(None)
|
|
822
|
+
|
|
823
|
+
async def wait(self) -> int:
|
|
824
|
+
"""Wait for process exit and pipe readers; return the same exit code on every call."""
|
|
825
|
+
if self.task is None:
|
|
826
|
+
raise ProcessStreamError("Process session has not been started.")
|
|
827
|
+
await asyncio.shield(self.task)
|
|
828
|
+
if self.failure is not None:
|
|
829
|
+
raise self.failure
|
|
830
|
+
return cast(int, self.process.returncode)
|