rapier-runtime 0.5.2__tar.gz → 0.5.3__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 (101) hide show
  1. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/CHANGELOG.md +24 -0
  2. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/PKG-INFO +2 -2
  3. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/docs/STATUS.md +35 -3
  4. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/docs/mcp-server-scope.md +9 -1
  5. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/pyproject.toml +4 -2
  6. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/site/README.md +1 -1
  7. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/site/index.html +1 -0
  8. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/mcp/server.py +72 -8
  9. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/mcp/tools.py +51 -0
  10. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_mcp.py +11 -0
  11. rapier_runtime-0.5.3/tests/test_mcp_resources.py +98 -0
  12. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/.claude/scripts/loom-deploy-nonprod.sh +0 -0
  13. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/.claude/scripts/loom-deploy-prod.sh +0 -0
  14. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/.claude/scripts/loom-env-check.sh +0 -0
  15. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/.claude/scripts/loom-state-docs.sh +0 -0
  16. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/.githooks/pre-commit +0 -0
  17. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/.github/workflows/secret-scan.yml +0 -0
  18. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/.gitignore +0 -0
  19. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/.gitleaks.toml +0 -0
  20. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/.loom/audits/smuggled-virtue.done +0 -0
  21. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/.loom/project.json +0 -0
  22. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/LICENSE +0 -0
  23. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/README.md +0 -0
  24. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/SECURITY.md +0 -0
  25. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/docs/chatlogs/.gitkeep +0 -0
  26. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/docs/chatlogs/202607301413.checkpoint.handoff-cut-0-5-0-to-pypi.md +0 -0
  27. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/docs/chatlogs/20260802-explainability-reminder.md +0 -0
  28. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/docs/threat-model.md +0 -0
  29. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/manifests/echo.yaml +0 -0
  30. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/manifests/sparring.frame.yaml +0 -0
  31. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/manifests/sparring.full.yaml +0 -0
  32. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/manifests/sparring.proposer.yaml +0 -0
  33. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/manifests/sparring.spar.yaml +0 -0
  34. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/site/coming-soon.html +0 -0
  35. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/site/rapier-logo.png +0 -0
  36. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/__init__.py +0 -0
  37. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/_json.py +0 -0
  38. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/cli.py +0 -0
  39. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/convergence.py +0 -0
  40. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/envelope.py +0 -0
  41. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/ledger.py +0 -0
  42. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/manifest.py +0 -0
  43. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/mcp/__init__.py +0 -0
  44. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/models.py +0 -0
  45. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/onboarding.py +0 -0
  46. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/pipeline.py +0 -0
  47. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/presets.py +0 -0
  48. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/secrets.py +0 -0
  49. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/stage.py +0 -0
  50. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/stages/__init__.py +0 -0
  51. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/stages/echo.py +0 -0
  52. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/stages/frame.py +0 -0
  53. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/stages/proposer/__init__.py +0 -0
  54. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/stages/proposer/phase_stages.py +0 -0
  55. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/stages/proposer/phases.py +0 -0
  56. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/stages/proposer/traceability_gate.py +0 -0
  57. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/stages/reconcile.py +0 -0
  58. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/stages/resolver/__init__.py +0 -0
  59. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/stages/resolver/_binding.py +0 -0
  60. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/stages/resolver/_extract.py +0 -0
  61. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/stages/resolver/anchored_fix.py +0 -0
  62. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/stages/resolver/author.py +0 -0
  63. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/stages/resolver/citation_gate.py +0 -0
  64. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/stages/resolver/compose.py +0 -0
  65. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/stages/resolver/cross_review.py +0 -0
  66. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/stages/resolver/definitiveness_gate.py +0 -0
  67. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/verify/__init__.py +0 -0
  68. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/verify/_bootstrap.py +0 -0
  69. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/verify/_llm_shim.py +0 -0
  70. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/verify/_vendor/PROVENANCE.md +0 -0
  71. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/verify/_vendor/cite_check.py +0 -0
  72. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/verify/_vendor/lib_llm.py +0 -0
  73. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/verify/_vendor/spar_cross_review.py +0 -0
  74. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/verify/_vendor/spar_definitiveness_gate.py +0 -0
  75. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/verify/_vendor/spar_verify_gate.py +0 -0
  76. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/verify/_vendor/verify_grounding.py +0 -0
  77. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/verify/rider_record.py +0 -0
  78. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/src/rapier/verify/service.py +0 -0
  79. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_cite_check_evidence_union.py +0 -0
  80. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_cli.py +0 -0
  81. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_convergence.py +0 -0
  82. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_envelope.py +0 -0
  83. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_extract_json.py +0 -0
  84. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_frame_stage.py +0 -0
  85. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_full_ceremony.py +0 -0
  86. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_manifest.py +0 -0
  87. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_models_vendors.py +0 -0
  88. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_onboarding.py +0 -0
  89. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_persistence_default.py +0 -0
  90. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_pipeline.py +0 -0
  91. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_policy.py +0 -0
  92. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_proposer_g2.py +0 -0
  93. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_proposer_stages.py +0 -0
  94. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_proposer_traceability_gate.py +0 -0
  95. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_reconcile.py +0 -0
  96. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_resolver_stages.py +0 -0
  97. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_rider_record.py +0 -0
  98. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_secrets.py +0 -0
  99. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_single_vendor_report.py +0 -0
  100. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_vendor_policy_v4.py +0 -0
  101. {rapier_runtime-0.5.2 → rapier_runtime-0.5.3}/tests/test_verify_service.py +0 -0
