harold-mcp 0.0.2__tar.gz

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 (62) hide show
  1. harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/design/detailed-design.md +796 -0
  2. harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/idea-honing.md +255 -0
  3. harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/implementation/plan.md +340 -0
  4. harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/research/existing-code.md +123 -0
  5. harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/research/logging.md +137 -0
  6. harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/research/maude-bindings.md +203 -0
  7. harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/research/process-pool-executor.md +171 -0
  8. harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/research/tool-schema.md +170 -0
  9. harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/research/worker-process-architecture.md +147 -0
  10. harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/rough-idea.md +207 -0
  11. harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/summary.md +55 -0
  12. harold_mcp-0.0.2/.agents/planning/sigsegv-under-load/issue.md +377 -0
  13. harold_mcp-0.0.2/.agents/planning/sigsegv-under-load/scala-issue.md +357 -0
  14. harold_mcp-0.0.2/.agents/summary/architecture.md +106 -0
  15. harold_mcp-0.0.2/.agents/summary/codebase_info.md +59 -0
  16. harold_mcp-0.0.2/.agents/summary/components.md +122 -0
  17. harold_mcp-0.0.2/.agents/summary/data_models.md +77 -0
  18. harold_mcp-0.0.2/.agents/summary/dependencies.md +50 -0
  19. harold_mcp-0.0.2/.agents/summary/index.md +49 -0
  20. harold_mcp-0.0.2/.agents/summary/interfaces.md +101 -0
  21. harold_mcp-0.0.2/.agents/summary/review_notes.md +62 -0
  22. harold_mcp-0.0.2/.agents/summary/workflows.md +84 -0
  23. harold_mcp-0.0.2/.gitignore +213 -0
  24. harold_mcp-0.0.2/AGENTS.md +170 -0
  25. harold_mcp-0.0.2/CHANGELOG.md +42 -0
  26. harold_mcp-0.0.2/CONTRIBUTING.md +120 -0
  27. harold_mcp-0.0.2/LICENSE +202 -0
  28. harold_mcp-0.0.2/Makefile +66 -0
  29. harold_mcp-0.0.2/PKG-INFO +195 -0
  30. harold_mcp-0.0.2/README.md +172 -0
  31. harold_mcp-0.0.2/docs/index.md +7 -0
  32. harold_mcp-0.0.2/docs/modules.md +5 -0
  33. harold_mcp-0.0.2/mkdocs.yml +52 -0
  34. harold_mcp-0.0.2/pyproject.toml +135 -0
  35. harold_mcp-0.0.2/src/harold_mcp/__init__.py +0 -0
  36. harold_mcp-0.0.2/src/harold_mcp/assets/brand/Harold_logo.png +0 -0
  37. harold_mcp-0.0.2/src/harold_mcp/logging.py +14 -0
  38. harold_mcp-0.0.2/src/harold_mcp/main.py +10 -0
  39. harold_mcp-0.0.2/src/harold_mcp/maude/__init__.py +27 -0
  40. harold_mcp-0.0.2/src/harold_mcp/maude/executor.py +229 -0
  41. harold_mcp-0.0.2/src/harold_mcp/maude/worker.py +133 -0
  42. harold_mcp-0.0.2/src/harold_mcp/resources.py +10 -0
  43. harold_mcp-0.0.2/src/harold_mcp/server/__init__.py +6 -0
  44. harold_mcp-0.0.2/src/harold_mcp/server/server.py +78 -0
  45. harold_mcp-0.0.2/src/harold_mcp/server/tools/__init__.py +5 -0
  46. harold_mcp-0.0.2/src/harold_mcp/server/tools/diagnostics.py +115 -0
  47. harold_mcp-0.0.2/src/harold_mcp/settings.py +26 -0
  48. harold_mcp-0.0.2/tests/integration/fixtures/broken-non-recoverable.maude +8 -0
  49. harold_mcp-0.0.2/tests/integration/fixtures/broken-recoverable.maude +8 -0
  50. harold_mcp-0.0.2/tests/integration/fixtures/hello.maude +8 -0
  51. harold_mcp-0.0.2/tests/integration/fixtures/hello2.maude +8 -0
  52. harold_mcp-0.0.2/tests/integration/fixtures/no_new_module.maude +2 -0
  53. harold_mcp-0.0.2/tests/integration/test_diagnostics_integration.py +185 -0
  54. harold_mcp-0.0.2/tests/integration/test_lifespan.py +69 -0
  55. harold_mcp-0.0.2/tests/integration/test_maude_executor_integration.py +81 -0
  56. harold_mcp-0.0.2/tests/integration/test_maude_worker_integration.py +79 -0
  57. harold_mcp-0.0.2/tests/unit/test_diagnostics.py +124 -0
  58. harold_mcp-0.0.2/tests/unit/test_maude_executor.py +241 -0
  59. harold_mcp-0.0.2/tests/unit/test_maude_worker.py +56 -0
  60. harold_mcp-0.0.2/tests/unit/test_settings.py +54 -0
  61. harold_mcp-0.0.2/tox.ini +10 -0
  62. harold_mcp-0.0.2/uv.lock +2165 -0
