diff --git a/CHANGELOG.md b/CHANGELOG.md index 0de4bfe..2e910e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. The format ### Changed +- [#416](https://github.com/mohanagy/miftah/issues/416) Added exact-package initialized Streamable HTTP evidence with the official legacy reference client. The deterministic consumer launches the clean-installed CLI, receives a real `Mcp-Session-Id`, completes one redacted form-elicitation approval with a successful terminal audit result, forwards one cancellation to the selected upstream with one `cancelled` / `REQUEST_CANCELLED` terminal, explicitly terminates the protocol session, proves its retained ID returns HTTP 404, and proves retained-upstream cleanup. This remains reference-client evidence only and does not establish named-host usage, migration, rollback, or retirement approval. - [#413](https://github.com/mohanagy/miftah/issues/413) Corrected the exact-package legacy STDIO evidence harness: list-change signals now come from the active catalog profile, and the cancellable call must create a fresh start marker before abort. The corrected fixture proves that published v1.1.2 delivers all three advertised list changes, forwards cancellation exactly once, and records one terminal `cancelled` / `REQUEST_CANCELLED` audit event; no runtime defect or runtime change was required. - [#388](https://github.com/mohanagy/miftah/issues/388) Added a v1.1.2 legacy-compatibility retirement evidence ledger that inventories every current candidate, grades source/package/host/usage evidence separately, defines migration and rollback proof requirements, and keeps all removal deferred until real usage, named-host runtime, state-safe rollback, a separate implementation issue, and explicit maintainer approval exist. diff --git a/docs/legacy-retirement-evidence.md b/docs/legacy-retirement-evidence.md index 7af5937..7effef7 100644 --- a/docs/legacy-retirement-evidence.md +++ b/docs/legacy-retirement-evidence.md @@ -44,16 +44,16 @@ Absence of reports is not `usage-attestation`. Generated configuration is not `n - **Owners:** `src/cli/main.ts`, `src/runtime/create-miftah-runtime.ts`, and the `@modelcontextprotocol/server-legacy` dependency. - **Current evidence:** `source-test` in `tests/mcp-v2-serving.test.ts` negotiates `2025-11-25`, exposes tools, distinguishes legacy subscription and cache behavior from the modern STDIO path, and forwards list changes plus cancellation through the SDK v2 initialized boundary. The corrected exact published-v1.1.2 `packaged-test` below drives the installed CLI process and proves initialized Roots routing and refresh, resource subscribe/update/unsubscribe, active-profile list changes, cancellation, terminal audit outcome, and cleanup with the official client. - **Migration required:** the exact client must negotiate `2026-07-28` through the SDK v2 entry. A future failure must name the retained revision and give an actionable client-upgrade or pinned-baseline path. -- **Missing evidence:** named-host STDIO transcripts, usage evidence for initialized-only clients, a packaged approval transcript, and proof that real profile, Roots, confirmation, resource, notification, and cancellation workflows survive migration. +- **Missing evidence:** named-host STDIO transcripts, usage evidence for initialized-only clients, a packaged STDIO approval transcript, and proof that real profile, Roots, confirmation, resource, notification, and cancellation workflows survive migration. - **Decision:** defer. ### Initialized `2025-11-25` Streamable HTTP sessions - **Shipped contract:** legacy initialization creates a bounded runtime keyed by `Mcp-Session-Id`. Profile state is session-scoped. Admission limits, idle expiry, interrupted response-stream reconnection, client termination, initialization failure, and shutdown own cleanup of the runtime, upstream sessions, and transport. - **Owners:** `src/http/miftah-http-server.ts`, `src/runtime/create-miftah-runtime.ts`, and `src/profiles/profile-state.ts`. -- **Current evidence:** `source-test` in `tests/mcp-v2-serving.test.ts` proves legacy negotiation and session creation. `tests/http-server.test.ts` covers concurrent session isolation, reconnection, capacity, idle expiry, DELETE cleanup, retained upstream release, and shutdown failures. +- **Current evidence:** `source-test` in `tests/mcp-v2-serving.test.ts` proves legacy negotiation and session creation. `tests/http-server.test.ts` covers concurrent session isolation, reconnection, capacity, idle expiry, DELETE cleanup, retained upstream release, and shutdown failures. The exact published-v1.1.2 `packaged-test` below drives the installed HTTP CLI with the official client, receives a real `Mcp-Session-Id`, completes one redacted form-elicitation approval, forwards one cancellation with explicit terminal audit evidence, explicitly terminates the session, proves the retained ID returns HTTP 404, and proves retained-upstream cleanup. - **Migration required:** clients must use request-scoped `2026-07-28`, stop depending on initialization, GET reconnection, DELETE session lifecycle, or mutable session profile state, and use an authenticated application-state boundary where cross-request profile context is required. -- **Missing evidence:** exact client-by-client behavior, an operator migration for every session-dependent workflow, and a named-host packaged transcript. +- **Missing evidence:** named-host client behavior, real usage, an operator migration for every session-dependent workflow, and candidate rollback proof. - **Decision:** defer. ### Roots-derived routing context @@ -78,7 +78,7 @@ Absence of reports is not `usage-attestation`. Generated configuration is not `n - **Shipped contract:** the legacy SDK shim exposes Miftah's integrity-bound, one-time approval continuation as form elicitation; the modern path uses `input_required`, `requestState`, and `inputResponses`. - **Owners:** `src/runtime/create-miftah-runtime.ts`, the approval continuation store, and the MCP server operation pipeline. -- **Current evidence:** `source-test` proves modern continuation across request-scoped server instances and legacy fallback behavior. Cross-process continuation is not claimed. +- **Current evidence:** `source-test` proves modern continuation across request-scoped server instances and legacy fallback behavior. The exact published-v1.1.2 initialized HTTP `packaged-test` records one form elicitation, the `requested` / `approved` / `consumed` lifecycle, exactly one upstream execution, a redacted `success` terminal audit, and no terminal error code. Cross-process continuation is not claimed. - **Migration required:** exact initialized clients must demonstrate the modern input-required flow, cancellation, expiry, replay rejection, and redacted audit results. - **Missing evidence:** packaged named-host UI/runtime transcripts and user recovery behavior when a host cannot render the modern flow. - **Decision:** defer with the initialized era; do not remove it independently without its own evidence. @@ -111,7 +111,7 @@ Sampling, MCP Logging, standalone downstream HTTP+SSE, Tasks, MCP Apps, and Ente | Client or artifact | Version and evidence | Proven operations | Boundary | | --- | --- | --- | --- | -| Official MCP TypeScript packages | `2.0.0`; broad `source-test` coverage, clean-tarball contracts, and the corrected exact published-v1.1.2 STDIO record below | Source tests cover modern and initialized STDIO/HTTP negotiation, tools, MRTR, headers, caching, cancellation, and session lifecycle. Published-package proof covers initialized Roots refresh, subscribe/update/unsubscribe, active-profile list changes, cancellation, terminal audit outcome, and shutdown | Reference client only; no named-host or usage evidence | +| Official MCP TypeScript packages | `2.0.0`; broad `source-test` coverage, clean-tarball contracts, and exact published-v1.1.2 STDIO and Streamable HTTP records below | Source tests cover modern and initialized STDIO/HTTP negotiation, tools, MRTR, headers, caching, cancellation, and session lifecycle. Published-package proof covers initialized STDIO Roots, subscriptions, active-profile list changes, cancellation, and shutdown plus initialized HTTP session assignment, one redacted approval, cancellation, terminal audits, explicit termination with a 404 probe, and retained-upstream cleanup | Reference client only; no named-host, usage, migration, or rollback evidence | | MCP Inspector | `2.1.0`; `packaged-test` on Linux Node 22 | `tools/list` over installed-package STDIO and modern Streamable HTTP | No UI, OAuth, legacy HTTP, Roots, subscription, or notification claim | | Claude Code `2.1.228` observed on macOS | `configuration-shape` | Generated project STDIO configuration and permission guidance | No packaged runtime transcript | | Claude Desktop `1.26832.0` observed on macOS | `configuration-shape` | Generated `mcpServers` STDIO configuration | No headless packaged runtime transcript | @@ -144,6 +144,28 @@ Deidentified transcript: The same corrected fixture runs against a clean tarball from current `development`; that reproducibility check is not a substitute for the exact published-package record. The package contract requires the same exact positive list-change, cancellation, and audit outcome across supported CI operating systems and Node versions. +### Initialized Streamable HTTP reference client — 2026-08-15 + +| Field | Recorded value | +| --- | --- | +| Author and evidence class | Miftah maintainer; `packaged-test` | +| Package | Exact `@lubab/miftah@1.1.2`; npm integrity `sha512-irvuGcic5EzsZc3cLEiw8+31Vnua5qjdU3l1gztl6q8I9RJQyCNLopVyolxRmmtoqHAoe+3803AvNy8V0ihvmw==`; SLSA provenance matches the immutable baseline above | +| Environment | macOS 26.3 arm64 (`Darwin 25.3.0`), Node 22.9.0, npm 11.12.1; fresh exact npm install with lifecycle scripts disabled | +| Downstream | Official `@modelcontextprotocol/client@2.0.0`, Streamable HTTP, negotiated `2025-11-25`, non-empty real `Mcp-Session-Id` retained only as a boolean in the deidentified transcript | +| Upstream | Deterministic fake STDIO upstream `1.0.0`; no provider or named-host claim | +| Reviewed fixture | `tests/fixtures/legacy-http-artifact-consumer.mjs`, SHA-256 `0354ae23f54a2eec27e9e7ab4a6741c5e10086ea16188e3aadcc1064ecbfd4c8`; embeds one non-secret work profile, form-elicitation policy, audit path, and bounded process/HTTP settings | +| Positive result | One form elicitation was accepted; approval audit actions were exactly `requested`, `approved`, `consumed`; the tool executed once; its terminal audit was `success` with no error code; one downstream abort produced exactly one upstream cancellation and one `cancelled` / `REQUEST_CANCELLED` terminal; explicit session termination succeeded and a request with the retained ID returned HTTP 404; retained work upstream cleanup completed; stderr was empty; the synthetic argument was absent from both elicitation and audit records | +| Known limitations | Reference client and deterministic fake upstream only; one profile, approval, cancellation, and close workflow; no named desktop host, provider, usage, reconnection, capacity, idle-expiry, migration, rollback, or retirement-approval claim. The broader lifecycle cases remain source-test evidence. | +| Claim boundary | Exact published-package evidence for only the named client, transport, protocol, operations, environment, and fixture. It does not authorize removal. | + +Deidentified transcript: + +```json +{"protocol":"2025-11-25","session":{"mcpSessionIdAssigned":true,"terminationProbeStatus":404,"closed":true},"approval":{"elicitationCount":1,"actions":["requested","approved","consumed"],"toolExecutions":1,"terminalAuditStatus":"success","terminalAuditErrorCode":null,"sensitiveArgumentRedacted":true},"cancellation":{"downstreamRejected":true,"upstreamNotifications":1,"terminalAuditEvents":1,"lastAuditStatus":"cancelled","lastAuditErrorCode":"REQUEST_CANCELLED"},"cleanup":{"work":true},"stderrEmpty":true} +``` + +The same reviewed fixture runs against a clean tarball from current `development`; that reproducibility check is not a substitute for the exact published-package record. The package contract requires this exact approval, cancellation, audit, session-close, cleanup, redaction, and stderr outcome across supported CI operating systems and Node versions. + ## Required evidence record Every new compatibility record must include: @@ -192,14 +214,14 @@ The current evidence supports **keep and collect**, not retire: - internal source and deterministic tests describe the shipped behavior; - the exact v1.1.2 artifact is published, reproducible, and signature/provenance verified; - corrected exact published-package STDIO evidence proves Roots, resource subscription/update/unsubscribe, active-profile list changes, cancellation, terminal audit outcome, and cleanup; +- exact published-package Streamable HTTP reference-client evidence proves one initialized session, redacted approval, cancellation, terminal audits, explicit termination with a 404 probe, and retained-upstream cleanup; - named desktop-host runtime evidence, real configuration usage, exact per-surface migration proof, and candidate rollback proof are still missing. Next evidence work: -1. Add exact published-package legacy Streamable HTTP evidence beyond `tools/list`, including approval, cancellation, session cleanup, and known limitations. -2. Collect deidentified exact-version transcripts for the named hosts that actually exercise Miftah, without upgrading a configuration-shape row into a runtime claim prematurely. -3. Collect maintainer-reviewed samples of real initialized, Roots, subscription, upstream `sse`, and SDK v1 library usage. Record zero observations as sample results, not proof of no usage. -4. Write and test one exact migration per observed workflow. -5. Run the rollback contract against a future incompatible candidate. -6. Obtain explicit maintainer approval on the evidence-backed keep/defer/retire decision. -7. Only then create a separate bounded retirement implementation issue and major-version release plan. +1. Collect deidentified exact-version transcripts for the named hosts that actually exercise Miftah, without upgrading a configuration-shape row into a runtime claim prematurely. +2. Collect maintainer-reviewed samples of real initialized, Roots, subscription, upstream `sse`, and SDK v1 library usage. Record zero observations as sample results, not proof of no usage. +3. Write and test one exact migration per observed workflow. +4. Run the rollback contract against a future incompatible candidate. +5. Obtain explicit maintainer approval on the evidence-backed keep/defer/retire decision. +6. Only then create a separate bounded retirement implementation issue and major-version release plan. diff --git a/docs/mcp-compatibility.md b/docs/mcp-compatibility.md index 1e3b433..ee88e64 100644 --- a/docs/mcp-compatibility.md +++ b/docs/mcp-compatibility.md @@ -4,7 +4,7 @@ This page is the compatibility source of truth for Miftah's downstream MCP serve - Miftah baseline: `1.1.2` - Locked MCP TypeScript packages: `@modelcontextprotocol/client`, `core`, `server`, `node`, and `server-legacy` `2.0.0` -- Evidence date: 2026-08-14 +- Evidence date: 2026-08-15 - Modern protocol era: `2026-07-28` - Supported initialized legacy era: `2025-11-25` @@ -15,7 +15,7 @@ This page is the compatibility source of truth for Miftah's downstream MCP serve | STDIO, modern | SDK v2 negotiates `2026-07-28`; no `initialize`/`initialized` handshake or protocol session is required. | The serving factory creates a modern runtime for each SDK-selected modern exchange. Connection-bound resource subscriptions are not advertised. | `tests/mcp-v2-serving.test.ts` exercises the modern SDK v2 STDIO entry. The packaged Inspector gate runs `tools/list` through the installed tarball. | | STDIO, initialized legacy | SDK v2 selects the frozen legacy adapter and negotiates `2025-11-25`. | One client connection owns the initialized runtime. Roots and resource subscriptions remain capability-gated compatibility behavior. | `tests/mcp-v2-serving.test.ts` exercises the legacy SDK v2 STDIO entry; the corrected `tests/fixtures/legacy-stdio-artifact-consumer.mjs` drives the actual published-v1.1.2 CLI process through Roots, subscriptions, list changes, cancellation, and cleanup. | | Streamable HTTP, modern | Each `2026-07-28` request is classified before broker construction and served by a fresh request-scoped Miftah server. `Mcp-Method` and `Mcp-Name` are validated against JSON-RPC; `Mcp-Session-Id` is absent. | Catalogs are private with `ttlMs: 0`; no connection-bound resource subscription or resumable protocol session is claimed. Application state uses explicit authenticated handles and bounded stores as described below. | Source and packed-artifact tests negotiate `2026-07-28`; the packaged Inspector gate runs `tools/list` over HTTP. | -| Streamable HTTP, initialized legacy | The existing initialized `2025-11-25` path uses a bounded `Mcp-Session-Id` session. | Admission, idle timeout, shutdown, and reconnection stay within configured HTTP session limits. | `tests/mcp-v2-serving.test.ts` and `tests/http-server.test.ts` cover negotiation, session ownership, capacity, cleanup, and interrupted SSE response reconnection. | +| Streamable HTTP, initialized legacy | The existing initialized `2025-11-25` path uses a bounded `Mcp-Session-Id` session. | Admission, idle timeout, shutdown, and reconnection stay within configured HTTP session limits. | `tests/mcp-v2-serving.test.ts` and `tests/http-server.test.ts` cover negotiation, session ownership, capacity, cleanup, and interrupted SSE response reconnection. `tests/fixtures/legacy-http-artifact-consumer.mjs` drives the actual published-v1.1.2 CLI through one redacted form-elicitation approval, one cancellation, terminal audits, explicit session termination, an HTTP 404 probe for the retained ID, and retained-upstream cleanup. | | Standalone downstream HTTP+SSE | Not supported and not newly adopted. | Miftah exposes STDIO or the single Streamable HTTP `/mcp` endpoint. It does not expose the retired standalone SSE server transport. | CLI and HTTP-server contract tests accept only the documented downstream transports. | | Upstream STDIO / Streamable HTTP | Supported independently of the downstream era. New remote configurations use `streamable-http`. | Upstream negotiation, lifecycle, cancellation, progress, and safe error mapping remain owned by Miftah's upstream session. | Cross-platform core, remote transport, and packaged CLI tests. | | Upstream legacy SSE | Deprecated compatibility input through `transport: "sse"`. | No new feature may depend on it. There is no remote session DELETE equivalent; migration is to `streamable-http`. | Configuration and remote-transport tests; any removal is gated by [#388](https://github.com/mohanagy/miftah/issues/388). | @@ -34,8 +34,8 @@ An unsupported pinned protocol revision receives a fixed negotiation failure who | Logging | Miftah stderr, audit JSONL, and lifecycle diagnostics | MCP Logging is not implemented or advertised | MCP Logging is not implemented or advertised | Deprecated by SEP-2577. Use stderr for STDIO diagnostics and Miftah's redacted audit journal for application events. | | Resource subscriptions and update notifications | Miftah subscription registry and selected upstream session | Not advertised because request-scoped servers cannot own a connection subscription | Advertised only when every selectable upstream supports it; profile changes and shutdown clean it up | Connection-bound compatibility behavior; not a modern application-state primitive. | | List-changed notifications | Miftah catalog registries and connected client | No durable subscription claim across requests | Direct runtime tests and the corrected exact published-v1.1.2 transcript forward tool, resource, and prompt changes from the active catalog profile. | Initialized delivery is claimed only for active-profile catalog changes. A modern polling or subscription extension needs separate evidence. | -| Progress and cancellation | Request context and selected upstream session | Request-scoped progress and cancellation are forwarded | Direct runtime tests and the corrected exact published-v1.1.2 transcript forward one cancellation to the selected upstream and record `cancelled` / `REQUEST_CANCELLED`. | Cancellation must continue to release the selected upstream and emit one explicit terminal `cancelled` audit outcome. | -| Multi Round-Trip Requests | Miftah approval continuation store | Form confirmation uses `input_required`, `requestState`, and `inputResponses` | The SDK legacy shim exposes the same handler as form elicitation | Continuations are short-lived, integrity-bound, one-time, and never profile or operation authorization by themselves. The current store is shared by request-scoped servers from one factory process; cross-process continuation is not claimed. | +| Progress and cancellation | Request context and selected upstream session | Request-scoped progress and cancellation are forwarded | Direct runtime tests and the exact published-v1.1.2 STDIO and Streamable HTTP transcripts each forward one cancellation to the selected upstream and record `cancelled` / `REQUEST_CANCELLED`. | Cancellation must continue to release the selected upstream and emit one explicit terminal `cancelled` audit outcome. | +| Multi Round-Trip Requests | Miftah approval continuation store | Form confirmation uses `input_required`, `requestState`, and `inputResponses` | The SDK legacy shim exposes the same handler as form elicitation; the exact published-v1.1.2 Streamable HTTP transcript records `requested`, `approved`, and `consumed`, one upstream execution, and a redacted successful terminal audit. | Continuations are short-lived, integrity-bound, one-time, and never profile or operation authorization by themselves. The current store is shared by request-scoped servers from one factory process; cross-process continuation is not claimed. | | Tasks extension | No Miftah owner selected | Not implemented or advertised | Not implemented or advertised | Experimental and deferred by the [MRTR and Tasks decision](plans/2026-08-11-mrtr-tasks-decision.md). | | MCP Apps | No Miftah owner selected | Not implemented, hosted, transformed, or advertised | Not implemented, hosted, transformed, or advertised | A UI resource and host trust model requires a separate issue and real supported-host proof. | | Enterprise Managed Authorization | No Miftah protocol-extension owner selected | Not implemented or advertised | Not implemented or advertised | Miftah's local OAuth/Console controls are not an EMA interoperability claim. | @@ -44,7 +44,7 @@ An unsupported pinned protocol revision receives a fixed negotiation failure who | Client | Exact observed or tested version | Transport and era evidence | Claim boundary | | --- | --- | --- | --- | -| Official MCP TypeScript client | `2.0.0` from the lockfile | Automated source and installed-tarball tests cover modern and initialized STDIO/Streamable HTTP behavior. The corrected exact published-v1.1.2 initialized STDIO transcript proves Roots refresh, resource subscribe/update/unsubscribe, active-profile list changes, cancellation, audit outcome, and shutdown. | This is the reference automated client and does not prove a named desktop host. | +| Official MCP TypeScript client | `2.0.0` from the lockfile | Automated source and installed-tarball tests cover modern and initialized STDIO/Streamable HTTP behavior. The exact published-v1.1.2 transcripts prove initialized STDIO Roots, subscriptions, active-profile list changes, cancellation, and cleanup plus initialized Streamable HTTP session assignment, one redacted approval, cancellation, terminal audits, explicit termination with a 404 probe, and retained-upstream cleanup. | This is the reference automated client and does not prove a named desktop host, real usage, migration, or rollback. | | MCP Inspector | `2.1.0` | CI installs the Miftah tarball into a clean consumer, then the pinned Inspector CLI runs `tools/list` over STDIO and modern Streamable HTTP on Linux Node 22. | Only those two operations/transports are claimed; Inspector UI workflows and OAuth are not covered. | | Claude Code | `2.1.228` observed on macOS | Generated project `.mcp.json` STDIO shape and permission guidance are contract-tested. | No packaged runtime exchange was completed for this audit; protocol compatibility remains unclaimed. | | Claude Desktop | `1.26832.0` observed on macOS | Generated `mcpServers` STDIO shape is contract-tested. | No headless packaged runtime exchange was completed; reconnect/restart remains a user action. | diff --git a/tests/fixtures/legacy-http-artifact-consumer.mjs b/tests/fixtures/legacy-http-artifact-consumer.mjs new file mode 100644 index 0000000..d2cb9ce --- /dev/null +++ b/tests/fixtures/legacy-http-artifact-consumer.mjs @@ -0,0 +1,294 @@ +import { spawn } from "node:child_process"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { request as httpRequest } from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import process from "node:process"; +import { clearTimeout, setTimeout } from "node:timers"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath, URL } from "node:url"; + +const upstreamFixture = process.argv[2]; +if (upstreamFixture === undefined) { + throw new Error("Usage: node legacy-http-artifact-consumer.mjs "); +} + +const waitFor = async (condition, description, timeoutMs = 10_000) => { + const deadline = Date.now() + timeoutMs; + while (!(await condition())) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${description}.`); + await delay(10); + } +}; + +const pathExists = (path) => access(path).then(() => true, () => false); + +const readTextResult = (result) => { + const text = result.content?.find((item) => item.type === "text")?.text; + if (text === undefined) throw new Error("Expected one text tool result."); + return text; +}; + +const countLines = async (path) => (await readFile(path, "utf8")).trim().split("\n").filter(Boolean).length; + +const readAuditEvents = async (path) => { + const audit = await readFile(path, "utf8").catch((error) => { + if (error?.code === "ENOENT") return ""; + throw error; + }); + return audit + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); +}; + +const readToolCallOperations = async (path) => + (await readAuditEvents(path)).filter((event) => event.kind === "operation" && event.operation === "tools/call"); + +const probeSessionStatus = (endpoint, sessionId) => new Promise((resolveProbe, rejectProbe) => { + const probe = httpRequest(endpoint, { + method: "GET", + headers: { "Mcp-Session-Id": sessionId } + }, (response) => { + const status = response.statusCode; + response.destroy(); + if (status === undefined) { + rejectProbe(new Error("The terminated-session probe returned no HTTP status.")); + return; + } + resolveProbe(status); + }); + probe.once("error", rejectProbe); + probe.setTimeout(5_000, () => { + probe.destroy(new Error("Timed out probing the terminated MCP session.")); + }); + probe.end(); +}); + +const resolveInstalledCliEntry = async () => { + let packageDirectory = dirname(fileURLToPath(import.meta.resolve("@lubab/miftah"))); + for (;;) { + const manifestPath = join(packageDirectory, "package.json"); + const manifest = await readFile(manifestPath, "utf8").then(JSON.parse, (error) => { + if (error?.code === "ENOENT") return undefined; + throw error; + }); + if (manifest?.name === "@lubab/miftah") { + const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.miftah; + if (typeof bin !== "string") throw new Error("Installed Miftah package does not declare bin.miftah."); + const cliEntry = resolve(packageDirectory, bin); + if (!(await pathExists(cliEntry))) throw new Error("Installed Miftah bin.miftah entry does not exist."); + return cliEntry; + } + const parent = dirname(packageDirectory); + if (parent === packageDirectory) throw new Error("Could not locate the installed Miftah package manifest."); + packageDirectory = parent; + } +}; + +const startInstalledHttpCli = async (cliEntry, configPath, cwd) => { + const child = spawn( + process.execPath, + [cliEntry, "serve", "--transport", "http", "--config", configPath], + { cwd, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] } + ); + let stdout = ""; + let stderr = ""; + let closePromise; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + + await waitFor(() => { + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error(`Installed Miftah HTTP CLI exited before startup: ${stderr || stdout || "no output"}`); + } + return Promise.resolve(/Miftah HTTP server listening on http:\/\/127\.0\.0\.1:\d+\/mcp\n/u.test(stdout)); + }, "the installed Miftah HTTP CLI to report its endpoint"); + + const endpoint = stdout.match(/http:\/\/127\.0\.0\.1:\d+\/mcp/u)?.[0]; + if (endpoint === undefined) throw new Error(`Installed Miftah HTTP CLI did not report its endpoint: ${stdout}`); + + return { + endpoint, + get stderr() { + return stderr; + }, + async stop() { + if (closePromise !== undefined) return closePromise; + closePromise = new Promise((resolveStop, rejectStop) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolveStop(); + return; + } + const timeout = setTimeout(() => { + clearTimeout(timeout); + child.kill("SIGKILL"); + rejectStop(new Error(`Installed Miftah HTTP CLI did not stop after SIGTERM: ${stderr || stdout || "no output"}`)); + }, 10_000); + timeout.unref(); + child.once("close", () => { + clearTimeout(timeout); + resolveStop(); + }); + child.kill("SIGTERM"); + }); + return closePromise; + } + }; +}; + +const directory = await mkdtemp(join(tmpdir(), "miftah-legacy-http-artifact-")); +const configPath = join(directory, "miftah.json"); +const auditPath = join(directory, "audit.jsonl"); +const createCountPath = join(directory, "create-count"); +const callStartedPath = join(directory, "call-started"); +const cancelledPath = join(directory, "cancelled"); +const workShutdownPath = join(directory, "work-shutdown"); +const sensitiveArgument = "packaged-legacy-http-sensitive-name"; + +let client; +let transport; +let httpCli; + +try { + await mkdir(directory, { recursive: true }); + await writeFile(configPath, JSON.stringify({ + version: "1", + name: "packed-legacy-http-evidence", + defaultProfile: "work", + upstream: { transport: "stdio", command: process.execPath, args: [upstreamFixture] }, + profiles: { + work: { + policy: "confirm", + env: { + TEST_ACCOUNT_NAME: "work", + TEST_CREATE_ITEM_COUNT_PATH: createCountPath, + TEST_CALL_TOOL_STARTED_PATH: callStartedPath, + TEST_CALL_TOOL_DELAY_MS: "500", + TEST_CANCELLED_PATH: cancelledPath, + TEST_SHUTDOWN_END_PATH: workShutdownPath + } + } + }, + policies: { confirm: { requireConfirmation: ["create_item"] } }, + audit: { path: auditPath }, + process: { startupTimeoutMs: 5_000, shutdownTimeoutMs: 5_000 }, + server: { http: { port: 0 } } + })); + + const cliEntry = await resolveInstalledCliEntry(); + httpCli = await startInstalledHttpCli(cliEntry, configPath, directory); + transport = new StreamableHTTPClientTransport(new URL(httpCli.endpoint)); + client = new Client( + { name: "packed-legacy-http-evidence-client", version: "1.0.0" }, + { capabilities: { elicitation: { form: {} } } } + ); + const elicitationRequests = []; + client.setRequestHandler("elicitation/create", async (request) => { + elicitationRequests.push(request); + return { action: "accept", content: { approved: true } }; + }); + + await client.connect(transport); + if (client.getNegotiatedProtocolVersion() !== "2025-11-25") { + throw new Error(`Expected legacy protocol 2025-11-25, got ${String(client.getNegotiatedProtocolVersion())}.`); + } + const mcpSessionId = transport.sessionId; + if (typeof mcpSessionId !== "string" || mcpSessionId.length === 0) { + throw new Error("Installed legacy HTTP server did not assign an Mcp-Session-Id."); + } + + const created = readTextResult(await client.callTool({ + name: "create_item", + arguments: { name: sensitiveArgument } + })); + if (created !== `created:${sensitiveArgument}`) throw new Error(`Unexpected create_item result: ${created}`); + await waitFor(() => pathExists(createCountPath), "the approved upstream tool execution"); + const approvalTerminalAudit = (await readToolCallOperations(auditPath)).at(-1); + if (approvalTerminalAudit?.status !== "success") { + throw new Error(`Expected a successful terminal approval audit event: ${JSON.stringify(approvalTerminalAudit)}`); + } + + await rm(callStartedPath, { force: true }); + const toolCallsBeforeCancellation = (await readToolCallOperations(auditPath)).length; + const controller = new globalThis.AbortController(); + const pending = client.callTool({ name: "whoami", arguments: {} }, { signal: controller.signal }); + await waitFor(() => pathExists(callStartedPath), "the cancellable upstream tool call"); + controller.abort("packaged legacy HTTP cancellation evidence"); + let cancellationRejected = false; + try { + await pending; + } catch { + cancellationRejected = true; + } + if (!cancellationRejected) throw new Error("The cancelled legacy HTTP tool call unexpectedly completed."); + await waitFor( + async () => (await readToolCallOperations(auditPath)).length > toolCallsBeforeCancellation, + "the terminal cancellation audit event" + ); + await waitFor( + async () => (await pathExists(cancelledPath)) && await countLines(cancelledPath) === 1, + "one upstream cancellation notification" + ); + + await transport.terminateSession(); + const terminationProbeStatus = await probeSessionStatus(httpCli.endpoint, mcpSessionId); + if (terminationProbeStatus !== 404) { + throw new Error(`Expected the terminated MCP session to return HTTP 404, got ${terminationProbeStatus}.`); + } + await client.close(); + client = undefined; + transport = undefined; + await waitFor(() => pathExists(workShutdownPath), "the retained work upstream shutdown after HTTP session close"); + await httpCli.stop(); + + const auditEvents = await readAuditEvents(auditPath); + const toolCallOperations = auditEvents.filter( + (event) => event.kind === "operation" && event.operation === "tools/call" + ); + const cancelledOperations = toolCallOperations.filter((event) => event.status === "cancelled"); + const approvalActions = auditEvents + .filter((event) => event.kind === "approval") + .map((event) => event.approvalAction); + const serializedElicitations = JSON.stringify(elicitationRequests); + const serializedAudit = JSON.stringify(auditEvents); + + process.stdout.write(JSON.stringify({ + protocol: "2025-11-25", + session: { + mcpSessionIdAssigned: true, + terminationProbeStatus, + closed: terminationProbeStatus === 404 + }, + approval: { + elicitationCount: elicitationRequests.length, + actions: approvalActions, + toolExecutions: await countLines(createCountPath), + terminalAuditStatus: approvalTerminalAudit.status, + terminalAuditErrorCode: approvalTerminalAudit.errorCode ?? null, + sensitiveArgumentRedacted: + !serializedElicitations.includes(sensitiveArgument) && !serializedAudit.includes(sensitiveArgument) + }, + cancellation: { + downstreamRejected: cancellationRejected, + upstreamNotifications: await countLines(cancelledPath), + terminalAuditEvents: cancelledOperations.length, + lastAuditStatus: toolCallOperations.at(-1)?.status ?? null, + lastAuditErrorCode: toolCallOperations.at(-1)?.errorCode ?? null + }, + cleanup: { work: await pathExists(workShutdownPath) }, + stderrEmpty: httpCli.stderr === "" + })); + httpCli = undefined; +} finally { + await Promise.allSettled([client?.close(), transport?.close(), httpCli?.stop()]); + await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); +} diff --git a/tests/package-contract.test.ts b/tests/package-contract.test.ts index b725181..0f139d3 100644 --- a/tests/package-contract.test.ts +++ b/tests/package-contract.test.ts @@ -58,6 +58,9 @@ const fakeStdioUpstreamFixture = fileURLToPath(new URL("./fixtures/fake-upstream const legacyStdioArtifactConsumerFixture = fileURLToPath( new URL("./fixtures/legacy-stdio-artifact-consumer.mjs", import.meta.url) ); +const legacyHttpArtifactConsumerFixture = fileURLToPath( + new URL("./fixtures/legacy-http-artifact-consumer.mjs", import.meta.url) +); const publicRuntimeExports = [ "AuthenticatedRequestContextError", "CURRENT_CONFIG_VERSION", @@ -1334,6 +1337,37 @@ describe("packed artifact contract", () => { lastAuditErrorCode: "REQUEST_CANCELLED" }); + const legacyHttpConsumerPath = join(directory, "legacy-http-artifact-consumer.mjs"); + await copyFile(legacyHttpArtifactConsumerFixture, legacyHttpConsumerPath); + const legacyHttpConsumer = spawnSync( + process.execPath, + [legacyHttpConsumerPath, fakeStdioUpstreamFixture], + { cwd: directory, encoding: "utf8", timeout: npmCommandTimeoutMs } + ); + expect(legacyHttpConsumer.error, String(legacyHttpConsumer.error)).toBeUndefined(); + expect(legacyHttpConsumer.status, legacyHttpConsumer.stderr || legacyHttpConsumer.stdout).toBe(0); + expect(JSON.parse(legacyHttpConsumer.stdout)).toEqual({ + protocol: "2025-11-25", + session: { mcpSessionIdAssigned: true, terminationProbeStatus: 404, closed: true }, + approval: { + elicitationCount: 1, + actions: ["requested", "approved", "consumed"], + toolExecutions: 1, + terminalAuditStatus: "success", + terminalAuditErrorCode: null, + sensitiveArgumentRedacted: true + }, + cancellation: { + downstreamRejected: true, + upstreamNotifications: 1, + terminalAuditEvents: 1, + lastAuditStatus: "cancelled", + lastAuditErrorCode: "REQUEST_CANCELLED" + }, + cleanup: { work: true }, + stderrEmpty: true + }); + const typeConsumerPath = join(directory, "consumer.ts"); await writeFile( typeConsumerPath,