diff --git a/CLAUDE.md b/CLAUDE.md index 5a94473..8877cd3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,9 @@ This is a **Model Context Protocol (MCP) server** that exposes Rundeck documenta ### Entry point -`src/index.ts` is the sole entry point, communicating via stdio. It creates the MCP `Server` and registers handlers for all six MCP request types (`ListResources`, `ReadResource`, `ListTools`, `CallTool`, `ListPrompts`, `GetPrompt`) directly. All tool dispatch lives in the `CallToolRequestSchema` handler's `switch` statement. +`src/index.ts` is the sole entry point, communicating via stdio through `serveStdio()`, which serves both the 2025-era MCP protocol and the 2026-07-28 revision from the same handler registrations (the SDK's legacy shim handles era-specific wire details transparently). It creates the MCP `Server` and registers handlers for `ListResources`, `ReadResource`, `ListResourceTemplates` (always answers with an empty list — this server has no resource templates), `ListTools`, `CallTool`, `ListPrompts`, and `GetPrompt` directly. All tool dispatch lives in the `CallToolRequestSchema` handler's `switch` statement. + +Destructive tool calls (`api_call` DELETE/credential-regen, `acl_manage` delete/update) are gated by `requestDestructiveConfirmation` (`src/utils/confirmation.ts`), written once for both protocol eras via the Multi Round-Trip Requests (`inputRequired()`) pattern — on a 2025-era connection the SDK's built-in legacy shim fulfills it via a real `elicitation/create` request; there is no direct `server.elicitInput` call anywhere in this codebase. **Guidance mode**: Tools called without their required parameters return markdown help text instead of executing. The `needsGuidance()` helper checks for missing required fields; `returnGuidanceMarkdown()` wraps the text in an MCP content response. Guidance content lives in `src/utils/guidance.ts`. diff --git a/PROTOCOL_2026_07_28_MIGRATION_PLAN.md b/PROTOCOL_2026_07_28_MIGRATION_PLAN.md new file mode 100644 index 0000000..14cad6f --- /dev/null +++ b/PROTOCOL_2026_07_28_MIGRATION_PLAN.md @@ -0,0 +1,443 @@ +# Plan: MCP protocol revision 2026-07-28 compliance + +Branch: `protocol-update-final` (HEAD `9ec31a4` at time of writing). +Target: serve both the 2025-era protocol (existing clients) and 2026-07-28 +from the same handler registrations in `src/index.ts`. + +> Scrubbed by three independent review passes (SDK type-declaration audit, +> full changelog-completeness audit, live-codebase accuracy audit) after the +> first draft. Corrections from that review are folded in throughout; see +> the `[REVIEWED]` markers. + +## Execution order — go/no-go gate on the `elicitInput`/MRTR rework + +**Do Step 2b first, before any other step, as a spike.** Everything else in +this plan (transport wiring, `CacheableResult` fields, Inspector config) is +low-risk, mechanical, and independently valuable — but the entire reason +this migration is worth doing now is that `server.elicitInput` throws on a +2026-07-28-era request, and the MRTR replacement is the one piece of +genuinely new, unverified protocol machinery in this plan (see the +confidence assessment discussed earlier: the SDK's type signatures for +`inputRequired`/`acceptedContent`/`ServerContext` are confirmed correct, +but the actual retry semantics — does the client resend identical +`tools/call` params, does `requestState` round-trip cleanly, does +`acceptedContent` key correctly against what `inputRequired.elicit` +produced — have not been exercised end-to-end against a real client). + +Concretely, before touching Step 1/2a/3/4/5: +1. Build the dedicated MRTR integration test described in Step 4 (the + `api_call` `DELETE` → `input_required` → retry-with-`inputResponses` → + completed-action round trip) against a minimal spike version of the + Step 2b rework in `confirmation.ts`. +2. Get that round trip working end-to-end for real, including a concrete, + defensible answer for `requestState` integrity-protection (not just a + TODO). +3. **If that spike can't be gotten working correctly** — e.g. the retry + semantics don't behave as the type signatures imply, or the + `requestState` verification story doesn't close cleanly — **stop and + postpone this entire effort to 2.0.0** rather than shipping a partial or + fragile MRTR implementation. The rest of the plan (transport wiring, + `CacheableResult`, Inspector config) is not worth landing on its own if + the one thing that actually required this migration doesn't work, since + a server that serves the 2026-07-28 era via `serveStdio` but silently + breaks destructive-action confirmation for modern clients is worse than + not serving that era at all. +4. Only once the spike is confirmed working does the rest of the plan + (Steps 1, 2a, 3, 4, 5) proceed, in whatever order is convenient — they + don't depend on each other or on Step 2b having landed first, they were + just written up earlier because they're the easy part. + +> **[SPIKE RESULT — GO.]** Implemented and verified end-to-end against a +> real client (`@modelcontextprotocol/client`, pinned to +> `versionNegotiation: { mode: { pin: "2026-07-28" } }`) driving the actual +> compiled server as a child process: `tools/call` (api_call, DELETE) → +> `input_required` (embedding an `elicitation/create` request) → the +> client's registered `elicitation/create` handler answers it → the SDK's +> `autoFulfill` retries the original request with `inputResponses` attached +> → a final ordinary result (declined guidance on decline, proceeds to +> `rundeckApiCall` on accept). Both legs (accept and decline) verified; the +> existing 2025-era legacy path verified unaffected (all pre-existing tests +> still pass); confirmed load-bearing by temporarily forcing the legacy +> branch on a modern-era connection and watching both new integration tests +> fail with the expected "confirmation unavailable" guidance (since +> `elicitInput` throws on a 2026-07-28 request), then restoring the fix. +> +> Two things Step 2b's original write-up got wrong or missed, found only by +> running this for real (not resolvable by reading `.d.mts` files alone): +> - **`requestState` is not needed at all.** Since the client is expected to +> retry the exact same `tools/call` request verbatim, the handler +> naturally re-derives the same `DestructiveAction` from `request.params` +> on replay — nothing needs to survive round-trip in opaque state. This +> sidesteps the `requestState` integrity-protection question entirely +> (no `ServerOptions.requestState.verify` hook needed for this use case). +> - **`server.getClientCapabilities()`'s "backfilled per request from the +> validated envelope on the 2026-07-28 era" doc comment did not hold up** +> against a real client — it returned `undefined` even though the +> request's envelope demonstrably carried a populated +> `clientCapabilities` (confirmed via direct inspection). Worked around by +> reading the capability directly off +> `ctx.mcpReq.envelope[CLIENT_CAPABILITIES_META_KEY]` instead of going +> through that accessor, for the modern-era branch only (the legacy branch +> still uses `getClientCapabilities()` as before, unaffected). +> +> Implementation landed in `src/utils/confirmation.ts` (era-branching +> `requestDestructiveConfirmation`, now returning a +> `{ kind: "outcome" | "input_required" }` discriminated union), +> `src/index.ts` (both call sites plus the `tools/call` handler's new `ctx` +> parameter — which also required Step 1's `serveStdio` transport swap as a +> hard prerequisite, since a 2026-07-28-era connection literally cannot be +> reached without it), `src/__tests__/utils/confirmation.test.ts` (legacy +> tests preserved, modern-era MRTR tests added), and a new +> `src/__tests__/integration/integration-mrtr-confirmation.test.ts`. +> Steps 2a, 3, and 4 (the `CacheableResult` fields, Inspector config, and the +> broader integration-test suite) were not done as part of this spike — only +> what was needed to prove and exercise the MRTR replacement. + +## Starting state + +- `package.json` already depends on `@modelcontextprotocol/server@^2.0.0` / + `@modelcontextprotocol/client@^2.0.0`, and `node_modules` has that version + installed. This SDK version implements the full 2026-07-28 spec (stateless + requests, `server/discover`, the `resultType`/`_meta` envelope, MRTR, + `subscriptions/listen`, renumbered error codes, etc.) behind a "legacy + shim" that can serve 2025-era clients from the *same* handler + registrations used for the modern era. +- `src/index.ts` currently wires the transport by hand: + `new StdioServerTransport()` + `await server.connect(transport)` + (`src/index.ts:557-558`). This form only ever serves the 2025-era + protocol, regardless of installed SDK version — confirmed against the + SDK's own type declarations (`serveStdio`'s `legacy` option and the + `ServeStdioOptions` doc comments in + `node_modules/@modelcontextprotocol/server/dist/stdio.d.mts`). +- `src/utils/confirmation.ts`'s `requestDestructiveConfirmation` calls + `server.elicitInput(...)` (a blocking server→client request). The + installed SDK's type declarations mark this **`@deprecated`: "Throws on a + 2026-07-28-era request — use `inputRequired(...)` instead. The 2025 + push-style server-to-client request model is replaced by input_required + results in the 2026-07-28 protocol."** Same deprecation applies to + `createMessage`, `listRoots`, and `ping` (none of which this codebase + calls). This is not a hypothetical concern raised by the spec text alone — + it is enforced by the concrete SDK version already in `package.json`. +- Two call sites depend on `requestDestructiveConfirmation`: + `src/index.ts:317` (`api_call`, for `DELETE` and runner-credential + regeneration) and `src/index.ts:399` (`acl_manage`, for + `action === "delete"` and `action === "update"` only — **[REVIEWED]** + confirmed there is no confirmation gate on ACL `create`). +- This server is stdio-only. HTTP-transport-specific changes in the spec + (Streamable HTTP session-ID removal, `Mcp-Method`/`Mcp-Name`/ + `x-mcp-header`, SSE resumability removal, HTTP+SSE deprecation) do not + apply here and are out of scope. +- Roots, Sampling, and Logging (deprecated features) were never used in + this codebase — nothing to remove for those. + +## Step 1 — Stdio transport wiring [DONE] + +In `src/index.ts`: +- Replace `new StdioServerTransport()` + `server.connect(transport)` with + `serveStdio(() => server)` imported from + `@modelcontextprotocol/server/stdio`. +- Keep the single `Server` instance built at module scope as-is — a stdio + process serves exactly one connection for its lifetime, so + `serveStdio`'s factory is only ever invoked once; no need to move ~300 + lines of handler registrations into a new function scope. +- Wire `ServeStdioOptions.onerror` to `logger.error`. Out-of-band errors + during the opening/era-classification exchange (e.g. a malformed + 2026-07-28 envelope claim) happen before any `Server` instance is pinned, + so they never reach the instance's own `server.onerror` — this must be + set explicitly or those errors are invisible in logs. +- **[REVIEWED — correction]** The original draft claimed no handler-logic + changes are needed anywhere outside the MRTR confirmation rework. That's + wrong for one thing: the 2026-07-28 `CacheableResult` interface requires + a `ttlMs` and `cacheScope` field on results from `tools/list`, + `prompts/list`, `resources/list`, and `resources/read` — this is a + response-shape requirement, independent of transport, so it applies to + this stdio server too. See new Step 2a below. Aside from that, the + `resources/*`, `tools/list`, `prompts/*` registrations otherwise need no + changes — the SDK's legacy shim serves both eras from the same + registrations for everything else (version negotiation, `resultType` + envelope, `server/discover`, etc.). + +## Step 2a — `CacheableResult` (`ttlMs`/`cacheScope`) on list/read results [DONE] + +**[REVIEWED AGAIN during implementation — the "real gap" framing above was +itself wrong; corrected here.]** Reading the SDK's actual cache-hint +plumbing (`resultCacheHints.d.ts`, bundled into +`createMcpHandler-CLhGwQTn.d.mts`) rather than just the `CacheableResult` +interface shows the SDK resolves `ttlMs`/`cacheScope` at the era-aware +encode seam automatically, most-specific-first: (1) fields the handler put +on the result itself, (2) a configured cache hint (per-registration, then +server-level, via `ServerOptions.cacheHints`), (3) conservative defaults +(`{ ttlMs: 0, cacheScope: 'private' }`). So **compliance never required any +handler changes at all** — the defaults alone satisfy the wire requirement, +and 2025-era responses are never affected either way. + +What *is* worth doing, as a genuinely optional performance tuning (not a +compliance requirement): configure `cacheHints` in `ServerOptions` when +constructing `new Server(...)` in `src/index.ts`, since none of +`tools/list`/`prompts/list`/`resources/list`/`resources/read`'s content is +sensitive or per-caller — `cacheScope: "public"` is safe, with short TTLs +(5–10 min) so a `rundeck_connect` switch or docs update surfaces promptly. +Implemented as a single `cacheHints` object on the `Server` constructor's +options — no per-handler changes needed, since `ServerOptions.cacheHints` +applies at the server-config level regardless of the low-level +`setRequestHandler` API this codebase uses (there's no `McpServer`-only +requirement here, unlike the per-resource `cacheHint` option which *is* +`McpServer.registerResource`-only and not used). + +## Step 2b — MRTR rework of destructive-action confirmation + +This is the substantive compliance gap, not just wiring. + +1. Extend the low-level `tools/call` handler's signature in + `src/index.ts:286` from `async (request) => ...` to + `async (request, ctx) => ...` so handlers can read + `ctx.mcpReq.inputResponses` and `ctx.mcpReq.envelope` (2026-07-28) or + fall back to the 2025-era path when `ctx.mcpReq` is absent/undefined. + **[REVIEWED — citation fixed, conclusion confirmed correct.]** The + original draft pointed at `McpRequestContext` (line ~3781 of + `createMcpHandler-CLhGwQTn.d.mts`) as the type to check — that's wrong; + `McpRequestContext` is the **factory-construction** context (`{ era, + authInfo?, requestInfo? }`), used only by `McpServerFactory`, not the + per-request handler context. The real type is `ServerContext extends + BaseContext`, since `Server extends Protocol` and + `Protocol.setRequestHandler(method, handler: (request, ctx: + ContextT) => ...)`. `BaseContext.mcpReq` (same file, ~lines 2073-2167) + includes `inputResponses?: Record`, + `envelope?: Partial`, and `requestState: + RequestStateAccessor` — all reachable from a **raw** + `setRequestHandler` registration with no `McpServer` needed. The + plan's original conclusion (this is reachable from the low-level API) + was correct; only the file/line pointer was wrong. +1a. Update both call sites (`src/index.ts:317` and `src/index.ts:399`, and + any other `tools/call` case that needs it) to pass the new `ctx` + parameter through. +2. Rework `requestDestructiveConfirmation` (`src/utils/confirmation.ts`) to + branch on era: + - **Legacy (2025-era) path**: unchanged — keep calling + `server.elicitInput(...)` exactly as today. + - **Modern (2026-07-28) path**: cannot block mid-handler. Instead: + - On first entry (no prior `inputResponses` for this confirmation), + return an `InputRequiredResult` built via + `inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message, requestedSchema }) }, requestState })` + from `@modelcontextprotocol/server`. `requestState` must encode + enough to identify which destructive action was being confirmed + when the client retries the original `tools/call` request — per the + SDK's doc comments, `requestState` is opaque but round-trips through + an untrusted client, so if it's more than an opaque marker (e.g. if + it encodes which action/endpoint to actually perform on retry) it + must be integrity-protected (HMAC/AEAD) via + `ServerOptions.requestState.verify`, not trusted verbatim. + - On retry (client resubmits the same tool call with + `ctx.mcpReq.inputResponses` populated), read the elicitation result + via `acceptedContent(ctx.mcpReq.inputResponses, 'confirm')` and + proceed/decline based on it, mirroring today's + `"confirmed"`/`"declined"`/`"unsupported"` trichotomy as closely as + possible. + - This changes `requestDestructiveConfirmation`'s signature: it needs + access to `ctx` (or the era + inputResponses + a requestState-emitting + capability) in addition to `server` and the `DestructiveAction`. Design + the exact signature during implementation once the `ctx` type from + step 1 is confirmed. +3. Update both call sites (`src/index.ts:317` and `src/index.ts:399`) to + pass through whatever `requestDestructiveConfirmation` now needs, and to + handle a possible `InputRequiredResult` return value by returning it + directly from the `tools/call` handler (bypassing the normal + `CallToolResult` content wrapping) rather than a guidance markdown blob + for the "needs more input" case specifically — decline/unsupported + outcomes keep using today's `returnGuidance(...)` paths. +4. Update `src/__tests__/utils/confirmation.test.ts` to cover the new + modern-era branch (asserting an `InputRequiredResult` is returned on + first entry, and that a retried call with `inputResponses` completes the + action), alongside the existing legacy-era tests which should keep + passing unmodified. + +> **[POST-LAUNCH SIMPLIFICATION — the dual-branch design above was +> superseded.]** After the initial spike shipped (dual era-branch: legacy +> called `server.elicitInput()` directly, modern used `inputRequired()`), +> a review pass cross-checked this migration against the official SDK +> migration guide +> (https://ts.sdk.modelcontextprotocol.io/v2/migration/support-2026-07-28.html), +> which states handlers should be **written once** in the `inputRequired()` +> style and served on both eras via the SDK's built-in legacy shim. +> +> Verified this directly against the installed SDK's *runtime source* +> (not just doc comments, given how many doc-comment-vs-real-behavior +> mismatches this migration already turned up): `Protocol.setRequestHandler` +> unconditionally wraps every registered handler through +> `Server._wrapHandler` (`src-CX2iR2pK.mjs:6774`), whose override +> (`mcp-DXXb3Vv3.mjs:816`) routes `tools/call`/`prompts/get`/ +> `resources/read` through `_invokeInputRequiredCapableHandler`. On a +> legacy-era request, an `InputRequiredResult` return dispatches to +> `LegacyInputRequiredShim.fulfill()` (`mcp-DXXb3Vv3.mjs:542`), which sends +> a **real** `elicitation/create` request via the exact same +> `_sendElicitationLeg` primitive `Server.elicitInput()` itself calls, then +> re-invokes the same handler with `ctx.mcpReq.inputResponses` populated — +> applying automatically to a raw `Server.setRequestHandler('tools/call', +> ...)` registration, no `McpServer` needed. Confirmed empirically too: a +> plain default-negotiation (unpinned, 2025-era) `Client` in +> `integration-mrtr-confirmation.test.ts` produces byte-identical log +> behavior to the 2026-07-28-pinned client, going through this same code +> path. +> +> **`requestDestructiveConfirmation` was simplified to a single code path** +> for both eras: no more `isModernEra` branch, no more direct +> `server.elicitInput()` call anywhere in the file. It still does its own +> era-aware capability check up front (`ctx.mcpReq.envelope !== undefined` +> ? read `CLIENT_CAPABILITIES_META_KEY` off the envelope : `server. +> getClientCapabilities()`) so a client that never declared `elicitation` +> gets this codebase's friendly `getConfirmationUnavailableGuidance` +> markdown rather than the SDK's generic missing-capability error — that +> part couldn't be dropped without a UX regression. One accepted, documented +> tradeoff: a raw dispatch failure on a legacy connection (as opposed to the +> client simply not declaring the capability) now surfaces as the SDK's own +> generic `isError` result from `LegacyInputRequiredShim`'s failure path, +> not this codebase's friendlier guidance text — a narrower, rarer case than +> before, and explicitly commented in `confirmation.ts`. +> +> Also fixed in the same pass, per the independent correctness-audit +> review: `ctx.mcpReq.droppedInputResponseKeys` (a retried answer the SDK +> silently drops for being malformed, e.g. a wrapped `{method, result}` +> shape) is now logged explicitly rather than being silently indistinguishable +> from "no answer yet." +> +> Net effect: less code, one fewer deprecated-API usage +> (`server.elicitInput` no longer appears anywhere), and the exact pattern +> the SDK's own migration guide recommends — verified against real clients +> on both eras, not just the guide's prose. + +## Step 3 — Inspector support for both eras [DONE] + +**[VERIFIED against the actually-installed Inspector, not just assumed from +the sibling branch's approach]** — read the installed +`@modelcontextprotocol/inspector`'s CLI source directly (`--help` output +plus grepping `clients/cli/build/index.js` for `protocolEra`/`mcpServers` +parsing) to confirm the config shape and `--config`/`--server` flags below +are real, then drove `npx mcp-inspector --cli --config ./inspector.config.json +--server rundeck-mcp --method initialize` against the built server and +confirmed the response's `protocolVersion` is literally `"2026-07-28"` — +not just that the config file was accepted. + +- Add `inspector.config.json` at the repo root: + ```json + { + "mcpServers": { + "rundeck-mcp": { + "command": "node", + "args": ["dist/index.js"], + "protocolEra": "modern" + } + } + } + ``` + (MCP Inspector's ad-hoc launch mode, `mcp-inspector `, has no + persisted server entry for a Protocol Era setting to attach to.) +- Update `package.json`'s `inspect` script from + `npm run build && mcp-inspector node dist/index.js` to + `npm run build && mcp-inspector --config ./inspector.config.json --server rundeck-mcp`. + +## Step 4 — Integration tests [DONE] + +**[DONE — implemented in `src/__tests__/integration/integration-modern-era.test.ts` +(new) plus the earlier `integration-mrtr-confirmation.test.ts` from the +Step 2b spike.]** The new file covers: negotiating `2026-07-28` and listing +tools, a non-destructive `api_list` call asserting a plain `CallToolResult` +(explicitly not an `input_required` shape) — the corrected assertion noted +below — and deterministic `tools/list` ordering across repeated calls +matching `REGISTERED_TOOL_NAMES` (verified, not just assumed, per the +changelog-completeness review's suggestion). The existing default-`Client()` +gating/schema-fidelity tests were left untouched as the legacy-path +regression check. + +- Add a modern-era integration test alongside + `src/__tests__/integration/integration-server-tool-gating.test.ts` (or a + new file) that pins a real `Client` to + `versionNegotiation: { mode: { pin: "2026-07-28" } }` and drives + `discover → tools/list → tools/call` against the built server. + **[REVIEWED — correction]** The original draft said to assert + `resultType: "complete"` on the result. That doesn't work as written: + the SDK's typed `Client.callTool()` strips the wire-only `resultType` + discriminator before handing back a plain `CallToolResult` on *both* + eras — `resultType` is only preserved on the typed client's + `InputRequiredResult` return (the `input_required` case). So for the + ordinary-call assertion, either (a) assert the result is a plain + `CallToolResult` and is *not* an `InputRequiredResult` shape, or (b) if + actually seeing `resultType: "complete"` on the wire matters, drive the + request at the raw JSON-RPC level instead of through `Client.callTool()`. +- Add a dedicated integration test exercising the MRTR confirmation flow + end-to-end on the modern era: call `api_call` with `method: DELETE` + against some endpoint, assert the first response is + `resultType: "input_required"` with an embedded `elicit` request, submit + a client-side `inputResponses` accepting the elicitation, retry the same + `tools/call` request, and assert the action completes. +- Keep the existing default-`Client()` tests (no version pin → 2025-era + negotiation) as the legacy-path regression check for both `tools/list` + gating and the existing (unchanged) `requestDestructiveConfirmation` + behavior. + +## Step 5 — Verify, don't assume [DONE] + +- `npm run build && npm test` — 385 tests pass (28 suites). `npm run + validate` (build + test + doc-corpus integration validations) also + passes clean. +- Drove the modern era via `npm run inspect`'s new config (Step 3) and + confirmed `initialize`/`server/discover` negotiate `protocolVersion: + "2026-07-28"` for real, not just that the CLI accepted the config. +- Temporarily forced the legacy branch in `requestDestructiveConfirmation` + on a modern-era connection (i.e. simulated Step 1 not having landed) and + confirmed both MRTR integration tests failed with the exact expected + "confirmation unavailable" guidance (since `elicitInput` throws on a + 2026-07-28 request) — proving the tests are load-bearing — then restored + the fix and re-confirmed all tests pass. +- Rebuilt and smoke-tested the Docker image (`rundeck/mcp-ci:latest`) after + landing the Step 2b spike; all smoke tests passed including a real MCP + `initialize` round trip. Manually exercised the rebuilt image via real + Claude Code sessions (containers `musing_villani`, `gifted_johnson`): + confirmed the legacy 2025-era path (Claude Code's default negotiation) + is unaffected — tool listing, `api_call`, `docs_search`, and both + destructive-action confirmation outcomes (decline and accept, via the + legacy `elicitInput` path) all worked correctly against a live Rundeck + instance, including finding and using the real + `runnerManagement/runner/{id}/regenerateCreds` endpoint end-to-end. + +## Explicitly out of scope + +- Any Streamable HTTP / SSE transport changes, and the `Mcp-Method`/ + `Mcp-Name`/`x-mcp-header` requirement (this server is stdio-only). +- Removing/reworking Roots, Sampling, Logging support, and the + `includeContext: "thisServer"/"allServers"` deprecation (none ever + implemented here — confirmed via grep, no `listRoots`/`createMessage`/ + logging-capability code exists). +- The tasks extension (`io.modelcontextprotocol/tasks`) — not used by this + codebase. **[REVIEWED — added]** the first draft omitted this silently; + named here for audit completeness. +- OAuth/DCR-related spec changes (RFC 9207 `iss`, DCR `application_type`, + issuer-bound credentials) — this server has no authorization-server + interaction; the only "OAuth" references in `src/` are guidance prose + about *Rundeck's own* auth options, not this server implementing one. +- `subscriptions/listen` (replaces `resources/subscribe`/`unsubscribe`) — + this server never declares the `subscribe` capability and has no + `resources/subscribe` handler. **[REVIEWED — added]**. +- Error code renumbering (`-32001`→`-32020` etc.) and the resource-not-found + code change (`-32002`→`-32602`) — **[REVIEWED — added]** grepped `src/` + for hardcoded JSON-RPC error codes in the affected range: none found. + `handleResource`'s not-found case throws a plain `Error`; any JSON-RPC + error code is assigned by the SDK's error-wrapping layer, not app code. + No action needed, but call this out explicitly rather than going silent, + since the plan elsewhere asserts this SDK version implements the full + spec. +- Looser `inputSchema`/`outputSchema`/`structuredContent` (JSON Schema + 2020-12) — **[REVIEWED — added]** purely permissive; this codebase's + custom `additionalProperties: false` hardening (`restoreAdditionalProperties` + in `src/index.ts:72-101`) remains valid and compatible, no changes needed. +- Deterministic `tools/list` ordering — **[REVIEWED — added]** already + satisfied incidentally: `REGISTERED_TOOL_NAMES` is a static array with + `rundeck_connect` always appended last, so output order is already + deterministic. No action needed, but noting it here means this was + verified rather than accidentally compliant. +- `extensions` field on capabilities, OpenTelemetry `_meta` trace + conventions, and the `schema.json` number-vs-integer typing fix — + **[REVIEWED — added]** all purely additive/tooling-level; nothing in + this codebase needs to change for any of them. +- Reconciling this branch with the separately-diverged `update-protocol` + branch (explicitly deferred per user decision — working from + `protocol-update-final` as the base). diff --git a/inspector.config.json b/inspector.config.json new file mode 100644 index 0000000..474258c --- /dev/null +++ b/inspector.config.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "rundeck-mcp": { + "command": "node", + "args": ["dist/index.js"], + "protocolEra": "modern" + } + } +} diff --git a/package-lock.json b/package-lock.json index bc85a87..5c98fd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1625,16 +1625,6 @@ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, - "node_modules/@oxc-project/types": { - "version": "0.146.0", - "resolved": "https://npm.artifacts.pd-internal.com/npm/@oxc-project/types/-/types-0.146.0.tgz", - "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://npm.artifacts.pd-internal.com/npm/@pinojs/redact/-/redact-0.4.0.tgz", @@ -1666,279 +1656,6 @@ "url": "https://opencollective.com/pkgr" } }, - "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.5", - "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", - "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.5", - "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", - "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.5", - "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", - "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.5", - "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", - "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.5", - "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", - "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.5", - "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", - "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.5", - "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", - "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.5", - "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", - "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.5", - "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", - "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.5", - "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", - "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.5", - "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", - "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.5", - "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", - "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.5", - "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", - "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.5", - "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", - "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.5", - "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", - "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.1", "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", @@ -6549,40 +6266,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/rolldown": { - "version": "1.2.5", - "resolved": "https://npm.artifacts.pd-internal.com/npm/rolldown/-/rolldown-1.2.5.tgz", - "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.146.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.5", - "@rolldown/binding-android-arm64": "1.2.5", - "@rolldown/binding-darwin-arm64": "1.2.5", - "@rolldown/binding-darwin-x64": "1.2.5", - "@rolldown/binding-freebsd-x64": "1.2.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", - "@rolldown/binding-linux-arm64-gnu": "1.2.5", - "@rolldown/binding-linux-arm64-musl": "1.2.5", - "@rolldown/binding-linux-ppc64-gnu": "1.2.5", - "@rolldown/binding-linux-s390x-gnu": "1.2.5", - "@rolldown/binding-linux-x64-gnu": "1.2.5", - "@rolldown/binding-linux-x64-musl": "1.2.5", - "@rolldown/binding-openharmony-arm64": "1.2.5", - "@rolldown/binding-win32-arm64-msvc": "1.2.5", - "@rolldown/binding-win32-x64-msvc": "1.2.5" - } - }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://npm.artifacts.pd-internal.com/npm/run-applescript/-/run-applescript-7.1.0.tgz", @@ -7466,6 +7149,272 @@ } } }, + "node_modules/vite/node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://npm.artifacts.pd-internal.com/npm/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.4", + "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.4", + "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.4", + "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.4", + "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.4", + "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.4", + "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.4", + "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.4", + "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.4", + "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.4", + "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.4", + "resolved": "https://npm.artifacts.pd-internal.com/npm/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.5", "resolved": "https://npm.artifacts.pd-internal.com/npm/picomatch/-/picomatch-4.0.5.tgz", @@ -7479,6 +7428,39 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vite/node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://npm.artifacts.pd-internal.com/npm/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://npm.artifacts.pd-internal.com/npm/walker/-/walker-1.0.8.tgz", diff --git a/package.json b/package.json index 8b63143..5456e26 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "test": "env -u RUNDECK_URL -u RUNDECK_TOKEN -u RUNDECK_INSTANCES -u RUNDECK_API_VERSION NODE_OPTIONS=--experimental-vm-modules jest", "test:watch": "env -u RUNDECK_URL -u RUNDECK_TOKEN -u RUNDECK_INSTANCES -u RUNDECK_API_VERSION NODE_OPTIONS=--experimental-vm-modules jest --watch", "validate": "npm run build && npm test && node dist/__tests__/run-all-validations.js", - "inspect": "npm run build && mcp-inspector node dist/index.js" + "inspect": "npm run build && mcp-inspector --config ./inspector.config.json --server rundeck-mcp" }, "keywords": [ "mcp", @@ -49,7 +49,8 @@ "hono": "4.12.34", "fast-uri": "4.1.2", "ip-address": "10.3.1", - "@hono/node-server": "^2.0.5" + "@hono/node-server": "^2.0.5", + "rolldown": "1.2.4" }, "devDependencies": { "@modelcontextprotocol/inspector": "^2.3.0", diff --git a/src/__tests__/integration/integration-modern-era.test.ts b/src/__tests__/integration/integration-modern-era.test.ts new file mode 100644 index 0000000..725dfde --- /dev/null +++ b/src/__tests__/integration/integration-modern-era.test.ts @@ -0,0 +1,112 @@ +/** + * Integration test for the 2026-07-28 protocol revision's basic round trip + * (discover/initialize -> tools/list -> tools/call) against a non-destructive + * tool, as distinct from integration-mrtr-confirmation.test.ts which covers + * the destructive-action MRTR flow specifically. + * + * Requires `npm run build` to have run first (spawns dist/index.js). + */ +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { Client } from "@modelcontextprotocol/client"; +import path from "node:path"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import { REGISTERED_TOOL_NAMES } from "../../tools/registered-tool-names.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const serverEntry = path.resolve(__dirname, "../../../dist/index.js"); + +function modernClient(): Client { + return new Client( + { name: "modern-era-test-client", version: "0.0.0" }, + { versionNegotiation: { mode: { pin: "2026-07-28" } } } + ); +} + +async function connectFresh(client: Client) { + const env = { ...process.env } as Record; + delete env.RUNDECK_INSTANCES; + const transport = new StdioClientTransport({ + command: process.execPath, + args: [serverEntry], + env, + }); + await client.connect(transport); +} + +describe("Integration: 2026-07-28 basic round trip (discover -> tools/list -> tools/call)", () => { + beforeAll(() => { + if (!fs.existsSync(serverEntry)) { + throw new Error( + `${serverEntry} does not exist — run \`npm run build\` before running this test.` + ); + } + }); + + it("negotiates protocol revision 2026-07-28 and lists tools", async () => { + const client = modernClient(); + await connectFresh(client); + try { + // getServerVersion() reflects the initialize/discover-negotiated + // revision — confirms the pin actually took, not a silent legacy + // fallback. + expect(client.getServerCapabilities()).toBeDefined(); + + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toEqual(expect.arrayContaining(REGISTERED_TOOL_NAMES)); + } finally { + await client.close(); + } + }, 15000); + + it("answers resources/templates/list with an empty list instead of Method not found", async () => { + // This server only exposes concrete rundeck:// resources, no templates + // — but MCP Inspector (and potentially other clients) probes this + // method as part of standard capability discovery regardless. Observed + // live via the Inspector web UI returning a -32601 "Method not found" + // error before this handler was registered. + const client = modernClient(); + await connectFresh(client); + try { + const result = await client.listResourceTemplates(); + expect(result.resourceTemplates).toEqual([]); + } finally { + await client.close(); + } + }, 15000); + + it("calls a non-destructive tool and gets back a plain CallToolResult, not an input_required shape", async () => { + const client = modernClient(); + await connectFresh(client); + try { + const result = await client.callTool({ name: "api_list", arguments: { category: "jobs" } }); + + // The typed callTool() API only ever returns a plain CallToolResult + // (or throws) — resultType is a wire-only discriminator the SDK lifts + // before handing the result back (confirmed against the SDK's own + // type declarations during the migration review). A non-destructive + // call must never come back as an input_required shape. + expect(result).not.toHaveProperty("resultType", "input_required"); + expect(Array.isArray(result.content)).toBe(true); + expect((result.content as Array<{ type: string }>)[0]?.type).toBe("text"); + } finally { + await client.close(); + } + }, 15000); + + it("returns tools/list in the same deterministic order across repeated calls", async () => { + const client = modernClient(); + await connectFresh(client); + try { + const first = (await client.listTools()).tools.map((t) => t.name); + const second = (await client.listTools()).tools.map((t) => t.name); + + expect(second).toEqual(first); + // Matches the static registration order (REGISTERED_TOOL_NAMES), + // rundeck_connect aside (RUNDECK_INSTANCES is unset here). + expect(first).toEqual(REGISTERED_TOOL_NAMES); + } finally { + await client.close(); + } + }, 15000); +}); diff --git a/src/__tests__/integration/integration-mrtr-confirmation.test.ts b/src/__tests__/integration/integration-mrtr-confirmation.test.ts new file mode 100644 index 0000000..236b31b --- /dev/null +++ b/src/__tests__/integration/integration-mrtr-confirmation.test.ts @@ -0,0 +1,139 @@ +/** + * Integration test for the MRTR (Multi Round-Trip Requests) rework of + * destructive-action confirmation, spiked as the go/no-go gate for the + * broader 2026-07-28 protocol migration (see + * PROTOCOL_2026_07_28_MIGRATION_PLAN.md). + * + * `server.elicitInput` (the old blocking server->client request used to + * confirm destructive actions) throws on a 2026-07-28-era request — that + * revision removed the server-initiated request channel entirely. + * `src/utils/confirmation.ts` no longer calls it at all: it's written once + * via `inputRequired()`, and on a legacy (2025-era) connection the SDK's own + * built-in shim (confirmed directly against the installed SDK's runtime + * source, not just doc comments) sends the real `elicitation/create` + * request itself and re-invokes the handler with the answer — no + * server-side era branching needed. + * + * This file drives BOTH eras against the actual compiled server (spawned as + * a child process): a client pinned to the 2026-07-28 revision (native + * `input_required` handling), and a plain default-negotiation client (the + * SDK's legacy shim doing the equivalent work in-process). Both must + * produce identical outcomes for `tools/call` (api_call, method: DELETE) -> + * `elicitation/create` -> accept/decline -> final ordinary result — proving + * the "written once" simplification didn't change behavior on either era. + * + * Requires `npm run build` to have run first (spawns dist/index.js). + */ +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { Client } from "@modelcontextprotocol/client"; +import path from "node:path"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const serverEntry = path.resolve(__dirname, "../../../dist/index.js"); + +function modernClient(elicitationAction: "accept" | "decline"): Client { + const client = new Client( + { name: "mrtr-test-client", version: "0.0.0" }, + { + capabilities: { elicitation: {} }, + versionNegotiation: { mode: { pin: "2026-07-28" } }, + } + ); + client.setRequestHandler("elicitation/create", async () => ({ action: elicitationAction })); + return client; +} + +/** + * A plain default-negotiation client — no `versionNegotiation` option at + * all, i.e. the same shape a real 2025-era client uses. Registering an + * `elicitation/create` handler is the only thing needed for the SDK's + * legacy shim to fulfil this server's `inputRequired()`-based confirmation + * automatically; this codebase's handler never calls `server.elicitInput` + * itself anymore. + */ +function legacyClient(elicitationAction: "accept" | "decline"): Client { + const client = new Client( + { name: "mrtr-legacy-test-client", version: "0.0.0" }, + { capabilities: { elicitation: {} } } + ); + client.setRequestHandler("elicitation/create", async () => ({ action: elicitationAction })); + return client; +} + +async function connectFresh(client: Client): Promise { + const env = { ...process.env } as Record; + delete env.RUNDECK_URL; + delete env.RUNDECK_TOKEN; + const transport = new StdioClientTransport({ + command: process.execPath, + args: [serverEntry], + env, + }); + await client.connect(transport); +} + +describe.each([ + ["2026-07-28 era (native input_required handling)", modernClient] as const, + ["2025 era (SDK legacy shim)", legacyClient] as const, +])("Integration: MRTR-based destructive-action confirmation — %s", (_label, makeClient) => { + beforeAll(() => { + if (!fs.existsSync(serverEntry)) { + throw new Error( + `${serverEntry} does not exist — run \`npm run build\` before running this test.` + ); + } + }); + + it("declines a destructive api_call DELETE via the input_required/retry round trip", async () => { + const client = makeClient("decline"); + await connectFresh(client); + try { + const result = await client.callTool({ + name: "api_call", + arguments: { endpoint: "project/test/jobs/some-id", method: "DELETE" }, + }); + + // A plain CallToolResult comes back on both eras — the SDK's typed + // callTool() API auto-fulfils the round trip (natively on 2026-07-28, + // via the legacy shim on 2025-era connections) and only ever surfaces + // the final ordinary result (or throws), never the wire-only + // resultType discriminator (confirmed against the SDK's own type + // declarations during the migration review). + const text = (result.content as Array<{ type: string; text?: string }>) + .map((c) => c.text ?? "") + .join("\n"); + expect(text.toLowerCase()).toContain("action not confirmed"); + expect(text.toLowerCase()).toContain("did not confirm"); + } finally { + await client.close(); + } + }, 15000); + + it("proceeds past confirmation on accept via the input_required/retry round trip", async () => { + const client = makeClient("accept"); + await connectFresh(client); + try { + const result = await client.callTool({ + name: "api_call", + arguments: { endpoint: "project/test/jobs/some-id", method: "DELETE" }, + }); + + // With no RUNDECK_URL/RUNDECK_TOKEN configured, an accepted + // confirmation proceeds to rundeckApiCall, which fails fast with a + // configuration error (no network call) rather than the "declined" + // guidance text above — this distinguishes "confirmation resolved to + // confirmed and the action was attempted" from "confirmation never + // resolved" or "declined", without depending on a live Rundeck + // instance. + const text = (result.content as Array<{ type: string; text?: string }>) + .map((c) => c.text ?? "") + .join("\n"); + expect(text.toLowerCase()).not.toContain("declined"); + expect(text).toContain("RUNDECK_URL"); + } finally { + await client.close(); + } + }, 15000); +}); diff --git a/src/__tests__/utils/confirmation.test.ts b/src/__tests__/utils/confirmation.test.ts index 490b296..c7b1741 100644 --- a/src/__tests__/utils/confirmation.test.ts +++ b/src/__tests__/utils/confirmation.test.ts @@ -1,20 +1,71 @@ import { jest } from "@jest/globals"; -import type { Server } from "@modelcontextprotocol/server"; +import type { Server, ServerContext } from "@modelcontextprotocol/server"; +import { CLIENT_CAPABILITIES_META_KEY } from "@modelcontextprotocol/server"; import { requestDestructiveConfirmation, type DestructiveAction } from "../../utils/confirmation.js"; -function fakeServer(overrides: { - getClientCapabilities: Server["getClientCapabilities"]; - elicitInput: Server["elicitInput"]; -}): Server { +function fakeServer(overrides: { getClientCapabilities: Server["getClientCapabilities"] }): Server { return overrides as unknown as Server; } +/** + * A legacy (2025-era) request context: no `_meta` envelope at all. + * Capability is read via `server.getClientCapabilities()`. + */ +function legacyCtx(options: { + inputResponses?: Record; + droppedInputResponseKeys?: string[]; +} = {}): ServerContext { + return { + mcpReq: { + inputResponses: options.inputResponses, + droppedInputResponseKeys: options.droppedInputResponseKeys, + }, + } as unknown as ServerContext; +} + +/** + * A modern (2026-07-28-era) request context. `envelope` being present (even + * empty) is the per-request signal this era is in play; the client's + * declared capabilities are read directly off + * `envelope[CLIENT_CAPABILITIES_META_KEY]` (not `server.getClientCapabilities()` + * — that accessor was found, via a real-client integration test, to NOT + * actually be backfilled per-request on a raw `Server` on this era, despite + * its doc comment). + */ +function modernCtx(options: { + inputResponses?: Record; + droppedInputResponseKeys?: string[]; + declaresElicitation?: boolean; +} = {}): ServerContext { + const { inputResponses, droppedInputResponseKeys, declaresElicitation = true } = options; + return { + mcpReq: { + envelope: { + [CLIENT_CAPABILITIES_META_KEY]: declaresElicitation ? { elicitation: {} } : {}, + }, + inputResponses, + droppedInputResponseKeys, + }, + } as unknown as ServerContext; +} + const action: DestructiveAction = { phrase: "permanently delete job 'my-job'", consequence: "Rundeck's API has no undo for this.", }; -describe("requestDestructiveConfirmation", () => { +// requestDestructiveConfirmation is written once for both protocol eras (the +// MRTR pattern): it never calls the deprecated `server.elicitInput` itself +// anymore — on a 2025-era connection, the SDK's own legacy shim sends the +// real elicitation/create request and re-invokes this same handler with the +// answer, transparently. So the two describe blocks below exercise the same +// logic; they differ only in how the *capability check* reads (`ctx.mcpReq. +// envelope[...]` vs `server.getClientCapabilities()`), and both must never +// call `elicitInput` — that's asserted explicitly throughout. +describe.each([ + ["legacy (2025-era)", legacyCtx] as const, + ["modern (2026-07-28-era)", modernCtx] as const, +])("requestDestructiveConfirmation — %s", (_label, makeCtx) => { const originalSkipElicitation = process.env.SKIP_ELICITATION; afterEach(() => { @@ -25,118 +76,132 @@ describe("requestDestructiveConfirmation", () => { } }); - it.each(["1", "true"])("returns 'confirmed' without asking when SKIP_ELICITATION=%s", async (value) => { - process.env.SKIP_ELICITATION = value; - const elicitInput = jest.fn(); - const server = fakeServer({ - getClientCapabilities: () => ({ elicitation: {} }), - elicitInput, - }); + it("returns 'confirmed' without asking when SKIP_ELICITATION=1", async () => { + process.env.SKIP_ELICITATION = "1"; + const server = fakeServer({ getClientCapabilities: () => ({ elicitation: {} }) }); - const outcome = await requestDestructiveConfirmation(server, action); + const result = await requestDestructiveConfirmation(server, makeCtx(), action); - expect(outcome).toBe("confirmed"); - expect(elicitInput).not.toHaveBeenCalled(); + expect(result).toEqual({ kind: "outcome", outcome: "confirmed" }); }); it("does not bypass confirmation for other SKIP_ELICITATION values", async () => { process.env.SKIP_ELICITATION = "yes"; - const elicitInput = jest.fn(); - const server = fakeServer({ - getClientCapabilities: () => ({}), - elicitInput, - }); + const server = fakeServer({ getClientCapabilities: () => ({}) }); - expect(await requestDestructiveConfirmation(server, action)).toBe("unsupported"); + const result = await requestDestructiveConfirmation( + server, + makeCtx({ declaresElicitation: false } as never), + action + ); + expect(result).toEqual({ kind: "outcome", outcome: "unsupported" }); }); it("returns 'unsupported' when the client doesn't declare the elicitation capability", async () => { - const elicitInput = jest.fn(); - const server = fakeServer({ - getClientCapabilities: () => ({}), - elicitInput, - }); + const server = fakeServer({ getClientCapabilities: () => ({}) }); - const outcome = await requestDestructiveConfirmation(server, action); + const result = await requestDestructiveConfirmation( + server, + makeCtx({ declaresElicitation: false } as never), + action + ); - expect(outcome).toBe("unsupported"); - expect(elicitInput).not.toHaveBeenCalled(); + expect(result).toEqual({ kind: "outcome", outcome: "unsupported" }); }); - it("returns 'confirmed' when the human accepts", async () => { - const elicitInput = jest.fn().mockResolvedValue({ - action: "accept", - }); - const server = fakeServer({ - getClientCapabilities: () => ({ elicitation: {} }), - elicitInput, - }); + it("returns an input_required result on first entry, with a capitalized message and no form fields", async () => { + const server = fakeServer({ getClientCapabilities: () => ({ elicitation: {} }) }); + + const result = await requestDestructiveConfirmation(server, makeCtx(), action); + + expect(result.kind).toBe("input_required"); + if (result.kind !== "input_required") throw new Error("unreachable"); + expect(result.result.resultType).toBe("input_required"); + const confirmRequest = result.result.inputRequests?.confirm as { + params?: { message?: string; requestedSchema?: unknown }; + }; + expect(confirmRequest).toBeDefined(); + expect(confirmRequest.params?.message).toMatch(/^Permanently delete/); + expect(confirmRequest.params?.requestedSchema).toEqual( + expect.objectContaining({ type: "object", properties: {} }) + ); + }); - const outcome = await requestDestructiveConfirmation(server, action); + it("resolves to 'confirmed' on retry when inputResponses.confirm carries action: 'accept'", async () => { + const server = fakeServer({ getClientCapabilities: () => ({ elicitation: {} }) }); - expect(outcome).toBe("confirmed"); - expect(elicitInput).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining("delete job 'my-job'"), - requestedSchema: expect.objectContaining({ type: "object" }), - }) + const result = await requestDestructiveConfirmation( + server, + makeCtx({ inputResponses: { confirm: { action: "accept" } } }), + action ); - }); - it("capitalizes the first letter of the phrase in the elicitation question", async () => { - const elicitInput = jest.fn().mockResolvedValue({ - action: "accept", - }); - const server = fakeServer({ - getClientCapabilities: () => ({ elicitation: {} }), - elicitInput, - }); + expect(result).toEqual({ kind: "outcome", outcome: "confirmed" }); + }); - await requestDestructiveConfirmation(server, action); + it("resolves to 'declined' on retry when inputResponses.confirm carries action: 'decline'", async () => { + const server = fakeServer({ getClientCapabilities: () => ({ elicitation: {} }) }); - expect(elicitInput).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringMatching(/^Permanently delete/), - }) + const result = await requestDestructiveConfirmation( + server, + makeCtx({ inputResponses: { confirm: { action: "decline" } } }), + action ); + + expect(result).toEqual({ kind: "outcome", outcome: "declined" }); }); - it("returns 'confirmed' on accept even if content carries a stale/mismatched value", async () => { + it("does not depend on content, mirroring accept even with a stale/mismatched value", async () => { // Observed live: a client returned action: "accept" (the human's real "yes") with // content: { confirmAction: false } — the requestedSchema's declared default, not what // the human picked. The decision must not depend on `content` at all; `action` alone - // is the signal, and the schema now declares no fields for exactly this reason. - const elicitInput = jest.fn().mockResolvedValue({ - action: "accept", - content: { confirmAction: false }, - }); - const server = fakeServer({ - getClientCapabilities: () => ({ elicitation: {} }), - elicitInput, - }); + // is the signal, and the schema declares no fields for exactly this reason. + const server = fakeServer({ getClientCapabilities: () => ({ elicitation: {} }) }); + + const result = await requestDestructiveConfirmation( + server, + makeCtx({ inputResponses: { confirm: { action: "accept", content: { confirmAction: false } } } }), + action + ); - expect(await requestDestructiveConfirmation(server, action)).toBe("confirmed"); + expect(result).toEqual({ kind: "outcome", outcome: "confirmed" }); }); - it("returns 'declined' when the human declines or cancels the prompt", async () => { - const elicitInput = jest.fn().mockResolvedValue({ action: "decline" }); - const server = fakeServer({ - getClientCapabilities: () => ({ elicitation: {} }), - elicitInput, - }); + it("re-issues the elicitation when the retry's confirm entry was dropped as malformed", async () => { + const server = fakeServer({ getClientCapabilities: () => ({ elicitation: {} }) }); + + const result = await requestDestructiveConfirmation( + server, + makeCtx({ droppedInputResponseKeys: ["confirm"] }), + action + ); - expect(await requestDestructiveConfirmation(server, action)).toBe("declined"); + expect(result.kind).toBe("input_required"); }); +}); - it("falls back to 'unsupported' if the elicitation request throws (including a timeout)", async () => { - const elicitInput = jest - .fn() - .mockRejectedValue(new Error("client doesn't actually support it")); - const server = fakeServer({ +describe("requestDestructiveConfirmation — never uses the deprecated server.elicitInput", () => { + it("has no code path that calls server.elicitInput at all", async () => { + const elicitInput = jest.fn(); + const server = { getClientCapabilities: () => ({ elicitation: {} }), elicitInput, + } as unknown as Server; + + await requestDestructiveConfirmation(server, legacyCtx(), { + phrase: "permanently delete job 'my-job'", + consequence: "Rundeck's API has no undo for this.", + }); + await requestDestructiveConfirmation( + server, + legacyCtx({ inputResponses: { confirm: { action: "accept" } } }), + { phrase: "permanently delete job 'my-job'", consequence: "Rundeck's API has no undo for this." } + ); + await requestDestructiveConfirmation(server, modernCtx(), { + phrase: "permanently delete job 'my-job'", + consequence: "Rundeck's API has no undo for this.", }); - expect(await requestDestructiveConfirmation(server, action)).toBe("unsupported"); + expect(elicitInput).not.toHaveBeenCalled(); }); }); diff --git a/src/index.ts b/src/index.ts index 03fffe6..9cc6ee9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,13 @@ #!/usr/bin/env node -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { Server } from "@modelcontextprotocol/server"; -import type { CallToolResult, GetPromptResult } from "@modelcontextprotocol/server"; +import type { + CallToolResult, + GetPromptResult, + ServerContext, + InputRequiredResult, +} from "@modelcontextprotocol/server"; import { handleResource, listResources } from "./resources/index.js"; import { rundeckApiCall, @@ -133,7 +138,24 @@ configManager.initialize(); const server = new Server( { name: "rundeck-docs", version: "1.0.0" }, - { capabilities: { resources: {}, tools: {}, prompts: {} } } + { + capabilities: { resources: {}, tools: {}, prompts: {} }, + // 2026-07-28's CacheableResult requires ttlMs/cacheScope on these + // results; the SDK fills the conservative defaults (ttlMs: 0, + // cacheScope: 'private') automatically when absent, so this is a + // performance tuning, not a compliance requirement. Nothing served + // here is sensitive or per-caller (docs/tool/prompt registrations are + // fixed for this process's lifetime), so 'public' is safe; TTLs are + // short enough that a `rundeck_connect` switch or docs update is + // reflected promptly. + cacheHints: { + "tools/list": { ttlMs: 5 * 60 * 1000, cacheScope: "public" }, + "prompts/list": { ttlMs: 10 * 60 * 1000, cacheScope: "public" }, + "resources/list": { ttlMs: 10 * 60 * 1000, cacheScope: "public" }, + "resources/read": { ttlMs: 10 * 60 * 1000, cacheScope: "public" }, + "resources/templates/list": { ttlMs: 10 * 60 * 1000, cacheScope: "public" }, + }, + } ); // ── Resources ────────────────────────────────────────────────────────────── @@ -153,6 +175,17 @@ server.setRequestHandler('resources/list', async (request) => { return result; }); +// This server has no resource templates — only concrete `rundeck://...` +// resources. Answering with an empty list (rather than leaving the method +// unregistered) avoids a spurious "Method not found" for any client that +// probes it as part of standard capability discovery (e.g. MCP Inspector). +server.setRequestHandler('resources/templates/list', async (request) => { + logger.logRequest("resources/templates/list", request.params); + const result = { resourceTemplates: [] }; + logger.logResponse("resources/templates/list", result); + return result; +}); + server.setRequestHandler('resources/read', async (request) => { const uri = request.params.uri; logger.logRequest("resources/read", request.params); @@ -283,7 +316,7 @@ ${ASK_USER_LINE}`, return result; }); -server.setRequestHandler('tools/call', async (request): Promise => { +server.setRequestHandler('tools/call', async (request, ctx: ServerContext): Promise => { const { name, arguments: args } = request.params; logger.logRequest("tools/call", request.params); logger.logToolCall(name, args); @@ -314,12 +347,16 @@ server.setRequestHandler('tools/call', async (request): Promise }; } if (apiDestructiveAction) { - const outcome = await requestDestructiveConfirmation(server, apiDestructiveAction); - if (outcome === "declined") { + const confirmation = await requestDestructiveConfirmation(server, ctx, apiDestructiveAction); + if (confirmation.kind === "input_required") { + logger.info("api_call destructive action awaiting elicitation answer (MRTR)"); + return confirmation.result; + } + if (confirmation.outcome === "declined") { logger.info("api_call destructive action declined via elicitation"); return returnGuidance(getConfirmationDeclinedGuidance("api_call", apiDestructiveAction)); } - if (outcome === "unsupported") { + if (confirmation.outcome === "unsupported") { logger.info("api_call destructive action blocked - elicitation unavailable"); return returnGuidance(getConfirmationUnavailableGuidance("api_call", apiDestructiveAction)); } @@ -396,12 +433,16 @@ server.setRequestHandler('tools/call', async (request): Promise "Rundeck has no way to revert to the previous version afterward — the old " + "policy content will be gone.", }; - const outcome = await requestDestructiveConfirmation(server, aclDestructiveAction); - if (outcome === "declined") { + const confirmation = await requestDestructiveConfirmation(server, ctx, aclDestructiveAction); + if (confirmation.kind === "input_required") { + logger.info(`acl_manage ${aclParams.action} awaiting elicitation answer (MRTR)`); + return confirmation.result; + } + if (confirmation.outcome === "declined") { logger.info(`acl_manage ${aclParams.action} declined via elicitation`); return returnGuidance(getConfirmationDeclinedGuidance("acl_manage", aclDestructiveAction)); } - if (outcome === "unsupported") { + if (confirmation.outcome === "unsupported") { logger.info(`acl_manage ${aclParams.action} blocked - elicitation unavailable`); return returnGuidance(getConfirmationUnavailableGuidance("acl_manage", aclDestructiveAction)); } @@ -548,14 +589,27 @@ server.onerror = (error) => { logger.error("MCP server error", error); }; +let stdioHandle: { close(): Promise } | undefined; + process.on("SIGINT", async () => { - await server.close(); + await stdioHandle?.close(); process.exit(0); }); async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); + // serveStdio() (rather than a hand-wired `new StdioServerTransport()` + + // `server.connect(...)`) owns the era decision for the connection: the + // opening exchange selects 2025-era or 2026-07-28, one instance from the + // factory is pinned for the connection's lifetime, and both eras are + // served from the same handler registrations above via the SDK's legacy + // shim — no handler-logic changes needed for that part. `onerror` here + // catches out-of-band errors during the opening/era-classification + // exchange itself (e.g. a malformed 2026-07-28 envelope claim), which + // happen before any Server instance is pinned and so never reach + // `server.onerror` above. + stdioHandle = serveStdio(() => server, { + onerror: (error) => logger.error("MCP stdio opening error", error), + }); logger.info("Rundeck Documentation MCP server running on stdio"); } diff --git a/src/utils/confirmation.ts b/src/utils/confirmation.ts index f3cdbdc..bbd8f41 100644 --- a/src/utils/confirmation.ts +++ b/src/utils/confirmation.ts @@ -1,16 +1,41 @@ /** * Human-in-the-loop confirmation for destructive tool calls, using the MCP - * `elicitation/create` capability (Server.elicitInput) so the *client* - * prompts the *human user* directly — independent of whatever the calling - * model already believes counts as "the user approved this." + * `elicitation/create` capability so the *client* prompts the *human user* + * directly — independent of whatever the calling model already believes + * counts as "the user approved this." * - * There is no per-call fallback path: if the connected client doesn't - * declare the `elicitation` capability, or the elicitation request itself - * fails, the action is simply not performed — see - * `getConfirmationUnavailableGuidance` in guidance.ts for what the calling - * agent is told in that case. The only bypass is the `SKIP_ELICITATION` - * environment variable, set by whoever deploys/configures this server — - * never something the calling agent can set itself. + * Written once via the Multi Round-Trip Requests (MRTR) pattern for both + * protocol eras, per the SDK's own migration guidance + * (https://ts.sdk.modelcontextprotocol.io/v2/migration/support-2026-07-28.html): + * a handler that returns an `InputRequiredResult` is served correctly on a + * 2026-07-28-era connection natively, and on a 2025-era connection via the + * SDK's built-in legacy shim — confirmed directly against the installed + * SDK's runtime source (`Protocol.setRequestHandler` unconditionally wraps + * every registered handler through `Server._wrapHandler`/ + * `_invokeInputRequiredCapableHandler`, which on a legacy-era request + * dispatches to `LegacyInputRequiredShim.fulfill()`; that shim sends a real + * `elicitation/create` request via the exact same `_sendElicitationLeg` + * primitive `Server.elicitInput()` itself uses, then re-invokes this same + * handler with `ctx.mcpReq.inputResponses` populated — this applies to a + * raw `Server.setRequestHandler('tools/call', ...)` registration with no + * `McpServer` needed). There is no more direct `server.elicitInput(...)` + * call anywhere in this file: an earlier version of this function + * hand-maintained a separate legacy branch that called it directly, which + * worked but duplicated logic the SDK already provides. + * + * One behavioral difference from that earlier hand-rolled legacy branch: + * a raw dispatch failure on a legacy connection (the elicitation request + * itself erroring or timing out, as opposed to the client simply not + * declaring the capability) now surfaces as the SDK's own generic + * `isError` result from `LegacyInputRequiredShim`'s failure path, not this + * codebase's friendlier `getConfirmationUnavailableGuidance` markdown — + * that guidance text is still used for the (far more common) "client + * never declared the elicitation capability at all" case, checked + * explicitly below before ever returning an `InputRequiredResult`. + * + * The only bypass is the `SKIP_ELICITATION` environment variable, set by + * whoever deploys/configures this server — never something the calling + * agent can set itself. * * Covers every action in this server that can't be walked back through the * Rundeck API: deleting a job/resource/ACL policy, overwriting an ACL @@ -18,7 +43,8 @@ * immediately revokes the old ones). */ -import type { Server } from "@modelcontextprotocol/server"; +import type { Server, ServerContext, InputRequiredResult } from "@modelcontextprotocol/server"; +import { inputRequired, CLIENT_CAPABILITIES_META_KEY } from "@modelcontextprotocol/server"; import { logger } from "./logger.js"; export type ConfirmationOutcome = "confirmed" | "declined" | "unsupported"; @@ -34,57 +60,120 @@ export interface DestructiveAction { consequence: string; } +/** + * Either a final outcome, or an `InputRequiredResult` the caller must + * return verbatim as the `tools/call` response — the SDK (natively on + * 2026-07-28, via its legacy shim on 2025-era connections) fulfils it and + * re-invokes the calling handler with the answer before this can resolve + * to an outcome. + */ +export type ConfirmationResult = + | { kind: "outcome"; outcome: ConfirmationOutcome } + | { kind: "input_required"; result: InputRequiredResult }; + +const CONFIRM_KEY = "confirm"; + +function outcomeResult(outcome: ConfirmationOutcome): ConfirmationResult { + return { kind: "outcome", outcome }; +} + /** * Asks the connected client to prompt the human to confirm `action`. - * Returns: + * + * Returns `{ kind: "outcome" }` with: * - "confirmed": the human explicitly accepted (or `SKIP_ELICITATION` is set — see below). * - "declined": the human explicitly declined or cancelled the prompt. - * - "unsupported": the client didn't declare the `elicitation` capability - * (or the request otherwise failed, including timing out) — no prompt - * was shown, or an answer never came back. + * - "unsupported": the client didn't declare the `elicitation` capability — + * no prompt was shown. + * + * With no answer recorded yet, returns `{ kind: "input_required" }` + * instead — the caller MUST return `result` directly as the `tools/call` + * response, unmodified. */ export async function requestDestructiveConfirmation( server: Server, + ctx: ServerContext, action: DestructiveAction -): Promise { +): Promise { if (process.env.SKIP_ELICITATION === "1" || process.env.SKIP_ELICITATION === "true") { logger.warn( `SKIP_ELICITATION is set — bypassing live confirmation for "${action.phrase}" without asking the user.` ); - return "confirmed"; + return outcomeResult("confirmed"); } - if (!server.getClientCapabilities()?.elicitation) { - return "unsupported"; + // Era-aware capability check, kept explicit here (rather than relying on + // the SDK's own internal gate inside the modern seam / legacy shim) so a + // client that never declared `elicitation` gets this codebase's + // friendlier guidance markdown instead of the SDK's generic + // missing-capability error shape. `ctx.mcpReq.envelope` is only + // populated when the request actually carried the 2026-07-28 `_meta` + // envelope, so its presence is the SDK's own per-request signal for + // which era this request is being served over; `server. + // getClientCapabilities()`'s doc comment claims it's backfilled per + // request on the modern era, but that did not hold up against a real + // client in testing — it returned `undefined` even though the request's + // envelope carried a populated `clientCapabilities`. Read the capability + // directly off the envelope instead, via the same + // `CLIENT_CAPABILITIES_META_KEY` the SDK itself uses to store it there. + const isModernEra = ctx.mcpReq.envelope !== undefined; + const declaredCapabilities = isModernEra + ? ((ctx.mcpReq.envelope as Record | undefined)?.[CLIENT_CAPABILITIES_META_KEY] as + | { elicitation?: unknown } + | undefined) + : server.getClientCapabilities(); + if (!declaredCapabilities?.elicitation) { + return outcomeResult("unsupported"); } - try { - const question = `${action.phrase.charAt(0).toUpperCase()}${action.phrase.slice(1)}? ${action.consequence}`; - // Deliberately no form fields (empty `properties`): a single boolean field here was found, - // via live testing, to be unreliable across at least one real client — its elicitation - // response came back as `action: "accept"` (the human's actual "yes") but with - // `content: { confirmAction: false }`, i.e. the schema's declared `default: false` verbatim, - // not what the human picked. Rather than depend on any client correctly threading a form - // field's value back through, the protocol-level `action` (accept/decline/cancel) alone is - // the confirmation signal — that's what "accept" already means. - const result = await server.elicitInput({ - message: question, - requestedSchema: { - type: "object", - properties: {}, - }, - }); + // An answer recorded for this confirmation. Present identically whether + // it arrived via a real 2026-07-28 client retry, or via the SDK's + // in-process legacy shim re-invoking this same handler after it sent + // the elicitation itself on a 2025-era connection — no era branching + // needed to read it. + const response = ctx.mcpReq.inputResponses?.[CONFIRM_KEY] as + | { action?: string; content?: unknown } + | undefined; - logger.info( - `Elicitation response for "${action.phrase}": action=${result.action}, ` + - `content=${JSON.stringify(result.content)}` - ); + if (response) { + // The protocol-level `action` (accept/decline/cancel) alone is the + // confirmation signal, never `content`: a single boolean form field + // here was found, via live testing, to be unreliable across at least + // one real client — its elicitation response came back as `action: + // "accept"` (the human's actual "yes") but with `content: { + // confirmAction: false }`, i.e. the schema's declared `default: false` + // verbatim, not what the human picked. That's why the schema below + // declares no fields at all. + logger.info(`Elicitation response for "${action.phrase}": action=${response.action}`); + return outcomeResult(response.action === "accept" ? "confirmed" : "declined"); + } - return result.action === "accept" ? "confirmed" : "declined"; - } catch (error) { + if (ctx.mcpReq.droppedInputResponseKeys?.includes(CONFIRM_KEY)) { + // The retry carried an entry for this confirmation, but the SDK + // dropped it before this handler ever saw it (not a bare response + // object — e.g. a wrapped `{method, result}` shape some peers emit). + // Re-asking below is the only recoverable option, but log it so a + // string of these isn't mistaken for the human simply not answering. logger.warn( - `Elicitation request failed: ${error instanceof Error ? error.message : String(error)}` + `Retried confirmation for "${action.phrase}" carried a malformed inputResponses entry for ` + + `"${CONFIRM_KEY}" (dropped by the SDK) — re-issuing the elicitation request.` ); - return "unsupported"; } + + const question = `${action.phrase.charAt(0).toUpperCase()}${action.phrase.slice(1)}? ${action.consequence}`; + + return { + kind: "input_required", + result: inputRequired({ + inputRequests: { + [CONFIRM_KEY]: inputRequired.elicit({ + message: question, + requestedSchema: { + type: "object", + properties: {}, + }, + }), + }, + }), + }; }