@@ -0,0 +1,796 @@
1
+ # Detailed Design — `maude_program_diagnostics` (v1)
2
+
3
+ > Standalone design document for the first real Harold MCP tool. Consolidates the
4
+ > requirements Q&A (`../idea-honing.md`) and the research notes (`../research/`). Dates in
5
+ > this document are the decision dates recorded in those files.
6
+
7
+ ## 1. Overview
8
+
9
+ `maude_program_diagnostics` is an MCP tool that **loads a Maude source file into the Maude
10
+ interpreter and reports every problem the interpreter finds** — including warnings Maude can
11
+ recover from. It is the first real tool of the `harold-mcp` server (an MCP server for
12
+ AI-assisted Maude programming); the placeholder `greet` tool is removed by this work.
13
+
14
+ The key insight driving the design (verified empirically, `research/maude-bindings.md`):
15
+ Maude is **lenient** — it loads files with syntactic errors, emitting `Warning:` lines to
16
+ stderr while still returning success. A tool that only reported "load failed or not" would
17
+ miss exactly the recoverable issues an AI coding agent needs to fix. Therefore the tool must
18
+ capture and surface those warnings.
19
+
20
+ The Maude interpreter therefore runs in a **dedicated worker process**, for two reasons:
21
+
22
+ 1. **Stderr isolation / capture**: the `maude` package writes its `Warning:` diagnostics to
23
+ **fd 2 (stderr) directly from C++**, so Python-level redirection (`contextlib`, `capsys`)
24
+ cannot capture them. Inside the worker we redirect its stderr to a temp file around each
25
+ `maude.load` call (the only viable capture mechanism; §4.2), while the parent FastMCP
26
+ process keeps its own stderr available for logging — as recommended for stdio MCP servers
27
+ (stdout stays reserved for the transport).
28
+ 2. **Crash containment**: feeding arbitrary, LLM-generated files to the interpreter is
29
+ adversarial — the `maude` bindings have a documented SIGSEGV history
30
+ (`.agents/planning/sigsegv-under-load/issue.md`). A worker death kills only the worker;
31
+ the server survives and replaces it.
32
+
33
+ ### Scope
34
+
35
+ In scope (v1):
36
+
37
+ - `maude_program_diagnostics(path: str)` → structured diagnostics result.
38
+ - Dedicated Maude worker process via `concurrent.futures.ProcessPoolExecutor`, with crash
39
+ containment and recovery.
40
+ - Worker configuration via `pydantic-settings` env vars.
41
+
42
+ Out of scope (explicitly deferred):
43
+
44
+ - Running/reducing terms, module introspection (future tools).
45
+ - RAG over the Maude documentation.
46
+ - Program slicing, advisory channel, richer diagnostics (columns, codes).
47
+ - Patching/vendoring the `maude` bindings for a proper warning API.
48
+
49
+ ## 2. Detailed Requirements
50
+
51
+ Consolidated from `../idea-honing.md` (Q1–Q8, design-review amendments) and
52
+ `../rough-idea.md`.
53
+
54
+ | ID | Requirement |
55
+ | --- | --- |
56
+ | R1 | The tool is a function `maude_program_diagnostics` in `src/harold_mcp/server/tools/diagnostics.py` (path amended during design review — see the amendments in `../idea-honing.md`), registered with `@mcp.tool` on the `mcp` instance. The docstring is the MCP tool description (FastMCP convention). |
57
+ | R2 | Input: a single `path: str` parameter (typically a `.maude` file; the extension is **not** validated). The MCP input schema must be exactly `{path: str}` — the executor is injected, not a schema parameter (R7). |
58
+ | R3 | Behavior: load the file into the Maude interpreter and report all problems. Tri-state outcome: (a) clean load — no diagnostics; (b) load with recoverable warnings — warnings reported, `success=False`; (c) unrecoverable load failure — warnings (if any) plus one synthesized `error` diagnostic, `success=False`. |
59
+ | R4 | `success=True` **iff there are no warnings and no errors**. A recoverable warning makes `success=False` (the tool's purpose is to point out anything the agent should fix). |
60
+ | R5 | Missing/unreadable file → **tool error** (`isError`), raised before touching the interpreter. Unrecoverable *parse* failures are diagnostics (R3c), not errors. |
61
+ | R6 | Output: structured pydantic model (see §5): `path` (echo of the input as given), `success`, `summary` (per-severity counts), `diagnostics` list. FastMCP emits both `structuredContent` and a JSON text block for object returns. |
62
+ | R7 | Diagnostics use LSP-style positions: a `range` with `start`/`end` positions, 1-based `line`, `column=None` (Maude reports no columns). `range=None` means a whole-file problem. |
63
+ | R8 | `summary` is a small per-severity counts object (`warning`, `error`). |
64
+ | R9 | Worker architecture: Maude runs in a dedicated long-lived worker process driven by `ProcessPoolExecutor(max_workers=HAROLD_MAUDE_WORKERS, mp_context=spawn, initializer=init_maude)`. `max_tasks_per_child` stays `None` (long-lived). |
65
+ | R10 | Settings via `pydantic-settings` in `src/harold_mcp/settings.py` (flat class, `HAROLD_` env prefix): `HAROLD_MAUDE_WORKERS` (default `1`, `gt=0`), `HAROLD_MAUDE_WORKER_TIMEOUT_SECS` (default `60`, `gt=0`), plus a `get_settings()` singleton for FastMCP dependency injection. `pydantic-settings` becomes a direct dependency. |
66
+ | R11 | Timeout: each call waits at most `maude_worker_timeout_secs` for the worker future. Timeout and worker crash → **tool error** (`isError`), and the pool is recreated so the *next* call works. |
67
+ | R12 | Fail fast at startup: the server warms up the pool (ping through every worker) during lifespan startup; Maude-init failure aborts server start with a meaningful error. |
68
+ | R13 | `greet` is removed (no migration needed: the worker protocol has no term-reduction op). |
69
+ | R14 | The tool declares `readOnlyHint=True` (it does not modify user files; it does mutate server-side interpreter state, which is documented in the tool description). |
70
+ | R15 | Advisories stay suppressed (`maude.init(advise=False)`); no `info`/`advisory` severity. |
71
+ | R16 | No module-set detection: well-formedness is judged solely by the `maude.load` return value + captured warnings. A program may legitimately define no modules. |
72
+ | R17 | The main process never imports or calls `maude.*`; only the worker process touches the interpreter. |
73
+ | R18 | Success criteria (acceptance, Q8): (1) tool callable over MCP with schema `{path: str}`, structured result; (2) four fixture outcomes correct end-to-end (clean / recoverable / non-recoverable / no-new-module); (3) missing/unreadable file → tool error; (4) simulated worker crash → current call errors, next call succeeds; (5) both settings honored; (6) CI green (`make release`), docs updated, unit + integration tests. Note on (2): the synthesized `error` for `ok=False` is unit-tested with a mocked worker result — empirically `maude.load` recovers from every parseable input, so no real fixture triggers it. |
74
+ | R19 | Repo conventions: Python ≥ 3.14, mypy strict (`disallow_untyped_defs`), ruff (auto-fix fails CI), `uv` for deps, new modules documented in `docs/modules.md`, README documents the env vars, knowledge base refreshed after implementation. |
75
+
76
+ ## 3. Architecture Overview
77
+
78
+ Two processes:
79
+
80
+ - **MCP server process** (FastMCP, threaded): the tool, the result models, and a thin
81
+ `MaudeExecutor` client wrapper around a `ProcessPoolExecutor`. Never imports the `maude`
82
+ bindings.
83
+ - **Maude worker process** (spawned, single-threaded): owns the interpreter, performs the
84
+ stderr capture, parses warnings. Executed via `ProcessPoolExecutor`; `max_workers=1` by
85
+ default makes it an implicit serialization lock (two concurrent tool calls run
86
+ sequentially, each with its own `Future`, so results never cross-talk — verified
87
+ empirically, `research/process-pool-executor.md`).
88
+
89
+ ```mermaid
90
+ graph TB
91
+ A[FastMCP server process] --> B[server/tools/diagnostics.py<br>maude_program_diagnostics tool]
92
+ B --> C[maude/executor.py<br>MaudeExecutor client + Settings]
93
+ B --> D[pydantic result models]
94
+ C -->|ProcessPoolExecutor spawn| E[Maude worker process<br>maude/worker.py]
95
+ E --> F[maude SWIG bindings<br>C++ interpreter]
96
+ C --> G[pydantic-settings<br>HAROLD_MAUDE_WORKERS<br>HAROLD_MAUDE_WORKER_TIMEOUT_SECS]
97
+ ```
98
+
99
+ ### 3.1 Tool call flow
100
+
101
+ ```mermaid
102
+ sequenceDiagram
103
+ participant C as MCP client
104
+ participant F as FastMCP server
105
+ participant T as diagnostics tool
106
+ participant P as MaudeExecutor
107
+ participant E as ProcessPoolExecutor
108
+ participant W as Maude worker
109
+ C->>F: call maude_program_diagnostics path
110
+ F->>T: run tool in thread pool
111
+ T->>T: pre-check file exists and readable
112
+ T->>P: diagnostics path
113
+ P->>E: submit load_diagnostics
114
+ E->>W: run task serialized
115
+ W->>W: redirect fd 2 to tempfile
116
+ W->>W: call maude.load
117
+ W->>W: restore fd 2 and parse warnings
118
+ W-->>E: ok and warnings dict
119
+ E-->>P: future result
120
+ P-->>T: worker result
121
+ T-->>F: pydantic result model
122
+ F-->>C: structuredContent and text
123
+ ```
124
+
125
+ ### 3.2 Lifecycle (pool warm-up and shutdown)
126
+
127
+ ```mermaid
128
+ flowchart TD
129
+ A[main.py run] --> B[server.run]
130
+ B --> C[mcp.run enters lifespan]
131
+ C --> D[MaudeExecutor.start]
132
+ D --> E[create executor with spawn context]
133
+ E --> F[warm-up pings through all workers]
134
+ F --> G{all pings ok}
135
+ G -->|no| H[raise MaudeInitError fail fast]
136
+ G -->|yes| I[serve MCP requests]
137
+ I --> J[server exit]
138
+ J --> K[executor shutdown in lifespan exit]
139
+ ```
140
+
141
+ ### 3.3 Crash recovery
142
+
143
+ A worker death breaks the whole executor (`BrokenProcessPool`); there is no self-healing.
144
+ `MaudeExecutor` recreates the executor so the next call works.
145
+
146
+ ```mermaid
147
+ sequenceDiagram
148
+ participant T as tool call thread
149
+ participant P as MaudeExecutor
150
+ participant E as executor generation 1
151
+ participant E2 as executor generation 2
152
+ T->>P: diagnostics path
153
+ P->>E: submit
154
+ E-->>P: BrokenProcessPool
155
+ P->>E: shutdown old executor
156
+ P->>P: create new executor
157
+ P-->>T: MaudeWorkerError for current call
158
+ T->>P: next diagnostics path
159
+ P->>E2: submit on fresh executor
160
+ E2-->>P: result
161
+ P-->>T: result model
162
+ ```
163
+
164
+ ## 4. Components and Interfaces
165
+
166
+ ### 4.0 Package layout
167
+
168
+ The design reorganizes the flat modules into two packages (decided in the design review —
169
+ see `../idea-honing.md`, "Design review amendments"):
170
+
171
+ ```
172
+ src/harold_mcp/
173
+ main.py # console-script entry point (unchanged behavior)
174
+ logging.py # get_logger re-export (unchanged)
175
+ resources.py # HAROLD_ICON (unchanged)
176
+ settings.py # Settings + get_settings (pydantic-settings)
177
+ server/
178
+ __init__.py # public API: mcp, run + registers the tools
179
+ server.py # FastMCP instance, lifespan, run() (moved from server.py)
180
+ tools/
181
+ __init__.py # re-exports the tool functions
182
+ diagnostics.py # pydantic result models + maude_program_diagnostics
183
+ maude/
184
+ __init__.py # re-exports errors, MaudeExecutor, get_maude_executor
185
+ executor.py # client-side: errors, MaudeExecutor (from maude.py)
186
+ worker.py # worker-side: init_maude, ping, load_diagnostics, _crash
187
+ ```
188
+
189
+ File changes:
190
+
191
+ | File | Change |
192
+ | --- | --- |
193
+ | `src/harold_mcp/server/__init__.py` | **New**: re-exports `mcp`/`run`; imports `tools` (registers the tool) |
194
+ | `src/harold_mcp/server/server.py` | Moved from `src/harold_mcp/server.py`; `greet` removed; `lifespan` added |
195
+ | `src/harold_mcp/server/tools/__init__.py` | **New**: re-exports the tool functions |
196
+ | `src/harold_mcp/server/tools/diagnostics.py` | **New**: pydantic result models + the tool |
197
+ | `src/harold_mcp/maude/__init__.py` | **New**: re-exports the public Maude-subsystem API |
198
+ | `src/harold_mcp/maude/executor.py` | Moved/reworked from `maude.py`: errors, `MaudeExecutor`, `get_maude_executor` |
199
+ | `src/harold_mcp/maude/worker.py` | **New**: interpreter-side functions run by the worker process |
200
+ | `src/harold_mcp/settings.py` | **New**: `Settings` (pydantic-settings) + `get_settings()` singleton |
201
+ | `src/harold_mcp/maude.py`, `src/harold_mcp/server.py` | Deleted (split/moved into the packages above) |
202
+ | `src/harold_mcp/main.py` | Effectively unchanged — importing `harold_mcp.server` now registers the tools |
203
+ | `pyproject.toml` | Add `pydantic-settings` (then `uv lock`; commit `uv.lock`) |
204
+ | `README.md` | Add an env-var configuration subsection under "How to run harold-mcp" (see §4.5) |
205
+ | `docs/modules.md` | Update entries to the new modules |
206
+ | `tests/unit/test_maude.py` | Replaced by `test_maude_executor.py` (wrapper) + `test_maude_worker.py` (parser) |
207
+ | `tests/integration/test_maude_runtime.py` | Replaced by integration tests for the executor + tool |
208
+
209
+ ### 4.1 `harold_mcp/maude/executor.py` — client-side access layer
210
+
211
+ Imports: stdlib (`concurrent.futures`, `multiprocessing`, `threading`), `fastmcp.dependencies`
212
+ (`Depends`, for the nested dependency below), `harold_mcp.logging.Logging` (mixin),
213
+ `from harold_mcp.maude import worker` (safe: `worker.py` imports the third-party `maude`
214
+ lazily, see §4.2), and `from harold_mcp.settings import Settings, get_settings`.
215
+ **Never** imports `maude`.
216
+
217
+ #### Errors
218
+
219
+ ```python
220
+ class MaudeError(RuntimeError): ...
221
+
222
+
223
+ class MaudeInitError(MaudeError):
224
+ """Maude failed to initialize in the worker (surfaced at warm-up)."""
225
+
226
+
227
+ class MaudeWorkerError(MaudeError):
228
+ """Base error for failures of a running Maude worker call."""
229
+
230
+ def __init__(self, reason: str) -> None: ...
231
+
232
+
233
+ class MaudeWorkerCrashedError(MaudeWorkerError):
234
+ """The worker process died during a call; the pool was replaced."""
235
+
236
+ # super().__init__("Maude worker crashed")
237
+
238
+
239
+ class MaudeWorkerTimeoutError(MaudeWorkerError):
240
+ """A call timed out; the stuck worker was killed and the pool replaced."""
241
+
242
+ # super().__init__("Maude worker timed out")
243
+
244
+
245
+ class MaudeFileNotFoundError(MaudeError):
246
+ """The input path is missing or unreadable (raised before any worker call)."""
247
+
248
+ def __init__(self, path: str) -> None: ...
249
+ ```
250
+
251
+ `MaudeLoadError` and `MaudeModuleNotFoundError` are removed (load failures are diagnostics,
252
+ not exceptions; no module lookups cross the process boundary).
253
+
254
+ #### Settings (`harold_mcp/settings.py`)
255
+
256
+ Settings are application-wide and live in their own module (future settings beyond Maude
257
+ are expected):
258
+
259
+ ```python
260
+ class Settings(BaseSettings):
261
+ model_config = SettingsConfigDict(env_prefix="HAROLD_")
262
+
263
+ maude_workers: int = Field(default=1, gt=0)
264
+ maude_worker_timeout_secs: int = Field(default=60, gt=0)
265
+
266
+
267
+ settings = Settings() # module-level singleton; env vars read once at import
268
+
269
+
270
+ def get_settings() -> Settings:
271
+ return settings
272
+ ```
273
+
274
+ pydantic-settings maps `HAROLD_MAUDE_WORKERS` / `HAROLD_MAUDE_WORKER_TIMEOUT_SECS`
275
+ (case-insensitive). Invalid values fail fast at import with a pydantic validation error.
276
+
277
+ #### `MaudeExecutor` — executor wrapper with recovery
278
+
279
+ ```python
280
+ ExecutorFactory = Callable[[], ProcessPoolExecutor]
281
+
282
+
283
+ class MaudeExecutor(Logging):
284
+ def __init__(
285
+ self,
286
+ settings: Settings | None = None,
287
+ *,
288
+ executor_factory: ExecutorFactory | None = None, # test seam
289
+ ) -> None: ...
290
+
291
+ def start(self) -> None: # reset + warm-up pings; MaudeInitError on failure
292
+ def shutdown(self) -> None: # idempotent pool teardown
293
+ def submit(self, fn, *args) -> Future: # raises MaudeWorkerCrashedError on a broken pool
294
+ def diagnostics(self, path: str) -> LoadDiagnosticsResult: ...
295
+
296
+ def _reset_executor(
297
+ self, *, replace: bool = True, failed: ProcessPoolExecutor | None = None
298
+ ) -> None: ... # pure effect (CQS); callers read `self._executor` under the lock if needed
299
+ ```
300
+
301
+ Behavior:
302
+
303
+ - `_default_executor()`: `ProcessPoolExecutor(max_workers=settings.maude_workers,
304
+ mp_context=multiprocessing.get_context("spawn"), initializer=worker.init_maude)`.
305
+ Explicit **spawn** (see the rationale box below).
306
+ - `_reset_executor(replace=True, failed=None)`: swaps the executor under
307
+ **`_executor_lock` (an `RLock`)** — reentrant because `_submit` holds it while calling
308
+ `_reset_executor`. With `failed` given, the swap is identity-checked so concurrent
309
+ threads reporting the same failure perform **exactly one** replacement. The old pool is
310
+ killed **after** releasing the lock via `old.kill_workers()` (Python 3.14; sends SIGKILL
311
+ to every worker and internally calls `shutdown()` to free the executor's resources).
312
+ SIGKILL rather than `shutdown`/`terminate_workers` because a worker stuck inside
313
+ `maude.load` must be interrupted unconditionally — graceful shutdown cannot stop a
314
+ running C++ task.
315
+ - `start()`: `_reset_executor()` (pure effect — it returns `None`, per
316
+ command-query separation) then reads the pool back under `_executor_lock` (fine: `start`
317
+ runs once at startup) and submits one `worker.ping` **per configured worker**. Note the
318
+ executor spawns workers **on demand** (only when none is idle), so a fast ping does not
319
+ guarantee `maude_workers` processes exist; the warm-up's real job is to fail fast — a
320
+ broken `init_maude` kills the pinged worker, surfacing as `BrokenProcessPool`. Any
321
+ worker spawned later still runs `init_maude` first via the initializer. Any
322
+ `BrokenProcessPool`/`TimeoutError` → discard + kill the failed pool (`_reset_executor(
323
+ replace=False, failed=...)`) → `MaudeInitError` (the initializer's own failure only
324
+ surfaces as a dead worker, since its exception cannot cross the process boundary).
325
+ - `_submit(fn, *args)`: lazily creates the pool if `None`; on submit-time
326
+ `BrokenProcessPool` it replaces the pool and raises `MaudeWorkerCrashedError` — **no
327
+ resubmit** (no reason to assume a retry would succeed; diagnostics is idempotent, so the
328
+ MCP client retries the tool call).
329
+ - `_run_task(fn, *args)`: the shared submit-and-await runner (generic in the task's return
330
+ type): `_submit(...)` then `future.result(timeout=settings.maude_worker_timeout_secs)`.
331
+ Result-time failures are handled **here** — submit-time recovery cannot see a crash
332
+ mid-task, and a timed-out worker must be killed or it poisons the pool forever:
333
+ - `BrokenProcessPool` → `_reset_executor(failed=executor)` → `MaudeWorkerCrashedError`;
334
+ - `concurrent.futures.TimeoutError` → `_reset_executor(failed=executor)` (kills the stuck
335
+ worker) → `MaudeWorkerTimeoutError`;
336
+ - any other worker exception → propagates unchanged (a real bug should be visible).
337
+ - `diagnostics(path)`: a thin typed wrapper — `self._run_task(worker.load_diagnostics, path)`
338
+ (future ops get the same crash/timeout semantics by calling `_run_task` with their
339
+ worker function).
340
+ - Replacement is **eager** (confirmed in the design review); the typed errors float up to
341
+ the tool unchanged (the tool does not catch them — FastMCP turns them into `isError`
342
+ tool results).
343
+ - `shutdown()`: idempotent — `_reset_executor(replace=False)`.
344
+ - The `Logging` mixin provides `self._log` (info: pool launch/teardown/ping; debug: task
345
+ submission; error: pool recovery).
346
+
347
+ **Why `mp_context=multiprocessing.get_context("spawn")`**: the MCP server process is
348
+ **multithreaded** (FastMCP runs sync tools in a thread pool), so `fork` is unsafe — fork
349
+ copies only the calling thread, and locks held by other threads stay locked in the child
350
+ (deadlocks/corruption). `forkserver` (Python 3.14's Linux default) is thread-safe but needs
351
+ an AF_UNIX socket and failed in sandboxes. `spawn` is pipe-based, portable (macOS default,
352
+ Windows-only option), and gives each worker a fresh interpreter with no inherited state;
353
+ the one-time cost (module re-import + prelude load per worker) is paid once at warm-up.
354
+
355
+ #### Singleton (nested FastMCP dependency)
356
+
357
+ ```python
358
+ _EXECUTOR: MaudeExecutor | None = None
359
+ _EXECUTOR_LOCK = threading.Lock()
360
+
361
+
362
+ def get_maude_executor(settings: Settings = Depends(get_settings)) -> MaudeExecutor: # noqa: B008
363
+ global _EXECUTOR
364
+ if _EXECUTOR is None:
365
+ with _EXECUTOR_LOCK:
366
+ if _EXECUTOR is None:
367
+ _EXECUTOR = MaudeExecutor(settings)
368
+ return _EXECUTOR
369
+ ```
370
+
371
+ - Created lazily under a lock, like `init_maude`.
372
+ - `settings` is a **nested FastMCP dependency**: when the tool asks for
373
+ `Depends(get_maude_executor)`, FastMCP resolves `get_settings` first and passes its
374
+ result. Direct callers (the lifespan) pass a `Settings` explicitly.
375
+ - Note: the **worker** process has its own equivalent — `init_maude()` — and tasks call
376
+ the interpreter directly (§4.2). Two singletons, one per process, by design.
377
+
378
+ #### Package `__init__.py`
379
+
380
+ ```python
381
+ from harold_mcp.maude.executor import (
382
+ MaudeError,
383
+ MaudeExecutor,
384
+ MaudeFileNotFoundError,
385
+ MaudeInitError,
386
+ MaudeWorkerCrashedError,
387
+ MaudeWorkerError,
388
+ MaudeWorkerTimeoutError,
389
+ get_maude_executor,
390
+ )
391
+
392
+ __all__ = [...]
393
+ ```
394
+ ```
395
+
396
+ ### 4.2 `harold_mcp/maude/worker.py` — interpreter side (worker only)
397
+
398
+ This module is pickled/spawn-imported by the worker process. It imports `maude` **lazily,
399
+ inside functions**, so that importing the module in the MCP server process (which
400
+ `executor.py` does, for the function references) does **not** import the SWIG bindings there
401
+ (R17).
402
+
403
+ **Gotcha**: inside this module the lazy `import maude` refers to the **third-party `maude`
404
+ package** (absolute import), not to our `harold_mcp.maude` package. Do not rename it to a
405
+ relative import.
406
+
407
+ ```python
408
+ class WarningDict(TypedDict):
409
+ line: int | None
410
+ message: str
411
+
412
+
413
+ class LoadDiagnosticsResult(TypedDict):
414
+ ok: bool
415
+ warnings: list[WarningDict]
416
+
417
+
418
+ def init_maude() -> None:
419
+ """Idempotent; maude.init(advise=False). Raises RuntimeError on failure."""
420
+ # raises RuntimeError("Failed to initialize the Maude interpreter") — the
421
+ # client maps the resulting dead-worker signal to MaudeInitError.
422
+
423
+
424
+ def ping() -> None:
425
+ """No-op task used to warm up workers (initializer runs before it)."""
426
+
427
+
428
+ def sleep(seconds: float) -> int:
429
+ """Test/timeout support: sleep and return the worker's pid."""
430
+
431
+
432
+ def load_diagnostics(path: str) -> LoadDiagnosticsResult: ...
433
+
434
+
435
+ def _crash() -> None:
436
+ """Test-only: os._exit(1) to simulate an abrupt worker death (SIGSEGV analogue)."""
437
+ ```
438
+
439
+ `load_diagnostics` performs the fd-2 capture (researched in `research/maude-bindings.md`,
440
+ option A, scoped to the worker):
441
+
442
+ 1. `saved = os.dup(2)`
443
+ 2. `os.dup2(tempfile.TemporaryFile().fileno(), 2)` — a **regular file**, not a pipe: no
444
+ 64 KB pipe-buffer blocking, and the worker is single-threaded so no reader thread needed.
445
+ 3. `ok = maude.load(path)` inside `try/finally` that restores fd 2 and closes `saved`.
446
+ 4. Read the tempfile back and parse `Warning:` lines into `list[WarningDict]`.
447
+
448
+ **Invariant**: no Python logging/prints between the `dup2` and the restore — anything
449
+ written to fd 2 in that window is captured and parsed as a Maude warning. (Worker logging
450
+ outside the window is harmless: the worker inherits the server's stderr, which is not the
451
+ MCP transport. The worker must **never write to stdout** — stdout *is* the transport.)
452
+
453
+ Warning parsing (formats from `research/maude-bindings.md` §4):
454
+
455
+ ```
456
+ Warning: "<file>", line <N>: <message>
457
+ Warning: "<file>", line <N> (<context>): <message>
458
+ Warning: <standard input>, line <N>: <message>
459
+ ```
460
+
461
+ ```python
462
+ _WARNING_RE = re.compile(r"Warning:\s+\S[^:]*,\s+line\s+(\d+)\s*(?:\([^)]*\))?:\s*(.*)")
463
+ ```
464
+
465
+ - Matched lines → `{"line": int, "message": str}`. The `line: int | None` union in the
466
+ TypedDict is reserved for future whole-file warnings.
467
+ - Unmatched lines: ignored for v1. Hardening the parser against adversarial formats
468
+ (multi-line messages, module redefinitions) is a final-testing item (§7.4).
469
+ - `Advisory:` lines are suppressed at the source (`advise=False`), per R15.
470
+
471
+ ### 4.3 `harold_mcp/server/tools/diagnostics.py` — the tool
472
+
473
+ Imports: `from fastmcp.dependencies import Depends` (the documented public path,
474
+ verified in fastmcp 3.4.7), `from mcp.types import ToolAnnotations` (the same class fastmcp
475
+ uses), `from harold_mcp.server.server import mcp` (**the concrete module — never the
476
+ package `__init__`**, which makes the import order cycle-proof), and
477
+ `from harold_mcp.maude import MaudeExecutor, MaudeFileNotFoundError, get_maude_executor`.
478
+
479
+ ```python
480
+ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
481
+ def maude_program_diagnostics(
482
+ path: str,
483
+ maude_executor: MaudeExecutor = Depends(get_maude_executor),
484
+ ) -> MaudeProgramDiagnosticsResult:
485
+ """Diagnose a Maude source file by loading it into the Maude interpreter.
486
+
487
+ Loads the file at `path` and reports every problem Maude encounters,
488
+ including warnings the interpreter can recover from. Use this tool to
489
+ check whether a Maude program is well formed, and to get a list of issues
490
+ to fix when it is not.
491
+
492
+ Args:
493
+ path: Path to the Maude source file to diagnose (typically `.maude`).
494
+
495
+ Returns:
496
+ A structured result with `success` (true only when the file loaded
497
+ with no warnings and no errors), per-severity counts, and one
498
+ diagnostic per problem, with LSP-style ranges.
499
+
500
+ Note:
501
+ Loading the file updates the interpreter's loaded modules
502
+ (last load wins), like the Maude CLI.
503
+ """
504
+ ```
505
+
506
+ Logic:
507
+
508
+ 1. Pre-check (R5): `if not Path(path).is_file() or not os.access(path, os.R_OK):
509
+ raise MaudeFileNotFoundError(path)`. Relative paths resolve against the server's cwd,
510
+ which equals the worker's cwd (spawn inherits it), so the same string is passed through.
511
+ 2. `worker: LoadDiagnosticsResult = maude_executor.diagnostics(path)`.
512
+ 3. Build the result (mapping lives in the tool, keeping the worker dumb):
513
+ - warnings → `MaudeDiagnostic(severity="warning",
514
+ range=MaudeRange(start=MaudePosition(line=line)))` (`range=None` if a parsed warning
515
+ has no line);
516
+ - if not `worker["ok"]`: append one synthesized
517
+ `MaudeDiagnostic(severity="error", range=None,
518
+ message="Failed to load Maude program: unrecoverable parse error.")`;
519
+ - `success = worker["ok"] and not warnings`;
520
+ - `summary` = counts by severity;
521
+ - `path` = the input string as given (R6).
522
+
523
+ Registration wiring: `server/__init__.py` registers the tools as a side effect of importing
524
+ the server package, so `main.py` needs no extra import:
525
+
526
+ ```python
527
+ # src/harold_mcp/server/__init__.py
528
+ from harold_mcp.server.server import mcp, run
529
+ from harold_mcp.server import tools # noqa: F401 (registers the tools on mcp)
530
+
531
+ __all__ = ["mcp", "run"]
532
+ ```
533
+
534
+ ```python
535
+ # src/harold_mcp/server/tools/__init__.py
536
+ from harold_mcp.server.tools.diagnostics import maude_program_diagnostics
537
+
538
+ __all__ = ["maude_program_diagnostics"]
539
+ ```
540
+
541
+ Because `diagnostics.py` imports `mcp` from the concrete `harold_mcp.server.server` module,
542
+ any import order works: importing the package, the subpackage, or the tool module directly
543
+ always runs `server/__init__.py` first, which defines `mcp` before the tool's decorator
544
+ runs. No import cycle.
545
+
546
+ ### 4.4 `harold_mcp/server/server.py` — lifespan and startup
547
+
548
+ ```python
549
+ from fastmcp.server.lifespan import lifespan
550
+
551
+
552
+ @lifespan
553
+ async def app_lifespan(server: FastMCP) -> AsyncGenerator[dict[str, Any] | None]:
554
+ executor = get_maude_executor(get_settings())
555
+ try:
556
+ executor.start() # pool + warm-up pings; MaudeInitError fails startup
557
+ yield None
558
+ finally:
559
+ executor.shutdown()
560
+
561
+
562
+ mcp = FastMCP(..., lifespan=app_lifespan)
563
+
564
+
565
+ def run() -> None:
566
+ _LOG.info("Initializing Harold...")
567
+ mcp.run() # enters the lifespan (start + shutdown)
568
+ ```
569
+
570
+ - The current `init_maude()` call in `run()` is replaced by the lifespan warm-up (R12).
571
+ - `greet` and its imports are removed.
572
+ - The warm-up ping blocks the event loop briefly at startup (worker spawn + `maude` import
573
+ + prelude load). Acceptable for v1; documented trade-off.
574
+ - **Signal handling** (verified on stdio, 2026-08-27 — final-testing reminder #2): FastMCP's
575
+ `mcp.run()` installs no signal handling, so `run()` registers a SIGTERM handler that
576
+ raises `KeyboardInterrupt`; the asyncio runner then cancels the server task, running the
577
+ lifespan's `finally` (pool teardown) before `run()` catches the `KeyboardInterrupt`. The
578
+ catch block calls `os._exit(0)`: FastMCP's stdio transport leaves a non-daemon worker
579
+ thread blocked reading stdin, and a normal interpreter shutdown would hang forever
580
+ joining it while the client is still connected. (SIGINT already behaves the same way via
581
+ the default `KeyboardInterrupt` handler.) **No OS resources are left hanging**: the workers
582
+ are killed in the lifespan `finally`, and the multiprocessing resource tracker (a
583
+ separate daemon) unlinks the remaining named semaphores itself when the parent dies —
584
+ the "leaked semaphore objects" warning it prints is it announcing that cleanup, not a
585
+ persistent leak (verified empirically: 0 lingering processes/semaphores after SIGTERM).
586
+ Caveat: a hard kill (SIGKILL) skips the lifespan, so workers then exit on their own via
587
+ the queue pipe breaking; `prctl(PR_SET_PDEATHSIG)` in the worker would close that gap
588
+ (future hardening). The lifespan follows the FastMCP docs pattern: `@lifespan`-decorated
589
+ `app_lifespan` async generator (`from fastmcp.server.lifespan import lifespan`),
590
+ avoiding the deprecated `@asynccontextmanager` + `AsyncIterator` annotation.
591
+
592
+ ### 4.5 Dependencies, docs, and README
593
+
594
+ - `uv add pydantic-settings` (already in the venv as a fastmcp transitive dep), commit the
595
+ new `uv.lock`. deptry (in `make check`) requires the direct import to be declared.
596
+ - `docs/modules.md` — replace the old entries with:
597
+
598
+ ```
599
+ ::: harold_mcp.server.server
600
+ ::: harold_mcp.server.tools.diagnostics
601
+ ::: harold_mcp.maude.executor
602
+ ::: harold_mcp.maude.worker
603
+ ::: harold_mcp.settings
604
+ ```
605
+
606
+ - `README.md` — add a subsection under "How to run harold-mcp" documenting the
607
+ configuration env vars (what they do and their defaults):
608
+
609
+ | Env var | Meaning | Default |
610
+ | --- | --- | --- |
611
+ | `HAROLD_MAUDE_WORKERS` | Number of Maude worker processes (parallel diagnostics) | `1` |
612
+ | `HAROLD_MAUDE_WORKER_TIMEOUT_SECS` | Max seconds to wait for each worker call before failing it | `60` |
613
+
614
+ - After implementation: re-run the codebase-summary skill to refresh `.agents/summary/`
615
+ (AGENTS.md describes the old flat layout).
616
+
617
+ ## 5. Data Models
618
+
619
+ All models are pydantic `BaseModel`s defined in `server/tools/diagnostics.py`. FastMCP
620
+ derives the output JSON Schema from the return annotation and emits the result as both
621
+ `structuredContent` and a JSON text block.
622
+
623
+ ```mermaid
624
+ classDiagram
625
+ class MaudeProgramDiagnosticsResult {
626
+ +str path
627
+ +bool success
628
+ +MaudeDiagnosticsSummary summary
629
+ +list diagnostics
630
+ }
631
+ class MaudeDiagnosticsSummary {
632
+ +int warning
633
+ +int error
634
+ }
635
+ class MaudeDiagnostic {
636
+ +str severity
637
+ +MaudeRange range
638
+ +str message
639
+ }
640
+ class MaudeRange {
641
+ +MaudePosition start
642
+ +MaudePosition end
643
+ }
644
+ class MaudePosition {
645
+ +int line
646
+ +int column
647
+ }
648
+ MaudeProgramDiagnosticsResult --> MaudeDiagnosticsSummary
649
+ MaudeProgramDiagnosticsResult --> MaudeDiagnostic
650
+ MaudeDiagnostic --> MaudeRange
651
+ MaudeRange --> MaudePosition
652
+ ```
653
+
654
+ | Model | Field | Type | Notes |
655
+ | --- | --- | --- | --- |
656
+ | `MaudePosition` | `line` | `int` | 1-based; always present when a range exists |
657
+ | | `column` | `int \| None = None` | Maude reports no columns; reserved for future sources |
658
+ | `MaudeRange` | `start` | `MaudePosition` | required |
659
+ | | `end` | `MaudePosition \| None = None` | Maude reports no spans |
660
+ | `MaudeDiagnostic` | `severity` | `Literal["warning", "error"]` | "error" is synthesized for unrecoverable load failures |
661
+ | | `range` | `MaudeRange \| None` | `None` = whole-file problem (e.g. completely unparseable input); Q7 |
662
+ | | `message` | `str` | free text from the Maude warning |
663
+ | `MaudeDiagnosticsSummary` | `warning` | `int` | count |
664
+ | | `error` | `int` | count |
665
+ | `MaudeProgramDiagnosticsResult` | `path` | `str` | echo of the input path as given |
666
+ | | `success` | `bool` | true iff no warnings and no errors (R4) |
667
+ | | `summary` | `MaudeDiagnosticsSummary` | per-severity counts (R8) |
668
+ | | `diagnostics` | `list[MaudeDiagnostic]` | in parse order |
669
+
670
+ Example (recoverable fixture `broken-recoverable.maude`):
671
+
672
+ ```json
673
+ {
674
+ "path": "tests/integration/fixtures/broken-recoverable.maude",
675
+ "success": false,
676
+ "summary": {"warning": 1, "error": 0},
677
+ "diagnostics": [
678
+ {
679
+ "severity": "warning",
680
+ "range": {"start": {"line": 2, "column": null}, "end": null},
681
+ "message": "missing is keyword."
682
+ }
683
+ ]
684
+ }
685
+ ```
686
+
687
+ ## 6. Error Handling
688
+
689
+ | Condition | Where detected | Response |
690
+ | --- | --- | --- |
691
+ | Input path missing/unreadable | Tool pre-check (before any worker call) | `MaudeFileNotFoundError` → FastMCP `ToolError` → `isError` result |
692
+ | Maude fails to initialize (worker start) | `MaudeExecutor.start` warm-up ping | `MaudeInitError` → server fails fast at startup |
693
+ | Worker crashes mid-call (e.g. SIGSEGV) | `future.result` or `submit` → `BrokenProcessPool` | `MaudeWorkerCrashedError` → `isError`; pool replaced for the next call |
694
+ | Worker times out | `future.result(timeout=...)` → `concurrent.futures.TimeoutError` | `MaudeWorkerTimeoutError` → `isError`; stuck worker killed and pool replaced |
695
+ | Recoverable parse warnings | worker stderr capture | `success=False` + `warning` diagnostics (not an error) |
696
+ | Unrecoverable parse failure | `maude.load` → `False` | `success=False` + warnings + one synthesized `error` diagnostic (`range=None`) |
697
+ | Unexpected worker task exception (bug) | `future.result` | propagates unchanged (visible in server logs; client gets a generic tool error) |
698
+ | Invalid env var value (e.g. `HAROLD_MAUDE_WORKERS=0`) | pydantic-settings at import | pydantic `ValidationError` fails fast at startup |
699
+
700
+ Notes:
701
+
702
+ - The FastMCP server converts uncaught tool exceptions into `ToolError` error results
703
+ ("Error calling tool ..."), i.e. the "HTTP 500" analogue (verified in fastmcp 3.4.7
704
+ source, `server.py`).
705
+ - Worker-crash detection relies on `BrokenProcessPool` from
706
+ `concurrent.futures.process` (in Python 3.14 it is **not** re-exported from the top-level
707
+ `concurrent.futures`).
708
+ - TOCTOU caveat: the file could vanish between the pre-check and `maude.load`; the load
709
+ then fails hard and surfaces as the synthesized error diagnostic. Accepted.
710
+ - The initializer's own exception text cannot cross the process boundary (the worker dies
711
+ before running any task); the warm-up maps the signal to a generic `MaudeInitError`, and
712
+ the worker's traceback lands on the server's stderr for diagnosis.
713
+
714
+ ## 7. Testing Strategy
715
+
716
+ Per repo convention: `tests/unit/` hermetic (mocked), `tests/integration/` real interpreter.
717
+ FastMCP's `Depends` default does not prevent direct calls — unit tests call
718
+ `maude_program_diagnostics(path, maude_executor=fake)` explicitly.
719
+
720
+ ### 7.1 Unit
721
+
722
+ | File | Covers |
723
+ | --- | --- |
724
+ | `tests/unit/test_settings.py` | defaults; env-var overrides (`HAROLD_MAUDE_WORKERS`, `HAROLD_MAUDE_WORKER_TIMEOUT_SECS`); invalid values rejected |
725
+ | `tests/unit/test_maude_worker.py` | `_parse_warnings`: the three observed formats, `<standard input>` attribution, `(context)` fragments, unmatched lines ignored, empty capture |
726
+ | `tests/unit/test_maude_executor.py` | (fake executor factory + fake futures) `start` warm-up success; warm-up `BrokenProcessPool` → `MaudeInitError`; `submit` recovery (broken submit → recreate → resubmit once); `diagnostics` success delegation; result `BrokenProcessPool` → `MaudeWorkerError` + executor replaced; `TimeoutError` → `MaudeWorkerError` + executor replaced; `shutdown` idempotence; concurrent replacement creates one executor (lock) |
727
+ | `tests/unit/test_diagnostics.py` | (fake `MaudeExecutor`) tri-state mapping → model (clean / warnings / hard failure + synthesized error with `range=None`); `success` semantics; `summary` counts; `path` echo; missing file → `MaudeFileNotFoundError`; unreadable file (chmod 000, skipped if root) |
728
+
729
+ ### 7.2 Integration
730
+
731
+ | File | Covers |
732
+ | --- | --- |
733
+ | `tests/integration/test_diagnostics_integration.py` | Through a real `MaudeExecutor` + real interpreter, all four fixtures at the **tool** level: `hello.maude`/`hello2.maude` → `success=True`, no diagnostics; `broken-recoverable.maude` → `success=False`, warning `missing is keyword.` at line 2; `broken-non-recoverable.maude` → `success=False`, 12 warnings, **no** synthesized error (`maude.load` recovers even from this file — the synthesized-error path is unit-tested only, since real loads always recover and missing files are pre-checked away by the tool); `no_new_module.maude` → `success=True` (no module-set heuristics); a binary file → warnings without crashing (regression for the lossy capture decode) |
734
+ | | Crash resilience: `executor.submit(worker._crash)` → `BrokenProcessPool`; the next `executor.diagnostics(...)` succeeds on the recreated worker (R18.4) |
735
+ | | Settings honored: an executor with `maude_workers=2` runs two `worker.sleep(1.0)` tasks on two distinct pids in parallel (asserted by wall-clock < 1.8s — slow tasks are required because the executor only spawns a worker when none is idle; microsecond tasks would never exercise the second worker); timeout mapping exercised via a short timeout against a slow task (or left to unit tests if no slow fixture exists) |
736
+
737
+ Replaced: `tests/unit/test_maude.py`, `tests/integration/test_maude_runtime.py` (their API —
738
+ `get_module`/`load_program`/`load_module`, in-process `init_maude` — no longer exists).
739
+
740
+ ### 7.3 Acceptance mapping (R18 / Q8)
741
+
742
+ 1–2 → unit + integration above; 3 → `test_diagnostics.py`; 4 → crash tests; 5 →
743
+ `test_settings.py` + integration; 6 → `make release` (install, check, test, docs-test) plus
744
+ the README subsection (§4.5).
745
+
746
+ ### 7.4 Final-testing reminders (from `../idea-honing.md`)
747
+
748
+ 1. **Warning-format catalog**: feed additional adversarial fixtures (module redefinitions →
749
+ advisories, multi-line messages, `<standard input>` attribution) through the worker
750
+ capture to harden the parser.
751
+ 2. **Lifespan + `mcp.run()` interaction**: verify lifespan startup/shutdown actually fires
752
+ on the stdio transport before relying on pool warm-up/teardown.
753
+
754
+ ## 8. Appendices
755
+
756
+ ### 8.1 Technology choices
757
+
758
+ | Choice | Rationale | Trade-offs |
759
+ | --- | --- | --- |
760
+ | `ProcessPoolExecutor` (`max_workers=1`, spawn) over hand-rolled `multiprocessing` queues | Per-call `Future`s eliminate result cross-talk between concurrent threads (the queue design could not demultiplex); battle-tested lifecycle + `BrokenProcessPool` semantics; `shutdown`/timeouts built in; later parallelism is one argument away. Empirically verified. | Timeout does not cancel the running task — killing the pool is the only interrupt; a crash breaks the whole executor (moot at `max_workers=1`); no self-healing → recovery wrapper needed. |
761
+ | `spawn` over `fork`/`forkserver` | `fork` in a threaded parent is hazardous; `forkserver` (3.14's Linux default) needs an AF_UNIX socket and failed in sandboxes. `spawn` is pipe-based and portable. | Slightly slower worker startup (re-import + prelude load per worker, one-time, during warm-up). |
762
+ | `pydantic-settings` for worker config | Already in the dependency tree via fastmcp; env-var config with validation (`gt=0`) fails fast. | Adds one direct dependency (must be declared for deptry). |
763
+ | fd-2 capture (`os.dup2` to a tempfile) inside the worker | The only mechanism that captures C++-side stderr; the bindings expose no warning hook; scoped to the worker so the server's stderr is untouched. | Process-global within the worker → no logging during the window; fragile against new Maude warning formats (hardened in final testing). |
764
+ | Structured pydantic output over plain text | LLM consumers get `structuredContent` + text for free; matches Zed/LSP diagnostic shapes. | Slightly more code than a text dump. |
765
+ | `readOnlyHint=True` | Declares the tool's safety profile for clients; accurate — user files are never modified. | None; interpreter-state mutation is documented in the tool description. |
766
+ | Package layout (`server/`, `maude/`) over flat modules | Tool registration becomes a package side effect (no wiring in `main.py`); groups the coupled server+tools and the Maude subsystem; scales to more tools. | One-time churn: AGENTS.md conventions, docs entries, knowledge base refresh (all in the plan). |
767
+
768
+ ### 8.2 Research findings (key facts)
769
+
770
+ - `maude.load` returns `True` even for garbage; `False` only for missing files /
771
+ unrecoverable bison failures. Well-formedness = load bool + captured warnings
772
+ (`research/maude-bindings.md`).
773
+ - `advise=False` suppresses advisories, **not** warnings; warnings always print to stderr
774
+ (`research/maude-bindings.md` §1).
775
+ - Module-set detection rejected: programs may define no modules
776
+ (`no_new_module.maude`).
777
+ - Warning formats: quoted file / `<standard input>`, `line N`, optional `(context)`
778
+ (`research/maude-bindings.md` §4).
779
+ - FastMCP: pydantic returns → `structuredContent` + JSON text; `Depends` params excluded
780
+ from the schema; tool exceptions → `ToolError`/`isError` (`research/tool-schema.md`,
781
+ verified again for the design).
782
+ - Python 3.14: `ProcessPoolExecutor` honors the default start method (no forced fork);
783
+ `BrokenProcessPool` lives in `concurrent.futures.process`
784
+ (`research/process-pool-executor.md`).
785
+
786
+ ### 8.3 Alternative approaches
787
+
788
+ | Approach | Why rejected |
789
+ | --- | --- |
790
+ | In-process interpreter + fd-2 capture + file logging | fd-2 redirect is process-global (thread races, stolen stderr); no SIGSEGV containment; required reverse-engineering FastMCP logging internals. Superseded by the worker (Q1). |
791
+ | Raw `multiprocessing` queue pair | Result demultiplexing problem; hand-rolled lifecycle/crash detection. Superseded by `ProcessPoolExecutor`. |
792
+ | Per-call `maude` CLI subprocess | Re-spawn + re-init + prelude load per call; no persistent interpreter state. |
793
+ | Patch/vendor the `maude` bindings (warning callback) | Cleanest long-term but requires forking the pip-installed package; out of scope for v1. |
794
+ | Plain-text output (Zed-style) | LLM must re-parse to count/filter severities; loses `structuredContent`. |
795
+ | Synthesized error with a fabricated line number | Misleading; `range=None` (whole-file) is honest and keeps `line` a strict invariant (Q7). |
796
+ | Automatic retry of a crashed call | A crashing file likely crashes again; diagnostics is idempotent so the client retries instead. |