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.
- harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/design/detailed-design.md +796 -0
- harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/idea-honing.md +255 -0
- harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/implementation/plan.md +340 -0
- harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/research/existing-code.md +123 -0
- harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/research/logging.md +137 -0
- harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/research/maude-bindings.md +203 -0
- harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/research/process-pool-executor.md +171 -0
- harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/research/tool-schema.md +170 -0
- harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/research/worker-process-architecture.md +147 -0
- harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/rough-idea.md +207 -0
- harold_mcp-0.0.2/.agents/planning/maude-diagnostics-tool-v1/summary.md +55 -0
- harold_mcp-0.0.2/.agents/planning/sigsegv-under-load/issue.md +377 -0
- harold_mcp-0.0.2/.agents/planning/sigsegv-under-load/scala-issue.md +357 -0
- harold_mcp-0.0.2/.agents/summary/architecture.md +106 -0
- harold_mcp-0.0.2/.agents/summary/codebase_info.md +59 -0
- harold_mcp-0.0.2/.agents/summary/components.md +122 -0
- harold_mcp-0.0.2/.agents/summary/data_models.md +77 -0
- harold_mcp-0.0.2/.agents/summary/dependencies.md +50 -0
- harold_mcp-0.0.2/.agents/summary/index.md +49 -0
- harold_mcp-0.0.2/.agents/summary/interfaces.md +101 -0
- harold_mcp-0.0.2/.agents/summary/review_notes.md +62 -0
- harold_mcp-0.0.2/.agents/summary/workflows.md +84 -0
- harold_mcp-0.0.2/.gitignore +213 -0
- harold_mcp-0.0.2/AGENTS.md +170 -0
- harold_mcp-0.0.2/CHANGELOG.md +42 -0
- harold_mcp-0.0.2/CONTRIBUTING.md +120 -0
- harold_mcp-0.0.2/LICENSE +202 -0
- harold_mcp-0.0.2/Makefile +66 -0
- harold_mcp-0.0.2/PKG-INFO +195 -0
- harold_mcp-0.0.2/README.md +172 -0
- harold_mcp-0.0.2/docs/index.md +7 -0
- harold_mcp-0.0.2/docs/modules.md +5 -0
- harold_mcp-0.0.2/mkdocs.yml +52 -0
- harold_mcp-0.0.2/pyproject.toml +135 -0
- harold_mcp-0.0.2/src/harold_mcp/__init__.py +0 -0
- harold_mcp-0.0.2/src/harold_mcp/assets/brand/Harold_logo.png +0 -0
- harold_mcp-0.0.2/src/harold_mcp/logging.py +14 -0
- harold_mcp-0.0.2/src/harold_mcp/main.py +10 -0
- harold_mcp-0.0.2/src/harold_mcp/maude/__init__.py +27 -0
- harold_mcp-0.0.2/src/harold_mcp/maude/executor.py +229 -0
- harold_mcp-0.0.2/src/harold_mcp/maude/worker.py +133 -0
- harold_mcp-0.0.2/src/harold_mcp/resources.py +10 -0
- harold_mcp-0.0.2/src/harold_mcp/server/__init__.py +6 -0
- harold_mcp-0.0.2/src/harold_mcp/server/server.py +78 -0
- harold_mcp-0.0.2/src/harold_mcp/server/tools/__init__.py +5 -0
- harold_mcp-0.0.2/src/harold_mcp/server/tools/diagnostics.py +115 -0
- harold_mcp-0.0.2/src/harold_mcp/settings.py +26 -0
- harold_mcp-0.0.2/tests/integration/fixtures/broken-non-recoverable.maude +8 -0
- harold_mcp-0.0.2/tests/integration/fixtures/broken-recoverable.maude +8 -0
- harold_mcp-0.0.2/tests/integration/fixtures/hello.maude +8 -0
- harold_mcp-0.0.2/tests/integration/fixtures/hello2.maude +8 -0
- harold_mcp-0.0.2/tests/integration/fixtures/no_new_module.maude +2 -0
- harold_mcp-0.0.2/tests/integration/test_diagnostics_integration.py +185 -0
- harold_mcp-0.0.2/tests/integration/test_lifespan.py +69 -0
- harold_mcp-0.0.2/tests/integration/test_maude_executor_integration.py +81 -0
- harold_mcp-0.0.2/tests/integration/test_maude_worker_integration.py +79 -0
- harold_mcp-0.0.2/tests/unit/test_diagnostics.py +124 -0
- harold_mcp-0.0.2/tests/unit/test_maude_executor.py +241 -0
- harold_mcp-0.0.2/tests/unit/test_maude_worker.py +56 -0
- harold_mcp-0.0.2/tests/unit/test_settings.py +54 -0
- harold_mcp-0.0.2/tox.ini +10 -0
- 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. |
|