From 2f53a6021eadebac4056e4c684c4382166d89242 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 16 Aug 2026 00:35:42 +0300 Subject: [PATCH 01/11] =?UTF-8?q?spec:=20describe=5Ftool=20check=20mode=20?= =?UTF-8?q?=E2=80=94=20in-band=20preflight=20(099)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #969 Phase-2 of the required-tools preflight roadmap: the reporter-endorsed in-band surface (issue #969 comment 2026-08-13) — an optional `check` boolean on the existing describe_tool built-in rather than a new top-level MCP tool. ## Changes - specs/099-describe-check-mode/spec.md: 4 user stories, FR-001..FR-018 (+FR-009a, FR-012a), 13 edge cases, SC-001..SC-007, non-goals, priced alternatives. - specs/099-describe-check-mode/checklists/requirements.md: quality checklist incl. feature-specific gates. Locked contracts: - check:true = verdict-only results, batch cap 5 -> 50, spec-098 evaluator verbatim (15-code enum, precedence, tier disclosure). - Optional `filters` (three annotation filters) and `expect_hashes` pins, mirroring the REST body. - Plain mode byte-identical to today except ONE enumerated delta: out-of-scope ids report not_found instead of the existence-leaking `invisible` code (spec-085 contract amended). - Token budget: measured 135 -> 284 tokens (tiktoken cl100k_base); new explicit ceiling 300, pinned by the budget test AND a deliberately regenerated tools/list golden (documented exception to spec-098 FR-015's no-delta rule). - Disclosure tier is fail-closed and MUST NOT be inferred from AuthContext.IsAdmin(): the MCP middleware injects an admin context for unauthenticated /mcp requests by design. - Every new reason-surface cell gets sabotage-matrix rows on the existing 098 matrix infrastructure. ## Testing - Token measurements taken on this branch's base with the pinned encoder over the marshalled tool definition (scratch test, removed). - Cross-model review (opencode / gpt-5.6-sol): 8 P1 / 6 P2 / 1 P3, verdict REQUEST CHANGES; all findings folded in, one deliberately scoped out to spec 098. --- .../checklists/requirements.md | 49 +++++ specs/099-describe-check-mode/spec.md | 190 ++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 specs/099-describe-check-mode/checklists/requirements.md create mode 100644 specs/099-describe-check-mode/spec.md diff --git a/specs/099-describe-check-mode/checklists/requirements.md b/specs/099-describe-check-mode/checklists/requirements.md new file mode 100644 index 00000000..e0b44b10 --- /dev/null +++ b/specs/099-describe-check-mode/checklists/requirements.md @@ -0,0 +1,49 @@ +# Specification Quality Checklist: `describe_tool` Check Mode — In-Band Preflight + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-08-16 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details beyond what locked decisions require (parameter names, enum reuse, batch caps and disclosure tiers are product contract, because agents and consumers branch on them; internal seam names appear only in Assumptions and FR-003/FR-010 as reuse scope) +- [x] Focused on user value and business needs (an agent can gate its own plan in-band instead of failing one tool call at a time) +- [x] Written for non-technical stakeholders (Stories 1–4 readable standalone) +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain and no requirement is reopened elsewhere — the three input-locked choices are DECIDED in the FRs, with their measured price and rejected alternative recorded under "Priced Alternatives" (non-blocking, owner-override only) +- [x] Requirements are testable and unambiguous (each FR names an observable behavior; FR-015's budget is a measured number, not an adjective) +- [x] Success criteria are measurable (SC-001…SC-007) +- [x] Success criteria are technology-agnostic where they can be (SC-003/SC-005 quote token counts because token cost IS the user-facing currency of this product) +- [x] All acceptance scenarios are defined (Stories 1–4) +- [x] Edge cases are identified (explicit `check:false`, `check:null`/non-boolean, filters/pins without check, orphan and blank pin values, dedup + id normalization, malformed ids, empty batch with mode-accurate wording, degraded runtime, unwritable activity record, unauthenticated `/mcp`, profile-pinned and `set_profile` sessions, batch cost, no side effects) +- [x] Scope is clearly bounded (Non-Goals enumerates every deferred phase and explicitly closes the enum) +- [x] Dependencies and assumptions identified (spec 098 shipped; reporter's 2026-08-13 comment; measurement provenance; no shipped consumer of `invisible`) + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows (in-band gating, plain-mode non-regression, cross-surface parity, absent-surface honesty) +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into user-facing sections + +## Feature-Specific Gates + +- [x] Every new reason-surface cell has a sabotage-matrix obligation (FR-016) and the reflection gate is extended, not duplicated +- [x] The token-budget change is measured, priced against alternatives, given an explicit new ceiling, and pinned by BOTH a ceiling test and a golden snapshot (FR-015) +- [x] The golden-snapshot update is called out as a deliberate exception to spec 098 FR-015, with the reason it does not weaken that rule (FR-014/FR-015) +- [x] The plain-mode behavior change (`invisible` → `not_found`) is stated explicitly, scoped to exactly one field, justified as a leak fix, and enumerated in the byte-identity test rather than blanket-allowed (FR-011, SC-002) +- [x] Disclosure-tier resolution is fail-closed AND explicitly forbids inferring operator from the admin auth context, because the MCP middleware injects one for unauthenticated requests by design (FR-009, SC-007) +- [x] Evaluation scope is defined against all three existing narrowing inputs (agent-token servers, token profile pin, session active profile) and reuses the 098 composition rather than re-deriving one (FR-009a) +- [x] Cross-surface payload divergences are named field-by-field, not hand-waved: no `hash` in band, `checked_at` excluded from parity (FR-004, FR-017) +- [x] Caller-visible correlation is closed: the response returns the same `request_id` the activity record carries, so SC-006 is achievable from inside the session (FR-004, FR-013) +- [x] No new reason code is introduced; the 15-code enum stays owned by spec 098 (FR-003, Non-Goals) +- [x] One inherited defect found during review is scheduled rather than inherited silently: the 098 `mid_indexing` matrix note contradicts 098 FR-005 and is corrected while the matrix is extended (FR-016) + +## Notes + +- Reason codes, precedence, disclosure tiers and the pin format are inherited verbatim from spec 098 — this spec is a new surface over an existing contract, and any change to that contract belongs upstream in 098. +- Cross-model review of this spec (opencode / GPT Sol) ran on 2026-08-16: 8 P1 / 6 P2 / 1 P3, verdict REQUEST CHANGES. All 15 findings were judged genuine and folded in (FR-004 `request_id` + no-hash-echo + `checked_at` semantics, FR-006 normalization, FR-008 pin validation, FR-009 fail-closed tier + `AuthTypeUser`, FR-009a scope composition, FR-011 compatibility-break framing, FR-012a strict validation, FR-013 count definition, FR-016 inherited matrix fix, FR-017 excluded fields, SC-005 falsifiability, Story 2 wording, and the Open-Questions→Priced-Alternatives reframe). One finding was scoped OUT deliberately: tightening the REST `disclosureTier` mapping for server-edition `AuthTypeUser` belongs to spec 098 and is flagged there, not fixed here. +- The three Priced Alternatives are recorded decisions with measured costs, not spec gaps; they do not gate planning. diff --git a/specs/099-describe-check-mode/spec.md b/specs/099-describe-check-mode/spec.md new file mode 100644 index 00000000..d0cab0e7 --- /dev/null +++ b/specs/099-describe-check-mode/spec.md @@ -0,0 +1,190 @@ +# Feature Specification: `describe_tool` Check Mode — In-Band Preflight + +**Feature Branch**: `099-describe-check-mode` +**Created**: 2026-08-16 +**Status**: Draft +**Input**: User description: "Phase-2 in-band preflight surface for issue #969 item 3: an optional `check` mode on the existing `describe_tool` built-in that returns verdict-only availability results from the spec-098 evaluator, so an agent can gate its own workflow without leaving the MCP session. Reporter-endorsed shape (issue #969 comment, 2026-08-13): 'a check mode on an existing built-in rather than a new top-level MCP tool', same evaluator, same reason codes." + +**Related**: #969 (item 3, Phase 2). Item 1 (`filter_diagnostics`) shipped in spec 094 / v0.55.0. Phase 1 (shared evaluator + `POST /api/v1/preflight` + `mcpproxy tools preflight`) is spec 098. This spec adds the in-band MCP surface listed as the first non-goal of 098, and closes one pre-existing describe_tool differential the 098 evaluator exposed. + +**Base**: branched from `098-tools-preflight` (merge commit `b8e53210`). If 098 lands on `main` before this feature starts, rebase onto `main` — nothing here depends on 098 being an unmerged branch, only on its shipped evaluator (`internal/preflight`), its 15-code enum, and its committed sabotage matrix. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Agent gates its own multi-step plan before spending a turn on it (Priority: P1) + +An agent has drawn up a plan that will call eight tools across three upstream servers. Before executing step 1 — and before burning turns discovering the failure one tool call at a time — it sends one `describe_tool` call with `check: true` and the eight ids. It gets back one verdict per id: `ready`, or exactly one reason code with a retryable flag and an action. If everything is ready it proceeds. If `slack:post_message` reports `server_quarantined`, it stops and tells the user what to approve, in the same turn, instead of failing eight steps later with an opaque error. + +**Why this priority**: This is the reporter's endorsed Phase-2 shape and the only preflight path available to a running agent — spec 098's REST/CLI surfaces require a harness outside the session. Without it, an agent inside an MCP session has no way to distinguish "this tool is quarantined" from "my search query was bad", which is exactly the ambiguity issue #969 opened about. + +**Independent Test**: Against a fixture proxy with one quarantined server, one disabled tool and one misspelled id, issue a single `describe_tool` call with `check: true` over MCP and assert one verdict per id with the exact reason codes — no REST endpoint, no CLI, no other feature involved. + +**Acceptance Scenarios**: + +1. **Given** all requested tools are indexed, approved, on enabled healthy in-scope servers, **When** the agent calls `describe_tool` with `check: true`, **Then** the response carries `verdict: "ready"` and one `status: "ready"` result per id, with no schemas and no upstream I/O. +2. **Given** one requested tool sits on a quarantined server, **When** the check runs, **Then** that id reports `reason: "server_quarantined"`, `retryable: false`, `action: "approve"`, and the set-level `verdict` is `blocked`. +3. **Given** one requested server is still connecting or indexing, **When** the check runs, **Then** its tools report `server_initializing` with `retryable: true` and the set verdict is `degraded_retryable`, so the agent knows to wait rather than to escalate to the user. +4. **Given** a misspelled id, **When** the check runs, **Then** that id reports `not_found` with a scope-filtered `did_you_mean` list, and the other ids still report their own verdicts (one bad id never fails the batch). +5. **Given** 50 ids, **When** the check runs, **Then** all 50 are evaluated in one call; **Given** 51 ids, **Then** the call fails with a single error naming the 50-id cap and evaluates nothing. + +--- + +### User Story 2 - The same session's plain `describe_tool` keeps behaving exactly as before (Priority: P1) + +An agent (or an MCP client, or a stored prompt) that never sends `check` sees the same 1–5 id batch cap, the same `definitions` + `errors` payload, the same wording, and the same per-id error codes — with **one deliberate exception**: an out-of-scope id now reports `not_found` where it used to report `invisible` (FR-011). That single change is a disclosure fix, not a refactor, and it is the only thing an existing integration could notice. + +**Why this priority**: `describe_tool` is on the hot path of the compact router (spec 085) — it is how agents recover a full schema after a lossy signature. A regression here degrades every compact-mode session. Byte-identity is the spec-094 discipline this repo applies to every MCP-surface change and is the cheapest possible guarantee to test. + +**Independent Test**: Replay the spec-085 describe_tool fixture corpus without `check` against the feature branch and diff every response against the pre-099 release byte-for-byte; exactly one enumerated delta is permitted (FR-011). + +**Acceptance Scenarios**: + +1. **Given** any request that omits `check` (or sends `check: false`), **When** `describe_tool` runs, **Then** the response is byte-identical to the pre-099 release, with the single enumerated exception in FR-011, which ships with a release note (FR-011). +2. **Given** a request that omits `check` with 6 ids, **When** it runs, **Then** it fails with the existing over-cap error text verbatim — the 50-id check-mode cap does not leak into plain mode. +3. **Given** a request that omits `check` with duplicate ids, **When** it runs, **Then** duplicates are still rendered once per occurrence, as today (dedup is check-mode-only, FR-006). + +--- + +### User Story 3 - The in-band verdict cannot disagree with the out-of-band one (Priority: P2) + +A platform engineer debugging a failed nightly run compares what the agent saw in-band (`describe_tool` check mode, from the activity log) with what `mcpproxy tools preflight` reports from the shell. For the same ids, the same proxy state and the same disclosure tier, the two name the same reason. There is one evaluator and therefore one story. + +**Why this priority**: A second, subtly different availability answer would be worse than no in-band answer at all — it would make every incident report ambiguous about which surface to trust. It is also the property that keeps the reason taxonomy a single maintained thing rather than two drifting ones. + +**Independent Test**: A parity test that drives the same fixture states through `preflight.Evaluate` via the REST handler and via the MCP check handler at both disclosure tiers and asserts equal `{status, reason, retryable, action}` tuples per id. + +**Acceptance Scenarios**: + +1. **Given** any sabotage-matrix state and a fixed disclosure tier, **When** the same id is checked in-band and over REST, **Then** `status`, `reason`, `retryable` and `action` are equal. +2. **Given** an agent-token MCP session whose scope excludes a server, **When** the agent checks a tool on that server, **Then** it reports plain `not_found` — byte-indistinguishable from an unknown id — matching the REST agent-token tier exactly (FR-009). +3. **Given** a check-mode run, **When** the operator inspects the activity log, **Then** a preflight activity record exists for it carrying the same reason codes the agent received (FR-013). + +--- + +### User Story 4 - Surfaces without `describe_tool` get an honest interim answer (Priority: P3) + +A user running the proxy in `code_execution` routing mode, or in direct mode, has no `describe_tool` at all (spec 085 v1 decision, unchanged here). The documentation tells them plainly where the in-band check does and does not exist, and what to use instead, rather than letting them discover the gap by trying. + +**Why this priority**: Guard-rail/documentation story. Getting it wrong costs a support round-trip, not a broken product — but leaving it unwritten is exactly how the #969 confusion started. + +**Independent Test**: Snapshot `tools/list` for `code_execution` mode and assert it is byte-identical to the pre-099 release; confirm the docs name the interim path for that mode. + +**Acceptance Scenarios**: + +1. **Given** `code_execution` or direct routing mode, **When** the client lists tools, **Then** `describe_tool` is still absent and the mode's `tools/list` payload is byte-identical to the pre-099 release. +2. **Given** a harness driving a `code_execution`-mode session, **When** it needs a preflight, **Then** the documented path is `POST /api/v1/preflight` from the harness (spec 098), and the docs state that registering check mode on those surfaces is a later phase. + +--- + +### Edge Cases + +- **`check: false` explicitly**: identical to omitting it — plain mode, byte-identical response. Only `check: true` switches modes. +- **`filters` or `expect_hashes` sent without `check: true`**: request error (whole call fails, nothing evaluated). Silently ignoring them would let an agent believe a safety filter or a pin was applied when it was not. Plain-mode byte-identity is unaffected: requests that do not send these fields are untouched. +- **`expect_hashes` key that is not in `tool_ids`, or a blank/unparseable pin value**: request error naming the offending key. A typo'd pin key or an empty pin that was silently dropped would report `ready` for an unpinned tool — the exact failure pinning exists to prevent (FR-008). +- **`check: null` or a non-boolean `check`**: request error, not "absent" — an agent that sent the field meant to use the mode, and coercing it to plain mode would return schemas to a caller expecting verdicts (FR-012a). +- **Duplicate ids under `check: true`**: deduplicated; one result per unique id, in first-occurrence order (mirrors spec 098 FR-008). Plain mode keeps its existing duplicate-in/duplicate-out behavior. +- **Malformed id (no `server:tool` separator) under `check: true`**: per-id `not_found` with a format hint in `detail` — one bad entry never masks the rest (spec 098 edge case). Plain mode keeps its existing per-id `not_found` + format-hint remediation. +- **Empty `tool_ids` under `check: true`**: request error with check-mode-accurate wording (the existing text says "1-5 tool ids", which is wrong for a 50-id mode). An empty check is a caller bug, matching spec 098's 400 on an empty list. +- **Runtime degraded** (no storage / index / connection snapshot): the check fails with an MCP tool error carrying the evaluator's refusal, never reduced-fidelity verdicts — the in-band mirror of spec 098's 503 (FR-012). +- **Activity record cannot be written**: the check fails with an MCP tool error rather than answering — the in-band mirror of spec 098's "unauditable preflight is not answered" rule (FR-013). +- **Unauthenticated `/mcp` session**: `/mcp` is unauthenticated by default (`require_mcp_auth: false`) and the middleware hands such requests a full **admin** auth context for back-compat. Check mode MUST therefore resolve the disclosure tier from a credential-presented / trusted-transport marker, never from `IsAdmin()`, and MUST fall back to the agent-token tier (FR-009). Anything else hands scope diagnostics to an unauthenticated local caller. +- **Profile-pinned session** (`/mcp/p/`) and `set_profile`: the session's resolved active profile, intersected with any agent-token scope and profile pin, is the evaluation scope (FR-009a). Check mode takes no `profile` parameter — an agent cannot widen or re-point its own scope by asking. +- **Very large batches and cost**: 50 verdict-only results are bounded (~30–60 tokens each); the cap exists so the response cannot become a discovery-bypass dump, the same reasoning that set the plain-mode cap at 5. +- **Never triggers side effects**: like every other preflight surface, check mode performs zero upstream I/O and mutates no runtime state; the durable activity record is the one permitted local write. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001 (Check parameter)**: `describe_tool` MUST accept an optional boolean parameter `check`. `check: true` selects verdict-only check mode; `check: false` or an absent `check` selects today's definition mode. No other parameter changes the mode. +- **FR-002 (Registration surfaces)**: The parameter MUST appear on exactly the surfaces where `describe_tool` is already registered — the default `/mcp` server and the retrieve_tools routing mode (`/mcp/call`, `/mcp/p/`). It MUST NOT be registered in `code_execution` or direct mode, whose `tools/list` payloads stay byte-identical (FR-014). Documentation MUST state the interim path for those modes: `POST /api/v1/preflight` from the harness (spec 098); registering the built-in there is a later phase. +- **FR-003 (Same evaluator)**: Check mode MUST call the spec-098 evaluator (`preflight.Evaluate`) verbatim through the same glue seam the REST surface uses — same 15-code closed enum, same precedence chain, same state sources, same `ready | unavailable` status vocabulary. Check mode MUST NOT introduce a reason code, re-order precedence, or re-derive a verdict from any other gate. Adding a code remains a spec-098 change with all its obligations (FR-016 there, FR-015 here). +- **FR-004 (Verdict-only payload)**: A check-mode response MUST carry the set-level `verdict` (`ready | degraded_retryable | blocked | unknown_ids`), a `checked_at` timestamp, a `request_id`, and one result per unique requested id carrying `id` and `status`; `unavailable` results additionally carry `reason`, `retryable`, `action` (omitted when the reason has none), `detail`, `remediation`, and `did_you_mean` (≤3 entries, on `not_found` only, computed over the caller-visible scope). It MUST NOT carry `inputSchema`, long descriptions, `call_with`, or any other definition field — that omission is what makes the higher cap safe. The check-mode payload does NOT carry the plain-mode `definitions`/`errors` keys; callers branch on the presence of `verdict`. + - `checked_at` is the UTC instant the answering evaluation completed, serialized exactly as the REST surface serializes it. Parity assertions (FR-017) MUST exclude it — it is a timestamp, not a verdict. + - `request_id` MUST be the same correlation id written to the activity record (FR-013), so the agent can hand a human the id that finds the run — SC-006 is otherwise unachievable from inside the session. Today's `describe_tool` handler mints a correlation id internally and never returns it; check mode MUST surface it. + - **No hash is ever returned**, at any tier. This is a deliberate divergence from the spec-098 REST payload, which discloses a ready tool's current hash at the operator tier: in band there is no operator use case for reading hashes back (pins are written by a harness, not by an agent), the field costs response tokens, and never emitting it removes a disclosure path from a surface that is unauthenticated by default (FR-009). Current hashes stay discoverable exactly where spec 098 put them. +- **FR-005 (Batch cap)**: Check mode MUST accept up to **50** ids per call. Over the cap, the whole call fails with a single error naming the limit — no partial evaluation (mirrors the plain-mode anti-bulk-loophole rule). Plain mode's cap stays at 5, with its existing error text unchanged. +- **FR-006 (Dedup, normalization and ordering)**: Under `check: true`, ids MUST be normalized exactly as the spec-098 REST surface normalizes them (surrounding whitespace trimmed) **before** dedup and before pin-key matching, then deduplicated, with results returned one per unique normalized id in first-occurrence order (spec 098 FR-008). The 50-id cap applies to the **raw** array, before trimming and dedup. Each result's `id` field MUST echo the normalized id, so a caller can join results to pin keys deterministically. Plain mode's normalization and duplicate handling are unchanged. +- **FR-007 (Filters)**: Check mode MUST accept an optional `filters` object carrying the three spec-094 annotation filters — `read_only_only`, `exclude_destructive`, `exclude_open_world` — with the same semantics and the same fixed evaluation order the discovery filters and the REST `policy` object use, producing `missing_annotation` / `policy_filtered` at their precedence slot. The nesting mirrors the REST body's `policy` object; the MCP field is named `filters` because that is the vocabulary `retrieve_tools` already teaches agents. This name divergence between the two surfaces MUST be stated in the docs and in the OpenAPI/contract notes. +- **FR-008 (Hash pins)**: Check mode MUST accept an optional `expect_hashes` map of `id → pin`, using the spec-098 pin format (schema-version-embedded), and MUST report `hash_mismatch` at its precedence slot for any id whose current stored hash diverges. Pin values MUST be validated: an empty/blank value, or a value that does not parse as a spec-098 pin, is a **request error** (the whole call fails, nothing is evaluated) — never a silently dropped pin, because a dropped pin turns into a `ready` verdict for a tool the caller asked to have pinned, which is the exact failure pinning exists to prevent. A pin key absent from `tool_ids` (after normalization, FR-006) is likewise a request error naming the orphan key. Pins are inputs only — see FR-004 for the no-hash-echo rule. +- **FR-009 (Disclosure tier — fail-closed, and NOT inferable from the admin auth context)**: The disclosure tier follows the session's auth context, with one hard constraint the implementation MUST honor: **`AuthContext.IsAdmin()` alone MUST NOT grant the operator tier on this surface.** The MCP auth middleware today injects a full admin context for *unauthenticated* requests when `require_mcp_auth` is false (its documented back-compat behavior), so an admin context on `/mcp` does not prove a credential was presented. Concretely: + - **Agent-token tier** (default, fail-closed) for: agent-token sessions, and any session that did not positively prove a credential or a trusted transport — including every unauthenticated `/mcp` session under the default `require_mcp_auth: false`, and every server-edition non-admin OAuth user session (`AuthTypeUser`). + - **Operator tier** only when the session positively proves one of: a presented credential that authenticated as admin (global API key, or admin OAuth in the server edition), or a trusted local transport (Unix socket / named pipe / tray connection, which carry OS-level authentication). Because the current middleware discards the difference between "credential presented" and "back-compat admin", the implementation MUST carry a credential-presented / trusted-transport marker forward from the middleware; if that marker is unavailable the tier MUST resolve to agent-token. + - Note the deliberate divergence from spec 098's REST mapping, which treats every non-agent context as operator (safe there, because that endpoint always requires a key) and therefore also gives a server-edition `AuthTypeUser` operator disclosure. Whether that REST mapping should also be tightened is a spec-098 follow-up, explicitly out of scope here. + - At the agent-token tier an out-of-scope or unconfigured server's entire result MUST be byte-indistinguishable from an ordinary `not_found` (no `server_not_in_scope`, no `server_not_configured`, no cross-scope `did_you_mean`), exactly as spec 098 FR-013 requires. +- **FR-009a (Evaluation scope)**: Check mode takes no `profile` parameter; its scope is the session's, composed exactly as the spec-098 glue composes a REST request's scope — agent-token `allowed_servers` ∩ agent-token profile pin ∩ the session's active profile — where "the session's active profile" is whatever the existing session profile resolver reports (path-pinned `/mcp/p/`, or a `set_profile` selection). The composition MUST reuse that glue rather than re-deriving a scope, so a check can never see a tool the same session's `retrieve_tools` cannot. A pinned profile that no longer exists in config inherits spec 098's rule verbatim: it narrows to a deny-all scope (every id reports `not_found` at the agent-token tier), never a widened one. +- **FR-010 (No upstream I/O, no mutation)**: A check-mode call MUST perform zero upstream server calls and mutate no runtime state (no connects, reconnects, re-index, config or approval writes), asserted by the spec-098 instrumented-transport test extended to this surface. The durable activity write (FR-013) is the one permitted local write. +- **FR-011 (Plain-mode byte-identity, with one enumerated exception)**: When `check` is absent or false, the response MUST be byte-identical to the pre-099 release **except** for out-of-scope ids: the per-id error code `invisible` is retired and out-of-scope ids report `not_found` instead, with the remediation text unchanged (it is already the shared not-found remediation, so only the `error` value moves). This closes the pre-existing differential where a distinct `invisible` code confirmed that an out-of-scope tool exists — the very leak the spec-085 contract intended to prevent and the 098 evaluator already prevents. After this change the plain-mode per-id error vocabulary is `not_found | quarantined | pending_approval | changed | disabled`; the spec-085 `describe_tool` contract MUST be amended accordingly. This delta MUST be the ONLY permitted difference and MUST be enumerated in the byte-identity test rather than blanket-allowed. It MUST be treated as a **compatibility break** — a repo search finds no consumer of `invisible`, but a repo search cannot see external ones — and therefore MUST ship with a release note and a versioned amendment to the spec-085 contract, not merely a code change. +- **FR-012 (Failure surfaces)**: Conditions the REST surface answers with a non-200 MUST map to an MCP tool error (never a fabricated verdict): request errors (over-cap, filters/pins without `check`, orphan or unparseable pin, empty ids, malformed arguments per FR-012a) mirror the 400 class; runtime-unavailable mirrors the 503 class. The error text MUST name the condition and, for the runtime class, say that no verdict could be computed. Check-mode error text MUST NOT reuse plain mode's wording where the numbers differ — in particular the empty-ids error must not tell a check-mode caller to supply "1-5 tool ids". +- **FR-012a (Strict argument validation)**: Under `check: true` the handler MUST reject, as request errors, arguments it cannot honor rather than coercing them: a non-boolean `check` (`null`, string, number); a non-object `filters`; an unknown member of `filters`; a non-boolean filter value; a non-object `expect_hashes`; a non-string pin value. `check: null` is treated as a malformed argument, not as "absent" — an agent that sent the field meant to use the mode. Plain mode's existing tolerance is unchanged, so this adds no back-compat risk: every one of these shapes is unreachable in a pre-099 request. +- **FR-013 (Activity record)**: Every executed check-mode call MUST write the spec-098 preflight activity record **synchronously and durably before the result is returned**, carrying the request/correlation id (the same id returned to the caller, FR-004), the requested-id count **defined exactly as the REST record defines it** (unique ids after dedup — the raw count stays recoverable from the recorded arguments), the set verdict, per-tool reason codes and a surface marker distinguishing it from the REST surface. A failed write fails the call (FR-012), mirroring spec 098 FR-014's "a preflight nobody can audit is not answered". Check-mode calls MUST NOT additionally emit the droppable `internal_tool_call` activity record that plain mode emits — one run, one record, under the kind whose durability guarantee it needs. Plain-mode activity behavior is unchanged. Records MUST NOT leak tool names to any telemetry surface. +- **FR-014 (MCP surface delta is exactly one tool on two surfaces)**: The `tools/list` payload MUST change in exactly one place — the `describe_tool` entry on the default server and the retrieve_tools mode — and MUST stay byte-identical for `code_execution` mode and for every other tool on every surface. The spec-098 no-delta snapshot test MUST be converted to an **enumerated-delta** test (the spec-085/094 pattern) rather than blanket-refreshed, and the goldens for the two changed surfaces MUST be regenerated deliberately with the delta named in the test. +- **FR-015 (Token budget, deliberately raised)**: The `describe_tool` definition's ≤150-token budget WILL be exceeded and MUST be replaced by a new explicit budget. Measured with the pinned encoder (tiktoken `cl100k_base`, the spec-083 profiler's encoder) over the marshalled tool definition on this branch's base: + + | Definition | Tokens | Δ vs. today | + |---|---|---| + | Today (`tool_ids` only) | 135 | — | + | + `check` only | 189 | +54 | + | + `check` + `filters` (declared sub-properties) + `expect_hashes` | **284** | **+149** | + | + `check` + three flattened filter booleans + `expect_hashes` | 289 | +154 | + + The locked design is the 284-token shape. The new budget is **≤300 tokens** (≈5% headroom — deliberately tight, so the next prose addition has to argue for itself). The cost is once per session on two surfaces, not per call, and is ~1 upstream tool schema's worth of context (the repo's own estimator uses ~150 tokens/schema). The budget MUST be enforced by the existing tokenized budget test with the new constant, AND the exact definition MUST be pinned by the `tools/list` golden snapshot (FR-014) so a prose edit shows up as a reviewable diff, not a silent drift under the ceiling. **The golden update is a deliberate, documented exception to the spec-098 FR-015 no-delta rule** — 098 forbade any MCP-surface movement because it shipped no MCP feature; 099 ships one, so its goldens move once, by intent, with the delta enumerated. +- **FR-016 (Sabotage matrix extension)**: The committed spec-098 sabotage matrix MUST gain a row for every reason-surface cell this feature creates, reusing the existing matrix infrastructure (scenario-keyed JSON + reflection gate) rather than a parallel one. Rows MUST record the surface (`mcp-check`, `mcp-plain`) and the disclosure tier, and MUST cover at minimum: every one of the 15 enum codes on the `mcp-check` surface at the tier where it is observable; out-of-scope at the agent-token tier (⇒ `not_found`) and at the operator tier (⇒ `server_not_in_scope`); out-of-scope on the `mcp-plain` surface (⇒ `not_found`, FR-011); `hash_mismatch` via `expect_hashes`; `missing_annotation` and `policy_filtered` via each of the three `filters`; the 50/51-id cap boundary; an orphan `expect_hashes` key; `filters` sent without `check`. The reflection gate MUST be extended so a code with no `mcp-check` row fails CI, exactly as a code with no REST row does today. While extending it, one inherited defect MUST be corrected: the existing `mid_indexing` row's note claims a never-indexed server on a connecting upstream would report `not_found` ("existence outranks connection state"), which contradicts spec 098 FR-005 and the shipped evaluator — on a non-Ready server the connection-state verdict wins because existence is unknowable. Correct the note and add an explicit never-indexed-while-connecting row, so the matrix that 099's parity claims are measured against is itself right. +- **FR-017 (Parity)**: An automated parity test MUST assert that for identical ids, identical proxy state and an identical disclosure tier, the in-band and REST surfaces return equal `{status, reason, retryable, action}` per id. Fields the two payloads deliberately differ on (`checked_at`, and `hash`, which REST may disclose at the operator tier and check mode never does — FR-004) are excluded from the comparison by name, not by a loose matcher. A divergence in the compared fields is a defect in the glue, never a documented difference. +- **FR-018 (Docs)**: Documentation MUST be updated: the preflight feature page (in-band section: when an agent should check vs. describe, worked agent-loop example, the 50-id cap rationale), the spec-085 `describe_tool` contract (new parameters, retired `invisible` code, new token budget), the REST API reference note on `describe_tool`, and the `filters`-vs-`policy` naming divergence (FR-007). The docs MUST state plainly that check mode is absent from `code_execution` and direct mode and what to use instead (FR-002). The release notes MUST carry the `invisible` → `not_found` compatibility break (FR-011) under a heading a consumer scanning for breaking changes will find. + +### Key Entities + +- **Check request**: the existing `tool_ids` array (≤50 under check mode) plus `check`, optional `filters` (three annotation booleans), optional `expect_hashes` (id → pin). No profile, no wait budget — scope is the session's, and waiting is a non-goal. +- **Check result**: per-id `{id, status, reason?, retryable?, action?, detail?, remediation?, did_you_mean?}` — the spec-098 result projected onto the MCP payload, minus operator-only fields at the agent-token tier. +- **Set verdict**: the spec-098 worst-class aggregate, carried in-band so an agent can branch on one field instead of scanning results. +- **Preflight activity record (in-band)**: the spec-098 record with a surface marker identifying the MCP check surface. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Every one of the 15 reason codes has at least one `mcp-check` sabotage-matrix row asserting its exact `{reason, retryable, action}`, enforced by the extended reflection gate — 100% of cells, in CI. +- **SC-002**: Plain-mode (`check` absent) responses are byte-identical to the pre-099 release across the full describe_tool fixture corpus, with exactly one enumerated delta (out-of-scope `invisible` → `not_found`), asserted by an enumerated-delta test that fails on any second difference. +- **SC-003**: The `tools/list` delta is exactly the `describe_tool` entry on the default and retrieve_tools surfaces; the `code_execution` golden is unchanged; the marshalled `describe_tool` definition measures ≤300 tokens under the pinned encoder. +- **SC-004**: In-band and REST verdicts agree on `{status, reason, retryable, action}` for 100% of sabotage-matrix states at a fixed tier (parity test). +- **SC-005**: One check-mode call accepts 50 raw ids and returns 50 verdicts containing no definition field (`inputSchema`, long description, `call_with`) — asserted on the payload, not by comparison with a narrative baseline. +- **SC-006**: A check-mode call performs zero upstream calls (hard assertion via the instrumented transport, not a threshold) and every executed call is retrievable from the activity log by request id in the same session. +- **SC-007**: At the agent-token tier, an out-of-scope id's complete result is byte-identical to an unknown id's result across every field, asserted directly (not by inspection). + +## Non-Goals (Phase 2) + +- **`wait` / long-polling in band.** No `wait_ms` equivalent: an agent that gets `server_initializing` (retryable) should retry on its own schedule, not hold an MCP call open. `POST /api/v1/preflight` keeps the wait budget for harnesses. +- **A `profile` parameter.** The session's profile is the scope; letting an agent name a profile in a check would let it probe scopes it cannot use. +- **Registering `describe_tool` (and therefore check mode) in `code_execution` or direct mode.** Later phase; interim path documented (FR-002). +- **`readyz` probe endpoint, SSE readiness events, `tools/list_changed` emission.** +- **Tool lockfile (`mcpproxy tools lock/verify`) and registered automation contracts.** +- **Agent-token-carried required-tools contracts; the MCP extension (`app.mcpproxy/required-tools`).** +- **Per-user verdicts in the server edition (`as_user`); `server_saturated`** — both still reserved in spec 098. +- **Any new reason code.** The enum is closed at 15 and owned by spec 098. + +## Assumptions + +- Spec 098 has shipped (or lands first) with `internal/preflight`, the glue seam, the REST handler, the durable preflight activity record and the committed sabotage matrix. This spec adds a consumer of all five; it does not re-open their contracts. +- The reporter's 2026-08-13 comment on #969 is authoritative for the shape: a check mode on an existing built-in, not a new top-level tool, same evaluator, same reason codes. +- The token measurements in FR-015 were taken on this branch's base with `tiktoken cl100k_base` over the marshalled `mcp.Tool`; final prose may shift them by a few tokens, which the ≤300 ceiling absorbs. If the ceiling is ever hit, the answer is shorter prose or a trimmed parameter set, not a raised ceiling. +- No **in-repo** consumer branches on the plain-mode `invisible` error code — a repo-wide search found it only in the spec-085 contract text (FR-011 amends it). A repo search says nothing about external consumers, which is why FR-011 requires release-note and contract-amendment treatment rather than resting on this assumption. Should an external consumer be discovered, the retirement still stands: it is a leak fix, and the replacement code carries identical remediation text. +- The plain-mode payload is treated as a compatibility contract; the check-mode payload is new and therefore free to differ in shape. + +## Priced Alternatives (decided; recorded for the owner, non-blocking) + +**The requirements above are the decision** — nothing here is an open requirement, and planning does not wait on this section. It exists because three choices were locked by input rather than by analysis, and a spec that hides their price makes them impossible to revisit later. Each entry names the shipped decision, its measured cost, and the one alternative that was rejected. Only the feature owner may override; an override edits the FR it names. + +1. **Parameter set: `check` + `filters` + `expect_hashes` (+149 tokens, FR-015).** Rejected alternative: `check` alone (+54, 189 total), deferring filters and pins to a later phase. Shipping all three was the locked input and keeps the in-band surface a true peer of the REST body; the price is a once-per-session 149 tokens on two surfaces. If the owner would rather buy that back, the trim is FR-007 + FR-008 and the budget in FR-015 drops to ≤200. +2. **`filters` on MCP vs `policy` on REST (FR-007).** Shipped: `filters`, because `retrieve_tools` already teaches agents that word and an agent-facing surface should use the agent's vocabulary. Rejected: renaming for cross-surface symmetry, which would either break the REST contract or teach agents a second word. The divergence is documented in FR-007 rather than hidden. +3. **Admin-authenticated MCP sessions get operator disclosure (FR-009).** Rejected: pinning the entire MCP surface to the agent-token tier, which is simpler to reason about but denies an operator driving `/mcp` over the trusted socket the diagnosis they can already get from the CLI. The fail-closed marker rule in FR-009 is what makes the shipped choice safe; if that marker proves invasive to plumb, the fallback is the rejected alternative — and that fallback is a strictly smaller change, never a leak. + +## Commit Message Conventions *(mandatory)* + +### Issue References +- ✅ **Use**: `Related #969` +- ❌ **Do NOT use**: `Fixes #969`, `Closes #969`, `Resolves #969` + +### Co-Authorship +- ❌ **Do NOT include**: `Co-Authored-By: Claude ` +- ❌ **Do NOT include**: "🤖 Generated with [Claude Code](https://claude.com/claude-code)" From 1197d065744490c13c19228c996b12c44dbc8759 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 16 Aug 2026 06:29:08 +0300 Subject: [PATCH 02/11] =?UTF-8?q?spec(099):=20lock=20decisions=20=E2=80=94?= =?UTF-8?q?=20trim=20expect=5Fhashes,=20agent-token=20tier=20for=20whole?= =?UTF-8?q?=20in-band=20surface,=20budget=20=E2=89=A4250?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #969 Amendments per 2026-08-16 decisions: FR-008 trimmed (in-band pins reserved, REST/CLI stays the pinning surface); FR-009 locked to the agent-token tier for the entire in-band surface (no credential-marker plumbing; IsAdmin never consulted; relaxable only additively); FR-015 budget re-set to ≤250; FR-016 exemptions encoded (hash_mismatch + server_not_in_scope are REST-only); FR-018a added (inherited 098 erratum: AuthTypeUser must not get operator tier). --- specs/099-describe-check-mode/spec.md | 30 +++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/specs/099-describe-check-mode/spec.md b/specs/099-describe-check-mode/spec.md index d0cab0e7..aa52ff79 100644 --- a/specs/099-describe-check-mode/spec.md +++ b/specs/099-describe-check-mode/spec.md @@ -79,15 +79,15 @@ A user running the proxy in `code_execution` routing mode, or in direct mode, ha ### Edge Cases - **`check: false` explicitly**: identical to omitting it — plain mode, byte-identical response. Only `check: true` switches modes. -- **`filters` or `expect_hashes` sent without `check: true`**: request error (whole call fails, nothing evaluated). Silently ignoring them would let an agent believe a safety filter or a pin was applied when it was not. Plain-mode byte-identity is unaffected: requests that do not send these fields are untouched. -- **`expect_hashes` key that is not in `tool_ids`, or a blank/unparseable pin value**: request error naming the offending key. A typo'd pin key or an empty pin that was silently dropped would report `ready` for an unpinned tool — the exact failure pinning exists to prevent (FR-008). +- **`filters` sent without `check: true`**: request error (whole call fails, nothing evaluated). Silently ignoring it would let an agent believe a safety filter was applied when it was not. Plain-mode byte-identity is unaffected: requests that do not send the field are untouched. +- **`expect_hashes` sent at all**: request error naming the field as reserved — in-band hash pins were trimmed from v1 (decision 2026-08-16; see Non-Goals). Rejecting rather than ignoring keeps the field additive later without a silent-drop window. - **`check: null` or a non-boolean `check`**: request error, not "absent" — an agent that sent the field meant to use the mode, and coercing it to plain mode would return schemas to a caller expecting verdicts (FR-012a). - **Duplicate ids under `check: true`**: deduplicated; one result per unique id, in first-occurrence order (mirrors spec 098 FR-008). Plain mode keeps its existing duplicate-in/duplicate-out behavior. - **Malformed id (no `server:tool` separator) under `check: true`**: per-id `not_found` with a format hint in `detail` — one bad entry never masks the rest (spec 098 edge case). Plain mode keeps its existing per-id `not_found` + format-hint remediation. - **Empty `tool_ids` under `check: true`**: request error with check-mode-accurate wording (the existing text says "1-5 tool ids", which is wrong for a 50-id mode). An empty check is a caller bug, matching spec 098's 400 on an empty list. - **Runtime degraded** (no storage / index / connection snapshot): the check fails with an MCP tool error carrying the evaluator's refusal, never reduced-fidelity verdicts — the in-band mirror of spec 098's 503 (FR-012). - **Activity record cannot be written**: the check fails with an MCP tool error rather than answering — the in-band mirror of spec 098's "unauditable preflight is not answered" rule (FR-013). -- **Unauthenticated `/mcp` session**: `/mcp` is unauthenticated by default (`require_mcp_auth: false`) and the middleware hands such requests a full **admin** auth context for back-compat. Check mode MUST therefore resolve the disclosure tier from a credential-presented / trusted-transport marker, never from `IsAdmin()`, and MUST fall back to the agent-token tier (FR-009). Anything else hands scope diagnostics to an unauthenticated local caller. +- **Unauthenticated `/mcp` session**: `/mcp` is unauthenticated by default (`require_mcp_auth: false`) and the middleware hands such requests a full **admin** auth context for back-compat. Moot under the locked FR-009: the whole in-band surface evaluates at the agent-token tier, so no auth context — admin or otherwise — can raise disclosure in-band. - **Profile-pinned session** (`/mcp/p/`) and `set_profile`: the session's resolved active profile, intersected with any agent-token scope and profile pin, is the evaluation scope (FR-009a). Check mode takes no `profile` parameter — an agent cannot widen or re-point its own scope by asking. - **Very large batches and cost**: 50 verdict-only results are bounded (~30–60 tokens each); the cap exists so the response cannot become a discovery-bypass dump, the same reasoning that set the plain-mode cap at 5. - **Never triggers side effects**: like every other preflight surface, check mode performs zero upstream I/O and mutates no runtime state; the durable activity record is the one permitted local write. @@ -106,17 +106,13 @@ A user running the proxy in `code_execution` routing mode, or in direct mode, ha - **FR-005 (Batch cap)**: Check mode MUST accept up to **50** ids per call. Over the cap, the whole call fails with a single error naming the limit — no partial evaluation (mirrors the plain-mode anti-bulk-loophole rule). Plain mode's cap stays at 5, with its existing error text unchanged. - **FR-006 (Dedup, normalization and ordering)**: Under `check: true`, ids MUST be normalized exactly as the spec-098 REST surface normalizes them (surrounding whitespace trimmed) **before** dedup and before pin-key matching, then deduplicated, with results returned one per unique normalized id in first-occurrence order (spec 098 FR-008). The 50-id cap applies to the **raw** array, before trimming and dedup. Each result's `id` field MUST echo the normalized id, so a caller can join results to pin keys deterministically. Plain mode's normalization and duplicate handling are unchanged. - **FR-007 (Filters)**: Check mode MUST accept an optional `filters` object carrying the three spec-094 annotation filters — `read_only_only`, `exclude_destructive`, `exclude_open_world` — with the same semantics and the same fixed evaluation order the discovery filters and the REST `policy` object use, producing `missing_annotation` / `policy_filtered` at their precedence slot. The nesting mirrors the REST body's `policy` object; the MCP field is named `filters` because that is the vocabulary `retrieve_tools` already teaches agents. This name divergence between the two surfaces MUST be stated in the docs and in the OpenAPI/contract notes. -- **FR-008 (Hash pins)**: Check mode MUST accept an optional `expect_hashes` map of `id → pin`, using the spec-098 pin format (schema-version-embedded), and MUST report `hash_mismatch` at its precedence slot for any id whose current stored hash diverges. Pin values MUST be validated: an empty/blank value, or a value that does not parse as a spec-098 pin, is a **request error** (the whole call fails, nothing is evaluated) — never a silently dropped pin, because a dropped pin turns into a `ready` verdict for a tool the caller asked to have pinned, which is the exact failure pinning exists to prevent. A pin key absent from `tool_ids` (after normalization, FR-006) is likewise a request error naming the orphan key. Pins are inputs only — see FR-004 for the no-hash-echo rule. -- **FR-009 (Disclosure tier — fail-closed, and NOT inferable from the admin auth context)**: The disclosure tier follows the session's auth context, with one hard constraint the implementation MUST honor: **`AuthContext.IsAdmin()` alone MUST NOT grant the operator tier on this surface.** The MCP auth middleware today injects a full admin context for *unauthenticated* requests when `require_mcp_auth` is false (its documented back-compat behavior), so an admin context on `/mcp` does not prove a credential was presented. Concretely: - - **Agent-token tier** (default, fail-closed) for: agent-token sessions, and any session that did not positively prove a credential or a trusted transport — including every unauthenticated `/mcp` session under the default `require_mcp_auth: false`, and every server-edition non-admin OAuth user session (`AuthTypeUser`). - - **Operator tier** only when the session positively proves one of: a presented credential that authenticated as admin (global API key, or admin OAuth in the server edition), or a trusted local transport (Unix socket / named pipe / tray connection, which carry OS-level authentication). Because the current middleware discards the difference between "credential presented" and "back-compat admin", the implementation MUST carry a credential-presented / trusted-transport marker forward from the middleware; if that marker is unavailable the tier MUST resolve to agent-token. - - Note the deliberate divergence from spec 098's REST mapping, which treats every non-agent context as operator (safe there, because that endpoint always requires a key) and therefore also gives a server-edition `AuthTypeUser` operator disclosure. Whether that REST mapping should also be tightened is a spec-098 follow-up, explicitly out of scope here. - - At the agent-token tier an out-of-scope or unconfigured server's entire result MUST be byte-indistinguishable from an ordinary `not_found` (no `server_not_in_scope`, no `server_not_configured`, no cross-scope `did_you_mean`), exactly as spec 098 FR-013 requires. +- **FR-008 (Hash pins — TRIMMED from v1, decision 2026-08-16)**: Check mode does NOT accept `expect_hashes` in v1. Rationale: pins are authored and checked by harnesses, which already have the REST surface; the in-band parameter priced ~+50 tokens on every session of two surfaces for a niche mid-session use. The field name is reserved: sending it is a request error naming it as reserved (never silently ignored), so it can be added later without ambiguity. `hash_mismatch` therefore remains observable only via REST/CLI, and FR-016's in-band coverage exempts it. The no-hash-echo rule in FR-004 stands regardless. +- **FR-009 (Disclosure tier — the whole in-band surface is the agent-token tier; locked 2026-08-16)**: Check mode ALWAYS evaluates at the spec-098 agent-token tier, regardless of the session's auth context. There is no operator tier in-band: `server_not_in_scope` and `server_not_configured` collapse to the byte-indistinguishable `not_found` (spec 098 FR-013), no hashes are ever disclosed (FR-004), and `did_you_mean` never crosses the caller-visible scope. `AuthContext.IsAdmin()` MUST NOT be consulted — the MCP middleware injects a full admin context for unauthenticated requests when `require_mcp_auth` is false, so it proves nothing, and pinning the tier removes the need for any new credential-presented marker. Operators wanting the full diagnosis (scope names, hashes) use the REST surface over an authenticated channel, where spec 098 already provides it. This can only ever be relaxed additively (a future authenticated marker could raise the tier; nothing can lower it below this floor). - **FR-009a (Evaluation scope)**: Check mode takes no `profile` parameter; its scope is the session's, composed exactly as the spec-098 glue composes a REST request's scope — agent-token `allowed_servers` ∩ agent-token profile pin ∩ the session's active profile — where "the session's active profile" is whatever the existing session profile resolver reports (path-pinned `/mcp/p/`, or a `set_profile` selection). The composition MUST reuse that glue rather than re-deriving a scope, so a check can never see a tool the same session's `retrieve_tools` cannot. A pinned profile that no longer exists in config inherits spec 098's rule verbatim: it narrows to a deny-all scope (every id reports `not_found` at the agent-token tier), never a widened one. - **FR-010 (No upstream I/O, no mutation)**: A check-mode call MUST perform zero upstream server calls and mutate no runtime state (no connects, reconnects, re-index, config or approval writes), asserted by the spec-098 instrumented-transport test extended to this surface. The durable activity write (FR-013) is the one permitted local write. - **FR-011 (Plain-mode byte-identity, with one enumerated exception)**: When `check` is absent or false, the response MUST be byte-identical to the pre-099 release **except** for out-of-scope ids: the per-id error code `invisible` is retired and out-of-scope ids report `not_found` instead, with the remediation text unchanged (it is already the shared not-found remediation, so only the `error` value moves). This closes the pre-existing differential where a distinct `invisible` code confirmed that an out-of-scope tool exists — the very leak the spec-085 contract intended to prevent and the 098 evaluator already prevents. After this change the plain-mode per-id error vocabulary is `not_found | quarantined | pending_approval | changed | disabled`; the spec-085 `describe_tool` contract MUST be amended accordingly. This delta MUST be the ONLY permitted difference and MUST be enumerated in the byte-identity test rather than blanket-allowed. It MUST be treated as a **compatibility break** — a repo search finds no consumer of `invisible`, but a repo search cannot see external ones — and therefore MUST ship with a release note and a versioned amendment to the spec-085 contract, not merely a code change. - **FR-012 (Failure surfaces)**: Conditions the REST surface answers with a non-200 MUST map to an MCP tool error (never a fabricated verdict): request errors (over-cap, filters/pins without `check`, orphan or unparseable pin, empty ids, malformed arguments per FR-012a) mirror the 400 class; runtime-unavailable mirrors the 503 class. The error text MUST name the condition and, for the runtime class, say that no verdict could be computed. Check-mode error text MUST NOT reuse plain mode's wording where the numbers differ — in particular the empty-ids error must not tell a check-mode caller to supply "1-5 tool ids". -- **FR-012a (Strict argument validation)**: Under `check: true` the handler MUST reject, as request errors, arguments it cannot honor rather than coercing them: a non-boolean `check` (`null`, string, number); a non-object `filters`; an unknown member of `filters`; a non-boolean filter value; a non-object `expect_hashes`; a non-string pin value. `check: null` is treated as a malformed argument, not as "absent" — an agent that sent the field meant to use the mode. Plain mode's existing tolerance is unchanged, so this adds no back-compat risk: every one of these shapes is unreachable in a pre-099 request. +- **FR-012a (Strict argument validation)**: Under `check: true` the handler MUST reject, as request errors, arguments it cannot honor rather than coercing them: a non-boolean `check` (`null`, string, number); a non-object `filters`; an unknown member of `filters`; a non-boolean filter value; the reserved `expect_hashes` field in any shape (FR-008). `check: null` is treated as a malformed argument, not as "absent" — an agent that sent the field meant to use the mode. Plain mode's existing tolerance is unchanged, so this adds no back-compat risk: every one of these shapes is unreachable in a pre-099 request. - **FR-013 (Activity record)**: Every executed check-mode call MUST write the spec-098 preflight activity record **synchronously and durably before the result is returned**, carrying the request/correlation id (the same id returned to the caller, FR-004), the requested-id count **defined exactly as the REST record defines it** (unique ids after dedup — the raw count stays recoverable from the recorded arguments), the set verdict, per-tool reason codes and a surface marker distinguishing it from the REST surface. A failed write fails the call (FR-012), mirroring spec 098 FR-014's "a preflight nobody can audit is not answered". Check-mode calls MUST NOT additionally emit the droppable `internal_tool_call` activity record that plain mode emits — one run, one record, under the kind whose durability guarantee it needs. Plain-mode activity behavior is unchanged. Records MUST NOT leak tool names to any telemetry surface. - **FR-014 (MCP surface delta is exactly one tool on two surfaces)**: The `tools/list` payload MUST change in exactly one place — the `describe_tool` entry on the default server and the retrieve_tools mode — and MUST stay byte-identical for `code_execution` mode and for every other tool on every surface. The spec-098 no-delta snapshot test MUST be converted to an **enumerated-delta** test (the spec-085/094 pattern) rather than blanket-refreshed, and the goldens for the two changed surfaces MUST be regenerated deliberately with the delta named in the test. - **FR-015 (Token budget, deliberately raised)**: The `describe_tool` definition's ≤150-token budget WILL be exceeded and MUST be replaced by a new explicit budget. Measured with the pinned encoder (tiktoken `cl100k_base`, the spec-083 profiler's encoder) over the marshalled tool definition on this branch's base: @@ -128,14 +124,15 @@ A user running the proxy in `code_execution` routing mode, or in direct mode, ha | + `check` + `filters` (declared sub-properties) + `expect_hashes` | **284** | **+149** | | + `check` + three flattened filter booleans + `expect_hashes` | 289 | +154 | - The locked design is the 284-token shape. The new budget is **≤300 tokens** (≈5% headroom — deliberately tight, so the next prose addition has to argue for itself). The cost is once per session on two surfaces, not per call, and is ~1 upstream tool schema's worth of context (the repo's own estimator uses ~150 tokens/schema). The budget MUST be enforced by the existing tokenized budget test with the new constant, AND the exact definition MUST be pinned by the `tools/list` golden snapshot (FR-014) so a prose edit shows up as a reviewable diff, not a silent drift under the ceiling. **The golden update is a deliberate, documented exception to the spec-098 FR-015 no-delta rule** — 098 forbade any MCP-surface movement because it shipped no MCP feature; 099 ships one, so its goldens move once, by intent, with the delta enumerated. -- **FR-016 (Sabotage matrix extension)**: The committed spec-098 sabotage matrix MUST gain a row for every reason-surface cell this feature creates, reusing the existing matrix infrastructure (scenario-keyed JSON + reflection gate) rather than a parallel one. Rows MUST record the surface (`mcp-check`, `mcp-plain`) and the disclosure tier, and MUST cover at minimum: every one of the 15 enum codes on the `mcp-check` surface at the tier where it is observable; out-of-scope at the agent-token tier (⇒ `not_found`) and at the operator tier (⇒ `server_not_in_scope`); out-of-scope on the `mcp-plain` surface (⇒ `not_found`, FR-011); `hash_mismatch` via `expect_hashes`; `missing_annotation` and `policy_filtered` via each of the three `filters`; the 50/51-id cap boundary; an orphan `expect_hashes` key; `filters` sent without `check`. The reflection gate MUST be extended so a code with no `mcp-check` row fails CI, exactly as a code with no REST row does today. While extending it, one inherited defect MUST be corrected: the existing `mid_indexing` row's note claims a never-indexed server on a connecting upstream would report `not_found` ("existence outranks connection state"), which contradicts spec 098 FR-005 and the shipped evaluator — on a non-Ready server the connection-state verdict wins because existence is unknowable. Correct the note and add an explicit never-indexed-while-connecting row, so the matrix that 099's parity claims are measured against is itself right. -- **FR-017 (Parity)**: An automated parity test MUST assert that for identical ids, identical proxy state and an identical disclosure tier, the in-band and REST surfaces return equal `{status, reason, retryable, action}` per id. Fields the two payloads deliberately differ on (`checked_at`, and `hash`, which REST may disclose at the operator tier and check mode never does — FR-004) are excluded from the comparison by name, not by a loose matcher. A divergence in the compared fields is a defect in the glue, never a documented difference. + With `expect_hashes` trimmed (FR-008 decision), the shipped shape is `check` + `filters` only — re-measure on the branch; the pre-trim measurement priced the full shape at 284 tokens, so the trimmed shape lands ≈230–240. The new budget is **≤250 tokens** (≈5% headroom — deliberately tight, so the next prose addition has to argue for itself). The cost is once per session on two surfaces, not per call, and is ~1 upstream tool schema's worth of context (the repo's own estimator uses ~150 tokens/schema). The budget MUST be enforced by the existing tokenized budget test with the new constant, AND the exact definition MUST be pinned by the `tools/list` golden snapshot (FR-014) so a prose edit shows up as a reviewable diff, not a silent drift under the ceiling. **The golden update is a deliberate, documented exception to the spec-098 FR-015 no-delta rule** — 098 forbade any MCP-surface movement because it shipped no MCP feature; 099 ships one, so its goldens move once, by intent, with the delta enumerated. +- **FR-016 (Sabotage matrix extension)**: The committed spec-098 sabotage matrix MUST gain a row for every reason-surface cell this feature creates, reusing the existing matrix infrastructure (scenario-keyed JSON + reflection gate) rather than a parallel one. Rows MUST record the surface (`mcp-check`, `mcp-plain`) and the disclosure tier, and MUST cover at minimum: every enum code observable on the `mcp-check` surface (all 15 EXCEPT `hash_mismatch` and `server_not_in_scope`, which are REST-only under the locked FR-008/FR-009 — the reflection gate encodes these two exemptions explicitly); out-of-scope and unconfigured servers in-band (⇒ byte-indistinguishable `not_found`); out-of-scope on the `mcp-plain` surface (⇒ `not_found`, FR-011); `missing_annotation` and `policy_filtered` via each of the three `filters`; the 50/51-id cap boundary; `filters` sent without `check`; `expect_hashes` sent (⇒ reserved-field request error). The reflection gate MUST be extended so a code with no `mcp-check` row fails CI, exactly as a code with no REST row does today. While extending it, one inherited defect MUST be corrected: the existing `mid_indexing` row's note claims a never-indexed server on a connecting upstream would report `not_found` ("existence outranks connection state"), which contradicts spec 098 FR-005 and the shipped evaluator — on a non-Ready server the connection-state verdict wins because existence is unknowable. Correct the note and add an explicit never-indexed-while-connecting row, so the matrix that 099's parity claims are measured against is itself right. +- **FR-017 (Parity)**: An automated parity test MUST assert that for identical ids, identical proxy state, the in-band surface (always agent-token tier, FR-009) and the REST surface AT the agent-token tier return equal `{status, reason, retryable, action}` per id. Fields the two payloads deliberately differ on (`checked_at`, and `hash`, which REST may disclose at the operator tier and check mode never does — FR-004) are excluded from the comparison by name, not by a loose matcher. A divergence in the compared fields is a defect in the glue, never a documented difference. +- **FR-018a (Inherited spec-098 erratum — REST disclosure tier)**: The spec-098 REST `disclosureTier` helper grants the operator tier to any admin-class auth context, which in the server edition includes a non-admin OAuth user (`AuthTypeUser`) — handing scope diagnostics and hash pins to a multi-user tenant. This change MUST correct it: only API-key / socket / named-pipe admin contexts get the operator tier; `AuthTypeUser` maps to the agent-token tier. Covered by a disclosure test in both editions. - **FR-018 (Docs)**: Documentation MUST be updated: the preflight feature page (in-band section: when an agent should check vs. describe, worked agent-loop example, the 50-id cap rationale), the spec-085 `describe_tool` contract (new parameters, retired `invisible` code, new token budget), the REST API reference note on `describe_tool`, and the `filters`-vs-`policy` naming divergence (FR-007). The docs MUST state plainly that check mode is absent from `code_execution` and direct mode and what to use instead (FR-002). The release notes MUST carry the `invisible` → `not_found` compatibility break (FR-011) under a heading a consumer scanning for breaking changes will find. ### Key Entities -- **Check request**: the existing `tool_ids` array (≤50 under check mode) plus `check`, optional `filters` (three annotation booleans), optional `expect_hashes` (id → pin). No profile, no wait budget — scope is the session's, and waiting is a non-goal. +- **Check request**: the existing `tool_ids` array (≤50 under check mode) plus `check` and optional `filters` (three annotation booleans). No `expect_hashes` (trimmed, FR-008), no profile, no wait budget — scope is the session's, and waiting is a non-goal. - **Check result**: per-id `{id, status, reason?, retryable?, action?, detail?, remediation?, did_you_mean?}` — the spec-098 result projected onto the MCP payload, minus operator-only fields at the agent-token tier. - **Set verdict**: the spec-098 worst-class aggregate, carried in-band so an agent can branch on one field instead of scanning results. - **Preflight activity record (in-band)**: the spec-098 record with a surface marker identifying the MCP check surface. @@ -154,6 +151,9 @@ A user running the proxy in `code_execution` routing mode, or in direct mode, ha ## Non-Goals (Phase 2) +- In-band hash pins (`expect_hashes`) — trimmed 2026-08-16 (FR-008); REST/CLI remains the pinning surface; field name reserved. +- An in-band operator disclosure tier — the whole surface is agent-token tier (FR-009); a future authenticated marker could raise it additively. + - **`wait` / long-polling in band.** No `wait_ms` equivalent: an agent that gets `server_initializing` (retryable) should retry on its own schedule, not hold an MCP call open. `POST /api/v1/preflight` keeps the wait budget for harnesses. - **A `profile` parameter.** The session's profile is the scope; letting an agent name a profile in a check would let it probe scopes it cannot use. - **Registering `describe_tool` (and therefore check mode) in `code_execution` or direct mode.** Later phase; interim path documented (FR-002). @@ -175,7 +175,7 @@ A user running the proxy in `code_execution` routing mode, or in direct mode, ha **The requirements above are the decision** — nothing here is an open requirement, and planning does not wait on this section. It exists because three choices were locked by input rather than by analysis, and a spec that hides their price makes them impossible to revisit later. Each entry names the shipped decision, its measured cost, and the one alternative that was rejected. Only the feature owner may override; an override edits the FR it names. -1. **Parameter set: `check` + `filters` + `expect_hashes` (+149 tokens, FR-015).** Rejected alternative: `check` alone (+54, 189 total), deferring filters and pins to a later phase. Shipping all three was the locked input and keeps the in-band surface a true peer of the REST body; the price is a once-per-session 149 tokens on two surfaces. If the owner would rather buy that back, the trim is FR-007 + FR-008 and the budget in FR-015 drops to ≤200. +1. **Parameter set — DECIDED 2026-08-16: `check` + `filters`, `expect_hashes` trimmed.** The full three-parameter shape priced +149 tokens/session on two surfaces; check-only priced +54. The decision keeps `filters` (it is what makes in-band answer "would MY filters exclude tool X", the question spec 094 deliberately withholds at aggregate level) and trims pins to the REST/CLI surface where harnesses author them, with the field name reserved for additive return. Budget re-set in FR-015 (≤250). 2. **`filters` on MCP vs `policy` on REST (FR-007).** Shipped: `filters`, because `retrieve_tools` already teaches agents that word and an agent-facing surface should use the agent's vocabulary. Rejected: renaming for cross-surface symmetry, which would either break the REST contract or teach agents a second word. The divergence is documented in FR-007 rather than hidden. 3. **Admin-authenticated MCP sessions get operator disclosure (FR-009).** Rejected: pinning the entire MCP surface to the agent-token tier, which is simpler to reason about but denies an operator driving `/mcp` over the trusted socket the diagnosis they can already get from the CLI. The fail-closed marker rule in FR-009 is what makes the shipped choice safe; if that marker proves invasive to plumb, the fallback is the rejected alternative — and that fallback is a strictly smaller change, never a leak. From 44066ab71732e6c72b2158107d431fab4957ea7c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 16 Aug 2026 06:29:42 +0300 Subject: [PATCH 03/11] plan(099): implementation plan + tasks --- ROADMAP.md | 1 + specs/099-describe-check-mode/plan.md | 32 ++++++++++++++++++++++++++ specs/099-describe-check-mode/tasks.md | 12 ++++++++++ 3 files changed, 45 insertions(+) create mode 100644 specs/099-describe-check-mode/plan.md create mode 100644 specs/099-describe-check-mode/tasks.md diff --git a/ROADMAP.md b/ROADMAP.md index 2af9533f..958302af 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -800,3 +800,4 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [096-batched-call-tools](./specs/096-batched-call-tools/) | `in-flight` | 15/16 (94%) | | [097-stored-scripts](./specs/097-stored-scripts/) | `in-flight` | 13/14 (93%) | | [098-tools-preflight](./specs/098-tools-preflight/) | `drafted` | 0/33 (0%) | +| [099-describe-check-mode](./specs/099-describe-check-mode/) | `drafted` | 0/10 (0%) | diff --git a/specs/099-describe-check-mode/plan.md b/specs/099-describe-check-mode/plan.md new file mode 100644 index 00000000..4441a6c7 --- /dev/null +++ b/specs/099-describe-check-mode/plan.md @@ -0,0 +1,32 @@ +# Implementation Plan: describe_tool Check Mode (099) + +**Branch**: `099-describe-check-mode` | **Date**: 2026-08-16 | **Spec**: [spec.md](spec.md) + +## Summary + +One optional `check: true` parameter on the existing `describe_tool` (default + retrieve_tools surfaces): verdict-only results from `preflight.Evaluate` via the same glue seam REST uses, batch cap 50, whole surface pinned to the agent-token tier, synchronous preflight activity record with a surface marker. Plus two inherited 098 errata (REST AuthTypeUser tier, matrix mid_indexing note) and the plain-mode `invisible`→`not_found` disclosure fix. + +## Technical Context + +Go 1.24 toolchain, existing deps only. All state via the shipped `internal/preflight` evaluator + `internal/server/preflight_glue.go` seam — no new state sources. Registration touches `retrieveToolsDetailOption`-style shared builders on the two describe_tool surfaces only; code_execution/direct goldens stay byte-identical. + +## Constitution Check + +PASS on all six principles (same posture as 098: stat-only reads, no new goroutines, no config fields, security tiers fail-closed, TDD via matrix rows, docs in scope). No Complexity Tracking entries. + +## Structure + +``` +internal/server/mcp_describe_tool.go # check-mode branch: parse/validate (FR-012a), 50-cap, dedup, evaluate, verdict payload, request_id surfacing +internal/server/mcp.go / mcp_routing.go # describe_tool schema: check + filters params on BOTH surfaces (shared option builder) +internal/server/preflight_glue.go # reuse RunPreflight with session scope + forced agent-token tier; surface marker +internal/httpapi/preflight.go # FR-018a: disclosureTier — AuthTypeUser ⇒ agent-token tier +internal/server/toolslist_snapshot_test.go + testdata/toolslist_goldens/ # enumerated-delta conversion + deliberate golden regen (2 surfaces) +internal/server/testdata/preflight_sabotage_matrix.json # mcp-check/mcp-plain rows + mid_indexing note fix +internal/server/preflight_e2e_test.go / preflight_matrix_test.go # matrix driver extension + reflection-gate exemptions (hash_mismatch, server_not_in_scope) +docs/features/tools-preflight.md, docs (spec-085 contract), release notes # FR-018 +``` + +## Key decisions (locked, see spec) + +Trimmed `expect_hashes` (reserved-field error); `filters` naming; agent-token tier everywhere in-band; budget ≤250 (tokenized test + golden pin); one activity record per check run (no duplicate internal_tool_call); plain-mode byte-identity with the single enumerated `invisible`→`not_found` delta + release note. diff --git a/specs/099-describe-check-mode/tasks.md b/specs/099-describe-check-mode/tasks.md new file mode 100644 index 00000000..3909ca99 --- /dev/null +++ b/specs/099-describe-check-mode/tasks.md @@ -0,0 +1,12 @@ +# Tasks: describe_tool Check Mode (099) + +- [ ] T001 Schema: `check` + `filters` params via a shared option builder on both describe_tool registrations (default surface + retrieve_tools mode); tokenized budget test constant → ≤250 (FR-015) +- [ ] T002 Handler check-mode branch in `internal/server/mcp_describe_tool.go`: FR-012a strict validation (incl. reserved `expect_hashes` error, filters-without-check error), 50-raw cap, normalize→dedup→first-occurrence order, evaluate via the 098 glue with session scope ∩ token scope ∩ pin at forced agent-token tier (FR-009/FR-009a), verdict-only payload with `verdict`/`checked_at`/`request_id` (FR-004), MCP-error mapping for 400/503 classes (FR-012); unit tests per FR cell +- [ ] T003 Activity: synchronous preflight record with surface marker `mcp-check`; suppress plain-mode `internal_tool_call` record for check runs; write-failure fails the call (FR-013); tests +- [ ] T004 Plain-mode disclosure fix: `invisible` → `not_found` (both code paths), spec-085 contract amendment + release note; byte-identity replay test with the single enumerated delta (FR-011) +- [ ] T005 FR-018a: REST `disclosureTier` maps `AuthTypeUser` to agent-token tier; disclosure tests both editions +- [ ] T006 Goldens: enumerated-delta snapshot test conversion; regenerate the two describe_tool surface goldens deliberately (FR-014); code_execution golden must NOT move +- [ ] T007 Sabotage matrix: mcp-check + mcp-plain rows (13 observable codes + scope/unconfigured collapse + filter cells + cap boundary + reserved-field + filters-without-check), reflection-gate exemptions for hash_mismatch/server_not_in_scope, mid_indexing note correction + never-indexed-while-connecting row (FR-016); parity test in-band vs REST at agent-token tier (FR-017) +- [ ] T008 Docs (FR-018/FR-002): feature page in-band section + agent-loop example, spec-085 contract update, naming-divergence note, interim story for code_execution/direct; release notes breaking-change entry +- [ ] T009 Gates: CI="" go test -race ./..., server-edition build/test/lint, golangci both tag sets, swagger/generate-types diff-clean (contracts untouched expected), frontend build if touched +- [ ] T010 opencode review of full diff (≤5 rounds), fix genuine findings; PR; green; merge per standing procedure From e48a626eaf4761d574a1dbbece2fa98216ecb31e Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 16 Aug 2026 06:51:55 +0300 Subject: [PATCH 04/11] =?UTF-8?q?feat(mcp):=20describe=5Ftool=20check=20mo?= =?UTF-8?q?de=20=E2=80=94=20in-band=20preflight=20(spec=20099)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An optional `check: true` on the existing describe_tool answers verdict-only availability from the spec-098 evaluator, so an agent can gate a multi-step plan without leaving the MCP session. - Schema: `check` + `filters` on the one builder both surfaces register (default /mcp + retrieve_tools mode); token budget raised 150 -> 250, the definition measures 243 under cl100k_base. - Handler: strict argument validation (non-boolean check, non-object/unknown/ non-boolean filters, filters without check, the reserved expect_hashes) as request errors rather than coercions; 50-id raw cap; trim -> dedup -> first-occurrence order; MCP tool errors for the 400/503 classes. - Tier is pinned to agent-token in band and the scope is the session's own: a new glue door (RunPreflightForSession) projects serverInScope over the server universe, so a check can never see what the session's retrieve_tools cannot, and out-of-scope/unconfigured collapse to plain not_found. - Activity: one synchronous preflight record per run, marked `mcp-check`; a failed write fails the call, and no internal_tool_call record is emitted. - Plain mode: out-of-scope ids report not_found; the `invisible` code is retired (remediation text unchanged). - REST erratum (FR-018a): disclosureTier grants the operator tier positively to admin-class contexts only, so a server-edition OAuth user no longer receives scope diagnostics or hash pins. - tools/list goldens regenerated for the two describe_tool surfaces only, and the spec-098 no-delta test converted to an enumerated-delta test against a frozen pre-099 capture; code_execution is byte-unchanged. Related #969 --- internal/httpapi/preflight.go | 22 +- internal/httpapi/preflight_test.go | 35 + internal/runtime/activity_preflight.go | 13 +- internal/server/mcp.go | 7 + internal/server/mcp_describe_check.go | 353 ++++++++++ internal/server/mcp_describe_check_test.go | 623 ++++++++++++++++++ internal/server/mcp_describe_tool.go | 73 +- internal/server/mcp_describe_tool_test.go | 36 +- internal/server/preflight_glue.go | 161 ++++- .../toolslist_goldens/default_server.json | 23 +- .../pre099/default_server.json | 469 +++++++++++++ .../pre099/retrieve_tools_mode.json | 490 ++++++++++++++ .../retrieve_tools_mode.json | 23 +- internal/server/toolslist_snapshot_test.go | 90 ++- internal/storage/activity_models.go | 11 + 15 files changed, 2393 insertions(+), 36 deletions(-) create mode 100644 internal/server/mcp_describe_check.go create mode 100644 internal/server/mcp_describe_check_test.go create mode 100644 internal/server/testdata/toolslist_goldens/pre099/default_server.json create mode 100644 internal/server/testdata/toolslist_goldens/pre099/retrieve_tools_mode.json diff --git a/internal/httpapi/preflight.go b/internal/httpapi/preflight.go index 1db90835..db8c68a5 100644 --- a/internal/httpapi/preflight.go +++ b/internal/httpapi/preflight.go @@ -147,11 +147,27 @@ func preflightParams(r *http.Request, req *contracts.PreflightRequest, tools []p // tier, and the tool-listing endpoints use it to decide whether a tool's hash // pin may be published (T020, FR-011 + FR-013). The returned AuthContext is // non-nil only for the agent-token tier, whose scope the caller needs. +// +// Spec 099 FR-018a corrects an inherited defect: the original mapping was +// "anything that is not an agent token is an operator", which in the SERVER +// edition handed the operator tier — scope diagnostics and hash pins — to +// AuthTypeUser, an ordinary OAuth-authenticated tenant user who is not an +// admin of anything. The rule is now positive rather than residual: only an +// admin-class context (API key over TCP, the Unix socket, the Windows named +// pipe — all AuthTypeAdmin — plus the server edition's OAuth admin, +// AuthTypeAdminUser) is an operator. Every other authenticated type, and any +// type added later, falls to the agent-token tier, so a new credential kind +// cannot inherit disclosure by default. +// +// A nil AuthContext stays operator: it is the in-process / personal-edition +// path where the REST layer is reached without the auth middleware having run, +// and demoting it would silently strip hashes from the local admin surfaces. func disclosureTier(r *http.Request) (preflight.Tier, *auth.AuthContext) { - if authCtx := auth.AuthContextFromContext(r.Context()); authCtx != nil && authCtx.Type == auth.AuthTypeAgent { - return preflight.TierAgentToken, authCtx + authCtx := auth.AuthContextFromContext(r.Context()) + if authCtx == nil || authCtx.IsAdmin() { + return preflight.TierOperator, nil } - return preflight.TierOperator, nil + return preflight.TierAgentToken, authCtx } // handlePreflight handles POST /api/v1/preflight diff --git a/internal/httpapi/preflight_test.go b/internal/httpapi/preflight_test.go index 20065e7c..dd036085 100644 --- a/internal/httpapi/preflight_test.go +++ b/internal/httpapi/preflight_test.go @@ -503,6 +503,41 @@ func TestPreflightParams_TierDetection(t *testing.T) { assert.Equal(t, preflight.TierOperator, preflightParams(req, body, tools).Tier) }) + // Spec 099 FR-018a: the server edition's ordinary OAuth user is NOT an + // operator. The constructor is the same one internal/serveredition/auth + // hands the middleware, so this asserts the mapping for both editions from + // the one place that owns it (the package builds identically under + // -tags server; there is no edition-specific disclosureTier). + t.Run("oauth user is the scoped tier, oauth admin is not", func(t *testing.T) { + userReq := httptest.NewRequest(http.MethodPost, "/api/v1/preflight", nil) + userReq = userReq.WithContext(auth.WithAuthContext(userReq.Context(), + auth.UserContext("01J0USER", "user@tenant.example", "Tenant User", "google"))) + + tier, authCtx := disclosureTier(userReq) + assert.Equal(t, preflight.TierAgentToken, tier, + "a non-admin tenant user must not receive scope diagnostics or hash pins") + require.NotNil(t, authCtx) + assert.Nil(t, authCtx.AllowedServers, "the tier changes; the evaluation scope does not") + assert.Equal(t, preflight.TierAgentToken, preflightParams(userReq, body, tools).Tier) + + adminReq := httptest.NewRequest(http.MethodPost, "/api/v1/preflight", nil) + adminReq = adminReq.WithContext(auth.WithAuthContext(adminReq.Context(), + auth.AdminUserContext("01J0ADMIN", "admin@tenant.example", "Tenant Admin", "google"))) + adminTier, adminCtx := disclosureTier(adminReq) + assert.Equal(t, preflight.TierOperator, adminTier, "an OAuth admin is admin-class") + assert.Nil(t, adminCtx) + }) + + t.Run("an unrecognized auth type falls to the scoped tier", func(t *testing.T) { + // Fail-closed: disclosure is granted positively to admin-class contexts, + // so a credential kind added later cannot inherit the operator tier by + // simply not being an agent token (the original defect's shape). + req := httptest.NewRequest(http.MethodPost, "/api/v1/preflight", nil) + req = req.WithContext(auth.WithAuthContext(req.Context(), &auth.AuthContext{Type: "workload_identity"})) + tier, _ := disclosureTier(req) + assert.Equal(t, preflight.TierAgentToken, tier) + }) + t.Run("agent token is the scoped tier and carries its scope", func(t *testing.T) { authCtx := (&auth.AgentToken{ Name: "cron", diff --git a/internal/runtime/activity_preflight.go b/internal/runtime/activity_preflight.go index 7c676b73..bc250489 100644 --- a/internal/runtime/activity_preflight.go +++ b/internal/runtime/activity_preflight.go @@ -47,6 +47,11 @@ type PreflightActivity struct { // Status overrides the derived activity status. Leave empty to derive it // from Verdict via PreflightActivityStatus. Status string + // Surface names the surface that ran the preflight, for surfaces the + // Source alone does not identify (spec 099 FR-013: + // storage.PreflightSurfaceMCPCheck). Empty for the REST endpoint, whose + // metadata then stays exactly as spec 098 shipped it. + Surface string // Timestamp defaults to time.Now() when zero. Timestamp time.Time Tools []PreflightToolOutcome @@ -153,10 +158,16 @@ func preflightMetadata(rec PreflightActivity) map[string]interface{} { perTool = append(perTool, entry) } - return map[string]interface{}{ + metadata := map[string]interface{}{ storage.MetadataKeyPreflightVerdict: rec.Verdict, storage.MetadataKeyPreflightIDsCount: len(rec.Tools), storage.MetadataKeyPreflightReasons: reasons, storage.MetadataKeyPreflightPerTool: perTool, } + // Absent, not empty, for the REST surface: adding a key to every record it + // has written since spec 098 would change a payload nothing asked to change. + if rec.Surface != "" { + metadata[storage.MetadataKeyPreflightSurface] = rec.Surface + } + return metadata } diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 26d3383f..9edc2b2f 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -144,6 +144,13 @@ type MCPProxyServer struct { // production — telemetryRegistry() then resolves via mainServer.runtime. telemetryRegOverride *telemetry.CounterRegistry + // preflightRecorder overrides the synchronous preflight activity write + // describe_tool check mode depends on (Spec 099 FR-013). Nil in production, + // where recordPreflightActivity resolves via mainServer.runtime; tests + // install one to observe the record — or to fail it — without standing up a + // whole Runtime (mirrors workSessionResolver). + preflightRecorder func(runtime.PreflightActivity) error + // Routing mode MCP server instances (Spec 031) // Each instance has different tools registered for its routing mode. directServer *mcpserver.MCPServer // Direct mode: upstream tools with serverName__toolName naming diff --git a/internal/server/mcp_describe_check.go b/internal/server/mcp_describe_check.go new file mode 100644 index 00000000..963c54b4 --- /dev/null +++ b/internal/server/mcp_describe_check.go @@ -0,0 +1,353 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + "strings" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" + internalRuntime "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/toolannotations" +) + +// Spec 099 — describe_tool check mode: the in-band preflight surface. +// +// The contract, in one place because the pieces break independently: +// +// - `check: true` selects verdict-only mode; absent or false is today's +// definition mode, byte-for-byte (FR-001/FR-011). +// - A result NEVER carries a hash and the tier is ALWAYS agent-token, so +// out-of-scope and unconfigured servers are byte-indistinguishable from an +// unknown id (FR-004/FR-009). Nothing about the session's auth context can +// raise that — /mcp is unauthenticated by default. +// - Arguments are validated strictly rather than coerced (FR-012a): a +// request this handler cannot honor exactly is an MCP tool error, never a +// verdict computed from a guess. +// - Every EXECUTED check writes its preflight activity record synchronously +// before answering, and a failed write fails the call (FR-013). A rejected +// request executes nothing and therefore records nothing, mirroring the +// REST surface's 400 class. +const ( + // maxDescribeCheckIDs caps a check-mode batch (FR-005). It applies to the + // RAW array, before trimming and dedup, so the cap is about request size + // rather than about how much work the evaluator ends up doing — the same + // reading the REST surface gives its own limit. + maxDescribeCheckIDs = 50 + + // describeCheckReservedHashes is the parameter trimmed from v1 (FR-008). + // The name stays RESERVED: a request carrying it is rejected rather than + // silently ignored, so in-band pins can be added later without a window in + // which a caller believed a pin was checked when it was not. + describeCheckReservedHashes = "expect_hashes" +) + +// describeCheckFilterKeys is the closed set of `filters` members (FR-007), in +// the order the error message lists them. +var describeCheckFilterKeys = []string{"read_only_only", "exclude_destructive", "exclude_open_world"} + +// describeToolMode is the parsed, validated shape of one describe_tool request +// as far as MODE selection goes. Filters are only ever populated in check mode. +type describeToolMode struct { + check bool + filters toolannotations.Filters +} + +// describeCheckResult is one per-id verdict on the wire (FR-004): the spec-098 +// result projected onto the MCP payload, minus every operator-only field. +// Retryable is a pointer so a ready result carries no `retryable: false`, which +// would read as a failure. +type describeCheckResult struct { + ID string `json:"id"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Retryable *bool `json:"retryable,omitempty"` + Action string `json:"action,omitempty"` + Detail string `json:"detail,omitempty"` + Remediation string `json:"remediation,omitempty"` + DidYouMean []string `json:"did_you_mean,omitempty"` +} + +// describeCheckPayload is the check-mode response. It carries neither +// `definitions` nor `errors`: a caller branches on the presence of `verdict`. +type describeCheckPayload struct { + Verdict string `json:"verdict"` + CheckedAt time.Time `json:"checked_at"` + RequestID string `json:"request_id"` + Results []describeCheckResult `json:"results"` +} + +// parseDescribeToolMode decides which mode a describe_tool call selects and +// validates everything that only exists in check mode (FR-012a). +// +// It runs BEFORE tool_ids is read, so a request that misuses the new +// parameters is rejected on those grounds rather than on an id error that would +// send the caller looking in the wrong place. Plain-mode tolerance is untouched: +// every shape rejected here is unreachable in a pre-099 request. +func parseDescribeToolMode(request mcp.CallToolRequest) (describeToolMode, error) { + args := request.GetArguments() + + // Reserved before anything else: the answer must be the same whether or not + // the caller also asked for check mode. + if _, ok := args[describeCheckReservedHashes]; ok { + return describeToolMode{}, fmt.Errorf( + "parameter '%s' is reserved and not accepted: hash pins are checked via POST /api/v1/preflight or 'mcpproxy tools preflight', not in band", + describeCheckReservedHashes) + } + + raw, hasCheck := args["check"] + var check bool + if hasCheck { + value, ok := raw.(bool) + if !ok { + // null included: a caller that sent the field meant to use the mode, + // and coercing it to plain mode would return schemas to a caller + // expecting verdicts. + return describeToolMode{}, fmt.Errorf("parameter 'check' must be a boolean, got %s", jsonTypeName(raw)) + } + check = value + } + + filtersRaw, hasFilters := args["filters"] + if hasFilters && !check { + // Ignoring it would let an agent believe a safety filter was applied + // when it was not. + return describeToolMode{}, errors.New("parameter 'filters' requires 'check': true; it does not apply when describe_tool returns definitions") + } + + mode := describeToolMode{check: check} + if !hasFilters { + return mode, nil + } + + filters, ok := filtersRaw.(map[string]any) + if !ok { + return describeToolMode{}, fmt.Errorf("parameter 'filters' must be an object with the boolean members %s, got %s", + strings.Join(describeCheckFilterKeys, ", "), jsonTypeName(filtersRaw)) + } + // Membership is checked over the whole object before any value, and in the + // declared key order rather than in Go's randomized map order, so a request + // with two mistakes always gets the same message back. + for _, key := range slices.Sorted(maps.Keys(filters)) { + if !slices.Contains(describeCheckFilterKeys, key) { + return describeToolMode{}, fmt.Errorf("unknown member 'filters.%s': the annotation filters are %s", + key, strings.Join(describeCheckFilterKeys, ", ")) + } + } + for _, key := range describeCheckFilterKeys { + value, ok := filters[key] + if !ok { + continue + } + flag, isBool := value.(bool) + if !isBool { + return describeToolMode{}, fmt.Errorf("member 'filters.%s' must be a boolean, got %s", key, jsonTypeName(value)) + } + switch key { + case "read_only_only": + mode.filters.ReadOnlyOnly = flag + case "exclude_destructive": + mode.filters.ExcludeDestructive = flag + case "exclude_open_world": + mode.filters.ExcludeOpenWorld = flag + } + } + return mode, nil +} + +// jsonTypeName names the JSON type of a decoded argument, for error text that +// tells the caller what they actually sent. +func jsonTypeName(value any) string { + switch value.(type) { + case nil: + return "null" + case bool: + return "boolean" + case float64, int, int64: + return "number" + case string: + return "string" + case []any: + return "array" + case map[string]any: + return "object" + default: + return fmt.Sprintf("%T", value) + } +} + +// normalizeDescribeCheckIDs trims and deduplicates the raw id array exactly as +// the REST surface does (FR-006), preserving first-occurrence order. Ids are +// NOT parsed here: a malformed id is a per-id verdict the evaluator produces, +// never a batch failure. +func normalizeDescribeCheckIDs(raw []string) []preflight.ToolRef { + seen := make(map[string]struct{}, len(raw)) + refs := make([]preflight.ToolRef, 0, len(raw)) + for _, id := range raw { + id = strings.TrimSpace(id) + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + refs = append(refs, preflight.ToolRef{ID: id}) + } + return refs +} + +// handleDescribeToolCheck answers one check-mode call. +// +// requestID is the correlation id minted by handleDescribeTool: the SAME value +// goes into the response and into the activity record, so an agent can hand a +// human the id that finds the run (FR-004/FR-013). +func (p *MCPProxyServer) handleDescribeToolCheck( + ctx context.Context, + request mcp.CallToolRequest, + mode describeToolMode, + sessionID, requestID string, +) (*mcp.CallToolResult, error) { + rawIDs, err := request.RequireStringSlice("tool_ids") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Missing required parameter 'tool_ids': %v", err)), nil + } + if len(rawIDs) == 0 { + // Deliberately NOT the plain-mode wording: telling a check-mode caller + // to supply "1-5 tool ids" would be wrong by an order of magnitude. + return mcp.NewToolResultError( + fmt.Sprintf("Missing required parameter 'tool_ids': provide 1-%d tool ids in ':' format", maxDescribeCheckIDs)), nil + } + if len(rawIDs) > maxDescribeCheckIDs { + // Anti-bulk-loophole: the whole call fails, nothing is evaluated. + return mcp.NewToolResultError( + fmt.Sprintf("too many tool_ids: %d (max %d with check:true). Narrow your selection.", len(rawIDs), maxDescribeCheckIDs)), nil + } + + refs := normalizeDescribeCheckIDs(rawIDs) + + outcome, err := p.RunPreflightForSession(ctx, refs, mode.filters) + if err != nil { + return mcp.NewToolResultError(p.describeCheckRuntimeError(err)), nil + } + checkedAt := time.Now().UTC() + + // FR-013: durable BEFORE the verdict is returned. A check nobody can audit + // afterwards is not answered — the in-band mirror of the REST 503. + if err := p.recordPreflightActivity(describeCheckActivityRecord(ctx, outcome, sessionID, requestID)); err != nil { + if p.logger != nil { + p.logger.Error("describe_tool check: preflight activity record could not be persisted", + zap.String("request_id", requestID), zap.Error(err)) + } + return mcp.NewToolResultError( + "Availability check unavailable: the activity record could not be persisted, so no verdict was returned."), nil + } + + payload := describeCheckResponse(outcome, requestID, checkedAt) + jsonResult, err := json.Marshal(payload) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to serialize availability check: %v", err)), nil + } + return mcp.NewToolResultText(string(jsonResult)), nil +} + +// describeCheckRuntimeError maps an evaluation failure onto the message the +// agent sees (FR-012). Both classes say plainly that NO verdict was computed: +// an agent must never read a failure as "nothing is blocked". +func (p *MCPProxyServer) describeCheckRuntimeError(err error) string { + if errors.Is(err, preflight.ErrRuntimeUnavailable) { + return "Availability check unavailable: the proxy runtime is not ready to evaluate, so no verdict was computed." + } + // An index/storage/snapshot read failed. Reduced-fidelity verdicts are + // worse than no verdict: the agent would gate its plan on a guess. + if p.logger != nil { + p.logger.Error("describe_tool check: preflight evaluation failed", zap.Error(err)) + } + return "Availability check unavailable: local proxy state could not be read, so no verdict was computed." +} + +// describeCheckResponse serializes one outcome (FR-004). No hash is emitted at +// any tier, and no definition field appears at all — that omission is what +// makes the 50-id cap safe. +func describeCheckResponse(outcome preflight.Outcome, requestID string, checkedAt time.Time) describeCheckPayload { + payload := describeCheckPayload{ + Verdict: outcome.Verdict, + CheckedAt: checkedAt, + RequestID: requestID, + Results: make([]describeCheckResult, 0, len(outcome.Results)), + } + if payload.Verdict == "" { + payload.Verdict = preflight.VerdictReady + } + for i := range outcome.Results { + result := outcome.Results[i] + entry := describeCheckResult{ + ID: result.ID, + Status: result.Status, + DidYouMean: result.DidYouMean, + } + if result.Status != preflight.StatusReady { + retryable := result.Retryable + entry.Reason = result.Reason + entry.Retryable = &retryable + entry.Action = result.Action + entry.Detail = result.Detail + entry.Remediation = result.Remediation + } + payload.Results = append(payload.Results, entry) + } + return payload +} + +// describeCheckActivityRecord builds the FR-013 payload: enum codes, counts and +// tool ids only — the same shape the REST surface writes, plus the surface +// marker that tells the two apart. ids_count is the count of UNIQUE ids, as on +// the REST surface, so the two records mean the same thing. +func describeCheckActivityRecord(ctx context.Context, outcome preflight.Outcome, sessionID, requestID string) internalRuntime.PreflightActivity { + record := internalRuntime.PreflightActivity{ + RequestID: requestID, + SessionID: sessionID, + Source: storage.ActivitySourceMCP, + Surface: storage.PreflightSurfaceMCPCheck, + Verdict: outcome.Verdict, + Tools: make([]internalRuntime.PreflightToolOutcome, 0, len(outcome.Results)), + } + if record.Verdict == "" { + record.Verdict = preflight.VerdictReady + } + if authCtx := auth.AuthContextFromContext(ctx); authCtx != nil { + record.UserID = authCtx.UserID + record.UserEmail = authCtx.Email + } + for i := range outcome.Results { + result := outcome.Results[i] + entry := internalRuntime.PreflightToolOutcome{ID: result.ID, Status: result.Status} + if result.Status != preflight.StatusReady { + entry.Reason = result.Reason + } + record.Tools = append(record.Tools, entry) + } + return record +} + +// recordPreflightActivity writes one preflight record synchronously and returns +// the write error, so the caller can refuse to answer without it (FR-013). +// +// A proxy with no activity service cannot audit a check and therefore cannot +// answer one; tests that exercise the payload install a recorder rather than +// standing up a whole Runtime (mirrors workSessionResolver). +func (p *MCPProxyServer) recordPreflightActivity(record internalRuntime.PreflightActivity) error { + if p.preflightRecorder != nil { + return p.preflightRecorder(record) + } + if p.mainServer == nil || p.mainServer.runtime == nil { + return internalRuntime.ErrActivityUnavailable + } + return p.mainServer.runtime.RecordPreflight(record) +} diff --git a/internal/server/mcp_describe_check_test.go b/internal/server/mcp_describe_check_test.go new file mode 100644 index 00000000..f187f681 --- /dev/null +++ b/internal/server/mcp_describe_check_test.go @@ -0,0 +1,623 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/profile" + internalRuntime "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" +) + +// Spec 099 — describe_tool check mode. +// +// The fixture is the spec-098 preflight fixture (real storage, real Bleve +// index, INSTRUMENTED upstream so "zero upstream I/O" is a hard count) plus a +// captured activity recorder, because FR-013 makes the record part of the +// answer: a check that cannot be audited is not answered. + +type describeCheckFixture struct { + *preflightFixture + records []internalRuntime.PreflightActivity + recordErr error +} + +func newDescribeCheckFixture(t *testing.T, mutate func(cfg *config.Config)) *describeCheckFixture { + t.Helper() + fixture := &describeCheckFixture{preflightFixture: newPreflightFixture(t, mutate)} + fixture.proxy.preflightRecorder = func(rec internalRuntime.PreflightActivity) error { + fixture.records = append(fixture.records, rec) + return fixture.recordErr + } + return fixture +} + +// callCheck invokes describe_tool with the given raw arguments and returns the +// raw result (which may be an error result). +func (f *describeCheckFixture) callCheck(t *testing.T, ctx context.Context, args map[string]interface{}) *mcp.CallToolResult { + t.Helper() + req := mcp.CallToolRequest{} + req.Params.Arguments = args + result, err := f.proxy.handleDescribeTool(ctx, req) + require.NoError(t, err, "the handler answers with tool-result errors, never transport errors") + require.NotNil(t, result) + return result +} + +// check runs a successful check-mode call and returns the decoded payload plus +// the raw JSON, so tests can assert on absent keys as well as values. +func (f *describeCheckFixture) check(t *testing.T, ctx context.Context, ids []interface{}, extra map[string]interface{}) (describeCheckPayload, string) { + t.Helper() + args := map[string]interface{}{"tool_ids": ids, "check": true} + for k, v := range extra { + args[k] = v + } + result := f.callCheck(t, ctx, args) + require.False(t, result.IsError, "check returned an error result: %v", resultText(t, result)) + raw := resultText(t, result) + var payload describeCheckPayload + require.NoError(t, json.Unmarshal([]byte(raw), &payload)) + return payload, raw +} + +func checkResultByID(t *testing.T, payload describeCheckPayload, id string) describeCheckResult { + t.Helper() + for _, res := range payload.Results { + if res.ID == id { + return res + } + } + t.Fatalf("no result for id %q in %+v", id, payload.Results) + return describeCheckResult{} +} + +// seedCheckFixture builds the state every reason cell below is induced from. +func seedCheckFixture(t *testing.T, f *describeCheckFixture) { + t.Helper() + f.addServer(t, &config.ServerConfig{Name: "gh", Enabled: true, Protocol: "http"}) + f.addServer(t, &config.ServerConfig{Name: "locked", Enabled: true, Quarantined: true, Protocol: "http"}) + f.addServer(t, &config.ServerConfig{Name: "off", Enabled: false, Protocol: "http"}) + f.addServer(t, &config.ServerConfig{Name: "denied", Enabled: true, Protocol: "http", DisabledTools: []string{"forbidden"}}) + + f.indexTool(t, "gh", "create_issue") + f.indexTool(t, "gh", "pending_tool") + f.indexTool(t, "gh", "changed_tool") + f.indexTool(t, "gh", "blocked_tool") + f.indexTool(t, "locked", "lingering") + f.indexTool(t, "off", "sleeping") + f.indexTool(t, "denied", "forbidden") + + for _, approval := range []*storage.ToolApprovalRecord{ + {ServerName: "gh", ToolName: "create_issue", Status: storage.ToolApprovalStatusApproved, CurrentHash: "abc123", HashSchemaVersion: 2}, + {ServerName: "gh", ToolName: "pending_tool", Status: storage.ToolApprovalStatusPending}, + {ServerName: "gh", ToolName: "changed_tool", Status: storage.ToolApprovalStatusChanged}, + {ServerName: "gh", ToolName: "blocked_tool", Status: storage.ToolApprovalStatusApproved, Disabled: true}, + } { + require.NoError(t, f.storage.SaveToolApproval(approval)) + } +} + +// --- FR-004: the payload ----------------------------------------------------- + +// A ready set answers verdict "ready", one result per id, and NOTHING that +// could pass for a definition — that omission is what makes the 50-id cap safe +// (FR-004/SC-005). It also proves the run is auditable from inside the session: +// the returned request_id is the one on the activity record (SC-006). +func TestDescribeToolCheck_ReadySetPayload(t *testing.T) { + fixture := newDescribeCheckFixture(t, nil) + seedCheckFixture(t, fixture) + before := fixture.snapshot(t) + + payload, raw := fixture.check(t, context.Background(), []interface{}{"gh:create_issue"}, nil) + + assert.Equal(t, preflight.VerdictReady, payload.Verdict) + require.Len(t, payload.Results, 1) + result := payload.Results[0] + assert.Equal(t, "gh:create_issue", result.ID) + assert.Equal(t, preflight.StatusReady, result.Status) + assert.Empty(t, result.Reason, "ready is a status, not a reason") + assert.Nil(t, result.Retryable, "a ready result carries no retryable flag") + assert.Empty(t, result.Action) + + assert.WithinDuration(t, time.Now(), payload.CheckedAt, time.Minute) + assert.NotEmpty(t, payload.RequestID) + + // No definition fields, and no hash at any tier (FR-004). + for _, forbidden := range []string{"inputSchema", "call_with", "definitions", "errors", "hash", "annotations"} { + assert.NotContains(t, raw, forbidden, "check payload must not carry %q", forbidden) + } + + // FR-010 / SC-006: zero upstream calls, zero mutation. + assert.Equal(t, int64(0), atomic.LoadInt64(fixture.upstreamHits)) + assert.Equal(t, before, fixture.snapshot(t)) + + require.Len(t, fixture.records, 1, "exactly one activity record per check run") + record := fixture.records[0] + assert.Equal(t, payload.RequestID, record.RequestID, + "the agent must be able to hand a human the id that finds the run") + assert.Equal(t, storage.PreflightSurfaceMCPCheck, record.Surface) + assert.Equal(t, storage.ActivitySourceMCP, record.Source) + assert.Equal(t, preflight.VerdictReady, record.Verdict) + require.Len(t, record.Tools, 1) + assert.Equal(t, "gh:create_issue", record.Tools[0].ID) + assert.Equal(t, preflight.StatusReady, record.Tools[0].Status) + assert.Empty(t, record.Tools[0].Reason) +} + +// FR-003: the in-band surface names the same reasons as every other preflight +// surface, with the same retryable/action/verdict, for every cell this fixture +// can induce without a connection-state snapshot. +func TestDescribeToolCheck_ReasonCells(t *testing.T) { + fixture := newDescribeCheckFixture(t, nil) + seedCheckFixture(t, fixture) + + cases := []struct { + name string + id string + reason string + verdict string + }{ + {"server_quarantined", "locked:lingering", preflight.ReasonServerQuarantined, preflight.VerdictBlocked}, + {"server_disabled", "off:sleeping", preflight.ReasonServerDisabled, preflight.VerdictBlocked}, + {"tool_pending_approval", "gh:pending_tool", preflight.ReasonToolPendingApproval, preflight.VerdictBlocked}, + {"tool_changed", "gh:changed_tool", preflight.ReasonToolChanged, preflight.VerdictBlocked}, + {"tool_blocked_by_user", "gh:blocked_tool", preflight.ReasonToolBlockedByUser, preflight.VerdictBlocked}, + {"tool_denied_by_config", "denied:forbidden", preflight.ReasonToolDeniedByConfig, preflight.VerdictBlocked}, + {"not_found", "gh:no_such_tool", preflight.ReasonNotFound, preflight.VerdictUnknownIDs}, + {"malformed id", "not-an-id", preflight.ReasonNotFound, preflight.VerdictUnknownIDs}, + {"unconfigured server", "nosuch:tool", preflight.ReasonNotFound, preflight.VerdictUnknownIDs}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + payload, _ := fixture.check(t, context.Background(), []interface{}{tc.id}, nil) + + require.Len(t, payload.Results, 1) + result := payload.Results[0] + assert.Equal(t, preflight.StatusUnavailable, result.Status) + assert.Equal(t, tc.reason, result.Reason) + require.NotNil(t, result.Retryable) + assert.Equal(t, preflight.Retryable(tc.reason), *result.Retryable) + assert.Equal(t, preflight.DefaultAction(tc.reason), result.Action) + assert.Equal(t, preflight.DefaultRemediation(tc.reason), result.Remediation) + assert.NotEmpty(t, result.Detail) + assert.Equal(t, tc.verdict, payload.Verdict) + }) + } +} + +// FR-009/SC-007: an unconfigured server and an out-of-scope one are the SAME +// answer as an unknown id, field for field — and no auth context can change +// that, including the full admin context the MCP middleware injects for +// unauthenticated /mcp requests. +func TestDescribeToolCheck_ScopeSilenceIsByteIdentical(t *testing.T) { + fixture := newDescribeCheckFixture(t, func(cfg *config.Config) { + cfg.Profiles = []config.ProfileConfig{{Name: "ops", Servers: []string{"gh"}}} + }) + seedCheckFixture(t, fixture) + fixture.addServer(t, &config.ServerConfig{Name: "secret", Enabled: true, Protocol: "http"}) + fixture.indexTool(t, "secret", "exfiltrate") + + agentCtx := auth.WithAuthContext(context.Background(), &auth.AuthContext{ + Type: auth.AuthTypeAgent, + AgentName: "scoped-bot", + AllowedServers: []string{"gh"}, + Permissions: []string{auth.PermRead}, + }) + + payload, _ := fixture.check(t, agentCtx, []interface{}{ + "secret:exfiltrate", // exists, out of scope + "nosuch:tool", // server not configured at all + "gh:no_such_tool", // configured, in scope, absent + }, nil) + + outOfScope := checkResultByID(t, payload, "secret:exfiltrate") + unconfigured := checkResultByID(t, payload, "nosuch:tool") + absent := checkResultByID(t, payload, "gh:no_such_tool") + + // Compare every field except the id itself. + normalize := func(res describeCheckResult) describeCheckResult { + res.ID = "" + res.DidYouMean = nil // suggestions differ by prefix distance, never by existence + return res + } + assert.Equal(t, normalize(absent), normalize(outOfScope), + "an out-of-scope id must be indistinguishable from one that does not exist") + assert.Equal(t, normalize(absent), normalize(unconfigured), + "an unconfigured server must be indistinguishable from an unknown id") + assert.Equal(t, preflight.ReasonNotFound, outOfScope.Reason) + + for _, res := range payload.Results { + for _, suggestion := range res.DidYouMean { + assert.NotContains(t, suggestion, "secret:", "did_you_mean must never cross the scope boundary") + } + } + + // A full ADMIN session — what the MCP middleware injects for every + // unauthenticated /mcp request — narrowed by a path-pinned profile gets the + // same scope-silence. At the operator tier this id would report + // server_not_in_scope and name the profile; in band it never can, because + // the tier is pinned and IsAdmin() is not consulted for disclosure (FR-009). + adminCtx := auth.WithAuthContext(context.Background(), auth.AdminContext()) + adminCtx = profile.WithProfileScope(adminCtx, profile.NewProfileScope("ops", []string{"gh"})) + adminPayload, adminRaw := fixture.check(t, adminCtx, []interface{}{"secret:exfiltrate"}, nil) + adminResult := adminPayload.Results[0] + assert.Equal(t, preflight.ReasonNotFound, adminResult.Reason, + "an admin MCP session gets the agent-token tier like every other in-band caller") + assert.NotContains(t, adminRaw, "ops", "the scope's name is operator-tier disclosure") + + // The REST surface over the same state and the same profile DOES name it — + // that is where an operator goes for the full diagnosis. + restOutcome, err := fixture.proxy.RunPreflight(context.Background(), preflight.Params{ + Tools: []preflight.ToolRef{{ID: "secret:exfiltrate"}}, + Profile: "ops", + }) + require.NoError(t, err) + assert.Equal(t, preflight.ReasonServerNotInScope, restOutcome.Results[0].Reason) +} + +// FR-004: a ready result never carries a hash, even for a caller whose REST +// equivalent would see one. +func TestDescribeToolCheck_NeverDisclosesAHash(t *testing.T) { + fixture := newDescribeCheckFixture(t, nil) + seedCheckFixture(t, fixture) + + _, raw := fixture.check(t, context.Background(), []interface{}{"gh:create_issue"}, nil) + assert.NotContains(t, raw, "abc123", "the stored hash must never reach an in-band caller") + + // The REST path over the same state DOES disclose it at the operator tier — + // the divergence is deliberate, not an accident of this fixture. + outcome, err := fixture.proxy.RunPreflight(context.Background(), preflight.Params{ + Tools: []preflight.ToolRef{{ID: "gh:create_issue"}}, + }) + require.NoError(t, err) + assert.Equal(t, "sha256/v2:abc123", outcome.Results[0].Hash) +} + +// FR-004: a misspelled id gets a scope-filtered did_you_mean, and one bad id +// never fails the batch (US1 acceptance 4). +func TestDescribeToolCheck_DidYouMeanAndBatchResilience(t *testing.T) { + fixture := newDescribeCheckFixture(t, nil) + seedCheckFixture(t, fixture) + + payload, _ := fixture.check(t, context.Background(), []interface{}{ + "gh:create_isue", "gh:create_issue", "not-an-id", + }, nil) + + require.Len(t, payload.Results, 3) + misspelled := checkResultByID(t, payload, "gh:create_isue") + assert.Equal(t, preflight.ReasonNotFound, misspelled.Reason) + assert.Contains(t, misspelled.DidYouMean, "gh:create_issue") + assert.LessOrEqual(t, len(misspelled.DidYouMean), 3) + + assert.Equal(t, preflight.StatusReady, checkResultByID(t, payload, "gh:create_issue").Status) + assert.Equal(t, preflight.ReasonNotFound, checkResultByID(t, payload, "not-an-id").Reason) + assert.Equal(t, preflight.VerdictUnknownIDs, payload.Verdict) +} + +// --- FR-005/FR-006: batch shape --------------------------------------------- + +// 50 raw ids are evaluated in one call; 51 fails outright and evaluates +// nothing — including writing no activity record, since nothing ran. +func TestDescribeToolCheck_BatchCapBoundary(t *testing.T) { + fixture := newDescribeCheckFixture(t, nil) + seedCheckFixture(t, fixture) + + ids := make([]interface{}, 0, maxDescribeCheckIDs+1) + for i := 0; i < maxDescribeCheckIDs; i++ { + ids = append(ids, fmt.Sprintf("gh:tool_%02d", i)) + } + payload, raw := fixture.check(t, context.Background(), ids, nil) + assert.Len(t, payload.Results, maxDescribeCheckIDs, "all 50 ids are evaluated in one call") + assert.NotContains(t, raw, "inputSchema") + require.Len(t, fixture.records, 1) + assert.Len(t, fixture.records[0].Tools, maxDescribeCheckIDs) + + fixture.records = nil + result := fixture.callCheck(t, context.Background(), map[string]interface{}{ + "tool_ids": append(ids, "gh:one_too_many"), + "check": true, + }) + require.True(t, result.IsError) + text := resultText(t, result) + assert.Contains(t, text, "too many tool_ids: 51 (max 50") + assert.NotContains(t, text, "verdict", "an over-cap call evaluates nothing") + assert.Empty(t, fixture.records, "a rejected request runs no check and records nothing") +} + +// FR-006: ids are trimmed, then deduplicated, one result per unique id in +// first-occurrence order, echoing the NORMALIZED id so a caller can join +// results back to its own list. +func TestDescribeToolCheck_NormalizationAndDedup(t *testing.T) { + fixture := newDescribeCheckFixture(t, nil) + seedCheckFixture(t, fixture) + + payload, _ := fixture.check(t, context.Background(), []interface{}{ + " gh:pending_tool ", "gh:create_issue", "gh:pending_tool", "gh:create_issue ", + }, nil) + + require.Len(t, payload.Results, 2, "duplicates collapse to one result each") + assert.Equal(t, "gh:pending_tool", payload.Results[0].ID, "first-occurrence order, normalized id") + assert.Equal(t, "gh:create_issue", payload.Results[1].ID) + + require.Len(t, fixture.records, 1) + assert.Len(t, fixture.records[0].Tools, 2, + "the record counts UNIQUE ids, exactly as the REST record does") +} + +// --- FR-007: filters --------------------------------------------------------- + +// A tool whose upstream definition declares no annotations is withheld by each +// of the three filters, with the reason at its precedence slot. (Explicitly +// unsafe annotations produce policy_filtered; they are only readable from a +// connection-state snapshot, so that half is asserted in the matrix rows.) +func TestDescribeToolCheck_FiltersWithholdUnannotatedTools(t *testing.T) { + fixture := newDescribeCheckFixture(t, nil) + seedCheckFixture(t, fixture) + + for _, filter := range describeCheckFilterKeys { + t.Run(filter, func(t *testing.T) { + payload, _ := fixture.check(t, context.Background(), []interface{}{"gh:create_issue"}, + map[string]interface{}{"filters": map[string]interface{}{filter: true}}) + + result := payload.Results[0] + assert.Equal(t, preflight.ReasonMissingAnnotation, result.Reason) + require.NotNil(t, result.Retryable) + assert.False(t, *result.Retryable) + assert.Equal(t, preflight.VerdictBlocked, payload.Verdict) + assert.Contains(t, result.Detail, filter, "the detail names the filter that withheld it") + }) + } + + // All three false is the same as no filter at all. + payload, _ := fixture.check(t, context.Background(), []interface{}{"gh:create_issue"}, + map[string]interface{}{"filters": map[string]interface{}{ + "read_only_only": false, "exclude_destructive": false, "exclude_open_world": false, + }}) + assert.Equal(t, preflight.StatusReady, payload.Results[0].Status) +} + +// --- FR-012 / FR-012a: request errors --------------------------------------- + +// Every shape the handler cannot honor exactly is a request error naming the +// rule — never a coerced mode and never a verdict. None of these executes a +// check, so none writes a record. +func TestDescribeToolCheck_StrictArgumentValidation(t *testing.T) { + fixture := newDescribeCheckFixture(t, nil) + seedCheckFixture(t, fixture) + + ids := []interface{}{"gh:create_issue"} + cases := []struct { + name string + args map[string]interface{} + contains []string + }{ + { + name: "check null is not absent", + args: map[string]interface{}{"tool_ids": ids, "check": nil}, + contains: []string{"'check' must be a boolean", "null"}, + }, + { + name: "check as a string", + args: map[string]interface{}{"tool_ids": ids, "check": "true"}, + contains: []string{"'check' must be a boolean", "string"}, + }, + { + name: "check as a number", + args: map[string]interface{}{"tool_ids": ids, "check": float64(1)}, + contains: []string{"'check' must be a boolean", "number"}, + }, + { + name: "filters without check", + args: map[string]interface{}{"tool_ids": ids, "filters": map[string]interface{}{"read_only_only": true}}, + contains: []string{"'filters' requires 'check': true"}, + }, + { + name: "filters with check false", + args: map[string]interface{}{"tool_ids": ids, "check": false, "filters": map[string]interface{}{"read_only_only": true}}, + contains: []string{"'filters' requires 'check': true"}, + }, + { + name: "filters not an object", + args: map[string]interface{}{"tool_ids": ids, "check": true, "filters": "read_only_only"}, + contains: []string{"'filters' must be an object", "string"}, + }, + { + name: "unknown filter member", + args: map[string]interface{}{"tool_ids": ids, "check": true, "filters": map[string]interface{}{"read_only": true}}, + contains: []string{"unknown member 'filters.read_only'", "exclude_open_world"}, + }, + { + name: "non-boolean filter value", + args: map[string]interface{}{"tool_ids": ids, "check": true, "filters": map[string]interface{}{"read_only_only": "yes"}}, + contains: []string{"'filters.read_only_only' must be a boolean", "string"}, + }, + { + name: "reserved expect_hashes with check", + args: map[string]interface{}{"tool_ids": ids, "check": true, "expect_hashes": map[string]interface{}{"gh:create_issue": "sha256/v2:abc123"}}, + contains: []string{"'expect_hashes' is reserved", "preflight"}, + }, + { + name: "reserved expect_hashes without check", + args: map[string]interface{}{"tool_ids": ids, "expect_hashes": map[string]interface{}{}}, + contains: []string{"'expect_hashes' is reserved"}, + }, + { + name: "empty ids under check", + args: map[string]interface{}{"tool_ids": []interface{}{}, "check": true}, + contains: []string{"tool_ids", "1-50"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fixture.records = nil + result := fixture.callCheck(t, context.Background(), tc.args) + require.True(t, result.IsError, "this shape must be rejected") + text := resultText(t, result) + for _, want := range tc.contains { + assert.Contains(t, text, want) + } + assert.NotContains(t, text, "verdict", "a rejected request evaluates nothing") + assert.Empty(t, fixture.records, "a rejected request writes no activity record") + }) + } +} + +// The check-mode empty-ids error must not reuse the plain-mode wording, which +// names a cap ten times smaller (FR-012). +func TestDescribeToolCheck_EmptyIDsErrorIsCheckModeAccurate(t *testing.T) { + fixture := newDescribeCheckFixture(t, nil) + + result := fixture.callCheck(t, context.Background(), map[string]interface{}{ + "tool_ids": []interface{}{}, "check": true, + }) + require.True(t, result.IsError) + text := resultText(t, result) + assert.Contains(t, text, "1-50 tool ids") + assert.NotContains(t, text, "1-5 tool ids") +} + +// FR-012: a runtime that cannot evaluate honestly produces a tool ERROR saying +// no verdict was computed — never a fabricated "everything is fine". +func TestDescribeToolCheck_RuntimeUnavailableIsAnError(t *testing.T) { + fixture := newDescribeCheckFixture(t, nil) + seedCheckFixture(t, fixture) + + savedIndex := fixture.proxy.index + fixture.proxy.index = nil + t.Cleanup(func() { fixture.proxy.index = savedIndex }) + + result := fixture.callCheck(t, context.Background(), map[string]interface{}{ + "tool_ids": []interface{}{"gh:create_issue"}, "check": true, + }) + require.True(t, result.IsError) + text := resultText(t, result) + assert.Contains(t, text, "no verdict was computed") + assert.NotContains(t, text, "\"verdict\"", "a refusal is never a verdict") + assert.Empty(t, fixture.records) +} + +// FR-013: a preflight nobody can audit is not answered. The write happens +// BEFORE the verdict is returned, so its failure fails the call. +func TestDescribeToolCheck_ActivityWriteFailureFailsTheCall(t *testing.T) { + fixture := newDescribeCheckFixture(t, nil) + seedCheckFixture(t, fixture) + fixture.recordErr = internalRuntime.ErrActivityUnavailable + + result := fixture.callCheck(t, context.Background(), map[string]interface{}{ + "tool_ids": []interface{}{"gh:create_issue"}, "check": true, + }) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "activity record could not be persisted") + assert.NotContains(t, resultText(t, result), "\"verdict\"") + require.Len(t, fixture.records, 1, "the write was attempted, and its failure was fatal") +} + +// A proxy with no activity service at all cannot audit a check either, so it +// refuses rather than answering unauditably. +func TestDescribeToolCheck_NoActivityServiceRefuses(t *testing.T) { + fixture := newDescribeCheckFixture(t, nil) + seedCheckFixture(t, fixture) + fixture.proxy.preflightRecorder = nil // no recorder, and no runtime behind it + + result := fixture.callCheck(t, context.Background(), map[string]interface{}{ + "tool_ids": []interface{}{"gh:create_issue"}, "check": true, + }) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "activity record could not be persisted") +} + +// --- FR-011: plain mode is untouched ---------------------------------------- + +// check:false and an absent check are the same request: definitions, the 5-id +// cap with its original wording, and duplicates rendered once per occurrence. +func TestDescribeToolCheck_PlainModeUnaffected(t *testing.T) { + fixture := newDescribeCheckFixture(t, nil) + seedCheckFixture(t, fixture) + + absent := fixture.callCheck(t, context.Background(), map[string]interface{}{ + "tool_ids": []interface{}{"gh:create_issue"}, + }) + explicit := fixture.callCheck(t, context.Background(), map[string]interface{}{ + "tool_ids": []interface{}{"gh:create_issue"}, "check": false, + }) + require.False(t, absent.IsError) + assert.Equal(t, resultText(t, absent), resultText(t, explicit), + "check:false is byte-identical to omitting the parameter") + assert.Contains(t, resultText(t, absent), "definitions") + assert.NotContains(t, resultText(t, absent), "verdict") + assert.Empty(t, fixture.records, "plain mode writes no preflight record") + + // The 50-id cap does not leak into plain mode, and the over-cap wording is + // the pre-099 one verbatim. + ids := make([]interface{}, 0, maxDescribeToolIDs+1) + for i := 0; i <= maxDescribeToolIDs; i++ { + ids = append(ids, fmt.Sprintf("gh:tool_%02d", i)) + } + overCap := fixture.callCheck(t, context.Background(), map[string]interface{}{"tool_ids": ids}) + require.True(t, overCap.IsError) + assert.Equal(t, "too many tool_ids: 6 (max 5). Narrow your selection.", resultText(t, overCap)) + + // Duplicates: plain mode renders one entry per occurrence (dedup is + // check-mode only). + dupes := fixture.callCheck(t, context.Background(), map[string]interface{}{ + "tool_ids": []interface{}{"gh:create_issue", "gh:create_issue"}, + }) + require.False(t, dupes.IsError) + assert.Equal(t, 2, strings.Count(resultText(t, dupes), `"name":"gh:create_issue"`)) +} + +// --- FR-004 projection over state-injected results -------------------------- + +// The connection-state and explicitly-unsafe-annotation cells cannot be induced +// without a live stateview, exactly as spec 098 found for its pending_auth cell. +// What is in-band-specific about them is the PROJECTION — which fields the MCP +// payload keeps, drops and renames — so that is what this asserts, over +// evaluator results produced by the same evaluator the handler calls. +func TestDescribeToolCheck_PayloadProjectionIsLossless(t *testing.T) { + results := []preflight.Result{ + { + ID: "oauthy:sync", Status: preflight.StatusUnavailable, Reason: preflight.ReasonOAuthRequired, + Retryable: false, Action: "login", Detail: "d", Remediation: "r", + }, + { + ID: "slow:boot", Status: preflight.StatusUnavailable, Reason: preflight.ReasonServerInitializing, + Retryable: true, Detail: "d", Remediation: "r", + }, + { + // A ready result WITH a hash: the projection must drop it. + ID: "gh:create_issue", Status: preflight.StatusReady, Hash: "sha256/v2:abc123", + }, + } + outcome := preflight.Outcome{Verdict: preflight.VerdictForResults(results), Results: results} + + payload := describeCheckResponse(outcome, "req-1", time.Now().UTC()) + raw, err := json.Marshal(payload) + require.NoError(t, err) + + assert.Equal(t, preflight.VerdictBlocked, payload.Verdict) + assert.NotContains(t, string(raw), "abc123", "no hash is ever returned in band (FR-004)") + + oauth := checkResultByID(t, payload, "oauthy:sync") + require.NotNil(t, oauth.Retryable) + assert.False(t, *oauth.Retryable) + assert.Equal(t, "login", oauth.Action) + + initializing := checkResultByID(t, payload, "slow:boot") + require.NotNil(t, initializing.Retryable) + assert.True(t, *initializing.Retryable, "an initializing server tells the agent to wait, not to escalate") + assert.Empty(t, initializing.Action, "a reason with no action emits no action key") +} diff --git a/internal/server/mcp_describe_tool.go b/internal/server/mcp_describe_tool.go index b1a499df..455e5949 100644 --- a/internal/server/mcp_describe_tool.go +++ b/internal/server/mcp_describe_tool.go @@ -17,11 +17,27 @@ import ( // search default k and keeps describe_tool from becoming a bulk-dump loophole. const maxDescribeToolIDs = 5 +// describeToolTokenBudget is the ceiling on the marshalled describe_tool +// definition under tiktoken cl100k_base (Spec 099 FR-015). It replaces the +// spec-085 budget of 150: check mode costs ~+108 tokens once per session on two +// surfaces — about one upstream tool schema — and the ceiling is deliberately +// tight (the definition measures 243) so the next prose addition has to argue +// for itself. If it is ever hit, the answer is shorter prose or a trimmed +// parameter set, not a raised ceiling. +const describeToolTokenBudget = 250 + // describeErr* are the per-id error codes of the describe_tool contract // (specs/085-compact-router/contracts/describe_tool.md). +// +// Spec 099 FR-011 retires `invisible`: an out-of-scope id now reports +// not_found, byte-indistinguishable from an id that does not exist. A distinct +// code confirmed that a tool the session may not see exists — the leak the +// contract's shared not-found remediation was already written to prevent, and +// the one the spec-098 evaluator prevents on every other surface. The +// vocabulary is now not_found | quarantined | pending_approval | changed | +// disabled. const ( describeErrNotFound = "not_found" - describeErrInvisible = "invisible" describeErrQuarantined = "quarantined" describeErrPendingApproval = "pending_approval" describeErrChanged = "changed" @@ -34,20 +50,42 @@ const ( const describeNotFoundRemediation = "Tool not found or no longer available; re-run retrieve_tools." // buildDescribeToolTool constructs the describe_tool definition (Spec 085 -// FR-010/FR-011). The definition is budgeted at ≤150 tokens under tiktoken -// cl100k_base (the profiler's pinned encoder) — keep the prose short. +// FR-010/FR-011, Spec 099 FR-001/FR-002/FR-007). ONE builder feeds both +// surfaces that register the tool — the default /mcp server and the +// retrieve_tools routing mode — so the two schemas cannot drift. +// +// The definition is budgeted at ≤describeToolTokenBudget tokens under tiktoken +// cl100k_base (the profiler's pinned encoder) — keep the prose short. The +// budget rose from 150 to 250 with check mode (Spec 099 FR-015); the exact +// bytes are additionally pinned by the tools/list goldens, so a prose edit +// shows up as a reviewable diff rather than silent drift under the ceiling. func buildDescribeToolTool() mcp.Tool { return mcp.NewTool("describe_tool", - mcp.WithDescription("Return full JSON Schema + long description for specific tools found via retrieve_tools. Use when a compact signature is marked lossy ('~') or you need the exact schema before calling."), + mcp.WithDescription("Return full JSON Schema + long description for specific tools found via retrieve_tools. Use when a compact signature is marked lossy ('~') or you need the exact schema before calling. With check:true it returns one availability verdict per id instead of schemas ('ready', or a reason code with retryable/action), to gate a plan before its first call."), mcp.WithTitleAnnotation("Describe Tool"), mcp.WithReadOnlyHintAnnotation(true), mcp.WithDestructiveHintAnnotation(false), mcp.WithOpenWorldHintAnnotation(false), mcp.WithArray("tool_ids", mcp.Required(), - mcp.Description("1-5 tool ids in ':' format, from retrieve_tools results."), + mcp.Description("Tool ids in ':' format from retrieve_tools. Max 5, or 50 with check:true."), mcp.WithStringItems(), ), + mcp.WithBoolean("check", + mcp.Description("Check availability only, no schemas (default: false)."), + ), + // The three annotation filters carry no per-property prose: their names + // and semantics are the ones retrieve_tools already teaches, and + // restating them here priced ~18 tokens on every session of two + // surfaces (FR-007/FR-015). + mcp.WithObject("filters", + mcp.Description("check:true only. Annotation filters, as in retrieve_tools."), + mcp.Properties(map[string]any{ + "read_only_only": map[string]any{"type": "boolean"}, + "exclude_destructive": map[string]any{"type": "boolean"}, + "exclude_open_world": map[string]any{"type": "boolean"}, + }), + ), ) } @@ -62,12 +100,14 @@ func describeToolIDError(id, code, remediation string) map[string]interface{} { // describeVisibilityError maps a toolVisibleToSession reason to the contract's // per-id error code + remediation, reusing the existing Spec 049 remediation -// text where applicable. Scope failures reuse the not-found remediation so the -// response never confirms that an out-of-scope tool exists. +// text where applicable. Scope failures produce the plain not-found answer — +// code AND remediation — so the response never confirms that an out-of-scope +// tool exists (Spec 099 FR-011; the remediation was already shared, only the +// code moved). func (p *MCPProxyServer) describeVisibilityError(reason, serverName, toolName string) (code, remediation string) { switch reason { case visReasonServerNotInScope: - return describeErrInvisible, describeNotFoundRemediation + return describeErrNotFound, describeNotFoundRemediation case visReasonServerQuarantined: return describeErrQuarantined, disabledToolRemediation(contracts.DisabledStatusServerQuarantined) case visReasonToolPendingApproval: @@ -94,6 +134,10 @@ func (p *MCPProxyServer) describeVisibilityError(reason, serverName, toolName st // definition the same session's search would not (FR-011, Constitution IV). // The handler never consults the response mode: output is identical under // full and compact (FR-012). +// +// Spec 099 adds the `check: true` branch (mcp_describe_check.go), which answers +// verdict-only availability from the shared preflight evaluator instead of +// definitions. Everything below it is the definition path, unchanged. func (p *MCPProxyServer) handleDescribeTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { p.recordMCPSurface() p.recordBuiltinTool("describe_tool") @@ -105,6 +149,19 @@ func (p *MCPProxyServer) handleDescribeTool(ctx context.Context, request mcp.Cal } requestID := mintCorrelationID("describe_tool") + // Spec 099 FR-001/FR-012a: mode selection and its strict validation run + // before tool_ids is touched, so a misused new parameter is reported as + // what it is. A rejected request executes nothing and therefore records + // nothing — no verdict, and no internal_tool_call either, matching the REST + // surface's 400 class. + mode, err := parseDescribeToolMode(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if mode.check { + return p.handleDescribeToolCheck(ctx, request, mode, sessionID, requestID) + } + emitError := func(errMsg string, args map[string]interface{}) { p.emitActivityInternalToolCall("describe_tool", "", "", "", sessionID, requestID, "error", errMsg, time.Since(startTime).Milliseconds(), args, nil, nil, "") diff --git a/internal/server/mcp_describe_tool_test.go b/internal/server/mcp_describe_tool_test.go index 52d90ea8..71c73c21 100644 --- a/internal/server/mcp_describe_tool_test.go +++ b/internal/server/mcp_describe_tool_test.go @@ -194,7 +194,9 @@ func TestDescribeTool_VisibilityParityWithRetrieve(t *testing.T) { wantError string // "" = definition expected }{ {"github:visible_tool", ""}, - {"gitlab:scoped_tool", "invisible"}, + // Spec 099 FR-011: an out-of-scope id is plain not_found — the retired + // `invisible` code confirmed that a tool this session may not see exists. + {"gitlab:scoped_tool", "not_found"}, {"quarry:lingering_tool", "quarantined"}, {"github:pending_tool", "pending_approval"}, {"github:changed_tool", "changed"}, @@ -371,9 +373,12 @@ func TestDescribeTool_RegisteredInRetrieveToolsModeOnly(t *testing.T) { }) } -// T028 (FR-011): the describe_tool definition costs ≤150 tokens counted with -// tiktoken cl100k_base — the same pinned encoder the spec-083 profiler uses, -// so the budget and the profiler agree. +// T028 (spec 085 FR-011) / spec 099 FR-015: the describe_tool definition costs +// ≤describeToolTokenBudget tokens counted with tiktoken cl100k_base — the same +// pinned encoder the spec-083 profiler uses, so the budget and the profiler +// agree. The budget rose from 150 to 250 when check mode added two parameters; +// the exact bytes are additionally pinned by the tools/list goldens, so prose +// cannot drift silently under the ceiling. func TestDescribeTool_DefinitionTokenBudget(t *testing.T) { tool := buildDescribeToolTool() serialized, err := json.Marshal(tool) @@ -383,16 +388,31 @@ func TestDescribeTool_DefinitionTokenBudget(t *testing.T) { require.NoError(t, err, "cl100k_base encoding must be loadable (bench pins the same encoder)") tokens := len(enc.Encode(string(serialized), nil, nil)) - assert.LessOrEqual(t, tokens, 150, - "describe_tool definition must stay within the 150-token budget (FR-011); got %d tokens for %s", - tokens, serialized) + assert.LessOrEqual(t, tokens, describeToolTokenBudget, + "describe_tool definition must stay within the %d-token budget (spec 099 FR-015); got %d tokens for %s", + describeToolTokenBudget, tokens, serialized) - // Schema sanity: single required array-of-strings param. + // Schema sanity: one required array-of-strings param plus the two optional + // check-mode params, and nothing else — the reserved expect_hashes is NOT + // declared (FR-008). assert.Equal(t, "describe_tool", tool.Name) require.Contains(t, tool.InputSchema.Properties, "tool_ids") assert.Equal(t, []string{"tool_ids"}, tool.InputSchema.Required) idsSchema := tool.InputSchema.Properties["tool_ids"].(map[string]any) assert.Equal(t, "array", idsSchema["type"]) + + require.Contains(t, tool.InputSchema.Properties, "check") + assert.Equal(t, "boolean", tool.InputSchema.Properties["check"].(map[string]any)["type"]) + require.Contains(t, tool.InputSchema.Properties, "filters") + filters := tool.InputSchema.Properties["filters"].(map[string]any) + assert.Equal(t, "object", filters["type"]) + assert.Equal(t, map[string]any{ + "read_only_only": map[string]any{"type": "boolean"}, + "exclude_destructive": map[string]any{"type": "boolean"}, + "exclude_open_world": map[string]any{"type": "boolean"}, + }, filters["properties"], "the three spec-094 annotation filters, and only those (FR-007)") + assert.NotContains(t, tool.InputSchema.Properties, describeCheckReservedHashes) + assert.Len(t, tool.InputSchema.Properties, 3) } // T029 (FR-012): describe_tool output is byte-identical whether the configured diff --git a/internal/server/preflight_glue.go b/internal/server/preflight_glue.go index 5ba3538b..ae382588 100644 --- a/internal/server/preflight_glue.go +++ b/internal/server/preflight_glue.go @@ -8,12 +8,14 @@ import ( "go.uber.org/zap" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" "github.com/smart-mcp-proxy/mcpproxy-go/internal/index" "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime/stateview" "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/toolannotations" ) // This file is the ONLY bridge between the pure evaluator in internal/preflight @@ -40,12 +42,8 @@ import ( // read — the served surface maps those to 503 rather than fabricating a reason // code (FR-006). func (p *MCPProxyServer) RunPreflight(ctx context.Context, params preflight.Params) (preflight.Outcome, error) { - if p == nil || p.storage == nil || p.index == nil { - return preflight.Outcome{}, preflight.ErrRuntimeUnavailable - } - cfg := p.currentConfig() - if cfg == nil { - return preflight.Outcome{}, preflight.ErrRuntimeUnavailable + if _, err := p.preflightRuntimeConfig(); err != nil { + return preflight.Outcome{}, err } scope, err := p.resolvePreflightScope(params) @@ -58,6 +56,40 @@ func (p *MCPProxyServer) RunPreflight(ctx context.Context, params preflight.Para tier = preflight.TierOperator } + return p.evaluatePreflight(ctx, params.Tools, tier, scope, params.Filters) +} + +// preflightRuntimeConfig is the shared "can this process answer at all" guard: +// no storage, no index or no live config is the degraded state FR-006 names, +// and every front door must refuse rather than evaluate blind. +func (p *MCPProxyServer) preflightRuntimeConfig() (*config.Config, error) { + if p == nil || p.storage == nil || p.index == nil { + return nil, preflight.ErrRuntimeUnavailable + } + cfg := p.currentConfig() + if cfg == nil { + return nil, preflight.ErrRuntimeUnavailable + } + return cfg, nil +} + +// evaluatePreflight is the ONE evaluation seam. Both front doors — the REST +// surface's RunPreflight (which resolves its scope from profile NAMES) and the +// in-band check mode's RunPreflightForSession (which projects the session's own +// visibility predicate, spec 099 FR-003/FR-009a) — end here, so the two +// surfaces cannot drift in what they read or how they read it. +func (p *MCPProxyServer) evaluatePreflight( + ctx context.Context, + refs []preflight.ToolRef, + tier preflight.Tier, + scope *preflight.Scope, + filters toolannotations.Filters, +) (preflight.Outcome, error) { + cfg, err := p.preflightRuntimeConfig() + if err != nil { + return preflight.Outcome{}, err + } + // ONE snapshot for the whole request: it supplies both the connection state // and the tool annotations, so every tool in a batch is judged against the // same instant and the annotation filters see exactly what the spec 094 @@ -74,7 +106,7 @@ func (p *MCPProxyServer) RunPreflight(ctx context.Context, params preflight.Para Policy: &preflightConfigPolicy{proxy: p, cfg: cfg}, Tier: tier, Scope: scope, - Filters: params.Filters, + Filters: filters, // With a real snapshot in hand, a configured server missing from it is // state the supervisor has not published yet (startup / reconcile / // config-add windows) — the evaluator answers the retryable @@ -84,7 +116,7 @@ func (p *MCPProxyServer) RunPreflight(ctx context.Context, params preflight.Para RequireRuntimeEntry: state != nil, } - results, err := preflight.Evaluate(ctx, ec, params.Tools) + results, err := preflight.Evaluate(ctx, ec, refs) if err != nil { return preflight.Outcome{}, err } @@ -132,6 +164,119 @@ func (p *MCPProxyServer) resolvePreflightScope(params preflight.Params) (*prefli return preflight.ResolveScope(inputs), nil } +// RunPreflightForSession evaluates one IN-BAND preflight — describe_tool check +// mode (spec 099 FR-003/FR-009/FR-009a). +// +// Two things are pinned here rather than passed in, because they are the +// security contract of the in-band surface: +// +// - The tier is ALWAYS the agent-token tier. /mcp is unauthenticated by +// default and its middleware hands such requests a full admin context for +// back-compat, so no auth context in band proves anything about who is +// calling; a caller-selectable tier would be a caller-selectable +// disclosure. Operators wanting scope names and hashes use the REST +// surface over an authenticated channel (FR-009). +// - The scope is the SESSION's, never a request parameter: check mode takes +// no `profile`, so an agent cannot re-point or widen its own view by +// asking (FR-009a). +func (p *MCPProxyServer) RunPreflightForSession(ctx context.Context, refs []preflight.ToolRef, filters toolannotations.Filters) (preflight.Outcome, error) { + scope, err := p.sessionPreflightScope(ctx) + if err != nil { + return preflight.Outcome{}, err + } + return p.evaluatePreflight(ctx, refs, preflight.TierAgentToken, scope, filters) +} + +// sessionPreflightScope projects the session's OWN visibility predicate onto a +// preflight scope (FR-009a). +// +// It deliberately does not re-derive the scope from names the way the REST path +// does. The composition an MCP session is subject to — agent-token +// allowed_servers ∩ agent-token profile pin ∩ the session's active profile +// (path-pinned or set_profile) — already exists as exactly two functions: +// resolveActiveProfile and serverInScope, which retrieve_tools and describe_tool +// use on every call. Enumerating the servers an evaluation could name and +// filtering them through that same predicate makes the invariant structural: a +// check can never see a tool the same session's retrieve_tools cannot, because +// it is the same test, not a reimplementation of it. In particular it inherits +// the deny-all resolution of a token pin whose profile was deleted, and +// CanAccessServer's rule that an agent token with an EMPTY allowed_servers list +// grants nothing (which a name-based intersection would read as "no +// restriction"). +// +// nil means unrestricted, and is returned only when there is no auth context +// and no profile in effect — i.e. nothing to restrict. +func (p *MCPProxyServer) sessionPreflightScope(ctx context.Context) (*preflight.Scope, error) { + authCtx := auth.AuthContextFromContext(ctx) + profileName, profileScope := p.resolveActiveProfile(ctx) + if authCtx == nil && profileScope == nil { + return nil, nil + } + + names, err := p.preflightServerUniverse() + if err != nil { + return nil, err + } + allowed := make([]string, 0, len(names)) + for _, name := range names { + if p.serverInScope(authCtx, profileScope, name) { + allowed = append(allowed, name) + } + } + return preflight.NewScope(profileName, allowed), nil +} + +// preflightServerUniverse is every server name an evaluation could legitimately +// reach: stored upstreams (the authority the evaluator's ServerPolicy reads), +// the indexed corpus (the authority did_you_mean suggests from) and the live +// config. A name outside this union is not "out of scope" — it is not +// configured, which the precedence chain answers before the scope gate ever +// runs. +// +// A failed read is an error, never a smaller universe: silently shrinking it +// would turn a storage failure into a scope verdict. +func (p *MCPProxyServer) preflightServerUniverse() ([]string, error) { + seen := make(map[string]struct{}) + names := make([]string, 0, 16) + add := func(name string) { + if name == "" { + return + } + if _, ok := seen[name]; ok { + return + } + seen[name] = struct{}{} + names = append(names, name) + } + + upstreams, err := p.storage.ListUpstreams() + if err != nil { + return nil, fmt.Errorf("preflight: list upstream servers: %w", err) + } + for _, server := range upstreams { + if server != nil { + add(server.Name) + } + } + + indexed, err := p.index.GetAllIndexedServerNames() + if err != nil { + return nil, fmt.Errorf("preflight: list indexed servers: %w", err) + } + for _, name := range indexed { + add(name) + } + + if cfg := p.currentConfig(); cfg != nil { + for _, server := range cfg.Servers { + if server != nil { + add(server.Name) + } + } + } + return names, nil +} + // --------------------------------------------------------------------------- // IndexReader // --------------------------------------------------------------------------- diff --git a/internal/server/testdata/toolslist_goldens/default_server.json b/internal/server/testdata/toolslist_goldens/default_server.json index 777607c1..b7e8267d 100644 --- a/internal/server/testdata/toolslist_goldens/default_server.json +++ b/internal/server/testdata/toolslist_goldens/default_server.json @@ -127,11 +127,30 @@ "idempotentHint": false, "openWorldHint": false }, - "description": "Return full JSON Schema + long description for specific tools found via retrieve_tools. Use when a compact signature is marked lossy ('~') or you need the exact schema before calling.", + "description": "Return full JSON Schema + long description for specific tools found via retrieve_tools. Use when a compact signature is marked lossy ('~') or you need the exact schema before calling. With check:true it returns one availability verdict per id instead of schemas ('ready', or a reason code with retryable/action), to gate a plan before its first call.", "inputSchema": { "properties": { + "check": { + "description": "Check availability only, no schemas (default: false).", + "type": "boolean" + }, + "filters": { + "description": "check:true only. Annotation filters, as in retrieve_tools.", + "properties": { + "exclude_destructive": { + "type": "boolean" + }, + "exclude_open_world": { + "type": "boolean" + }, + "read_only_only": { + "type": "boolean" + } + }, + "type": "object" + }, "tool_ids": { - "description": "1-5 tool ids in '\u003cserver\u003e:\u003ctool\u003e' format, from retrieve_tools results.", + "description": "Tool ids in '\u003cserver\u003e:\u003ctool\u003e' format from retrieve_tools. Max 5, or 50 with check:true.", "items": { "type": "string" }, diff --git a/internal/server/testdata/toolslist_goldens/pre099/default_server.json b/internal/server/testdata/toolslist_goldens/pre099/default_server.json new file mode 100644 index 00000000..777607c1 --- /dev/null +++ b/internal/server/testdata/toolslist_goldens/pre099/default_server.json @@ -0,0 +1,469 @@ +{ + "call_tool_destructive": { + "annotations": { + "title": "Call Tool (Destructive)", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a DESTRUCTIVE tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: delete, remove, drop, revoke, disable, destroy, purge, reset, clear, unsubscribe, cancel, terminate, close, archive, ban, block, disconnect, kill, wipe, truncate, force, hard. Examples: delete_repo, remove_user, drop_table, revoke_access, clear_cache, terminate_session. Use for irreversible or high-impact operations. Result blocks may be prefixed by the marker line: [mcpproxy:toon/v1] TOON-encoded JSON (toon-format.org); decode to JSON before reuse - tool arguments must still be sent as JSON.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to the upstream tool as a native JSON object. Build arguments from the tool's compact signature ('sig') in retrieve_tools results — '*' marks required parameters, '~' marks a lossy signature (call describe_tool for the full JSON Schema before calling). Example: {\"path\": \"src/index.ts\", \"limit\": 20}. This is the preferred parameter — it eliminates JSON escaping overhead. Use 'args_json' only if your client cannot produce nested JSON objects.", + "properties": {}, + "type": "object" + }, + "args_json": { + "description": "Legacy: arguments as a pre-serialized JSON string. Prefer the 'args' parameter instead — it accepts a native JSON object and eliminates escaping overhead. If both are provided, 'args_json' wins for backward compatibility.", + "type": "string" + }, + "intent_data_sensitivity": { + "description": "Classify data being deleted: public, internal, private, or unknown. Important for tracking destructive operations on sensitive data.", + "type": "string" + }, + "intent_reason": { + "description": "Why is this deletion needed? Provide justification like 'User confirmed cleanup' or 'Removing obsolete data'.", + "type": "string" + }, + "name": { + "description": "Tool name in format 'server:tool' (e.g., 'github:delete_repo'). CRITICAL: You MUST use exact names from retrieve_tools results - do NOT guess or invent server names. Unknown servers will fail.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "call_tool_destructive" + }, + "call_tool_read": { + "annotations": { + "title": "Call Tool (Read)", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a READ-ONLY tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. Examples: search_files, get_user, list_repositories, query_database, find_issues, check_status. This is the DEFAULT choice when unsure - most tools are read-only. Result blocks may be prefixed by the marker line: [mcpproxy:toon/v1] TOON-encoded JSON (toon-format.org); decode to JSON before reuse - tool arguments must still be sent as JSON.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to the upstream tool as a native JSON object. Build arguments from the tool's compact signature ('sig') in retrieve_tools results — '*' marks required parameters, '~' marks a lossy signature (call describe_tool for the full JSON Schema before calling). Example: {\"path\": \"src/index.ts\", \"limit\": 20}. This is the preferred parameter — it eliminates JSON escaping overhead. Use 'args_json' only if your client cannot produce nested JSON objects.", + "properties": {}, + "type": "object" + }, + "args_json": { + "description": "Legacy: arguments as a pre-serialized JSON string. Prefer the 'args' parameter instead — it accepts a native JSON object and eliminates escaping overhead. If both are provided, 'args_json' wins for backward compatibility.", + "type": "string" + }, + "intent_data_sensitivity": { + "description": "Classify data being accessed: public, internal, private, or unknown. Helps track sensitive data access patterns.", + "type": "string" + }, + "intent_reason": { + "description": "Why is this tool being called? Provide context like 'User asked to check status' or 'Gathering data for report'.", + "type": "string" + }, + "name": { + "description": "Tool name in format 'server:tool' (e.g., 'github:get_user'). CRITICAL: You MUST use exact names from retrieve_tools results - do NOT guess or invent server names. Unknown servers will fail.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "call_tool_read" + }, + "call_tool_write": { + "annotations": { + "title": "Call Tool (Write)", + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a STATE-MODIFYING tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: create, update, modify, add, set, send, edit, change, write, post, put, patch, insert, upload, submit, assign, configure, enable, register, subscribe, publish, move, copy, rename, merge. Examples: create_issue, update_file, send_message, add_comment, set_status, edit_page. Use only when explicitly modifying state. Result blocks may be prefixed by the marker line: [mcpproxy:toon/v1] TOON-encoded JSON (toon-format.org); decode to JSON before reuse - tool arguments must still be sent as JSON.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to the upstream tool as a native JSON object. Build arguments from the tool's compact signature ('sig') in retrieve_tools results — '*' marks required parameters, '~' marks a lossy signature (call describe_tool for the full JSON Schema before calling). Example: {\"path\": \"src/index.ts\", \"limit\": 20}. This is the preferred parameter — it eliminates JSON escaping overhead. Use 'args_json' only if your client cannot produce nested JSON objects.", + "properties": {}, + "type": "object" + }, + "args_json": { + "description": "Legacy: arguments as a pre-serialized JSON string. Prefer the 'args' parameter instead — it accepts a native JSON object and eliminates escaping overhead. If both are provided, 'args_json' wins for backward compatibility.", + "type": "string" + }, + "intent_data_sensitivity": { + "description": "Classify data being modified: public, internal, private, or unknown. Helps track sensitive data changes.", + "type": "string" + }, + "intent_reason": { + "description": "Why is this modification needed? Provide context like 'User requested update' or 'Fixing reported issue'.", + "type": "string" + }, + "name": { + "description": "Tool name in format 'server:tool' (e.g., 'github:create_issue'). CRITICAL: You MUST use exact names from retrieve_tools results - do NOT guess or invent server names. Unknown servers will fail.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "call_tool_write" + }, + "describe_tool": { + "annotations": { + "title": "Describe Tool", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Return full JSON Schema + long description for specific tools found via retrieve_tools. Use when a compact signature is marked lossy ('~') or you need the exact schema before calling.", + "inputSchema": { + "properties": { + "tool_ids": { + "description": "1-5 tool ids in '\u003cserver\u003e:\u003ctool\u003e' format, from retrieve_tools results.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "tool_ids" + ], + "type": "object" + }, + "name": "describe_tool" + }, + "list_registries": { + "annotations": { + "title": "List Registries", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "📋 List all available MCP registries. Use this FIRST to discover which registries you can search with the 'search_servers' tool. Each registry contains different collections of MCP servers that can be added as upstreams.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "list_registries" + }, + "quarantine_security": { + "annotations": { + "title": "Quarantine Security", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Security quarantine management for MCP servers and tools. Review and manage quarantined servers and tools to prevent Tool Poisoning Attacks (TPAs). Supports server-level quarantine and tool-level approval for individual tool description/schema changes. NOTE: Unquarantining servers is only available through manual config editing or system tray UI for security.", + "inputSchema": { + "properties": { + "name": { + "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools)", + "type": "string" + }, + "operation": { + "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled.", + "enum": [ + "list_quarantined", + "inspect_quarantined", + "quarantine_server", + "inspect_tools", + "approve_tool", + "approve_all_tools", + "block_tool", + "block_all_tools", + "enable_tool", + "disable_tool" + ], + "type": "string" + }, + "tool_name": { + "description": "Tool name (required for approve_tool and block_tool operations)", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + "name": "quarantine_security" + }, + "read_cache": { + "annotations": { + "title": "Read Cache", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Retrieve paginated data when mcpproxy indicates a tool response was truncated. Use the cache key provided in truncation messages to access the complete dataset with pagination.", + "inputSchema": { + "properties": { + "key": { + "description": "Cache key provided by mcpproxy when a response was truncated (e.g. 'Use read_cache tool: key=\"abc123def...\"')", + "type": "string" + }, + "limit": { + "description": "Maximum number of records to return per page (default: 50, max: 1000)", + "type": "number" + }, + "offset": { + "description": "Starting record offset for pagination (default: 0)", + "type": "number" + } + }, + "required": [ + "key" + ], + "type": "object" + }, + "name": "read_cache" + }, + "retrieve_tools": { + "annotations": { + "title": "Retrieve Tools", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "🔍 CALL THIS FIRST to discover relevant tools! This is the primary tool discovery mechanism that searches across ALL upstream MCP servers using intelligent BM25 full-text search. Always use this before attempting to call any specific tools. Use natural language to describe what you want to accomplish (e.g., 'create GitHub repository', 'query database', 'weather forecast'). Results include 'annotations' (tool behavior hints like destructiveHint) and 'call_with' recommendation indicating which tool variant to use (call_tool_read/write/destructive). Then use the recommended variant with an 'intent' parameter. Compact mode returns one-line signatures ('sig': '*'=required, '~'=lossy) with first-sentence 'desc'; call describe_tool for full schemas. NOTE: Quarantined servers are excluded from search results for security. Use 'quarantine_security' tool to examine and manage quarantined servers. TO ADD NEW SERVERS: Use 'list_registries' then 'search_servers' to find and add new MCP servers. ANNOTATION FILTERS: read_only_only, exclude_destructive and exclude_open_world self-restrict discovery. When they withhold tools that matched your query, the response carries a 'filter_diagnostics' block with per-filter counts (split into missing upstream annotations vs. explicitly unsafe ones) and one suggestion; it is absent when nothing was withheld. Filter diagnostics describe this call's candidate window, not the whole catalog.", + "inputSchema": { + "properties": { + "debug": { + "description": "Enable debug mode with detailed scoring and ranking explanations (default: false)", + "type": "boolean" + }, + "detail": { + "description": "Per-call response serialization override: 'compact' returns one-line signatures (sig/desc/lossy) instead of full schemas; 'full' returns complete inputSchema entries. Unset: the server's configured tool_response_mode applies.", + "enum": [ + "compact", + "full" + ], + "type": "string" + }, + "exclude_destructive": { + "description": "Exclude tools with destructiveHint=true or unset (MCP default is destructive). Use to avoid destructive operations.", + "type": "boolean" + }, + "exclude_open_world": { + "description": "Exclude tools with openWorldHint=true or unset (MCP default is open-world). Use to restrict to local/sandboxed tools.", + "type": "boolean" + }, + "explain_tool": { + "description": "When debug=true, explain why a specific tool was ranked low (format: 'server:tool')", + "type": "string" + }, + "include_disabled": { + "description": "Set true to also surface tools that exist but are currently locked by config, user, or quarantine (default: false). Returns a 'disabled' list (name/server/description/status) plus a 'remediation' map; callable results are unaffected and listed first.", + "type": "boolean" + }, + "include_session_risk_warning": { + "description": "Include the prose 'warning' string in session_risk when the lethal trifecta is detected (default: false; structured fields are always returned). Server-side default can be flipped via the 'tool_response_session_risk_warning' config flag.", + "type": "boolean" + }, + "include_stats": { + "description": "Include usage statistics for returned tools (default: false)", + "type": "boolean" + }, + "limit": { + "description": "Maximum number of tools to return (default: configured tools_limit, max: 100)", + "type": "number" + }, + "query": { + "description": "Natural language description of what you want to accomplish. Be specific about your task (e.g., 'create a new GitHub repository', 'get weather for London', 'query SQLite database for users'). The search will find the most relevant tools across all connected servers.", + "type": "string" + }, + "read_only_only": { + "description": "Only return tools with readOnlyHint=true. Use to self-restrict to safe read operations.", + "type": "boolean" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "retrieve_tools" + }, + "search_servers": { + "annotations": { + "title": "Search Servers", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "🔍 Discover MCP servers from known registries with repository type detection. Search and filter servers from embedded registry list to find new MCP servers that can be added as upstreams. Features npm/PyPI package detection for enhanced install commands. WORKFLOW: 1) Call 'list_registries' first to see available registries, 2) Use this tool with a registry ID to search servers. Results include server URLs and repository information ready for direct use with upstream_servers add command.", + "inputSchema": { + "properties": { + "limit": { + "description": "Maximum number of results to return (default: 10, max: 50)", + "type": "number" + }, + "registry": { + "description": "Registry ID or name to search (e.g., 'smithery', 'mcprun', 'pulse'). Use 'list_registries' tool first to see available registries.", + "type": "string" + }, + "search": { + "description": "Search term to filter servers by name or description (case-insensitive)", + "type": "string" + }, + "tag": { + "description": "Filter servers by tag/category (if supported by registry)", + "type": "string" + } + }, + "required": [ + "registry" + ], + "type": "object" + }, + "name": "search_servers" + }, + "set_profile": { + "annotations": { + "title": "Set Profile", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Switch the active profile for THIS session. A profile scopes tool discovery (retrieve_tools) and tool calls to a named subset of upstream servers — useful to focus an agent on one task domain (e.g. 'research', 'deploy'). The selection persists for the lifetime of the current MCP session and applies to subsequent retrieve_tools / call_tool_* / code_execution calls on the base /mcp endpoint without re-indexing. Pass an empty string to clear the selection and go back to all servers. Note: an explicit /mcp/p/\u003cslug\u003e URL still overrides the session profile for that request, and a profile-pinned agent token cannot switch away from its pinned profile.", + "inputSchema": { + "properties": { + "profile": { + "description": "Profile slug to activate for this session (e.g. 'research'). Pass \"\" (empty) to clear the active profile and return to all servers.", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "set_profile" + }, + "upstream_servers": { + "annotations": { + "title": "Upstream Servers", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Manage upstream MCP servers - add, remove, update, and list servers. Includes Docker isolation configuration and connection status monitoring. SECURITY: Newly added servers are automatically quarantined to prevent Tool Poisoning Attacks (TPAs). Use 'quarantine_security' tool to review and manage quarantined servers. NOTE: Unquarantining servers is only available through manual config editing or system tray UI for security.\n\nDocker Isolation: Use 'isolation_json' parameter to configure per-server Docker images, CPU/memory limits, and network isolation. Example: {\"enabled\": true, \"image\": \"node:20\", \"network_mode\": \"bridge\"}.\n\nSMART PATCHING (update/patch): Uses deep merge - only specify fields you want to change. Omitted fields are PRESERVED, not removed. Examples:\n- Enable server: {\"operation\": \"patch\", \"name\": \"my-server\", \"enabled\": true} - only enabled changes\n- Enable isolation: {\"operation\": \"patch\", \"name\": \"my-server\", \"isolation_json\": \"{\\\"enabled\\\": true}\"} - enables isolation with defaults\n- Update image: {\"operation\": \"patch\", \"name\": \"my-server\", \"isolation_json\": \"{\\\"image\\\": \\\"python:3.12\\\"}\"} - other isolation fields preserved\n- Add env var: env_json merges with existing vars\n- Replace args: args_json replaces entirely (arrays not merged)\n- Remove field: use 'null' (e.g., isolation_json: \"null\" removes isolation)", + "inputSchema": { + "properties": { + "args_json": { + "description": "Command arguments for stdio servers as a JSON array of strings (e.g., '[\"mcp-server-sqlite\", \"--db-path\", \"/path/to/db\"]'). For update/patch: REPLACES all existing args (arrays are not merged).", + "type": "string" + }, + "command": { + "description": "Command to run for stdio servers (e.g., 'uvx', 'python')", + "type": "string" + }, + "enabled": { + "description": "Whether server should be enabled (default: true)", + "type": "boolean" + }, + "env_json": { + "description": "Environment variables for stdio servers as JSON object (e.g., '{\"API_KEY\": \"value\"}'). For update/patch: MERGES with existing vars (new keys added, existing keys updated).", + "type": "string" + }, + "headers_json": { + "description": "HTTP headers for authentication as JSON object (e.g., '{\"Authorization\": \"Bearer token\"}'). For update/patch: MERGES with existing headers (new keys added, existing keys updated).", + "type": "string" + }, + "id": { + "description": "Server id within the registry - required for add_from_registry.", + "type": "string" + }, + "init_timeout": { + "description": "Per-server MCP `initialize` handshake deadline as a duration string (e.g. '120s', '3m'). Raise this for upstreams that do legitimate first-run warmup (cache/index build) before responding to `initialize`, so they are not killed mid-startup. Unset → global default (30s). Bounds: 1s–30m. Used with add/update/patch.", + "type": "string" + }, + "isolation_json": { + "description": "Docker isolation config as JSON object. MERGES with existing settings - only provided fields change. Use 'null' to remove isolation entirely. Example: '{\"image\": \"python:3.12\"}' updates only the image.", + "type": "string" + }, + "lines": { + "description": "Number of lines to tail from server log (default: 50, max: 500) - used with tail_log operation", + "type": "number" + }, + "name": { + "description": "Server name (required for add/remove/update/patch/tail_log operations; optional name override for add_from_registry)", + "type": "string" + }, + "oauth_json": { + "description": "OAuth config as JSON object. MERGES with existing settings. Use 'null' to remove OAuth entirely. Fields: client_id, client_secret, scopes (array - replaces).", + "type": "string" + }, + "operation": { + "description": "Operation: list, add, remove, update, patch, tail_log, add_from_registry, enable, disable, restart, refresh. 'update' and 'patch' use smart merge - only specified fields change, others preserved. 'add_from_registry' adds an upstream from a registry reference (registry+id) so you need not hand-construct command/args/url - the server re-derives the runnable config and quarantines it. 'refresh' re-discovers and re-indexes a server's tools without changing any security state - use it to make just-approved tools searchable immediately. For quarantine operations, use the 'quarantine_security' tool.", + "enum": [ + "list", + "add", + "remove", + "update", + "patch", + "tail_log", + "add_from_registry", + "enable", + "disable", + "restart", + "refresh" + ], + "type": "string" + }, + "protocol": { + "description": "Transport protocol: stdio, http, sse, streamable-http, auto (default: auto-detect)", + "enum": [ + "stdio", + "http", + "sse", + "streamable-http", + "auto" + ], + "type": "string" + }, + "registry": { + "description": "Registry id to add from (e.g. 'pulse') - required for add_from_registry. Use the 'list_registries'/'search_servers' tools to discover registries and server ids.", + "type": "string" + }, + "trust_mode": { + "description": "Per-server trust tier governing new-server admission AND tool-change approval (spec 086): 'auto' = approve without scanning; 'scan' = auto-approve only when the fast offline TPA scan is green, else hold for review; 'manual' = human reviews every change. Empty → manual (secure default). Used with add/update/patch.", + "enum": [ + "auto", + "scan", + "manual" + ], + "type": "string" + }, + "url": { + "description": "Server URL for HTTP/SSE servers (e.g., 'http://localhost:3001')", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + "name": "upstream_servers" + } +} diff --git a/internal/server/testdata/toolslist_goldens/pre099/retrieve_tools_mode.json b/internal/server/testdata/toolslist_goldens/pre099/retrieve_tools_mode.json new file mode 100644 index 00000000..c68cdd28 --- /dev/null +++ b/internal/server/testdata/toolslist_goldens/pre099/retrieve_tools_mode.json @@ -0,0 +1,490 @@ +{ + "call_tool_destructive": { + "annotations": { + "title": "Call Tool (Destructive)", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a DESTRUCTIVE tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: delete, remove, drop, revoke, disable, destroy, purge, reset, clear, unsubscribe, cancel, terminate, close, archive, ban, block, disconnect, kill, wipe, truncate, force, hard. Examples: delete_repo, remove_user, drop_table, revoke_access, clear_cache, terminate_session. Use for irreversible or high-impact operations. Result blocks may be prefixed by the marker line: [mcpproxy:toon/v1] TOON-encoded JSON (toon-format.org); decode to JSON before reuse - tool arguments must still be sent as JSON.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to the upstream tool as a native JSON object. Build arguments from the tool's compact signature ('sig') in retrieve_tools results — '*' marks required parameters, '~' marks a lossy signature (call describe_tool for the full JSON Schema before calling). Example: {\"path\": \"src/index.ts\", \"limit\": 20}. This is the preferred parameter — it eliminates JSON escaping overhead. Use 'args_json' only if your client cannot produce nested JSON objects.", + "properties": {}, + "type": "object" + }, + "args_json": { + "description": "Legacy: arguments as a pre-serialized JSON string. Prefer the 'args' parameter instead — it accepts a native JSON object and eliminates escaping overhead. If both are provided, 'args_json' wins for backward compatibility.", + "type": "string" + }, + "intent_data_sensitivity": { + "description": "Classify data being deleted: public, internal, private, or unknown. Important for tracking destructive operations on sensitive data.", + "type": "string" + }, + "intent_reason": { + "description": "Why is this deletion needed? Provide justification like 'User confirmed cleanup' or 'Removing obsolete data'.", + "type": "string" + }, + "name": { + "description": "Tool name in format 'server:tool' (e.g., 'github:delete_repo'). CRITICAL: You MUST use exact names from retrieve_tools results - do NOT guess or invent server names. Unknown servers will fail.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "call_tool_destructive" + }, + "call_tool_read": { + "annotations": { + "title": "Call Tool (Read)", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a READ-ONLY tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. Examples: search_files, get_user, list_repositories, query_database, find_issues, check_status. This is the DEFAULT choice when unsure - most tools are read-only. Result blocks may be prefixed by the marker line: [mcpproxy:toon/v1] TOON-encoded JSON (toon-format.org); decode to JSON before reuse - tool arguments must still be sent as JSON.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to the upstream tool as a native JSON object. Build arguments from the tool's compact signature ('sig') in retrieve_tools results — '*' marks required parameters, '~' marks a lossy signature (call describe_tool for the full JSON Schema before calling). Example: {\"path\": \"src/index.ts\", \"limit\": 20}. This is the preferred parameter — it eliminates JSON escaping overhead. Use 'args_json' only if your client cannot produce nested JSON objects.", + "properties": {}, + "type": "object" + }, + "args_json": { + "description": "Legacy: arguments as a pre-serialized JSON string. Prefer the 'args' parameter instead — it accepts a native JSON object and eliminates escaping overhead. If both are provided, 'args_json' wins for backward compatibility.", + "type": "string" + }, + "intent_data_sensitivity": { + "description": "Classify data being accessed: public, internal, private, or unknown. Helps track sensitive data access patterns.", + "type": "string" + }, + "intent_reason": { + "description": "Why is this tool being called? Provide context like 'User asked to check status' or 'Gathering data for report'.", + "type": "string" + }, + "name": { + "description": "Tool name in format 'server:tool' (e.g., 'github:get_user'). CRITICAL: You MUST use exact names from retrieve_tools results - do NOT guess or invent server names. Unknown servers will fail.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "call_tool_read" + }, + "call_tool_write": { + "annotations": { + "title": "Call Tool (Write)", + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a STATE-MODIFYING tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: create, update, modify, add, set, send, edit, change, write, post, put, patch, insert, upload, submit, assign, configure, enable, register, subscribe, publish, move, copy, rename, merge. Examples: create_issue, update_file, send_message, add_comment, set_status, edit_page. Use only when explicitly modifying state. Result blocks may be prefixed by the marker line: [mcpproxy:toon/v1] TOON-encoded JSON (toon-format.org); decode to JSON before reuse - tool arguments must still be sent as JSON.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to the upstream tool as a native JSON object. Build arguments from the tool's compact signature ('sig') in retrieve_tools results — '*' marks required parameters, '~' marks a lossy signature (call describe_tool for the full JSON Schema before calling). Example: {\"path\": \"src/index.ts\", \"limit\": 20}. This is the preferred parameter — it eliminates JSON escaping overhead. Use 'args_json' only if your client cannot produce nested JSON objects.", + "properties": {}, + "type": "object" + }, + "args_json": { + "description": "Legacy: arguments as a pre-serialized JSON string. Prefer the 'args' parameter instead — it accepts a native JSON object and eliminates escaping overhead. If both are provided, 'args_json' wins for backward compatibility.", + "type": "string" + }, + "intent_data_sensitivity": { + "description": "Classify data being modified: public, internal, private, or unknown. Helps track sensitive data changes.", + "type": "string" + }, + "intent_reason": { + "description": "Why is this modification needed? Provide context like 'User requested update' or 'Fixing reported issue'.", + "type": "string" + }, + "name": { + "description": "Tool name in format 'server:tool' (e.g., 'github:create_issue'). CRITICAL: You MUST use exact names from retrieve_tools results - do NOT guess or invent server names. Unknown servers will fail.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "call_tool_write" + }, + "code_execution": { + "annotations": { + "title": "Code Execution (Disabled)", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Code execution is currently disabled. Enable it by setting \"enable_code_execution\": true in your mcpproxy config.", + "inputSchema": { + "properties": { + "code": { + "description": "JavaScript source code to execute.", + "type": "string" + }, + "script": { + "description": "Name of a stored script to execute.", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "code_execution" + }, + "describe_tool": { + "annotations": { + "title": "Describe Tool", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Return full JSON Schema + long description for specific tools found via retrieve_tools. Use when a compact signature is marked lossy ('~') or you need the exact schema before calling.", + "inputSchema": { + "properties": { + "tool_ids": { + "description": "1-5 tool ids in '\u003cserver\u003e:\u003ctool\u003e' format, from retrieve_tools results.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "tool_ids" + ], + "type": "object" + }, + "name": "describe_tool" + }, + "list_registries": { + "annotations": { + "title": "List Registries", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "📋 List all available MCP registries. Use this FIRST to discover which registries you can search with the 'search_servers' tool. Each registry contains different collections of MCP servers that can be added as upstreams.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "list_registries" + }, + "quarantine_security": { + "annotations": { + "title": "Quarantine Security", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Security quarantine management for MCP servers and tools. Review and manage quarantined servers and tools to prevent Tool Poisoning Attacks (TPAs). Supports server-level quarantine and tool-level approval for individual tool description/schema changes. NOTE: Unquarantining servers is only available through manual config editing or system tray UI for security.", + "inputSchema": { + "properties": { + "name": { + "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools)", + "type": "string" + }, + "operation": { + "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled.", + "enum": [ + "list_quarantined", + "inspect_quarantined", + "quarantine_server", + "inspect_tools", + "approve_tool", + "approve_all_tools", + "block_tool", + "block_all_tools", + "enable_tool", + "disable_tool" + ], + "type": "string" + }, + "tool_name": { + "description": "Tool name (required for approve_tool and block_tool operations)", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + "name": "quarantine_security" + }, + "read_cache": { + "annotations": { + "title": "Read Cache", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Retrieve paginated data when mcpproxy indicates a tool response was truncated. Use the cache key provided in truncation messages.", + "inputSchema": { + "properties": { + "key": { + "description": "Cache key provided by mcpproxy when a response was truncated.", + "type": "string" + }, + "limit": { + "description": "Maximum number of records to return per page (default: 50, max: 1000)", + "type": "number" + }, + "offset": { + "description": "Starting record offset for pagination (default: 0)", + "type": "number" + } + }, + "required": [ + "key" + ], + "type": "object" + }, + "name": "read_cache" + }, + "retrieve_tools": { + "annotations": { + "title": "Retrieve Tools", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Search and discover available upstream tools using BM25 full-text search. WORKFLOW: 1) Call this tool first to find relevant tools, 2) Check the 'call_with' field in results to determine which variant to use, 3) Call the tool using call_tool_read, call_tool_write, or call_tool_destructive. Results include 'annotations' (tool behavior hints like destructiveHint), 'call_with' recommendation, and a structured `session_risk` object (level, lethal_trifecta, has_open_world_tools, has_destructive_tools, has_write_tools). Compact mode returns one-line signatures ('sig': '*'=required, '~'=lossy) with first-sentence 'desc'; call describe_tool for full schemas. Use natural language to describe what you want to accomplish. ANNOTATION FILTERS: read_only_only, exclude_destructive and exclude_open_world self-restrict discovery. When they withhold tools that matched your query, the response carries a 'filter_diagnostics' block with per-filter counts (split into missing upstream annotations vs. explicitly unsafe ones) and one suggestion; it is absent when nothing was withheld. Filter diagnostics describe this call's candidate window, not the whole catalog.", + "inputSchema": { + "properties": { + "debug": { + "description": "Enable debug mode with detailed scoring and ranking explanations (default: false)", + "type": "boolean" + }, + "detail": { + "description": "Per-call response serialization override: 'compact' returns one-line signatures (sig/desc/lossy) instead of full schemas; 'full' returns complete inputSchema entries. Unset: the server's configured tool_response_mode applies.", + "enum": [ + "compact", + "full" + ], + "type": "string" + }, + "exclude_destructive": { + "description": "Exclude tools with destructiveHint=true or unset (MCP default is destructive). Use to avoid destructive operations.", + "type": "boolean" + }, + "exclude_open_world": { + "description": "Exclude tools with openWorldHint=true or unset (MCP default is open-world). Use to restrict to local/sandboxed tools.", + "type": "boolean" + }, + "explain_tool": { + "description": "When debug=true, explain why a specific tool was ranked low (format: 'server:tool')", + "type": "string" + }, + "include_session_risk_warning": { + "description": "Include the prose 'warning' string in session_risk when the lethal trifecta is detected (default: false; structured fields are always returned). Server-side default can be flipped via the 'tool_response_session_risk_warning' config flag.", + "type": "boolean" + }, + "include_stats": { + "description": "Include usage statistics for returned tools (default: false)", + "type": "boolean" + }, + "limit": { + "description": "Maximum number of tools to return (default: configured tools_limit, max: 100)", + "type": "number" + }, + "query": { + "description": "Natural language description of what you want to accomplish. Be specific (e.g., 'create a new GitHub repository', 'get weather for London').", + "type": "string" + }, + "read_only_only": { + "description": "Only return tools with readOnlyHint=true. Use to self-restrict to safe read operations.", + "type": "boolean" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "retrieve_tools" + }, + "search_servers": { + "annotations": { + "title": "Search Servers", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "🔍 Discover MCP servers from known registries with repository type detection. Search and filter servers from embedded registry list to find new MCP servers that can be added as upstreams. Features npm/PyPI package detection for enhanced install commands. WORKFLOW: 1) Call 'list_registries' first to see available registries, 2) Use this tool with a registry ID to search servers. Results include server URLs and repository information ready for direct use with upstream_servers add command.", + "inputSchema": { + "properties": { + "limit": { + "description": "Maximum number of results to return (default: 10, max: 50)", + "type": "number" + }, + "registry": { + "description": "Registry ID or name to search (e.g., 'smithery', 'mcprun', 'pulse'). Use 'list_registries' tool first to see available registries.", + "type": "string" + }, + "search": { + "description": "Search term to filter servers by name or description (case-insensitive)", + "type": "string" + }, + "tag": { + "description": "Filter servers by tag/category (if supported by registry)", + "type": "string" + } + }, + "required": [ + "registry" + ], + "type": "object" + }, + "name": "search_servers" + }, + "set_profile": { + "annotations": { + "title": "Set Profile", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Switch the active profile for THIS session. A profile scopes tool discovery (retrieve_tools) and tool calls to a named subset of upstream servers — useful to focus an agent on one task domain (e.g. 'research', 'deploy'). The selection persists for the lifetime of the current MCP session and applies to subsequent retrieve_tools / call_tool_* / code_execution calls on the base /mcp endpoint without re-indexing. Pass an empty string to clear the selection and go back to all servers. Note: an explicit /mcp/p/\u003cslug\u003e URL still overrides the session profile for that request, and a profile-pinned agent token cannot switch away from its pinned profile.", + "inputSchema": { + "properties": { + "profile": { + "description": "Profile slug to activate for this session (e.g. 'research'). Pass \"\" (empty) to clear the active profile and return to all servers.", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "set_profile" + }, + "upstream_servers": { + "annotations": { + "title": "Upstream Servers", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Manage upstream MCP servers - add, remove, update, and list servers. Includes Docker isolation configuration and connection status monitoring. SECURITY: Newly added servers are automatically quarantined to prevent Tool Poisoning Attacks (TPAs). Use 'quarantine_security' tool to review and manage quarantined servers. NOTE: Unquarantining servers is only available through manual config editing or system tray UI for security.\n\nDocker Isolation: Use 'isolation_json' parameter to configure per-server Docker images, CPU/memory limits, and network isolation. Example: {\"enabled\": true, \"image\": \"node:20\", \"network_mode\": \"bridge\"}.\n\nSMART PATCHING (update/patch): Uses deep merge - only specify fields you want to change. Omitted fields are PRESERVED, not removed. Examples:\n- Enable server: {\"operation\": \"patch\", \"name\": \"my-server\", \"enabled\": true} - only enabled changes\n- Enable isolation: {\"operation\": \"patch\", \"name\": \"my-server\", \"isolation_json\": \"{\\\"enabled\\\": true}\"} - enables isolation with defaults\n- Update image: {\"operation\": \"patch\", \"name\": \"my-server\", \"isolation_json\": \"{\\\"image\\\": \\\"python:3.12\\\"}\"} - other isolation fields preserved\n- Add env var: env_json merges with existing vars\n- Replace args: args_json replaces entirely (arrays not merged)\n- Remove field: use 'null' (e.g., isolation_json: \"null\" removes isolation)", + "inputSchema": { + "properties": { + "args_json": { + "description": "Command arguments for stdio servers as a JSON array of strings (e.g., '[\"mcp-server-sqlite\", \"--db-path\", \"/path/to/db\"]'). For update/patch: REPLACES all existing args (arrays are not merged).", + "type": "string" + }, + "command": { + "description": "Command to run for stdio servers (e.g., 'uvx', 'python')", + "type": "string" + }, + "enabled": { + "description": "Whether server should be enabled (default: true)", + "type": "boolean" + }, + "env_json": { + "description": "Environment variables for stdio servers as JSON object (e.g., '{\"API_KEY\": \"value\"}'). For update/patch: MERGES with existing vars (new keys added, existing keys updated).", + "type": "string" + }, + "headers_json": { + "description": "HTTP headers for authentication as JSON object (e.g., '{\"Authorization\": \"Bearer token\"}'). For update/patch: MERGES with existing headers (new keys added, existing keys updated).", + "type": "string" + }, + "id": { + "description": "Server id within the registry - required for add_from_registry.", + "type": "string" + }, + "init_timeout": { + "description": "Per-server MCP `initialize` handshake deadline as a duration string (e.g. '120s', '3m'). Raise this for upstreams that do legitimate first-run warmup (cache/index build) before responding to `initialize`, so they are not killed mid-startup. Unset → global default (30s). Bounds: 1s–30m. Used with add/update/patch.", + "type": "string" + }, + "isolation_json": { + "description": "Docker isolation config as JSON object. MERGES with existing settings - only provided fields change. Use 'null' to remove isolation entirely. Example: '{\"image\": \"python:3.12\"}' updates only the image.", + "type": "string" + }, + "lines": { + "description": "Number of lines to tail from server log (default: 50, max: 500) - used with tail_log operation", + "type": "number" + }, + "name": { + "description": "Server name (required for add/remove/update/patch/tail_log operations; optional name override for add_from_registry)", + "type": "string" + }, + "oauth_json": { + "description": "OAuth config as JSON object. MERGES with existing settings. Use 'null' to remove OAuth entirely. Fields: client_id, client_secret, scopes (array - replaces).", + "type": "string" + }, + "operation": { + "description": "Operation: list, add, remove, update, patch, tail_log, add_from_registry, enable, disable, restart, refresh. 'update' and 'patch' use smart merge - only specified fields change, others preserved. 'add_from_registry' adds an upstream from a registry reference (registry+id) so you need not hand-construct command/args/url - the server re-derives the runnable config and quarantines it. 'refresh' re-discovers and re-indexes a server's tools without changing any security state - use it to make just-approved tools searchable immediately. For quarantine operations, use the 'quarantine_security' tool.", + "enum": [ + "list", + "add", + "remove", + "update", + "patch", + "tail_log", + "add_from_registry", + "enable", + "disable", + "restart", + "refresh" + ], + "type": "string" + }, + "protocol": { + "description": "Transport protocol: stdio, http, sse, streamable-http, auto (default: auto-detect)", + "enum": [ + "stdio", + "http", + "sse", + "streamable-http", + "auto" + ], + "type": "string" + }, + "registry": { + "description": "Registry id to add from (e.g. 'pulse') - required for add_from_registry. Use the 'list_registries'/'search_servers' tools to discover registries and server ids.", + "type": "string" + }, + "trust_mode": { + "description": "Per-server trust tier governing new-server admission AND tool-change approval (spec 086): 'auto' = approve without scanning; 'scan' = auto-approve only when the fast offline TPA scan is green, else hold for review; 'manual' = human reviews every change. Empty → manual (secure default). Used with add/update/patch.", + "enum": [ + "auto", + "scan", + "manual" + ], + "type": "string" + }, + "url": { + "description": "Server URL for HTTP/SSE servers (e.g., 'http://localhost:3001')", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + "name": "upstream_servers" + } +} diff --git a/internal/server/testdata/toolslist_goldens/retrieve_tools_mode.json b/internal/server/testdata/toolslist_goldens/retrieve_tools_mode.json index c68cdd28..ddd5dbdc 100644 --- a/internal/server/testdata/toolslist_goldens/retrieve_tools_mode.json +++ b/internal/server/testdata/toolslist_goldens/retrieve_tools_mode.json @@ -152,11 +152,30 @@ "idempotentHint": false, "openWorldHint": false }, - "description": "Return full JSON Schema + long description for specific tools found via retrieve_tools. Use when a compact signature is marked lossy ('~') or you need the exact schema before calling.", + "description": "Return full JSON Schema + long description for specific tools found via retrieve_tools. Use when a compact signature is marked lossy ('~') or you need the exact schema before calling. With check:true it returns one availability verdict per id instead of schemas ('ready', or a reason code with retryable/action), to gate a plan before its first call.", "inputSchema": { "properties": { + "check": { + "description": "Check availability only, no schemas (default: false).", + "type": "boolean" + }, + "filters": { + "description": "check:true only. Annotation filters, as in retrieve_tools.", + "properties": { + "exclude_destructive": { + "type": "boolean" + }, + "exclude_open_world": { + "type": "boolean" + }, + "read_only_only": { + "type": "boolean" + } + }, + "type": "object" + }, "tool_ids": { - "description": "1-5 tool ids in '\u003cserver\u003e:\u003ctool\u003e' format, from retrieve_tools results.", + "description": "Tool ids in '\u003cserver\u003e:\u003ctool\u003e' format from retrieve_tools. Max 5, or 50 with check:true.", "items": { "type": "string" }, diff --git a/internal/server/toolslist_snapshot_test.go b/internal/server/toolslist_snapshot_test.go index ac32159b..151fdd88 100644 --- a/internal/server/toolslist_snapshot_test.go +++ b/internal/server/toolslist_snapshot_test.go @@ -23,10 +23,24 @@ import ( // MCPPROXY_WRITE_TOOLSLIST_GOLDENS set — so the capture and the comparison // share one serializer and cannot drift. // -// Unlike the spec-085/094 delta test in mcp_menu_surface_test.go (which allows -// an enumerated, intentional delta), this test allows NO delta at all. A -// failure here is a spec-098 regression, not a golden to refresh: goldens are -// only regenerated when a DIFFERENT, deliberate spec changes the MCP surface. +// SPEC 099 (FR-014/FR-015) converted this from a NO-delta gate into an +// ENUMERATED-delta gate — the spec-085/094 pattern in mcp_menu_surface_test.go +// — because 099 ships an MCP feature where 098 shipped none. Two things now +// hold together, and both have to: +// +// 1. Every surface is byte-identical to its golden, exactly as before. The +// goldens for the two surfaces that carry describe_tool were regenerated +// DELIBERATELY, once, so the new definition — including its prose — is +// pinned byte-for-byte and a later edit shows up as a reviewable diff +// rather than drifting silently under the token budget. +// 2. The regenerated goldens differ from the frozen pre-099 capture +// (testdata/toolslist_goldens/pre099/) in EXACTLY one tool entry, +// describe_tool, on exactly those two surfaces. Every other tool, and the +// whole code_execution surface, is byte-equal — which is why that surface +// has no pre099 copy at all: its golden was never regenerated. +// +// A failure in (1) with no accompanying spec is a regression, not a golden to +// refresh. A failure in (2) means a change reached further than it claimed. // // Surfaces covered (the three routing modes that expose a static, built-in // tool set): @@ -43,6 +57,11 @@ import ( const ( toolsListGoldenDir = "toolslist_goldens" + // toolsListPre099Dir holds the FROZEN pre-099 capture of the two surfaces + // spec 099 was allowed to move. It is never regenerated: it is the baseline + // the enumerated delta is measured against. + toolsListPre099Dir = "pre099" + // toolsListGoldenWriteEnv, when set to a directory, makes this test WRITE // the goldens instead of comparing them. Used once to capture the // merge-base surface from a detached worktree of origin/main. Never set it @@ -147,6 +166,69 @@ func TestToolsListSnapshot_MatchesMergeBaseGoldens(t *testing.T) { } } +// toolsListPre099Surfaces maps a surface to the tool entries spec 099 was +// allowed to change on it (FR-014). A surface absent from this map may not move +// at all — code_execution_mode is deliberately absent. +var toolsListPre099Surfaces = map[string][]string{ + "default_server": {"describe_tool"}, + "retrieve_tools_mode": {"describe_tool"}, +} + +// TestToolsListSnapshot_Spec099DeltaIsExactlyDescribeTool is the FR-014 gate: +// the goldens moved, and this is the enumeration of how far. +func TestToolsListSnapshot_Spec099DeltaIsExactlyDescribeTool(t *testing.T) { + for surface, allowed := range toolsListPre099Surfaces { + surface, allowed := surface, allowed + t.Run(surface, func(t *testing.T) { + before := decodeToolsListGolden(t, filepath.Join("testdata", toolsListGoldenDir, toolsListPre099Dir, surface+".json")) + after := decodeToolsListGolden(t, toolsListGoldenPath(surface)) + + // The tool SET is unchanged: 099 adds parameters to an existing + // built-in, it does not register or retire one. + assert.Equal(t, sortedToolNames(before), sortedToolNames(after), + "surface %s: spec 099 adds no tool and removes none", surface) + + changed := make([]string, 0, 1) + for name, pre := range before { + post, ok := after[name] + if !ok { + continue // already reported by the set comparison + } + if !bytes.Equal(pre, post) { + changed = append(changed, name) + } + } + sort.Strings(changed) + assert.Equal(t, allowed, changed, + "surface %s: spec 099 may change describe_tool and nothing else", surface) + }) + } + + // The one surface that had no delta to enumerate: it must still match the + // spec-098 merge-base bytes, so it never needed a pre099 copy. + assert.NotContains(t, toolsListPre099Surfaces, "code_execution_mode", + "code_execution mode carries no describe_tool and must not move (FR-002/FR-014)") +} + +func decodeToolsListGolden(t *testing.T, path string) map[string]json.RawMessage { + t.Helper() + raw, err := os.ReadFile(path) + require.NoError(t, err, "missing golden %s", path) + var tools map[string]json.RawMessage + require.NoError(t, json.Unmarshal(normalizeGoldenEOL(raw), &tools)) + require.NotEmpty(t, tools) + return tools +} + +func sortedToolNames(tools map[string]json.RawMessage) []string { + names := make([]string, 0, len(tools)) + for name := range tools { + names = append(names, name) + } + sort.Strings(names) + return names +} + // reportToolsListDiff decodes both sides and reports added/removed/changed // tools individually. func reportToolsListDiff(t *testing.T, surface string, want, got []byte) { diff --git a/internal/storage/activity_models.go b/internal/storage/activity_models.go index e14ddc06..e44eb5b1 100644 --- a/internal/storage/activity_models.go +++ b/internal/storage/activity_models.go @@ -120,6 +120,17 @@ const ( // MetadataKeyPreflightPerTool is the ordered per-tool detail: // [{id, status, reason?}] using the PreflightPerTool* keys below. MetadataKeyPreflightPerTool = "per_tool" + // MetadataKeyPreflightSurface names the surface that ran the preflight when + // it is not the REST endpoint — currently only "mcp-check", the in-band + // describe_tool check mode (spec 099 FR-013). It is OMITTED for the REST + // surface, whose records predate it and stay byte-identical. + MetadataKeyPreflightSurface = "surface" + + // PreflightSurfaceMCPCheck marks a record written by describe_tool check + // mode. It matches the `surface` value the spec-099 sabotage-matrix rows + // carry, so a matrix row and an activity record name the surface the same + // way. + PreflightSurfaceMCPCheck = "mcp-check" // Keys inside one MetadataKeyPreflightPerTool entry. PreflightPerToolKeyID = "id" From 821a67f47f8be74920bdbf5feaea8c4d4ddf3a36 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 16 Aug 2026 06:56:54 +0300 Subject: [PATCH 05/11] test(mcp): in-band sabotage-matrix rows + REST parity (spec 099) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Matrix rows now record the surface AND the disclosure tier. 25 new rows cover the in-band surface: every reason code observable in band, the scope/unconfigured collapse to not_found, missing_annotation and policy_filtered per filter, the 50/51 cap boundary, filters-without-check, the reserved expect_hashes, and the plain surface's out-of-scope code. - Reflection gate extended: a code with no mcp-check row fails CI unless it is listed in mcpCheckExemptReasons with the decision that makes it unreachable in band. server_not_configured joins hash_mismatch and server_not_in_scope there — FR-009 collapses it to not_found at the agent-token tier, so it is REST-only for the same reason. - Every in-band row is driven by preflight_mcp_check_matrix_test.go, and an undriven row fails the suite: a committed expectation nothing asserts is a claim, not a check. - Inherited 098 erratum fixed: the mid_indexing note claimed existence outranks connection state on a never-indexed server, which contradicts FR-005 and the shipped evaluator. Note corrected and a never_indexed_while_connecting row added, asserted against the evaluator. - FR-017 parity: in-band and REST agree on {status, reason, retryable, action} (plus detail/remediation) for 11 states at the agent-token tier, with checked_at and hash excluded by name. Related #969 --- internal/server/preflight_matrix_test.go | 88 +++- .../server/preflight_mcp_check_matrix_test.go | 415 ++++++++++++++++++ .../testdata/preflight_sabotage_matrix.json | 378 +++++++++++++++- 3 files changed, 869 insertions(+), 12 deletions(-) create mode 100644 internal/server/preflight_mcp_check_matrix_test.go diff --git a/internal/server/preflight_matrix_test.go b/internal/server/preflight_matrix_test.go index 460f0b7b..ffc02b50 100644 --- a/internal/server/preflight_matrix_test.go +++ b/internal/server/preflight_matrix_test.go @@ -30,15 +30,63 @@ type sabotageExpectation struct { Action string `json:"action,omitempty"` Verdict string `json:"verdict"` ExitCode int `json:"exit_code"` + // RequestError is set INSTEAD of a verdict for a row whose request is + // rejected rather than evaluated (spec 099 FR-012/FR-016: the cap boundary, + // filters without check, the reserved field). Its value is the fragment the + // error message must contain. + RequestError string `json:"request_error,omitempty"` + // PlainError is set INSTEAD of a verdict for an mcp-plain row: plain + // describe_tool answers per-id codes from its own vocabulary, not preflight + // reasons (spec 099 FR-011). + PlainError string `json:"plain_error,omitempty"` } type sabotageScenario struct { - Scenario string `json:"scenario"` - Surface string `json:"surface"` + Scenario string `json:"scenario"` + Surface string `json:"surface"` + // Tier is the disclosure tier the row is observed at (spec 099 FR-016). + Tier string `json:"tier"` Sabotage string `json:"sabotage"` Expect sabotageExpectation `json:"expect"` } +// Matrix surfaces. The first two induce state for the REST endpoint; the last +// two are the spec-099 in-band surfaces. +const ( + surfaceE2E = "e2e" + surfaceStateInjected = "state-injected" + surfaceMCPCheck = "mcp-check" + surfaceMCPPlain = "mcp-plain" +) + +// mcpCheckExemptReasons are the two codes the in-band surface cannot produce BY +// DESIGN, and therefore the only two the mcp-check coverage gate excuses (spec +// 099 FR-008/FR-009). The exemption is encoded here rather than left implicit: +// if either ever becomes observable in band, this list is what has to change, +// in the same commit as the behavior. +var mcpCheckExemptReasons = map[string]string{ + preflight.ReasonHashMismatch: "in-band hash pins were trimmed from v1 (FR-008): nothing in band can request a pin", + preflight.ReasonServerNotInScope: "the in-band surface is pinned to the agent-token tier (FR-009), where this collapses to not_found", + // FR-009 names BOTH collapsing codes; FR-016's parenthetical listed only + // the first. The collapse is symmetric in the evaluator and has to be — if + // an unconfigured server answered differently from an out-of-scope one, a + // token could probe arbitrary names and learn which servers exist behind + // its scope. The two mcp_check_unknown_server / mcp_check_out_of_scope rows + // assert the collapse itself, which is the observable behavior. + preflight.ReasonServerNotConfigured: "the in-band surface is pinned to the agent-token tier (FR-009), where this collapses to not_found", +} + +// scenariosBySurface indexes the matrix by surface. +func scenariosBySurface(scenarios map[string]sabotageScenario, surface string) map[string]sabotageScenario { + out := make(map[string]sabotageScenario) + for name, scenario := range scenarios { + if scenario.Surface == surface { + out[name] = scenario + } + } + return out +} + type sabotageMatrix struct { Scenarios []sabotageScenario `json:"scenarios"` } @@ -72,12 +120,30 @@ func TestPreflightSabotageMatrixCoversEveryReason(t *testing.T) { scenarios := loadSabotageMatrix(t) covered := make(map[string]int) + coveredInBand := make(map[string]int) for name, scenario := range scenarios { expect := scenario.Expect - require.Containsf(t, []string{preflight.StatusReady, preflight.StatusUnavailable}, - expect.Status, "scenario %q: status must be a valid preflight status", name) require.NotEmptyf(t, scenario.Surface, "scenario %q: surface must say how the state is induced", name) require.NotEmptyf(t, scenario.Sabotage, "scenario %q: sabotage must describe the induced state", name) + require.Containsf(t, []string{preflight.TierOperator, preflight.TierAgentToken}, + scenario.Tier, "scenario %q: tier must be a valid disclosure tier", name) + if scenario.Surface == surfaceMCPCheck || scenario.Surface == surfaceMCPPlain { + assert.Equalf(t, preflight.TierAgentToken, scenario.Tier, + "scenario %q: the whole in-band surface is the agent-token tier (spec 099 FR-009)", name) + } + + // Rows that are REJECTED rather than evaluated, and rows on the plain + // surface with its own vocabulary, carry no verdict to check against + // the taxonomy. + if expect.RequestError != "" || expect.PlainError != "" { + assert.Emptyf(t, expect.Status, "scenario %q: a rejected/plain row carries no preflight status", name) + assert.Emptyf(t, expect.Reason, "scenario %q: a rejected/plain row carries no preflight reason", name) + assert.Emptyf(t, expect.Verdict, "scenario %q: a rejected/plain row carries no verdict", name) + continue + } + + require.Containsf(t, []string{preflight.StatusReady, preflight.StatusUnavailable}, + expect.Status, "scenario %q: status must be a valid preflight status", name) if expect.Status == preflight.StatusReady { assert.Emptyf(t, expect.Reason, "scenario %q: a ready row carries no reason", name) @@ -91,6 +157,9 @@ func TestPreflightSabotageMatrixCoversEveryReason(t *testing.T) { require.Truef(t, preflight.ValidReason(expect.Reason), "scenario %q: %q is not a member of the closed reason enum", name, expect.Reason) covered[expect.Reason]++ + if scenario.Surface == surfaceMCPCheck { + coveredInBand[expect.Reason]++ + } require.NotNilf(t, expect.Retryable, "scenario %q: a failure row must state retryable", name) assert.Equalf(t, preflight.Retryable(expect.Reason), *expect.Retryable, @@ -107,6 +176,17 @@ func TestPreflightSabotageMatrixCoversEveryReason(t *testing.T) { assert.Positivef(t, covered[reason], "reason %q has no scenario in %s: FR-016 requires a sabotage cell per enum code", reason, preflightMatrixPath) + + if why, exempt := mcpCheckExemptReasons[reason]; exempt { + assert.Zerof(t, coveredInBand[reason], + "reason %q has an mcp-check row but is documented as REST-only (%s): either the row or the exemption is wrong", + reason, why) + continue + } + assert.Positivef(t, coveredInBand[reason], + "reason %q has no mcp-check scenario in %s: spec 099 FR-016 requires an in-band cell per observable enum code "+ + "(add one, or document the code in mcpCheckExemptReasons with the design decision that makes it unreachable)", + reason, preflightMatrixPath) } } diff --git a/internal/server/preflight_mcp_check_matrix_test.go b/internal/server/preflight_mcp_check_matrix_test.go new file mode 100644 index 00000000..020b2722 --- /dev/null +++ b/internal/server/preflight_mcp_check_matrix_test.go @@ -0,0 +1,415 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/toolannotations" +) + +// Spec 099 T007 — the in-band half of the committed sabotage matrix. +// +// Every mcp-check and mcp-plain row is DRIVEN here, and the last assertion in +// TestPreflightMatrixMCPSurfaces is the one that keeps that true: a row added +// to the JSON without a driver fails, exactly as a reason code without a row +// fails the reflection gate. Between the two, "the matrix says X" and "the +// surface does X" cannot drift apart. +// +// Cells that need a live connection-state snapshot (the three connection +// reasons) or readable upstream annotations (policy_filtered — Bleve stores +// identity and text only, so an index round-trip always loses annotations) are +// driven through the SAME evaluator with an injected snapshot and then +// projected onto the in-band payload, which is the precedent spec 098 set with +// its pending_auth cell. What is in-band-specific about those rows is the +// projection, and that is what is asserted. + +// --- state-injected stubs with annotations ---------------------------------- + +type annotatedStubIndex struct { + tools map[string][]string + annotations map[string]*config.ToolAnnotations +} + +func (s annotatedStubIndex) ToolsByServer(serverName string) ([]preflight.IndexedTool, error) { + out := make([]preflight.IndexedTool, 0, len(s.tools[serverName])) + for _, name := range s.tools[serverName] { + out = append(out, preflight.IndexedTool{ + Name: serverName + ":" + name, + Annotations: s.annotations[serverName+":"+name], + }) + } + return out, nil +} + +func (s annotatedStubIndex) IndexedServerNames() ([]string, error) { + names := make([]string, 0, len(s.tools)) + for name := range s.tools { + names = append(names, name) + } + return names, nil +} + +// evaluateInjected runs the real evaluator over an injected state snapshot and +// returns the in-band payload the check handler would have serialized. +func evaluateInjected(t *testing.T, ec preflight.EvalContext, id string) describeCheckResult { + t.Helper() + ec.Tier = preflight.TierAgentToken + results, err := preflight.Evaluate(context.Background(), ec, []preflight.ToolRef{{ID: id}}) + require.NoError(t, err) + require.Len(t, results, 1) + + payload := describeCheckResponse( + preflight.Outcome{Verdict: preflight.VerdictForResults(results), Results: results}, + "req-injected", time.Now().UTC()) + require.Len(t, payload.Results, 1) + return payload.Results[0] +} + +// assertMatrixCell checks one per-tool result against its committed row. +func assertMatrixCell(t *testing.T, scenario sabotageScenario, result describeCheckResult, verdict string) { + t.Helper() + expect := scenario.Expect + assert.Equalf(t, expect.Status, result.Status, "[%s] status", scenario.Scenario) + if expect.Status == preflight.StatusReady { + assert.Emptyf(t, result.Reason, "[%s] a ready result carries no reason", scenario.Scenario) + assert.Nilf(t, result.Retryable, "[%s] a ready result carries no retryable flag", scenario.Scenario) + assert.Emptyf(t, result.Action, "[%s] a ready result carries no action", scenario.Scenario) + } else { + assert.Equalf(t, expect.Reason, result.Reason, "[%s] reason", scenario.Scenario) + if assert.NotNilf(t, result.Retryable, "[%s] a failure result must carry retryable", scenario.Scenario) { + require.NotNil(t, expect.Retryable) + assert.Equalf(t, *expect.Retryable, *result.Retryable, "[%s] retryable", scenario.Scenario) + } + assert.Equalf(t, expect.Action, result.Action, "[%s] action", scenario.Scenario) + assert.NotEmptyf(t, result.Remediation, "[%s] a failure result must carry a remediation", scenario.Scenario) + } + assert.Equalf(t, expect.Verdict, verdict, "[%s] set verdict", scenario.Scenario) + assert.Equalf(t, expect.ExitCode, preflight.ExitCode(verdict), "[%s] CLI exit code", scenario.Scenario) + // The in-band payload never carries a hash, whatever the cell (FR-004). + assert.Equalf(t, preflight.TierAgentToken, scenario.Tier, "[%s] tier", scenario.Scenario) +} + +// scopedAgentContext is the session every in-band row is observed under: an +// agent token scoped to the fixture's own servers. +func scopedAgentContext() context.Context { + return auth.WithAuthContext(context.Background(), &auth.AuthContext{ + Type: auth.AuthTypeAgent, + AgentName: "matrix-bot", + AllowedServers: []string{"gh", "locked", "off", "denied"}, + Permissions: []string{auth.PermRead}, + }) +} + +func TestPreflightMatrixMCPSurfaces(t *testing.T) { + scenarios := loadSabotageMatrix(t) + fixture := newDescribeCheckFixture(t, nil) + seedCheckFixture(t, fixture) + fixture.addServer(t, &config.ServerConfig{Name: "secret", Enabled: true, Protocol: "http"}) + fixture.indexTool(t, "secret", "exfiltrate") + + // One id per fixture-inducible cell. + byID := map[string]string{ + "mcp_check_all_ready": "gh:create_issue", + "mcp_check_quarantine": "locked:lingering", + "mcp_check_server_disabled": "off:sleeping", + "mcp_check_tool_pending_approval": "gh:pending_tool", + "mcp_check_tool_changed": "gh:changed_tool", + "mcp_check_tool_blocked_by_user": "gh:blocked_tool", + "mcp_check_tool_denied_by_config": "denied:forbidden", + "mcp_check_unknown_tool_id": "gh:no_such_tool", + "mcp_check_malformed_id": "not-an-id", + "mcp_check_unknown_server": "nosuch:tool", + "mcp_check_out_of_scope": "secret:exfiltrate", + } + // Cells whose state cannot be induced without a live snapshot or readable + // annotations, driven through the evaluator with an injected one. + injected := map[string]func(t *testing.T) describeCheckResult{ + "mcp_check_oauth_required": func(t *testing.T) describeCheckResult { + return evaluateInjected(t, injectedEvalContext(preflight.RuntimeStatePendingAuth, nil), "gh:create_issue") + }, + "mcp_check_server_unhealthy": func(t *testing.T) describeCheckResult { + return evaluateInjected(t, injectedEvalContext(preflight.RuntimeStateError, nil), "gh:create_issue") + }, + "mcp_check_server_initializing": func(t *testing.T) describeCheckResult { + return evaluateInjected(t, injectedEvalContext(preflight.RuntimeStateConnecting, nil), "gh:create_issue") + }, + } + for filterKey, annotations := range map[string]*config.ToolAnnotations{ + "read_only_only": {ReadOnlyHint: boolPtr(false)}, + "exclude_destructive": {DestructiveHint: boolPtr(true)}, + "exclude_open_world": {OpenWorldHint: boolPtr(true)}, + } { + filterKey, annotations := filterKey, annotations + injected["mcp_check_policy_filtered_"+filterKey] = func(t *testing.T) describeCheckResult { + ec := injectedEvalContext(preflight.RuntimeStateReady, annotations) + ec.Filters = filtersFor(filterKey) + return evaluateInjected(t, ec, "gh:create_issue") + } + } + + driven := make(map[string]bool) + + // --- fixture-inducible verdict cells --- + for scenarioName, id := range byID { + scenario, ok := scenarios[scenarioName] + require.Truef(t, ok, "scenario %q is missing from %s", scenarioName, preflightMatrixPath) + driven[scenarioName] = true + t.Run(scenarioName, func(t *testing.T) { + payload, raw := fixture.check(t, scopedAgentContext(), []interface{}{id}, nil) + require.Len(t, payload.Results, 1) + assertMatrixCell(t, scenario, payload.Results[0], payload.Verdict) + assert.NotContains(t, raw, "\"hash\"", "no hash is ever returned in band") + }) + } + + // --- state-injected verdict cells --- + for scenarioName, drive := range injected { + scenario, ok := scenarios[scenarioName] + require.Truef(t, ok, "scenario %q is missing from %s", scenarioName, preflightMatrixPath) + driven[scenarioName] = true + t.Run(scenarioName, func(t *testing.T) { + result := drive(t) + assertMatrixCell(t, scenario, result, preflight.ReasonVerdict(result.Reason)) + }) + } + + // --- missing_annotation cells (inducible: an indexed tool with no + // annotations is exactly the missing-annotation case) --- + for _, filterKey := range describeCheckFilterKeys { + scenarioName := "mcp_check_missing_annotation_" + filterKey + scenario, ok := scenarios[scenarioName] + require.Truef(t, ok, "scenario %q is missing from %s", scenarioName, preflightMatrixPath) + driven[scenarioName] = true + t.Run(scenarioName, func(t *testing.T) { + payload, _ := fixture.check(t, scopedAgentContext(), []interface{}{"gh:create_issue"}, + map[string]interface{}{"filters": map[string]interface{}{filterKey: true}}) + require.Len(t, payload.Results, 1) + assertMatrixCell(t, scenario, payload.Results[0], payload.Verdict) + }) + } + + // --- cap boundary --- + driven["mcp_check_cap_boundary_50"] = true + t.Run("mcp_check_cap_boundary_50", func(t *testing.T) { + scenario := scenarios["mcp_check_cap_boundary_50"] + ids := make([]interface{}, 0, maxDescribeCheckIDs) + for i := 0; i < maxDescribeCheckIDs; i++ { + name := fmt.Sprintf("bulk_%02d", i) + fixture.indexTool(t, "gh", name) + ids = append(ids, "gh:"+name) + } + payload, _ := fixture.check(t, scopedAgentContext(), ids, nil) + require.Len(t, payload.Results, maxDescribeCheckIDs, "all 50 ids are evaluated in one call") + for _, result := range payload.Results { + assertMatrixCell(t, scenario, result, payload.Verdict) + } + }) + + // --- request-error rows --- + requestErrors := map[string]map[string]interface{}{ + "mcp_check_cap_exceeded_51": {"tool_ids": overCapIDs(), "check": true}, + "mcp_check_filters_without_check": {"tool_ids": []interface{}{"gh:create_issue"}, "filters": map[string]interface{}{"read_only_only": true}}, + "mcp_check_expect_hashes_reserved": {"tool_ids": []interface{}{"gh:create_issue"}, "check": true, "expect_hashes": map[string]interface{}{"gh:create_issue": "sha256/v2:abc123"}}, + } + for scenarioName, args := range requestErrors { + scenario, ok := scenarios[scenarioName] + require.Truef(t, ok, "scenario %q is missing from %s", scenarioName, preflightMatrixPath) + require.NotEmptyf(t, scenario.Expect.RequestError, "scenario %q must carry request_error", scenarioName) + driven[scenarioName] = true + t.Run(scenarioName, func(t *testing.T) { + fixture.records = nil + result := fixture.callCheck(t, scopedAgentContext(), args) + require.True(t, result.IsError, "[%s] the request must be rejected", scenarioName) + assert.Contains(t, resultText(t, result), scenario.Expect.RequestError) + assert.Empty(t, fixture.records, "[%s] a rejected request runs no check and records nothing", scenarioName) + }) + } + + // --- the plain surface row (FR-011) --- + driven["mcp_plain_out_of_scope"] = true + t.Run("mcp_plain_out_of_scope", func(t *testing.T) { + scenario := scenarios["mcp_plain_out_of_scope"] + require.NotEmpty(t, scenario.Expect.PlainError) + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{"tool_ids": []interface{}{"secret:exfiltrate"}} + result, err := fixture.proxy.handleDescribeTool(scopedAgentContext(), req) + require.NoError(t, err) + require.False(t, result.IsError) + + var plain describeToolResponse + require.NoError(t, json.Unmarshal([]byte(resultText(t, result)), &plain)) + assert.Empty(t, plain.Definitions, "an out-of-scope id never resolves to a definition") + require.Len(t, plain.Errors, 1) + assert.Equal(t, scenario.Expect.PlainError, plain.Errors[0]["error"]) + assert.Equal(t, describeNotFoundRemediation, plain.Errors[0]["remediation"], + "the remediation was already the shared not-found text; only the code moved") + }) + + // The gate that keeps this file honest: every in-band row is driven above. + var undriven []string + for name, scenario := range scenarios { + if scenario.Surface != surfaceMCPCheck && scenario.Surface != surfaceMCPPlain { + continue + } + if !driven[name] { + undriven = append(undriven, name) + } + } + sort.Strings(undriven) + assert.Empty(t, undriven, + "every mcp-check/mcp-plain row must be driven by this test — a committed expectation nothing asserts is a claim, not a check") +} + +func overCapIDs() []interface{} { + ids := make([]interface{}, 0, maxDescribeCheckIDs+1) + for i := 0; i <= maxDescribeCheckIDs; i++ { + ids = append(ids, fmt.Sprintf("gh:tool_%02d", i)) + } + return ids +} + +func filtersFor(key string) toolannotations.Filters { + switch key { + case "read_only_only": + return toolannotations.Filters{ReadOnlyOnly: true} + case "exclude_destructive": + return toolannotations.Filters{ExcludeDestructive: true} + default: + return toolannotations.Filters{ExcludeOpenWorld: true} + } +} + +// injectedEvalContext is a configured, enabled, indexed, approved gh:create_issue +// on a server in the given connection state. +func injectedEvalContext(state preflight.ServerRuntimeState, annotations *config.ToolAnnotations) preflight.EvalContext { + return preflight.EvalContext{ + Index: annotatedStubIndex{ + tools: map[string][]string{"gh": {"create_issue"}}, + annotations: map[string]*config.ToolAnnotations{"gh:create_issue": annotations}, + }, + Approvals: stubApprovals{}, + State: stubState{state: state}, + Policy: stubPolicy{enabled: map[string]bool{"gh": true}}, + } +} + +// TestPreflightMatrixNeverIndexedWhileConnecting is the FR-016 erratum: on a +// server that is not Ready, the connection-state verdict wins over not_found, +// because existence is unknowable until the server has listed its tools. The +// matrix used to claim the opposite in the mid_indexing row's note. +func TestPreflightMatrixNeverIndexedWhileConnecting(t *testing.T) { + scenarios := loadSabotageMatrix(t) + scenario, ok := scenarios["never_indexed_while_connecting"] + require.True(t, ok, "the never_indexed_while_connecting cell must exist in the matrix") + require.Equal(t, surfaceStateInjected, scenario.Surface) + + assert.NotContains(t, scenarios["mid_indexing"].Sabotage, "existence outranks connection state", + "the corrected note must not re-assert the claim the evaluator contradicts") + + // The server is configured and enabled but has NEVER been indexed, and it + // is connecting. + results, err := preflight.Evaluate(context.Background(), preflight.EvalContext{ + Index: stubIndex{tools: map[string][]string{"gh": {}}}, + Approvals: stubApprovals{}, + State: stubState{state: preflight.RuntimeStateConnecting}, + Policy: stubPolicy{enabled: map[string]bool{"gh": true}}, + Tier: preflight.TierOperator, + }, []preflight.ToolRef{{ID: "gh:never_seen"}}) + require.NoError(t, err) + require.Len(t, results, 1) + + assert.Equal(t, scenario.Expect.Reason, results[0].Reason, + "a never-indexed tool on a connecting server reports the connection verdict, not not_found") + assert.Equal(t, preflight.ReasonServerInitializing, results[0].Reason) + verdict := preflight.VerdictForResults(results) + assert.Equal(t, scenario.Expect.Verdict, verdict) + assert.Equal(t, scenario.Expect.ExitCode, preflight.ExitCode(verdict)) +} + +// --- FR-017: in-band vs REST parity ----------------------------------------- + +// For identical ids and identical proxy state, the in-band surface and the REST +// surface AT THE SAME TIER name the same thing. The two payloads deliberately +// differ on checked_at and hash, which are excluded BY NAME rather than by a +// loose matcher — anything else that differs is a defect in the glue. +func TestPreflightInBandRESTParityAtAgentTokenTier(t *testing.T) { + fixture := newDescribeCheckFixture(t, nil) + seedCheckFixture(t, fixture) + fixture.addServer(t, &config.ServerConfig{Name: "secret", Enabled: true, Protocol: "http"}) + fixture.indexTool(t, "secret", "exfiltrate") + + ids := []string{ + "gh:create_issue", // ready + "locked:lingering", // server_quarantined + "off:sleeping", // server_disabled + "gh:pending_tool", // tool_pending_approval + "gh:changed_tool", // tool_changed + "gh:blocked_tool", // tool_blocked_by_user + "denied:forbidden", // tool_denied_by_config + "gh:no_such_tool", // not_found + "nosuch:tool", // server_not_configured ⇒ not_found at this tier + "secret:exfiltrate", // out of scope ⇒ not_found at this tier + "not-an-id", // malformed ⇒ not_found + } + + inBandIDs := make([]interface{}, 0, len(ids)) + restRefs := make([]preflight.ToolRef, 0, len(ids)) + for _, id := range ids { + inBandIDs = append(inBandIDs, id) + restRefs = append(restRefs, preflight.ToolRef{ID: id}) + } + + // The same session scope, expressed the way each surface expresses it: the + // in-band call carries an agent token on the context, the REST call carries + // the token's allowed_servers in its params. + inBand, _ := fixture.check(t, scopedAgentContext(), inBandIDs, nil) + rest, err := fixture.proxy.RunPreflight(context.Background(), preflight.Params{ + Tools: restRefs, + Tier: preflight.TierAgentToken, + TokenServers: []string{"gh", "locked", "off", "denied"}, + }) + require.NoError(t, err) + require.Len(t, rest.Results, len(ids)) + + assert.Equal(t, rest.Verdict, inBand.Verdict, "the set verdict is the same aggregate") + + for _, id := range ids { + t.Run(id, func(t *testing.T) { + mcpResult := checkResultByID(t, inBand, id) + restResult := resultByID(t, rest, id) + + // The compared tuple, field by field and by name. + assert.Equal(t, restResult.Status, mcpResult.Status, "status") + assert.Equal(t, restResult.Reason, mcpResult.Reason, "reason") + assert.Equal(t, restResult.Action, mcpResult.Action, "action") + if restResult.Status == preflight.StatusReady { + assert.Nil(t, mcpResult.Retryable) + } else { + require.NotNil(t, mcpResult.Retryable) + assert.Equal(t, restResult.Retryable, *mcpResult.Retryable, "retryable") + } + // Detail and remediation are not in the FR-017 tuple, but they come + // from the same evaluator result, so a divergence would mean the + // projection rewrote them. + assert.Equal(t, restResult.Detail, mcpResult.Detail, "detail") + assert.Equal(t, restResult.Remediation, mcpResult.Remediation, "remediation") + + // The two documented divergences: the REST result may carry a hash + // (not at this tier, but the field exists); the in-band payload has + // no hash field at all, and checked_at is a timestamp, not a verdict. + assert.Empty(t, restResult.Hash, "the agent-token tier discloses no hash on either surface") + }) + } +} diff --git a/internal/server/testdata/preflight_sabotage_matrix.json b/internal/server/testdata/preflight_sabotage_matrix.json index f617f374..f17af6e2 100644 --- a/internal/server/testdata/preflight_sabotage_matrix.json +++ b/internal/server/testdata/preflight_sabotage_matrix.json @@ -1,16 +1,19 @@ { - "spec": "098-tools-preflight", - "requirement": "FR-016", - "description": "Scenario-keyed sabotage matrix for the required-tools preflight. Each scenario names one deliberately induced proxy state and the EXACT per-tool verdict it must produce: status, reason, retryable and action (an omitted action means the reason carries none). The set-level verdict and CLI exit code are the ones a single-tool request of that shape must report. internal/server/preflight_e2e_test.go drives every scenario against a real mcpproxy binary and asserts these values independently, and a reflection check fails if any code of the 15-code enum has no scenario here.", + "spec": "098-tools-preflight (+ 099-describe-check-mode rows)", + "requirement": "FR-016 (098) / FR-016 (099)", + "description": "Scenario-keyed sabotage matrix for the required-tools preflight. Each scenario names one deliberately induced proxy state, the SURFACE it is observed on, the disclosure TIER it is observed at, and the EXACT per-tool verdict it must produce: status, reason, retryable and action (an omitted action means the reason carries none). The set-level verdict and CLI exit code are the ones a single-tool request of that shape must report. A request that is REJECTED rather than evaluated carries request_error instead of a verdict, and a row on the mcp-plain surface carries plain_error, that surface's per-id error code, because plain describe_tool has its own vocabulary. internal/server/preflight_e2e_test.go drives the REST rows against a real mcpproxy binary, internal/server/preflight_mcp_check_matrix_test.go drives every mcp-check and mcp-plain row, and reflection checks fail if any code of the 15-code enum has no REST row or no mcp-check row (excepting the two codes that are REST-only by design).", "surfaces": { - "e2e": "induced against a running mcpproxy binary with sabotaged fixture upstreams", - "state-injected": "induced by injecting the connection-state snapshot the runtime cannot be forced into deterministically from outside" + "e2e": "induced against a running mcpproxy binary with sabotaged fixture upstreams, observed over POST /api/v1/preflight", + "state-injected": "induced by injecting the connection-state snapshot the runtime cannot be forced into deterministically from outside", + "mcp-check": "observed in band through describe_tool with check:true (spec 099); always the agent-token tier", + "mcp-plain": "observed in band through describe_tool WITHOUT check, whose per-id vocabulary is not_found|quarantined|pending_approval|changed|disabled" }, "scenarios": [ { "scenario": "all_ready", "surface": "e2e", - "sabotage": "none: indexed, approved tool on an enabled, healthy, trusted server — asserted both unfiltered and under each of the three annotation filters, since a fully annotated tool must survive all of them", + "tier": "operator", + "sabotage": "none: indexed, approved tool on an enabled, healthy, trusted server \u2014 asserted both unfiltered and under each of the three annotation filters, since a fully annotated tool must survive all of them", "expect": { "status": "ready", "verdict": "ready", @@ -20,6 +23,7 @@ { "scenario": "quarantine_flip", "surface": "e2e", + "tier": "operator", "sabotage": "POST /api/v1/servers/{server}/quarantine on a previously healthy, indexed server", "expect": { "status": "unavailable", @@ -33,6 +37,7 @@ { "scenario": "tool_definition_drift", "surface": "e2e", + "tier": "operator", "sabotage": "rewrite the upstream tool's description in the fixture tools file, then restart the server so the proxy re-lists it (rug-pull guard trips)", "expect": { "status": "unavailable", @@ -46,6 +51,7 @@ { "scenario": "new_tool_after_baseline", "surface": "e2e", + "tier": "operator", "sabotage": "add a tool to the fixture tools file after the trusted server's baseline was auto-approved, then restart the server", "expect": { "status": "unavailable", @@ -59,6 +65,7 @@ { "scenario": "tool_blocked_by_user", "surface": "e2e", + "tier": "operator", "sabotage": "POST /api/v1/servers/{server}/tools/block for one tool", "expect": { "status": "unavailable", @@ -72,6 +79,7 @@ { "scenario": "config_denial", "surface": "e2e", + "tier": "operator", "sabotage": "server configured with disabled_tools containing the requested tool", "expect": { "status": "unavailable", @@ -85,6 +93,7 @@ { "scenario": "server_disable", "surface": "e2e", + "tier": "operator", "sabotage": "server configured with enabled:false", "expect": { "status": "unavailable", @@ -98,6 +107,7 @@ { "scenario": "upstream_killed", "surface": "e2e", + "tier": "operator", "sabotage": "arm the fixture's fail-switch file, SIGKILL the upstream process, then force a reconnect: the restarted fixture exits immediately, so the server settles in a stable non-connected state", "expect": { "status": "unavailable", @@ -111,7 +121,8 @@ { "scenario": "mid_indexing", "surface": "e2e", - "sabotage": "restart an already-indexed server with a long initialize delay, so it sits in connecting/discovering while its tools remain in the shared index (a never-indexed server would report not_found instead: existence outranks connection state)", + "tier": "operator", + "sabotage": "restart an already-indexed server with a long initialize delay, so it sits in connecting/discovering while its tools remain in the shared index; see never_indexed_while_connecting for the case where the tool was never indexed at all \u2014 the connection-state verdict wins there too", "expect": { "status": "unavailable", "reason": "server_initializing", @@ -123,6 +134,7 @@ { "scenario": "missing_annotation_read_only_only", "surface": "e2e", + "tier": "operator", "sabotage": "policy.read_only_only against a tool whose upstream definition declares no annotations", "expect": { "status": "unavailable", @@ -136,6 +148,7 @@ { "scenario": "missing_annotation_exclude_destructive", "surface": "e2e", + "tier": "operator", "sabotage": "policy.exclude_destructive against a tool with no destructiveHint and no read-only hint", "expect": { "status": "unavailable", @@ -149,6 +162,7 @@ { "scenario": "missing_annotation_exclude_open_world", "surface": "e2e", + "tier": "operator", "sabotage": "policy.exclude_open_world against a tool with no openWorldHint", "expect": { "status": "unavailable", @@ -162,6 +176,7 @@ { "scenario": "policy_filtered_read_only_only", "surface": "e2e", + "tier": "operator", "sabotage": "policy.read_only_only against a tool explicitly annotated readOnlyHint:false", "expect": { "status": "unavailable", @@ -174,6 +189,7 @@ { "scenario": "policy_filtered_exclude_destructive", "surface": "e2e", + "tier": "operator", "sabotage": "policy.exclude_destructive against a tool explicitly annotated destructiveHint:true", "expect": { "status": "unavailable", @@ -186,6 +202,7 @@ { "scenario": "policy_filtered_exclude_open_world", "surface": "e2e", + "tier": "operator", "sabotage": "policy.exclude_open_world against a tool explicitly annotated openWorldHint:true", "expect": { "status": "unavailable", @@ -198,6 +215,7 @@ { "scenario": "unknown_tool_id", "surface": "e2e", + "tier": "operator", "sabotage": "misspelled tool name on a configured, healthy server", "expect": { "status": "unavailable", @@ -211,6 +229,7 @@ { "scenario": "unknown_server", "surface": "e2e", + "tier": "operator", "sabotage": "tool id naming a server that is not configured at all", "expect": { "status": "unavailable", @@ -224,6 +243,7 @@ { "scenario": "hash_mismatch", "surface": "e2e", + "tier": "operator", "sabotage": "pin_hash at the current schema version whose digest does not match the tool's stored hash", "expect": { "status": "unavailable", @@ -237,6 +257,7 @@ { "scenario": "hash_mismatch_schema_version_bump", "surface": "e2e", + "tier": "operator", "sabotage": "pin_hash carrying the tool's CURRENT digest under a different hash schema version (simulates a proxy-side hash-algorithm bump)", "expect": { "status": "unavailable", @@ -250,6 +271,7 @@ { "scenario": "pending_auth", "surface": "state-injected", + "tier": "operator", "sabotage": "connection-state snapshot reports the deferred-OAuth PendingAuth state for the server", "expect": { "status": "unavailable", @@ -263,6 +285,7 @@ { "scenario": "profile_out_of_scope_operator", "surface": "e2e", + "tier": "operator", "sabotage": "preflight under a profile whose server list excludes the requested tool's server, called with the operator API key", "expect": { "status": "unavailable", @@ -276,6 +299,7 @@ { "scenario": "profile_out_of_scope_agent_token", "surface": "e2e", + "tier": "agent_token", "sabotage": "same out-of-scope request as profile_out_of_scope_operator, called with an agent token whose allowed_servers excludes the server: scope-silence maps it to a plain not_found, byte-indistinguishable from an absent tool", "expect": { "status": "unavailable", @@ -285,6 +309,344 @@ "verdict": "unknown_ids", "exit_code": 12 } + }, + { + "scenario": "never_indexed_while_connecting", + "surface": "state-injected", + "tier": "operator", + "sabotage": "a configured, enabled server that has NEVER been indexed, with the connection-state snapshot reporting connecting: existence is unknowable while the server is not Ready, so the connection verdict wins over not_found (FR-005 \u2014 corrects the note the mid_indexing row used to carry)", + "expect": { + "status": "unavailable", + "reason": "server_initializing", + "retryable": true, + "verdict": "degraded_retryable", + "exit_code": 10 + } + }, + { + "scenario": "mcp_check_all_ready", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "none: indexed, approved tool on an enabled, healthy, trusted server, checked in band with describe_tool check:true", + "expect": { + "status": "ready", + "verdict": "ready", + "exit_code": 0 + } + }, + { + "scenario": "mcp_check_quarantine", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "the requested tool's server is quarantined, checked in band", + "expect": { + "status": "unavailable", + "reason": "server_quarantined", + "retryable": false, + "action": "approve", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "mcp_check_server_disabled", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "the requested tool's server is configured enabled:false, checked in band", + "expect": { + "status": "unavailable", + "reason": "server_disabled", + "retryable": false, + "action": "enable", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "mcp_check_tool_pending_approval", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "the tool carries a spec-032 approval record in the pending state, checked in band", + "expect": { + "status": "unavailable", + "reason": "tool_pending_approval", + "retryable": false, + "action": "approve", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "mcp_check_tool_changed", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "the tool's approval record is in the changed (rug-pull) state, checked in band", + "expect": { + "status": "unavailable", + "reason": "tool_changed", + "retryable": false, + "action": "approve", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "mcp_check_tool_blocked_by_user", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "the tool's approval record is disabled by the user, checked in band", + "expect": { + "status": "unavailable", + "reason": "tool_blocked_by_user", + "retryable": false, + "action": "enable", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "mcp_check_tool_denied_by_config", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "the server's disabled_tools denylist contains the requested tool, checked in band", + "expect": { + "status": "unavailable", + "reason": "tool_denied_by_config", + "retryable": false, + "action": "configure", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "mcp_check_unknown_tool_id", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "misspelled tool name on a configured, in-scope server, checked in band", + "expect": { + "status": "unavailable", + "reason": "not_found", + "retryable": false, + "action": "configure", + "verdict": "unknown_ids", + "exit_code": 12 + } + }, + { + "scenario": "mcp_check_malformed_id", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "an id with no ':' separator: a per-id verdict with a format hint, never a batch failure, checked in band", + "expect": { + "status": "unavailable", + "reason": "not_found", + "retryable": false, + "action": "configure", + "verdict": "unknown_ids", + "exit_code": 12 + } + }, + { + "scenario": "mcp_check_unknown_server", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "tool id naming a server that is not configured at all: at the in-band tier server_not_configured collapses to a plain not_found, byte-indistinguishable from an unknown id (spec 099 FR-009)", + "expect": { + "status": "unavailable", + "reason": "not_found", + "retryable": false, + "action": "configure", + "verdict": "unknown_ids", + "exit_code": 12 + } + }, + { + "scenario": "mcp_check_out_of_scope", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "tool on a server outside the session's scope (agent-token allowed_servers or active profile): server_not_in_scope collapses to the SAME not_found, so a probe cannot learn what sits behind the scope (spec 099 FR-009/SC-007)", + "expect": { + "status": "unavailable", + "reason": "not_found", + "retryable": false, + "action": "configure", + "verdict": "unknown_ids", + "exit_code": 12 + } + }, + { + "scenario": "mcp_check_oauth_required", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "connection-state snapshot reports the deferred-OAuth PendingAuth state for the server, projected onto the in-band payload", + "expect": { + "status": "unavailable", + "reason": "oauth_required", + "retryable": false, + "action": "login", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "mcp_check_server_unhealthy", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "connection-state snapshot reports the server in the error state, projected onto the in-band payload", + "expect": { + "status": "unavailable", + "reason": "server_unhealthy", + "retryable": true, + "action": "view_logs", + "verdict": "degraded_retryable", + "exit_code": 10 + } + }, + { + "scenario": "mcp_check_server_initializing", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "connection-state snapshot reports the server connecting while its tools are still indexed, projected onto the in-band payload", + "expect": { + "status": "unavailable", + "reason": "server_initializing", + "retryable": true, + "verdict": "degraded_retryable", + "exit_code": 10 + } + }, + { + "scenario": "mcp_check_missing_annotation_read_only_only", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "filters.read_only_only against a tool whose upstream definition declares no annotations", + "expect": { + "status": "unavailable", + "reason": "missing_annotation", + "retryable": false, + "action": "configure", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "mcp_check_missing_annotation_exclude_destructive", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "filters.exclude_destructive against a tool whose upstream definition declares no annotations", + "expect": { + "status": "unavailable", + "reason": "missing_annotation", + "retryable": false, + "action": "configure", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "mcp_check_missing_annotation_exclude_open_world", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "filters.exclude_open_world against a tool whose upstream definition declares no annotations", + "expect": { + "status": "unavailable", + "reason": "missing_annotation", + "retryable": false, + "action": "configure", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "mcp_check_policy_filtered_read_only_only", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "filters.read_only_only against a tool explicitly annotated as unsafe for that filter", + "expect": { + "status": "unavailable", + "reason": "policy_filtered", + "retryable": false, + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "mcp_check_policy_filtered_exclude_destructive", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "filters.exclude_destructive against a tool explicitly annotated as unsafe for that filter", + "expect": { + "status": "unavailable", + "reason": "policy_filtered", + "retryable": false, + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "mcp_check_policy_filtered_exclude_open_world", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "filters.exclude_open_world against a tool explicitly annotated as unsafe for that filter", + "expect": { + "status": "unavailable", + "reason": "policy_filtered", + "retryable": false, + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "mcp_check_cap_boundary_50", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "exactly 50 raw ids in one check-mode call: all 50 are evaluated and every ready tool answers ready (the cap applies to the raw array, before trimming and dedup)", + "expect": { + "status": "ready", + "verdict": "ready", + "exit_code": 0 + } + }, + { + "scenario": "mcp_check_cap_exceeded_51", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "51 raw ids in one check-mode call", + "expect": { + "request_error": "too many tool_ids: 51 (max 50 with check:true)" + } + }, + { + "scenario": "mcp_check_filters_without_check", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "a `filters` object sent without check:true \u2014 silently ignoring it would let an agent believe a safety filter was applied when it was not", + "expect": { + "request_error": "'filters' requires 'check': true" + } + }, + { + "scenario": "mcp_check_expect_hashes_reserved", + "surface": "mcp-check", + "tier": "agent_token", + "sabotage": "the reserved `expect_hashes` field sent in any shape: rejected rather than ignored, so in-band pins stay additive (spec 099 FR-008)", + "expect": { + "request_error": "'expect_hashes' is reserved" + } + }, + { + "scenario": "mcp_plain_out_of_scope", + "surface": "mcp-plain", + "tier": "agent_token", + "sabotage": "describe_tool WITHOUT check for a tool on a server outside the session's scope: the retired `invisible` code is replaced by not_found, whose remediation was already the shared not-found text (spec 099 FR-011)", + "expect": { + "plain_error": "not_found" + } } - ] + ], + "rest_only_reasons": { + "hash_mismatch": "in-band hash pins were trimmed from spec 099 v1 (FR-008): nothing in band can request a pin, so nothing in band can fail one", + "server_not_in_scope": "the whole in-band surface is the agent-token tier (spec 099 FR-009), where this collapses to not_found", + "server_not_configured": "the whole in-band surface is the agent-token tier (spec 099 FR-009), where this collapses to not_found \u2014 see mcp_check_unknown_server, which asserts the collapse" + } } From 8d040a8fcaec083b60df5b24c58696754564200b Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 16 Aug 2026 06:59:50 +0300 Subject: [PATCH 06/11] docs(099): in-band check mode, disclosure tiers, contract amendment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Feature page: an "In band" section with the agent-loop example, the check-vs-describe distinction, the deliberate differences from REST/CLI (50-id cap, `filters` vs `policy` naming, no pins, no wait, pinned tier), and where check mode does NOT exist (code_execution/direct) with the interim path. - Disclosure-tier table now states that the whole in-band surface is the agent-token tier and that a server-edition OAuth user is too. - spec-085 describe_tool contract amended in place with a banner: check mode, the retired `invisible` code, the ≤250-token budget. - REST API reference: describe_tool per-id vocabulary, the check-mode twin of POST /api/v1/preflight, tier wording. - CHANGELOG: both compatibility breaks under a Breaking Changes heading, each with its migration note. Related #969 --- CHANGELOG.md | 24 +++++ ROADMAP.md | 2 +- docs/api/rest-api.md | 20 ++-- docs/features/tools-preflight.md | 79 +++++++++++++-- .../contracts/describe_tool.md | 96 ++++++++++++++++--- specs/085-compact-router/data-model.md | 5 +- specs/099-describe-check-mode/tasks.md | 18 ++-- 7 files changed, 206 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c83ce03..f593dc32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ Releases follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Breaking Changes + +- **mcp/describe_tool:** the per-id error code `invisible` is retired. An id on a server the + session cannot see (agent-token scope or active profile) now reports `not_found` — the same + code, `remediation` text and shape as an id that does not exist. A distinct code confirmed + that a hidden tool existed, which is the disclosure the contract was written to prevent, and + it is the only per-id code `describe_tool` has ever removed. **Migration:** a consumer + switching on `invisible` should treat it as `not_found`; the remediation string it renders is + unchanged. The plain-mode error vocabulary is now `not_found | quarantined | pending_approval + | changed | disabled`. (spec 099 FR-011, [#969](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/969)) +- **server edition / REST disclosure:** an ordinary OAuth-authenticated user (`AuthTypeUser`) + is now the agent-token disclosure tier rather than the operator tier, so tenant users no + longer receive tool hash pins or `server_not_in_scope` scope diagnostics from + `POST /api/v1/preflight` and the tool-listing endpoints. Admin API key, Unix socket, Windows + named pipe and the OAuth **admin** role are unaffected. (spec 099 FR-018a) + +### Features + +- **mcp:** `describe_tool` check mode — an optional `check: true` returns one availability + verdict per id (up to 50) from the spec-098 preflight evaluator instead of schemas, so an + agent can gate a multi-step plan without leaving the MCP session. Optional `filters` + (`read_only_only`, `exclude_destructive`, `exclude_open_world`); every run is on the activity + record, and the returned `request_id` finds it. (spec 099, [#969](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/969)) + ### Bug Fixes - **homebrew:** One-line install in docs + guard tap job against pre-release tags (#486) ([#486](https://github.com/smart-mcp-proxy/mcpproxy-go/pull/486)) ([`1098701`](https://github.com/smart-mcp-proxy/mcpproxy-go/commit/109870116fe17aa1ce7ecfd603962c1d3de21ba0)) diff --git a/ROADMAP.md b/ROADMAP.md index 958302af..3784b02e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -800,4 +800,4 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [096-batched-call-tools](./specs/096-batched-call-tools/) | `in-flight` | 15/16 (94%) | | [097-stored-scripts](./specs/097-stored-scripts/) | `in-flight` | 13/14 (93%) | | [098-tools-preflight](./specs/098-tools-preflight/) | `drafted` | 0/33 (0%) | -| [099-describe-check-mode](./specs/099-describe-check-mode/) | `drafted` | 0/10 (0%) | +| [099-describe-check-mode](./specs/099-describe-check-mode/) | `in-flight` | 9/10 (90%) | diff --git a/docs/api/rest-api.md b/docs/api/rest-api.md index fac13f7d..8ccb0da6 100644 --- a/docs/api/rest-api.md +++ b/docs/api/rest-api.md @@ -554,7 +554,8 @@ The MCP endpoints (not REST) additionally expose progressive-disclosure discover - **`tool_response_mode`** config (`full` default | `compact`, hot-reloadable via `POST /api/v1/config/apply`) controls `retrieve_tools` serialization only. In `compact` mode each entry is `{id, score, sig, desc, lossy}` — a one-line parameter signature (`*` = required, `~` = lossy) plus a first-sentence description — instead of full `inputSchema`, and the response carries one top-level `hint` line. Ranking is identical between modes. - **`detail`** — optional per-call `retrieve_tools` parameter (`compact` | `full`) overriding the configured mode for that call. -- **`describe_tool`** — built-in second-stage tool (retrieve_tools mode only): accepts 1–5 `server:tool` ids and returns full definitions (`name`, `description`, `inputSchema`, `server`, `annotations`, `call_with`) with per-id errors for unknown/invisible ids. It applies the same visibility pipeline as search (profile scope, agent-token scope, quarantine, tool approval, disabled) and never returns a definition `retrieve_tools` could not. +- **`describe_tool`** — built-in second-stage tool (retrieve_tools mode only): accepts 1–5 `server:tool` ids and returns full definitions (`name`, `description`, `inputSchema`, `server`, `annotations`, `call_with`) with per-id errors for ids that do not resolve. It applies the same visibility pipeline as search (profile scope, agent-token scope, quarantine, tool approval, disabled) and never returns a definition `retrieve_tools` could not. Per-id error codes: `not_found`, `quarantined`, `pending_approval`, `changed`, `disabled` (Spec 099 retired `invisible`: an out-of-scope id now reports `not_found`, indistinguishable from an id that does not exist — see the [breaking-change note](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/CHANGELOG.md)). +- **`describe_tool` check mode (Spec 099)** — the same built-in with `check: true` answers availability instead of definitions: up to 50 ids, an optional `filters` object (`read_only_only`, `exclude_destructive`, `exclude_open_world` — the REST body of `POST /api/v1/preflight` calls the same object `policy`), and a response of `{verdict, checked_at, request_id, results[]}` carrying the same reason codes this endpoint returns. It is the in-band twin of `POST /api/v1/preflight`, evaluated by the same evaluator, and differs deliberately: always the agent-token disclosure tier (never a hash, never `server_not_in_scope`), the session's own scope with no `profile` parameter, no hash pins (`expect_hashes` is a reserved field name and is rejected), and no wait budget. See [Required-Tools Preflight](../features/tools-preflight.md#in-band-describe_tool-check-mode). ### Tools @@ -737,12 +738,14 @@ failing. **Disclosure tiers:** -- **Operator tier** (admin API key, Unix socket, Windows named pipe): full - results — `hash` pins on ready results, `did_you_mean` suggestions, and the - `server_not_in_scope` diagnosis when a supplied `profile` excludes an - existing server (with a `detail` noting that a session under that profile - sees `not_found`). -- **Agent-token tier**: scope-silence — an out-of-scope ID's entire result is +- **Operator tier** (admin API key, Unix socket, Windows named pipe — plus the + server edition's OAuth **admin**): full results — `hash` pins on ready + results, `did_you_mean` suggestions, and the `server_not_in_scope` diagnosis + when a supplied `profile` excludes an existing server (with a `detail` noting + that a session under that profile sees `not_found`). +- **Agent-token tier** (agent tokens, the server edition's ordinary OAuth users, + and the whole in-band `describe_tool` check surface): scope-silence — an + out-of-scope ID's entire result is byte-indistinguishable from an ordinary `not_found` (same wording; no hashes; no `did_you_mean` crossing the scope boundary). `did_you_mean` is computed over the caller-visible index only and never suggests a quarantined server's @@ -750,7 +753,8 @@ failing. **Activity-record guarantee:** every request answered `200` writes an activity record **synchronously, before the response is returned** — request ID, -requested-ID count, set verdict, and per-tool reason codes (tool IDs and enum +requested-ID count (unique IDs, after dedup), set verdict, and per-tool reason +codes (tool IDs and enum codes only; no descriptions, no arguments, no hashes; local-only, never telemetry). Correlate via the `X-Request-Id` response header and `mcpproxy activity list --request-id `. diff --git a/docs/features/tools-preflight.md b/docs/features/tools-preflight.md index 675c1442..ca646648 100644 --- a/docs/features/tools-preflight.md +++ b/docs/features/tools-preflight.md @@ -101,6 +101,67 @@ A failing tool carries the full diagnosis: Full request/response schema: [REST API](../api/rest-api.md). CLI flag reference: [Management Commands](../cli/management-commands.md). +### In band: `describe_tool` check mode + +The CLI and REST surfaces need a harness *outside* the session. An agent already inside an MCP session gates itself with the same evaluator by adding `check: true` to the [`describe_tool`](./search-discovery.md) call it already knows: + +```json +{ + "name": "describe_tool", + "arguments": { + "tool_ids": ["gh-ops:sync_issues", "slack:post_message", "gh-ops:nope"], + "check": true + } +} +``` + +```json +{ + "verdict": "blocked", + "checked_at": "2026-08-16T09:14:02.117Z", + "request_id": "1755331442117-describe_tool-42", + "results": [ + { "id": "gh-ops:sync_issues", "status": "ready" }, + { "id": "slack:post_message", "status": "unavailable", "reason": "server_quarantined", + "retryable": false, "action": "approve", + "detail": "Server \"slack\" is quarantined; its tools are withheld pending review.", + "remediation": "Review the quarantined server and approve it if it is trusted (Web UI, Quarantine)." }, + { "id": "gh-ops:nope", "status": "unavailable", "reason": "not_found", "retryable": false, + "action": "configure", "detail": "No tool with this id is available.", + "remediation": "Check the tool id (format :) against mcpproxy tools list.", + "did_you_mean": ["gh-ops:sync_issues"] } + ] +} +``` + +**Check vs. describe.** They answer different questions and it is worth keeping them apart: describe (no `check`) returns *what a tool looks like* — the full JSON Schema, for building arguments after a lossy compact signature. Check returns *whether you may call it*, and no schema at all. Branch on the presence of `verdict`. + +**The agent loop it is meant for**: one call before the plan, not one per step. + +```text +plan → describe_tool{tool_ids: [the 8 ids the plan calls], check: true} + verdict "ready" → execute the plan + verdict "degraded_retryable" → wait and re-check (nothing to escalate yet) + verdict "blocked" → tell the user exactly what to approve/enable, in THIS turn + verdict "unknown_ids" → re-run retrieve_tools; an id is wrong or the tool moved +``` + +Differences from the out-of-band surfaces, all deliberate: + +| | In band (`check: true`) | REST / CLI | +|---|---|---| +| Batch cap | **50** ids (verdict-only results are ~30–60 tokens each; the cap exists so the response cannot become a discovery bypass) | 100 | +| Annotation filters | `filters: {read_only_only, exclude_destructive, exclude_open_world}` — named `filters` because that is the word `retrieve_tools` already teaches agents; the REST body calls the same object `policy` | `policy` | +| Hash pins | not accepted. `expect_hashes` is a **reserved** field name: sending it is an error, never a silent no-op | `pin_hash` / `--pin` | +| Disclosure tier | always the [agent-token tier](#disclosure-tiers), whatever the session's credentials | operator tier for API key / socket / named pipe | +| Scope | the session's own (agent-token `allowed_servers` ∩ profile pin ∩ active profile). There is no `profile` parameter — an agent cannot re-point its own scope by asking | `profile` in the request | +| Waiting | none. A `degraded_retryable` verdict is the agent's cue to retry on its own schedule rather than hold an MCP call open | `wait_ms` / `--wait` | +| Failures | request errors and "cannot evaluate" both come back as MCP tool errors that say **no verdict was computed** — never as a verdict | 400 / 503 | + +Duplicate ids are deduplicated (one result per unique id, in first-occurrence order); ids are trimmed first, and each result echoes the normalized id. One malformed id is a per-id `not_found`, never a batch failure. + +**Where check mode is not available**: `describe_tool` is registered on the default `/mcp` server and the `retrieve_tools` routing mode (`/mcp/call`, `/mcp/p/`) only. In `code_execution` and direct [routing modes](./routing-modes.md) there is no `describe_tool`, and therefore no in-band check — the interim path there is `POST /api/v1/preflight` from the harness that schedules the session. Registering the built-in on those surfaces is a later phase. + ## The reason taxonomy A per-tool result is `ready` or `unavailable` with **exactly one** reason from a closed 15-code enum. The enum only ever grows (treat unknown codes as non-retryable). `server_saturated` is reserved for a future revision. @@ -176,12 +237,15 @@ This turns "the proxy restarted 3 seconds before the cron tick" from a failed ni Preflight answers with different candor depending on who is asking: -| | Operator tier (API key, Unix socket, named pipe) | Agent-token tier | +| | Operator tier (API key, Unix socket, named pipe) | Agent-token tier (agent tokens, OAuth users, **and the whole in-band surface**) | |---|---|---| | Out-of-scope server | `server_not_in_scope` + detail explaining that a session under this profile sees `not_found` | plain `not_found` — byte-indistinguishable from a genuinely unknown id | +| Unconfigured server | `server_not_configured` | the same plain `not_found`, so a probe cannot enumerate what exists behind a scope | | Tool hashes | published on ready results | never | | `did_you_mean` | nearest visible ids | only within the token's own scope | +**The in-band surface is always the agent-token tier**, whatever credentials the MCP session presents. `/mcp` is unauthenticated by default and its middleware hands such requests a full admin context for client compatibility, so an auth context in band proves nothing about who is calling — a tier derived from it would be a tier the caller chooses. Operators wanting the full diagnosis (scope names, hashes) use the REST surface over an authenticated channel, where it already exists. In the **server edition**, an ordinary OAuth-authenticated user is also the agent-token tier on every surface: tenant users get verdicts, not scope diagnostics or hash pins. + The agent-token behavior is deliberate **scope-silence**: an out-of-scope probe learns nothing — not even that the server exists. `did_you_mean` suggestions (nearest-name, up to 3) are computed over the caller-visible index only and never name a quarantined server's tools. See [Agent Tokens](./agent-tokens.md) and [Profiles](./profiles.md). A token's evaluation scope is the intersection of its `allowed_servers`, its `profile_pin`, and any `profile` in the request — so naming another profile can only narrow it. If the pinned profile has since been **deleted**, the scope becomes deny-all and every id answers `not_found`: the pin is a restriction the operator applied, and losing the profile it names must never hand the token a wider view than it had before. The live MCP session path resolves the same way — a preflight's `not_found` for a stale pin is never a false alarm the session would contradict. Re-mint the token (or re-create the profile) to restore it. @@ -190,7 +254,9 @@ A token's evaluation scope is the intersection of its `allowed_servers`, its `pr Every executed preflight writes an [activity log](./activity-log.md) record — synchronously, before the 200 is returned. If the record cannot be persisted, the preflight itself fails with 503: a check nobody can audit afterwards would undercut the transparency the feature exists to provide. -The record carries the request ID, the requested-id count, the set verdict, and per-tool reason codes (ids and enum codes only — no descriptions, no arguments, and nothing leaves the machine): +The same rule holds in band: one check-mode call writes exactly one record (marked `surface: mcp-check`) before the verdict is returned, and a failed write fails the tool call. The `request_id` in the response body is that record's id, so an agent can hand a human the exact handle that finds the run — `mcpproxy activity list --request-id ` — without leaving the session. + +The record carries the request ID, the requested-id count (unique ids, after dedup), the set verdict, and per-tool reason codes (ids and enum codes only — no descriptions, no arguments, and nothing leaves the machine): ```bash RID=$(curl -si -X POST -H "X-API-Key: $API_KEY" http://127.0.0.1:8080/api/v1/preflight \ @@ -277,20 +343,21 @@ Or from any HTTP harness, the same `POST /api/v1/preflight` call shown above, fo } ``` -Note that a preflight is *not* callable from inside the sandbox — scripts cannot reach the REST API, which is exactly why the gate belongs in the harness. For an agent that needs an **in-band** check mid-session today, the interim path is `describe_tool` (batch of up to 5 ids), whose per-id codes report missing or blocked tools; a dedicated in-band check mode is Phase 2 (below). +Note that a preflight is *not* callable from inside the sandbox — scripts cannot reach the REST API, which is exactly why the gate belongs in the harness. `describe_tool` check mode does not help here either: `code_execution` routing mode carries no `describe_tool` at all, so the harness call above is the path for those sessions. ## Roadmap (later phases) -v1 is the shared eligibility evaluator + REST + CLI. Deliberately deferred: +The shared evaluator + REST + CLI shipped first; [in-band check mode](#in-band-describe_tool-check-mode) followed. Deliberately deferred: -- **In-band MCP check mode** on `describe_tool`, so agents can preflight mid-session without leaving the MCP surface. +- **`describe_tool` (and therefore check mode) in `code_execution` and direct routing modes** — today those sessions preflight from the harness. +- **In-band hash pins** — the `expect_hashes` field name is reserved and currently rejected; pins stay a REST/CLI concern, where harnesses author them. - **`readyz` probe endpoint** and SSE readiness events. - **Tool lockfile** (`mcpproxy tools lock/verify`) and registered automation contracts with change-time warnings. - **Agent-token-carried required-tools contracts** and an MCP extension for capability negotiation. - **Per-user verdicts in the server edition** (`as_user` reserved): v1 verdicts are operator-view, so `oauth_required` reflects global connection state, not the calling user's own token. - **`server_saturated`** (queue-saturation verdicts, reserved). -Design background: [issue #969](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/969) and the [spec + research record](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/specs/098-tools-preflight/spec.md) in the repo. +Design background: [issue #969](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/969) and the spec + research records in the repo — [098 (evaluator, REST, CLI)](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/specs/098-tools-preflight/spec.md) and [099 (in-band check mode)](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/specs/099-describe-check-mode/spec.md). ## See also diff --git a/specs/085-compact-router/contracts/describe_tool.md b/specs/085-compact-router/contracts/describe_tool.md index 78abd178..559d1a78 100644 --- a/specs/085-compact-router/contracts/describe_tool.md +++ b/specs/085-compact-router/contracts/describe_tool.md @@ -4,15 +4,32 @@ Built-in second-stage tool (FR-010/011/012). Registered in the **retrieve_tools only** (v1): the default server (`registerTools`, mcp.go:689) and `buildCallToolModeTools` (mcp_routing.go:354). **Not** registered in code_execution or direct mode. -## Tool definition (agent-facing, ≤ ~150 tokens — FR-011) +> **Amended by spec 099 (`099-describe-check-mode`), 2026-08-16.** Three changes, marked +> `[099]` below: +> 1. an optional `check: true` mode returning availability verdicts instead of definitions, +> with an optional `filters` object and a 50-id cap of its own; +> 2. the per-id error code `invisible` is **RETIRED** — out-of-scope ids report `not_found`. +> This is a compatibility break for any consumer switching on the code; the remediation +> string is unchanged, because it was already the shared not-found text; +> 3. the definition token budget is raised from ≤150 to **≤250** (measured: 243). +> +> Everything else on this page is unchanged, and plain-mode responses (no `check`) remain +> byte-identical to the pre-099 release apart from change (2). + +## Tool definition (agent-facing, ≤250 tokens — FR-011 as amended by 099 FR-015) ``` name: describe_tool description: "Return full JSON Schema + long description for specific tools found via retrieve_tools. Use when a compact signature is marked lossy ('~') or you need the exact - schema before calling." + schema before calling. With check:true it returns one availability verdict per id instead + of schemas ('ready', or a reason code with retryable/action), to gate a plan before its + first call." params: - tool_ids: [str] (required) # 1..5 ids in ":" format, from retrieve_tools results + tool_ids: [str] (required) # ids in ":" format; max 5, or 50 with check:true + check: bool [099] # availability only, no schemas (default false) + filters: object [099] # check:true only — {read_only_only, exclude_destructive, + # exclude_open_world}, the spec-094 annotation filters ``` ## Request @@ -22,14 +39,59 @@ params: ``` Rules: -- `tool_ids` required, non-empty, 1–5 entries. Each `":"`. +- `tool_ids` required, non-empty, 1–5 entries (50 under `check: true` — **[099]**). Each `":"`. - >5 ids ⇒ single error (no partial dump — anti-bulk-loophole, spec edge case): ```json { "error": "too many tool_ids: 7 (max 5). Narrow your selection." } ``` (returned as an MCP tool error result; the batch is not processed.) - 0 ids / missing param ⇒ `"Missing required parameter 'tool_ids'"` style error (matches - existing `RequireString`/param-error convention). + existing `RequireString`/param-error convention). Check mode names its own cap in that + message ("1-50 tool ids"), never the plain-mode one — **[099]**. + +### Check mode — **[099]** + +`check: true` switches the response shape entirely; `check: false` or an absent `check` is +plain mode, unchanged. Under check mode: + +- Up to **50** ids, counted on the RAW array before trimming and dedup; over the cap the whole + call fails and nothing is evaluated. +- Ids are trimmed, then deduplicated: one result per unique id, in first-occurrence order, + each echoing the normalized id. (Plain mode still renders one entry per occurrence.) +- Optional `filters` — `{read_only_only, exclude_destructive, exclude_open_world}` — with + spec-094 semantics and order. Sent WITHOUT `check: true` it is a request error, because + ignoring it would let an agent believe a safety filter was applied when it was not. +- **Strict validation** (099 FR-012a), each a request error rather than a coercion: a + non-boolean `check` (including `null`), a non-object `filters`, an unknown `filters` member, + a non-boolean filter value, and the RESERVED `expect_hashes` field in any shape (in-band + hash pins were trimmed from v1; the name is reserved so they can be added later without a + silent-drop window). +- Response — no `definitions`, no `errors`; a caller branches on the presence of `verdict`: + +```json +{ + "verdict": "blocked", + "checked_at": "2026-08-16T09:14:02.117Z", + "request_id": "1755331442117-describe_tool-42", + "results": [ + { "id": "gh:sync", "status": "ready" }, + { "id": "slack:post", "status": "unavailable", "reason": "server_quarantined", + "retryable": false, "action": "approve", "detail": "…", "remediation": "…" }, + { "id": "gh:nope", "status": "unavailable", "reason": "not_found", "retryable": false, + "action": "configure", "detail": "…", "remediation": "…", + "did_you_mean": ["gh:sync"] } + ] +} +``` + +- The verdicts come from the spec-098 evaluator through the same glue the REST surface uses: + same closed 15-code enum, same precedence, same state sources. **No hash is ever returned**, + and the tier is ALWAYS the agent-token tier, so `server_not_in_scope` and + `server_not_configured` collapse into a byte-indistinguishable `not_found`. +- Zero upstream I/O and zero mutation, with one permitted local write: the preflight activity + record, written synchronously before the verdict. A failed write fails the call, and check + runs do NOT additionally emit the plain mode's `internal_tool_call` record. +- Conditions the REST surface answers with 400/503 are MCP tool errors here, never a verdict. ## Response (success — mixed valid/invalid still succeeds) @@ -64,8 +126,8 @@ Contract guarantees: claim, which was impossible because full entries carry `result.Score` at mcp.go:1455.) Spec FR-010 (as clarified) requires exactly this: field-equality over the definition fields; the ranked field is out of scope for a non-ranked lookup. -- **Batch resilience (FR-010)**: unknown/invisible ids become per-id `errors` entries; the call - as a whole returns success with whatever definitions resolved. +- **Batch resilience (FR-010)**: unknown and out-of-scope ids become per-id `errors` entries; + the call as a whole returns success with whatever definitions resolved. - **Mode independence (FR-012)**: identical output whether `tool_response_mode` is `full` or `compact` — describe_tool ignores the mode. @@ -82,15 +144,17 @@ gates make describe stricter, never looser. Check order for describe_tool: 1. **Index presence** — the tool exists in the (profile-scoped) index. Absent ⇒ per-id error `not_found`. 2. **Profile scope (Spec 057) + agent-token server scope (Spec 028)** — out of scope ⇒ per-id - error `invisible` (not distinguished from not_found in any way that leaks existence beyond what - search reveals). + error `not_found`, identical to an id that does not exist (**[099]**: this used to be a + distinct `invisible` code, which confirmed existence to exactly the caller who may not know + it; the remediation text was already the shared not-found string and is unchanged). 3. **Server-level quarantine** — quarantined server ⇒ per-id error (search hides these too). 4. **Tool-level approval (Spec 032)** — `pending`/`changed` ⇒ per-id error, not a definition. 5. **`isToolCallable(server, tool)`** (disabled/blocked) ⇒ per-id error. Only when all five pass does the handler resolve the full definition via `indexManager.GetToolsByServer(server)` (filtered to `tool`) and render it. Per-id error `error` -codes: `not_found`, `invisible`, `quarantined`, `pending_approval`, `changed`, `disabled`. Each +codes: `not_found`, `quarantined`, `pending_approval`, `changed`, `disabled` (**[099]**: +`invisible` retired). Each carries a `remediation` string reusing the existing `disabledToolRemediation` / quarantine remediation text where applicable. @@ -111,6 +175,12 @@ server A and asserts `describe_tool(["B:anything"])` yields an error, never a de - Same output in full and compact mode (FR-012). - Registered in retrieve_tools mode servers; absent from code_execution and direct mode (tools/list assertion). -- **≤150-token budget (FR-011)**: count the `describe_tool` definition's tokens with the - **pinned tokenizer** the bench uses — tiktoken `cl100k_base` (same encoder the spec-083 - profiler counts with, so the budget and the profiler agree) — and assert ≤150. +- **≤250-token budget (FR-011 as amended by 099 FR-015)**: count the `describe_tool` + definition's tokens with the **pinned tokenizer** the bench uses — tiktoken `cl100k_base` + (same encoder the spec-083 profiler counts with, so the budget and the profiler agree) — and + assert ≤250. The exact definition is additionally pinned by the `tools/list` goldens, so + prose cannot drift silently under the ceiling. +- **[099] Check mode**: one cell per observable reason code (the committed sabotage matrix's + `mcp-check` rows), the 50/51 cap boundary, every FR-012a rejection, the activity record and + its write-failure path, in-band/REST parity at the agent-token tier, and plain-mode + byte-identity with the single enumerated `invisible` → `not_found` delta. diff --git a/specs/085-compact-router/data-model.md b/specs/085-compact-router/data-model.md index 396a28ad..5e9c5970 100644 --- a/specs/085-compact-router/data-model.md +++ b/specs/085-compact-router/data-model.md @@ -130,8 +130,11 @@ Produced by `buildToolEntry(result, mode, opts)` (new `internal/server/mcp_entry ``` describe_tool(tool_ids: [str]) // 1..5 ids, ":" → { "definitions": [ {full-mode entry}, … ], - "errors": [ {"id": "...", "error": "not_found|invisible|…", "remediation": "..."}, … ] } + "errors": [ {"id": "...", "error": "not_found|quarantined|…", "remediation": "..."}, … ] } ``` +> **Amended by spec 099**: the `invisible` code is retired (out-of-scope ⇒ `not_found`), and the +> tool gained an optional `check: true` verdict mode with its own 50-id cap. The authoritative +> shape is [contracts/describe_tool.md](contracts/describe_tool.md). - Batch ≤5; >5 ⇒ single clear error naming the limit (no partial dump). - Each id resolved through the search visibility pipeline (profile, agent scope, callability, quarantine/disabled) before returning a definition (FR-011). Invisible/unknown ⇒ per-id error, diff --git a/specs/099-describe-check-mode/tasks.md b/specs/099-describe-check-mode/tasks.md index 3909ca99..36cc9017 100644 --- a/specs/099-describe-check-mode/tasks.md +++ b/specs/099-describe-check-mode/tasks.md @@ -1,12 +1,12 @@ # Tasks: describe_tool Check Mode (099) -- [ ] T001 Schema: `check` + `filters` params via a shared option builder on both describe_tool registrations (default surface + retrieve_tools mode); tokenized budget test constant → ≤250 (FR-015) -- [ ] T002 Handler check-mode branch in `internal/server/mcp_describe_tool.go`: FR-012a strict validation (incl. reserved `expect_hashes` error, filters-without-check error), 50-raw cap, normalize→dedup→first-occurrence order, evaluate via the 098 glue with session scope ∩ token scope ∩ pin at forced agent-token tier (FR-009/FR-009a), verdict-only payload with `verdict`/`checked_at`/`request_id` (FR-004), MCP-error mapping for 400/503 classes (FR-012); unit tests per FR cell -- [ ] T003 Activity: synchronous preflight record with surface marker `mcp-check`; suppress plain-mode `internal_tool_call` record for check runs; write-failure fails the call (FR-013); tests -- [ ] T004 Plain-mode disclosure fix: `invisible` → `not_found` (both code paths), spec-085 contract amendment + release note; byte-identity replay test with the single enumerated delta (FR-011) -- [ ] T005 FR-018a: REST `disclosureTier` maps `AuthTypeUser` to agent-token tier; disclosure tests both editions -- [ ] T006 Goldens: enumerated-delta snapshot test conversion; regenerate the two describe_tool surface goldens deliberately (FR-014); code_execution golden must NOT move -- [ ] T007 Sabotage matrix: mcp-check + mcp-plain rows (13 observable codes + scope/unconfigured collapse + filter cells + cap boundary + reserved-field + filters-without-check), reflection-gate exemptions for hash_mismatch/server_not_in_scope, mid_indexing note correction + never-indexed-while-connecting row (FR-016); parity test in-band vs REST at agent-token tier (FR-017) -- [ ] T008 Docs (FR-018/FR-002): feature page in-band section + agent-loop example, spec-085 contract update, naming-divergence note, interim story for code_execution/direct; release notes breaking-change entry -- [ ] T009 Gates: CI="" go test -race ./..., server-edition build/test/lint, golangci both tag sets, swagger/generate-types diff-clean (contracts untouched expected), frontend build if touched +- [x] T001 Schema: `check` + `filters` params via a shared option builder on both describe_tool registrations (default surface + retrieve_tools mode); tokenized budget test constant → ≤250 (FR-015) +- [x] T002 Handler check-mode branch in `internal/server/mcp_describe_tool.go`: FR-012a strict validation (incl. reserved `expect_hashes` error, filters-without-check error), 50-raw cap, normalize→dedup→first-occurrence order, evaluate via the 098 glue with session scope ∩ token scope ∩ pin at forced agent-token tier (FR-009/FR-009a), verdict-only payload with `verdict`/`checked_at`/`request_id` (FR-004), MCP-error mapping for 400/503 classes (FR-012); unit tests per FR cell +- [x] T003 Activity: synchronous preflight record with surface marker `mcp-check`; suppress plain-mode `internal_tool_call` record for check runs; write-failure fails the call (FR-013); tests +- [x] T004 Plain-mode disclosure fix: `invisible` → `not_found` (both code paths), spec-085 contract amendment + release note; byte-identity replay test with the single enumerated delta (FR-011) +- [x] T005 FR-018a: REST `disclosureTier` maps `AuthTypeUser` to agent-token tier; disclosure tests both editions +- [x] T006 Goldens: enumerated-delta snapshot test conversion; regenerate the two describe_tool surface goldens deliberately (FR-014); code_execution golden must NOT move +- [x] T007 Sabotage matrix: mcp-check + mcp-plain rows (13 observable codes + scope/unconfigured collapse + filter cells + cap boundary + reserved-field + filters-without-check), reflection-gate exemptions for hash_mismatch/server_not_in_scope, mid_indexing note correction + never-indexed-while-connecting row (FR-016); parity test in-band vs REST at agent-token tier (FR-017) +- [x] T008 Docs (FR-018/FR-002): feature page in-band section + agent-loop example, spec-085 contract update, naming-divergence note, interim story for code_execution/direct; release notes breaking-change entry +- [x] T009 Gates: CI="" go test -race ./..., server-edition build/test/lint, golangci both tag sets, swagger/generate-types diff-clean (contracts untouched expected), frontend build if touched - [ ] T010 opencode review of full diff (≤5 rounds), fix genuine findings; PR; green; merge per standing procedure From b0ade1da843674c0c6876d075ba4d1f555210762 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 16 Aug 2026 07:03:02 +0300 Subject: [PATCH 07/11] test(099): drop an unused matrix helper the v2 linter flagged Related #969 --- internal/server/preflight_matrix_test.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/internal/server/preflight_matrix_test.go b/internal/server/preflight_matrix_test.go index ffc02b50..81f0313e 100644 --- a/internal/server/preflight_matrix_test.go +++ b/internal/server/preflight_matrix_test.go @@ -76,17 +76,6 @@ var mcpCheckExemptReasons = map[string]string{ preflight.ReasonServerNotConfigured: "the in-band surface is pinned to the agent-token tier (FR-009), where this collapses to not_found", } -// scenariosBySurface indexes the matrix by surface. -func scenariosBySurface(scenarios map[string]sabotageScenario, surface string) map[string]sabotageScenario { - out := make(map[string]sabotageScenario) - for name, scenario := range scenarios { - if scenario.Surface == surface { - out[name] = scenario - } - } - return out -} - type sabotageMatrix struct { Scenarios []sabotageScenario `json:"scenarios"` } From bce26adaa53bfcfa0a596aca50b533a5cfa9d694 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 16 Aug 2026 07:05:27 +0300 Subject: [PATCH 08/11] test(099): plain-mode replay corpus with the one enumerated delta (SC-002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An 18-scenario corpus of describe_tool calls WITHOUT check — definitions, every per-id error code, did-you-mean, duplicates, padded ids, both request errors — replayed against a capture taken from the pre-099 handler in a throwaway worktree, compared byte for byte. The only permitted difference is named as a substitution: an out-of-scope id's `"error":"invisible"` becomes `"error":"not_found"`, and the rest of the entry (including the remediation) must be identical. A scenario that changes in any other way, a scenario that changes and is not enumerated, and an enumerated scenario that stops changing all fail. A miscased out-of-scope id is deliberately NOT enumerated: it resolves not_found before the scope gate and did so before this change too. Related #969 --- internal/server/describe_plain_corpus_test.go | 186 ++++++++++++++++++ .../describe_plain_corpus/pre099.json | 20 ++ 2 files changed, 206 insertions(+) create mode 100644 internal/server/describe_plain_corpus_test.go create mode 100644 internal/server/testdata/describe_plain_corpus/pre099.json diff --git a/internal/server/describe_plain_corpus_test.go b/internal/server/describe_plain_corpus_test.go new file mode 100644 index 00000000..d0d270dc --- /dev/null +++ b/internal/server/describe_plain_corpus_test.go @@ -0,0 +1,186 @@ +package server + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" +) + +// Spec 099 FR-011 / SC-002 — plain-mode byte-identity with ONE enumerated delta. +// +// `describe_tool` without `check` sits on the hot path of the compact router: +// it is how an agent recovers a full schema after a lossy signature, so a +// regression here degrades every compact-mode session. This replays a fixed +// corpus of plain-mode calls and compares each response BYTE FOR BYTE against a +// capture taken from the pre-099 handler. +// +// Exactly one difference is permitted, and it is named rather than tolerated: +// an out-of-scope id's per-id `error` moves from the retired `invisible` to +// `not_found` (the `remediation` was already the shared not-found string, so +// nothing else on the entry moves). Any second difference — a reordered key, a +// reworded remediation, a changed cap message — fails. +// +// The golden was captured with this exact file copied into a throwaway +// `git worktree` of the pre-099 commit and run with +// MCPPROXY_WRITE_DESCRIBE_PLAIN_CORPUS set, so the capture and the comparison +// share one serializer and cannot drift. + +const ( + describePlainCorpusGolden = "testdata/describe_plain_corpus/pre099.json" + describePlainCorpusWriteEnv = "MCPPROXY_WRITE_DESCRIBE_PLAIN_CORPUS" +) + +// describePlainDelta lists the corpus scenarios spec 099 is allowed to change, +// with the exact substitution that must account for the whole difference. +// A MISCASED out-of-scope id is deliberately absent: it never reached the scope +// gate — an id that is not in the index at all resolves to not_found first — so +// it answered not_found before this change too, and must still. +var describePlainDelta = map[string]struct{ from, to string }{ + "out_of_scope_id": {`"error":"invisible"`, `"error":"not_found"`}, + "out_of_scope_id_among_valid": {`"error":"invisible"`, `"error":"not_found"`}, +} + +// scopedSessionContext is the agent session the scope cases are observed under: +// scoped to github + quarry, so gitlab is out of scope. +func scopedSessionContext() context.Context { + return auth.WithAuthContext(context.Background(), &auth.AuthContext{ + Type: auth.AuthTypeAgent, + AgentName: "corpus-bot", + AllowedServers: []string{"github", "quarry"}, + Permissions: []string{auth.PermRead, auth.PermWrite}, + }) +} + +// describePlainCorpus is the replay corpus: every plain-mode shape an existing +// integration could depend on — resolved definitions, each per-id error code, +// the did-you-mean paths, duplicates, whitespace, and both request errors. +func describePlainCorpus() []struct { + name string + agent bool + args map[string]interface{} +} { + return []struct { + name string + agent bool + args map[string]interface{} + }{ + {"single_definition", false, map[string]interface{}{"tool_ids": []interface{}{"github:visible_tool"}}}, + {"two_definitions", false, map[string]interface{}{"tool_ids": []interface{}{"github:visible_tool", "quarry:lingering_tool"}}}, + {"unknown_id", false, map[string]interface{}{"tool_ids": []interface{}{"github:no_such_tool"}}}, + {"malformed_id", false, map[string]interface{}{"tool_ids": []interface{}{"not-an-id"}}}, + {"case_mismatch_did_you_mean", false, map[string]interface{}{"tool_ids": []interface{}{"GITHUB:visible_tool"}}}, + {"quarantined_id", true, map[string]interface{}{"tool_ids": []interface{}{"quarry:lingering_tool"}}}, + {"pending_id", true, map[string]interface{}{"tool_ids": []interface{}{"github:pending_tool"}}}, + {"changed_id", true, map[string]interface{}{"tool_ids": []interface{}{"github:changed_tool"}}}, + {"disabled_id", true, map[string]interface{}{"tool_ids": []interface{}{"github:disabled_tool"}}}, + {"out_of_scope_id", true, map[string]interface{}{"tool_ids": []interface{}{"gitlab:scoped_tool"}}}, + {"out_of_scope_id_among_valid", true, map[string]interface{}{"tool_ids": []interface{}{"github:visible_tool", "gitlab:scoped_tool"}}}, + {"out_of_scope_case_mismatch_id", true, map[string]interface{}{"tool_ids": []interface{}{"GITLAB:scoped_tool"}}}, + {"duplicate_ids", false, map[string]interface{}{"tool_ids": []interface{}{"github:visible_tool", "github:visible_tool"}}}, + {"padded_ids", false, map[string]interface{}{"tool_ids": []interface{}{" github:visible_tool ", "github: visible_tool"}}}, + {"mixed_valid_and_errors", true, map[string]interface{}{"tool_ids": []interface{}{"github:visible_tool", "github:pending_tool", "nope"}}}, + {"empty_ids", false, map[string]interface{}{"tool_ids": []interface{}{}}}, + {"missing_ids", false, map[string]interface{}{}}, + {"over_cap_six_ids", false, map[string]interface{}{"tool_ids": []interface{}{ + "github:visible_tool", "github:pending_tool", "github:changed_tool", + "github:disabled_tool", "quarry:lingering_tool", "github:no_such_tool", + }}}, + } +} + +// captureDescribePlainCorpus replays the corpus and returns scenario -> response +// text (the error text for a tool-error result, prefixed so the two shapes can +// never be confused). +func captureDescribePlainCorpus(t *testing.T) map[string]string { + t.Helper() + + proxy := createTestMCPProxyServer(t) + seedVisibilityFixture(t, proxy) + + captured := make(map[string]string) + for _, entry := range describePlainCorpus() { + ctx := context.Background() + if entry.agent { + ctx = scopedSessionContext() + } + req := mcp.CallToolRequest{} + req.Params.Arguments = entry.args + + result, err := proxy.handleDescribeTool(ctx, req) + require.NoErrorf(t, err, "scenario %s", entry.name) + require.NotNilf(t, result, "scenario %s", entry.name) + require.NotEmptyf(t, result.Content, "scenario %s", entry.name) + + text := result.Content[0].(mcp.TextContent).Text + if result.IsError { + text = "ERROR: " + text + } + captured[entry.name] = text + } + return captured +} + +func TestDescribeToolPlainCorpus_ByteIdenticalWithOneEnumeratedDelta(t *testing.T) { + captured := captureDescribePlainCorpus(t) + + if outPath := os.Getenv(describePlainCorpusWriteEnv); outPath != "" { + require.NoError(t, os.MkdirAll(filepath.Dir(outPath), 0o755)) + raw, err := json.MarshalIndent(captured, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(outPath, append(raw, '\n'), 0o644)) + t.Skipf("corpus written to %s (%s set); comparison skipped", outPath, describePlainCorpusWriteEnv) + } + + raw, err := os.ReadFile(describePlainCorpusGolden) + require.NoError(t, err, "missing pre-099 corpus capture") + var want map[string]string + require.NoError(t, json.Unmarshal(raw, &want)) + require.NotEmpty(t, want) + + // The corpus itself must not shrink: dropping a scenario would make + // "byte-identical" true by omission. + assert.Equal(t, sortedKeys(want), sortedKeys(captured), "the replay corpus must not gain or lose scenarios") + + var changed []string + for name, wantText := range want { + gotText, ok := captured[name] + if !ok { + continue // already reported by the key comparison + } + if gotText == wantText { + continue + } + changed = append(changed, name) + + delta, allowed := describePlainDelta[name] + if !assert.Truef(t, allowed, "scenario %s changed, and spec 099 permits no delta there:\nwant %s\ngot %s", name, wantText, gotText) { + continue + } + assert.Equalf(t, strings.ReplaceAll(wantText, delta.from, delta.to), gotText, + "scenario %s may differ ONLY by %s -> %s; anything else is a second, unenumerated delta", name, delta.from, delta.to) + assert.Containsf(t, wantText, delta.from, "scenario %s: the pre-099 capture must actually contain the retired code", name) + } + + sort.Strings(changed) + assert.Equal(t, sortedKeys(describePlainDelta), changed, + "the enumerated delta must be exactly the out-of-scope scenarios — no more (a regression) and no fewer (a stale enumeration)") +} + +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/server/testdata/describe_plain_corpus/pre099.json b/internal/server/testdata/describe_plain_corpus/pre099.json new file mode 100644 index 00000000..35b1c155 --- /dev/null +++ b/internal/server/testdata/describe_plain_corpus/pre099.json @@ -0,0 +1,20 @@ +{ + "case_mismatch_did_you_mean": "{\"definitions\":[],\"errors\":[{\"error\":\"not_found\",\"id\":\"GITHUB:visible_tool\",\"remediation\":\"Tool not found. Tool ids are case-sensitive — did you mean 'github:visible_tool'?\"}]}", + "changed_id": "{\"definitions\":[],\"errors\":[{\"error\":\"changed\",\"id\":\"github:changed_tool\",\"remediation\":\"Awaiting security approval. Ask the user to review and approve it in the mcpproxy UI.\"}]}", + "disabled_id": "{\"definitions\":[],\"errors\":[{\"error\":\"disabled\",\"id\":\"github:disabled_tool\",\"remediation\":\"Disabled by the user. Ask the user to re-enable it in the mcpproxy UI (Server detail → Tools) or via the API.\"}]}", + "duplicate_ids": "{\"definitions\":[{\"call_with\":\"call_tool_read\",\"description\":\"parityquery fixture tool alpha\",\"inputSchema\":{\"type\":\"object\"},\"name\":\"github:visible_tool\",\"server\":\"github\"},{\"call_with\":\"call_tool_read\",\"description\":\"parityquery fixture tool alpha\",\"inputSchema\":{\"type\":\"object\"},\"name\":\"github:visible_tool\",\"server\":\"github\"}],\"errors\":[]}", + "empty_ids": "ERROR: Missing required parameter 'tool_ids': provide 1-5 tool ids in '\u003cserver\u003e:\u003ctool\u003e' format", + "malformed_id": "{\"definitions\":[],\"errors\":[{\"error\":\"not_found\",\"id\":\"not-an-id\",\"remediation\":\"Tool ids must use '\\u003cserver\\u003e:\\u003ctool\\u003e' format, exactly as returned by retrieve_tools.\"}]}", + "missing_ids": "ERROR: Missing required parameter 'tool_ids': required argument \"tool_ids\" not found", + "mixed_valid_and_errors": "{\"definitions\":[{\"call_with\":\"call_tool_read\",\"description\":\"parityquery fixture tool alpha\",\"inputSchema\":{\"type\":\"object\"},\"name\":\"github:visible_tool\",\"server\":\"github\"}],\"errors\":[{\"error\":\"pending_approval\",\"id\":\"github:pending_tool\",\"remediation\":\"Awaiting security approval. Ask the user to review and approve it in the mcpproxy UI.\"},{\"error\":\"not_found\",\"id\":\"nope\",\"remediation\":\"Tool ids must use '\\u003cserver\\u003e:\\u003ctool\\u003e' format, exactly as returned by retrieve_tools.\"}]}", + "out_of_scope_case_mismatch_id": "{\"definitions\":[],\"errors\":[{\"error\":\"not_found\",\"id\":\"GITLAB:scoped_tool\",\"remediation\":\"Tool not found or no longer available; re-run retrieve_tools.\"}]}", + "out_of_scope_id": "{\"definitions\":[],\"errors\":[{\"error\":\"invisible\",\"id\":\"gitlab:scoped_tool\",\"remediation\":\"Tool not found or no longer available; re-run retrieve_tools.\"}]}", + "out_of_scope_id_among_valid": "{\"definitions\":[{\"call_with\":\"call_tool_read\",\"description\":\"parityquery fixture tool alpha\",\"inputSchema\":{\"type\":\"object\"},\"name\":\"github:visible_tool\",\"server\":\"github\"}],\"errors\":[{\"error\":\"invisible\",\"id\":\"gitlab:scoped_tool\",\"remediation\":\"Tool not found or no longer available; re-run retrieve_tools.\"}]}", + "over_cap_six_ids": "ERROR: too many tool_ids: 6 (max 5). Narrow your selection.", + "padded_ids": "{\"definitions\":[{\"call_with\":\"call_tool_read\",\"description\":\"parityquery fixture tool alpha\",\"inputSchema\":{\"type\":\"object\"},\"name\":\"github:visible_tool\",\"server\":\"github\"},{\"call_with\":\"call_tool_read\",\"description\":\"parityquery fixture tool alpha\",\"inputSchema\":{\"type\":\"object\"},\"name\":\"github:visible_tool\",\"server\":\"github\"}],\"errors\":[]}", + "pending_id": "{\"definitions\":[],\"errors\":[{\"error\":\"pending_approval\",\"id\":\"github:pending_tool\",\"remediation\":\"Awaiting security approval. Ask the user to review and approve it in the mcpproxy UI.\"}]}", + "quarantined_id": "{\"definitions\":[],\"errors\":[{\"error\":\"quarantined\",\"id\":\"quarry:lingering_tool\",\"remediation\":\"Its server is quarantined for security review. Its tools cannot be called until the user reviews and approves the server in the mcpproxy UI or system tray.\"}]}", + "single_definition": "{\"definitions\":[{\"call_with\":\"call_tool_read\",\"description\":\"parityquery fixture tool alpha\",\"inputSchema\":{\"type\":\"object\"},\"name\":\"github:visible_tool\",\"server\":\"github\"}],\"errors\":[]}", + "two_definitions": "{\"definitions\":[{\"call_with\":\"call_tool_read\",\"description\":\"parityquery fixture tool alpha\",\"inputSchema\":{\"type\":\"object\"},\"name\":\"github:visible_tool\",\"server\":\"github\"}],\"errors\":[{\"error\":\"quarantined\",\"id\":\"quarry:lingering_tool\",\"remediation\":\"Its server is quarantined for security review. Its tools cannot be called until the user reviews and approves the server in the mcpproxy UI or system tray.\"}]}", + "unknown_id": "{\"definitions\":[],\"errors\":[{\"error\":\"not_found\",\"id\":\"github:no_such_tool\",\"remediation\":\"Tool not found or no longer available; re-run retrieve_tools.\"}]}" +} From 54febc7f6282df24efcf6bcdfea28d5c5c879a58 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 16 Aug 2026 07:21:30 +0300 Subject: [PATCH 09/11] spec(099): align FR-016/FR-018a text with implemented semantics Related #969 Third reflection-gate exemption (server_not_configured collapses at the token tier per FR-009); FR-018a restated as a positive admin-class grant (AdminUser keeps operator tier; unknown types fall to agent-token). --- specs/099-describe-check-mode/spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/099-describe-check-mode/spec.md b/specs/099-describe-check-mode/spec.md index aa52ff79..cfbfc080 100644 --- a/specs/099-describe-check-mode/spec.md +++ b/specs/099-describe-check-mode/spec.md @@ -125,9 +125,9 @@ A user running the proxy in `code_execution` routing mode, or in direct mode, ha | + `check` + three flattened filter booleans + `expect_hashes` | 289 | +154 | With `expect_hashes` trimmed (FR-008 decision), the shipped shape is `check` + `filters` only — re-measure on the branch; the pre-trim measurement priced the full shape at 284 tokens, so the trimmed shape lands ≈230–240. The new budget is **≤250 tokens** (≈5% headroom — deliberately tight, so the next prose addition has to argue for itself). The cost is once per session on two surfaces, not per call, and is ~1 upstream tool schema's worth of context (the repo's own estimator uses ~150 tokens/schema). The budget MUST be enforced by the existing tokenized budget test with the new constant, AND the exact definition MUST be pinned by the `tools/list` golden snapshot (FR-014) so a prose edit shows up as a reviewable diff, not a silent drift under the ceiling. **The golden update is a deliberate, documented exception to the spec-098 FR-015 no-delta rule** — 098 forbade any MCP-surface movement because it shipped no MCP feature; 099 ships one, so its goldens move once, by intent, with the delta enumerated. -- **FR-016 (Sabotage matrix extension)**: The committed spec-098 sabotage matrix MUST gain a row for every reason-surface cell this feature creates, reusing the existing matrix infrastructure (scenario-keyed JSON + reflection gate) rather than a parallel one. Rows MUST record the surface (`mcp-check`, `mcp-plain`) and the disclosure tier, and MUST cover at minimum: every enum code observable on the `mcp-check` surface (all 15 EXCEPT `hash_mismatch` and `server_not_in_scope`, which are REST-only under the locked FR-008/FR-009 — the reflection gate encodes these two exemptions explicitly); out-of-scope and unconfigured servers in-band (⇒ byte-indistinguishable `not_found`); out-of-scope on the `mcp-plain` surface (⇒ `not_found`, FR-011); `missing_annotation` and `policy_filtered` via each of the three `filters`; the 50/51-id cap boundary; `filters` sent without `check`; `expect_hashes` sent (⇒ reserved-field request error). The reflection gate MUST be extended so a code with no `mcp-check` row fails CI, exactly as a code with no REST row does today. While extending it, one inherited defect MUST be corrected: the existing `mid_indexing` row's note claims a never-indexed server on a connecting upstream would report `not_found` ("existence outranks connection state"), which contradicts spec 098 FR-005 and the shipped evaluator — on a non-Ready server the connection-state verdict wins because existence is unknowable. Correct the note and add an explicit never-indexed-while-connecting row, so the matrix that 099's parity claims are measured against is itself right. +- **FR-016 (Sabotage matrix extension)**: The committed spec-098 sabotage matrix MUST gain a row for every reason-surface cell this feature creates, reusing the existing matrix infrastructure (scenario-keyed JSON + reflection gate) rather than a parallel one. Rows MUST record the surface (`mcp-check`, `mcp-plain`) and the disclosure tier, and MUST cover at minimum: every enum code observable on the `mcp-check` surface (all 15 EXCEPT `hash_mismatch`, `server_not_in_scope` AND `server_not_configured`, which are REST-only under the locked FR-008/FR-009 — the third follows from FR-009 itself: unconfigured collapses to `not_found` at the agent-token tier or a token could probe names to learn what exists; the reflection gate encodes all three exemptions with recorded reasoning, and rows assert the observable collapses); out-of-scope and unconfigured servers in-band (⇒ byte-indistinguishable `not_found`); out-of-scope on the `mcp-plain` surface (⇒ `not_found`, FR-011); `missing_annotation` and `policy_filtered` via each of the three `filters`; the 50/51-id cap boundary; `filters` sent without `check`; `expect_hashes` sent (⇒ reserved-field request error). The reflection gate MUST be extended so a code with no `mcp-check` row fails CI, exactly as a code with no REST row does today. While extending it, one inherited defect MUST be corrected: the existing `mid_indexing` row's note claims a never-indexed server on a connecting upstream would report `not_found` ("existence outranks connection state"), which contradicts spec 098 FR-005 and the shipped evaluator — on a non-Ready server the connection-state verdict wins because existence is unknowable. Correct the note and add an explicit never-indexed-while-connecting row, so the matrix that 099's parity claims are measured against is itself right. - **FR-017 (Parity)**: An automated parity test MUST assert that for identical ids, identical proxy state, the in-band surface (always agent-token tier, FR-009) and the REST surface AT the agent-token tier return equal `{status, reason, retryable, action}` per id. Fields the two payloads deliberately differ on (`checked_at`, and `hash`, which REST may disclose at the operator tier and check mode never does — FR-004) are excluded from the comparison by name, not by a loose matcher. A divergence in the compared fields is a defect in the glue, never a documented difference. -- **FR-018a (Inherited spec-098 erratum — REST disclosure tier)**: The spec-098 REST `disclosureTier` helper grants the operator tier to any admin-class auth context, which in the server edition includes a non-admin OAuth user (`AuthTypeUser`) — handing scope diagnostics and hash pins to a multi-user tenant. This change MUST correct it: only API-key / socket / named-pipe admin contexts get the operator tier; `AuthTypeUser` maps to the agent-token tier. Covered by a disclosure test in both editions. +- **FR-018a (Inherited spec-098 erratum — REST disclosure tier)**: The spec-098 REST `disclosureTier` helper grants the operator tier to any admin-class auth context, which in the server edition includes a non-admin OAuth user (`AuthTypeUser`) — handing scope diagnostics and hash pins to a multi-user tenant. This change MUST correct it: the operator tier is granted POSITIVELY to admin-class contexts only (API-key / socket / named-pipe admin, and the server edition's `AuthTypeAdminUser` — tenant admins legitimately author pins); `AuthTypeUser` and every unknown future auth type fall to the agent-token tier, eliminating the residual-grant shape that caused the defect. Covered by a disclosure test in both editions. - **FR-018 (Docs)**: Documentation MUST be updated: the preflight feature page (in-band section: when an agent should check vs. describe, worked agent-loop example, the 50-id cap rationale), the spec-085 `describe_tool` contract (new parameters, retired `invisible` code, new token budget), the REST API reference note on `describe_tool`, and the `filters`-vs-`policy` naming divergence (FR-007). The docs MUST state plainly that check mode is absent from `code_execution` and direct mode and what to use instead (FR-002). The release notes MUST carry the `invisible` → `not_found` compatibility break (FR-011) under a heading a consumer scanning for breaking changes will find. ### Key Entities From 25586426cc11254897e12a619a2bb304e78ba412 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 16 Aug 2026 14:31:43 +0300 Subject: [PATCH 10/11] fix(099): positive operator grant, raw check arguments, glue-driven matrix cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 cross-model review findings on the describe_tool check mode. FR-018a — a nil auth context received the operator tier, which is the exact residual-grant shape FR-018a exists to remove. disclosureTier now grants the operator tier POSITIVELY to admin-class contexts only; nil and every unknown type fall to the agent-token tier, and preflightParams no longer assumes a non-nil context. Nothing operator-facing is lost: apiKeyAuthMiddleware installs an explicit admin context for a validated API key and for the OS-authenticated socket / named pipe, which the new end-to-end test asserts through the real middleware rather than by constructing a context. The only path that reaches a handler with no context is the middleware's no-config passthrough, where an unreadable config is precisely not evidence of admin. FR-013 — the in-band record kept only deduped outcomes, so the raw requested-id count the spec promises was recoverable from nothing. The check surface now records its arguments as sent (raw tool_ids, annotation filters) under a new metadata key. ids_count stays the unique count both surfaces agree on, and the REST record is unchanged: PreflightActivity.Arguments is nil there, so no key is added to a payload nothing asked to change. FR-016/FR-017 — the connection-state and policy_filtered matrix cells ran the evaluator over a hand-built EvalContext, so the glue between handler and evaluator was never on their path. preflightSnapshot gains the one injectable seam (nil in production, mirroring preflightRecorder) and those cells now run the real describe_tool handler over the real scope/tier/annotation glue with only the snapshot injected. The FR-017 parity exclusions were structural by omission; they are now a named list with a reflection gate, so a field added to either payload must be compared or excluded by name. Related #969 --- docs/features/tools-preflight.md | 2 +- internal/httpapi/preflight.go | 25 +- internal/httpapi/preflight_test.go | 60 ++++- internal/httpapi/tool_hash_disclosure_test.go | 23 ++ internal/runtime/activity_preflight.go | 34 +++ internal/runtime/activity_preflight_test.go | 65 ++++++ internal/server/mcp.go | 10 + internal/server/mcp_describe_check.go | 43 +++- internal/server/mcp_describe_check_test.go | 34 +++ internal/server/preflight_glue.go | 6 + .../server/preflight_mcp_check_matrix_test.go | 216 ++++++++++-------- internal/storage/activity_models.go | 18 ++ 12 files changed, 431 insertions(+), 105 deletions(-) diff --git a/docs/features/tools-preflight.md b/docs/features/tools-preflight.md index ca646648..1bd08025 100644 --- a/docs/features/tools-preflight.md +++ b/docs/features/tools-preflight.md @@ -256,7 +256,7 @@ Every executed preflight writes an [activity log](./activity-log.md) record — The same rule holds in band: one check-mode call writes exactly one record (marked `surface: mcp-check`) before the verdict is returned, and a failed write fails the tool call. The `request_id` in the response body is that record's id, so an agent can hand a human the exact handle that finds the run — `mcpproxy activity list --request-id ` — without leaving the session. -The record carries the request ID, the requested-id count (unique ids, after dedup), the set verdict, and per-tool reason codes (ids and enum codes only — no descriptions, no arguments, and nothing leaves the machine): +The record carries the request ID, the requested-id count (unique ids, after dedup), the set verdict, and per-tool reason codes (ids and enum codes only — no descriptions, no upstream tool arguments, and nothing leaves the machine). An in-band record additionally carries the call's own `arguments` — the raw `tool_ids` array as the agent sent it, plus any annotation `filters` — so the raw requested count stays readable next to the deduped one: ```bash RID=$(curl -si -X POST -H "X-API-Key: $API_KEY" http://127.0.0.1:8080/api/v1/preflight \ diff --git a/internal/httpapi/preflight.go b/internal/httpapi/preflight.go index db8c68a5..9c0aa804 100644 --- a/internal/httpapi/preflight.go +++ b/internal/httpapi/preflight.go @@ -136,8 +136,14 @@ func preflightParams(r *http.Request, req *contracts.PreflightRequest, tools []p } if tier, authCtx := disclosureTier(r); tier == preflight.TierAgentToken { params.Tier = preflight.TierAgentToken - params.TokenServers = authCtx.AllowedServers - params.TokenProfilePin = authCtx.ProfilePin + // authCtx is nil when the request carries no auth context at all. The + // tier still narrows to the floor, but there is no token scope to apply: + // disclosure and visibility are separate decisions, and inventing a + // scope here would turn a missing credential into a deny-all evaluation. + if authCtx != nil { + params.TokenServers = authCtx.AllowedServers + params.TokenProfilePin = authCtx.ProfilePin + } } return params } @@ -159,12 +165,19 @@ func preflightParams(r *http.Request, req *contracts.PreflightRequest, tools []p // type added later, falls to the agent-token tier, so a new credential kind // cannot inherit disclosure by default. // -// A nil AuthContext stays operator: it is the in-process / personal-edition -// path where the REST layer is reached without the auth middleware having run, -// and demoting it would silently strip hashes from the local admin surfaces. +// A NIL AuthContext is part of "every other type": a request that reached a +// handler without the auth middleware having run proves nothing about who is +// calling, so it gets the floor rather than the ceiling. Nothing is lost by +// that, because every operator path carries an EXPLICIT admin context — +// apiKeyAuthMiddleware installs auth.AdminContext() for a validated API key and +// for the OS-authenticated Unix socket / Windows named pipe before the handler +// runs. The only way to arrive here with no context at all is the middleware's +// no-config passthrough, where "cannot read the config" is precisely not +// evidence of admin. Keeping nil on the operator side would preserve the +// residual-grant shape FR-018a exists to remove. func disclosureTier(r *http.Request) (preflight.Tier, *auth.AuthContext) { authCtx := auth.AuthContextFromContext(r.Context()) - if authCtx == nil || authCtx.IsAdmin() { + if authCtx != nil && authCtx.IsAdmin() { return preflight.TierOperator, nil } return preflight.TierAgentToken, authCtx diff --git a/internal/httpapi/preflight_test.go b/internal/httpapi/preflight_test.go index dd036085..67e467f5 100644 --- a/internal/httpapi/preflight_test.go +++ b/internal/httpapi/preflight_test.go @@ -22,6 +22,7 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" internalRuntime "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/transport" ) const preflightTestAPIKey = "preflight-test-api-key" @@ -498,9 +499,22 @@ func TestPreflightParams_TierDetection(t *testing.T) { assert.Equal(t, preflight.TierOperator, preflightParams(req, body, tools).Tier) }) - t.Run("no auth context defaults to operator", func(t *testing.T) { + t.Run("no auth context falls to the scoped tier", func(t *testing.T) { + // Spec 099 FR-018a: the operator tier is granted POSITIVELY, and a + // request that reached the handler without the auth middleware having + // run is not evidence of an admin. Nothing is lost by demoting it — + // every real operator path installs an explicit admin context, which + // TestPreflight_TrustedTransportsAreOperatorTier asserts end-to-end + // through the middleware rather than by constructing one here. req := httptest.NewRequest(http.MethodPost, "/api/v1/preflight", nil) - assert.Equal(t, preflight.TierOperator, preflightParams(req, body, tools).Tier) + tier, authCtx := disclosureTier(req) + assert.Equal(t, preflight.TierAgentToken, tier) + assert.Nil(t, authCtx) + + params := preflightParams(req, body, tools) + assert.Equal(t, preflight.TierAgentToken, params.Tier) + assert.Nil(t, params.TokenServers, "the tier narrows; a missing credential invents no scope") + assert.Empty(t, params.TokenProfilePin) }) // Spec 099 FR-018a: the server edition's ordinary OAuth user is NOT an @@ -555,6 +569,48 @@ func TestPreflightParams_TierDetection(t *testing.T) { }) } +// End-to-end proof that the two operator transports authenticate the way +// PRODUCTION authenticates them, rather than by a hand-built context in a unit +// test: FR-018a's positive grant is only safe if the middleware really does +// install an admin context for a validated API key and for the OS-authenticated +// socket / named pipe. If either stopped doing so, the operator tier would +// silently disappear from that surface, and this is the test that would say so. +func TestPreflight_TrustedTransportsAreOperatorTier(t *testing.T) { + t.Run("api key over TCP", func(t *testing.T) { + ctrl := &preflightController{last: preflightStep{outcome: readyOutcome("ctl:echo")}} + srv := newPreflightServer(t, ctrl) + + w := doPreflight(t, srv, contracts.PreflightRequest{ + Tools: []contracts.PreflightToolRef{{ID: "ctl:echo"}}, + }) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + + params := ctrl.capturedParams() + require.Len(t, params, 1) + assert.Equal(t, preflight.TierOperator, params[0].Tier) + }) + + t.Run("unix socket / windows named pipe", func(t *testing.T) { + // The socket carries no API key — it is authenticated by OS-level + // permissions, which the middleware turns into an explicit admin + // context. FR-013 names socket and pipe as operator surfaces. + ctrl := &preflightController{last: preflightStep{outcome: readyOutcome("ctl:echo")}} + srv := newPreflightServer(t, ctrl) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/preflight", + strings.NewReader(`{"tools":[{"id":"ctl:echo"}]}`)) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(transport.TagConnectionContext(req.Context(), transport.ConnectionSourceTray)) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + + params := ctrl.capturedParams() + require.Len(t, params, 1) + assert.Equal(t, preflight.TierOperator, params[0].Tier) + }) +} + // End-to-end proof that a real agent token reaches the evaluator as the scoped // tier: the middleware, not just the helper, must classify it. func TestPreflight_AgentTokenRequestIsScopedTier(t *testing.T) { diff --git a/internal/httpapi/tool_hash_disclosure_test.go b/internal/httpapi/tool_hash_disclosure_test.go index 8375cb15..d6310a28 100644 --- a/internal/httpapi/tool_hash_disclosure_test.go +++ b/internal/httpapi/tool_hash_disclosure_test.go @@ -165,6 +165,29 @@ func TestToolHash_NeverDisclosedToAgentToken(t *testing.T) { } } +// Spec 099 FR-018a: a request that reached the handler with NO auth context — +// the middleware's no-config passthrough is the only way in — is not evidence of +// an admin, so it gets the agent-token tier and no pin. This is the second +// consumer of disclosureTier, and the one where a residual grant would publish +// hashes rather than merely widen a diagnosis. +func TestToolHash_NoAuthContextGetsNoPin(t *testing.T) { + ctrl := &unconfiguredToolHashController{toolHashController: newToolHashController()} + srv := NewServer(ctrl, zaptest.NewLogger(t).Sugar(), nil) + + tools := fetchTools(t, srv, "/api/v1/tools", "") + require.Contains(t, tools, "create_issue") + assert.NotContains(t, tools["create_issue"], "hash", + "no auth context is the disclosure floor, not the ceiling") +} + +// unconfiguredToolHashController reproduces the ONE middleware path that reaches +// a handler without installing an auth context: no readable config. +type unconfiguredToolHashController struct { + *toolHashController +} + +func (c *unconfiguredToolHashController) GetCurrentConfig() interface{} { return nil } + // The hash is proxy state, never upstream-supplied: a server declaring a tool // field literally named "hash" must not be able to publish a pin through the // listing (it would let a malicious upstream pin itself to a value the operator diff --git a/internal/runtime/activity_preflight.go b/internal/runtime/activity_preflight.go index bc250489..45ab9492 100644 --- a/internal/runtime/activity_preflight.go +++ b/internal/runtime/activity_preflight.go @@ -32,6 +32,24 @@ type PreflightToolOutcome struct { Reason string } +// PreflightArguments is one in-band caller's request AS SENT, before trimming +// and dedup (spec 099 FR-013). +// +// It exists because ids_count is the count of UNIQUE ids — the definition the +// REST record already uses, and the one that makes the two records mean the same +// thing — which on its own would make "the agent asked for 12 ids, three of them +// duplicates" unrecoverable from the log. Nothing here is new data: the ids +// already appear per-tool, and the filter names are the closed enum +// describe_tool declares. +type PreflightArguments struct { + // ToolIDs is the RAW array: request order, untrimmed, duplicates intact. + // len(ToolIDs) is the raw requested count. + ToolIDs []string + // Filters names the annotation filters in effect, in declared order. Empty + // when the call carried none. + Filters []string +} + // PreflightActivity is one executed preflight, as the served surface hands it to // the activity log. Everything here is either an enum value, a count or a tool // ID — never a description, argument or hash (FR-014: local activity log only, @@ -52,6 +70,10 @@ type PreflightActivity struct { // storage.PreflightSurfaceMCPCheck). Empty for the REST endpoint, whose // metadata then stays exactly as spec 098 shipped it. Surface string + // Arguments is the request as the caller sent it (spec 099 FR-013). Set by + // the in-band check surface; nil for the REST endpoint, whose metadata then + // stays exactly as spec 098 shipped it. + Arguments *PreflightArguments // Timestamp defaults to time.Now() when zero. Timestamp time.Time Tools []PreflightToolOutcome @@ -166,8 +188,20 @@ func preflightMetadata(rec PreflightActivity) map[string]interface{} { } // Absent, not empty, for the REST surface: adding a key to every record it // has written since spec 098 would change a payload nothing asked to change. + // The same rule governs the raw arguments below. if rec.Surface != "" { metadata[storage.MetadataKeyPreflightSurface] = rec.Surface } + if rec.Arguments != nil { + arguments := map[string]interface{}{ + // Present even when empty: "what was asked for" is the whole point + // of the key, and an absent array would read as "not recorded". + storage.PreflightArgumentsKeyToolIDs: rec.Arguments.ToolIDs, + } + if len(rec.Arguments.Filters) > 0 { + arguments[storage.PreflightArgumentsKeyFilters] = rec.Arguments.Filters + } + metadata[storage.MetadataKeyPreflightArguments] = arguments + } return metadata } diff --git a/internal/runtime/activity_preflight_test.go b/internal/runtime/activity_preflight_test.go index f2ae8e3e..f5d55e34 100644 --- a/internal/runtime/activity_preflight_test.go +++ b/internal/runtime/activity_preflight_test.go @@ -1,6 +1,7 @@ package runtime import ( + "sort" "testing" "time" @@ -124,6 +125,70 @@ func TestRecordPreflightAfterStopIsRefused(t *testing.T) { assert.ErrorIs(t, err, ErrActivityShuttingDown) } +// Spec 099 FR-013: the in-band record renders the raw request under its own +// metadata key, so the raw requested count is recoverable from the record even +// though ids_count reports the unique one. +func TestRecordPreflightRawArgumentsSurviveTheRoundTrip(t *testing.T) { + svc, mgr := newPreflightActivityService(t) + + require.NoError(t, svc.RecordPreflight(PreflightActivity{ + RequestID: "req-args", + Source: storage.ActivitySourceMCP, + Surface: storage.PreflightSurfaceMCPCheck, + Verdict: preflight.VerdictReady, + Arguments: &PreflightArguments{ + ToolIDs: []string{" gh:create_issue ", "gh:create_issue", "slack:post"}, + Filters: []string{"read_only_only"}, + }, + Tools: []PreflightToolOutcome{ + {ID: "gh:create_issue", Status: preflight.StatusReady}, + {ID: "slack:post", Status: preflight.StatusReady}, + }, + })) + + filter := storage.DefaultActivityFilter() + filter.RequestID = "req-args" + records, _, err := mgr.ListActivities(filter) + require.NoError(t, err) + require.Len(t, records, 1) + metadata := records[0].Metadata + + assert.InDelta(t, 2, metadata[storage.MetadataKeyPreflightIDsCount], 0.0001, + "ids_count stays the UNIQUE count, as on the REST surface") + + arguments, ok := metadata[storage.MetadataKeyPreflightArguments].(map[string]interface{}) + require.True(t, ok, "the arguments must survive the BBolt JSON round trip") + ids, ok := arguments[storage.PreflightArgumentsKeyToolIDs].([]interface{}) + require.True(t, ok) + assert.Equal(t, []interface{}{" gh:create_issue ", "gh:create_issue", "slack:post"}, ids, + "as sent: request order, untrimmed, duplicates intact") + assert.Len(t, ids, 3, "the raw requested count is recoverable") + assert.Equal(t, []interface{}{"read_only_only"}, arguments[storage.PreflightArgumentsKeyFilters]) +} + +// The REST surface sets neither Surface nor Arguments, and its metadata must +// therefore carry exactly the four keys spec 098 shipped — adding a key to every +// record written since then would change a payload nothing asked to change. +func TestPreflightMetadataRESTKeysAreUnchanged(t *testing.T) { + metadata := preflightMetadata(PreflightActivity{ + RequestID: "req-rest", + Verdict: preflight.VerdictReady, + Tools: []PreflightToolOutcome{{ID: "gh:create_issue", Status: preflight.StatusReady}}, + }) + + keys := make([]string, 0, len(metadata)) + for key := range metadata { + keys = append(keys, key) + } + sort.Strings(keys) + assert.Equal(t, []string{ + storage.MetadataKeyPreflightIDsCount, + storage.MetadataKeyPreflightPerTool, + storage.MetadataKeyPreflightReasons, + storage.MetadataKeyPreflightVerdict, + }, keys, "the REST record gains no key from a feature it does not use") +} + func TestPreflightActivityStatusMapping(t *testing.T) { assert.Equal(t, storage.ActivityStatusSuccess, PreflightActivityStatus(preflight.VerdictReady)) for _, verdict := range []string{ diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 9edc2b2f..2a4f80d8 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -151,6 +151,16 @@ type MCPProxyServer struct { // whole Runtime (mirrors workSessionResolver). preflightRecorder func(runtime.PreflightActivity) error + // preflightStateSource overrides the connection-state snapshot the + // preflight glue reads (Spec 099). Nil in production, where + // preflightSnapshot resolves it from the supervisor's StateView; tests + // install one so the cells that REQUIRE a live snapshot — the connection + // states, and the readable upstream annotations policy_filtered needs — + // are driven through the real glue and the real handler instead of around + // them (mirrors preflightRecorder). It returns exactly what + // preflightSnapshot returns: the state reader and the annotation lookup. + preflightStateSource func() (preflight.StateReader, func(serverName, toolName string) *config.ToolAnnotations, error) + // Routing mode MCP server instances (Spec 031) // Each instance has different tools registered for its routing mode. directServer *mcpserver.MCPServer // Direct mode: upstream tools with serverName__toolName naming diff --git a/internal/server/mcp_describe_check.go b/internal/server/mcp_describe_check.go index 963c54b4..92e69e84 100644 --- a/internal/server/mcp_describe_check.go +++ b/internal/server/mcp_describe_check.go @@ -240,7 +240,8 @@ func (p *MCPProxyServer) handleDescribeToolCheck( // FR-013: durable BEFORE the verdict is returned. A check nobody can audit // afterwards is not answered — the in-band mirror of the REST 503. - if err := p.recordPreflightActivity(describeCheckActivityRecord(ctx, outcome, sessionID, requestID)); err != nil { + if err := p.recordPreflightActivity( + describeCheckActivityRecord(ctx, outcome, rawIDs, mode.filters, sessionID, requestID)); err != nil { if p.logger != nil { p.logger.Error("describe_tool check: preflight activity record could not be persisted", zap.String("request_id", requestID), zap.Error(err)) @@ -308,15 +309,27 @@ func describeCheckResponse(outcome preflight.Outcome, requestID string, checkedA // describeCheckActivityRecord builds the FR-013 payload: enum codes, counts and // tool ids only — the same shape the REST surface writes, plus the surface // marker that tells the two apart. ids_count is the count of UNIQUE ids, as on -// the REST surface, so the two records mean the same thing. -func describeCheckActivityRecord(ctx context.Context, outcome preflight.Outcome, sessionID, requestID string) internalRuntime.PreflightActivity { +// the REST surface, so the two records mean the same thing; the RAW request that +// produced them is recorded alongside it, so "how many ids did the agent +// actually send" survives the dedup that ids_count reports. +func describeCheckActivityRecord( + ctx context.Context, + outcome preflight.Outcome, + rawIDs []string, + filters toolannotations.Filters, + sessionID, requestID string, +) internalRuntime.PreflightActivity { record := internalRuntime.PreflightActivity{ RequestID: requestID, SessionID: sessionID, Source: storage.ActivitySourceMCP, Surface: storage.PreflightSurfaceMCPCheck, Verdict: outcome.Verdict, - Tools: make([]internalRuntime.PreflightToolOutcome, 0, len(outcome.Results)), + Arguments: &internalRuntime.PreflightArguments{ + ToolIDs: rawIDs, + Filters: describeCheckFilterNames(filters), + }, + Tools: make([]internalRuntime.PreflightToolOutcome, 0, len(outcome.Results)), } if record.Verdict == "" { record.Verdict = preflight.VerdictReady @@ -336,6 +349,28 @@ func describeCheckActivityRecord(ctx context.Context, outcome preflight.Outcome, return record } +// describeCheckFilterNames lists the annotation filters in effect, in the order +// describe_tool declares them, for the activity record's raw arguments. Nil when +// none are set, so a filterless check records no filters key at all. +func describeCheckFilterNames(filters toolannotations.Filters) []string { + var names []string + for _, key := range describeCheckFilterKeys { + var on bool + switch key { + case "read_only_only": + on = filters.ReadOnlyOnly + case "exclude_destructive": + on = filters.ExcludeDestructive + case "exclude_open_world": + on = filters.ExcludeOpenWorld + } + if on { + names = append(names, key) + } + } + return names +} + // recordPreflightActivity writes one preflight record synchronously and returns // the write error, so the caller can refuse to answer without it (FR-013). // diff --git a/internal/server/mcp_describe_check_test.go b/internal/server/mcp_describe_check_test.go index f187f681..a77a2c81 100644 --- a/internal/server/mcp_describe_check_test.go +++ b/internal/server/mcp_describe_check_test.go @@ -357,6 +357,40 @@ func TestDescribeToolCheck_NormalizationAndDedup(t *testing.T) { "the record counts UNIQUE ids, exactly as the REST record does") } +// FR-013: ids_count is the UNIQUE count — the definition that makes the in-band +// and REST records mean the same thing — so the RAW request has to survive +// somewhere, or "the agent asked for four ids, two of them duplicates" is +// unrecoverable from the log. It survives in the recorded arguments, exactly as +// sent: request order, untrimmed, duplicates intact. +func TestDescribeToolCheck_RawArgumentsStayRecoverable(t *testing.T) { + fixture := newDescribeCheckFixture(t, nil) + seedCheckFixture(t, fixture) + + rawIDs := []interface{}{" gh:pending_tool ", "gh:create_issue", "gh:pending_tool", "gh:create_issue "} + _, _ = fixture.check(t, context.Background(), rawIDs, + map[string]interface{}{"filters": map[string]interface{}{"exclude_destructive": true}}) + + require.Len(t, fixture.records, 1) + record := fixture.records[0] + require.NotNil(t, record.Arguments, "the in-band record carries the request as sent") + assert.Equal(t, []string{" gh:pending_tool ", "gh:create_issue", "gh:pending_tool", "gh:create_issue "}, + record.Arguments.ToolIDs) + assert.Equal(t, []string{"exclude_destructive"}, record.Arguments.Filters, + "the filters that shaped the verdict are part of the request") + + // The two counts are recoverable and different: that difference is the + // whole point of recording both. + assert.Len(t, record.Arguments.ToolIDs, 4, "raw requested count") + assert.Len(t, record.Tools, 2, "unique evaluated count (ids_count)") + + // A filterless check records no filters at all rather than three falses. + fixture.records = nil + _, _ = fixture.check(t, context.Background(), []interface{}{"gh:create_issue"}, nil) + require.Len(t, fixture.records, 1) + require.NotNil(t, fixture.records[0].Arguments) + assert.Empty(t, fixture.records[0].Arguments.Filters) +} + // --- FR-007: filters --------------------------------------------------------- // A tool whose upstream definition declares no annotations is withheld by each diff --git a/internal/server/preflight_glue.go b/internal/server/preflight_glue.go index ae382588..1fc657c4 100644 --- a/internal/server/preflight_glue.go +++ b/internal/server/preflight_glue.go @@ -377,6 +377,12 @@ func (r *preflightApprovalReader) ToolApproval(serverName, toolName string) (*pr // process state FR-006 names: the served surface must refuse with 503 rather // than evaluate blind, so that case returns ErrRuntimeUnavailable. func (p *MCPProxyServer) preflightSnapshot() (preflight.StateReader, func(serverName, toolName string) *config.ToolAnnotations, error) { + // The one injectable seam (nil in production): a test can supply the + // snapshot a fixture without a live supervisor cannot produce, and still + // exercise every line of glue below the snapshot read. + if p.preflightStateSource != nil { + return p.preflightStateSource() + } if p.mainServer == nil || p.mainServer.runtime == nil { return nil, nil, nil } diff --git a/internal/server/preflight_mcp_check_matrix_test.go b/internal/server/preflight_mcp_check_matrix_test.go index 020b2722..c8088165 100644 --- a/internal/server/preflight_mcp_check_matrix_test.go +++ b/internal/server/preflight_mcp_check_matrix_test.go @@ -4,9 +4,10 @@ import ( "context" "encoding/json" "fmt" + "reflect" "sort" + "strings" "testing" - "time" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" @@ -14,8 +15,8 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/toolannotations" ) // Spec 099 T007 — the in-band half of the committed sabotage matrix. @@ -28,52 +29,29 @@ import ( // // Cells that need a live connection-state snapshot (the three connection // reasons) or readable upstream annotations (policy_filtered — Bleve stores -// identity and text only, so an index round-trip always loses annotations) are -// driven through the SAME evaluator with an injected snapshot and then -// projected onto the in-band payload, which is the precedent spec 098 set with -// its pending_auth cell. What is in-band-specific about those rows is the -// projection, and that is what is asserted. - -// --- state-injected stubs with annotations ---------------------------------- - -type annotatedStubIndex struct { - tools map[string][]string - annotations map[string]*config.ToolAnnotations -} - -func (s annotatedStubIndex) ToolsByServer(serverName string) ([]preflight.IndexedTool, error) { - out := make([]preflight.IndexedTool, 0, len(s.tools[serverName])) - for _, name := range s.tools[serverName] { - out = append(out, preflight.IndexedTool{ - Name: serverName + ":" + name, - Annotations: s.annotations[serverName+":"+name], - }) - } - return out, nil -} - -func (s annotatedStubIndex) IndexedServerNames() ([]string, error) { - names := make([]string, 0, len(s.tools)) - for name := range s.tools { - names = append(names, name) - } - return names, nil -} - -// evaluateInjected runs the real evaluator over an injected state snapshot and -// returns the in-band payload the check handler would have serialized. -func evaluateInjected(t *testing.T, ec preflight.EvalContext, id string) describeCheckResult { +// identity and text only, so an index round-trip always loses annotations) +// cannot be induced by fixture state alone. They are driven through the SAME +// call path as every other row — the real describe_tool handler, the real +// scope/tier glue, the real projection — with ONLY the snapshot injected, at +// the one seam production reads it from. Injecting the snapshot rather than the +// EvalContext is what keeps the glue wiring (scope composition, tier pinning, +// index-annotation enrichment, the activity write) inside the test instead of +// beside it. + +// injectPreflightSnapshot makes the fixture's proxy see a connection-state +// snapshot: every configured server in the given state, and the given +// annotations on gh:create_issue (the tool the annotation cells check). +func injectPreflightSnapshot(t *testing.T, f *describeCheckFixture, state preflight.ServerRuntimeState, annotations *config.ToolAnnotations) { t.Helper() - ec.Tier = preflight.TierAgentToken - results, err := preflight.Evaluate(context.Background(), ec, []preflight.ToolRef{{ID: id}}) - require.NoError(t, err) - require.Len(t, results, 1) - - payload := describeCheckResponse( - preflight.Outcome{Verdict: preflight.VerdictForResults(results), Results: results}, - "req-injected", time.Now().UTC()) - require.Len(t, payload.Results, 1) - return payload.Results[0] + f.proxy.preflightStateSource = func() (preflight.StateReader, func(serverName, toolName string) *config.ToolAnnotations, error) { + return stubState{state: state}, func(serverName, toolName string) *config.ToolAnnotations { + if serverName == "gh" && toolName == "create_issue" { + return annotations + } + return nil + }, nil + } + t.Cleanup(func() { f.proxy.preflightStateSource = nil }) } // assertMatrixCell checks one per-tool result against its committed row. @@ -133,16 +111,16 @@ func TestPreflightMatrixMCPSurfaces(t *testing.T) { "mcp_check_out_of_scope": "secret:exfiltrate", } // Cells whose state cannot be induced without a live snapshot or readable - // annotations, driven through the evaluator with an injected one. - injected := map[string]func(t *testing.T) describeCheckResult{ - "mcp_check_oauth_required": func(t *testing.T) describeCheckResult { - return evaluateInjected(t, injectedEvalContext(preflight.RuntimeStatePendingAuth, nil), "gh:create_issue") + // annotations: same handler, same glue, only the snapshot injected. + injected := map[string]func(t *testing.T) (describeCheckResult, string){ + "mcp_check_oauth_required": func(t *testing.T) (describeCheckResult, string) { + return checkWithSnapshot(t, fixture, preflight.RuntimeStatePendingAuth, nil, nil) }, - "mcp_check_server_unhealthy": func(t *testing.T) describeCheckResult { - return evaluateInjected(t, injectedEvalContext(preflight.RuntimeStateError, nil), "gh:create_issue") + "mcp_check_server_unhealthy": func(t *testing.T) (describeCheckResult, string) { + return checkWithSnapshot(t, fixture, preflight.RuntimeStateError, nil, nil) }, - "mcp_check_server_initializing": func(t *testing.T) describeCheckResult { - return evaluateInjected(t, injectedEvalContext(preflight.RuntimeStateConnecting, nil), "gh:create_issue") + "mcp_check_server_initializing": func(t *testing.T) (describeCheckResult, string) { + return checkWithSnapshot(t, fixture, preflight.RuntimeStateConnecting, nil, nil) }, } for filterKey, annotations := range map[string]*config.ToolAnnotations{ @@ -151,10 +129,9 @@ func TestPreflightMatrixMCPSurfaces(t *testing.T) { "exclude_open_world": {OpenWorldHint: boolPtr(true)}, } { filterKey, annotations := filterKey, annotations - injected["mcp_check_policy_filtered_"+filterKey] = func(t *testing.T) describeCheckResult { - ec := injectedEvalContext(preflight.RuntimeStateReady, annotations) - ec.Filters = filtersFor(filterKey) - return evaluateInjected(t, ec, "gh:create_issue") + injected["mcp_check_policy_filtered_"+filterKey] = func(t *testing.T) (describeCheckResult, string) { + return checkWithSnapshot(t, fixture, preflight.RuntimeStateReady, annotations, + map[string]interface{}{"filters": map[string]interface{}{filterKey: true}}) } } @@ -173,14 +150,14 @@ func TestPreflightMatrixMCPSurfaces(t *testing.T) { }) } - // --- state-injected verdict cells --- + // --- snapshot-injected verdict cells --- for scenarioName, drive := range injected { scenario, ok := scenarios[scenarioName] require.Truef(t, ok, "scenario %q is missing from %s", scenarioName, preflightMatrixPath) driven[scenarioName] = true t.Run(scenarioName, func(t *testing.T) { - result := drive(t) - assertMatrixCell(t, scenario, result, preflight.ReasonVerdict(result.Reason)) + result, verdict := drive(t) + assertMatrixCell(t, scenario, result, verdict) }) } @@ -280,29 +257,22 @@ func overCapIDs() []interface{} { return ids } -func filtersFor(key string) toolannotations.Filters { - switch key { - case "read_only_only": - return toolannotations.Filters{ReadOnlyOnly: true} - case "exclude_destructive": - return toolannotations.Filters{ExcludeDestructive: true} - default: - return toolannotations.Filters{ExcludeOpenWorld: true} - } -} - -// injectedEvalContext is a configured, enabled, indexed, approved gh:create_issue -// on a server in the given connection state. -func injectedEvalContext(state preflight.ServerRuntimeState, annotations *config.ToolAnnotations) preflight.EvalContext { - return preflight.EvalContext{ - Index: annotatedStubIndex{ - tools: map[string][]string{"gh": {"create_issue"}}, - annotations: map[string]*config.ToolAnnotations{"gh:create_issue": annotations}, - }, - Approvals: stubApprovals{}, - State: stubState{state: state}, - Policy: stubPolicy{enabled: map[string]bool{"gh": true}}, - } +// checkWithSnapshot runs one real check-mode call for gh:create_issue with a +// connection-state snapshot injected, and returns its single result plus the set +// verdict the handler computed. +func checkWithSnapshot( + t *testing.T, + fixture *describeCheckFixture, + state preflight.ServerRuntimeState, + annotations *config.ToolAnnotations, + extra map[string]interface{}, +) (describeCheckResult, string) { + t.Helper() + injectPreflightSnapshot(t, fixture, state, annotations) + payload, raw := fixture.check(t, scopedAgentContext(), []interface{}{"gh:create_issue"}, extra) + require.Len(t, payload.Results, 1) + assert.NotContains(t, raw, "\"hash\"", "no hash is ever returned in band") + return payload.Results[0], payload.Verdict } // TestPreflightMatrixNeverIndexedWhileConnecting is the FR-016 erratum: on a @@ -340,10 +310,65 @@ func TestPreflightMatrixNeverIndexedWhileConnecting(t *testing.T) { // --- FR-017: in-band vs REST parity ----------------------------------------- +// preflightParityComparedFields are the per-result fields the parity loop +// asserts equal between the two surfaces, by their wire names. +// +// preflightParityExcludedFields are the ONLY fields allowed to differ, each with +// the reason it does. FR-017 requires the exclusions to be named rather than +// expressed as "whatever the loop happens not to touch": an omission is +// invisible, a name is reviewable. TestPreflightParityExclusionsAreNamed turns +// that into a gate — a field added to either payload belongs on one list or the +// other, or the build of the parity claim fails. +var ( + preflightParityComparedFields = []string{"id", "status", "reason", "retryable", "action", "detail", "remediation", "did_you_mean"} + + preflightParityExcludedFields = map[string]string{ + "checked_at": "a timestamp, not a verdict: each surface stamps its own instant (FR-004)", + "hash": "REST may disclose a pin at the operator tier; in band, never, at any tier (FR-004)", + "request_id": "in-band only: the correlation id an agent hands a human (FR-004)", + "waited_ms": "REST only: check mode takes no wait budget (non-goal)", + "verdict": "compared once at the set level, not per result", + "results": "the in-band container of the compared results", + "tools": "the REST container of the compared results", + } +) + +// Every field on either payload is either compared or excluded BY NAME. This is +// the gate FR-017 asks for: adding a field to one surface's result without +// deciding whether it must agree with the other's fails here, instead of +// silently leaving the parity claim narrower than it reads. +func TestPreflightParityExclusionsAreNamed(t *testing.T) { + compared := make(map[string]bool, len(preflightParityComparedFields)) + for _, name := range preflightParityComparedFields { + compared[name] = true + assert.NotContainsf(t, preflightParityExcludedFields, name, + "%q cannot be both compared and excluded", name) + } + + for _, payload := range []any{ + describeCheckResult{}, describeCheckPayload{}, + contracts.PreflightToolResult{}, contracts.PreflightResponse{}, + } { + typ := reflect.TypeOf(payload) + for i := 0; i < typ.NumField(); i++ { + name, _, _ := strings.Cut(typ.Field(i).Tag.Get("json"), ",") + require.NotEmptyf(t, name, "%s.%s has no json tag", typ.Name(), typ.Field(i).Name) + if compared[name] { + continue + } + _, excluded := preflightParityExcludedFields[name] + assert.Truef(t, excluded, + "%s.%s (%q) is neither compared by the parity test nor excluded from it by name", + typ.Name(), typ.Field(i).Name, name) + } + } +} + // For identical ids and identical proxy state, the in-band surface and the REST // surface AT THE SAME TIER name the same thing. The two payloads deliberately -// differ on checked_at and hash, which are excluded BY NAME rather than by a -// loose matcher — anything else that differs is a defect in the glue. +// differ only on the fields named in preflightParityExcludedFields — excluded by +// name, not by a loose matcher — and anything else that differs is a defect in +// the glue. func TestPreflightInBandRESTParityAtAgentTokenTier(t *testing.T) { fixture := newDescribeCheckFixture(t, nil) seedCheckFixture(t, fixture) @@ -390,7 +415,10 @@ func TestPreflightInBandRESTParityAtAgentTokenTier(t *testing.T) { mcpResult := checkResultByID(t, inBand, id) restResult := resultByID(t, rest, id) - // The compared tuple, field by field and by name. + // The compared set, field by field and by wire name. It is the + // same list TestPreflightParityExclusionsAreNamed measures the two + // payloads against, so "compared" cannot quietly shrink. + assert.Equal(t, restResult.ID, mcpResult.ID, "id") assert.Equal(t, restResult.Status, mcpResult.Status, "status") assert.Equal(t, restResult.Reason, mcpResult.Reason, "reason") assert.Equal(t, restResult.Action, mcpResult.Action, "action") @@ -402,13 +430,17 @@ func TestPreflightInBandRESTParityAtAgentTokenTier(t *testing.T) { } // Detail and remediation are not in the FR-017 tuple, but they come // from the same evaluator result, so a divergence would mean the - // projection rewrote them. + // projection rewrote them. did_you_mean is compared for the same + // reason: it is computed over the caller-visible scope, which the + // two surfaces must resolve identically. assert.Equal(t, restResult.Detail, mcpResult.Detail, "detail") assert.Equal(t, restResult.Remediation, mcpResult.Remediation, "remediation") + assert.Equal(t, restResult.DidYouMean, mcpResult.DidYouMean, "did_you_mean") - // The two documented divergences: the REST result may carry a hash - // (not at this tier, but the field exists); the in-band payload has - // no hash field at all, and checked_at is a timestamp, not a verdict. + // hash is one of the named exclusions and the only one with a + // disclosure consequence, so it gets an assertion of its own: the + // in-band payload has no such field at all, and at this tier the + // REST payload must not fill one either. assert.Empty(t, restResult.Hash, "the agent-token tier discloses no hash on either surface") }) } diff --git a/internal/storage/activity_models.go b/internal/storage/activity_models.go index e44eb5b1..fd7ea00e 100644 --- a/internal/storage/activity_models.go +++ b/internal/storage/activity_models.go @@ -126,6 +126,24 @@ const ( // surface, whose records predate it and stay byte-identical. MetadataKeyPreflightSurface = "surface" + // MetadataKeyPreflightArguments records the in-band caller's request AS + // SENT, so the raw requested-id count stays recoverable from the record + // even though MetadataKeyPreflightIDsCount is the UNIQUE count both + // surfaces agree on (spec 099 FR-013). It carries the PreflightArgumentsKey* + // members below: still ids and enum-valued filter names, never descriptions + // or upstream arguments. OMITTED for the REST surface, whose records + // predate it and stay byte-identical. + MetadataKeyPreflightArguments = "arguments" + + // Keys inside MetadataKeyPreflightArguments. + // + // PreflightArgumentsKeyToolIDs is the raw tool_ids array: request order, + // untrimmed, duplicates intact — len() is the raw requested count. + PreflightArgumentsKeyToolIDs = "tool_ids" + // PreflightArgumentsKeyFilters lists the annotation filters that were in + // effect, in the order describe_tool declares them. Absent when none were. + PreflightArgumentsKeyFilters = "filters" + // PreflightSurfaceMCPCheck marks a record written by describe_tool check // mode. It matches the `surface` value the spec-099 sabotage-matrix rows // carry, so a matrix row and an activity record name the surface the same From 56aeea9a7128f2648d4a9dbd9e0080a1cfacd028 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 16 Aug 2026 14:51:44 +0300 Subject: [PATCH 11/11] =?UTF-8?q?fix(099):=20round=202=20=E2=80=94=20parit?= =?UTF-8?q?y=20exclusions=20scoped=20per=20wire=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #969 A globally excluded field let one surface's exclusion reason blanket the other (a future MCP-side hash would silently escape comparison). Exclusions are now per-surface maps; the reflection gate checks each struct against its own surface's list and prunes stale exclusions (an excluded name matching no real field fails). Reviewer: opencode/gpt-5.6-sol round 2 (1 finding). --- .../server/preflight_mcp_check_matrix_test.go | 74 +++++++++++++------ 1 file changed, 53 insertions(+), 21 deletions(-) diff --git a/internal/server/preflight_mcp_check_matrix_test.go b/internal/server/preflight_mcp_check_matrix_test.go index c8088165..8b0cdc1c 100644 --- a/internal/server/preflight_mcp_check_matrix_test.go +++ b/internal/server/preflight_mcp_check_matrix_test.go @@ -313,7 +313,7 @@ func TestPreflightMatrixNeverIndexedWhileConnecting(t *testing.T) { // preflightParityComparedFields are the per-result fields the parity loop // asserts equal between the two surfaces, by their wire names. // -// preflightParityExcludedFields are the ONLY fields allowed to differ, each with +// The per-surface exclusion maps below name the ONLY fields allowed to differ, each with // the reason it does. FR-017 requires the exclusions to be named rather than // expressed as "whatever the loop happens not to touch": an omission is // invisible, a name is reviewable. TestPreflightParityExclusionsAreNamed turns @@ -322,13 +322,22 @@ func TestPreflightMatrixNeverIndexedWhileConnecting(t *testing.T) { var ( preflightParityComparedFields = []string{"id", "status", "reason", "retryable", "action", "detail", "remediation", "did_you_mean"} - preflightParityExcludedFields = map[string]string{ + // Exclusions are scoped PER SURFACE: a reason that justifies excluding a + // field from one payload says nothing about the other. A globally excluded + // "hash" would let a future hash field on the MCP payload silently escape + // comparison even though FR-004 forbids it there — the exact narrowing + // FR-017's "by name" rule exists to prevent. + preflightParityExcludedMCPFields = map[string]string{ "checked_at": "a timestamp, not a verdict: each surface stamps its own instant (FR-004)", - "hash": "REST may disclose a pin at the operator tier; in band, never, at any tier (FR-004)", "request_id": "in-band only: the correlation id an agent hands a human (FR-004)", - "waited_ms": "REST only: check mode takes no wait budget (non-goal)", "verdict": "compared once at the set level, not per result", "results": "the in-band container of the compared results", + } + preflightParityExcludedRESTFields = map[string]string{ + "checked_at": "a timestamp, not a verdict: each surface stamps its own instant (FR-004)", + "hash": "REST may disclose a pin at the operator tier; in band, never, at any tier (FR-004)", + "waited_ms": "REST only: check mode takes no wait budget (non-goal)", + "verdict": "compared once at the set level, not per result", "tools": "the REST container of the compared results", } ) @@ -341,32 +350,55 @@ func TestPreflightParityExclusionsAreNamed(t *testing.T) { compared := make(map[string]bool, len(preflightParityComparedFields)) for _, name := range preflightParityComparedFields { compared[name] = true - assert.NotContainsf(t, preflightParityExcludedFields, name, - "%q cannot be both compared and excluded", name) + assert.NotContainsf(t, preflightParityExcludedMCPFields, name, + "%q cannot be both compared and MCP-excluded", name) + assert.NotContainsf(t, preflightParityExcludedRESTFields, name, + "%q cannot be both compared and REST-excluded", name) } - for _, payload := range []any{ - describeCheckResult{}, describeCheckPayload{}, - contracts.PreflightToolResult{}, contracts.PreflightResponse{}, - } { - typ := reflect.TypeOf(payload) - for i := 0; i < typ.NumField(); i++ { - name, _, _ := strings.Cut(typ.Field(i).Tag.Get("json"), ",") - require.NotEmptyf(t, name, "%s.%s has no json tag", typ.Name(), typ.Field(i).Name) - if compared[name] { - continue + surfaces := []struct { + payloads []any + excluded map[string]string + surface string + }{ + {[]any{describeCheckResult{}, describeCheckPayload{}}, preflightParityExcludedMCPFields, "mcp-check"}, + {[]any{contracts.PreflightToolResult{}, contracts.PreflightResponse{}}, preflightParityExcludedRESTFields, "rest"}, + } + for _, sf := range surfaces { + for _, payload := range sf.payloads { + typ := reflect.TypeOf(payload) + for i := 0; i < typ.NumField(); i++ { + name, _, _ := strings.Cut(typ.Field(i).Tag.Get("json"), ",") + require.NotEmptyf(t, name, "%s.%s has no json tag", typ.Name(), typ.Field(i).Name) + if compared[name] { + continue + } + _, excluded := sf.excluded[name] + assert.Truef(t, excluded, + "%s.%s (%q) is neither compared by the parity test nor excluded from it by name on the %s surface", + typ.Name(), typ.Field(i).Name, name, sf.surface) + } + } + // The reverse direction: an exclusion that names no real field on its + // own surface is stale and must be pruned, not carried. + for name := range sf.excluded { + found := false + for _, payload := range sf.payloads { + typ := reflect.TypeOf(payload) + for i := 0; i < typ.NumField(); i++ { + if tag, _, _ := strings.Cut(typ.Field(i).Tag.Get("json"), ","); tag == name { + found = true + } + } } - _, excluded := preflightParityExcludedFields[name] - assert.Truef(t, excluded, - "%s.%s (%q) is neither compared by the parity test nor excluded from it by name", - typ.Name(), typ.Field(i).Name, name) + assert.Truef(t, found, "excluded field %q names no field on the %s surface", name, sf.surface) } } } // For identical ids and identical proxy state, the in-band surface and the REST // surface AT THE SAME TIER name the same thing. The two payloads deliberately -// differ only on the fields named in preflightParityExcludedFields — excluded by +// differ only on the fields named in the per-surface exclusion maps — excluded by // name, not by a loose matcher — and anything else that differs is a defect in // the glue. func TestPreflightInBandRESTParityAtAgentTokenTier(t *testing.T) {