From 53ded577ad7b1bd448b20569af3105427e600ef2 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Mon, 3 Aug 2026 17:02:04 +0100 Subject: [PATCH] mcp: close final hardening gaps --- .github/ISSUE_TEMPLATE/guide-usage-report.yml | 14 +- .github/workflows/rust-spike.yml | 64 +++++++- docs/cli.md | 55 ++++--- docs/context-cost.md | 2 +- docs/index.md | 6 +- docs/mcp.md | 149 ++++-------------- docs/security.md | 5 +- docs/shared-server.md | 2 +- rust/decided-mcp/src/main.rs | 2 +- rust/decided-mcp/tests/docs_contract.rs | 137 ++++++++++++++++ rust/decided/tests/cli.rs | 75 +++++++++ rust/rac-engine/src/cli.rs | 4 +- rust/rac-engine/src/output.rs | 4 +- 13 files changed, 350 insertions(+), 169 deletions(-) create mode 100644 rust/decided-mcp/tests/docs_contract.rs create mode 100644 rust/decided/tests/cli.rs diff --git a/.github/ISSUE_TEMPLATE/guide-usage-report.yml b/.github/ISSUE_TEMPLATE/guide-usage-report.yml index bdfbc8b0..588e2f63 100644 --- a/.github/ISSUE_TEMPLATE/guide-usage-report.yml +++ b/.github/ISSUE_TEMPLATE/guide-usage-report.yml @@ -1,24 +1,24 @@ -name: Guide usage report +name: MCP usage report description: >- - Share an opt-in Guide telemetry summary. The report contains only tool-call + Share an opt-in MCP usage summary. The report contains only tool-call counts, outcomes, durations, and timestamps — never tool arguments, queries, or repository content. Submitting posts it as a public issue. -title: "Guide usage report" +title: "MCP usage report" labels: - usage-report body: - type: markdown attributes: value: >- - Thanks for sharing! This report comes from `rac mcp-stats --share` and - helps decide where Guide investment goes. Review the prefilled summary + Thanks for sharing! This report comes from `decided mcp-stats --share` and + helps decide where MCP investment goes. Review the prefilled summary below before submitting — it should contain only counts and timestamps, and it will be publicly visible. - type: textarea id: report attributes: label: Telemetry summary - description: The JSON summary produced by `rac mcp-stats --share`. + description: The JSON summary produced by `decided mcp-stats --share`. render: json validations: required: true @@ -27,7 +27,7 @@ body: attributes: label: Anything to add? (optional) description: >- - How you use Guide, what worked, what didn't — anything that gives the + How you use the MCP server, what worked, what didn't — anything that gives the numbers context. validations: required: false diff --git a/.github/workflows/rust-spike.yml b/.github/workflows/rust-spike.yml index a9288e01..10e85717 100644 --- a/.github/workflows/rust-spike.yml +++ b/.github/workflows/rust-spike.yml @@ -17,10 +17,10 @@ on: - ".github/workflows/rust-spike.yml" jobs: - windows-compile: - name: Windows native compile + windows-runtime: + name: Windows native runtime smoke runs-on: windows-latest - timeout-minutes: 20 + timeout-minutes: 25 steps: - uses: actions/checkout@v4 @@ -31,9 +31,63 @@ jobs: with: workspaces: rust - - name: Check native workspace + - name: Build native release binaries + working-directory: rust + run: cargo build --release --locked + + # Keep this bounded: Linux owns the full workspace and inotify matrix; + # Windows proves that the shipped CLI, cache/store, rename transaction, + # and MCP wire contracts execute on the native filesystem. + - name: Native CLI and cache/store smoke + working-directory: rust + run: | + cargo test -p decided --test cli --release + cargo test -p asdecided-core --test index_store_vectors --release + cargo test -p asdecided-core --lib --release rename::tests::successful_transaction_replaces_all_files_and_cleans_backups + + - name: MCP contract smoke working-directory: rust - run: cargo check --workspace --locked + run: | + cargo test -p decided-mcp --test protocol_legacy --release + cargo test -p decided-mcp --test protocol_2026 --release + cargo test -p decided-mcp --test response_budget --release + cargo test -p decided-mcp --test docs_contract --release + + macos-runtime: + name: macOS native runtime smoke + runs-on: macos-15 + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + + - name: Install rust toolchain (pinned by rust/rust-toolchain.toml) + run: rustup show + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + + - name: Build native release binaries + working-directory: rust + run: cargo build --release --locked + + # Keep this bounded: macOS proves the shipped CLI, cache/store, rename + # transaction, and MCP contracts without duplicating Linux's watcher + # correctness gate. + - name: Native CLI and cache/store smoke + working-directory: rust + run: | + cargo test -p decided --test cli --release + cargo test -p asdecided-core --test index_store_vectors --release + cargo test -p asdecided-core --lib --release rename::tests::successful_transaction_replaces_all_files_and_cleans_backups + + - name: MCP contract smoke + working-directory: rust + run: | + cargo test -p decided-mcp --test protocol_legacy --release + cargo test -p decided-mcp --test protocol_2026 --release + cargo test -p decided-mcp --test response_budget --release + cargo test -p decided-mcp --test docs_contract --release rust-contract: name: Rust contract + live-corpus invariants diff --git a/docs/cli.md b/docs/cli.md index bc5cb451..8d95805c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1046,20 +1046,26 @@ Explorer is retired and is not part of the native product. ## mcp -Serve RAC repository knowledge to coding agents over MCP (stdio). The four -read-only tools, client configuration, and team setup are documented in the -[MCP server guide](mcp.md). +Serve AsDecided repository knowledge to coding agents over MCP. The native +server exposes six read-only tools; client configuration, response budgets, +and team setup are documented in the [MCP server guide](mcp.md). ```bash decided-mcp --root /path/to/repo -decided-mcp --root /path/to/repo --telemetry +decided-mcp --root /path/to/repo \ + --transport http --host 127.0.0.1 --port 8000 --path /mcp --budget 10000 ``` - **`--root PATH`** — repository root to serve (default: current directory) -- **`--telemetry`** — record tool-call counts and metadata (never arguments - or content) to a local log under `$XDG_STATE_HOME/decisions/` (default - `~/.local/state/decisions/guide-telemetry.jsonl`); off by default, announced on - stderr when on +- **`--transport {stdio,http}`** — MCP transport (default: `stdio`) +- **`--host HOST`** — HTTP bind host (default: `127.0.0.1`) +- **`--port PORT`** — HTTP bind port (default: `8000`) +- **`--path PATH`** — HTTP endpoint path (default: `/mcp`) +- **`--budget N`** — maximum response size in characters (minimum `128`) +- **`--allowed-origin ORIGIN`** — allow an HTTP `Origin` header; repeat for + multiple origins +- **`--cache` / `--no-cache`** — enable or disable the derived read-model cache + (cache is enabled by default and output-neutral) - **Exit codes:** `0` server shutdown on client disconnect · `2` `--root` is not a directory @@ -1067,18 +1073,18 @@ decided-mcp --root /path/to/repo --telemetry ## mcp-stats -Summarize the local Guide telemetry log: events, sessions, first and last -timestamps, and per-tool calls, errors, truncation, and average duration. -An empty or missing log is a valid answer — telemetry is opt-in and off by -default. +Read a local compatibility MCP usage log, if one exists: events, sessions, +first and last timestamps, and per-tool calls, errors, truncation, and average +duration. The native `decided-mcp` server does not write this log; an empty or +missing log is therefore the normal answer for new installations. ```bash -decided-mcp-stats # human summary -decided-mcp-stats --json # the same summary as JSON (the shareable export) -decided-mcp-stats --share # prefilled GitHub usage-report issue URL +decided mcp-stats # human summary +decided mcp-stats --json # the same summary as JSON (the shareable export) +decided mcp-stats --share # prefilled GitHub usage-report issue URL ``` -`--share` prints a URL that opens a prefilled usage-report issue containing +`decided mcp-stats --share` prints a URL that opens a prefilled usage-report issue containing only counts and timestamps; you review and submit it in your own browser — RAC sends nothing itself. `--json` and `--share` are mutually exclusive. @@ -1089,16 +1095,16 @@ RAC sends nothing itself. `--json` and `--share` are mutually exclusive. ## usage -Summarize recorded **CLI usage** alongside the Guide MCP tools — per-command and -per-tool call counts, errors, session count, and a recent-activity trend. When -sharing consent is recorded (`decided telemetry on`), each completed `rac` command +Summarize recorded **CLI usage** alongside any compatibility MCP log — +per-command and per-tool call counts, errors, session count, and a recent-activity trend. When +sharing consent is recorded (`decided telemetry on`), each completed `decided` command appends one **content-free** event (subcommand name, outcome, duration — never argv, paths, or artifact ids) to a local log; `decided usage` reads it back. -`decided-mcp-stats` stays Guide-only for back-compat; `decided usage` covers both logs +`decided mcp-stats` remains a compatibility read-back; `decided usage` covers both logs (ADR-046). ```bash -decided usage # human summary of CLI + Guide usage +decided usage # human summary of CLI + MCP compatibility usage decided usage --json # the same summary as JSON decided usage --share # prefilled GitHub usage-report issue URL (counts only) ``` @@ -1113,9 +1119,10 @@ default. `--json` and `--share` are mutually exclusive. ## telemetry Show or change anonymous usage-sharing consent (ADR-041). With consent on, -`decided-mcp` sends at most one anonymous daily ping — a random install id, the +the native CLI sends at most one anonymous daily ping — a random install id, the version, and an active-repo count; never paths, queries, or repository -content. Sharing is independent of the local `decided-mcp --telemetry` flag. +content. Sharing is independent of MCP serving; `decided-mcp` never sends +product telemetry. ```bash decided telemetry # status (default): what is shared, and whether sending is possible @@ -1510,7 +1517,7 @@ decided decisions-for rust/Cargo.toml decisions/ --json ``` The same lookup is available to agents over MCP as an additive optional `path` -argument on the `find_decisions` tool (the five-tool surface is unchanged); +argument on the `find_decisions` tool (the six-tool surface is unchanged); `find_decisions` called with a `topic` is byte-identical to before. ### Herald rendering diff --git a/docs/context-cost.md b/docs/context-cost.md index 32e25da5..a2d6007b 100644 --- a/docs/context-cost.md +++ b/docs/context-cost.md @@ -18,7 +18,7 @@ compression — payloads stay small because they are *scoped*, not lossily shrun ## 1. The MCP surface is measured and budgeted -The standing cost of the MCP server — the five tool descriptions and their JSON +The standing cost of the MCP server — the six tool descriptions and their JSON schemas a client loads every session — is measured deterministically and offline (no model, no network) and held under a budget as a regression check. Today it measures ~915 tokens against a 1000 budget — roughly 25× under the ~23k figure the diff --git a/docs/index.md b/docs/index.md index 18257769..a3342460 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,7 +22,9 @@ Point your agent at your repo and ask: The agent calls AsDecided, finds your soft-delete decision, cites it by ID, and proposes the compliant change — instead of reintroducing the thing you removed on purpose. -The server exposes four read-only tools: `get_artifact`, `search_artifacts`, `get_related`, `get_summary`. It never writes to your repo. +The server exposes six read-only tools: `get_artifact`, `search_artifacts`, +`retrieve_grounding`, `find_decisions`, `get_related`, and `get_summary`. It +never writes to your repo. ▶ **Full walkthrough + runnable example: [examples/guide/](https://github.com/asdecided/core/tree/main/examples/guide)** @@ -102,7 +104,7 @@ AsDecided asks you to trust it with your product knowledge, so it holds itself t - **No AI in the core.** Retrieval is deterministic: the same repo state and the same query always return the same result. The reasoning is your agent's job; AsDecided's job is to hand it the facts. - **It dogfoods itself.** AsDecided's own planning corpus under [`decisions/`](https://github.com/asdecided/core/tree/main/rac) is validated by RAC in CI — if the tool's rules break the tool's own artifacts, the build fails. - **Output is a contract.** Golden tests pin CLI and MCP output; any change to what the tools return is reviewed as a product change. -- **Telemetry is opt-in twice over.** Local recording needs an explicit `--telemetry` flag and never includes your arguments or repository content. Remote sharing is a separate, explicit consent (`decided telemetry on`, or one honest question at `decided init`): one anonymous daily ping — a random install id, the version, and an active-repo count — never paths, queries, or content. `decided telemetry status` shows exactly what is shared, the network surface is a single readable module, and ADR-041 records the decision. +- **Telemetry is opt-in.** Optional anonymous sharing is configured explicitly with `decided telemetry on` (or one honest question at `decided init`): one daily ping with a random install id, the version, and an active-repo count — never paths, queries, or repository content. `decided telemetry status` shows exactly what is shared, the network surface is a single readable module, and ADR-041 records the decision. The native MCP server itself never sends telemetry. ## Sharing the corpus (the Portal) diff --git a/docs/mcp.md b/docs/mcp.md index 9490f640..3c4063a7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,4 +1,4 @@ -# AsDecided Guide — MCP Server +# AsDecided MCP Server AsDecided MCP serves your repository's requirements, decisions, designs, and roadmaps to coding agents as callable tools. It ships @@ -10,7 +10,9 @@ as the native `decided-mcp` binary. brew install asdecided/tap/asdecided-core ``` -No Python runtime or extra is needed. +No Python runtime or extra is needed. The server is read-only and has no +network side channel; optional anonymous product sharing is configured +separately with `decided telemetry` and does not change MCP responses. ## Protocol compatibility @@ -123,7 +125,7 @@ harness Omnigent routes to. A worked setup — including pointing the agent's -## 3. Point Guide at a repository +## 3. Point AsDecided at a repository `--root` accepts any directory. It does not have to be the top of a Git repository — point it at the folder where your RAC Markdown artifacts live. @@ -137,7 +139,7 @@ decided index /path/to/your/repo That should list your artifacts. If it shows nothing, run `decided init /path/to/your/repo` to initialize the repository. -To try Guide against a ready-made corpus before using your own, point `--root` +To try AsDecided against a ready-made corpus before using your own, point `--root` at the included examples: ```bash @@ -145,7 +147,7 @@ decided-mcp --root examples/guide ``` The `examples/guide/` corpus contains one requirement, decision, design, and -roadmap for a fictional user management service — enough to explore all four +roadmap for a fictional user management service — enough to explore all six tools. ### Response budgets @@ -180,12 +182,14 @@ and cite the decision ID in its response. If you are pointing at your own repository, substitute a topic you know a decision covers. -## 5. The four tools +## 5. The six tools | Tool | When the agent calls it | |---|---| | `get_summary` | Once at session start — counts artifacts, flags health issues | -| `search_artifacts` | Before designing or implementing anything that a recorded decision might cover | +| `search_artifacts` | Before designing or implementing anything that a recorded decision might cover — keyword search across the corpus | +| `retrieve_grounding` | For one-call task grounding, optionally scoped to a path, with ranked excerpts and provenance | +| `find_decisions` | To find live decisions by topic or the decisions whose declared scope governs a code path | | `get_artifact` | When an artifact ID appears, or before changing anything a decision covers | | `get_related` | After retrieving an artifact — finds what else the change could affect | @@ -245,7 +249,7 @@ Canonical agent guidance lives in `decisions/prompts/` as validated RAC artifact Claude Code inlines the referenced artifact at session start, so the effect is identical to pasting the text — but the guidance is now a governed artifact: `decided validate` checks it in CI, it is versioned and diffable like any other -decision, and Guide itself can serve it (`get_artifact` retrieves your usage +decision, and AsDecided itself can serve it (`get_artifact` retrieves your usage instructions — the system is self-describing). Two caveats: @@ -256,108 +260,11 @@ Two caveats: carry the same pointer in their native convention (for example `.cursor/rules`); the prompt artifact remains the single source of truth. -## 7. Telemetry (opt-in) - -Guide records nothing by default. If you want to see whether it is actually -being used — and help decide where Guide investment goes — opt in with an -explicit flag in your client's server configuration: - -```json -{ - "mcpServers": { - "asdecided": { - "command": "decided-mcp", - "args": ["--root", "/path/to/repo", "--telemetry"] - } - } -} -``` - -When enabled, each tool call appends one JSON line to a local log -(`~/.local/state/decisions/guide-telemetry.jsonl`, or under `$XDG_STATE_HOME`): - -```json -{"schema_version": "1", "ts": "2026-06-12T14:03:22.512Z", "session": "a3f29c1b", - "tool": "search_artifacts", "outcome": "ok", "duration_ms": 12, "truncated": false} -``` - -What is recorded: timestamp, a random per-session id, the tool name, whether -the call succeeded, how long it took, and whether the response was truncated. -What is **never** recorded: tool arguments, artifact IDs, search queries, -file paths, or any repository content. The server announces the log path on -stderr at startup, so enablement is never silent. - -Read the log back any time: - -```bash -decided-mcp-stats # human summary: events, sessions, per-tool usage -decided-mcp-stats --json # the same summary as JSON — this is the export -``` - -If you want to share your usage with the project (early reports directly -shape Guide's roadmap): - -```bash -decided-mcp-stats --share -``` - -This prints a prefilled GitHub issue URL. Open it, review the report — counts -and timestamps only — and submit it with your own account. RAC never sends -anything anywhere; building a URL is string formatting, and transmission -belongs to you and your browser. Submitted reports are public issues. - -### Share anonymously (optional) - -If you'd rather contribute a signal without writing anything, opt in to an -anonymous daily ping: - -```bash -decided telemetry on # opt in (decided init also asks once, on a real terminal) -decided telemetry status # exactly what is shared, and whether sending is possible -decided telemetry off # stop; nothing else changes -``` - -With consent on, `decided-mcp` sends at most one ping per 24 hours. This is the -entire transmission — adding a field requires a new recorded decision -(ADR-041): - -```json -{ - "api_key": "", - "event": "asdecided-daily-ping", - "timestamp": "", - "properties": { - "distinct_id": "", - "$process_person_profile": false, - "schema_version": "1", - "rac_version": "", - "active_repos": 2 - } -} -``` - -What the fields are: the install id is a random token minted when you opt in -(derived from nothing, so it identifies nothing); `$process_person_profile: -false` tells PostHog to create no person profile, so the event stays -anonymous on the receiving side as well; `active_repos` counts the -distinct repositories Guide served in the last 30 days, tracked locally as -salted digests in `~/.local/state/decisions/active-repos.json` — the salt never -leaves your machine and only the count is sent. The last-ping marker lives at -`~/.local/state/decisions/last-ping`; your consent record at -`~/.config/decisions/telemetry.json`. - -Sharing is independent of `--telemetry` — each is its own opt-in. When -sharing is on, the server announces it on stderr at startup; it is never -silent. The sender is one readable module (`decisions/mcp/ping.py`, the only -network code in RAC): failures are dropped without retries, the socket -timeout is three seconds, and a build with no endpoint key configured sends -nothing at all — `decided telemetry status` will say so. - -## 8. Read-access audit log (enterprise, opt-in) +## 7. Read-access audit log (enterprise, opt-in) For regulated installs that must record *who consulted which decision, when, and -which artifact references came back* — the audit trail telemetry deliberately does not -keep — the server can append one JSON line per read-tool call to a local file +which artifact references came back* — information the response payload does not +carry — the server can append one JSON line per read-tool call to a local file ([ADR-084](https://github.com/asdecided/core/blob/main/decisions/decisions/adr-084-read-access-audit-recorder.md)). It is **content-bearing by design and off by default**: with no `audit:` stanza @@ -374,11 +281,11 @@ git-diffable artifact to point at): ```yaml audit: enabled: true - # path: /var/log/asdecided/audit.jsonl # optional; default: $XDG_STATE_HOME/decisions/audit.jsonl + # path: /var/log/asdecided/audit.jsonl # optional; default: $XDG_STATE_HOME/decided/audit.jsonl # on_write_error: warn # warn (default) | block ``` -- **`path`** — where the JSONL is written. Default `$XDG_STATE_HOME/decisions/audit.jsonl`; +- **`path`** — where the JSONL is written. Default `$XDG_STATE_HOME/decided/audit.jsonl`; override per machine with the `DECIDED_AUDIT_PATH` environment variable (for data residency). - **`on_write_error`** — `warn` (the default) reports a write failure on stderr @@ -421,7 +328,7 @@ serving is also mandatory audit-on: it refuses to start without a working sink, and a sink write failure blocks the call. When enabled, startup announces the resolved path, recorded scope, transport, and write-failure mode on stderr. -## 9. Shared HTTP endpoint (team scale) +## 8. Shared HTTP endpoint (team scale) By default `decided-mcp` speaks **stdio**: one server process per developer, against that developer's own checkout. At team scale you may instead want **one @@ -440,7 +347,7 @@ decided-mcp --root /path/to/your/repo --transport http --host 127.0.0.1 --port 8 (defaults `127.0.0.1`, `8000`, `/mcp`). It binds to loopback by default; exposing it to a network is a deliberate deployment choice. -The HTTP transport is **serving-layer only**: the five tools are unchanged, the +The HTTP transport is **serving-layer only**: the six tools are unchanged, the server re-reads the repository per call (no cache, no session state), and an HTTP response is **payload-identical to stdio** for the same corpus bytes. @@ -453,7 +360,7 @@ authenticated* (ADR-084). **HTTP serving is mandatory audit-on.** Because a shared endpoint serves reads no single developer's git identity can attribute, the HTTP transport **refuses to start without a working audit log** — enable the `audit:` stanza (see -[section 8](#8-read-access-audit-log-enterprise-opt-in)) first, or the server +[section 7](#7-read-access-audit-log-enterprise-opt-in)) first, or the server exits with an actionable error. stdio is unaffected. Keeping the fronted checkout current with `main` — a merge webhook or a periodic @@ -483,26 +390,24 @@ correctness — the files in git remain the single source of truth. Pass `--no-cache` (or set `DECIDED_NO_CACHE=1`) to restore the zero-state posture where every tool call re-reads the repository from disk. -## 10. Troubleshooting +## 9. Troubleshooting ### Server not listed in the client -- Confirm `rac` is on the PATH the client uses. Test with: +- Confirm `decided-mcp` is on the PATH the client uses. Test with: ```bash - which rac + which decided-mcp decided --version ``` -- If you installed with `uv tool install`, the tool binary may be in - `~/.local/bin/` — add that to PATH or use the full path in the config. - Check the client's MCP server log for startup errors. -### Wrong root (Guide answers from the wrong repository) +### Wrong root (AsDecided answers from the wrong repository) - Verify the `--root` path in your config matches the directory you intend. - Run `decided index /path/to/your/repo` to confirm the right artifacts are visible. - In Claude Code, run `/mcp` to inspect the server configuration. -### Empty corpus (Guide says no artifacts found) +### Empty corpus (AsDecided says no artifacts found) When the server starts against a root with no RAC artifacts it prints a diagnostic to stderr: @@ -528,7 +433,7 @@ the repository has not been initialized. See the troubleshooting steps above. ## Further reading -- [CLI reference](cli.md) — every `rac` command including `decided-mcp` +- [CLI reference](cli.md) — every `decided` command including `decided-mcp` - [Artifact types](artifacts.md) — what requirements, decisions, designs, roadmaps, and prompts look like - [Repository workflow](repo-workflow.md) — how to organize a RAC repository -- [Examples corpus](https://github.com/asdecided/core/tree/main/examples/guide/) — the ready-made guide corpus +- [Examples corpus](https://github.com/asdecided/core/tree/main/examples/guide/) — the ready-made AsDecided corpus diff --git a/docs/security.md b/docs/security.md index 225f5bd1..c3f706cf 100644 --- a/docs/security.md +++ b/docs/security.md @@ -8,8 +8,9 @@ Python runtime. ## No-egress boundary Validation, relationships, review, gate, search, and export operate on the -local filesystem. Optional MCP usage telemetry remains consent-gated and -content-free; regulated installations can hard-disable it with: +local filesystem. The native MCP server emits no usage telemetry and has no +network side channel. Optional product sharing is configured separately +through the CLI; regulated installations can hard-disable it with: ```bash decided telemetry off --enterprise diff --git a/docs/shared-server.md b/docs/shared-server.md index f9d03999..6312d572 100644 --- a/docs/shared-server.md +++ b/docs/shared-server.md @@ -69,7 +69,7 @@ docker run --rm --entrypoint decided-mcp -p 8000:8000 -v "$PWD:/corpus:ro" \ HTTP serving is **mandatory audit-on**: the server refuses to start without a working audit sink. Enable it in the corpus's `.decided/config.yaml` (committed, so an auditor has one git-diffable artifact) — see -[the audit section](mcp.md#8-read-access-audit-log-enterprise-opt-in): +[the audit section](mcp.md#7-read-access-audit-log-enterprise-opt-in): ```yaml audit: diff --git a/rust/decided-mcp/src/main.rs b/rust/decided-mcp/src/main.rs index 037a0f4b..2a79c20f 100644 --- a/rust/decided-mcp/src/main.rs +++ b/rust/decided-mcp/src/main.rs @@ -1,4 +1,4 @@ -//! decided-mcp — the Guide MCP server over stdio, a Rust port of `decided mcp` +//! decided-mcp — the AsDecided MCP server over stdio //! (PORT-CONTRACT.d/10-mcp-surface.md is the binding wire contract). //! //! Framing: newline-delimited JSON-RPC, UTF-8, non-ASCII raw, no diff --git a/rust/decided-mcp/tests/docs_contract.rs b/rust/decided-mcp/tests/docs_contract.rs new file mode 100644 index 00000000..c6b75d4e --- /dev/null +++ b/rust/decided-mcp/tests/docs_contract.rs @@ -0,0 +1,137 @@ +//! Keep the public MCP guide tied to the shipped Rust binary. + +use std::fs; +use std::io::Write; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde_json::Value; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .to_path_buf() +} + +#[test] +fn guide_documents_the_native_surface_and_starts_the_example_server() { + let root = repo_root(); + let guide = fs::read_to_string(root.join("docs/mcp.md")).expect("read MCP guide"); + for tool in [ + "get_artifact", + "search_artifacts", + "retrieve_grounding", + "find_decisions", + "get_related", + "get_summary", + ] { + assert!(guide.contains(&format!("`{tool}`")), "guide omits {tool}"); + } + assert!( + guide.contains("--budget N"), + "guide omits the response budget flag" + ); + assert!( + !guide.contains("--telemetry"), + "guide still advertises the retired MCP telemetry flag" + ); + + let example_root = root.join("examples/guide"); + let mut child = Command::new(env!("CARGO_BIN_EXE_decided-mcp")) + .args([ + "--root", + example_root.to_str().expect("example path is UTF-8"), + "--no-cache", + "--budget", + "128", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("start documented stdio command"); + child + .stdin + .take() + .expect("stdin pipe") + .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}\n") + .expect("send tools/list"); + let output = child + .wait_with_output() + .expect("wait for documented command"); + assert!( + output.status.success(), + "documented command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let frame = String::from_utf8(output.stdout).expect("MCP stdout is UTF-8"); + let response: Value = serde_json::from_str(frame.trim()).expect("tools/list response is JSON"); + let tools = response + .pointer("/result/tools") + .and_then(Value::as_array) + .expect("tools/list response contains tools"); + assert_eq!(tools.len(), 6, "the guide and binary must expose six tools"); + + // The documented HTTP command has a mandatory audit-on contract. Start it + // with the smallest valid config and prove that the process binds before + // killing it; the detailed request/response matrix remains in http_transport. + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(); + let http_root = std::env::temp_dir().join(format!("asdecided-mcp-docs-{nonce}")); + fs::create_dir_all(http_root.join(".decided")).expect("create HTTP smoke corpus"); + fs::write( + http_root.join(".decided/config.yaml"), + "audit:\n enabled: true\n", + ) + .expect("write HTTP audit config"); + let audit_path = http_root.join("audit.jsonl"); + let probe = TcpListener::bind(("127.0.0.1", 0)).expect("reserve HTTP smoke port"); + let port = probe.local_addr().expect("read HTTP smoke port").port(); + drop(probe); + let mut http = Command::new(env!("CARGO_BIN_EXE_decided-mcp")) + .args([ + "--root", + http_root.to_str().expect("HTTP root is UTF-8"), + "--transport", + "http", + "--host", + "127.0.0.1", + "--port", + &port.to_string(), + "--path", + "/mcp", + "--budget", + "10000", + ]) + .env("DECIDED_AUDIT_PATH", &audit_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("start documented HTTP command"); + let mut ready = false; + for _ in 0..100 { + if TcpStream::connect(("127.0.0.1", port)).is_ok() { + ready = true; + break; + } + thread::sleep(Duration::from_millis(10)); + } + if !ready { + let _ = http.kill(); + let _ = http.wait(); + let _ = fs::remove_dir_all(&http_root); + panic!("documented HTTP command did not bind"); + } + http.kill().expect("stop HTTP smoke server"); + http.wait().expect("reap HTTP smoke server"); + fs::remove_dir_all(http_root).expect("remove HTTP smoke corpus"); +} diff --git a/rust/decided/tests/cli.rs b/rust/decided/tests/cli.rs new file mode 100644 index 00000000..2af36e0d --- /dev/null +++ b/rust/decided/tests/cli.rs @@ -0,0 +1,75 @@ +//! Cross-platform smoke tests for the native `decided` argv surface. + +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn scratch_root() -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "asdecided-cli-positional-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&root).expect("create CLI smoke corpus"); + fs::write( + root.join("decision.md"), + "---\nschema_version: 1\nid: RAC-111111111111\ntype: decision\n---\n# Decision\n\n## Context\n\nA CLI parser smoke fixture.\n\n## Decision\n\nKeep the positional contract explicit.\n\n## Consequences\n\nThe native parser is tested on every supported platform.\n\n## Status\n\nAccepted\n", + ) + .expect("write CLI smoke fixture"); + root +} + +fn run(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_decided")) + .args(args) + .output() + .expect("run decided") +} + +#[test] +fn find_accepts_a_literal_hyphen_query() { + let root = scratch_root(); + let root = root.to_string_lossy().into_owned(); + let output = run(&["find", "-", "--no-cache", &root]); + + assert!( + output.status.success(), + "find - should be a normal query, stdout={:?}, stderr={:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !String::from_utf8_lossy(&output.stderr).contains("required: query"), + "the literal hyphen was parsed as an option" + ); + + fs::remove_dir_all(root).expect("remove CLI smoke corpus"); +} + +#[test] +fn resolve_accepts_a_literal_hyphen_id_and_reaches_resolution() { + let root = scratch_root(); + let root = root.to_string_lossy().into_owned(); + let output = run(&["resolve", "-", &root]); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_eq!( + output.status.code(), + Some(1), + "resolve - should be a normal not-found result" + ); + assert!( + stderr.contains("artifact not found: -"), + "stderr={stderr:?}" + ); + assert!( + !stderr.contains("required: id"), + "the literal hyphen was parsed as an option" + ); + + fs::remove_dir_all(root).expect("remove CLI smoke corpus"); +} diff --git a/rust/rac-engine/src/cli.rs b/rust/rac-engine/src/cli.rs index 33a6af17..3ed25f47 100644 --- a/rust/rac-engine/src/cli.rs +++ b/rust/rac-engine/src/cli.rs @@ -1575,7 +1575,7 @@ fn run_resolve(rest: &[&String]) -> u8 { for arg in rest { let arg = arg.as_str(); - if positional_only || !arg.starts_with('-') { + if positional_only || arg == "-" || !arg.starts_with('-') { if id.is_none() { id = Some(arg.to_string()); } else if directory.is_none() { @@ -1628,7 +1628,7 @@ fn run_find(rest: &[&String]) -> u8 { let mut i = 0; while i < rest.len() { let arg = rest[i].as_str(); - if positional_only || !arg.starts_with('-') { + if positional_only || arg == "-" || !arg.starts_with('-') { if query.is_none() { query = Some(arg.to_string()); } else if directory.is_none() { diff --git a/rust/rac-engine/src/output.rs b/rust/rac-engine/src/output.rs index 0de2aa32..f9ac694e 100644 --- a/rust/rac-engine/src/output.rs +++ b/rust/rac-engine/src/output.rs @@ -3210,7 +3210,7 @@ pub fn render_find_human(result: &SearchResult, explain: bool) -> String { /// populated log whose events all lack a string `ts` prints `None`. pub fn render_mcp_stats_human(summary: &crate::telemetry::TelemetrySummary) -> String { let mut lines = vec![ - bold("Guide Telemetry"), + bold("MCP Usage (compatibility read-back)"), "===============".to_string(), String::new(), format!("Log: {}", summary.path), @@ -3218,7 +3218,7 @@ pub fn render_mcp_stats_human(summary: &crate::telemetry::TelemetrySummary) -> S if summary.event_count == 0 { lines.push(String::new()); lines.push("No telemetry recorded.".to_string()); - lines.push("Telemetry is off by default; enable it with: decided mcp --telemetry".to_string()); + lines.push("The native decided-mcp server does not record usage telemetry; this command only reads an existing compatibility log.".to_string()); if summary.skipped_lines != 0 { lines.push(String::new()); lines.push(format!("Skipped Unreadable Lines: {}", summary.skipped_lines));