@@ -4,6 +4,30 @@ Notable changes to `rapier-runtime`. Earlier releases are summarized in
4
4
  [`docs/STATUS.md`](docs/STATUS.md) and on the
5
5
  [Releases](https://github.com/muddyone/rapier-runtime/releases) page.
6
6
 
7
+ ## 0.5.3 — 2026-10-16
8
+
9
+ ### Added: saved runs as MCP resources
10
+
11
+ MCP clients can now browse saved runs instead of calling tools:
12
+
13
+ - `rapier://runs`: every saved run, newest first, with its kind, topic and
14
+ verdict (JSON).
15
+ - `rapier://runs/{run_id}`: a run's report, as the CLI printed it (plain text).
16
+ - `rapier://runs/{run_id}/record`: a run's full record, redacted when it was
17
+ saved (JSON).
18
+
19
+ Each saved run also appears in the client's resource list, titled by kind and
20
+ topic, including runs saved after the server started. The `list_runs` and
21
+ `get_run` tools still work.
22
+
23
+ ### Fixed: `rapier mcp` failed on a fresh install
24
+
25
+ The `[mcp]` extra allowed any `mcp>=1.2`, so a new install got the mcp 2.x SDK,
26
+ which renamed the server API this version uses. `rapier mcp` then exited with a
27
+ message telling you to install the extra you already had. The extra is now
28
+ capped at `mcp<2`, and if a 2.x SDK is present anyway, `rapier mcp` says so and
29
+ gives the command that fixes it.
30
+
7
31
  ## 0.5.2 — 2026-10-16
8
32
 
9
33
  ### Fixed: the report no longer calls a same-vendor review independent
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rapier-runtime
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: A code-orchestrated engine that runs the SPARRING method: grounded, cross-vendor adversarial review for AI-in-the-loop decisions.
5
5
  Project-URL: Homepage, https://rapierruntime.com
6
6
  Project-URL: Repository, https://github.com/muddyone/rapier-runtime
@@ -25,7 +25,7 @@ Requires-Dist: requests>=2.31
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: pytest>=8.0; extra == 'dev'
27
27
  Provides-Extra: mcp
28
- Requires-Dist: mcp>=1.2; extra == 'mcp'
28
+ Requires-Dist: mcp<2,>=1.2; extra == 'mcp'
29
29
  Description-Content-Type: text/markdown
30
30
 
31
31
  # Rapier Runtime
@@ -1,9 +1,34 @@
1
1
  # Rapier — dev status
2
2
 
3
- _Last updated: 2026-10-16 (tag v0.5.1, 230 tests)._ A running "where we are /
3
+ _Last updated: 2026-10-16 (tag v0.5.2, 235 tests)._ A running "where we are /
4
4
  what's next" so a new session can continue without reconstructing from git log.
5
5
 
6
6
  ## Done
7
+ - **RELEASED (2026-10-16) — `rapier-runtime` 0.5.2 on PyPI. The report stops
8
+ calling a same-vendor review independent.** Git tag `v0.5.2`;
9
+ https://pypi.org/project/rapier-runtime/0.5.2/. A live 0.5.1 run with only an
10
+ OpenAI key (author and reviewer both `gpt-6-sol`) showed three defects. The
11
+ report said "an independent, different-vendor reviewer" raised the objections
12
+ while its own trust section said the review was not independent. The progress
13
+ warning blamed a missing OpenAI key that was set. LaTeX arithmetic printed raw.
14
+ All three are fixed (`compose.py`, `cross_review.py`; the vendored reviewer is
15
+ untouched) and re-checked on a second live run. 235 tests. The same day the
16
+ repo gained the Loom secret-scan gate: the GitHub gitleaks workflow, the
17
+ pre-commit hook and `.gitleaks.toml`.
18
+ The live runs confirmed `gpt-6-sol` works, including the framing step at its
19
+ 1,024-token limit. **OpenRouter confirmed end to end (2026-10-16)** with only
20
+ an OpenRouter key set: `frame` (1 call) and a full `spar` (8 calls) all went
21
+ to `openrouter` / `openai/gpt-6-sol` with no empty responses, and the 0.5.2
22
+ single-vendor wording held. Keys: 1Password, Sparring Framework vault, item
23
+ `OpenRouter` (field `rapier-runtime-test` for tests).
24
+ **Gemini default `gemini-3.5-flash-lite` confirmed live (2026-10-16):** one
25
+ `frame` call with only the stored Gemini key returned a complete answer.
26
+ **DeepSeek default `deepseek-flash` confirmed live (2026-10-16):** DeepSeek's
27
+ own model list for the key is `deepseek-flash` and `deepseek-v4-pro`, and one
28
+ `frame` call with only the DeepSeek key returned a complete answer. Key:
29
+ 1Password, Sparring Framework vault, item `DeepSeek`.
30
+ Still never called live: the Groq and Together `openai/gpt-oss-120b`
31
+ defaults (no keys).
7
32
  - **RELEASED (2026-10-16) — `rapier-runtime` 0.5.1 on PyPI. Default models for
8
33
  five vendors replaced.** Git tag `v0.5.1`;
9
34
  https://pypi.org/project/rapier-runtime/0.5.1/. OpenRouter's default
@@ -144,8 +169,15 @@ what's next" so a new session can continue without reconstructing from git log.
144
169
  2. **Paper 2 (the Proposer)** stays parked until the engine is fully shipped.
145
170
  Note: the Proposer study runs *on* this engine — Frame, seeded generation, and
146
171
  the depth knob are the instrument it will exercise.
147
- 3. **Optional polish, if worth it:** expose runs as MCP *resources* rather than
148
- tools; a landing line advertising the front door / `--depth` / `--seed`.
172
+ 3. ~~**Optional polish:** runs as MCP resources; a landing line for the front
173
+ door / `--depth` / `--seed`.~~ **DONE (2026-10-16).** The landing line is live.
174
+ Saved runs are MCP resources (`rapier://runs`, `rapier://runs/{run_id}`,
175
+ `rapier://runs/{run_id}/record`); the `list_runs` / `get_run` tools stay for
176
+ tool-only clients. Not yet released: ships with the next version, together
177
+ with the `mcp<2` cap that makes a fresh `pip install "rapier-runtime[mcp]"`
178
+ start again (mcp 2.0 renamed FastMCP).
179
+ 4. **Port the MCP server to the mcp 2.x SDK** (`FastMCP` → `MCPServer`, per the
180
+ SDK's migration guide), then lift the `<2` cap.
149
181
 
150
182
  _(M4 publish is **DONE** — v0.4.0 on PyPI + the full landing live; see Done above.
151
183
  Loom submodule pin bumped to the v0.4.0 release commit `514df56`.)_
@@ -97,7 +97,15 @@ existing redaction on every tool result. No new secret surface — keys stay in
97
97
  **THE RECORD** section naming the run directory, so a Desktop user can see the
98
98
  audit trail exists and where — governance is only real if the record is visible.
99
99
 
100
- Follow-ups: consider exposing runs as MCP *resources* (currently tools).
100
+ Saved runs are also MCP **resources** (2026-10-16): `rapier://runs` (index,
101
+ JSON), `rapier://runs/{run_id}` (the report, plain text) and
102
+ `rapier://runs/{run_id}/record` (the full redacted envelope, JSON). Each saved
103
+ run is listed as its own resource every time a client asks, including runs saved
104
+ after the server started. The `list_runs` / `get_run` tools remain for clients
105
+ that only call tools.
106
+
107
+ Follow-ups: port to the mcp 2.x SDK (`FastMCP` was renamed `MCPServer`); until
108
+ then the `[mcp]` extra is capped at `mcp<2`.
101
109
 
102
110
  ## Open decisions
103
111
  1. `rapier mcp` subcommand (recommended) vs a separate `rapier-mcp` entry point.
@@ -9,7 +9,7 @@ build-backend = "hatchling.build"
9
9
 
10
10
  [project]
11
11
  name = "rapier-runtime"
12
- version = "0.5.2"
12
+ version = "0.5.3"
13
13
  description = "A code-orchestrated engine that runs the SPARRING method: grounded, cross-vendor adversarial review for AI-in-the-loop decisions."
14
14
  readme = "README.md"
15
15
  requires-python = ">=3.10"
@@ -50,7 +50,9 @@ Paper = "https://doi.org/10.5281/zenodo.21210264"
50
50
  dev = ["pytest>=8.0"]
51
51
  # The MCP server (`rapier mcp`) — kept out of the core so the tiny runtime stays
52
52
  # `requests` + `pyyaml`. Install with: pip install "rapier-runtime[mcp]".
53
- mcp = ["mcp>=1.2"]
53
+ # Capped below 2: mcp 2.0 renamed FastMCP (to MCPServer) and changed other APIs
54
+ # the server uses, so an uncapped install broke `rapier mcp`.
55
+ mcp = ["mcp>=1.2,<2"]
54
56
 
55
57
  [project.scripts]
56
58
  rapier = "rapier.cli:main"
@@ -11,7 +11,7 @@ fonts. `index.html` is **light-only** and serves the logo master
11
11
  Kept as a rollback target; **not** what is deployed.
12
12
  - **`index.html`** — the full MVP landing page. **This is what is live** at
13
13
  rapierruntime.com (swapped in at launch, 2026-07-16; verified byte-identical
14
- 2026-08-28).
14
+ 2026-10-16, after adding the quickstart line for `frame`, `--depth` and `--seed`).
15
15
 
16
16
  ## Status: LAUNCHED 2026-07-16 — the full landing is live
17
17
 
@@ -301,6 +301,7 @@ pip install <b>rapier-runtime</b>
301
301
  rapier <span class="p">spar</span> --request <span class="s">"Should we drop Postgres for DynamoDB?"</span>
302
302
  </div>
303
303
  <p class="qs-note">Bring your own API keys — Rapier calls the AI vendors you configure, and nothing runs on our servers. Want the full four-phase ceremony instead of the resolver? <span class="gold">rapier sparring</span>.</p>
304
+ <p class="qs-note">Not sure if you have a decision or still a question? <span class="gold">rapier frame</span> tells you which, and which command fits: an open question needs options generated first (<span class="gold">sparring</span>); a well-argued decision can go straight to the challenge (<span class="gold">spar</span>). In <span class="gold">sparring</span>, <span class="gold">--depth shallow|standard|deep</span> sets how widely it explores options, and <span class="gold">--seed</span> adds one you already favor &mdash; it gets no head start and survives only on its merits.</p>
304
305
  </div>
305
306
  </section>
306
307
 
@@ -1,4 +1,6 @@
1
- """The ``rapier mcp`` stdio server — frame / proposer / spar / sparring / doctor as MCP tools.
1
+ """The ``rapier mcp`` stdio server — frame / proposer / spar / sparring / doctor as MCP tools,
2
+ and saved runs as MCP resources (``rapier://runs``, ``rapier://runs/{run_id}``,
3
+ ``rapier://runs/{run_id}/record``).
2
4
 
3
5
  Optional: requires the ``mcp`` extra (``pip install "rapier-runtime[mcp]"``). The
4
6
  SDK is imported lazily inside :func:`build_server`, so importing this module (and
@@ -18,10 +20,15 @@ annotations`` — FastMCP evaluates each tool's signature, and the lazily-import
18
20
  ``Context`` annotation must be a real class object at definition time, not a
19
21
  string it cannot resolve.
20
22
  """
23
+ import json
21
24
  import os
22
25
  import sys
23
26
  from typing import Any, Callable
24
27
 
28
+ # Cap on runs listed individually in resources/list; every run stays readable by URI
29
+ # and is indexed in rapier://runs.
30
+ _MAX_LISTED_RUNS = 200
31
+
25
32
 
26
33
  async def _run_with_progress(
27
34
  fn: Callable[..., dict],
@@ -208,29 +215,86 @@ def build_server():
208
215
  values) and whether cross-vendor review is available."""
209
216
  return tools.doctor()
210
217
 
218
+ # Kept for clients that only call tools; the resources below are the browsable form.
211
219
  @server.tool()
212
220
  def list_runs() -> dict:
213
- """List persisted run ids (requires the server's RAPIER_MCP_LEDGER)."""
221
+ """List persisted run ids. Clients that support resources can browse rapier://runs instead."""
214
222
  return tools.list_runs(ledger_root)
215
223
 
216
224
  @server.tool()
217
225
  def get_run(run_id: str) -> dict:
218
- """Fetch a persisted run's report + verdict by id (requires RAPIER_MCP_LEDGER)."""
226
+ """Fetch a persisted run's report + verdict by id. Also readable as rapier://runs/{run_id}."""
219
227
  return tools.get_run(ledger_root, run_id)
220
228
 
229
+ # ── saved runs as resources ──────────────────────────────────────────────
230
+ @server.resource("rapier://runs", name="runs", mime_type="application/json",
231
+ description="Every saved run, newest first: kind, topic, verdict, and "
232
+ "the URIs of its report and full record.")
233
+ def runs_index() -> str:
234
+ return json.dumps(tools.run_index(ledger_root), indent=2)
235
+
236
+ @server.resource("rapier://runs/{run_id}", name="run-report", mime_type="text/plain",
237
+ description="A saved run's report, as the CLI printed it.")
238
+ def run_report(run_id: str) -> str:
239
+ return tools.run_report(ledger_root, run_id)
240
+
241
+ @server.resource("rapier://runs/{run_id}/record", name="run-record",
242
+ mime_type="application/json",
243
+ description="A saved run's full record (redacted on write): request, "
244
+ "recommendation, trust rider, verdict, trace, meta.")
245
+ def run_record(run_id: str) -> str:
246
+ return json.dumps(tools.run_record(ledger_root, run_id), indent=2, default=str)
247
+
248
+ # FastMCP lists only resources registered at startup, so runs saved after the
249
+ # server started would never appear. Replace the list handler with one that adds
250
+ # each saved run's report as a concrete resource every time a client asks.
251
+ from mcp.types import Resource
252
+
253
+ async def _list_resources():
254
+ listed = await server.list_resources()
255
+ index = tools.run_index(ledger_root)
256
+ for run in (index.get("runs") or [])[:_MAX_LISTED_RUNS]:
257
+ listed.append(Resource(
258
+ uri=run["report_uri"],
259
+ name=run["run_id"],
260
+ title=f"{run['kind']}: {run['topic']}" if run["topic"] else run["run_id"],
261
+ description=f"verdict: {run['verdict']}" if run["verdict"] else None,
262
+ mimeType="text/plain",
263
+ ))
264
+ return listed
265
+
266
+ server._mcp_server.list_resources()(_list_resources)
267
+
221
268
  return server
222
269
 
223
270
 
271
+ def _installed_mcp_version() -> str | None:
272
+ try:
273
+ from importlib.metadata import PackageNotFoundError, version
274
+ return version("mcp")
275
+ except PackageNotFoundError:
276
+ return None
277
+
278
+
224
279
  def serve() -> int:
225
280
  """Run the stdio MCP server. Returns non-zero with a hint if the SDK is absent."""
226
281
  try:
227
282
  server = build_server()
228
283
  except ImportError:
229
- print(
230
- 'The MCP server needs the optional "mcp" extra:\n'
231
- ' pip install "rapier-runtime[mcp]"',
232
- file=sys.stderr,
233
- )
284
+ installed = _installed_mcp_version()
285
+ if installed and installed.split(".")[0].isdigit() and int(installed.split(".")[0]) >= 2:
286
+ print(
287
+ f"The MCP server needs the mcp SDK 1.x, but {installed} is installed\n"
288
+ "(mcp 2.0 renamed the server API this version uses). Install a 1.x SDK:\n"
289
+ ' pip install "mcp>=1.2,<2"',
290
+ file=sys.stderr,
291
+ )
292
+ else:
293
+ print(
294
+ 'The MCP server needs the optional "mcp" extra:\n'
295
+ ' pip install "rapier-runtime[mcp]"',
296
+ file=sys.stderr,
297
+ )
234
298
  return 1
235
299
  server.run() # stdio transport by default
236
300
  return 0
@@ -162,6 +162,57 @@ def list_runs(ledger_root: str | None) -> dict[str, Any]:
162
162
  return {"ok": True, "runs": runs, "ledger_root": ledger_root}
163
163
 
164
164
 
165
+ def _load_envelope(ledger_root: str | None, run_id: str) -> dict[str, Any]:
166
+ """A run's persisted envelope (already redacted on write); ``LookupError`` if absent."""
167
+ if not ledger_root:
168
+ raise LookupError("run persistence is disabled (RAPIER_NO_PERSIST is set)")
169
+ if not _safe_run_id(run_id):
170
+ raise LookupError("invalid run id")
171
+ path = os.path.join(ledger_root, run_id, "envelope.json")
172
+ if not os.path.isfile(path):
173
+ raise LookupError(f"run '{run_id}' not found")
174
+ with open(path, encoding="utf-8") as fh:
175
+ return json.load(fh)
176
+
177
+
178
+ def run_index(ledger_root: str | None) -> dict[str, Any]:
179
+ """Every persisted run with enough to choose one: kind, topic, verdict (newest first).
180
+
181
+ Backs the ``rapier://runs`` resource. A run id is ``<YYYYmmddHHMMSS>-<kind>``.
182
+ """
183
+ listed = list_runs(ledger_root)
184
+ if not listed.get("ok"):
185
+ return listed
186
+ runs = []
187
+ for run_id in reversed(listed["runs"]):
188
+ try:
189
+ env = _load_envelope(ledger_root, run_id)
190
+ except (LookupError, ValueError, OSError):
191
+ continue # an in-progress or foreign dir has no envelope; it isn't a run yet
192
+ request = (env.get("request") or "").strip()
193
+ runs.append({
194
+ "run_id": run_id,
195
+ "kind": run_id.split("-", 1)[1] if "-" in run_id else "",
196
+ "topic": request.splitlines()[0][:120] if request else "",
197
+ "verdict": env.get("verdict"),
198
+ "report_uri": f"rapier://runs/{run_id}",
199
+ "record_uri": f"rapier://runs/{run_id}/record",
200
+ })
201
+ return {"ok": True, "ledger_root": ledger_root, "runs": runs}
202
+
203
+
204
+ def run_report(ledger_root: str | None, run_id: str) -> str:
205
+ """A run's plain-text report, as the CLI printed it. ``LookupError`` if absent."""
206
+ env = _load_envelope(ledger_root, run_id)
207
+ meta = env.get("meta") or {}
208
+ return meta.get("report_md") or meta.get("report") or env.get("recommendation") or ""
209
+
210
+
211
+ def run_record(ledger_root: str | None, run_id: str) -> dict[str, Any]:
212
+ """A run's full persisted record (the redacted envelope). ``LookupError`` if absent."""
213
+ return _load_envelope(ledger_root, run_id)
214
+
215
+
165
216
  def get_run(ledger_root: str | None, run_id: str) -> dict[str, Any]:
166
217
  """Return a persisted run's report + verdict by id (from its envelope.json)."""
167
218
  if not ledger_root:
@@ -169,3 +169,14 @@ def test_runs_disabled_without_ledger():
169
169
 
170
170
  assert tools.list_runs(None)["ok"] is False
171
171
  assert tools.get_run(None, "x")["ok"] is False
172
+
173
+
174
+ def test_serve_names_mcp_2_when_that_is_why_import_failed(monkeypatch, capsys):
175
+ def _raise():
176
+ raise ImportError("No module named 'mcp.server.fastmcp'")
177
+
178
+ monkeypatch.setattr(server, "build_server", _raise)
179
+ monkeypatch.setattr(server, "_installed_mcp_version", lambda: "2.3.0")
180
+ assert server.serve() == 1
181
+ err = capsys.readouterr().err
182
+ assert "2.3.0 is installed" in err and 'pip install "mcp>=1.2,<2"' in err
@@ -0,0 +1,98 @@
1
+ """Saved runs as MCP resources: rapier://runs, rapier://runs/{id}, rapier://runs/{id}/record.
2
+
3
+ The index/report/record logic is plain Python and always runs. The protocol test
4
+ needs the optional ``mcp`` extra and is skipped without it.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+
11
+ import pytest
12
+
13
+ from rapier.mcp import tools
14
+
15
+
16
+ def _save_run(root, run_id, request="Should we ship?", verdict="PASS", report="REPORT TEXT"):
17
+ d = os.path.join(root, run_id)
18
+ os.makedirs(d)
19
+ with open(os.path.join(d, "envelope.json"), "w", encoding="utf-8") as fh:
20
+ json.dump({"request": request, "verdict": verdict,
21
+ "recommendation": "do it", "meta": {"report_md": report}}, fh)
22
+
23
+
24
+ @pytest.fixture
25
+ def ledger(tmp_path):
26
+ root = str(tmp_path / "runs")
27
+ os.makedirs(root)
28
+ _save_run(root, "20261016095019-frame", request="Is this a question?\nsecond line",
29
+ verdict=None, report="FRAME REPORT")
30
+ _save_run(root, "20261016095044-spar", report="SPAR REPORT")
31
+ os.makedirs(os.path.join(root, "20261016100000-spar")) # in progress: no envelope yet
32
+ return root
33
+
34
+
35
+ def test_index_lists_runs_newest_first_and_skips_unfinished(ledger):
36
+ out = tools.run_index(ledger)
37
+ assert out["ok"] is True
38
+ assert [r["run_id"] for r in out["runs"]] == ["20261016095044-spar", "20261016095019-frame"]
39
+ spar, frame = out["runs"]
40
+ assert spar["kind"] == "spar" and spar["verdict"] == "PASS"
41
+ assert frame["topic"] == "Is this a question?"
42
+ assert spar["report_uri"] == "rapier://runs/20261016095044-spar"
43
+ assert spar["record_uri"] == "rapier://runs/20261016095044-spar/record"
44
+
45
+
46
+ def test_index_when_persistence_is_off_or_nothing_saved(tmp_path):
47
+ assert tools.run_index(None)["ok"] is False
48
+ assert tools.run_index(str(tmp_path / "absent"))["runs"] == []
49
+
50
+
51
+ def test_report_and_record_read_the_saved_run(ledger):
52
+ assert tools.run_report(ledger, "20261016095044-spar") == "SPAR REPORT"
53
+ assert tools.run_record(ledger, "20261016095044-spar")["verdict"] == "PASS"
54
+
55
+
56
+ @pytest.mark.parametrize("bad", ["../etc", "a/b", "", "nope-spar"])
57
+ def test_report_rejects_traversal_and_unknown_ids(ledger, bad):
58
+ with pytest.raises(LookupError):
59
+ tools.run_report(ledger, bad)
60
+
61
+
62
+ def test_resources_over_the_protocol(ledger, monkeypatch):
63
+ pytest.importorskip("mcp.server.fastmcp")
64
+ anyio = pytest.importorskip("anyio")
65
+ from mcp.shared.memory import create_connected_server_and_client_session
66
+
67
+ from rapier.mcp.server import build_server
68
+
69
+ monkeypatch.delenv("RAPIER_NO_PERSIST", raising=False)
70
+ monkeypatch.setenv("RAPIER_MCP_LEDGER", ledger)
71
+ server = build_server()
72
+
73
+ async def go():
74
+ async with create_connected_server_and_client_session(server._mcp_server) as client:
75
+ listed = {str(r.uri): r for r in (await client.list_resources()).resources}
76
+ templates = {t.uriTemplate for t in
77
+ (await client.list_resource_templates()).resourceTemplates}
78
+
79
+ # A run saved after the server started still shows up in the next listing.
80
+ _save_run(ledger, "20261016110000-sparring", request="Later run")
81
+ relisted = {str(r.uri) for r in (await client.list_resources()).resources}
82
+
83
+ index = json.loads((await client.read_resource("rapier://runs")).contents[0].text)
84
+ report = (await client.read_resource("rapier://runs/20261016095044-spar")).contents[0]
85
+ record = (await client.read_resource(
86
+ "rapier://runs/20261016095044-spar/record")).contents[0]
87
+ return listed, templates, relisted, index, report, record
88
+
89
+ listed, templates, relisted, index, report, record = anyio.run(go)
90
+
91
+ assert "rapier://runs" in listed
92
+ assert listed["rapier://runs/20261016095044-spar"].mimeType == "text/plain"
93
+ assert "rapier://runs/20261016100000-spar" not in listed # unfinished run not listed
94
+ assert {"rapier://runs/{run_id}", "rapier://runs/{run_id}/record"} <= templates
95
+ assert "rapier://runs/20261016110000-sparring" in relisted
96
+ assert [r["run_id"] for r in index["runs"]][-1] == "20261016095019-frame"
97
+ assert report.text == "SPAR REPORT"
98
+ assert json.loads(record.text)["verdict"] == "PASS"
File without changes
File without changes