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,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)